From dba2e353699180324168cd0d3e60547cdaa7075b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 15 Sep 2026 19:38:46 -0500 Subject: [PATCH 01/47] build(rust): bump toolchain to 1.91.1 arti 2.6 requires Rust 1.91. --- rust/rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml index edb844f8..1af0a9b3 100644 --- a/rust/rust-toolchain.toml +++ b/rust/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.90.0" +channel = "1.91.1" profile = "minimal" targets = [ From 62f502ca7feb18173d61374b25cfc9ac5764fda7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 5 Oct 2026 14:10:47 -0500 Subject: [PATCH 02/47] fix(rust): stop freeing the client handle on bootstrap and set_dormant tor_client_bootstrap() rebuilt the owning Box from the raw pointer Dart holds and let it drop on return, freeing the TorClient allocation while Dart kept using the address. tor_client_set_dormant() did the same dance but leaked the Box back, so it only worked as long as it was never preceded by a bootstrap call. Any set_dormant() after start() was a use after free; a bootstrap failure followed by stop() left a dangling handle. Borrow the handle in both functions instead and add tor_client_free() as the single place where ownership is taken back. The Dart wrapper frees the client when the proxy is stopped and nulls the pointer so the existing ClientNotActive guard covers later calls. Adds unit tests that exercise repeated calls through one handle, and a failed tor_client_bootstrap() followed by dormancy calls, each ending in an explicit free. The bootstrap test corrupts the directory cache after the client opens it, so bootstrap fails at once without network access. Both segfault against the previous implementation. --- CHANGELOG.md | 6 + lib/tor_ffi_plugin.dart | 5 +- lib/tor_ffi_plugin_bindings_generated.dart | 3 + rust/src/lib.rs | 125 +++++++++++++++++++-- rust/target/tor_ffi_plugin.h | 9 ++ 5 files changed, 137 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 599c4cf6..27b908fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +* Fix `tor_client_bootstrap` and `tor_client_set_dormant` freeing the client + handle Dart still holds. Add `tor_client_free`; `Tor.stop` now releases the + client handle. + ## 0.1.0 * Build the Rust core with Flutter native assets instead of cargokit. diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index 0c19105c..fad19b79 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -141,10 +141,13 @@ class Tor { _status = TorStatus.off; } - /// Stop the proxy. + /// Stop the proxy and release the native client handle. stop() async { bindings.tor_proxy_stop(_proxyPtr); _proxyPtr = nullptr; + + bindings.tor_client_free(_clientPtr); + _clientPtr = nullptr; } setClientDormant(bool dormant) async { diff --git a/lib/tor_ffi_plugin_bindings_generated.dart b/lib/tor_ffi_plugin_bindings_generated.dart index 6113d4ce..f1970777 100644 --- a/lib/tor_ffi_plugin_bindings_generated.dart +++ b/lib/tor_ffi_plugin_bindings_generated.dart @@ -16,6 +16,9 @@ import 'dart:ffi' as ffi; @ffi.Native)>() external bool tor_client_bootstrap(ffi.Pointer client); +@ffi.Native)>() +external void tor_client_free(ffi.Pointer client); + @ffi.Native, ffi.Bool)>() external void tor_client_set_dormant( ffi.Pointer client, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 3af430ce..f3624fd8 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -79,12 +79,18 @@ pub unsafe extern "C" fn tor_start( } } +/// Borrow the client handle produced by [`tor_start`] without taking ownership. +/// +/// Ownership stays with the caller until [`tor_client_free`] is called, so the +/// handle can be passed to any number of FFI calls in between. +unsafe fn client_ref<'a>(client: *mut c_void) -> &'a TorClient { + assert!(!client.is_null()); + &*(client as *const TorClient) +} + #[no_mangle] pub unsafe extern "C" fn tor_client_bootstrap(client: *mut c_void) -> bool { - let client = { - assert!(!client.is_null()); - Box::from_raw(client as *mut TorClient) - }; + let client = client_ref(client); unwrap_or_return!(client.runtime().block_on(client.bootstrap()), false); true @@ -92,10 +98,7 @@ pub unsafe extern "C" fn tor_client_bootstrap(client: *mut c_void) -> bool { #[no_mangle] pub unsafe extern "C" fn tor_client_set_dormant(client: *mut c_void, soft_mode: bool) { - let client = { - assert!(!client.is_null()); - Box::from_raw(client as *mut TorClient) - }; + let client = client_ref(client); let dormant_mode = if soft_mode { DormantMode::Soft @@ -103,7 +106,20 @@ pub unsafe extern "C" fn tor_client_set_dormant(client: *mut c_void, soft_mode: DormantMode::Normal }; client.set_dormant(dormant_mode); - Box::leak(client); +} + +/// Release the client handle returned by [`tor_start`]. +/// +/// The handle must not be used after this call. The proxy task keeps its own +/// clone of the underlying client, so stopping the proxy and freeing the +/// handle can happen in either order. +#[no_mangle] +pub unsafe extern "C" fn tor_client_free(client: *mut c_void) { + if client.is_null() { + return; + } + + drop(Box::from_raw(client as *mut TorClient)); } #[no_mangle] @@ -135,4 +151,93 @@ fn start_proxy( #[no_mangle] pub unsafe extern "C" fn tor_hello() { println!("HELLO THERE"); -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + use arti_client::config::TorClientConfigBuilder; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn scratch_dir(label: &str) -> PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "tor_ffi_plugin-test-{}-{n}-{label}", + std::process::id() + )); + // Process ids are reused, so a directory from an earlier run (such as + // a deliberately corrupted cache) may already exist under this name. + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Build a client the same way `tor_start` does, minus bootstrapping, + /// and hand it out as the opaque pointer Dart would receive. + fn unbootstrapped_client_handle() -> *mut c_void { + let runtime = TokioNativeTlsRuntime::create().unwrap(); + let cfg = + TorClientConfigBuilder::from_directories(scratch_dir("state"), scratch_dir("cache")) + .build() + .unwrap(); + let client = TorClient::with_runtime(runtime) + .config(cfg) + .create_unbootstrapped() + .unwrap(); + Box::into_raw(Box::new(client)) as *mut c_void + } + + #[test] + fn client_handle_survives_repeated_calls_until_freed() { + let handle = unbootstrapped_client_handle(); + + // Before the fix every call through the handle reconstructed and + // dropped the owning Box, so the second call here was a use after + // free and the explicit free a double free. + unsafe { + tor_client_set_dormant(handle, true); + tor_client_set_dormant(handle, false); + tor_client_free(handle); + } + } + + #[test] + fn client_handle_survives_a_failed_bootstrap() { + // Overwrite the directory cache after the client has opened it, so + // bootstrap fails at once instead of reaching for the network. The + // client holds a lock on dir.lock, which Windows enforces, so skip it. + let cache_dir = scratch_dir("cache"); + let cfg = TorClientConfigBuilder::from_directories(scratch_dir("state"), &cache_dir) + .build() + .unwrap(); + let client = TorClient::with_runtime(TokioNativeTlsRuntime::create().unwrap()) + .config(cfg) + .create_unbootstrapped() + .unwrap(); + for entry in std::fs::read_dir(&cache_dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() && path.file_name() != Some("dir.lock".as_ref()) { + std::fs::write(&path, [0x5a; 8192]).unwrap(); + } + } + let handle = Box::into_raw(Box::new(client)) as *mut c_void; + + // Before the fix tor_client_bootstrap() freed the handle on every + // return, so the calls after it were a use after free and the final + // free a double free. + unsafe { + assert!(!tor_client_bootstrap(handle)); + tor_client_set_dormant(handle, true); + tor_client_set_dormant(handle, false); + tor_client_free(handle); + } + assert!(crate::error::take_last_error().is_some()); + } + + #[test] + fn client_free_ignores_null() { + unsafe { tor_client_free(ptr::null_mut()) }; + } +} diff --git a/rust/target/tor_ffi_plugin.h b/rust/target/tor_ffi_plugin.h index 052f1375..c1e5ee98 100644 --- a/rust/target/tor_ffi_plugin.h +++ b/rust/target/tor_ffi_plugin.h @@ -14,6 +14,15 @@ bool tor_client_bootstrap(void *client); void tor_client_set_dormant(void *client, bool soft_mode); +/** + * Release the client handle returned by [`tor_start`]. + * + * The handle must not be used after this call. The proxy task keeps its own + * clone of the underlying client, so stopping the proxy and freeing the + * handle can happen in either order. + */ +void tor_client_free(void *client); + void tor_proxy_stop(void *proxy); void tor_hello(void); From 9035b655b4ab6000af24360afcc2e919a6594da8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 15 Sep 2026 19:38:46 -0500 Subject: [PATCH 03/47] build(rust): bump arti to 2.6.0 arti 1.4.6 -> 2.6.0, arti-client/tor-rtcompat/tor-config 0.33 -> 0.46. --- rust/Cargo.lock | 1960 +++++++++++------- rust/Cargo.toml | 21 +- rust/patches/saturating-time/Cargo.toml | 45 + rust/patches/saturating-time/LICENSE-APACHE | 201 ++ rust/patches/saturating-time/LICENSE-MIT | 21 + rust/patches/saturating-time/README.md | 101 + rust/patches/saturating-time/src/internal.rs | 263 +++ rust/patches/saturating-time/src/lib.rs | 289 +++ rust/src/lib.rs | 46 +- rust/tests/saturating_time.rs | 32 + 10 files changed, 2167 insertions(+), 812 deletions(-) create mode 100644 rust/patches/saturating-time/Cargo.toml create mode 100644 rust/patches/saturating-time/LICENSE-APACHE create mode 100644 rust/patches/saturating-time/LICENSE-MIT create mode 100644 rust/patches/saturating-time/README.md create mode 100644 rust/patches/saturating-time/src/internal.rs create mode 100644 rust/patches/saturating-time/src/lib.rs create mode 100644 rust/tests/saturating_time.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4664cb43..f767f630 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" -dependencies = [ - "gimli", -] - [[package]] name = "adler" version = "1.0.2" @@ -19,13 +10,13 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "aes" -version = "0.8.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", "zeroize", ] @@ -97,12 +88,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - [[package]] name = "anstream" version = "0.6.14" @@ -166,60 +151,71 @@ checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" [[package]] name = "arti" -version = "1.4.6" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b4487ced256657f3b506a3c9250004ecf74e92f79c3577283c1eb255229573" +checksum = "6c2d88fb27c29401cac3d384ef7c3998915192b9e08fda9823e02ca7d578be2e" dependencies = [ "anyhow", "arti-client", "cfg-if", "clap 4.5.7", + "derive-deftly 1.11.5", "derive_builder_fork_arti", + "derive_more", + "extend", "fs-mistrust", "futures", + "futures-copy", "hickory-proto", "humantime", "humantime-serde", - "itertools 0.14.0", + "itertools 0.15.0", "libc", "notify", "paste", + "pin-project", "postage", - "rlimit", + "rlimit 0.11.0", "safelog", "secmem-proc", "serde", - "thiserror 2.0.14", + "serde_json", + "thiserror 2.0.20", "time", "tokio", "tokio-util", - "toml 0.8.14", + "toml 1.1.6+spec-1.1.0", "tor-async-utils", "tor-basic-utils", "tor-config", "tor-config-path", + "tor-config-shared", "tor-error", + "tor-general-addr", "tor-key-forge", "tor-keymgr", + "tor-log-ratelim", + "tor-proto", "tor-rtcompat", "tor-socksproto", "tracing", "tracing-appender", "tracing-subscriber", "visibility", + "web-time-compat", "winapi", ] [[package]] name = "arti-client" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375c3b0681ca73c8678dc2e879f01964121955dc8e45f3b334ed0f7e7cefec48" +checksum = "aafcbc506657d886b27ef2d8e06a99edd9014b28130c798a3013935b46983097" dependencies = [ "anyhow", "async-trait", "cfg-if", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_builder_fork_arti", "derive_more", "educe", @@ -231,10 +227,11 @@ dependencies = [ "libc", "once_cell", "postage", - "rand 0.9.2", + "rand 0.10.2", "safelog", "serde", - "thiserror 2.0.14", + "tempfile", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -242,6 +239,7 @@ dependencies = [ "tor-circmgr", "tor-config", "tor-config-path", + "tor-dircommon", "tor-dirmgr", "tor-error", "tor-guardmgr", @@ -260,6 +258,7 @@ dependencies = [ "tor-rtcompat", "tracing", "void", + "web-time-compat", ] [[package]] @@ -280,7 +279,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.14", + "thiserror 2.0.20", ] [[package]] @@ -330,13 +329,13 @@ dependencies = [ [[package]] name = "async-native-tls" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec" +checksum = "37dd6b179962fe4048a6f81d4c0d7ed419a21fdf49204b4c6b04971693358e79" dependencies = [ "futures-util", "native-tls", - "thiserror 1.0.61", + "thiserror 2.0.20", "url", ] @@ -400,7 +399,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" dependencies = [ - "hermit-abi 0.1.19", + "hermit-abi", "libc", "winapi", ] @@ -411,21 +410,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" -[[package]] -name = "backtrace" -version = "0.3.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -440,9 +424,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.6.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bincode" @@ -499,10 +483,14 @@ dependencies = [ ] [[package]] -name = "bounded-vec-deque" -version = "0.1.1" +name = "block-buffer" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2225b558afc76c596898f5f1b3fc35cfce0eb1b13635cbd7d1b2a7177dc10ccd" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] [[package]] name = "bstr" @@ -511,7 +499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", - "regex-automata 0.4.9", + "regex-automata", "serde", ] @@ -547,15 +535,9 @@ checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" [[package]] name = "caret" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061dc3258f029feaf9ff02b43c6af5ea67a7dfaed5d2aef36204c812e614ef9c" - -[[package]] -name = "cast" -version = "0.3.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" [[package]] name = "cbindgen" @@ -593,6 +575,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.38" @@ -607,40 +600,24 @@ dependencies = [ ] [[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "ciborium-io", - "half", + "crypto-common 0.1.6", + "inout 0.1.3", ] [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common", - "inout", + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", "zeroize", ] @@ -709,6 +686,12 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "coarsetime" version = "0.1.34" @@ -726,6 +709,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -743,9 +736,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -775,6 +768,12 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.12" @@ -785,58 +784,21 @@ dependencies = [ ] [[package]] -name = "crc32fast" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "criterion" -version = "0.5.1" +name = "cpufeatures" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ - "anes", - "cast", - "ciborium", - "clap 4.5.7", - "criterion-plot", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", + "libc", ] [[package]] -name = "criterion-cycles-per-byte" -version = "0.6.1" +name = "crc32fast" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1029452fa751c93f8834962dd74807d69f0a6c7624d5b06625b393aeb6a14fc2" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", - "criterion", -] - -[[package]] -name = "criterion-plot" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" -dependencies = [ - "cast", - "itertools 0.10.5", ] [[package]] @@ -854,25 +816,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.11" @@ -888,12 +831,6 @@ version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-bigint" version = "0.5.5" @@ -916,13 +853,31 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -932,7 +887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.12", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -972,6 +927,16 @@ dependencies = [ "darling_macro 0.20.9", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + [[package]] name = "darling_core" version = "0.14.4" @@ -1000,6 +965,19 @@ dependencies = [ "syn 2.0.105", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.105", +] + [[package]] name = "darling_macro" version = "0.14.4" @@ -1022,6 +1000,17 @@ dependencies = [ "syn 2.0.105", ] +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.105", +] + [[package]] name = "data-encoding" version = "2.6.0" @@ -1055,12 +1044,11 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -1075,11 +1063,11 @@ dependencies = [ [[package]] name = "derive-deftly" -version = "1.2.0" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957bb73a3a9c0bbcac67e129b81954661b3cfcb9e28873d8441f91b54852e77a" +checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e" dependencies = [ - "derive-deftly-macros 1.2.0", + "derive-deftly-macros 1.11.5", "heck 0.5.0", ] @@ -1090,32 +1078,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357422a457ccb850dc8f1c1680e0670079560feaad6c2e247e3f345c4fab8a3f" dependencies = [ "heck 0.5.0", - "indexmap 2.2.6", - "itertools 0.12.1", + "indexmap 1.9.3", + "itertools 0.14.0", "proc-macro-crate", "proc-macro2", "quote", "sha3", - "strum 0.26.2", + "strum 0.27.2", "syn 2.0.105", "void", ] [[package]] name = "derive-deftly-macros" -version = "1.2.0" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea41269bd490d251b9eca50ccb43117e641cc68b129849757c15ece88fe0574" +checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" dependencies = [ "heck 0.5.0", - "indexmap 2.2.6", - "itertools 0.12.1", + "indexmap 1.9.3", + "itertools 0.14.0", "proc-macro-crate", "proc-macro2", "quote", "sha3", - "strum 0.26.2", + "strum 0.27.2", "syn 2.0.105", + "unicode-ident", "void", ] @@ -1152,22 +1141,23 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", "quote", + "rustc_version", "syn 2.0.105", "unicode-xid", ] @@ -1178,9 +1168,9 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] @@ -1284,9 +1274,9 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", @@ -1336,25 +1326,46 @@ dependencies = [ ] [[package]] -name = "enum-as-inner" -version = "0.6.0" +name = "enum-ordinalize" +version = "3.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ffccbb6966c05b32ef8fbac435df276c4ae4d3dc55a8cd0eb9745e6c12f546a" +checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" dependencies = [ - "heck 0.4.1", + "num-bigint", + "num-traits", "proc-macro2", "quote", "syn 2.0.105", ] [[package]] -name = "enum-ordinalize" -version = "3.1.15" +name = "enum_dispatch" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" dependencies = [ - "num-bigint", - "num-traits", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.105", +] + +[[package]] +name = "enumset" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.105", @@ -1373,7 +1384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -1387,6 +1398,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extend" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311a6d2f1f9d60bff73d2c78a0af97ed27f79672f15c238192a5bbb64db56d00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.105", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1491,36 +1513,39 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "fs-mistrust" -version = "0.10.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198b8f9ab4cff63b5c91e9e64edd4e6b43cd7fe7a52519a03c6c32ea0acfa557" +checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9" dependencies = [ "derive_builder_fork_arti", "dirs 6.0.0", + "extend", "libc", "pwd-grp", "serde", - "thiserror 2.0.14", + "thiserror 2.0.20", + "void", "walkdir", ] [[package]] -name = "fslock" -version = "0.2.1" +name = "fslock-guard" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1" dependencies = [ "libc", - "winapi", + "thiserror 2.0.20", + "windows-sys 0.61.2", ] [[package]] @@ -1531,9 +1556,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.30" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1546,25 +1571,38 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", ] +[[package]] +name = "futures-copy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ac0c1a7354d4bbc3264ba752342f2cb8c606c4897fed4ff88a6f266b9dc2388" +dependencies = [ + "extend", + "futures", + "libc", + "pin-project", + "windows-sys 0.61.2", +] + [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1573,38 +1611,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 3.0.5", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1614,7 +1652,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1636,8 +1673,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1647,16 +1686,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] -name = "gimli" -version = "0.29.0" +name = "getset" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.105", +] [[package]] name = "glob" @@ -1681,28 +1741,12 @@ dependencies = [ "subtle", ] -[[package]] -name = "half" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" -dependencies = [ - "cfg-if", - "crunchy", -] - [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.5" @@ -1712,6 +1756,18 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.10.0" @@ -1742,18 +1798,6 @@ dependencies = [ "libc", ] -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -1762,25 +1806,19 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hickory-proto" -version = "0.25.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" dependencies = [ - "async-trait", - "cfg-if", "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", "idna", "ipnet", + "jni", "once_cell", - "rand 0.9.2", + "rand 0.10.2", "ring", - "thiserror 2.0.14", + "thiserror 2.0.20", "tinyvec", - "tokio", "tracing", "url", ] @@ -1822,9 +1860,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.4" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -1848,6 +1886,16 @@ dependencies = [ "serde", ] +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", + "zeroize", +] + [[package]] name = "iana-time-zone" version = "0.1.60" @@ -1965,9 +2013,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1984,6 +2032,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "imara-diff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1997,13 +2055,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.17.1", "serde", + "serde_core", ] [[package]] @@ -2035,6 +2094,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "inventory" version = "0.3.15" @@ -2043,19 +2111,17 @@ checksum = "f958d3d68f4167080a18141e10381e7634563984a537f2a49a30fd8e53ac5767" [[package]] name = "ipnet" -version = "2.9.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] -name = "is-terminal" -version = "0.4.16" +name = "iprange" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "37209be0ad225457e63814401415e748e2453a5297f9b637338f5fb8afa4ec00" dependencies = [ - "hermit-abi 0.5.2", - "libc", - "windows-sys 0.52.0", + "ipnet", ] [[package]] @@ -2065,37 +2131,77 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" [[package]] -name = "itertools" -version = "0.10.5" +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "either", + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", ] [[package]] -name = "itertools" -version = "0.12.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "either", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.105", ] [[package]] -name = "itertools" -version = "0.14.0" +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" dependencies = [ - "either", + "jni-sys-macros", ] [[package]] -name = "itoa" -version = "1.0.11" +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.105", +] [[package]] name = "jobserver" @@ -2108,10 +2214,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -2121,7 +2229,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.12", ] [[package]] @@ -2155,9 +2263,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.175" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -2233,11 +2341,11 @@ dependencies = [ [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -2282,17 +2390,6 @@ dependencies = [ "adler", ] -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.0.4" @@ -2332,6 +2429,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonany" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0" + [[package]] name = "notify" version = "8.2.0" @@ -2343,7 +2446,7 @@ dependencies = [ "kqueue", "libc", "log", - "mio 1.0.4", + "mio", "notify-types", "walkdir", "windows-sys 0.60.2", @@ -2366,12 +2469,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.60.2", ] [[package]] @@ -2403,9 +2505,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -2437,16 +2539,6 @@ dependencies = [ "libm", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi 0.3.9", - "libc", -] - [[package]] name = "num_enum" version = "0.7.2" @@ -2470,32 +2562,23 @@ dependencies = [ [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.9.1", ] [[package]] name = "objc2-io-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ "libc", "objc2-core-foundation", ] -[[package]] -name = "object" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -2508,19 +2591,13 @@ dependencies = [ [[package]] name = "oneshot-fused-workaround" -version = "0.2.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2948fd2414b613f9a97f8401270bd5d7638265ab940475cdbcfa28a0273d58" +checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2" dependencies = [ "futures", ] -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - [[package]] name = "openssl" version = "0.10.64" @@ -2599,12 +2676,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "p256" version = "0.13.2" @@ -2689,15 +2760,15 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -2706,9 +2777,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -2716,9 +2787,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -2729,9 +2800,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -2762,12 +2833,6 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkcs1" version = "0.7.5" @@ -2796,31 +2861,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" +name = "polyval" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ - "plotters-backend", + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", ] [[package]] @@ -2882,7 +2930,7 @@ checksum = "70c501afe3a2e25c9bd219aa56ec1e04cdb3fcdd763055be268778c13fa82c1f" dependencies = [ "autocfg", "equivalent", - "indexmap 2.2.6", + "indexmap 2.14.2", ] [[package]] @@ -2930,6 +2978,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -2948,12 +3002,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2968,12 +3023,12 @@ dependencies = [ [[package]] name = "rand_chacha" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.10.1", ] [[package]] @@ -2987,51 +3042,35 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.3", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_jitter" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" +checksum = "3fdcd80e68f0a8f9ca5ec7cfd02fd5fbb8fbe6ef4e9b90ea2f48bb929b74f88e" dependencies = [ "libc", - "rand_core 0.9.3", - "winapi", -] - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", + "rand_core 0.10.1", + "windows-sys 0.59.0", ] [[package]] -name = "rayon-core" -version = "1.13.0" +name = "rangemap" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" [[package]] name = "rdrand" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" +checksum = "84448986e59427c795b929d8dbe12d176275c7d0887ee48098c35b7206f51bae" dependencies = [ - "rand_core 0.6.4", + "libc", + "rand_core 0.10.1", ] [[package]] @@ -3071,7 +3110,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.15", "libredox", - "thiserror 2.0.14", + "thiserror 2.0.20", ] [[package]] @@ -3082,17 +3121,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", + "regex-automata", + "regex-syntax", ] [[package]] @@ -3103,26 +3133,33 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.6.29" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] -name = "regex-syntax" -version = "0.8.5" +name = "reseeding_rng" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "ecfc8e855ab517235426ec1290bfb0745e73fa478b29619eb66a2a81be9d8716" +dependencies = [ + "rand_core 0.10.1", +] [[package]] name = "retry-error" -version = "0.6.5" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce97442758392c7e2a7716e06c514de75f0fe4b5a4b76e14ba1e5edfb7ba3512" +checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0" +dependencies = [ + "humantime", + "web-time", +] [[package]] name = "rfc6979" @@ -3157,6 +3194,15 @@ dependencies = [ "libc", ] +[[package]] +name = "rlimit" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" +dependencies = [ + "libc", +] + [[package]] name = "rsa" version = "0.9.6" @@ -3193,12 +3239,6 @@ dependencies = [ "time", ] -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - [[package]] name = "rustc_version" version = "0.4.0" @@ -3240,7 +3280,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3267,15 +3307,15 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "safelog" -version = "0.4.8" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4e1c994fbc7521a5003e5c1c54304654ea0458881e777f6e2638520c2de8c5" +checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029" dependencies = [ "derive_more", "educe", "either", "fluid-let", - "thiserror 2.0.14", + "thiserror 2.0.20", ] [[package]] @@ -3296,6 +3336,10 @@ dependencies = [ "regex", ] +[[package]] +name = "saturating-time" +version = "0.4.0" + [[package]] name = "schannel" version = "0.1.23" @@ -3336,8 +3380,8 @@ dependencies = [ "libc", "rustix 1.0.8", "rustix-linux-procfs", - "thiserror 2.0.14", - "windows", + "thiserror 2.0.20", + "windows 0.61.3", ] [[package]] @@ -3371,10 +3415,11 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ + "serde_core", "serde_derive", ] @@ -3388,15 +3433,24 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 3.0.5", ] [[package]] @@ -3428,6 +3482,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_with" version = "3.8.1" @@ -3438,7 +3501,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.2.6", + "indexmap 2.14.2", "serde", "serde_derive", "serde_json", @@ -3465,7 +3528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.12", "digest", ] @@ -3476,7 +3539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.12", "digest", ] @@ -3535,6 +3598,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.1" @@ -3562,14 +3641,14 @@ dependencies = [ [[package]] name = "slotmap-careful" -version = "0.2.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9500059071474a36baac642b6bb99ca1dbac0ce43727abbba02dad83822dadf2" +checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac" dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.14", + "thiserror 2.0.20", "void", ] @@ -3581,12 +3660,12 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.7" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3606,20 +3685,20 @@ dependencies = [ ] [[package]] -name = "ssh-cipher" +name = "ssh-cipher-fork-arti" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +checksum = "125c5795103fc93fced42d123c8044180afc55469caa1ab56487c3c5543c898d" dependencies = [ - "cipher", - "ssh-encoding", + "cipher 0.4.4", + "ssh-encoding-fork-arti", ] [[package]] -name = "ssh-encoding" +name = "ssh-encoding-fork-arti" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +checksum = "e0cf03c3a7ea88451ff83a129a79451fd9891d44fc76c25e916a11848b81814c" dependencies = [ "base64ct", "pem-rfc7468", @@ -3627,11 +3706,12 @@ dependencies = [ ] [[package]] -name = "ssh-key" -version = "0.6.6" +name = "ssh-key-fork-arti" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca9b366a80cf18bb6406f4cf4d10aebfb46140a8c0c33f666a144c5c76ecbafc" +checksum = "433782176b73ea7907763dc314c4a17438a231864d4aa683ba47c07c1cf0a388" dependencies = [ + "num-bigint-dig", "p256", "p384", "p521", @@ -3640,8 +3720,8 @@ dependencies = [ "sec1", "sha2", "signature", - "ssh-cipher", - "ssh-encoding", + "ssh-cipher-fork-arti", + "ssh-encoding-fork-arti", "subtle", "zeroize", ] @@ -3672,40 +3752,39 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.2" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.26.4", + "strum_macros 0.27.2", ] [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros 0.27.2", + "strum_macros 0.28.0", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "rustversion", "syn 2.0.105", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3741,6 +3820,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3760,16 +3850,16 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.36.1" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" dependencies = [ "libc", "memchr", "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows", + "windows 0.62.2", ] [[package]] @@ -3826,11 +3916,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.14" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.14", + "thiserror-impl 2.0.20", ] [[package]] @@ -3846,13 +3936,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.14" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 3.0.5", ] [[package]] @@ -3867,30 +3957,29 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3906,16 +3995,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "tinyvec" version = "1.6.0" @@ -3933,28 +4012,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.38.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", "bytes", "libc", - "mio 0.8.11", - "num_cpus", + "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -3991,11 +4068,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.6", + "toml_datetime 0.6.6", "toml_edit 0.22.14", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.6" @@ -4005,14 +4097,23 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" dependencies = [ - "indexmap 2.2.6", - "toml_datetime", + "indexmap 2.14.2", + "toml_datetime 0.6.6", "winnow 0.5.40", ] @@ -4022,61 +4123,93 @@ version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.14.2", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.6", + "toml_datetime 0.6.6", "winnow 0.6.13", ] +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tor-async-utils" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28240d2b739ecba7514c92c0e42f2b5b81a95b5286366bdb2c2f8ef526a5578c" +checksum = "31d9f28de36ed7646f0f03835ce2e306e2c5df8a1784a2cac27bd549cdc07599" dependencies = [ - "derive-deftly 1.2.0", + "cfg-if", + "derive-deftly 1.11.5", "educe", + "extend", "futures", "oneshot-fused-workaround", "pin-project", "postage", - "thiserror 2.0.14", + "slotmap-careful", + "sync_wrapper", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tor-basic-utils", + "tor-rtcompat", + "tracing", "void", + "web-time-compat", ] [[package]] name = "tor-basic-utils" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f86a4e4768d337df0e1189eb229c1c27125e86282fabdef89088e1f0be9107" +checksum = "1a2bd42110ae0faea1d446b35899aa5c3591cd31a94b593aa9264f00baba41da" dependencies = [ + "derive-deftly 1.11.5", "derive_more", + "educe", + "extend", + "getrandom 0.4.3", "hex", - "itertools 0.14.0", + "itertools 0.15.0", "libc", "paste", - "rand 0.9.2", - "rand_chacha 0.9.0", + "rand 0.10.2", + "rand_chacha 0.10.0", "serde", "slab", "smallvec", - "thiserror 2.0.14", + "thiserror 2.0.20", + "tracing", + "weak-table", + "web-time-compat", ] [[package]] name = "tor-bytes" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d08b5b2e93fd21a4aaa9a3867962ea82f5dc830522737183abb514bdeae9fc9" +checksum = "73de2ba54a125c27a440616f2d806a171bcb4e30013de166c7dc0a79bde44ab4" dependencies = [ "bytes", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "digest", "educe", - "getrandom 0.3.3", + "getrandom 0.4.3", "safelog", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-error", "tor-llcrypto", "zeroize", @@ -4084,21 +4217,22 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60677bfa808c00539df7f8276facd59f92e5d0bd22ee8e5bdc1ab6a14632db41" +checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750" dependencies = [ "amplify", "bitflags 2.9.1", "bytes", "caret", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_more", "educe", + "itertools 0.15.0", "paste", - "rand 0.9.2", + "rand 0.10.2", "smallvec", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -4114,43 +4248,52 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd81384d03705336b9fb7ecf152e4f61b2e0a0cb1adbd9bbd116b46a010e230" +checksum = "b2cec6ae3abafc382e126a26e74f000bffbbf2c9b1a8c9ec26bb84463f0120b4" dependencies = [ "caret", "derive_builder_fork_arti", "derive_more", "digest", - "thiserror 2.0.14", + "saturating-time", + "thiserror 2.0.20", "tor-bytes", "tor-checkable", + "tor-error", "tor-llcrypto", + "web-time-compat", ] [[package]] name = "tor-chanmgr" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74322fa01b09b3839903da1e7f443b2cf8aecd31f0cfd5395253ddad473b4ef3" +checksum = "6cef673b170f10790ff81d2f190e0a8150e5c9683c2c8f4c09652a6954c0e5ab" dependencies = [ "async-trait", + "base64ct", "caret", - "derive_builder_fork_arti", + "cfg-if", + "derive-deftly 1.11.5", "derive_more", "educe", "futures", + "httparse", "oneshot-fused-workaround", + "percent-encoding", "postage", - "rand 0.9.2", + "rand 0.10.2", "safelog", "serde", - "thiserror 2.0.14", + "serde_with", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-cell", "tor-config", "tor-error", + "tor-keymgr", "tor-linkspec", "tor-llcrypto", "tor-memquota", @@ -4160,31 +4303,35 @@ dependencies = [ "tor-socksproto", "tor-units", "tracing", + "url", "void", + "web-time-compat", ] [[package]] name = "tor-checkable" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5703d370d4ee4b5c318ac8b944a3b183e2865f6f0104475d36b9e1b8c89f0f38" +checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535" dependencies = [ "humantime", + "itertools 0.15.0", "signature", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-llcrypto", + "web-time-compat", ] [[package]] name = "tor-circmgr" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f8f6d0e84dcf5fa3761e55fe8bb75725e699ee03e3b37bbd06e6421e9961fbd" +checksum = "2cf1eb6c2e3184c9e51c746df81f1a890ba7b663ac418debe2dac3756d840c8a" dependencies = [ "amplify", "async-trait", - "bounded-vec-deque", "cfg-if", + "derive-deftly 1.11.5", "derive_builder_fork_arti", "derive_more", "downcast-rs", @@ -4192,20 +4339,21 @@ dependencies = [ "educe", "futures", "humantime-serde", - "itertools 0.14.0", + "itertools 0.15.0", "once_cell", "oneshot-fused-workaround", "pin-project", - "rand 0.9.2", + "rand 0.10.2", "retry-error", "safelog", "serde", - "static_assertions", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", + "tor-cell", "tor-chanmgr", "tor-config", + "tor-dircommon", "tor-error", "tor-guardmgr", "tor-linkspec", @@ -4221,24 +4369,27 @@ dependencies = [ "tracing", "void", "weak-table", + "web-time-compat", ] [[package]] name = "tor-config" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3852d0e3a6ca41ab9fed79fa4cb89347bdb24bcd5f6665f506a10b993ac24e8b" +checksum = "dd8ca5684100c9223590c11685f3f39c4e4c0037c65daddc26927b3ae4cc893d" dependencies = [ "amplify", "cfg-if", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_builder_fork_arti", "educe", "either", + "extend", "figment", "fs-mistrust", "futures", - "itertools 0.14.0", + "humantime-serde", + "itertools 0.15.0", "notify", "paste", "postage", @@ -4246,9 +4397,9 @@ dependencies = [ "serde", "serde-value", "serde_ignored", - "strum 0.27.2", - "thiserror 2.0.14", - "toml 0.8.14", + "strum 0.28.0", + "thiserror 2.0.20", + "toml 1.1.6+spec-1.1.0", "tor-basic-utils", "tor-error", "tor-rtcompat", @@ -4258,35 +4409,53 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf19ba283027eb8d8c441eec743d2c73c971c4b22c9830aae87163e8fcdc334e" +checksum = "cbed06099bacbdbb1d91cfff1032863896d6097c33eece067ab20756e4a6fa2b" dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-error", "tor-general-addr", ] +[[package]] +name = "tor-config-shared" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93dbc005cca78ea51b726c8da13fda60329dd57bc2ccd97efe674dbb84d5eee4" +dependencies = [ + "amplify", + "derive-deftly 1.11.5", + "serde", + "tor-config", + "tor-config-path", +] + [[package]] name = "tor-consdiff" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5164757c908a50737ebd483a0e29380e53db0b14103f7996751e28964c6d98a" +checksum = "9760d0051159a257b1d0065615ad6bc023da4e178d41ff5640222a29852de848" dependencies = [ + "derive_more", "digest", "hex", - "thiserror 2.0.14", + "imara-diff", + "static_assertions", + "thiserror 2.0.20", + "tor-error", "tor-llcrypto", + "tor-netdoc", ] [[package]] name = "tor-dirclient" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a411a66d9a5f41b2e85e8defb17a3641d5827f168e4b175a28db9b987125843f" +checksum = "403a79a0d00de6c78f15773a284e790459cff6983449e8578490e6b5dfb2afcd" dependencies = [ "async-compression", "base64ct", @@ -4296,9 +4465,9 @@ dependencies = [ "http", "httparse", "httpdate", - "itertools 0.14.0", + "itertools 0.15.0", "memchr", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -4308,13 +4477,35 @@ dependencies = [ "tor-proto", "tor-rtcompat", "tracing", + "web-time-compat", +] + +[[package]] +name = "tor-dircommon" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5cd2a4f641ecb007cc50914bb1b46128de0975aba08baa56497c8e03c71c71" +dependencies = [ + "base64ct", + "derive-deftly 1.11.5", + "getset", + "humantime", + "humantime-serde", + "serde", + "tor-basic-utils", + "tor-checkable", + "tor-config", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tracing", ] [[package]] name = "tor-dirmgr" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c74b75377747b13d338c8ab29c5de44f4485c04c492a15edebed61110e24299" +checksum = "805da4e947311285ce0782ab129df318701c2c08046c316086cb6bb3eb7a463e" dependencies = [ "async-trait", "base64ct", @@ -4324,17 +4515,17 @@ dependencies = [ "educe", "event-listener", "fs-mistrust", - "fslock", + "fslock-guard", "futures", "hex", "humantime", "humantime-serde", - "itertools 0.14.0", + "itertools 0.15.0", "memmap2", "oneshot-fused-workaround", "paste", "postage", - "rand 0.9.2", + "rand 0.10.2", "rusqlite", "safelog", "scopeguard", @@ -4342,8 +4533,8 @@ dependencies = [ "serde_json", "signature", "static_assertions", - "strum 0.27.2", - "thiserror 2.0.14", + "strum 0.28.0", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -4352,6 +4543,7 @@ dependencies = [ "tor-config", "tor-consdiff", "tor-dirclient", + "tor-dircommon", "tor-error", "tor-guardmgr", "tor-llcrypto", @@ -4362,45 +4554,48 @@ dependencies = [ "tor-protover", "tor-rtcompat", "tracing", + "void", + "web-time-compat", ] [[package]] name = "tor-error" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5566edb4beb34d7be7bc4fc2f653a5119a3ccade26a9237912e53d4f4029e83" +checksum = "08f29e906f9f333a6e32e32afac9fb6712ac340191eb3292295b3337682b2dad" dependencies = [ "derive_more", "futures", "paste", "retry-error", "static_assertions", - "strum 0.27.2", - "thiserror 2.0.14", + "strum 0.28.0", + "thiserror 2.0.20", "tracing", "void", + "web-time-compat", ] [[package]] name = "tor-general-addr" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9d4a8c4593a0c590c66bae348590fb49dbf04da264cacc0631d71a92e5d2ac" +checksum = "7b45ecd95dbf79070f9e477e1d84b22e47f03b2ab2f853abe31887daa9090cf0" dependencies = [ "derive_more", - "thiserror 2.0.14", + "thiserror 2.0.20", "void", ] [[package]] name = "tor-guardmgr" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef0ad8a7ba30210b9c5664e10c423cc19f83d55fafdf1c84097be3a01d0dacd" +checksum = "3e84beedbf276ce4ea170206b38ca01c995dbf1b9f678e9708f50e2e71dbfef4" dependencies = [ "amplify", "base64ct", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_builder_fork_arti", "derive_more", "dyn-clone", @@ -4408,19 +4603,20 @@ dependencies = [ "futures", "humantime", "humantime-serde", - "itertools 0.14.0", + "itertools 0.15.0", "num_enum", "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.9.2", + "rand 0.10.2", "safelog", "serde", - "strum 0.27.2", - "thiserror 2.0.14", + "strum 0.28.0", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-config", + "tor-dircommon", "tor-error", "tor-linkspec", "tor-llcrypto", @@ -4433,29 +4629,31 @@ dependencies = [ "tor-rtcompat", "tor-units", "tracing", + "web-time-compat", ] [[package]] name = "tor-hsclient" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0ed4b8790cf8849d10c7ff13db3ec570c2a4aec68331dfe6e1f05d2987d40d" +checksum = "3eba66be03b5eb899185e5b4416d8893fdb09a97b33db9e450042f06e2550f0e" dependencies = [ "async-trait", - "derive-deftly 1.2.0", + "cfg-if", + "derive-deftly 1.11.5", "derive_more", "educe", "either", "futures", - "itertools 0.14.0", + "itertools 0.15.0", "oneshot-fused-workaround", "postage", - "rand 0.9.2", + "rand 0.10.2", "retry-error", "safelog", "slotmap-careful", - "strum 0.27.2", - "thiserror 2.0.14", + "strum 0.28.0", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -4477,28 +4675,29 @@ dependencies = [ "tor-protover", "tor-rtcompat", "tracing", + "web-time-compat", ] [[package]] name = "tor-hscrypto" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "163e60a7c8069ea8d8a3031be8620047b21cbed7d535fd9f1662daae7803ccd6" +checksum = "118ff6a69bfa111b6e8d087ffb107ea9cbcd5d4469295cd7ada1b3cf19ba5a0f" dependencies = [ "data-encoding", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_more", "digest", "hex", "humantime", - "itertools 0.14.0", + "itertools 0.15.0", "paste", - "rand 0.9.2", + "rand 0.10.2", "safelog", "serde", "signature", "subtle", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-error", @@ -4507,22 +4706,24 @@ dependencies = [ "tor-memquota", "tor-units", "void", + "web-time-compat", ] [[package]] name = "tor-key-forge" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1ddfa5126b2619a9f27b5c5d1a360acf41f15d99ced37af1f30fc449c65889" +checksum = "1861b5eebab93515a0ab2401540f3d5a9fb376f1b1535d5793dcd697fb19f928" dependencies = [ - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_more", "downcast-rs", "paste", - "rand 0.9.2", + "rand 0.10.2", + "rsa", "signature", - "ssh-key", - "thiserror 2.0.14", + "ssh-key-fork-arti", + "thiserror 2.0.20", "tor-bytes", "tor-cert", "tor-checkable", @@ -4532,14 +4733,14 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26e8e0222d2ac27bd8bf4848cd46371fdc72d820940eb38f3d8da616176b3d39" +checksum = "b3088a573dadebb34acacb30d686751e2a94d12ccf9d6647946ab6c6b4ad5a0e" dependencies = [ "amplify", "arrayvec", "cfg-if", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_builder_fork_arti", "derive_more", "downcast-rs", @@ -4548,13 +4749,13 @@ dependencies = [ "glob-match", "humantime", "inventory", - "itertools 0.14.0", - "rand 0.9.2", + "itertools 0.15.0", + "rand 0.10.2", "safelog", "serde", "signature", - "ssh-key", - "thiserror 2.0.14", + "ssh-key-fork-arti", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4567,28 +4768,29 @@ dependencies = [ "tracing", "visibility", "walkdir", + "web-time-compat", "zeroize", ] [[package]] name = "tor-linkspec" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e6eab944bf3096b1964cbb0f4a3605c1376a35bdfa9d44512464474ffb8e53c" +checksum = "6d8d3a2aaf3bb67110218b9ff1718cd7d6c7e62226e2fee1a18efa6aa19ff07c" dependencies = [ "base64ct", "by_address", "caret", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_builder_fork_arti", "derive_more", "hex", - "itertools 0.14.0", + "itertools 0.15.0", "safelog", "serde", "serde_with", - "strum 0.27.2", - "thiserror 2.0.14", + "strum 0.28.0", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4599,28 +4801,31 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b92fa9a99a066f06cd266287f6f89270c010693cce3c4c2fa38c27abfcda5fb" +checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e" dependencies = [ "aes", "base64ct", "ctr", "curve25519-dalek", "der-parser", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_more", "digest", "ed25519-dalek", "educe", + "getrandom 0.2.15", "getrandom 0.3.3", + "getrandom 0.4.3", "hex", - "rand 0.9.2", - "rand_chacha 0.9.0", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", "rand_core 0.6.4", - "rand_core 0.9.3", "rand_jitter", "rdrand", + "reseeding_rng", "rsa", "safelog", "serde", @@ -4629,8 +4834,9 @@ dependencies = [ "sha3", "signature", "subtle", - "thiserror 2.0.14", - "tor-memquota", + "thiserror 2.0.20", + "tor-error", + "tor-memquota-cost", "visibility", "x25519-dalek", "zeroize", @@ -4638,54 +4844,69 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc97bd804ac7aeaf771dc4507c65f8c5fac1d4f8e469551aaa3bf240fce1171" +checksum = "2f8696c8ea9090059d91eea287628b5df0b3b4fba433179d893e348857857544" dependencies = [ "futures", "humantime", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-error", "tor-rtcompat", "tracing", "weak-table", + "web-time-compat", ] [[package]] name = "tor-memquota" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f015b156dc186601f46b3f89ebd6ace49ef3b142c9a5004559e5d75a6477cc1f" +checksum = "03786604757846a5f936285eef397e3fc0ed455893dc5012ceafdf455ada0d8d" dependencies = [ "cfg-if", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_more", "dyn-clone", "educe", + "extend", "futures", - "itertools 0.14.0", + "itertools 0.15.0", "paste", "pin-project", "serde", "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-config", "tor-error", "tor-log-ratelim", + "tor-memquota-cost", "tor-rtcompat", "tracing", "void", ] +[[package]] +name = "tor-memquota-cost" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c" +dependencies = [ + "derive-deftly 1.11.5", + "itertools 0.15.0", + "paste", + "void", +] + [[package]] name = "tor-netdir" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7874878d0c579e7b1dea5947581a5f2d4ba350af2a20b1946350ef9ace16ebe1" +checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798" dependencies = [ "async-trait", "bitflags 2.9.1", @@ -4694,13 +4915,12 @@ dependencies = [ "futures", "hex", "humantime", - "itertools 0.14.0", + "itertools 0.15.0", "num_enum", - "rand 0.9.2", + "rand 0.10.2", "serde", - "static_assertions", - "strum 0.27.2", - "thiserror 2.0.14", + "strum 0.28.0", + "thiserror 2.0.20", "time", "tor-basic-utils", "tor-error", @@ -4712,34 +4932,44 @@ dependencies = [ "tor-units", "tracing", "typed-index-collections", + "web-time-compat", ] [[package]] name = "tor-netdoc" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea853a14cb2011b9f4c45641323e19fbad13010d638dd3d84502e0decf9db0b" +checksum = "5b6abd7aaeb2bdbb3fbf760aab9aaa4e8e1a0f8b84d93a834df5a16882faa31e" dependencies = [ "amplify", "base64ct", - "bitflags 2.9.1", - "cipher", + "cipher 0.5.2", + "derive-deftly 1.11.5", "derive_builder_fork_arti", "derive_more", "digest", "educe", + "enumset", + "extend", "hex", + "hostname-validator", "humantime", - "itertools 0.14.0", + "ipnet", + "iprange", + "itertools 0.15.0", "memchr", + "paste", "phf", - "rand 0.9.2", + "rand 0.10.2", + "rangemap", + "saturating-time", "serde", "serde_with", "signature", "smallvec", + "strum 0.28.0", "subtle", - "thiserror 2.0.14", + "thiserror 2.0.20", "time", "tinystr", "tor-basic-utils", @@ -4754,74 +4984,81 @@ dependencies = [ "tor-protover", "tor-units", "void", - "weak-table", + "web-time-compat", "zeroize", ] [[package]] name = "tor-persist" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e899d9dd8104fae50c33f15e00a1515bee15683ff0017cfc003315fcd6a195d6" +checksum = "e6f2a41d13a685e181361cfc3bdf25d319b96b56f5806594b6b8ed760d0099ed" dependencies = [ - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_more", + "extend", "filetime", "fs-mistrust", - "fslock", + "fslock-guard", "futures", - "itertools 0.14.0", + "itertools 0.15.0", "oneshot-fused-workaround", "paste", "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", "tor-error", "tracing", "void", + "web-time-compat", ] [[package]] name = "tor-proto" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08f8d03310fadbc44e9544777a0bb5a7364564798d2236f00db3c337fb25987" +checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880" dependencies = [ + "aes", "amplify", + "async-trait", "asynchronous-codec", "bitvec", "bytes", "caret", "cfg-if", - "cipher", + "cipher 0.5.2", "coarsetime", - "criterion-cycles-per-byte", - "derive-deftly 1.2.0", + "ctr", + "derive-deftly 1.11.5", "derive_builder_fork_arti", "derive_more", "digest", "educe", + "enum_dispatch", "futures", "futures-util", "hkdf", "hmac", - "itertools 0.14.0", + "itertools 0.15.0", + "nonany", "oneshot-fused-workaround", "pin-project", + "polyval", "postage", - "rand 0.9.2", - "rand_core 0.9.3", + "rand 0.10.2", + "rand_core 0.10.1", "safelog", "slotmap-careful", "smallvec", "static_assertions", + "strum 0.28.0", "subtle", - "sync_wrapper", - "thiserror 2.0.14", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-async-utils", @@ -4845,37 +5082,41 @@ dependencies = [ "typenum", "visibility", "void", + "web-time-compat", "zeroize", ] [[package]] name = "tor-protover" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc72e258205ca0511bdc84801748c59dd5d7accfd080d909afd11f6b8be182" +checksum = "cab080a7551561bd56c0c2e3578b7114f0853700b96e43613fabc1fe06e5d885" dependencies = [ "caret", + "derive-deftly 1.11.5", "paste", "serde_with", - "thiserror 2.0.14", + "thiserror 2.0.20", + "tor-basic-utils", "tor-bytes", ] [[package]] name = "tor-ptmgr" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a5f226fd8436d1561ac111d97a7162ba4eed50c5e0791818508b5d1bd511e69" +checksum = "523678f0839a2429386518d5e1c5b26cfaac77b1cbf44a8d8f5b14377e2025a7" dependencies = [ "async-trait", "cfg-if", + "derive-deftly 1.11.5", "derive_builder_fork_arti", "fs-mistrust", "futures", - "itertools 0.14.0", + "itertools 0.15.0", "oneshot-fused-workaround", "serde", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-chanmgr", @@ -4883,18 +5124,20 @@ dependencies = [ "tor-config-path", "tor-error", "tor-linkspec", + "tor-proto", "tor-rtcompat", "tor-socksproto", "tracing", + "web-time-compat", ] [[package]] name = "tor-relay-selection" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19816299f125c71bd4ba59be8ea425256aa4494b6a3d39bdb9ccbc385722a70" +checksum = "07a5c22b7cfd59c40e38d4e111906793c0d65cf881e31efe3a83cac42887b1c9" dependencies = [ - "rand 0.9.2", + "rand 0.10.2", "serde", "tor-basic-utils", "tor-linkspec", @@ -4904,15 +5147,18 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9545fa3b1420df5b165e6bad6537b7202c533311431cabc84ff1908fbca3908" +checksum = "542f4e13054bbba8d764ee24178fd889f77afb59dca7c6ae0a370604118c20b6" dependencies = [ + "amplify", "async-native-tls", "async-trait", "async_executors", "asynchronous-codec", + "cfg-if", "coarsetime", + "derive_builder_fork_arti", "derive_more", "dyn-clone", "educe", @@ -4922,71 +5168,75 @@ dependencies = [ "native-tls", "paste", "pin-project", - "thiserror 2.0.14", + "socket2", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-error", "tor-general-addr", "tracing", "void", + "web-time-compat", + "zeroize", ] [[package]] name = "tor-rtmock" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a6ca08427dff9e7a22aa08d8c680c56231ce9b6afa35125e8a9a78f6b8e2890" +checksum = "04cb78b3a39634e46c1bc795f62fca17b4d8a9404fead18750592a03216e1da3" dependencies = [ "amplify", "assert_matches", "async-trait", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_more", "educe", "futures", "humantime", - "itertools 0.14.0", + "itertools 0.15.0", "oneshot-fused-workaround", "pin-project", "priority-queue", "slotmap-careful", - "strum 0.27.2", - "thiserror 2.0.14", + "strum 0.28.0", + "thiserror 2.0.20", "tor-error", "tor-general-addr", "tor-rtcompat", "tracing", "tracing-test", "void", + "web-time-compat", ] [[package]] name = "tor-socksproto" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5b33285d16e5695b6aba7c9656387ab0c662781e2911376887b8b61d1a3d2b2" +checksum = "2f96e25e5d36b4aff59af40ec86f7ebb227a32b2f726ef1bc8cccf927f8fb31a" dependencies = [ "amplify", "caret", - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "educe", "safelog", "subtle", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-bytes", "tor-error", ] [[package]] name = "tor-units" -version = "0.33.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40e61ae922f0f0209338d63afd4b1b4ba780313b427a54dda212ca3853845578" +checksum = "9a562c031f6c1f9d22f7bbcfbe715840d0e2cfe5e59d1296cc26000c17130555" dependencies = [ - "derive-deftly 1.2.0", + "derive-deftly 1.11.5", "derive_more", "serde", - "thiserror 2.0.14", + "thiserror 2.0.20", "tor-memquota", ] @@ -5000,9 +5250,11 @@ dependencies = [ "cbindgen", "glob", "lazy_static", + "libc", "log", "lzma-sys", - "rlimit", + "rlimit 0.10.1", + "saturating-time", "security-framework", "tokio", "tor-config", @@ -5011,9 +5263,9 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -5034,9 +5286,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -5045,9 +5297,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -5066,14 +5318,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -5146,6 +5398,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -5160,13 +5422,14 @@ checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -5252,34 +5515,22 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" -dependencies = [ - "bumpalo", - "log", "once_cell", - "proc-macro2", - "quote", - "syn 2.0.105", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5287,39 +5538,55 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.105", - "wasm-bindgen-backend", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] [[package]] name = "weak-table" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" +checksum = "5eba2ec791141b0bf4db542f6b038a8f9310cee33e076cb181245780b09a24ff" +dependencies = [ + "cfg-if", + "hashbrown 0.16.1", +] [[package]] -name = "web-sys" -version = "0.3.69" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", ] +[[package]] +name = "web-time-compat" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7" +dependencies = [ + "web-time", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5357,11 +5624,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", - "windows-link", - "windows-numerics", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5373,6 +5652,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -5390,9 +5678,22 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -5402,15 +5703,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", - "windows-link", - "windows-threading", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -5419,9 +5731,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -5434,6 +5746,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-numerics" version = "0.2.0" @@ -5441,7 +5759,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ "windows-core 0.61.2", - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", ] [[package]] @@ -5450,7 +5778,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -5459,7 +5796,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -5498,6 +5844,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -5535,7 +5890,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -5552,7 +5907,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -5711,6 +6075,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -5803,18 +6173,18 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b53342ba..b08966f4 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -15,16 +15,19 @@ crate-type = ["cdylib", "staticlib"] [dependencies] lazy_static = "1.4" tokio = { version = "1", features = ["full"] } -arti-client = { version = "0.33.0", features = ["static", "onion-service-client"] } -arti = { version = "1.4.6", features = ["experimental-api", "static"] } -tor-rtcompat = { version = "0.33.0", features = ["static", "native-tls", "tokio"] } -tor-config = "=0.33.0" +arti-client = { version = "0.46.0", features = ["static", "onion-service-client"] } +arti = { version = "2.6.0", features = ["experimental-api", "static"] } +tor-rtcompat = { version = "0.46.0", features = ["static", "native-tls", "tokio"] } +tor-config = "=0.46.0" log = "0.4.20" lzma-sys = { version = "0.1.20", features = ["static"] } #android_log-sys = "0.3.1" rlimit = "0.10.1" anyhow = "1.0.79" +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(target_os = "ios")'.dependencies] # Specific version for iOS security-framework = "=2.10.0" @@ -32,3 +35,13 @@ security-framework = "=2.10.0" [build-dependencies] cbindgen = "= 0.24.3" glob = "0.3.1" + +[dev-dependencies] +saturating-time = "0.4.0" + +# saturating-time 0.4.0 (via tor-netdoc) never finishes computing the minimum +# SystemTime on Windows, which hangs consensus parsing and therefore bootstrap. +# The vendored copy only changes `find_limit` to stop on steps below the +# platform's clock resolution. +[patch.crates-io] +saturating-time = { path = "patches/saturating-time" } diff --git a/rust/patches/saturating-time/Cargo.toml b/rust/patches/saturating-time/Cargo.toml new file mode 100644 index 00000000..5ef0e19d --- /dev/null +++ b/rust/patches/saturating-time/Cargo.toml @@ -0,0 +1,45 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.85" +name = "saturating-time" +version = "0.4.0" +authors = ["Clara Engler"] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A trait for limits and saturations on types inside std::time" +readme = "README.md" +keywords = [ + "time", + "system-time", + "instant", + "saturating", +] +categories = [ + "date-and-time", + "os", + "rust-patterns", +] +license = "MIT OR Apache-2.0" +repository = "https://codeberg.org/cve/saturating-time" + +[features] +nightly = [] + +[lib] +name = "saturating_time" +path = "src/lib.rs" diff --git a/rust/patches/saturating-time/LICENSE-APACHE b/rust/patches/saturating-time/LICENSE-APACHE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/rust/patches/saturating-time/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/rust/patches/saturating-time/LICENSE-MIT b/rust/patches/saturating-time/LICENSE-MIT new file mode 100644 index 00000000..d101fd82 --- /dev/null +++ b/rust/patches/saturating-time/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Clara Engler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/patches/saturating-time/README.md b/rust/patches/saturating-time/README.md new file mode 100644 index 00000000..74d463a5 --- /dev/null +++ b/rust/patches/saturating-time/README.md @@ -0,0 +1,101 @@ +# `saturating-time` + +A trait for limits and saturations on types inside [`std::time`]. + +* **Easy**: `saturating-time` only adds a single trait, [`SaturatingTime`] that + is implemented for various types from the standard library. +* **Future-Proof**: In the case that [`SaturatingTime::saturating_add()`] and + [`SaturatingTime::saturating_sub()`] become a part of the standard library, + developers would only have to remove the `use saturating_time::SaturatingTime` + line from their code.[^1] [^2]. +* **Portable**: The algorithm for determining the limits is portable across + operating systems: enjoy this crate from Windows, Darwin, and Linux, across + the BSD systems, up to exotic ones such as Hermit OS and Redox. +* **Secure**: This crate does not make any use of `unsafe` Rust code. + +## Overview + +`saturating-time` is a very minimal crate that only exposes a minimal trait: +[`SaturatingTime`]. + +The trait itself offers the following methods: +* [`SaturatingTime::max_value()`] – Returns the maximum value for this type. +* [`SaturatingTime::min_value()`] – Returns the minimum value for this type. +* [`SaturatingTime::saturating_add()`] – Saturating addition for this type. +* [`SaturatingTime::saturating_sub()`] – Saturating subtraction for this type. +* [`SaturatingTime::saturating_duration_since()`] - Saturating time deltas for this type. + +This trait is sealed, meaning applications may not implement it themselves. +However, this crate implements this trait for two structures: +* [`std::time::Instant`] +* [`std::time::SystemTime`] + +## Example + +Add the following to your `Cargo.toml`: +```toml +[dependencies] +saturating-time = "0.4.0" +``` + +If you use Rust nightly, you may want to do: +```toml +[dependencies] +saturating-time = { version = "0.4.0", features = ["nightly"] } +``` + +Now, you can use `saturating-time` in your code: +```rust +use std::time::{Duration, SystemTime}; +use saturating_time::SaturatingTime; + +// Get the maximum and minimum. +let max = SystemTime::max_value(); +let min = SystemTime::min_value(); + +assert_eq!(max.saturating_add(Duration::new(1, 0)), max); +assert_eq!(min.saturating_sub(Duration::new(1, 0)), min); +assert!(max.saturating_duration_since(SystemTime::UNIX_EPOCH) >= Duration::ZERO); +``` + +## Standardization Efforts + +The eventual goal is to get this functionality into the Rust standard library. + +### `SystemTime` + +#### `SystemTime::MIN` and `SystemTime::MAX` + +In December 2025, `SystemTime::MIN` and `SystemTime::MAX` got merged +into nightly.[^3] + +This feature is guarded behind `time_systemtime_limits` and a tracking +issue regarding the stabilization of it exists.[^4] + +#### `SystemTime::saturating_add()`, et, al. + +In January 2025, `SystemTime::saturating_add()`, `SystemTime::saturating_sub()`, +and `SystemTime::saturating_duration_since()` got merged into nightly.[^5] + +This feature is guarded behind `time_saturating_systemtime` and a tracking +issue regarding the stabilization of it exists.[^6] + +### `Instant` + +None yet. + +[^1]: This is an effort the maintainers are actively working upon. +[^2]: Assuming the name, signature, and behavior does not change. + Unfortunately, we likely have to change the signature for the parameters + because we currently use `self` whereas the standard library uses `&self`. + It should not be a big problem though, because both `Instant` and + `SystemTime` implement `Copy`. +[^3]: +[^4]: +[^5]: +[^6]: + +## License and Copyright + +This crate is licensed under `MIT OR Apache-2.0`. +See the respective `LICENSE-*` files in the repository for more information. diff --git a/rust/patches/saturating-time/src/internal.rs b/rust/patches/saturating-time/src/internal.rs new file mode 100644 index 00000000..0c7cf8fa --- /dev/null +++ b/rust/patches/saturating-time/src/internal.rs @@ -0,0 +1,263 @@ +//! Internal parts used for sealing. +//! +//! This module primarily consists of the internal [`SaturatingTime`] trait, an +//! unstable abstraction used internally to implement the main logic behind +//! this. +//! +//! Normal users should not be using this. + +use std::{ + cmp, + sync::LazyLock, + time::{Duration, Instant, SystemTime}, +}; + +/// The maximum value of [`SystemTime`] for this platform. +static MAX_SYSTEM_TIME: LazyLock = LazyLock::new(find_max); + +/// The minimum value of [`SystemTime`] for this platform. +static MIN_SYSTEM_TIME: LazyLock = LazyLock::new(find_min); + +/// The maximum value of [`Instant`] for this platform. +static MAX_INSTANT: LazyLock = LazyLock::new(find_max); + +/// The minimum value of [`Instant`] for this platform. +static MIN_INSTANT: LazyLock = LazyLock::new(find_min); + +/// An internal trait implementing the actual magic behind this. +pub trait SaturatingTime: Sized + Copy { + /// Anchor method to obtain an instance of this type. + fn anchor() -> Self; + + /// Returns the maximum value of this type. + fn max_value() -> Self; + + /// Returns the minimum value of this type. + fn min_value() -> Self; + + /// Performs a checked addition on this type. + fn checked_add(&self, duration: Duration) -> Option; + + /// Performs a checked subtraction on this type. + fn checked_sub(&self, duration: Duration) -> Option; + + /// Performs a checked time delta on this type. + fn checked_duration_since(&self, earlier: Self) -> Option; +} + +impl SaturatingTime for SystemTime { + fn anchor() -> Self { + Self::UNIX_EPOCH + } + + fn max_value() -> Self { + *MAX_SYSTEM_TIME + } + + fn min_value() -> Self { + *MIN_SYSTEM_TIME + } + + fn checked_add(&self, duration: Duration) -> Option { + Self::checked_add(self, duration) + } + + fn checked_sub(&self, duration: Duration) -> Option { + Self::checked_sub(self, duration) + } + + fn checked_duration_since(&self, earlier: Self) -> Option { + Self::duration_since(self, earlier).ok() + } +} + +impl SaturatingTime for Instant { + fn anchor() -> Self { + Self::now() + } + + fn max_value() -> Self { + *MAX_INSTANT + } + + fn min_value() -> Self { + *MIN_INSTANT + } + + fn checked_add(&self, duration: Duration) -> Option { + Self::checked_add(self, duration) + } + + fn checked_sub(&self, duration: Duration) -> Option { + Self::checked_sub(self, duration) + } + + /// DO NOT USE! + /// + /// Instead, override the top-level provided implementation with the already + /// existing [`Instant::saturating_duration_since()`]. + fn checked_duration_since(&self, _earlier: Self) -> Option { + unreachable!() + } +} + +/// Finds the value for [`SaturatingTime::max_value()`]. +fn find_max() -> T { + find_limit(T::checked_add) +} + +/// Finds the value for [`SaturatingTime::min_value()`]. +fn find_min() -> T { + find_limit(T::checked_sub) +} + +/// Internal algorithm of [`find_max()`] and [`find_min()`]. +/// +/// It works by performing `f` with a very large [`Duration`] onto +/// [`SaturatingTime::anchor()`] until this call returns [`None`], in which case +/// this [`Duration`] gets halved. This process is repeated until `f` returns +/// [`None`] and the [`Duration`] has reached 1ns. +/// +/// # Algorithm +/// +/// 1. Set `step` to `INITIAL_STEP` and `res` to [`SaturatingTime::anchor()`]. +/// 2. Call `f(&res, step)`. +/// 1. If [`Some`] and different from `res`, set `res` to the returned +/// value and continue. +/// 2. If [`None`] or unchanged, and `step == 1ns`, return `res`. +/// 3. Else, set `step` to `MAX{1ns, step / 2}` and continue. +/// +/// A step below the platform's clock resolution (100ns for Windows' +/// [`SystemTime`]) rounds to zero and leaves `res` unchanged, so it is treated +/// like [`None`]. Otherwise the loop would never terminate on such platforms. +fn find_limit(f: F) -> T +where + T: SaturatingTime + PartialEq, + F: Fn(&T, Duration) -> Option, +{ + const INITIAL_STEP: Duration = Duration::new(1_000_000_000_000_000_000, 0); + const ONE_NS: Duration = Duration::new(0, 1); + + // (1) Set step to INITIAL_STEP and res to T::anchor(). + let mut step = INITIAL_STEP; + let mut res = T::anchor(); + + loop { + // (2) Call f(). + let next = f(&res, step); + match next { + Some(st) if st != res => { + // (2.1) If Some and changed, set res to the returned value and + // continue. + res = st + } + Some(_) | None => { + if step == ONE_NS { + // (2.2) If None or unchanged and step == 1ns, return res. + return res; + } else { + // (2.3) Else, set step to MAX{1ns, step / 2}. + step = cmp::max(ONE_NS, step / 2); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + fmt::Debug, + ops::{Add, Sub}, + }; + + use super::*; + + /// Checks whether the minimum and maximum values are correct. + fn min_max() + where + T: SaturatingTime + + PartialEq + + Debug + + Add + + Sub, + { + assert_eq!( + T::max_value().checked_add(Duration::ZERO), + Some(T::max_value()) + ); + assert_eq!(T::max_value().checked_add(Duration::new(0, 1)), None); + assert_eq!( + T::max_value().checked_sub(Duration::ZERO), + Some(T::max_value()) + ); + assert_eq!( + T::max_value().checked_sub(Duration::new(0, 1)), + Some(T::max_value() - Duration::new(0, 1)) + ); + + assert_eq!( + T::min_value().checked_sub(Duration::ZERO), + Some(T::min_value()) + ); + assert_eq!(T::min_value().checked_sub(Duration::new(0, 1)), None); + assert_eq!( + T::min_value().checked_add(Duration::ZERO), + Some(T::min_value()) + ); + assert_eq!( + T::min_value().checked_add(Duration::new(0, 1)), + Some(T::min_value() + Duration::new(0, 1)) + ); + } + + /// Verifies [`SystemTime::min_value()`] and [`SystemTime::max_value()`] are + /// correct. + #[test] + fn system_time_min_max() { + min_max::(); + } + + /// Verifies [`Instant::min_value()`] and [`Instant::max_value()`] are + /// correct. + #[test] + fn instant_min_max() { + min_max::(); + } + + /// Verifies [`SystemTime::min_value()`] and [`SystemTime::max_value()`] are + /// correct on Unix systems. + #[cfg(target_family = "unix")] + #[test] + fn system_time_min_max_unix() { + assert_eq!( + SystemTime::max_value(), + SystemTime::UNIX_EPOCH + Duration::new(i64::MAX as u64, 999_999_999) + ); + assert_eq!( + SystemTime::min_value(), + SystemTime::UNIX_EPOCH - Duration::new(i64::MAX as u64 + 1, 0) + ); + } + + /// Verifies that [`Instant::min_value()`] and [`Instant::max_value()`] are + /// correct on Unix systems. + #[test] + #[cfg(target_family = "unix")] + fn instant_min_max_unix() { + // Using format is not nice but I cannot see a better way for now. + assert_eq!( + format!("{:?}", Instant::max_value()), + format!( + "Instant {{ tv_sec: {}, tv_nsec: {} }}", + i64::MAX, + 999_999_999 + ) + ); + + assert_eq!( + format!("{:?}", Instant::min_value()), + format!("Instant {{ tv_sec: {}, tv_nsec: {} }}", i64::MIN, 0) + ); + } +} diff --git a/rust/patches/saturating-time/src/lib.rs b/rust/patches/saturating-time/src/lib.rs new file mode 100644 index 00000000..cb24b64a --- /dev/null +++ b/rust/patches/saturating-time/src/lib.rs @@ -0,0 +1,289 @@ +#![doc = include_str!("../README.md")] +#![forbid(unsafe_code)] +#![cfg_attr( + feature = "nightly", + feature(time_systemtime_limits, time_saturating_systemtime) +)] + +use std::time::{Duration, Instant, SystemTime}; + +mod internal; + +/// The core trait of this crait, [`SaturatingTime`]. +/// +/// This trait provides methods for performing saturating arithmetic on those +/// types in [`std::time`] that not already come with such a functionality, +/// such as [`SystemTime`] or [`Instant`]. +/// +/// The trait itself is not implementable from the outside, because it is sealed +/// by an internal trait. +/// +/// See the methods or the top-level documentation for concrete code examples. +pub trait SaturatingTime: internal::SaturatingTime { + /// Returns the maximum value for this type on the current platform. + /// + /// This limit is highly platform specific. It differs heavily between + /// Unix, Windows, and other operating systems. + /// + /// The limit itself is calculated dynamically during runtime with a correct + /// algorithm. Afterwards, it gets stored in a lazy static value, meaning + /// that only the first call to it will be slightly more expensive, whereas + /// all latter calls will result in an immediate return of the value. + /// + /// # Examples + /// + /// ``` + /// use std::time::{Duration, SystemTime}; + /// use saturating_time::SaturatingTime; + /// + /// let max = SystemTime::max_value(); + /// + /// // Adding zero to the maximum value will change nothing. + /// assert!(max.checked_add(Duration::ZERO).is_some()); + /// + /// // Adding 1ns to the maximum value will fail. + /// assert!(max.checked_add(Duration::new(0, 1)).is_none()); + /// + /// // Subtracting 1ns from the maximum value will work of course. + /// assert!(max.checked_sub(Duration::new(0, 1)).is_some()); + /// ``` + fn max_value() -> Self { + internal::SaturatingTime::max_value() + } + + /// Returns the minimum value for this type on the current platform. + /// + /// This limit is highly platform specific. It differs heavily between + /// Unix, Windows, and other operating systems. + /// + /// The limit itself is calculated dynamically during runtime with a correct + /// algorithm. Afterwards, it gets stored in a lazy static value, meaning + /// that only the first call to it will be slightly more expensive, whereas + /// all latter calls will result in an immediate return of the value. + /// + /// # Examples + /// + /// ``` + /// use std::time::{Duration, SystemTime}; + /// use saturating_time::SaturatingTime; + /// + /// let min = SystemTime::min_value(); + /// + /// // Subtracting a zero from the minimum value will change nothing. + /// assert!(min.checked_sub(Duration::ZERO).is_some()); + /// + /// // Subtracting 1ns from the minimum value will fail. + /// assert!(min.checked_sub(Duration::new(0, 1)).is_none()); + /// + /// // Adding 1ns to the minimum value will work of course. + /// assert!(min.checked_add(Duration::new(0, 1)).is_some()); + /// ``` + fn min_value() -> Self { + internal::SaturatingTime::min_value() + } + + /// Performs a saturating addition of a [`Duration`]. + /// + /// The resulting value will saturate to [`SaturatingTime::max_value()`] in + /// the case the addition would have caused an overflow of value. + /// + /// # Examples + /// + /// ``` + /// use std::time::{Duration, SystemTime}; + /// use saturating_time::SaturatingTime; + /// + /// let max = SystemTime::max_value(); + /// + /// // Adding zero will change nothing. + /// assert_eq!(max.saturating_add(Duration::ZERO), max); + /// + /// // Adding 1ns would overflow so we saturate to the maximum. + /// assert_eq!(max.saturating_add(Duration::new(0, 1)), max); + /// ``` + fn saturating_add(self, duration: Duration) -> Self { + self.checked_add(duration) + .unwrap_or(SaturatingTime::max_value()) + } + + /// Performs a saturating subtraction of a [`Duration`]. + /// + /// The resulting value will saturate to [`SaturatingTime::min_value()`] in + /// the case the subtraction would have caused an overflow of value. + /// + /// # Examples + /// + /// ``` + /// use std::time::{Duration, SystemTime}; + /// use saturating_time::SaturatingTime; + /// + /// let min = SystemTime::min_value(); + /// + /// // Subtracting zero will change nothing. + /// assert_eq!(min.saturating_sub(Duration::ZERO), min); + /// + /// // Subtracting 1ns would overflow so we saturate to the minimum. + /// assert_eq!(min.saturating_sub(Duration::new(0, 1)), min); + /// ``` + fn saturating_sub(self, duration: Duration) -> Self { + self.checked_sub(duration) + .unwrap_or(SaturatingTime::min_value()) + } + + /// Performs a saturating time difference calculation between two points. + /// + /// The resulting value will saturate to [`Duration::ZERO`] in the case that + /// the `earlier` point in time is actually not earlier, thereby resulting + /// in a negative difference. + /// + /// # Examples + /// + /// ``` + /// use std::time::{Duration, SystemTime}; + /// use saturating_time::SaturatingTime; + /// + /// let epoch = SystemTime::UNIX_EPOCH; + /// let now = SystemTime::now(); + /// let min = SystemTime::min_value(); + /// + /// assert!(now.saturating_duration_since(epoch).as_secs() > 0); + /// assert!(epoch.saturating_duration_since(epoch) == Duration::ZERO); + /// assert!(min.saturating_duration_since(epoch) == Duration::ZERO); + /// ``` + fn saturating_duration_since(&self, earlier: Self) -> Duration { + self.checked_duration_since(earlier) + .unwrap_or(Duration::ZERO) + } +} + +// Use nightly implementation if compiled with the nightly feature. +#[cfg(feature = "nightly")] +impl SaturatingTime for SystemTime { + fn max_value() -> Self { + Self::MAX + } + + fn min_value() -> Self { + Self::MIN + } + + fn saturating_add(self, duration: Duration) -> Self { + Self::saturating_add(&self, duration) + } + + fn saturating_sub(self, duration: Duration) -> Self { + Self::saturating_sub(&self, duration) + } + + fn saturating_duration_since(&self, earlier: Self) -> Duration { + Self::saturating_duration_since(&self, earlier) + } +} + +// Otherwise, use the default one. +#[cfg(not(feature = "nightly"))] +impl SaturatingTime for SystemTime {} + +impl SaturatingTime for Instant { + // Override to use the provided implementation from the standard library. + fn saturating_duration_since(&self, earlier: Self) -> Duration { + Self::saturating_duration_since(self, earlier) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::internal; + use std::{ + fmt::Debug, + ops::{Add, Sub}, + time::{Instant, SystemTime}, + }; + + /// Verifies the maximum and minimum values of [`SaturatingTime`] equal + /// their pedant in [`internal::SaturatingTime`]. + fn min_max() { + assert_eq!( + ::max_value(), + ::max_value() + ); + assert_eq!( + ::min_value(), + ::min_value() + ); + } + + /// Verifies the saturating arithmetic for [`SaturatingTime`]. + fn saturating_add_sub< + T: SaturatingTime + PartialEq + Debug + Add + Sub, + >() { + let max = ::max_value(); + assert_eq!(max.saturating_add(Duration::ZERO), max); + assert_eq!(max.saturating_add(Duration::new(0, 1)), max); + assert_eq!(max.saturating_sub(Duration::ZERO), max); + assert_eq!( + max.saturating_sub(Duration::new(0, 1)), + max - Duration::new(0, 1) + ); + + let min = ::min_value(); + assert_eq!(min.saturating_sub(Duration::ZERO), min); + assert_eq!(min.saturating_sub(Duration::new(0, 1)), min); + assert_eq!(min.saturating_add(Duration::ZERO), min); + assert_eq!( + min.saturating_add(Duration::new(0, 1)), + min + Duration::new(0, 1) + ); + } + + /// Verifies whether the saturating logic behind [`Duration`] types work. + fn saturating_duration() { + // The duration from the same anchor should always be zero. + let anchor = T::anchor(); + assert_eq!(anchor.saturating_duration_since(anchor), Duration::ZERO); + + // Try with a later anchor. + let later_anchor = anchor.checked_add(Duration::from_secs(1)).unwrap(); + assert!(later_anchor.saturating_duration_since(anchor) == Duration::from_secs(1)); + assert_eq!( + anchor.saturating_duration_since(later_anchor), + Duration::ZERO + ); + + // Try with min and max. + let max = ::max_value(); + let min = ::min_value(); + + // This first assertion might not be so portable, maybe remove it if + // this becomes a problem. + assert_eq!(max.saturating_duration_since(min), Duration::MAX); + assert_eq!(min.saturating_duration_since(max), Duration::ZERO); + } + + /// Calls [`min_max()`] using [`SystemTime`]. + #[test] + fn system_time_min_max() { + min_max::(); + } + + /// Calls [`min_max()`] using [`Instant`]. + #[test] + fn instant_min_max() { + min_max::(); + } + + /// Calls [`saturating_add_sub()`] and [`saturating_duration()`] using [`SystemTime`]. + #[test] + fn system_time_saturating() { + saturating_add_sub::(); + saturating_duration::(); + } + + /// Calls [`saturating_add_sub()`] and [`saturating_duration()`] using [`Instant`]. + #[test] + fn instant_saturating() { + saturating_add_sub::(); + saturating_duration::(); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 3af430ce..cb4e7ef2 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3,17 +3,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later use crate::error::update_last_error; -use arti::socks; +use arti::proxy::{run_proxy_with_listeners, ListenProtocols}; use arti_client::config::CfgPath; use arti_client::{DormantMode, TorClient, TorClientConfig}; use lazy_static::lazy_static; use std::ffi::{c_char, c_void, CStr}; +use std::sync::Arc; use std::{io, ptr}; use tokio::runtime::{Builder, Runtime}; use tokio::task::JoinHandle; use tor_config::Listen; use tor_rtcompat::tokio::TokioNativeTlsRuntime; -use tor_rtcompat::ToplevelBlockOn; +use tor_rtcompat::{NetStreamProvider, TcpListenOptions, ToplevelBlockOn}; pub use crate::error::tor_last_error_message; #[cfg(not(target_os = "windows"))] @@ -70,8 +71,8 @@ pub unsafe extern "C" fn tor_start( err_ret ); - let proxy_handle_box = Box::new(start_proxy(socks_port, client.clone())); - let client_box = Box::new(client.clone()); + let proxy_handle_box = Box::new(start_proxy(socks_port, Arc::clone(&client))); + let client_box = Box::new(client); Tor { client: Box::into_raw(client_box) as *mut c_void, @@ -83,7 +84,7 @@ pub unsafe extern "C" fn tor_start( pub unsafe extern "C" fn tor_client_bootstrap(client: *mut c_void) -> bool { let client = { assert!(!client.is_null()); - Box::from_raw(client as *mut TorClient) + Box::from_raw(client as *mut Arc>) }; unwrap_or_return!(client.runtime().block_on(client.bootstrap()), false); @@ -94,7 +95,7 @@ pub unsafe extern "C" fn tor_client_bootstrap(client: *mut c_void) -> bool { pub unsafe extern "C" fn tor_client_set_dormant(client: *mut c_void, soft_mode: bool) { let client = { assert!(!client.is_null()); - Box::from_raw(client as *mut TorClient) + Box::from_raw(client as *mut Arc>) }; let dormant_mode = if soft_mode { @@ -119,16 +120,35 @@ pub unsafe extern "C" fn tor_proxy_stop(proxy: *mut c_void) { fn start_proxy( port: u16, - client: TorClient, + client: Arc>, ) -> JoinHandle> { println!("Starting proxy!"); let rt = RUNTIME.as_ref().unwrap(); - rt.spawn(socks::run_socks_proxy( - client.runtime().clone(), - client.clone(), - Listen::new_localhost(port), - None, - )) + rt.spawn(async move { + let listeners = bind_localhost(client.runtime(), port).await?; + run_proxy_with_listeners(client, listeners, ListenProtocols::SocksOnly, None).await + }) +} + +/// Bind `port` on every localhost address family that is available. +async fn bind_localhost( + runtime: &TokioNativeTlsRuntime, + port: u16, +) -> anyhow::Result::Listener>> { + let listen = Listen::new_localhost(port); + let mut listeners = Vec::new(); + for addr in listen.ip_addrs()?.flatten() { + match runtime.listen(&addr, &TcpListenOptions::default()).await { + Ok(listener) => listeners.push(listener), + #[cfg(unix)] + Err(ref e) if e.raw_os_error() == Some(libc::EAFNOSUPPORT) => {} + Err(e) => anyhow::bail!("Can't listen on {addr}: {e}"), + } + } + if listeners.is_empty() { + anyhow::bail!("Couldn't open SOCKS listeners"); + } + Ok(listeners) } // Due to its simple signature this dummy function is the one added (unused) to iOS swift codebase to force Xcode to link the lib diff --git a/rust/tests/saturating_time.rs b/rust/tests/saturating_time.rs new file mode 100644 index 00000000..3a445c45 --- /dev/null +++ b/rust/tests/saturating_time.rs @@ -0,0 +1,32 @@ +//! tor-netdoc reads `SystemTime::min_value()` while parsing every consensus. +//! Upstream saturating-time 0.4.0 never finishes computing it on Windows, +//! where `SystemTime` has 100ns resolution, so bootstrap hung forever. + +use saturating_time::SaturatingTime; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +#[test] +fn time_limits_are_found_on_this_platform() { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let limits = ( + SystemTime::min_value(), + SystemTime::max_value(), + Instant::min_value(), + Instant::max_value(), + ); + tx.send(limits).unwrap(); + }); + + let (min_time, max_time, min_instant, max_instant) = rx + .recv_timeout(Duration::from_secs(10)) + .expect("saturating-time limits did not resolve"); + + assert!(min_time < SystemTime::UNIX_EPOCH); + assert!(max_time > SystemTime::now()); + assert!(min_time.checked_sub(Duration::from_secs(1)).is_none()); + assert!(max_time.checked_add(Duration::from_secs(1)).is_none()); + assert!(min_instant < max_instant); +} From e48a1eafcb34d6d40bddb1d4836bc50809cbdd41 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 5 Oct 2026 14:11:27 -0500 Subject: [PATCH 04/47] fix(rust): bind SOCKS listeners before tor_start returns socks::run_socks_proxy() binds its listeners inside the future, and that future ran in the task spawned by start_proxy(), so a bind failure (typically EADDRINUSE on the port Dart picked moments earlier) ended up as the JoinHandle's result and nothing ever looked at it. tor_start() still returned live handles, tor_client_bootstrap() reported success because it only checks the client, and Dart published a proxy port with nothing listening on it. Bind on the proxy runtime synchronously in tor_start(), set LAST_ERROR and return null handles when that fails, and hand the already bound listeners to socks::run_socks_proxy_with_listeners(). Dart already treats a null client as a start failure and surfaces the Rust error, so no wrapper change is needed. libc becomes a direct dependency for the EAFNOSUPPORT check that run_socks_proxy() does internally. Unit tests cover the occupied-port failure and that a successful bind holds the port until the listeners are dropped. --- CHANGELOG.md | 2 + rust/Cargo.lock | 1 + rust/Cargo.toml | 3 ++ rust/src/lib.rs | 133 ++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b908fe..30a82c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ * Fix `tor_client_bootstrap` and `tor_client_set_dormant` freeing the client handle Dart still holds. Add `tor_client_free`; `Tor.stop` now releases the client handle. +* Bind the SOCKS listeners before `tor_start` returns, so a port collision + fails `Tor.start` instead of leaving a dead proxy behind a published port. ## 0.1.0 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4664cb43..2de5e7cf 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -5000,6 +5000,7 @@ dependencies = [ "cbindgen", "glob", "lazy_static", + "libc", "log", "lzma-sys", "rlimit", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b53342ba..2a8c04b4 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -25,6 +25,9 @@ lzma-sys = { version = "0.1.20", features = ["static"] } rlimit = "0.10.1" anyhow = "1.0.79" +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(target_os = "ios")'.dependencies] # Specific version for iOS security-framework = "=2.10.0" diff --git a/rust/src/lib.rs b/rust/src/lib.rs index f3624fd8..d8c841d9 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -13,7 +13,7 @@ use tokio::runtime::{Builder, Runtime}; use tokio::task::JoinHandle; use tor_config::Listen; use tor_rtcompat::tokio::TokioNativeTlsRuntime; -use tor_rtcompat::ToplevelBlockOn; +use tor_rtcompat::{NetStreamProvider, ToplevelBlockOn}; pub use crate::error::tor_last_error_message; #[cfg(not(target_os = "windows"))] @@ -51,6 +51,11 @@ pub unsafe extern "C" fn tor_start( let runtime = unwrap_or_return!(TokioNativeTlsRuntime::create(), err_ret); + // Reserve the port before initializing storage or bootstrapping. On any + // later failure these listeners are dropped and the port is released. + let rt = unwrap_or_return!(proxy_runtime(), err_ret); + let listeners = unwrap_or_return!(bind_localhost(rt, &runtime, socks_port), err_ret); + let mut cfg_builder = TorClientConfig::builder(); cfg_builder .storage() @@ -70,8 +75,8 @@ pub unsafe extern "C" fn tor_start( err_ret ); - let proxy_handle_box = Box::new(start_proxy(socks_port, client.clone())); - let client_box = Box::new(client.clone()); + let proxy_handle_box = Box::new(start_proxy(rt, client.clone(), listeners)); + let client_box = Box::new(client); Tor { client: Box::into_raw(client_box) as *mut c_void, @@ -119,7 +124,9 @@ pub unsafe extern "C" fn tor_client_free(client: *mut c_void) { return; } - drop(Box::from_raw(client as *mut TorClient)); + drop(Box::from_raw( + client as *mut TorClient, + )); } #[no_mangle] @@ -133,20 +140,64 @@ pub unsafe extern "C" fn tor_proxy_stop(proxy: *mut c_void) { proxy.abort(); } +type Listener = ::Listener; + +/// The runtime that drives the SOCKS accept loop. +fn proxy_runtime() -> io::Result<&'static Runtime> { + RUNTIME + .as_ref() + .map_err(|e| io::Error::new(e.kind(), e.to_string())) +} + fn start_proxy( - port: u16, + rt: &Runtime, client: TorClient, + listeners: Vec, ) -> JoinHandle> { println!("Starting proxy!"); - let rt = RUNTIME.as_ref().unwrap(); - rt.spawn(socks::run_socks_proxy( - client.runtime().clone(), - client.clone(), - Listen::new_localhost(port), - None, + rt.spawn(socks::run_socks_proxy_with_listeners( + client, listeners, None, )) } +/// Bind `port` on every localhost address family that is available. +/// +/// This blocks on `rt` so the listeners are registered with the reactor that +/// later runs the accept loop, and so the caller sees a bind failure before +/// the port is published. Mirrors what `socks::run_socks_proxy` does +/// internally, minus the part where the error disappears into the task. +fn bind_localhost( + rt: &Runtime, + runtime: &TokioNativeTlsRuntime, + port: u16, +) -> io::Result> { + rt.block_on(async { + let listen = Listen::new_localhost(port); + let mut listeners = Vec::new(); + for addr in listen + .ip_addrs() + .map_err(|e| io::Error::other(e.to_string()))? + .flatten() + { + match runtime.listen(&addr).await { + Ok(listener) => listeners.push(listener), + #[cfg(unix)] + Err(ref e) if e.raw_os_error() == Some(libc::EAFNOSUPPORT) => {} + Err(e) => { + return Err(io::Error::new( + e.kind(), + format!("Can't listen on {addr}: {e}"), + )) + } + } + } + if listeners.is_empty() { + return Err(io::Error::other("Couldn't open SOCKS listeners")); + } + Ok(listeners) + }) +} + // Due to its simple signature this dummy function is the one added (unused) to iOS swift codebase to force Xcode to link the lib #[no_mangle] pub unsafe extern "C" fn tor_hello() { @@ -157,6 +208,8 @@ pub unsafe extern "C" fn tor_hello() { mod tests { use super::*; use arti_client::config::TorClientConfigBuilder; + use std::ffi::CString; + use std::net::{Ipv4Addr, TcpListener}; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -240,4 +293,62 @@ mod tests { fn client_free_ignores_null() { unsafe { tor_client_free(ptr::null_mut()) }; } + + #[test] + fn bind_localhost_reports_an_occupied_port() { + let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = occupied.local_addr().unwrap().port(); + let arti_rt = TokioNativeTlsRuntime::create().unwrap(); + + let err = match bind_localhost(proxy_runtime().unwrap(), &arti_rt, port) { + Ok(_) => panic!("bound port {port} while it was occupied"), + Err(err) => err, + }; + + assert!(err.to_string().contains("Can't listen on"), "{err}"); + } + + #[test] + fn tor_start_rejects_an_occupied_port_before_opening_storage() { + let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let root = scratch_dir("busy-port"); + // This cannot be a storage directory. A late bind would fail on storage + // initialization instead of reporting the occupied port. + let state = root.join("state-file"); + std::fs::write(&state, b"not a directory").unwrap(); + let state = CString::new(state.to_str().unwrap()).unwrap(); + let cache = CString::new(root.join("cache").to_str().unwrap()).unwrap(); + + let result = unsafe { + tor_start( + occupied.local_addr().unwrap().port(), + state.as_ptr(), + cache.as_ptr(), + ) + }; + assert!(result.client.is_null()); + assert!(result.proxy.is_null()); + let error = crate::error::take_last_error().unwrap().to_string(); + std::fs::remove_dir_all(root).unwrap(); + assert!(error.contains("Can't listen on"), "{error}"); + } + + #[test] + fn bind_localhost_holds_the_port_until_dropped() { + let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .unwrap() + .local_addr() + .unwrap() + .port(); + let arti_rt = TokioNativeTlsRuntime::create().unwrap(); + + let listeners = bind_localhost(proxy_runtime().unwrap(), &arti_rt, port).unwrap(); + + assert!( + TcpListener::bind((Ipv4Addr::LOCALHOST, port)).is_err(), + "port {port} was published without being held" + ); + drop(listeners); + TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + } } From fd103a560e1ddf4f23ea3c5146a54d45f2cbef88 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 16 Sep 2026 12:44:04 -0500 Subject: [PATCH 05/47] chore: release 0.2.0 --- CHANGELOG.md | 4 ++++ example/pubspec.lock | 2 +- pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 599c4cf6..2e295665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.0 + +* Bump arti to 2.6.0; needs Rust 1.91. + ## 0.1.0 * Build the Rust core with Flutter native assets instead of cargokit. diff --git a/example/pubspec.lock b/example/pubspec.lock index 79337fd2..76d4fc6e 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -406,7 +406,7 @@ packages: path: ".." relative: true source: path - version: "0.1.0" + version: "0.2.0" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 438ec75f..136c89e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: tor_ffi_plugin description: Tor FFI plugin for Dart and Flutter. Uses Arti (A Rust Tor Implementation) via FFI. -version: 0.1.0 +version: 0.2.0 homepage: https://github.com/cypherstack/tor environment: From be07416dbf158b4bd93bf58000b6dde4b8a34870 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 09:46:57 -0500 Subject: [PATCH 06/47] build(rust): update crates with published security advisories Lockfile-only, semver-compatible updates: bytes 1.6.0 -> 1.12.1 RUSTSEC-2026-0007, yanked crossbeam-channel 0.5.13 -> 0.5.17 RUSTSEC-2025-0024, yanked openssl 0.10.64 -> 0.10.81 RUSTSEC-2024-0357, -2025-0004, -2025-0022 openssl-sys 0.9.102 -> 0.9.117 required by openssl openssl-src 3.3.1 -> 3.6.3 vendored OpenSSL used on Linux and Android; 3.3.1 predates several OpenSSL security releases None of these looked reachable in a practical way from the plugin, but the versions were flagged in review and two were yanked. --- rust/Cargo.lock | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2de5e7cf..4147a758 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -541,9 +541,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "caret" @@ -847,9 +847,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.13" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] @@ -2523,15 +2523,14 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "openssl" -version = "0.10.64" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags 2.9.1", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2555,18 +2554,18 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-src" -version = "300.3.1+3.3.1" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7259953d42a81bf137fbbd73bd30a8e1914d6dce43c2b90ed575783a22608b91" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.102" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", From 32a7c23418c9cf95a0b394ba3fe121d1f9449542 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 10:52:27 -0500 Subject: [PATCH 07/47] chore(ffi): document safety contracts and regenerate bindings --- lib/tor_ffi_plugin_bindings_generated.dart | 57 ++++++++++++++++++-- rust/build.rs | 5 +- rust/src/error.rs | 5 ++ rust/src/lib.rs | 29 +++++++++++ rust/src/util.rs | 10 +++- rust/target/tor_ffi_plugin.h | 60 +++++++++++++++++++++- 6 files changed, 156 insertions(+), 10 deletions(-) diff --git a/lib/tor_ffi_plugin_bindings_generated.dart b/lib/tor_ffi_plugin_bindings_generated.dart index f1970777..ff37ca35 100644 --- a/lib/tor_ffi_plugin_bindings_generated.dart +++ b/lib/tor_ffi_plugin_bindings_generated.dart @@ -13,35 +13,83 @@ library; import 'dart:ffi' as ffi; +/// Ensure the client has bootstrapped. +/// +/// # Safety +/// `client` must be a live, non-null handle returned by [`tor_start`]. +/// It must not be freed while this call is in progress. @ffi.Native)>() external bool tor_client_bootstrap(ffi.Pointer client); +/// Release the client handle returned by [`tor_start`]. +/// +/// The handle must not be used after this call. The proxy task keeps its own +/// reference to the underlying client, so stopping the proxy and freeing the +/// handle can happen in either order. +/// +/// # Safety +/// `client` must be null or a live handle returned by [`tor_start`]. +/// No other call may use the handle concurrently with or after this call. @ffi.Native)>() external void tor_client_free(ffi.Pointer client); +/// Change the client's dormant mode. +/// +/// # Safety +/// `client` must be a live, non-null handle returned by [`tor_start`]. +/// It must not be freed while this call is in progress. @ffi.Native, ffi.Bool)>() external void tor_client_set_dormant( ffi.Pointer client, bool soft_mode, ); +/// Read the current open-file limit. +/// +/// # Safety +/// This function has no additional safety requirements. @ffi.Native() external int tor_get_nofile_limit(); +/// Print a greeting to verify that the library is linked. +/// +/// # Safety +/// This function has no additional safety requirements. @ffi.Native() external void tor_hello(); +/// Take the current thread's last error as a newly allocated C string. +/// +/// # Safety +/// This function has no preconditions. The returned string is owned by the +/// caller and must only be reclaimed with Rust's `CString::from_raw`. @ffi.Native Function()>() external ffi.Pointer tor_last_error_message(); +/// Stop the proxy and release its handle. Null is accepted. +/// +/// # Safety +/// `proxy` must be null or a live proxy handle returned by [`tor_start`]. +/// A non-null handle must be passed to this function only once. @ffi.Native)>() external void tor_proxy_stop(ffi.Pointer proxy); +/// Increase the open-file limit, up to the hard limit. +/// +/// # Safety +/// This function has no additional safety requirements. @ffi.Native() external int tor_set_nofile_limit(int limit); +/// Start a bootstrapped Tor client and a localhost SOCKS proxy. +/// +/// # Safety +/// `state_dir` and `cache_dir` must point to valid, NUL-terminated strings +/// for the duration of the call. Each returned non-null handle must be +/// released exactly once with its corresponding cleanup function. @ffi.Native< - Tor Function(ffi.Uint16, ffi.Pointer, ffi.Pointer)>() + Tor Function(ffi.Uint16, ffi.Pointer, ffi.Pointer) +>() external Tor tor_start( int socks_port, ffi.Pointer state_dir, @@ -57,8 +105,7 @@ final class Tor extends ffi.Struct { ffi.Allocator $allocator, { required ffi.Pointer client, required ffi.Pointer proxy, - }) => - $allocator() - ..ref.client = client - ..ref.proxy = proxy; + }) => $allocator() + ..ref.client = client + ..ref.proxy = proxy; } diff --git a/rust/build.rs b/rust/build.rs index 817bbcf3..f9dded50 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -3,9 +3,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later use cbindgen::{Config, Language}; +use glob::glob; use std::env; use std::path::PathBuf; -use glob::glob; fn main() { android_on_linux_check(); @@ -37,7 +37,6 @@ fn target_dir() -> PathBuf { } } - fn android_on_linux_check() { let target = env::var("TARGET").unwrap(); if target == "x86_64-linux-android" { @@ -48,7 +47,7 @@ fn android_on_linux_check() { }; let ndk_home_result = env::var("ANDROID_NDK_HOME"); - let ndk_home = if let Some(value) = ndk_home_result.ok() { + let ndk_home = if let Ok(value) = ndk_home_result { value } else { println!("ANDROID_NDK_HOME not set. Trying CC_x86_64_linux_android"); diff --git a/rust/src/error.rs b/rust/src/error.rs index 7bf5ba0c..831ce627 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -12,6 +12,11 @@ thread_local! { static LAST_ERROR: RefCell>> = RefCell::new(None); } +/// Take the current thread's last error as a newly allocated C string. +/// +/// # Safety +/// This function has no preconditions. The returned string is owned by the +/// caller and must only be reclaimed with Rust's `CString::from_raw`. #[no_mangle] pub unsafe extern "C" fn tor_last_error_message() -> *const c_char { let last_error = match crate::error::take_last_error() { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 497024d5..b7b9e973 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -36,6 +36,12 @@ pub struct Tor { proxy: *mut c_void, } +/// Start a bootstrapped Tor client and a localhost SOCKS proxy. +/// +/// # Safety +/// `state_dir` and `cache_dir` must point to valid, NUL-terminated strings +/// for the duration of the call. Each returned non-null handle must be +/// released exactly once with its corresponding cleanup function. #[no_mangle] pub unsafe extern "C" fn tor_start( socks_port: u16, @@ -94,6 +100,11 @@ unsafe fn client_ref<'a>(client: *mut c_void) -> &'a Arc>) } +/// Ensure the client has bootstrapped. +/// +/// # Safety +/// `client` must be a live, non-null handle returned by [`tor_start`]. +/// It must not be freed while this call is in progress. #[no_mangle] pub unsafe extern "C" fn tor_client_bootstrap(client: *mut c_void) -> bool { let client = client_ref(client); @@ -102,6 +113,11 @@ pub unsafe extern "C" fn tor_client_bootstrap(client: *mut c_void) -> bool { true } +/// Change the client's dormant mode. +/// +/// # Safety +/// `client` must be a live, non-null handle returned by [`tor_start`]. +/// It must not be freed while this call is in progress. #[no_mangle] pub unsafe extern "C" fn tor_client_set_dormant(client: *mut c_void, soft_mode: bool) { let client = client_ref(client); @@ -119,6 +135,10 @@ pub unsafe extern "C" fn tor_client_set_dormant(client: *mut c_void, soft_mode: /// The handle must not be used after this call. The proxy task keeps its own /// reference to the underlying client, so stopping the proxy and freeing the /// handle can happen in either order. +/// +/// # Safety +/// `client` must be null or a live handle returned by [`tor_start`]. +/// No other call may use the handle concurrently with or after this call. #[no_mangle] pub unsafe extern "C" fn tor_client_free(client: *mut c_void) { if client.is_null() { @@ -130,6 +150,11 @@ pub unsafe extern "C" fn tor_client_free(client: *mut c_void) { )); } +/// Stop the proxy and release its handle. Null is accepted. +/// +/// # Safety +/// `proxy` must be null or a live proxy handle returned by [`tor_start`]. +/// A non-null handle must be passed to this function only once. #[no_mangle] pub unsafe extern "C" fn tor_proxy_stop(proxy: *mut c_void) { if proxy.is_null() { @@ -202,6 +227,10 @@ fn bind_localhost( } // Due to its simple signature this dummy function is the one added (unused) to iOS swift codebase to force Xcode to link the lib +/// Print a greeting to verify that the library is linked. +/// +/// # Safety +/// This function has no additional safety requirements. #[no_mangle] pub unsafe extern "C" fn tor_hello() { println!("HELLO THERE"); diff --git a/rust/src/util.rs b/rust/src/util.rs index 76f1dd7c..88bbec91 100644 --- a/rust/src/util.rs +++ b/rust/src/util.rs @@ -3,6 +3,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later use crate::error::update_last_error; +/// Read the current open-file limit. +/// +/// # Safety +/// This function has no additional safety requirements. #[no_mangle] #[cfg(not(target_os = "windows"))] pub unsafe extern "C" fn tor_get_nofile_limit() -> u64 { @@ -10,8 +14,12 @@ pub unsafe extern "C" fn tor_get_nofile_limit() -> u64 { nofile_limit.0 } +/// Increase the open-file limit, up to the hard limit. +/// +/// # Safety +/// This function has no additional safety requirements. #[no_mangle] #[cfg(not(target_os = "windows"))] pub unsafe extern "C" fn tor_set_nofile_limit(limit: u64) -> u64 { unwrap_or_return!(rlimit::increase_nofile_limit(limit), 0) -} \ No newline at end of file +} diff --git a/rust/target/tor_ffi_plugin.h b/rust/target/tor_ffi_plugin.h index c1e5ee98..08b66d15 100644 --- a/rust/target/tor_ffi_plugin.h +++ b/rust/target/tor_ffi_plugin.h @@ -8,27 +8,85 @@ typedef struct Tor { void *proxy; } Tor; +/** + * Start a bootstrapped Tor client and a localhost SOCKS proxy. + * + * # Safety + * `state_dir` and `cache_dir` must point to valid, NUL-terminated strings + * for the duration of the call. Each returned non-null handle must be + * released exactly once with its corresponding cleanup function. + */ struct Tor tor_start(uint16_t socks_port, const char *state_dir, const char *cache_dir); +/** + * Ensure the client has bootstrapped. + * + * # Safety + * `client` must be a live, non-null handle returned by [`tor_start`]. + * It must not be freed while this call is in progress. + */ bool tor_client_bootstrap(void *client); +/** + * Change the client's dormant mode. + * + * # Safety + * `client` must be a live, non-null handle returned by [`tor_start`]. + * It must not be freed while this call is in progress. + */ void tor_client_set_dormant(void *client, bool soft_mode); /** * Release the client handle returned by [`tor_start`]. * * The handle must not be used after this call. The proxy task keeps its own - * clone of the underlying client, so stopping the proxy and freeing the + * reference to the underlying client, so stopping the proxy and freeing the * handle can happen in either order. + * + * # Safety + * `client` must be null or a live handle returned by [`tor_start`]. + * No other call may use the handle concurrently with or after this call. */ void tor_client_free(void *client); +/** + * Stop the proxy and release its handle. Null is accepted. + * + * # Safety + * `proxy` must be null or a live proxy handle returned by [`tor_start`]. + * A non-null handle must be passed to this function only once. + */ void tor_proxy_stop(void *proxy); +/** + * Print a greeting to verify that the library is linked. + * + * # Safety + * This function has no additional safety requirements. + */ void tor_hello(void); +/** + * Take the current thread's last error as a newly allocated C string. + * + * # Safety + * This function has no preconditions. The returned string is owned by the + * caller and must only be reclaimed with Rust's `CString::from_raw`. + */ const char *tor_last_error_message(void); +/** + * Read the current open-file limit. + * + * # Safety + * This function has no additional safety requirements. + */ uint64_t tor_get_nofile_limit(void); +/** + * Increase the open-file limit, up to the hard limit. + * + * # Safety + * This function has no additional safety requirements. + */ uint64_t tor_set_nofile_limit(uint64_t limit); From a90b83841179c7edef8016d1526c4cc072d759e8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 10:53:52 -0500 Subject: [PATCH 08/47] fix(example): resolve analyzer findings --- example/lib/main.dart | 107 ++++++++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index c1daf17e..adf630bf 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -19,9 +19,7 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp( - home: Home(), - ); + return const MaterialApp(home: Home()); } } @@ -42,8 +40,8 @@ class _MyAppState extends State { // Set the default text for the onion input field. final onionController = TextEditingController( - text: - 'https://cflarexljc3rw355ysrkrzwapozws6nre6xsy3n4yrj7taye3uiby3ad.onion'); + text: 'https://cflarexljc3rw355ysrkrzwapozws6nre6xsy3n4yrj7taye3uiby3ad.onion', + ); // See https://blog.cloudflare.com/cloudflare-onion-service/ for more options: // cflarexljc3rw355ysrkrzwapozws6nre6xsy3n4yrj7taye3uiby3ad.onion // cflarenuttlfuyn7imozr4atzvfbiw3ezgbdjdldmdx7srterayaozid.onion @@ -57,14 +55,15 @@ class _MyAppState extends State { // cflare2nge4h4yqr3574crrd7k66lil3torzbisz6uciyuzqc2h2ykyd.onion final bitcoinOnionController = TextEditingController( - text: - 'qly7g5n5t3f3h23xvbp44vs6vpmayurno4basuu5rcvrupli7y2jmgid.onion:50001'); + text: + 'qly7g5n5t3f3h23xvbp44vs6vpmayurno4basuu5rcvrupli7y2jmgid.onion:50001', + ); // For more options, see https://bitnodes.io/nodes/addresses/?q=onion and // https://sethforprivacy.com/about/ final moneroOnionController = TextEditingController( - text: - 'ucdouiihzwvb5edg3ezeufcs4yp26gq4x64n6b4kuffb7s7jxynnk7qd.onion:18081/json_rpc'); + text: 'ucdouiihzwvb5edg3ezeufcs4yp26gq4x64n6b4kuffb7s7jxynnk7qd.onion:18081/json_rpc', + ); @override void initState() { @@ -83,7 +82,7 @@ class _MyAppState extends State { torIsRunning = Tor.instance.status == TorStatus.on; // Update flag }); - print('Done awaiting; tor should be running'); + debugPrint('Done awaiting; tor should be running'); } @override @@ -105,23 +104,24 @@ class _MyAppState extends State { torIsRunning = Tor.instance.status == TorStatus.on; // Update flag }); - print('Done awaiting; tor should be running'); + debugPrint('Done awaiting; tor should be running'); } Future testMoneroOnionNodeConnection() async { try { // Validate the onion address. if (!moneroOnionController.text.contains(".onion")) { - print("Invalid onion address"); + debugPrint("Invalid onion address"); return; } else if (!moneroOnionController.text.contains(":")) { - print("Invalid onion address (needs port)"); + debugPrint("Invalid onion address (needs port)"); return; } final String host = moneroOnionController.text.split(":").first; final int port = int.parse( - moneroOnionController.text.split(":").last.split("/").first); + moneroOnionController.text.split(":").last.split("/").first, + ); final String path = moneroOnionController.text .split(":") .last @@ -143,7 +143,8 @@ class _MyAppState extends State { "method": "get_info", }); - final request = 'POST /$path HTTP/1.1\r\n' + final request = + 'POST /$path HTTP/1.1\r\n' 'Host: $host\r\n' 'Content-Type: application/json\r\n' 'Content-Length: ${body.length}\r\n' @@ -151,17 +152,17 @@ class _MyAppState extends State { '$body'; socksSocket.write(request); - print("Request sent: $request"); + debugPrint("Request sent: $request"); await for (var response in socksSocket.inputStream) { final result = utf8.decode(response); - print("Response received: $result"); + debugPrint("Response received: $result"); break; } await socksSocket.close(); } catch (e, s) { - print("Error connecting to Monero onion node: $e\n$s"); + debugPrint("Error connecting to Monero onion node: $e\n$s"); } } @@ -169,9 +170,7 @@ class _MyAppState extends State { Widget build(BuildContext context) { const spacerSmall = SizedBox(height: 10); return Scaffold( - appBar: AppBar( - title: const Text('Tor example'), - ), + appBar: AppBar(title: const Text('Tor example')), body: SingleChildScrollView( child: Container( padding: const EdgeInsets.all(10), @@ -198,15 +197,17 @@ class _MyAppState extends State { final time = DateTime.now(); - print("NOW: $time"); + debugPrint("NOW: $time"); await startTor(); - print("Start tor took " - "${DateTime.now().difference(time).inSeconds} " - "seconds"); + debugPrint( + "Start tor took " + "${DateTime.now().difference(time).inSeconds} " + "seconds", + ); - if (mounted) { + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -251,21 +252,24 @@ class _MyAppState extends State { // Assign connection factory. SocksTCPClient.assignToHttpClient(client, [ - ProxySettings(InternetAddress.loopbackIPv4, - Tor.instance.port, - password: - null), // TODO get from tor's config file. + ProxySettings( + InternetAddress.loopbackIPv4, + Tor.instance.port, + password: null, + ), // TODO get from tor's config file. ]); // GET request. - final request = await client - .getUrl(Uri.parse(hostController.text)); + final request = await client.getUrl( + Uri.parse(hostController.text), + ); final response = await request.close(); // Print response. - var responseString = - await utf8.decodeStream(response); - print(responseString); + var responseString = await utf8.decodeStream( + response, + ); + debugPrint(responseString); // If host input left to default icanhazip.com, a Tor // exit node IP should be printed to the console. // @@ -298,7 +302,9 @@ class _MyAppState extends State { // // Note that this is an SSL example. await socksSocket.connectTo( - 'bitcoin.stackwallet.com', 50002); + 'bitcoin.stackwallet.com', + 50002, + ); // Send a server features command to the connected socket, see method for more specific usage example.. await socksSocket.sendServerFeaturesCommand(); @@ -356,24 +362,27 @@ class _MyAppState extends State { ? () async { // Validate the onion address.www if (!onionController.text.contains(".onion")) { - print("Invalid onion address"); + debugPrint("Invalid onion address"); return; } else if (!onionController.text.contains(":")) { - print("Invalid onion address (needs port)"); + debugPrint("Invalid onion address (needs port)"); return; } - String domain = - bitcoinOnionController.text.split(":").first; + String domain = bitcoinOnionController.text + .split(":") + .first; int port = int.parse( - bitcoinOnionController.text.split(":").last); + bitcoinOnionController.text.split(":").last, + ); // Instantiate a socks socket at localhost and on the port selected by the tor service. var socksSocket = await SOCKSSocket.create( proxyHost: InternetAddress.loopbackIPv4.address, proxyPort: Tor.instance.port, - sslEnabled: !domain - .endsWith(".onion"), // For SSL connections. + sslEnabled: !domain.endsWith( + ".onion", + ), // For SSL connections. ); // Connect to the socks instantiated above. @@ -416,13 +425,10 @@ class _MyAppState extends State { // Close the socket. await socksSocket.close(); } - // A mutex should be added to this example to prevent // multiple connections from being made at once. TODO : null, - child: const Text( - "Test Bitcoin onion node connection", - ), + child: const Text("Test Bitcoin onion node connection"), ), ], ), @@ -440,12 +446,13 @@ class _MyAppState extends State { ), spacerSmall, TextButton( - onPressed: - torIsRunning ? testMoneroOnionNodeConnection : null, + onPressed: torIsRunning + ? testMoneroOnionNodeConnection + : null, child: const Text("Test Monero onion node connection"), ), ], - ) + ), ], ), ), From c8fb284e70dedc8f0688eef976ed27b0a27dc042 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 10:54:04 -0500 Subject: [PATCH 09/47] fix(dart): reset Tor state on stop and serialize start and stop stop() now resets the status, port and handles, so Tor can be started again after it was stopped. Resetting the status alone let a start() that was still in flight finish after stop() and publish its handles over those of a newer start(), leaving a running Tor client and proxy that nothing could release. Run start() and stop() one at a time, in call order, through a queue: a stop() issued during a pending start() waits for it and then tears down what it started, and a start() issued after that stop() begins only once the stop has finished. Native calls go through a TorNative class that tests can replace, so the ordering tests run deterministically without Tor. The restart test against the real network runs when TOR_NETWORK_TESTS is set. Route greetings through the same injectable native layer and cover the substitution without loading the real library. --- CHANGELOG.md | 5 + README.md | 17 +++ lib/tor_ffi_plugin.dart | 152 ++++++++++++++++++-------- pubspec.yaml | 1 + test/tor_lifecycle_ordering_test.dart | 115 +++++++++++++++++++ test/tor_lifecycle_test.dart | 68 ++++++++++++ 6 files changed, 313 insertions(+), 45 deletions(-) create mode 100644 test/tor_lifecycle_ordering_test.dart create mode 100644 test/tor_lifecycle_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fca06600..2b75b710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ client handle. * Bind the SOCKS listeners before `tor_start` returns, so a port collision fails `Tor.start` instead of leaving a dead proxy behind a published port. +* `Tor.stop` resets the wrapper's state, so Tor can be started again. +* `Tor.start` and `Tor.stop` run one at a time. A `stop` issued while a + `start` is in progress waits for it and then tears it down. +* Route `Tor.hello` through the injectable native layer so test instances + can substitute the greeting without calling the real library. ## 0.1.0 diff --git a/README.md b/README.md index 25bcb552..b462beac 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,23 @@ dart --packages=.dart_tool/package_config.json tool/prebuilt_manifest.dart artif To generate `tor_ffi_plugin.h` C bindings for Rust, `cargo build` in `rust` to produce headers according to `build.rs`. To generate `tor_ffi_plugin_bindings_generated.dart` Dart bindings for C, `dart run ffigen --config ffigen.yaml`. +### Tests + +Run the Rust tests with the pinned toolchain, then the offline Dart tests: + +```sh +(cd rust && cargo test --locked) +flutter test +``` + +The lifecycle regression also verifies stop/restart against the real Tor +network. Enable it explicitly (it requires network access and can take several +minutes): + +```sh +flutter test --dart-define=TOR_NETWORK_TESTS=true +``` + ## Example app `flutter run` in `example` to run the example app diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index fad19b79..526ed914 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -9,6 +9,7 @@ import 'dart:isolate'; import 'dart:math'; import 'package:ffi/ffi.dart'; +import 'package:meta/meta.dart'; import 'package:tor_ffi_plugin/tor_ffi_plugin_bindings_generated.dart' as bindings; @@ -18,15 +19,77 @@ class CouldntBootstrapDirectory implements Exception { CouldntBootstrapDirectory({this.rustError}); } -enum TorStatus { - on, - starting, - off; +enum TorStatus { on, starting, off } + +/// Native handles produced by a successful [TorNative.start]. +typedef TorHandles = ({Pointer client, Pointer proxy}); + +/// The native calls [Tor] makes. Tests substitute a fake to drive the +/// lifecycle without starting Tor. +@visibleForTesting +class TorNative { + const TorNative(); + + /// Start Tor with its SOCKS proxy on [port], in a worker isolate. + /// + /// Throws the Rust error if the native start fails. + Future start(int port, String stateDir, String cacheDir) async { + final (client, proxy) = await Isolate.run( + () => _startInIsolate(port, stateDir, cacheDir), + ); + return ( + client: Pointer.fromAddress(client), + proxy: Pointer.fromAddress(proxy), + ); + } + + static (int, int) _startInIsolate( + int port, + String stateDir, + String cacheDir, + ) { + final tor = bindings.tor_start( + port, + stateDir.toNativeUtf8() as Pointer, + cacheDir.toNativeUtf8() as Pointer, + ); + + // Throw an exception if the Tor service fails to start. + if (tor.client == nullptr) { + Tor.throwRustException(); + } + + return (tor.client.address, tor.proxy.address); + } + + /// Bootstrap [client]. Throws the Rust error on failure. + void bootstrap(Pointer client) { + if (!bindings.tor_client_bootstrap(client)) { + Tor.throwRustException(); + } + } + + void setDormant(Pointer client, bool softMode) => + bindings.tor_client_set_dormant(client, softMode); + + void stopProxy(Pointer proxy) => bindings.tor_proxy_stop(proxy); + + void freeClient(Pointer client) => bindings.tor_client_free(client); + + /// Print the native greeting to verify that the library is linked. + void hello() => bindings.tor_hello(); } class Tor { /// Private constructor for the Tor class. - Tor._(); + Tor._() : _native = const TorNative(); + + /// Create an instance that is independent of [instance] and talks to + /// [native] instead of the Rust library. + @visibleForTesting + Tor.withNative(TorNative native) : _native = native; + + final TorNative _native; /// Singleton instance of the Tor class. static final Tor instance = Tor._(); @@ -35,10 +98,6 @@ class Tor { TorStatus get status => _status; TorStatus _status = TorStatus.off; - /// Flag to indicate that a Tor circuit is thought to have been established - /// (true means that Tor has bootstrapped). - bool _bootstrapped = false; - /// Getter for the proxy port. /// /// Throws if Tor is not enabled or if the circuit is not established. @@ -64,9 +123,13 @@ class Tor { /// Throws an exception if the Tor service fails to start. /// /// Returns a Future that completes when the Tor service has started. - Future start({required String torDataDirPath}) async { - if (_status != TorStatus.off) { - // already starting or running + /// + /// [start] and [stop] run one at a time, in the order they were called. + Future start({required String torDataDirPath}) => + _enqueue(() => _start(torDataDirPath)); + + Future _start(String torDataDirPath) async { + if (_status == TorStatus.on) { return; } @@ -85,24 +148,11 @@ class Tor { } // Start the Tor service in an isolate. - final tor = await Isolate.run(() async { - // Start the Tor service. - final tor = bindings.tor_start( - newPort, - stateDir.path.toNativeUtf8() as Pointer, - cacheDir.path.toNativeUtf8() as Pointer); - - // Throw an exception if the Tor service fails to start. - if (tor.client == nullptr) { - throwRustException(); - } - - return tor; - }); + final tor = await _native.start(newPort, stateDir.path, cacheDir.path); // Set the client pointer and started flag. - _clientPtr = Pointer.fromAddress(tor.client.address); - _proxyPtr = Pointer.fromAddress(tor.proxy.address); + _clientPtr = tor.client; + _proxyPtr = tor.proxy; // Bootstrap the Tor service. _bootstrap(); @@ -126,15 +176,7 @@ class Tor { /// Throws an exception if the Tor service fails to bootstrap. /// /// Returns void. - void _bootstrap() { - // Bootstrap the Tor service. - _bootstrapped = bindings.tor_client_bootstrap(_clientPtr); - - // Throw an exception if the Tor service fails to bootstrap. - if (!_bootstrapped) { - throwRustException(); - } - } + void _bootstrap() => _native.bootstrap(_clientPtr); /// Prevent traffic flowing through the proxy void disable() { @@ -142,20 +184,38 @@ class Tor { } /// Stop the proxy and release the native client handle. - stop() async { - bindings.tor_proxy_stop(_proxyPtr); + /// + /// If a [start] is still in progress, this waits for it to finish and then + /// tears down what it started, so no Tor instance outlives the call. + Future stop() => _enqueue(() async => _stop()); + + void _stop() { + _native.stopProxy(_proxyPtr); _proxyPtr = nullptr; - bindings.tor_client_free(_clientPtr); + _native.freeClient(_clientPtr); _clientPtr = nullptr; + + _proxyPort = null; + _status = TorStatus.off; + } + + /// Tail of the queue that runs [start] and [stop] one at a time. + Future _lifecycle = Future.value(); + + Future _enqueue(Future Function() operation) { + final result = _lifecycle.then((_) => operation()); + // A failed operation must not stall the ones queued behind it. + _lifecycle = result.catchError((Object _) {}); + return result; } - setClientDormant(bool dormant) async { + Future setClientDormant(bool dormant) async { if (_clientPtr == nullptr || status == TorStatus.off) { throw ClientNotActive(); } - bindings.tor_client_set_dormant(_clientPtr, dormant); + _native.setDormant(_clientPtr, dormant); } Pointer _clientPtr = nullptr; @@ -187,8 +247,10 @@ class Tor { // } static void throwRustException() { - String rustError = - bindings.tor_last_error_message().cast().toDartString(); + String rustError = bindings + .tor_last_error_message() + .cast() + .toDartString(); throw _getRustException(rustError); } @@ -202,7 +264,7 @@ class Tor { } void hello() { - bindings.tor_hello(); + _native.hello(); } } diff --git a/pubspec.yaml b/pubspec.yaml index 136c89e4..ec548808 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: code_assets: ^1.2.1 crypto: ^3.0.7 hooks: ^2.0.2 + meta: ^1.16.0 native_toolchain_rust: ^1.0.6 dev_dependencies: diff --git a/test/tor_lifecycle_ordering_test.dart b/test/tor_lifecycle_ordering_test.dart new file mode 100644 index 00000000..976a1cba --- /dev/null +++ b/test/tor_lifecycle_ordering_test.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tor_ffi_plugin/tor_ffi_plugin.dart'; + +/// Stands in for the Rust library. Each native start stays pending until the +/// test releases it, and the fake tracks which native instances are alive. +class FakeTorNative implements TorNative { + final pendingStarts = >[]; + final liveClients = {}; + final liveProxies = {}; + int maxLiveClients = 0; + int helloCalls = 0; + int _nextAddress = 0x1000; + + /// Wait until [count] native starts have been requested. + Future waitForStarts(int count) async { + while (pendingStarts.length < count) { + await Future.delayed(const Duration(milliseconds: 5)); + } + } + + @override + Future start(int port, String stateDir, String cacheDir) async { + final gate = Completer(); + pendingStarts.add(gate); + await gate.future; + + final client = _nextAddress += 0x10; + final proxy = _nextAddress += 0x10; + liveClients.add(client); + liveProxies.add(proxy); + if (liveClients.length > maxLiveClients) { + maxLiveClients = liveClients.length; + } + return ( + client: Pointer.fromAddress(client), + proxy: Pointer.fromAddress(proxy), + ); + } + + @override + void bootstrap(Pointer client) {} + + @override + void hello() => helloCalls++; + + @override + void setDormant(Pointer client, bool softMode) {} + + @override + void stopProxy(Pointer proxy) => liveProxies.remove(proxy.address); + + @override + void freeClient(Pointer client) => liveClients.remove(client.address); +} + +void main() { + late Directory dataDir; + late FakeTorNative native; + late Tor tor; + + setUp(() async { + dataDir = await Directory.systemTemp.createTemp('tor-ordering-'); + native = FakeTorNative(); + tor = Tor.withNative(native); + }); + + tearDown(() => dataDir.delete(recursive: true)); + + test('hello uses the injected native layer', () { + tor.hello(); + expect(native.helloCalls, 1); + expect(native.liveClients, isEmpty); + }); + + test('stop during start tears down what the start created', () async { + final started = tor.start(torDataDirPath: dataDir.path); + await native.waitForStarts(1); + + final stopped = tor.stop(); + native.pendingStarts[0].complete(); + await started; + await stopped; + + expect(tor.status, TorStatus.off); + expect(native.liveClients, isEmpty); + expect(native.liveProxies, isEmpty); + }); + + test('restart during a pending start never overlaps instances', () async { + final first = tor.start(torDataDirPath: dataDir.path); + await native.waitForStarts(1); + + final stopped = tor.stop(); + final second = tor.start(torDataDirPath: dataDir.path); + native.pendingStarts[0].complete(); + await first; + await stopped; + + await native.waitForStarts(2); + native.pendingStarts[1].complete(); + await second; + + expect(tor.status, TorStatus.on); + expect(native.liveClients, hasLength(1)); + expect(native.maxLiveClients, 1); + + await tor.stop(); + expect(native.liveClients, isEmpty); + expect(native.liveProxies, isEmpty); + }); +} diff --git a/test/tor_lifecycle_test.dart b/test/tor_lifecycle_test.dart new file mode 100644 index 00000000..d8d6b8d6 --- /dev/null +++ b/test/tor_lifecycle_test.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tor_ffi_plugin/tor_ffi_plugin.dart'; + +Future expectSocksHandshake(int port) async { + final socket = await Socket.connect( + InternetAddress.loopbackIPv4, + port, + timeout: const Duration(seconds: 10), + ); + try { + socket.add([5, 1, 0]); + final response = await socket + .expand((chunk) => chunk) + .take(2) + .toList() + .timeout(const Duration(seconds: 10)); + expect(response, [5, 0]); + } finally { + socket.destroy(); + } +} + +void main() { + test('stop is idempotent while off', () async { + final tor = Tor.instance; + await tor.stop(); + await tor.stop(); + expect(tor.status, TorStatus.off); + expect(() => tor.port, throwsException); + await expectLater( + tor.setClientDormant(true), + throwsA(isA()), + ); + }); + + test( + 'stop resets state and allows a fresh start', + () async { + final directory = await Directory.systemTemp.createTemp('tor-lifecycle-'); + final tor = Tor.instance; + try { + for (var attempt = 0; attempt < 2; attempt++) { + await tor.start(torDataDirPath: directory.path); + expect(tor.status, TorStatus.on); + await expectSocksHandshake(tor.port); + await tor.setClientDormant(true); + await tor.setClientDormant(false); + await expectSocksHandshake(tor.port); + await tor.stop(); + expect(tor.status, TorStatus.off); + expect(() => tor.port, throwsException); + await expectLater( + tor.setClientDormant(true), + throwsA(isA()), + ); + await tor.stop(); + } + } finally { + await tor.stop(); + await directory.delete(recursive: true); + } + }, + skip: !const bool.fromEnvironment('TOR_NETWORK_TESTS'), + timeout: const Timeout(Duration(minutes: 5)), + ); +} From 39b6cb1a785ee81d738aae9e505633ce59a92d5a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 10:55:47 -0500 Subject: [PATCH 10/47] build(rust): update num-bigint-dig for compiler compatibility The locked num-bigint-dig 0.8.4 was incompatible with the compiler in use. Update it to 0.8.6 and refresh its checksum and resolved dependencies. --- rust/Cargo.lock | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d8e69386..7933bda3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2488,11 +2488,10 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", From c1a93b362dbdd4ba082052a16da01781b5c1baa2 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 13:21:17 -0500 Subject: [PATCH 11/47] fix(dart): let concurrent start() calls share one attempt A start() made while another was in flight returned immediately because the status was already "starting", so its caller went on to read port before Tor was ready and got an exception. Keep the pending attempt and hand it to later start() callers, so they all complete when Tor is up or fail with the same error. A stop() clears the pending attempt, so a start() requested after it begins a fresh one instead of joining the attempt that stop() is about to tear down. --- CHANGELOG.md | 2 ++ lib/tor_ffi_plugin.dart | 27 ++++++++++++++++++++--- test/tor_lifecycle_ordering_test.dart | 31 +++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b75b710..907a2fab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ * `Tor.stop` resets the wrapper's state, so Tor can be started again. * `Tor.start` and `Tor.stop` run one at a time. A `stop` issued while a `start` is in progress waits for it and then tears it down. +* Concurrent `Tor.start` calls share one attempt and complete only when Tor is + ready, or fail together with the same error. * Route `Tor.hello` through the injectable native layer so test instances can substitute the greeting without calling the real library. diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index 526ed914..4d4b58ce 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -125,8 +125,25 @@ class Tor { /// Returns a Future that completes when the Tor service has started. /// /// [start] and [stop] run one at a time, in the order they were called. - Future start({required String torDataDirPath}) => - _enqueue(() => _start(torDataDirPath)); + /// Calling [start] while an earlier start is still pending returns that + /// start's future, so every caller sees the same success or failure. + Future start({required String torDataDirPath}) { + final pending = _pendingStart; + if (pending != null) { + return pending; + } + + late final Future attempt; + attempt = _enqueue(() => _start(torDataDirPath)).whenComplete(() { + if (identical(_pendingStart, attempt)) { + _pendingStart = null; + } + }); + return _pendingStart = attempt; + } + + /// The start that later [start] calls join, until a [stop] is requested. + Future? _pendingStart; Future _start(String torDataDirPath) async { if (_status == TorStatus.on) { @@ -187,7 +204,11 @@ class Tor { /// /// If a [start] is still in progress, this waits for it to finish and then /// tears down what it started, so no Tor instance outlives the call. - Future stop() => _enqueue(() async => _stop()); + Future stop() { + // A start requested after this stop must not join the earlier attempt. + _pendingStart = null; + return _enqueue(() async => _stop()); + } void _stop() { _native.stopProxy(_proxyPtr); diff --git a/test/tor_lifecycle_ordering_test.dart b/test/tor_lifecycle_ordering_test.dart index 976a1cba..388e8522 100644 --- a/test/tor_lifecycle_ordering_test.dart +++ b/test/tor_lifecycle_ordering_test.dart @@ -90,6 +90,37 @@ void main() { expect(native.liveProxies, isEmpty); }); + test('concurrent starts wait for the same attempt', () async { + final first = tor.start(torDataDirPath: dataDir.path); + final second = tor.start(torDataDirPath: dataDir.path); + await native.waitForStarts(1); + + var secondDone = false; + unawaited(second.then((_) => secondDone = true)); + await Future.delayed(const Duration(milliseconds: 20)); + expect(secondDone, isFalse); + + native.pendingStarts[0].complete(); + await Future.wait([first, second]); + + expect(tor.status, TorStatus.on); + expect(native.pendingStarts, hasLength(1)); + await tor.stop(); + }); + + test('concurrent starts both see a failed attempt', () async { + final first = tor.start(torDataDirPath: dataDir.path); + final second = tor.start(torDataDirPath: dataDir.path); + await native.waitForStarts(1); + + native.pendingStarts[0].completeError(Exception('bootstrap failed')); + + await expectLater(first, throwsException); + await expectLater(second, throwsException); + expect(tor.status, TorStatus.off); + expect(native.pendingStarts, hasLength(1)); + }, timeout: const Timeout(Duration(seconds: 10))); + test('restart during a pending start never overlaps instances', () async { final first = tor.start(torDataDirPath: dataDir.path); await native.waitForStarts(1); From c96bc83af1fbd16cc314b5c5288f0b404dec2488 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 13:21:40 -0500 Subject: [PATCH 12/47] fix(dart): release native handles when bootstrap fails start() stored the client and proxy handles before bootstrapping. When bootstrap then failed it only set the status back to off, so the proxy kept running and the next start() overwrote both handles, leaking a Tor client nothing could free. Tear down whatever the native start produced before rethrowing. --- CHANGELOG.md | 2 ++ lib/tor_ffi_plugin.dart | 4 +++- test/tor_lifecycle_ordering_test.dart | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 907a2fab..a1cad29d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ `start` is in progress waits for it and then tears it down. * Concurrent `Tor.start` calls share one attempt and complete only when Tor is ready, or fail together with the same error. +* Release the native client and proxy when bootstrap fails during + `Tor.start`, instead of leaving the proxy running. * Route `Tor.hello` through the injectable native layer so test instances can substitute the greeting without calling the real library. diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index 4d4b58ce..5b92b25f 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -178,7 +178,9 @@ class Tor { _proxyPort = newPort; _status = TorStatus.on; } catch (_) { - _status = TorStatus.off; + // Release anything the native start produced before bootstrap failed; + // the next start would otherwise overwrite these handles. + _stop(); rethrow; } } diff --git a/test/tor_lifecycle_ordering_test.dart b/test/tor_lifecycle_ordering_test.dart index 388e8522..599a678f 100644 --- a/test/tor_lifecycle_ordering_test.dart +++ b/test/tor_lifecycle_ordering_test.dart @@ -13,6 +13,7 @@ class FakeTorNative implements TorNative { final liveProxies = {}; int maxLiveClients = 0; int helloCalls = 0; + bool failBootstrap = false; int _nextAddress = 0x1000; /// Wait until [count] native starts have been requested. @@ -42,7 +43,11 @@ class FakeTorNative implements TorNative { } @override - void bootstrap(Pointer client) {} + void bootstrap(Pointer client) { + if (failBootstrap) { + throw Exception('bootstrap failed'); + } + } @override void hello() => helloCalls++; @@ -121,6 +126,18 @@ void main() { expect(native.pendingStarts, hasLength(1)); }, timeout: const Timeout(Duration(seconds: 10))); + test('a failed bootstrap releases the native handles', () async { + native.failBootstrap = true; + final started = tor.start(torDataDirPath: dataDir.path); + await native.waitForStarts(1); + native.pendingStarts[0].complete(); + + await expectLater(started, throwsException); + expect(tor.status, TorStatus.off); + expect(native.liveClients, isEmpty); + expect(native.liveProxies, isEmpty); + }); + test('restart during a pending start never overlaps instances', () async { final first = tor.start(torDataDirPath: dataDir.path); await native.waitForStarts(1); From f9cf9a93feb4a580f4623759c5f55f37816cffa4 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 13:21:58 -0500 Subject: [PATCH 13/47] fix(dart): make disable() stop the proxy disable() is documented to prevent traffic flowing through the proxy but only set the Dart status to off. The SOCKS listener kept accepting and relaying connections, so a caller that relied on disable() alone stayed on Tor while the status said otherwise. Make disable() an alias for stop(). It now returns a Future; existing callers that ignore the result, or follow it with stop() as Stack Wallet does, keep working because stop() is idempotent and queued. --- CHANGELOG.md | 4 ++++ lib/tor_ffi_plugin.dart | 9 +++++---- test/tor_lifecycle_ordering_test.dart | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1cad29d..01330d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ ready, or fail together with the same error. * Release the native client and proxy when bootstrap fails during `Tor.start`, instead of leaving the proxy running. +* `Tor.disable` now stops the proxy like `Tor.stop`; before, it only changed + the reported status while traffic kept flowing. **API change:** it returns + a `Future` that can throw on release failure. Await it or attach an + error handler when calling from a synchronous dispose method. * Route `Tor.hello` through the injectable native layer so test instances can substitute the greeting without calling the real library. diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index 5b92b25f..42509dc3 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -197,10 +197,11 @@ class Tor { /// Returns void. void _bootstrap() => _native.bootstrap(_clientPtr); - /// Prevent traffic flowing through the proxy - void disable() { - _status = TorStatus.off; - } + /// Prevent traffic flowing through the proxy. + /// + /// This is the same as [stop]: the SOCKS proxy is shut down and the native + /// client released. + Future disable() => stop(); /// Stop the proxy and release the native client handle. /// diff --git a/test/tor_lifecycle_ordering_test.dart b/test/tor_lifecycle_ordering_test.dart index 599a678f..19e3f6c3 100644 --- a/test/tor_lifecycle_ordering_test.dart +++ b/test/tor_lifecycle_ordering_test.dart @@ -138,6 +138,21 @@ void main() { expect(native.liveProxies, isEmpty); }); + test('disable stops the proxy', () async { + final started = tor.start(torDataDirPath: dataDir.path); + await native.waitForStarts(1); + native.pendingStarts[0].complete(); + await started; + + await tor.disable(); + expect(tor.status, TorStatus.off); + expect(native.liveClients, isEmpty); + expect(native.liveProxies, isEmpty); + + // Callers that follow disable() with stop() keep working. + await tor.stop(); + }); + test('restart during a pending start never overlaps instances', () async { final first = tor.start(torDataDirPath: dataDir.path); await native.waitForStarts(1); From d73b660aca079ca859fa043857eb813b3a2f9a01 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 13:22:33 -0500 Subject: [PATCH 14/47] fix(tor): allocate the SOCKS port atomically Dart probed a free port before Rust bound it, allowing another process to claim it. Allocate the port while binding the IPv4 listener, reserve the same port on available IPv6 loopback, and return the bound port to Dart. Retry an OS-assigned port when another process holds its IPv6 twin, with a bounded error after four attempts. Never retry a caller-requested port. Recognize disabled IPv6 on Windows and fall back to IPv4 only when IPv6 is unavailable. Cover fallback decisions and occupied-port errors. --- CHANGELOG.md | 15 +- lib/tor_ffi_plugin.dart | 43 +--- lib/tor_ffi_plugin_bindings_generated.dart | 7 +- rust/src/lib.rs | 233 ++++++++++++++++++--- rust/target/tor_ffi_plugin.h | 1 + test/tor_lifecycle_ordering_test.dart | 1 + 6 files changed, 231 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01330d70..a0baa178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ * Fix `tor_client_bootstrap` and `tor_client_set_dormant` freeing the client handle Dart still holds. Add `tor_client_free`; `Tor.stop` now releases the client handle. -* Bind the SOCKS listeners before `tor_start` returns, so a port collision - fails `Tor.start` instead of leaving a dead proxy behind a published port. +* Let the OS allocate the SOCKS port while Rust binds the real IPv4 listener, + removing the probe-then-bind race. The same port is used for optional IPv6 + loopback and returned to Dart while the listeners remain reserved. * `Tor.stop` resets the wrapper's state, so Tor can be started again. * `Tor.start` and `Tor.stop` run one at a time. A `stop` issued while a `start` is in progress waits for it and then tears it down. @@ -17,6 +18,16 @@ the reported status while traffic kept flowing. **API change:** it returns a `Future` that can throw on release failure. Await it or attach an error handler when calling from a synchronous dispose method. +* When the OS assigns the SOCKS port and its IPv6 loopback twin is held by + another process, try up to three more ports, then fail to start with an + error saying the twin stayed occupied. Serving IPv4 alone there would let + clients that resolve `localhost` to `::1` reach the other process. IPv4-only + loopback is used only when IPv6 is unavailable on the host. A caller-requested + non-zero port is not retried, and start fails if its IPv6 twin is held by + another process. Dart always passes zero, so only direct C callers see this. +* Start on Windows hosts with IPv6 disabled. Winsock reports the missing + address family as `WSAEAFNOSUPPORT`, which was not recognized, so + `Tor.start` failed instead of serving IPv4 loopback. * Route `Tor.hello` through the injectable native layer so test instances can substitute the greeting without calling the real library. diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index 42509dc3..d341d5db 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -6,7 +6,6 @@ import 'dart:async'; import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; -import 'dart:math'; import 'package:ffi/ffi.dart'; import 'package:meta/meta.dart'; @@ -22,7 +21,7 @@ class CouldntBootstrapDirectory implements Exception { enum TorStatus { on, starting, off } /// Native handles produced by a successful [TorNative.start]. -typedef TorHandles = ({Pointer client, Pointer proxy}); +typedef TorHandles = ({Pointer client, Pointer proxy, int port}); /// The native calls [Tor] makes. Tests substitute a fake to drive the /// lifecycle without starting Tor. @@ -34,16 +33,17 @@ class TorNative { /// /// Throws the Rust error if the native start fails. Future start(int port, String stateDir, String cacheDir) async { - final (client, proxy) = await Isolate.run( + final (client, proxy, boundPort) = await Isolate.run( () => _startInIsolate(port, stateDir, cacheDir), ); return ( client: Pointer.fromAddress(client), proxy: Pointer.fromAddress(proxy), + port: boundPort, ); } - static (int, int) _startInIsolate( + static (int, int, int) _startInIsolate( int port, String stateDir, String cacheDir, @@ -59,7 +59,7 @@ class TorNative { Tor.throwRustException(); } - return (tor.client.address, tor.proxy.address); + return (tor.client.address, tor.proxy.address, tor.port); } /// Bootstrap [client]. Throws the Rust error on failure. @@ -157,15 +157,9 @@ class Tor { final stateDir = await Directory('$torDataDirPath/tor_state').create(); final cacheDir = await Directory('$torDataDirPath/tor_cache').create(); - // Generate a random port. - final int? newPort = await _getRandomUnusedPort(); - - if (newPort == null) { - throw Exception("Failed to get random unused port!"); - } - - // Start the Tor service in an isolate. - final tor = await _native.start(newPort, stateDir.path, cacheDir.path); + // Let the OS allocate the port while Rust binds the real listeners. This + // avoids a probe-then-bind race and keeps the published port reserved. + final tor = await _native.start(0, stateDir.path, cacheDir.path); // Set the client pointer and started flag. _clientPtr = tor.client; @@ -175,7 +169,7 @@ class Tor { _bootstrap(); // Set the proxy port and change status. - _proxyPort = newPort; + _proxyPort = tor.port; _status = TorStatus.on; } catch (_) { // Release anything the native start produced before bootstrap failed; @@ -245,25 +239,6 @@ class Tor { Pointer _clientPtr = nullptr; Pointer _proxyPtr = nullptr; - Future _getRandomUnusedPort({List excluded = const []}) async { - var random = Random.secure(); - int potentialPort = 0; - - retry: - while (potentialPort <= 0 || excluded.contains(potentialPort)) { - potentialPort = random.nextInt(65535); - try { - var socket = await ServerSocket.bind("0.0.0.0", potentialPort); - socket.close(); - return potentialPort; - } catch (_) { - continue retry; - } - } - - return null; - } - // Future restart() async { // // TODO: arti seems to recover by itself and there is no client restart fn // // TODO: but follow up with them if restart is truly unnecessary diff --git a/lib/tor_ffi_plugin_bindings_generated.dart b/lib/tor_ffi_plugin_bindings_generated.dart index ff37ca35..c814af12 100644 --- a/lib/tor_ffi_plugin_bindings_generated.dart +++ b/lib/tor_ffi_plugin_bindings_generated.dart @@ -101,11 +101,16 @@ final class Tor extends ffi.Struct { external ffi.Pointer proxy; + @ffi.Uint16() + external int port; + static ffi.Pointer $allocate( ffi.Allocator $allocator, { required ffi.Pointer client, required ffi.Pointer proxy, + required int port, }) => $allocator() ..ref.client = client - ..ref.proxy = proxy; + ..ref.proxy = proxy + ..ref.port = port; } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index b7b9e973..28a6036c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -8,13 +8,13 @@ use arti_client::config::CfgPath; use arti_client::{DormantMode, TorClient, TorClientConfig}; use lazy_static::lazy_static; use std::ffi::{c_char, c_void, CStr}; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use std::{io, ptr}; use tokio::runtime::{Builder, Runtime}; use tokio::task::JoinHandle; -use tor_config::Listen; use tor_rtcompat::tokio::TokioNativeTlsRuntime; -use tor_rtcompat::{NetStreamProvider, TcpListenOptions, ToplevelBlockOn}; +use tor_rtcompat::{NetStreamListener, NetStreamProvider, TcpListenOptions, ToplevelBlockOn}; pub use crate::error::tor_last_error_message; #[cfg(not(target_os = "windows"))] @@ -30,10 +30,15 @@ lazy_static! { static ref RUNTIME: io::Result = Builder::new_multi_thread().enable_all().build(); } +/// How many more OS-assigned ports to try when another process holds the +/// IPv6 twin of the assigned port, before failing. +const IPV6_BIND_RETRIES: u32 = 3; + #[repr(C)] pub struct Tor { client: *mut c_void, proxy: *mut c_void, + port: u16, } /// Start a bootstrapped Tor client and a localhost SOCKS proxy. @@ -51,6 +56,7 @@ pub unsafe extern "C" fn tor_start( let err_ret = Tor { client: ptr::null_mut(), proxy: ptr::null_mut(), + port: 0, }; let state_dir = unwrap_or_return!(CStr::from_ptr(state_dir).to_str(), err_ret); @@ -61,7 +67,8 @@ pub unsafe extern "C" fn tor_start( // Reserve the port before initializing storage or bootstrapping. On any // later failure these listeners are dropped and the port is released. let rt = unwrap_or_return!(proxy_runtime(), err_ret); - let listeners = unwrap_or_return!(bind_localhost(rt, &runtime, socks_port), err_ret); + let (listeners, socks_port) = + unwrap_or_return!(bind_localhost(rt, &runtime, socks_port), err_ret); let mut cfg_builder = TorClientConfig::builder(); cfg_builder @@ -88,6 +95,7 @@ pub unsafe extern "C" fn tor_start( Tor { client: Box::into_raw(client_box) as *mut c_void, proxy: Box::into_raw(proxy_handle_box) as *mut c_void, + port: socks_port, } } @@ -191,41 +199,117 @@ fn start_proxy( /// Bind `port` on every localhost address family that is available. /// +/// IPv4 is authoritative: the IPv4 listener's port is the published port, and +/// IPv6 loopback is added on the same port. IPv4 alone is served only when +/// IPv6 is unavailable on the host. When the OS picks the port and another +/// process already holds its IPv6 twin, a fresh port is tried a few times +/// before failing, since a client resolving `localhost` to `::1` would reach +/// that process. +/// /// This blocks on `rt` so the listeners are registered with the reactor that /// later runs the accept loop, and so the caller sees a bind failure before /// the port is published. fn bind_localhost( rt: &Runtime, runtime: &TokioNativeTlsRuntime, - port: u16, -) -> io::Result> { + requested_port: u16, +) -> io::Result<(Vec, u16)> { rt.block_on(async { - let listen = Listen::new_localhost(port); - let mut listeners = Vec::new(); - for addr in listen - .ip_addrs() - .map_err(|e| io::Error::other(e.to_string()))? - .flatten() - { - match runtime.listen(&addr, &TcpListenOptions::default()).await { - Ok(listener) => listeners.push(listener), - #[cfg(unix)] - Err(ref e) if e.raw_os_error() == Some(libc::EAFNOSUPPORT) => {} - Err(e) => { - return Err(io::Error::new( - e.kind(), - format!("Can't listen on {addr}: {e}"), - )) + let options = TcpListenOptions::default(); + let mut retries_left = if requested_port == 0 { + IPV6_BIND_RETRIES + } else { + 0 + }; + loop { + let ipv4 = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), requested_port); + let ipv4_listener = runtime + .listen(&ipv4, &options) + .await + .map_err(|e| io::Error::new(e.kind(), format!("Can't listen on {ipv4}: {e}")))?; + let port = ipv4_listener.local_addr()?.port(); + + let ipv6 = SocketAddr::new(Ipv6Addr::LOCALHOST.into(), port); + let error = match runtime.listen(&ipv6, &options).await { + Ok(listener) => return Ok((vec![ipv4_listener, listener], port)), + Err(error) => error, + }; + match ipv6_bind_fallback(requested_port, retries_left, &error) { + Ipv6Fallback::Ipv4Only => return Ok((vec![ipv4_listener], port)), + Ipv6Fallback::Retry => { + retries_left -= 1; + drop(ipv4_listener); } + Ipv6Fallback::Fail => return Err(ipv6_bind_error(requested_port, ipv6, error)), } } - if listeners.is_empty() { - return Err(io::Error::other("Couldn't open SOCKS listeners")); - } - Ok(listeners) }) } +/// The error reported when IPv6 loopback could not be bound on `ipv6`. +fn ipv6_bind_error(requested_port: u16, ipv6: SocketAddr, error: io::Error) -> io::Error { + let reason = if requested_port == 0 && error.kind() == io::ErrorKind::AddrInUse { + format!( + "the IPv6 twin of the assigned port was occupied after {} attempts ({error})", + IPV6_BIND_RETRIES + 1 + ) + } else { + error.to_string() + }; + io::Error::new(error.kind(), format!("Can't listen on {ipv6}: {reason}")) +} + +/// What to do when IPv6 loopback could not be bound on the IPv4 port. +#[derive(Debug, PartialEq, Eq)] +enum Ipv6Fallback { + /// Serve IPv4 loopback only. + Ipv4Only, + /// Release the IPv4 listener and ask the OS for another port. + Retry, + /// Report the error. This includes an OS-assigned port whose IPv6 twin + /// stayed occupied after every retry. + Fail, +} + +fn ipv6_bind_fallback(requested_port: u16, retries_left: u32, error: &io::Error) -> Ipv6Fallback { + if ipv6_is_unavailable(error) { + return Ipv6Fallback::Ipv4Only; + } + // Only an OS-assigned port can be swapped for another one. A port the + // caller asked for is reported as occupied, as it is for IPv4. Serving + // IPv4 alone would leave `[::1]` on the published port to another process. + if requested_port == 0 && error.kind() == io::ErrorKind::AddrInUse && retries_left > 0 { + return Ipv6Fallback::Retry; + } + Ipv6Fallback::Fail +} + +/// Whether IPv6 loopback is unavailable on this host. IPv4 remains usable in +/// this case, so the SOCKS proxy should still start. +fn ipv6_is_unavailable(error: &io::Error) -> bool { + if matches!( + error.kind(), + io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported + ) { + return true; + } + + #[cfg(unix)] + if error.raw_os_error() == Some(libc::EAFNOSUPPORT) { + return true; + } + + // std maps only WSAEADDRNOTAVAIL to a typed kind on Windows. A disabled + // IPv6 stack fails socket creation with WSAEAFNOSUPPORT (10047) or + // WSAEPROTONOSUPPORT (10043), which arrive uncategorized. + #[cfg(windows)] + if matches!(error.raw_os_error(), Some(10047 | 10043)) { + return true; + } + + false +} + // Due to its simple signature this dummy function is the one added (unused) to iOS swift codebase to force Xcode to link the lib /// Print a greeting to verify that the library is linked. /// @@ -367,14 +451,28 @@ mod tests { #[test] fn bind_localhost_holds_the_port_until_dropped() { - let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) - .unwrap() - .local_addr() - .unwrap() - .port(); let arti_rt = TokioNativeTlsRuntime::create().unwrap(); - - let listeners = bind_localhost(proxy_runtime().unwrap(), &arti_rt, port).unwrap(); + let rt = proxy_runtime().unwrap(); + + // A requested port must be honored and held. Another test may take the + // freed port first, so retry with a fresh one. + let (listeners, port) = (0..8) + .find_map(|_| { + let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .unwrap() + .local_addr() + .unwrap() + .port(); + match bind_localhost(rt, &arti_rt, port) { + Ok((listeners, bound_port)) => { + assert_eq!(bound_port, port); + Some((listeners, port)) + } + Err(e) if e.kind() == io::ErrorKind::AddrInUse => None, + Err(e) => panic!("bind_localhost failed: {e}"), + } + }) + .expect("no free port after 8 attempts"); assert!( TcpListener::bind((Ipv4Addr::LOCALHOST, port)).is_err(), @@ -383,4 +481,75 @@ mod tests { drop(listeners); TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); } + + #[test] + fn ipv6_bind_fallback_retries_only_os_assigned_ports() { + let in_use = io::Error::from(io::ErrorKind::AddrInUse); + let unavailable = io::Error::from(io::ErrorKind::AddrNotAvailable); + let denied = io::Error::from(io::ErrorKind::PermissionDenied); + + assert_eq!(ipv6_bind_fallback(0, 2, &in_use), Ipv6Fallback::Retry); + // Out of retries, the IPv6 twin still belongs to another process. + assert_eq!(ipv6_bind_fallback(0, 0, &in_use), Ipv6Fallback::Fail); + assert_eq!(ipv6_bind_fallback(9050, 0, &in_use), Ipv6Fallback::Fail); + assert_eq!( + ipv6_bind_fallback(9050, 0, &unavailable), + Ipv6Fallback::Ipv4Only + ); + assert_eq!(ipv6_bind_fallback(0, 2, &denied), Ipv6Fallback::Fail); + + #[cfg(windows)] + { + let no_ipv6 = io::Error::from_raw_os_error(10047); + assert_eq!(ipv6_bind_fallback(0, 0, &no_ipv6), Ipv6Fallback::Ipv4Only); + assert_eq!( + ipv6_bind_fallback(9050, 0, &no_ipv6), + Ipv6Fallback::Ipv4Only + ); + } + } + + #[test] + fn ipv6_bind_error_names_the_exhausted_retries() { + let ipv6 = SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 41000); + let in_use = || io::Error::from(io::ErrorKind::AddrInUse); + + let exhausted = ipv6_bind_error(0, ipv6, in_use()); + assert_eq!(exhausted.kind(), io::ErrorKind::AddrInUse); + let message = exhausted.to_string(); + assert!( + message.contains("IPv6 twin of the assigned port was occupied after 4 attempts"), + "{message}" + ); + + let requested = ipv6_bind_error(41000, ipv6, in_use()).to_string(); + assert!( + requested.starts_with("Can't listen on [::1]:41000"), + "{requested}" + ); + assert!(!requested.contains("attempts"), "{requested}"); + } + + #[test] + #[cfg(windows)] + fn windows_reports_a_disabled_ipv6_stack_as_unavailable() { + // WSAEAFNOSUPPORT and WSAEPROTONOSUPPORT have no typed ErrorKind. + assert!(ipv6_is_unavailable(&io::Error::from_raw_os_error(10047))); + assert!(ipv6_is_unavailable(&io::Error::from_raw_os_error(10043))); + } + + #[test] + fn bind_localhost_allocates_and_reports_one_port() { + let arti_rt = TokioNativeTlsRuntime::create().unwrap(); + + let (listeners, port) = bind_localhost(proxy_runtime().unwrap(), &arti_rt, 0).unwrap(); + + assert_ne!(port, 0); + assert!( + TcpListener::bind((Ipv4Addr::LOCALHOST, port)).is_err(), + "reported port {port} was not held by the proxy listener" + ); + drop(listeners); + TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + } } diff --git a/rust/target/tor_ffi_plugin.h b/rust/target/tor_ffi_plugin.h index 08b66d15..256ad617 100644 --- a/rust/target/tor_ffi_plugin.h +++ b/rust/target/tor_ffi_plugin.h @@ -6,6 +6,7 @@ typedef struct Tor { void *client; void *proxy; + uint16_t port; } Tor; /** diff --git a/test/tor_lifecycle_ordering_test.dart b/test/tor_lifecycle_ordering_test.dart index 19e3f6c3..dcad2267 100644 --- a/test/tor_lifecycle_ordering_test.dart +++ b/test/tor_lifecycle_ordering_test.dart @@ -39,6 +39,7 @@ class FakeTorNative implements TorNative { return ( client: Pointer.fromAddress(client), proxy: Pointer.fromAddress(proxy), + port: 19050, ); } From 338dec6cac65eb4af2382d4d1791c4068fbc456b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 13:22:52 -0500 Subject: [PATCH 15/47] fix(dart): free the directory strings passed to tor_start Both paths were allocated with toNativeUtf8() and never freed. tor_start() only needs them for the duration of the call (it copies them into the arti config), so free them in a finally once it returns, on success and on failure. --- CHANGELOG.md | 1 + lib/tor_ffi_plugin.dart | 28 ++++++++++++++++++---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0baa178..7962a289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ the reported status while traffic kept flowing. **API change:** it returns a `Future` that can throw on release failure. Await it or attach an error handler when calling from a synchronous dispose method. +* Free the state and cache directory strings passed to `tor_start`. * When the OS assigns the SOCKS port and its IPv6 loopback twin is held by another process, try up to three more ports, then fail to start with an error saying the twin stayed occupied. Serving IPv4 alone there would let diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index d341d5db..cbb110c6 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -48,18 +48,26 @@ class TorNative { String stateDir, String cacheDir, ) { - final tor = bindings.tor_start( - port, - stateDir.toNativeUtf8() as Pointer, - cacheDir.toNativeUtf8() as Pointer, - ); + // tor_start() copies both paths, so they are freed once it returns. + final stateDirPtr = stateDir.toNativeUtf8(); + final cacheDirPtr = cacheDir.toNativeUtf8(); + try { + final tor = bindings.tor_start( + port, + stateDirPtr.cast(), + cacheDirPtr.cast(), + ); + + // Throw an exception if the Tor service fails to start. + if (tor.client == nullptr) { + Tor.throwRustException(); + } - // Throw an exception if the Tor service fails to start. - if (tor.client == nullptr) { - Tor.throwRustException(); + return (tor.client.address, tor.proxy.address, tor.port); + } finally { + malloc.free(stateDirPtr); + malloc.free(cacheDirPtr); } - - return (tor.client.address, tor.proxy.address, tor.port); } /// Bootstrap [client]. Throws the Rust error on failure. From 759b278f8aa5ef53d53b207a6fba15d9c76abf1a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 13:23:48 -0500 Subject: [PATCH 16/47] fix(ffi): release Rust error strings after Dart reads them tor_last_error_message() hands out a CString allocated by Rust, but no function existed to give it back, so Dart copied the message and leaked the allocation on every reported error. Export tor_string_free() and call it from throwRustException() once the message has been copied. Bindings and header are regenerated. Adds a Rust round-trip test and a Dart test that drives a real tor_start() failure on an occupied port, which exercises this path without network access. --- CHANGELOG.md | 2 ++ lib/tor_ffi_plugin.dart | 11 ++++++---- lib/tor_ffi_plugin_bindings_generated.dart | 10 ++++++++- rust/src/error.rs | 16 ++++++++++++++- rust/src/lib.rs | 14 ++++++++++++- rust/target/tor_ffi_plugin.h | 11 +++++++++- test/tor_lifecycle_test.dart | 24 ++++++++++++++++++++++ 7 files changed, 80 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7962a289..1f0baa27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ a `Future` that can throw on release failure. Await it or attach an error handler when calling from a synchronous dispose method. * Free the state and cache directory strings passed to `tor_start`. +* Add `tor_string_free` and use it to release Rust error messages after + Dart copies them. * When the OS assigns the SOCKS port and its IPv6 loopback twin is held by another process, try up to three more ports, then fail to start with an error saying the twin stayed occupied. Serving IPv4 alone there would let diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index cbb110c6..7b166c5a 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -254,10 +254,13 @@ class Tor { // } static void throwRustException() { - String rustError = bindings - .tor_last_error_message() - .cast() - .toDartString(); + final message = bindings.tor_last_error_message(); + final String rustError; + try { + rustError = message.cast().toDartString(); + } finally { + bindings.tor_string_free(message); + } throw _getRustException(rustError); } diff --git a/lib/tor_ffi_plugin_bindings_generated.dart b/lib/tor_ffi_plugin_bindings_generated.dart index c814af12..1141ddee 100644 --- a/lib/tor_ffi_plugin_bindings_generated.dart +++ b/lib/tor_ffi_plugin_bindings_generated.dart @@ -62,7 +62,7 @@ external void tor_hello(); /// /// # Safety /// This function has no preconditions. The returned string is owned by the -/// caller and must only be reclaimed with Rust's `CString::from_raw`. +/// caller and must be released exactly once with [`tor_string_free`]. @ffi.Native Function()>() external ffi.Pointer tor_last_error_message(); @@ -96,6 +96,14 @@ external Tor tor_start( ffi.Pointer cache_dir, ); +/// Release a string returned by [`tor_last_error_message`]. +/// +/// # Safety +/// `message` must be null or a pointer returned by `tor_last_error_message` +/// that has not been released yet. It must not be used after this call. +@ffi.Native)>() +external void tor_string_free(ffi.Pointer message); + final class Tor extends ffi.Struct { external ffi.Pointer client; diff --git a/rust/src/error.rs b/rust/src/error.rs index 831ce627..ef7e5368 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -16,7 +16,7 @@ thread_local! { /// /// # Safety /// This function has no preconditions. The returned string is owned by the -/// caller and must only be reclaimed with Rust's `CString::from_raw`. +/// caller and must be released exactly once with [`tor_string_free`]. #[no_mangle] pub unsafe extern "C" fn tor_last_error_message() -> *const c_char { let last_error = match crate::error::take_last_error() { @@ -28,6 +28,20 @@ pub unsafe extern "C" fn tor_last_error_message() -> *const c_char { CString::new(error_message).unwrap().into_raw() } +/// Release a string returned by [`tor_last_error_message`]. +/// +/// # Safety +/// `message` must be null or a pointer returned by `tor_last_error_message` +/// that has not been released yet. It must not be used after this call. +#[no_mangle] +pub unsafe extern "C" fn tor_string_free(message: *mut c_char) { + if message.is_null() { + return; + } + + drop(CString::from_raw(message)); +} + macro_rules! unwrap_or_return { ($a:expr,$b:expr) => { match $a { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 28a6036c..59f561a4 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -16,7 +16,7 @@ use tokio::task::JoinHandle; use tor_rtcompat::tokio::TokioNativeTlsRuntime; use tor_rtcompat::{NetStreamListener, NetStreamProvider, TcpListenOptions, ToplevelBlockOn}; -pub use crate::error::tor_last_error_message; +pub use crate::error::{tor_last_error_message, tor_string_free}; #[cfg(not(target_os = "windows"))] pub use crate::util::tor_get_nofile_limit; #[cfg(not(target_os = "windows"))] @@ -405,6 +405,18 @@ mod tests { assert!(crate::error::take_last_error().is_some()); } + #[test] + fn error_message_is_released_by_tor_string_free() { + update_last_error(io::Error::other("boom")); + + unsafe { + let message = tor_last_error_message(); + assert_eq!(CStr::from_ptr(message).to_str().unwrap(), "boom"); + tor_string_free(message as *mut c_char); + tor_string_free(ptr::null_mut()); + } + } + #[test] fn client_free_ignores_null() { unsafe { tor_client_free(ptr::null_mut()) }; diff --git a/rust/target/tor_ffi_plugin.h b/rust/target/tor_ffi_plugin.h index 256ad617..0983443a 100644 --- a/rust/target/tor_ffi_plugin.h +++ b/rust/target/tor_ffi_plugin.h @@ -72,10 +72,19 @@ void tor_hello(void); * * # Safety * This function has no preconditions. The returned string is owned by the - * caller and must only be reclaimed with Rust's `CString::from_raw`. + * caller and must be released exactly once with [`tor_string_free`]. */ const char *tor_last_error_message(void); +/** + * Release a string returned by [`tor_last_error_message`]. + * + * # Safety + * `message` must be null or a pointer returned by `tor_last_error_message` + * that has not been released yet. It must not be used after this call. + */ +void tor_string_free(char *message); + /** * Read the current open-file limit. * diff --git a/test/tor_lifecycle_test.dart b/test/tor_lifecycle_test.dart index d8d6b8d6..bbd2acd9 100644 --- a/test/tor_lifecycle_test.dart +++ b/test/tor_lifecycle_test.dart @@ -35,6 +35,30 @@ void main() { ); }); + test('native start reports an occupied port', () async { + final occupied = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(occupied.close); + final dir = await Directory.systemTemp.createTemp('tor-busy-port-'); + addTearDown(() => dir.delete(recursive: true)); + + // Fails before storage or network are touched, and runs the error string + // and path string cleanup against the real library. + await expectLater( + const TorNative().start( + occupied.port, + '${dir.path}/state', + '${dir.path}/cache', + ), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains("Can't listen on"), + ), + ), + ); + }); + test( 'stop resets state and allows a fresh start', () async { From f6812543f5b77f0455f83b94a19b3a0199e40605 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 14:19:44 -0500 Subject: [PATCH 17/47] fix(ffi): strip NUL bytes from Rust error messages tor_last_error_message() unwrapped CString::new(), which fails on an interior NUL. The resulting panic would abort the process at the FFI boundary. Drop NULs before building the C string and add a Rust test. --- CHANGELOG.md | 1 + rust/src/error.rs | 4 +++- rust/src/lib.rs | 11 +++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0baa27..018423a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ * Free the state and cache directory strings passed to `tor_start`. * Add `tor_string_free` and use it to release Rust error messages after Dart copies them. +* Strip NUL bytes from Rust error messages instead of panicking across FFI. * When the OS assigns the SOCKS port and its IPv6 loopback twin is held by another process, try up to three more ports, then fail to start with an error saying the twin stayed occupied. Serving IPv4 alone there would let diff --git a/rust/src/error.rs b/rust/src/error.rs index ef7e5368..7182c3ed 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -24,7 +24,9 @@ pub unsafe extern "C" fn tor_last_error_message() -> *const c_char { None => return CString::new("").unwrap().into_raw(), }; - let error_message = last_error.to_string(); + // CString::new rejects interior NULs, and a panic here would abort the + // process at the FFI boundary. + let error_message = last_error.to_string().replace('\0', ""); CString::new(error_message).unwrap().into_raw() } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 59f561a4..35531306 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -417,6 +417,17 @@ mod tests { } } + #[test] + fn error_message_drops_interior_nul() { + update_last_error(io::Error::other("bo\0om")); + + unsafe { + let message = tor_last_error_message(); + assert_eq!(CStr::from_ptr(message).to_str().unwrap(), "boom"); + tor_string_free(message as *mut c_char); + } + } + #[test] fn client_free_ignores_null() { unsafe { tor_client_free(ptr::null_mut()) }; From 2a5d7e776e0b768e673013d3245ccb7a8f57e521 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 12:04:01 -0800 Subject: [PATCH 18/47] fix(rust): only import update_last_error where it is used util.rs uses update_last_error only in the nofile-limit functions, which are compiled out on Windows, so every Windows build warned about an unused import. --- rust/src/util.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/src/util.rs b/rust/src/util.rs index 88bbec91..770ae5c1 100644 --- a/rust/src/util.rs +++ b/rust/src/util.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2024 Foundation Devices Inc. // // SPDX-License-Identifier: GPL-3.0-or-later +#[cfg(not(target_os = "windows"))] use crate::error::update_last_error; /// Read the current open-file limit. From fecbffea9a6f2435d79dac68aa074558fa142f97 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 12:05:18 -0800 Subject: [PATCH 19/47] fix(dart): free the native client in a worker isolate and wait for it Tor.stop() called tor_client_free() synchronously on the calling isolate and did not wait for anything. Releasing the client is about to shut down its runtime, which can take a few seconds, so run it through Isolate.run() the way start already runs tor_start(), and complete stop() only once it returns. A failed start waits for the same cleanup before rethrowing. The test fake now completes freeClient() asynchronously, so the ordering tests fail if a caller stops waiting for it. --- lib/tor_ffi_plugin.dart | 24 +++++++++++++++++++----- test/tor_lifecycle_ordering_test.dart | 7 ++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index 7b166c5a..74dbd0a0 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -82,7 +82,19 @@ class TorNative { void stopProxy(Pointer proxy) => bindings.tor_proxy_stop(proxy); - void freeClient(Pointer client) => bindings.tor_client_free(client); + /// Release [client] in a worker isolate, since tearing down Tor can block + /// for a few seconds. + Future freeClient(Pointer client) async { + if (client == nullptr) { + return; + } + + final address = client.address; + await Isolate.run(() => _freeInIsolate(address)); + } + + static void _freeInIsolate(int client) => + bindings.tor_client_free(Pointer.fromAddress(client)); /// Print the native greeting to verify that the library is linked. void hello() => bindings.tor_hello(); @@ -182,7 +194,7 @@ class Tor { } catch (_) { // Release anything the native start produced before bootstrap failed; // the next start would otherwise overwrite these handles. - _stop(); + await _stop(); rethrow; } } @@ -212,18 +224,20 @@ class Tor { Future stop() { // A start requested after this stop must not join the earlier attempt. _pendingStart = null; - return _enqueue(() async => _stop()); + return _enqueue(_stop); } - void _stop() { + Future _stop() async { _native.stopProxy(_proxyPtr); _proxyPtr = nullptr; - _native.freeClient(_clientPtr); + final client = _clientPtr; _clientPtr = nullptr; _proxyPort = null; _status = TorStatus.off; + + await _native.freeClient(client); } /// Tail of the queue that runs [start] and [stop] one at a time. diff --git a/test/tor_lifecycle_ordering_test.dart b/test/tor_lifecycle_ordering_test.dart index dcad2267..4132e54f 100644 --- a/test/tor_lifecycle_ordering_test.dart +++ b/test/tor_lifecycle_ordering_test.dart @@ -60,7 +60,12 @@ class FakeTorNative implements TorNative { void stopProxy(Pointer proxy) => liveProxies.remove(proxy.address); @override - void freeClient(Pointer client) => liveClients.remove(client.address); + Future freeClient(Pointer client) async { + // Complete later, like the native free does, so tests catch callers that + // do not wait for it. + await Future.delayed(Duration.zero); + liveClients.remove(client.address); + } } void main() { From ce90eee9d328fa8c45cf526fa5fcea780dc07eea Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 12:10:44 -0800 Subject: [PATCH 20/47] fix(tor): make lifecycle cancellable and bounded Unbounded bootstrap serialized stop behind network work, and incomplete teardown left clients alive and data directories locked. Give each client a cancellable deadline and an owned runtime, activate the proxy only after bootstrap, and join its accept loop before releasing the client. Join starts with identical raw arguments, resolve physical directories asynchronously inside the queue, and cancel every pending start on stop. Reuse a running instance only for its physical directory. Retain failed releases in order and retry before opening another client; keep status on until a running instance is released, or starting after failed startup cleanup. Never use lifecycle status as a proxy liveness monitor. Preserve typed bootstrap failures and their stacks if cleanup also fails, report secondary errors, and return the native cause chain without repeating embedded suffixes. Namespace C status constants, preserve numeric ABI and Dart names, and compile-check C/C++ interoperability. Include deterministic lifecycle, error-chain and header regressions, documentation and release notes describing the completed behavior. --- CHANGELOG.md | 71 ++- README.md | 47 ++ ffigen.yaml | 6 + lib/tor_ffi_plugin.dart | 328 ++++++++--- lib/tor_ffi_plugin_bindings_generated.dart | 94 ++- rust/build.rs | 9 +- rust/src/error.rs | 99 +++- rust/src/lib.rs | 538 +++++++++++++++--- rust/target/tor_ffi_plugin.h | 99 +++- rust/tests/header.rs | 52 ++ test/tor_lifecycle_ordering_test.dart | 630 +++++++++++++++++++-- test/tor_lifecycle_test.dart | 50 ++ 12 files changed, 1825 insertions(+), 198 deletions(-) create mode 100644 rust/tests/header.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 018423a2..5131c190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,40 @@ client handle. * Let the OS allocate the SOCKS port while Rust binds the real IPv4 listener, removing the probe-then-bind race. The same port is used for optional IPv6 - loopback and returned to Dart while the listeners remain reserved. + loopback and returned to Dart while the listeners remain reserved. The SOCKS + accept loop starts only after bootstrap succeeds. +* **Breaking change for C callers:** `tor_start` returns + `struct Tor { client, proxy, port }`. The SOCKS listeners are bound but idle + until `tor_proxy_start(proxy)` is called after a successful bootstrap. + Dart's `Tor.start` does this for you. * `Tor.stop` resets the wrapper's state, so Tor can be started again. -* `Tor.start` and `Tor.stop` run one at a time. A `stop` issued while a - `start` is in progress waits for it and then tears it down. -* Concurrent `Tor.start` calls share one attempt and complete only when Tor is - ready, or fail together with the same error. +* **Behavior change:** give bootstrap a configurable two-minute default + deadline. Before this release `Tor.start` waited indefinitely for + bootstrap; pass a longer `bootstrapTimeout` to keep waiting on slow + networks. A `stop` issued during bootstrap cancels it promptly before + releasing the native handles. +* Add `tor_client_cancel_bootstrap`. Cancellation is permanent for the + handle; free it and call `tor_start` again to bootstrap anew. +* `tor_client_bootstrap` returns a `TorBootstrapStatus` code (ok, cancelled, + timed out, failed, null client) instead of a bool, and Dart maps it without + matching error text. C enumerators are prefixed with `TorBootstrapStatus_` + to avoid collisions with application enums; their numeric values and Dart + names are unchanged. The header also supports C++ callers. A passed deadline throws the new `TorBootstrapTimeout` + instead of a plain `Exception`. +* Report Arti's full cause chain on bootstrap failure in the error message + Dart receives, without repeating a cause already included as the exact + suffix of its parent's message. +* A null client handle no longer aborts the process: `tor_client_bootstrap` + returns `NullClient` and `tor_client_set_dormant` does nothing; both set + the last error message. +* Concurrent `Tor.start` calls with the same raw data directory and timeout + share one attempt and complete when Tor is ready, or fail together with the + same error. Other starts queue behind it and either reuse the running + instance or fail with a `StateError` for a different data directory. * Release the native client and proxy when bootstrap fails during - `Tor.start`, instead of leaving the proxy running. + `Tor.start`, instead of leaving the proxy running. Preserve the bootstrap + error and stack if cleanup also fails; print the secondary cleanup failure + and expose it through `lastCleanupError` and `lastCleanupStackTrace`. * `Tor.disable` now stops the proxy like `Tor.stop`; before, it only changed the reported status while traffic kept flowing. **API change:** it returns a `Future` that can throw on release failure. Await it or attach an @@ -22,6 +48,29 @@ * Add `tor_string_free` and use it to release Rust error messages after Dart copies them. * Strip NUL bytes from Rust error messages instead of panicking across FFI. +* `Tor.stop` now shuts down the client's runtime, releasing its threads and + its state and cache files, and completes once they are released. Shutdown + waits up to five seconds for blocking storage work; a write still running + after that may keep its file open briefly after the future completes. Before, + every start leaked a client that kept the cache open, so the data directory could + not be deleted on Windows. If the release itself fails, `stop` throws and + the next `start` or `stop` retries it before opening a new client. If proxy + release fails, retain its client too and retry the proxy before freeing it. +* `Tor.start` after an unawaited `Tor.stop` restarts Tor instead of returning + at once and letting the queued stop leave it off. Starts always queue behind + earlier calls; a start with a different data directory than the running + instance fails with a `StateError` and leaves it running. +* `Tor.status` stays `on` while releasing a running instance, including when + release fails and handles remain for retry. Failed startup cleanup stays + `starting`; the next `start` or `stop` retries teardown before creating a new + client. Await a successful `stop` before deleting the data directory. + Status tracks lifecycle transitions and does not detect an unexpected SOCKS + accept-loop exit after startup. `Tor.port` throws as soon as the proxy has stopped, + with the message "Tor proxy is not running" instead of "Tor proxy port is + unexpectedly null"; the type is still `Exception`. +* `tor_proxy_stop` waits for the SOCKS accept loop to exit before returning, + so the listeners and its reference to the client are released, and Dart + runs it on a worker isolate. * When the OS assigns the SOCKS port and its IPv6 loopback twin is held by another process, try up to three more ports, then fail to start with an error saying the twin stayed occupied. Serving IPv4 alone there would let @@ -32,6 +81,16 @@ * Start on Windows hosts with IPv6 disabled. Winsock reports the missing address family as `WSAEAFNOSUPPORT`, which was not recognized, so `Tor.start` failed instead of serving IPv4 loopback. +* Reject bootstrap timeouts under one millisecond before starting the native + client. +* `Tor.start` asynchronously creates the data directory and resolves its + physical path inside the queued start before comparing it with the running + instance. Paths through symbolic links or trailing separators reuse that + instance, and on macOS `/var/...` and `/private/var/...` no longer conflict. + When starting from off, status reports `starting` as soon as `start` is + called, before the queued start runs. + A path that cannot be created fails with the `FileSystemException` and restores + status to `off` if no native handles remain. * Route `Tor.hello` through the injectable native layer so test instances can substitute the greeting without calling the real library. diff --git a/README.md b/README.md index b462beac..4fa304b0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,53 @@ Use `rustup`, not `homebrew`. The toolchain and cross-compilation targets pinne The Rust library is built and bundled by Flutter's native assets support (see `hook/build.dart`), so just `flutter run` or build as usual. Requires Flutter 3.47.2 or later; Android builds need NDK 27 or later. +### Starting and stopping Tor + +`Tor.start` completes after Tor has bootstrapped and the local SOCKS proxy is +ready. Bootstrap has a two-minute deadline by default; when it passes, +`Tor.start` throws `TorBootstrapTimeout`. Before 0.2.0 it waited +indefinitely. Callers can provide a different positive duration when needed: + +```dart +final directory = await getApplicationSupportDirectory(); +await Tor.instance.start( + torDataDirPath: directory.path, + bootstrapTimeout: const Duration(minutes: 3), +); +``` + +Starts and stops run one at a time in the order they were called, so +`stop()` followed by `start()` restarts Tor even when neither is awaited. +Concurrent starts with the same raw data directory and timeout share one +attempt. Other starts queue behind it and either reuse the running instance +or fail with a `StateError` for a different data directory, leaving it running. +The queued start creates and resolves the directory asynchronously before +comparing its physical path with the running instance. `Tor.stop()` and +`Tor.disable()` cancel an in-progress bootstrap, release the native client, and +complete after shutdown finishes. `Tor.status` stays `on` while releasing a +running instance, including when shutdown throws and resources remain for retry. +A failed start whose cleanup fails stays `starting`. The next `start()` or +`stop()` retries teardown before opening another client. Await a successful +`stop()` before deleting the data directory. Status tracks lifecycle +transitions; it does not detect an unexpected SOCKS accept-loop exit after +startup. +Native shutdown waits up to five seconds for blocking storage work; a longer +write may hold its file open briefly after the future completes. + +`disable()` returns the same throwing `Future` as `stop()`. Await it +where possible. A synchronous Flutter `dispose()` must handle its error +explicitly; `unawaited` alone does not catch failures: + +```dart +unawaited(Tor.instance.disable().catchError((Object error, StackTrace stack) { + debugPrint('Tor shutdown failed; retry stop before deleting its data: $error'); +})); +``` + +If startup and its cleanup both fail, the original startup exception and stack +are preserved. The cleanup failure is printed with its stack and remains +available as `lastCleanupError` and `lastCleanupStackTrace` until the next native start attempt. + ### Prebuilt libraries Copy the manifest URL and SHA-256 from a native release into your application's diff --git a/ffigen.yaml b/ffigen.yaml index 10f45ae8..2c0ac03a 100644 --- a/ffigen.yaml +++ b/ffigen.yaml @@ -19,3 +19,9 @@ preamble: | comments: style: any length: full + +# Keep Dart names stable while keeping C enumerators out of the global namespace. +enums: + member-rename: + TorBootstrapStatus: + 'TorBootstrapStatus_(.*)': '$1' diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index 74dbd0a0..3727fecf 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -18,6 +18,27 @@ class CouldntBootstrapDirectory implements Exception { CouldntBootstrapDirectory({this.rustError}); } +class TorStartCancelled implements Exception { + const TorStartCancelled(); + + @override + String toString() => 'Tor start was cancelled'; +} + +/// Bootstrap did not finish within the deadline passed to [Tor.start]. +class TorBootstrapTimeout implements Exception { + const TorBootstrapTimeout(this.timeout, this.message); + + /// The deadline that passed. + final Duration timeout; + + /// The native error message. + final String message; + + @override + String toString() => 'TorBootstrapTimeout: $message'; +} + enum TorStatus { on, starting, off } /// Native handles produced by a successful [TorNative.start]. @@ -70,17 +91,64 @@ class TorNative { } } - /// Bootstrap [client]. Throws the Rust error on failure. - void bootstrap(Pointer client) { - if (!bindings.tor_client_bootstrap(client)) { - Tor.throwRustException(); + /// Bootstrap [client] in a worker isolate. Throws [TorStartCancelled] on + /// cancellation, [TorBootstrapTimeout] when [timeout] elapses, and the Rust + /// error on failure. + Future bootstrap(Pointer client, Duration timeout) async { + final timeoutMillis = timeout.inMilliseconds; + await Isolate.run(() => _bootstrapInIsolate(client.address, timeoutMillis)); + } + + static void _bootstrapInIsolate(int client, int timeoutMillis) { + final code = bindings.tor_client_bootstrap( + Pointer.fromAddress(client), + timeoutMillis, + ); + switch (bindings.TorBootstrapStatus.fromValue(code)) { + case bindings.TorBootstrapStatus.Ok: + return; + case bindings.TorBootstrapStatus.Cancelled: + Tor._takeRustError(); + throw const TorStartCancelled(); + case bindings.TorBootstrapStatus.TimedOut: + throw TorBootstrapTimeout( + Duration(milliseconds: timeoutMillis), + Tor._takeRustError(), + ); + case bindings.TorBootstrapStatus.Failed: + Tor.throwRustException(); + case bindings.TorBootstrapStatus.NullClient: + throw ArgumentError(Tor._takeRustError()); } } + /// Interrupt an in-progress [bootstrap]. + void cancelBootstrap(Pointer client) => + bindings.tor_client_cancel_bootstrap(client); + void setDormant(Pointer client, bool softMode) => bindings.tor_client_set_dormant(client, softMode); - void stopProxy(Pointer proxy) => bindings.tor_proxy_stop(proxy); + void startProxy(Pointer proxy) { + if (!bindings.tor_proxy_start(proxy)) { + Tor.throwRustException(); + } + } + + /// Stop [proxy] in a worker isolate. The native call waits for the accept + /// loop to exit, so its listeners and client reference are released when + /// this completes. + Future stopProxy(Pointer proxy) async { + if (proxy == nullptr) { + return; + } + + final address = proxy.address; + await Isolate.run(() => _stopProxyInIsolate(address)); + } + + static void _stopProxyInIsolate(int proxy) => + bindings.tor_proxy_stop(Pointer.fromAddress(proxy)); /// Release [client] in a worker isolate, since tearing down Tor can block /// for a few seconds. @@ -114,7 +182,9 @@ class Tor { /// Singleton instance of the Tor class. static final Tor instance = Tor._(); - /// Status of the tor proxy service + /// The lifecycle state of the Tor service. + /// + /// This does not monitor SOCKS accept-loop liveness after startup. TorStatus get status => _status; TorStatus _status = TorStatus.off; @@ -127,7 +197,7 @@ class Tor { /// This is the port that should be used for all requests. int get port { if (_proxyPort == null || _status == TorStatus.off) { - throw Exception("Tor proxy port is unexpectedly null"); + throw Exception('Tor proxy is not running'); } return _proxyPort!; } @@ -144,100 +214,216 @@ class Tor { /// /// Returns a Future that completes when the Tor service has started. /// - /// [start] and [stop] run one at a time, in the order they were called. - /// Calling [start] while an earlier start is still pending returns that - /// start's future, so every caller sees the same success or failure. - Future start({required String torDataDirPath}) { - final pending = _pendingStart; - if (pending != null) { - return pending; + /// [start] and [stop] run one at a time, in the order they were called, so + /// a start issued after [stop] restarts Tor once that stop has finished. + /// Concurrent starts with the same raw data directory and timeout share + /// one attempt and see the same success or failure. Other starts queue + /// behind it and reuse the running instance, or fail with a [StateError] + /// for a different data directory, leaving that instance running. + /// + /// [torDataDirPath] is created and resolved asynchronously inside the queued + /// start. Physical paths are compared with the running instance, so symbolic + /// links and trailing separators can reuse it. A path that cannot be created + /// or resolved fails with the file system error. + Future start({ + required String torDataDirPath, + Duration bootstrapTimeout = const Duration(minutes: 2), + }) { + // The native deadline has millisecond resolution. + if (bootstrapTimeout.inMilliseconds <= 0) { + return Future.error( + ArgumentError.value( + bootstrapTimeout, + 'bootstrapTimeout', + 'must be at least one millisecond', + ), + ); } - late final Future attempt; - attempt = _enqueue(() => _start(torDataDirPath)).whenComplete(() { - if (identical(_pendingStart, attempt)) { - _pendingStart = null; + for (final pending in _pendingStarts) { + if (pending.dataDirPath == torDataDirPath && + pending.bootstrapTimeout == bootstrapTimeout) { + return pending.future; } + } + + // Always queue, even when Tor looks running: a stop may be queued ahead of + // this start, in which case the start must restart Tor rather than return + // early and leave it off. + final operation = _StartOperation(torDataDirPath, bootstrapTimeout); + // _start runs later, so report starting now. A start cancelled before it + // runs is reset to off by the stop that cancelled it. + if (_status == TorStatus.off) { + _status = TorStatus.starting; + } + operation.future = _enqueue(() => _start(operation)).whenComplete(() { + _pendingStarts.remove(operation); }); - return _pendingStart = attempt; + _pendingStarts.add(operation); + return operation.future; } - /// The start that later [start] calls join, until a [stop] is requested. - Future? _pendingStart; + /// Queued starts that later calls can join until a [stop] is requested. + final _pendingStarts = <_StartOperation>[]; - Future _start(String torDataDirPath) async { - if (_status == TorStatus.on) { - return; + String? _activeDataDirPath; + + // Retained handles need teardown, even if status still reports on. + bool _releasePending = false; + + /// The last cleanup failure observed while preserving an earlier start + /// failure. This is cleared at the beginning of each start attempt. + Object? get lastCleanupError => _lastCleanupError; + + /// Stack trace associated with [lastCleanupError]. + StackTrace? get lastCleanupStackTrace => _lastCleanupStackTrace; + + Object? _lastCleanupError; + StackTrace? _lastCleanupStackTrace; + + Future _start(_StartOperation operation) async { + if (operation.cancelRequested) { + // A stop was requested after this start was queued and runs next. + throw const TorStartCancelled(); + } + + final wasRunning = _status == TorStatus.on; + if (!wasRunning) { + _status = TorStatus.starting; + } + final String dataDirPath; + try { + final directory = await Directory(operation.dataDirPath).absolute + .create(recursive: true); + dataDirPath = await directory.resolveSymbolicLinks(); + } catch (_) { + if (!wasRunning && !_releasePending) { + _status = TorStatus.off; + } + rethrow; } + if (_status == TorStatus.on && !_releasePending) { + if (_activeDataDirPath == dataDirPath) { + return; + } + throw StateError( + 'Tor is already running with a different data directory.', + ); + } + + _lastCleanupError = null; + _lastCleanupStackTrace = null; try { + if (_proxyPtr != nullptr || _clientPtr != nullptr) { + await _stop(); + } _status = TorStatus.starting; // Set the state and cache directories. - final stateDir = await Directory('$torDataDirPath/tor_state').create(); - final cacheDir = await Directory('$torDataDirPath/tor_cache').create(); + final stateDir = await Directory('$dataDirPath/tor_state').create(); + final cacheDir = await Directory('$dataDirPath/tor_cache').create(); + + if (operation.cancelRequested) { + throw const TorStartCancelled(); + } // Let the OS allocate the port while Rust binds the real listeners. This // avoids a probe-then-bind race and keeps the published port reserved. final tor = await _native.start(0, stateDir.path, cacheDir.path); - // Set the client pointer and started flag. + // Retain the native handles before bootstrap so stop can cancel it. _clientPtr = tor.client; _proxyPtr = tor.proxy; + operation.client = tor.client; + + if (operation.cancelRequested) { + throw const TorStartCancelled(); + } // Bootstrap the Tor service. - _bootstrap(); + await _native.bootstrap(tor.client, operation.bootstrapTimeout); + + if (operation.cancelRequested) { + throw const TorStartCancelled(); + } + + _native.startProxy(tor.proxy); // Set the proxy port and change status. _proxyPort = tor.port; + _activeDataDirPath = dataDirPath; _status = TorStatus.on; - } catch (_) { + } catch (error, stackTrace) { // Release anything the native start produced before bootstrap failed; // the next start would otherwise overwrite these handles. - await _stop(); - rethrow; + operation.client = nullptr; + try { + await _stop(); + } catch (cleanupError, cleanupStackTrace) { + _lastCleanupError = cleanupError; + _lastCleanupStackTrace = cleanupStackTrace; + try { + // Also expose secondary failures in normal application logs. A + // custom zone's print handler must not mask the bootstrap error. + // ignore: avoid_print + print( + 'Tor cleanup failed after start failed: $cleanupError\n$cleanupStackTrace', + ); + } catch (_) { + // Preserve the original start exception even if logging fails. + } + } + Error.throwWithStackTrace(error, stackTrace); } } - /// Bootstrap the Tor service. - /// - /// This will bootstrap the Tor service and establish a Tor circuit. This - /// function should only be called after the Tor service has been started. - /// - /// This function will block until the Tor service has bootstrapped. - /// - /// Throws an exception if the Tor service fails to bootstrap. - /// - /// Returns void. - void _bootstrap() => _native.bootstrap(_clientPtr); - /// Prevent traffic flowing through the proxy. /// /// This is the same as [stop]: the SOCKS proxy is shut down and the native - /// client released. + /// client released. Await the returned future and handle release failures, + /// including when calling from a synchronous dispose method. Future disable() => stop(); /// Stop the proxy and release the native client handle. /// - /// If a [start] is still in progress, this waits for it to finish and then - /// tears down what it started, so no Tor instance outlives the call. + /// Cancels an in-progress [start], which throws [TorStartCancelled], then + /// releases its handles. A running instance stays [TorStatus.on] until its + /// native resources have been released, including when release fails and + /// needs retry. A failed start stays [TorStatus.starting] if cleanup fails. + /// Await a successful [stop] before deleting the data directory. Future stop() { - // A start requested after this stop must not join the earlier attempt. - _pendingStart = null; + for (final pending in _pendingStarts) { + pending.cancelRequested = true; + if (pending.client != nullptr) { + _native.cancelBootstrap(pending.client); + } + } + _pendingStarts.clear(); return _enqueue(_stop); } Future _stop() async { - _native.stopProxy(_proxyPtr); - _proxyPtr = nullptr; - - final client = _clientPtr; - _clientPtr = nullptr; - + // Retry retained handles on the next start or stop. Never shut down the + // client's runtime while its proxy may still be accepting connections. + _releasePending = true; _proxyPort = null; - _status = TorStatus.off; - - await _native.freeClient(client); + _activeDataDirPath = null; + try { + if (_proxyPtr != nullptr) { + await _native.stopProxy(_proxyPtr); + _proxyPtr = nullptr; + } + if (_clientPtr != nullptr) { + await _native.freeClient(_clientPtr); + _clientPtr = nullptr; + } + } finally { + _releasePending = _proxyPtr != nullptr || _clientPtr != nullptr; + if (!_releasePending) { + _status = TorStatus.off; + } + } } /// Tail of the queue that runs [start] and [stop] one at a time. @@ -251,7 +437,7 @@ class Tor { } Future setClientDormant(bool dormant) async { - if (_clientPtr == nullptr || status == TorStatus.off) { + if (_releasePending || _clientPtr == nullptr || status == TorStatus.off) { throw ClientNotActive(); } @@ -267,16 +453,18 @@ class Tor { // // if (enabled && started && circuitEstablished) {} // } - static void throwRustException() { + static Never throwRustException() { + throw _getRustException(_takeRustError()); + } + + /// The calling thread's last Rust error message, which this clears. + static String _takeRustError() { final message = bindings.tor_last_error_message(); - final String rustError; try { - rustError = message.cast().toDartString(); + return message.cast().toDartString(); } finally { bindings.tor_string_free(message); } - - throw _getRustException(rustError); } static Exception _getRustException(String rustError) { @@ -287,9 +475,17 @@ class Tor { } } - void hello() { - _native.hello(); - } + void hello() => _native.hello(); } class ClientNotActive implements Exception {} + +class _StartOperation { + _StartOperation(this.dataDirPath, this.bootstrapTimeout); + + final String dataDirPath; + final Duration bootstrapTimeout; + late final Future future; + bool cancelRequested = false; + Pointer client = nullptr; +} diff --git a/lib/tor_ffi_plugin_bindings_generated.dart b/lib/tor_ffi_plugin_bindings_generated.dart index 1141ddee..d22a890a 100644 --- a/lib/tor_ffi_plugin_bindings_generated.dart +++ b/lib/tor_ffi_plugin_bindings_generated.dart @@ -13,30 +13,51 @@ library; import 'dart:ffi' as ffi; -/// Ensure the client has bootstrapped. +/// Bootstrap the client, returning on cancellation or after `timeout_millis`. +/// A null `client` returns `NullClient`. /// /// # Safety -/// `client` must be a live, non-null handle returned by [`tor_start`]. +/// `client` must be null or a live handle returned by [`tor_start`]. /// It must not be freed while this call is in progress. -@ffi.Native)>() -external bool tor_client_bootstrap(ffi.Pointer client); +@ffi.Native, ffi.Uint64)>() +external int tor_client_bootstrap( + ffi.Pointer client, + int timeout_millis, +); + +/// Ask an in-progress bootstrap to return promptly. Null is accepted. +/// +/// Cancellation is sticky: once this is called, every later +/// [`tor_client_bootstrap`] on the same handle returns `Cancelled` at once. +/// To bootstrap again, free the handle with [`tor_client_free`] and create a +/// new one with [`tor_start`]. +/// +/// # Safety +/// `client` must be null or a live handle returned by [`tor_start`]. It may be +/// called concurrently with [`tor_client_bootstrap`], but not with or after +/// [`tor_client_free`]. +@ffi.Native)>() +external void tor_client_cancel_bootstrap(ffi.Pointer client); /// Release the client handle returned by [`tor_start`]. /// -/// The handle must not be used after this call. The proxy task keeps its own -/// reference to the underlying client, so stopping the proxy and freeing the -/// handle can happen in either order. +/// This shuts down the client's background tasks and closes its state and +/// cache files, which can block for a few seconds. Stop the proxy first: it +/// keeps its own reference to the client, but cannot serve connections once +/// the client is freed. /// /// # Safety /// `client` must be null or a live handle returned by [`tor_start`]. /// No other call may use the handle concurrently with or after this call. +/// It must not be called from within an async runtime. @ffi.Native)>() external void tor_client_free(ffi.Pointer client); -/// Change the client's dormant mode. +/// Change the client's dormant mode. A null `client` is ignored and sets the +/// message returned by [`tor_last_error_message`]. /// /// # Safety -/// `client` must be a live, non-null handle returned by [`tor_start`]. +/// `client` must be null or a live handle returned by [`tor_start`]. /// It must not be freed while this call is in progress. @ffi.Native, ffi.Bool)>() external void tor_client_set_dormant( @@ -66,11 +87,26 @@ external void tor_hello(); @ffi.Native Function()>() external ffi.Pointer tor_last_error_message(); -/// Stop the proxy and release its handle. Null is accepted. +/// Start accepting SOCKS connections on the listeners reserved by [`tor_start`]. +/// Repeated calls are harmless. +/// +/// # Safety +/// `proxy` must be a live, non-null proxy handle returned by [`tor_start`]. It +/// must not be used concurrently or after [`tor_proxy_stop`]. +@ffi.Native)>() +external bool tor_proxy_start(ffi.Pointer proxy); + +/// Stop the proxy, or release its reserved listeners if it was not started. +/// Null is accepted. +/// +/// This waits for the accept loop to exit, so the listeners and the loop's +/// reference to the client are released by the time it returns. Call it +/// before [`tor_client_free`]. /// /// # Safety /// `proxy` must be null or a live proxy handle returned by [`tor_start`]. /// A non-null handle must be passed to this function only once. +/// It must not be called from within an async runtime. @ffi.Native)>() external void tor_proxy_stop(ffi.Pointer proxy); @@ -81,7 +117,7 @@ external void tor_proxy_stop(ffi.Pointer proxy); @ffi.Native() external int tor_set_nofile_limit(int limit); -/// Start a bootstrapped Tor client and a localhost SOCKS proxy. +/// Create a Tor client and a localhost SOCKS proxy. /// /// # Safety /// `state_dir` and `cache_dir` must point to valid, NUL-terminated strings @@ -122,3 +158,39 @@ final class Tor extends ffi.Struct { ..ref.proxy = proxy ..ref.port = port; } + +/// The outcome of [`tor_client_bootstrap`]. +/// +/// Every code other than `Ok` also sets the message returned by +/// [`tor_last_error_message`]. +enum TorBootstrapStatus { + /// The client bootstrapped. + Ok(0), + + /// [`tor_client_cancel_bootstrap`] was called on the handle. + Cancelled(1), + + /// The deadline passed before bootstrap finished. + TimedOut(2), + + /// Arti reported an error. + Failed(3), + + /// The client handle was null. + NullClient(4); + + final int value; + const TorBootstrapStatus(this.value); + + static TorBootstrapStatus fromValue(int value) => switch (value) { + 0 => Ok, + 1 => Cancelled, + 2 => TimedOut, + 3 => Failed, + 4 => NullClient, + _ => throw ArgumentError('Unknown value for TorBootstrapStatus: $value'), + }; +} + +typedef TorBootstrapStatus$1 = ffi.Int32; +typedef DartTorBootstrapStatus = int; diff --git a/rust/build.rs b/rust/build.rs index f9dded50..15376643 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -use cbindgen::{Config, Language}; +use cbindgen::{Config, EnumConfig, Language}; use glob::glob; use std::env; use std::path::PathBuf; @@ -18,9 +18,16 @@ fn main() { .join(format!("{}.h", package_name)) .display() .to_string(); + println!("cargo:rerun-if-env-changed=CARGO_TARGET_DIR"); + println!("cargo:rustc-env=TOR_FFI_PLUGIN_HEADER={}", output_file); let config = Config { language: Language::C, + cpp_compat: true, + enumeration: EnumConfig { + prefix_with_name: true, + ..Default::default() + }, ..Default::default() }; diff --git a/rust/src/error.rs b/rust/src/error.rs index 7182c3ed..b1c1c3fc 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -26,7 +26,18 @@ pub unsafe extern "C" fn tor_last_error_message() -> *const c_char { // CString::new rejects interior NULs, and a panic here would abort the // process at the FFI boundary. - let error_message = last_error.to_string().replace('\0', ""); + let mut error_message = last_error.to_string().replace('\0', ""); + let mut cause = last_error.source(); + while let Some(error) = cause { + let message = error.to_string().replace('\0', ""); + // Some Display implementations already include their source. Only + // omit an exact, colon-delimited suffix, never a substring match. + if error_message != message && !error_message.ends_with(&format!(": {message}")) { + error_message.push_str(": "); + error_message.push_str(&message); + } + cause = error.source(); + } CString::new(error_message).unwrap().into_raw() } @@ -79,3 +90,89 @@ pub fn update_last_error(err: E) { pub fn take_last_error() -> Option> { LAST_ERROR.with(|prev| prev.borrow_mut().take()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CStr; + use std::{fmt, io}; + + #[derive(Debug)] + struct BootstrapError(io::Error); + + impl fmt::Display for BootstrapError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "bootstrap failed") + } + } + + impl Error for BootstrapError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(&self.0) + } + } + + fn take_message() -> String { + unsafe { + let message = tor_last_error_message(); + let text = CStr::from_ptr(message).to_string_lossy().into_owned(); + tor_string_free(message.cast_mut()); + text + } + } + + #[test] + fn error_message_includes_causes_in_order() { + update_last_error(BootstrapError(io::Error::other("storage unavailable"))); + assert_eq!(take_message(), "bootstrap failed: storage unavailable"); + + update_last_error(io::Error::other("standalone error")); + assert_eq!(take_message(), "standalone error"); + } + #[derive(Debug)] + struct Context { + message: &'static str, + source: Box, + display_source: bool, + } + + impl fmt::Display for Context { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message)?; + if self.display_source { + write!(f, ": {}", self.source)?; + } + Ok(()) + } + } + + impl Error for Context { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(self.source.as_ref()) + } + } + + #[test] + fn error_message_does_not_repeat_embedded_causes() { + for outer_embeds in [false, true] { + update_last_error(Context { + message: "top", + source: Box::new(Context { + message: "cause", + source: Box::new(io::Error::other("root")), + display_source: true, + }), + display_source: outer_embeds, + }); + assert_eq!(take_message(), "top: cause: root"); + } + + // Shared words are not evidence that a cause is already embedded. + update_last_error(Context { + message: "root failed during bootstrap", + source: Box::new(io::Error::other("root failed")), + display_source: false, + }); + assert_eq!(take_message(), "root failed during bootstrap: root failed"); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 35531306..52dc5bc6 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -8,10 +8,13 @@ use arti_client::config::CfgPath; use arti_client::{DormantMode, TorClient, TorClientConfig}; use lazy_static::lazy_static; use std::ffi::{c_char, c_void, CStr}; +use std::future::Future; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; +use std::time::{Duration, Instant}; use std::{io, ptr}; use tokio::runtime::{Builder, Runtime}; +use tokio::sync::watch; use tokio::task::JoinHandle; use tor_rtcompat::tokio::TokioNativeTlsRuntime; use tor_rtcompat::{NetStreamListener, NetStreamProvider, TcpListenOptions, ToplevelBlockOn}; @@ -30,6 +33,9 @@ lazy_static! { static ref RUNTIME: io::Result = Builder::new_multi_thread().enable_all().build(); } +/// How long freeing a client waits for its background tasks to finish. +const CLIENT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + /// How many more OS-assigned ports to try when another process holds the /// IPv6 twin of the assigned port, before failing. const IPV6_BIND_RETRIES: u32 = 3; @@ -41,7 +47,91 @@ pub struct Tor { port: u16, } -/// Start a bootstrapped Tor client and a localhost SOCKS proxy. +/// The tokio runtime a single client and its background tasks run on. +/// +/// Arti's background tasks hold the client's runtime, so a runtime the client +/// owns is never dropped and the tasks keep the directory cache and state +/// files open after the client is freed. Each client instead runs on a +/// runtime owned here and shut down when this is dropped. +struct ClientRuntime(Option); + +impl ClientRuntime { + fn new() -> io::Result { + Ok(Self(Some( + Builder::new_multi_thread().enable_all().build()?, + ))) + } + + /// An arti runtime that spawns onto this runtime without owning it. + fn arti(&self) -> io::Result { + let _guard = self.0.as_ref().expect("runtime is live").enter(); + TokioNativeTlsRuntime::current() + } +} + +impl Drop for ClientRuntime { + fn drop(&mut self) { + if let Some(runtime) = self.0.take() { + let started = Instant::now(); + runtime.shutdown_timeout(CLIENT_SHUTDOWN_TIMEOUT); + if started.elapsed() >= CLIENT_SHUTDOWN_TIMEOUT { + // Blocking tasks cannot be interrupted, so a slow storage + // write may still hold its file open after this returns. + log::warn!( + "Tor client runtime did not shut down within {:?}; files may remain open", + CLIENT_SHUTDOWN_TIMEOUT + ); + } + } + } +} + +/// What a client handle passed across FFI points to. +struct ClientHandle { + // Fields drop in order, so the client is released before its runtime + // shuts down. + client: Arc>, + cancel_bootstrap: watch::Sender, + _runtime: ClientRuntime, +} + +/// A reserved SOCKS listener that becomes active only after bootstrap. +enum ProxyHandle { + /// Bound but idle until `start`. + Reserved { + client: Arc>, + listeners: Vec, + }, + /// The accept loop is running. + Running(JoinHandle>), +} + +impl ProxyHandle { + fn new(client: Arc>, listeners: Vec) -> Self { + Self::Reserved { client, listeners } + } + + fn start(&mut self, rt: &Runtime) { + if let Self::Reserved { client, listeners } = self { + let task = start_proxy(rt, Arc::clone(client), std::mem::take(listeners)); + *self = Self::Running(task); + } + } +} + +fn into_client_handle( + client: Arc>, + runtime: ClientRuntime, +) -> *mut c_void { + let (cancel_bootstrap, _) = watch::channel(false); + Box::into_raw(Box::new(ClientHandle { + client, + cancel_bootstrap, + _runtime: runtime, + })) as *mut c_void +} + +/// Create a Tor client and a localhost SOCKS proxy. /// /// # Safety /// `state_dir` and `cache_dir` must point to valid, NUL-terminated strings @@ -62,7 +152,8 @@ pub unsafe extern "C" fn tor_start( let state_dir = unwrap_or_return!(CStr::from_ptr(state_dir).to_str(), err_ret); let cache_dir = unwrap_or_return!(CStr::from_ptr(cache_dir).to_str(), err_ret); - let runtime = unwrap_or_return!(TokioNativeTlsRuntime::create(), err_ret); + let client_runtime = unwrap_or_return!(ClientRuntime::new(), err_ret); + let runtime = unwrap_or_return!(client_runtime.arti(), err_ret); // Reserve the port before initializing storage or bootstrapping. On any // later failure these listeners are dropped and the port is released. @@ -83,17 +174,15 @@ pub unsafe extern "C" fn tor_start( runtime.block_on(async { TorClient::with_runtime(runtime.clone()) .config(cfg) - .create_bootstrapped() - .await + .create_unbootstrapped() }), err_ret ); - let proxy_handle_box = Box::new(start_proxy(rt, Arc::clone(&client), listeners)); - let client_box = Box::new(client); + let proxy_handle_box = Box::new(ProxyHandle::new(Arc::clone(&client), listeners)); Tor { - client: Box::into_raw(client_box) as *mut c_void, + client: into_client_handle(client, client_runtime), proxy: Box::into_raw(proxy_handle_box) as *mut c_void, port: socks_port, } @@ -102,76 +191,244 @@ pub unsafe extern "C" fn tor_start( /// Borrow the client handle produced by [`tor_start`] without taking ownership. /// /// Ownership stays with the caller until [`tor_client_free`] is called, so the -/// handle can be passed to any number of FFI calls in between. -unsafe fn client_ref<'a>(client: *mut c_void) -> &'a Arc> { - assert!(!client.is_null()); - &*(client as *const Arc>) +/// handle can be passed to any number of FFI calls in between. Null yields +/// `None` so callers can report it instead of aborting the process. +unsafe fn client_handle_ref<'a>(client: *mut c_void) -> Option<&'a ClientHandle> { + (client as *const ClientHandle).as_ref() +} + +fn null_client_error() -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, "client handle is null") } -/// Ensure the client has bootstrapped. +/// The outcome of [`tor_client_bootstrap`]. +/// +/// Every code other than `Ok` also sets the message returned by +/// [`tor_last_error_message`]. +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TorBootstrapStatus { + /// The client bootstrapped. + Ok = 0, + /// [`tor_client_cancel_bootstrap`] was called on the handle. + Cancelled = 1, + /// The deadline passed before bootstrap finished. + TimedOut = 2, + /// Arti reported an error. + Failed = 3, + /// The client handle was null. + NullClient = 4, +} + +/// Bootstrap the client, returning on cancellation or after `timeout_millis`. +/// A null `client` returns `NullClient`. /// /// # Safety -/// `client` must be a live, non-null handle returned by [`tor_start`]. +/// `client` must be null or a live handle returned by [`tor_start`]. /// It must not be freed while this call is in progress. #[no_mangle] -pub unsafe extern "C" fn tor_client_bootstrap(client: *mut c_void) -> bool { - let client = client_ref(client); +pub unsafe extern "C" fn tor_client_bootstrap( + client: *mut c_void, + timeout_millis: u64, +) -> TorBootstrapStatus { + let Some(handle) = client_handle_ref(client) else { + update_last_error(null_client_error()); + return TorBootstrapStatus::NullClient; + }; + let client = &handle.client; + let cancelled = handle.cancel_bootstrap.subscribe(); - unwrap_or_return!(client.runtime().block_on(client.bootstrap()), false); - true + let result = client.runtime().block_on(controlled_bootstrap( + client.bootstrap(), + cancelled, + Duration::from_millis(timeout_millis), + )); + + match result { + Ok(()) => TorBootstrapStatus::Ok, + Err(error) => { + let status = error.status(); + update_last_error(error); + status + } + } +} + +/// Why [`controlled_bootstrap`] returned without bootstrapping. +#[derive(Debug)] +enum BootstrapError { + Cancelled, + TimedOut(Duration), + Failed(io::Error), +} + +impl BootstrapError { + fn status(&self) -> TorBootstrapStatus { + match self { + BootstrapError::Cancelled => TorBootstrapStatus::Cancelled, + BootstrapError::TimedOut(_) => TorBootstrapStatus::TimedOut, + BootstrapError::Failed(_) => TorBootstrapStatus::Failed, + } + } +} + +impl std::fmt::Display for BootstrapError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BootstrapError::Cancelled => write!(f, "Tor bootstrap cancelled"), + BootstrapError::TimedOut(timeout) => write!( + f, + "Tor bootstrap timed out after {} ms", + timeout.as_millis() + ), + BootstrapError::Failed(error) => error.fmt(f), + } + } +} + +impl std::error::Error for BootstrapError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + BootstrapError::Failed(error) => error.source(), + _ => None, + } + } } -/// Change the client's dormant mode. +async fn controlled_bootstrap( + bootstrap: F, + mut cancelled: watch::Receiver, + timeout: Duration, +) -> Result<(), BootstrapError> +where + F: Future>, + E: std::error::Error + Send + Sync + 'static, +{ + if *cancelled.borrow() { + return Err(BootstrapError::Cancelled); + } + + tokio::select! { + biased; + result = cancelled.changed() => { + result.map_err(|_| { + BootstrapError::Failed(io::Error::other("Tor bootstrap cancellation channel closed")) + })?; + Err(BootstrapError::Cancelled) + } + // Keep the error itself, not its message, so its source chain reaches + // update_last_error. + result = bootstrap => result.map_err(|error| BootstrapError::Failed(io::Error::other(error))), + _ = tokio::time::sleep(timeout) => Err(BootstrapError::TimedOut(timeout)), + } +} + +/// Ask an in-progress bootstrap to return promptly. Null is accepted. +/// +/// Cancellation is sticky: once this is called, every later +/// [`tor_client_bootstrap`] on the same handle returns `Cancelled` at once. +/// To bootstrap again, free the handle with [`tor_client_free`] and create a +/// new one with [`tor_start`]. +/// +/// # Safety +/// `client` must be null or a live handle returned by [`tor_start`]. It may be +/// called concurrently with [`tor_client_bootstrap`], but not with or after +/// [`tor_client_free`]. +#[no_mangle] +pub unsafe extern "C" fn tor_client_cancel_bootstrap(client: *mut c_void) { + if let Some(handle) = client_handle_ref(client) { + handle.cancel_bootstrap.send_replace(true); + } +} + +/// Change the client's dormant mode. A null `client` is ignored and sets the +/// message returned by [`tor_last_error_message`]. /// /// # Safety -/// `client` must be a live, non-null handle returned by [`tor_start`]. +/// `client` must be null or a live handle returned by [`tor_start`]. /// It must not be freed while this call is in progress. #[no_mangle] pub unsafe extern "C" fn tor_client_set_dormant(client: *mut c_void, soft_mode: bool) { - let client = client_ref(client); + let Some(handle) = client_handle_ref(client) else { + update_last_error(null_client_error()); + return; + }; let dormant_mode = if soft_mode { DormantMode::Soft } else { DormantMode::Normal }; - client.set_dormant(dormant_mode); + handle.client.set_dormant(dormant_mode); } /// Release the client handle returned by [`tor_start`]. /// -/// The handle must not be used after this call. The proxy task keeps its own -/// reference to the underlying client, so stopping the proxy and freeing the -/// handle can happen in either order. +/// This shuts down the client's background tasks and closes its state and +/// cache files, which can block for a few seconds. Stop the proxy first: it +/// keeps its own reference to the client, but cannot serve connections once +/// the client is freed. /// /// # Safety /// `client` must be null or a live handle returned by [`tor_start`]. /// No other call may use the handle concurrently with or after this call. +/// It must not be called from within an async runtime. #[no_mangle] pub unsafe extern "C" fn tor_client_free(client: *mut c_void) { if client.is_null() { return; } - drop(Box::from_raw( - client as *mut Arc>, - )); + drop(Box::from_raw(client as *mut ClientHandle)); } -/// Stop the proxy and release its handle. Null is accepted. +/// Start accepting SOCKS connections on the listeners reserved by [`tor_start`]. +/// Repeated calls are harmless. +/// +/// # Safety +/// `proxy` must be a live, non-null proxy handle returned by [`tor_start`]. It +/// must not be used concurrently or after [`tor_proxy_stop`]. +#[no_mangle] +pub unsafe extern "C" fn tor_proxy_start(proxy: *mut c_void) -> bool { + if proxy.is_null() { + update_last_error(io::Error::new( + io::ErrorKind::InvalidInput, + "proxy handle is null", + )); + return false; + } + let rt = unwrap_or_return!(proxy_runtime(), false); + let proxy = &mut *(proxy as *mut ProxyHandle); + proxy.start(rt); + true +} + +/// Stop the proxy, or release its reserved listeners if it was not started. +/// Null is accepted. +/// +/// This waits for the accept loop to exit, so the listeners and the loop's +/// reference to the client are released by the time it returns. Call it +/// before [`tor_client_free`]. /// /// # Safety /// `proxy` must be null or a live proxy handle returned by [`tor_start`]. /// A non-null handle must be passed to this function only once. +/// It must not be called from within an async runtime. #[no_mangle] pub unsafe extern "C" fn tor_proxy_stop(proxy: *mut c_void) { if proxy.is_null() { return; } - let proxy = Box::from_raw(proxy as *mut JoinHandle>); - - proxy.abort(); + if let ProxyHandle::Running(task) = *Box::from_raw(proxy as *mut ProxyHandle) { + task.abort(); + // Aborting only requests cancellation; the task's future, which owns + // the listeners and an `Arc` of the client, is dropped by a runtime + // worker. Join it so teardown is complete when this returns. + if let Ok(rt) = proxy_runtime() { + let _ = rt.block_on(task); + } + } } type Listener = ::Listener; @@ -188,7 +445,6 @@ fn start_proxy( client: Arc>, listeners: Vec, ) -> JoinHandle> { - println!("Starting proxy!"); rt.spawn(run_proxy_with_listeners( client, listeners, @@ -326,7 +582,7 @@ mod tests { use arti_client::config::TorClientConfigBuilder; use std::ffi::CString; use std::net::{Ipv4Addr, TcpListener}; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; fn scratch_dir(label: &str) -> PathBuf { @@ -343,19 +599,47 @@ mod tests { dir } - /// Build a client the same way `tor_start` does, minus bootstrapping, - /// and hand it out as the opaque pointer Dart would receive. - fn unbootstrapped_client_handle() -> *mut c_void { - let runtime = TokioNativeTlsRuntime::create().unwrap(); - let cfg = - TorClientConfigBuilder::from_directories(scratch_dir("state"), scratch_dir("cache")) - .build() - .unwrap(); - let client = TorClient::with_runtime(runtime) + /// Build a client under `root` the same way `tor_start` does, minus + /// bootstrapping. + fn unbootstrapped_client( + root: &Path, + ) -> (Arc>, ClientRuntime) { + let runtime = ClientRuntime::new().unwrap(); + let cfg = TorClientConfigBuilder::from_directories(root.join("state"), root.join("cache")) + .build() + .unwrap(); + let client = TorClient::with_runtime(runtime.arti().unwrap()) .config(cfg) .create_unbootstrapped() .unwrap(); - Box::into_raw(Box::new(client)) as *mut c_void + (client, runtime) + } + + /// An unbootstrapped client as the opaque pointer Dart would receive. + fn unbootstrapped_client_handle() -> *mut c_void { + let (client, runtime) = unbootstrapped_client(&scratch_dir("client")); + into_client_handle(client, runtime) + } + + #[test] + fn freeing_the_client_closes_its_files() { + let root = scratch_dir("free"); + let (client, runtime) = unbootstrapped_client(&root); + // Leave a bootstrap running, as Tor.stop() does with a live client, so + // background tasks are using the directory cache. The result does not + // matter, so this works offline too. + let bootstrapping = Arc::clone(&client); + runtime + .0 + .as_ref() + .unwrap() + .spawn(async move { drop(bootstrapping.bootstrap().await) }); + std::thread::sleep(Duration::from_secs(1)); + unsafe { tor_client_free(into_client_handle(client, runtime)) }; + + // Windows refuses to delete files that are still open. Before the fix + // the client's background tasks kept the directory cache open forever. + std::fs::remove_dir_all(&root).unwrap(); } #[test] @@ -373,36 +657,97 @@ mod tests { } #[test] - fn client_handle_survives_a_failed_bootstrap() { - // Overwrite the directory cache after the client has opened it, so - // bootstrap fails at once instead of reaching for the network. The - // client holds a lock on dir.lock, which Windows enforces, so skip it. - let cache_dir = scratch_dir("cache"); - let cfg = TorClientConfigBuilder::from_directories(scratch_dir("state"), &cache_dir) - .build() - .unwrap(); - let client = TorClient::with_runtime(TokioNativeTlsRuntime::create().unwrap()) - .config(cfg) - .create_unbootstrapped() - .unwrap(); - for entry in std::fs::read_dir(&cache_dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_file() && path.file_name() != Some("dir.lock".as_ref()) { - std::fs::write(&path, [0x5a; 8192]).unwrap(); - } - } - let handle = Box::into_raw(Box::new(client)) as *mut c_void; + fn cancelled_bootstrap_returns_without_network_and_preserves_the_handle() { + let handle = unbootstrapped_client_handle(); - // Before the fix tor_client_bootstrap() freed the handle on every - // return, so the calls after it were a use after free and the final - // free a double free. unsafe { - assert!(!tor_client_bootstrap(handle)); + tor_client_cancel_bootstrap(handle); + assert_eq!( + tor_client_bootstrap(handle, 60_000), + TorBootstrapStatus::Cancelled + ); tor_client_set_dormant(handle, true); tor_client_set_dormant(handle, false); tor_client_free(handle); } - assert!(crate::error::take_last_error().is_some()); + let error = crate::error::take_last_error().unwrap().to_string(); + assert!(error.contains("cancelled"), "{error}"); + } + + #[test] + fn controlled_bootstrap_honors_its_deadline() { + let (_cancel, cancelled) = tokio::sync::watch::channel(false); + let result = proxy_runtime().unwrap().block_on(controlled_bootstrap( + std::future::pending::>(), + cancelled, + Duration::ZERO, + )); + + let error = result.unwrap_err(); + assert_eq!(error.status(), TorBootstrapStatus::TimedOut); + assert!(error.to_string().contains("timed out"), "{error}"); + } + + #[test] + fn controlled_bootstrap_keeps_the_error_chain() { + #[derive(Debug)] + struct Inner; + impl std::fmt::Display for Inner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("no consensus") + } + } + impl std::error::Error for Inner {} + + #[derive(Debug)] + struct Outer(Inner); + impl std::fmt::Display for Outer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("bootstrap failed") + } + } + impl std::error::Error for Outer { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } + } + + let (_cancel, cancelled) = tokio::sync::watch::channel(false); + let error = proxy_runtime() + .unwrap() + .block_on(controlled_bootstrap( + async { Err::<(), _>(Outer(Inner)) }, + cancelled, + Duration::from_secs(60), + )) + .unwrap_err(); + + assert_eq!(error.status(), TorBootstrapStatus::Failed); + assert_eq!(error.to_string(), "bootstrap failed"); + let source = std::error::Error::source(&error).expect("source chain was dropped"); + assert_eq!(source.to_string(), "no consensus"); + } + + #[test] + fn bootstrap_status_codes_are_stable() { + let handle = unbootstrapped_client_handle(); + + unsafe { + // A zero deadline expires before an offline bootstrap can finish. + assert_eq!(tor_client_bootstrap(handle, 0) as i32, 2); + let error = crate::error::take_last_error().unwrap().to_string(); + assert!(error.contains("timed out"), "{error}"); + + tor_client_cancel_bootstrap(handle); + assert_eq!(tor_client_bootstrap(handle, 60_000) as i32, 1); + let error = crate::error::take_last_error().unwrap().to_string(); + assert!(error.contains("cancelled"), "{error}"); + + tor_client_free(handle); + } + assert_eq!(TorBootstrapStatus::Ok as i32, 0); + assert_eq!(TorBootstrapStatus::Failed as i32, 3); + assert_eq!(TorBootstrapStatus::NullClient as i32, 4); } #[test] @@ -433,6 +778,23 @@ mod tests { unsafe { tor_client_free(ptr::null_mut()) }; } + #[test] + fn client_bootstrap_reports_null() { + let status = unsafe { tor_client_bootstrap(ptr::null_mut(), 1_000) }; + + assert_eq!(status, TorBootstrapStatus::NullClient); + let error = crate::error::take_last_error().unwrap().to_string(); + assert!(error.contains("null"), "{error}"); + } + + #[test] + fn client_set_dormant_ignores_null() { + unsafe { tor_client_set_dormant(ptr::null_mut(), true) }; + + let error = crate::error::take_last_error().unwrap().to_string(); + assert!(error.contains("null"), "{error}"); + } + #[test] fn bind_localhost_reports_an_occupied_port() { let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); @@ -472,6 +834,26 @@ mod tests { assert!(error.contains("Can't listen on"), "{error}"); } + #[test] + fn unstarted_proxy_holds_its_port_and_can_be_released() { + let root = scratch_dir("reserved-proxy"); + let state = CString::new(root.join("state").to_str().unwrap()).unwrap(); + let cache = CString::new(root.join("cache").to_str().unwrap()).unwrap(); + + let result = unsafe { tor_start(0, state.as_ptr(), cache.as_ptr()) }; + assert!(!result.client.is_null()); + assert!(!result.proxy.is_null()); + assert_ne!(result.port, 0); + assert!(TcpListener::bind((Ipv4Addr::LOCALHOST, result.port)).is_err()); + + unsafe { + tor_proxy_stop(result.proxy); + tor_client_free(result.client); + } + TcpListener::bind((Ipv4Addr::LOCALHOST, result.port)).unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn bind_localhost_holds_the_port_until_dropped() { let arti_rt = TokioNativeTlsRuntime::create().unwrap(); @@ -505,6 +887,28 @@ mod tests { TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); } + #[test] + fn stopping_the_proxy_releases_its_listeners_and_client_reference() { + let (client, runtime) = unbootstrapped_client(&scratch_dir("proxy-stop")); + let arti_rt = runtime.arti().unwrap(); + let rt = proxy_runtime().unwrap(); + let (listeners, port) = bind_localhost(rt, &arti_rt, 0).unwrap(); + let mut proxy = Box::new(ProxyHandle::new(Arc::clone(&client), listeners)); + proxy.start(rt); + // Let the accept loop start polling its listeners. + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(Arc::strong_count(&client), 2); + + unsafe { tor_proxy_stop(Box::into_raw(proxy) as *mut c_void) }; + + // Before the fix the aborted task still owned the listeners and the + // client until a runtime worker got around to dropping it. + assert_eq!(Arc::strong_count(&client), 1); + TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + drop(client); + drop(runtime); + } + #[test] fn ipv6_bind_fallback_retries_only_os_assigned_ports() { let in_use = io::Error::from(io::ErrorKind::AddrInUse); diff --git a/rust/target/tor_ffi_plugin.h b/rust/target/tor_ffi_plugin.h index 0983443a..00ae3e28 100644 --- a/rust/target/tor_ffi_plugin.h +++ b/rust/target/tor_ffi_plugin.h @@ -3,14 +3,54 @@ #include #include +/** + * The outcome of [`tor_client_bootstrap`]. + * + * Every code other than `Ok` also sets the message returned by + * [`tor_last_error_message`]. + */ +enum TorBootstrapStatus +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { + /** + * The client bootstrapped. + */ + TorBootstrapStatus_Ok = 0, + /** + * [`tor_client_cancel_bootstrap`] was called on the handle. + */ + TorBootstrapStatus_Cancelled = 1, + /** + * The deadline passed before bootstrap finished. + */ + TorBootstrapStatus_TimedOut = 2, + /** + * Arti reported an error. + */ + TorBootstrapStatus_Failed = 3, + /** + * The client handle was null. + */ + TorBootstrapStatus_NullClient = 4, +}; +#ifndef __cplusplus +typedef int32_t TorBootstrapStatus; +#endif // __cplusplus + typedef struct Tor { void *client; void *proxy; uint16_t port; } Tor; +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + /** - * Start a bootstrapped Tor client and a localhost SOCKS proxy. + * Create a Tor client and a localhost SOCKS proxy. * * # Safety * `state_dir` and `cache_dir` must point to valid, NUL-terminated strings @@ -20,19 +60,36 @@ typedef struct Tor { struct Tor tor_start(uint16_t socks_port, const char *state_dir, const char *cache_dir); /** - * Ensure the client has bootstrapped. + * Bootstrap the client, returning on cancellation or after `timeout_millis`. + * A null `client` returns `NullClient`. * * # Safety - * `client` must be a live, non-null handle returned by [`tor_start`]. + * `client` must be null or a live handle returned by [`tor_start`]. * It must not be freed while this call is in progress. */ -bool tor_client_bootstrap(void *client); +TorBootstrapStatus tor_client_bootstrap(void *client, uint64_t timeout_millis); + +/** + * Ask an in-progress bootstrap to return promptly. Null is accepted. + * + * Cancellation is sticky: once this is called, every later + * [`tor_client_bootstrap`] on the same handle returns `Cancelled` at once. + * To bootstrap again, free the handle with [`tor_client_free`] and create a + * new one with [`tor_start`]. + * + * # Safety + * `client` must be null or a live handle returned by [`tor_start`]. It may be + * called concurrently with [`tor_client_bootstrap`], but not with or after + * [`tor_client_free`]. + */ +void tor_client_cancel_bootstrap(void *client); /** - * Change the client's dormant mode. + * Change the client's dormant mode. A null `client` is ignored and sets the + * message returned by [`tor_last_error_message`]. * * # Safety - * `client` must be a live, non-null handle returned by [`tor_start`]. + * `client` must be null or a live handle returned by [`tor_start`]. * It must not be freed while this call is in progress. */ void tor_client_set_dormant(void *client, bool soft_mode); @@ -40,22 +97,40 @@ void tor_client_set_dormant(void *client, bool soft_mode); /** * Release the client handle returned by [`tor_start`]. * - * The handle must not be used after this call. The proxy task keeps its own - * reference to the underlying client, so stopping the proxy and freeing the - * handle can happen in either order. + * This shuts down the client's background tasks and closes its state and + * cache files, which can block for a few seconds. Stop the proxy first: it + * keeps its own reference to the client, but cannot serve connections once + * the client is freed. * * # Safety * `client` must be null or a live handle returned by [`tor_start`]. * No other call may use the handle concurrently with or after this call. + * It must not be called from within an async runtime. */ void tor_client_free(void *client); /** - * Stop the proxy and release its handle. Null is accepted. + * Start accepting SOCKS connections on the listeners reserved by [`tor_start`]. + * Repeated calls are harmless. + * + * # Safety + * `proxy` must be a live, non-null proxy handle returned by [`tor_start`]. It + * must not be used concurrently or after [`tor_proxy_stop`]. + */ +bool tor_proxy_start(void *proxy); + +/** + * Stop the proxy, or release its reserved listeners if it was not started. + * Null is accepted. + * + * This waits for the accept loop to exit, so the listeners and the loop's + * reference to the client are released by the time it returns. Call it + * before [`tor_client_free`]. * * # Safety * `proxy` must be null or a live proxy handle returned by [`tor_start`]. * A non-null handle must be passed to this function only once. + * It must not be called from within an async runtime. */ void tor_proxy_stop(void *proxy); @@ -100,3 +175,7 @@ uint64_t tor_get_nofile_limit(void); * This function has no additional safety requirements. */ uint64_t tor_set_nofile_limit(uint64_t limit); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus diff --git a/rust/tests/header.rs b/rust/tests/header.rs new file mode 100644 index 00000000..991700e6 --- /dev/null +++ b/rust/tests/header.rs @@ -0,0 +1,52 @@ +//! Check that the public C ABI can coexist with application enum names. + +#[cfg(unix)] +#[test] +fn header_enum_names_do_not_collide() { + use std::io::Write; + use std::process::{Command, Stdio}; + + let header = std::path::Path::new(env!("TOR_FFI_PLUGIN_HEADER")); + let source = format!( + "enum ApplicationResult {{ Ok, Cancelled, TimedOut, Failed, NullClient }};\n\ + #include \"{}\"\n\ + static_assert(sizeof(TorBootstrapStatus) == sizeof(int32_t), \"ABI size\");\n\ + static_assert(TorBootstrapStatus_Ok == 0, \"ABI value\");\n\ + static_assert(TorBootstrapStatus_Cancelled == 1, \"ABI value\");\n\ + static_assert(TorBootstrapStatus_TimedOut == 2, \"ABI value\");\n\ + static_assert(TorBootstrapStatus_Failed == 3, \"ABI value\");\n\ + static_assert(TorBootstrapStatus_NullClient == 4, \"ABI value\");\n", + header.display() + ); + for (compiler, language, standard) in [("cc", "c", "c11"), ("c++", "c++", "c++11")] { + let source = if language == "c" { + source.replace("static_assert", "_Static_assert") + } else { + source.clone() + }; + let mut child = Command::new(compiler) + .args([ + "-x", + language, + &format!("-std={standard}"), + "-fsyntax-only", + "-", + ]) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("install a C and C++ compiler to check the public header"); + child + .stdin + .take() + .unwrap() + .write_all(source.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{language} header compilation failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/test/tor_lifecycle_ordering_test.dart b/test/tor_lifecycle_ordering_test.dart index 4132e54f..5166c0a4 100644 --- a/test/tor_lifecycle_ordering_test.dart +++ b/test/tor_lifecycle_ordering_test.dart @@ -5,34 +5,42 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:tor_ffi_plugin/tor_ffi_plugin.dart'; -/// Stands in for the Rust library. Each native start stays pending until the -/// test releases it, and the fake tracks which native instances are alive. +/// Stands in for the Rust library. Client creation is immediate, while each +/// network bootstrap stays pending until the test releases or cancels it. class FakeTorNative implements TorNative { - final pendingStarts = >[]; + final pendingBootstraps = >[]; + final startDirectories = []; + final bootstrapTimeouts = []; final liveClients = {}; final liveProxies = {}; + final createdProxies = {}; int maxLiveClients = 0; + int cancelCalls = 0; int helloCalls = 0; - bool failBootstrap = false; + int freeSpawnFailures = 0; + int stopProxySpawnFailures = 0; + Object? freeError; + Completer? startGate; + Completer? freeGate; + + /// Native teardown calls in the order they completed. + final teardownOrder = []; int _nextAddress = 0x1000; - /// Wait until [count] native starts have been requested. - Future waitForStarts(int count) async { - while (pendingStarts.length < count) { + Future waitForBootstraps(int count) async { + while (pendingBootstraps.length < count) { await Future.delayed(const Duration(milliseconds: 5)); } } @override Future start(int port, String stateDir, String cacheDir) async { - final gate = Completer(); - pendingStarts.add(gate); - await gate.future; - + startDirectories.add(stateDir); + await startGate?.future; final client = _nextAddress += 0x10; final proxy = _nextAddress += 0x10; liveClients.add(client); - liveProxies.add(proxy); + createdProxies.add(proxy); if (liveClients.length > maxLiveClients) { maxLiveClients = liveClients.length; } @@ -44,9 +52,20 @@ class FakeTorNative implements TorNative { } @override - void bootstrap(Pointer client) { - if (failBootstrap) { - throw Exception('bootstrap failed'); + Future bootstrap(Pointer client, Duration timeout) async { + bootstrapTimeouts.add(timeout); + final gate = Completer(); + pendingBootstraps.add(gate); + await gate.future; + } + + @override + void cancelBootstrap(Pointer client) { + cancelCalls++; + for (final bootstrap in pendingBootstraps) { + if (!bootstrap.isCompleted) { + bootstrap.completeError(Exception('bootstrap cancelled')); + } } } @@ -57,17 +76,63 @@ class FakeTorNative implements TorNative { void setDormant(Pointer client, bool softMode) {} @override - void stopProxy(Pointer proxy) => liveProxies.remove(proxy.address); + void startProxy(Pointer proxy) { + if (createdProxies.contains(proxy.address)) { + liveProxies.add(proxy.address); + } + } + + @override + Future stopProxy(Pointer proxy) async { + if (proxy == nullptr) { + return; + } + if (stopProxySpawnFailures > 0) { + stopProxySpawnFailures--; + throw Exception('stopProxy isolate failed to spawn'); + } + // Complete later, like the native stop does once it joins the accept loop. + await Future.delayed(Duration.zero); + liveProxies.remove(proxy.address); + createdProxies.remove(proxy.address); + teardownOrder.add('stopProxy'); + } @override Future freeClient(Pointer client) async { + if (client == nullptr) { + return; + } + if (freeSpawnFailures > 0) { + freeSpawnFailures--; + throw Exception('freeClient isolate failed to spawn'); + } // Complete later, like the native free does, so tests catch callers that // do not wait for it. await Future.delayed(Duration.zero); + await freeGate?.future; + if (freeError case final error?) { + throw error; + } liveClients.remove(client.address); + teardownOrder.add('freeClient'); } } +/// Start [tor] and complete its bootstrap so it reports on. +Future startAndBootstrap( + Tor tor, + FakeTorNative native, + String dir, +) async { + final started = tor.start(torDataDirPath: dir); + final index = native.pendingBootstraps.length; + await native.waitForBootstraps(index + 1); + native.pendingBootstraps[index].complete(); + await started; + expect(tor.status, TorStatus.on); +} + void main() { late Directory dataDir; late FakeTorNative native; @@ -87,15 +152,16 @@ void main() { expect(native.liveClients, isEmpty); }); - test('stop during start tears down what the start created', () async { + test('stop cancels a pending bootstrap and tears down its handles', () async { final started = tor.start(torDataDirPath: dataDir.path); - await native.waitForStarts(1); + final startFailure = expectLater(started, throwsException); + await native.waitForBootstraps(1); final stopped = tor.stop(); - native.pendingStarts[0].complete(); - await started; + await startFailure; await stopped; + expect(native.cancelCalls, 1); expect(tor.status, TorStatus.off); expect(native.liveClients, isEmpty); expect(native.liveProxies, isEmpty); @@ -104,39 +170,85 @@ void main() { test('concurrent starts wait for the same attempt', () async { final first = tor.start(torDataDirPath: dataDir.path); final second = tor.start(torDataDirPath: dataDir.path); - await native.waitForStarts(1); + await native.waitForBootstraps(1); var secondDone = false; unawaited(second.then((_) => secondDone = true)); await Future.delayed(const Duration(milliseconds: 20)); expect(secondDone, isFalse); - native.pendingStarts[0].complete(); + native.pendingBootstraps[0].complete(); await Future.wait([first, second]); expect(tor.status, TorStatus.on); - expect(native.pendingStarts, hasLength(1)); + expect(native.pendingBootstraps, hasLength(1)); + await tor.stop(); + }); + + test('the proxy is not active until bootstrap succeeds', () async { + final started = tor.start(torDataDirPath: dataDir.path); + await native.waitForBootstraps(1); + + expect(native.liveProxies, isEmpty); + native.pendingBootstraps[0].complete(); + await started; + expect(native.liveProxies, hasLength(1)); + await tor.stop(); }); test('concurrent starts both see a failed attempt', () async { final first = tor.start(torDataDirPath: dataDir.path); final second = tor.start(torDataDirPath: dataDir.path); - await native.waitForStarts(1); + await native.waitForBootstraps(1); - native.pendingStarts[0].completeError(Exception('bootstrap failed')); + native.pendingBootstraps[0].completeError(Exception('bootstrap failed')); await expectLater(first, throwsException); await expectLater(second, throwsException); expect(tor.status, TorStatus.off); - expect(native.pendingStarts, hasLength(1)); + expect(native.pendingBootstraps, hasLength(1)); }, timeout: const Timeout(Duration(seconds: 10))); + test( + 'a bootstrap timeout releases handles and reaches every caller', + () async { + const timeout = Duration(seconds: 3); + final first = tor.start( + torDataDirPath: dataDir.path, + bootstrapTimeout: timeout, + ); + final second = tor.start( + torDataDirPath: dataDir.path, + bootstrapTimeout: timeout, + ); + await native.waitForBootstraps(1); + + native.pendingBootstraps[0].completeError( + const TorBootstrapTimeout( + timeout, + 'Tor bootstrap timed out after 3000 ms', + ), + ); + + final isTimeout = isA().having( + (error) => error.timeout, + 'timeout', + timeout, + ); + await expectLater(first, throwsA(isTimeout)); + await expectLater(second, throwsA(isTimeout)); + expect(tor.status, TorStatus.off); + expect(native.liveClients, isEmpty); + expect(native.liveProxies, isEmpty); + expect(native.pendingBootstraps, hasLength(1)); + }, + ); + test('a failed bootstrap releases the native handles', () async { - native.failBootstrap = true; final started = tor.start(torDataDirPath: dataDir.path); - await native.waitForStarts(1); - native.pendingStarts[0].complete(); + await native.waitForBootstraps(1); + native.pendingBootstraps[0].completeError(Exception('bootstrap failed')); await expectLater(started, throwsException); expect(tor.status, TorStatus.off); @@ -146,8 +258,8 @@ void main() { test('disable stops the proxy', () async { final started = tor.start(torDataDirPath: dataDir.path); - await native.waitForStarts(1); - native.pendingStarts[0].complete(); + await native.waitForBootstraps(1); + native.pendingBootstraps[0].complete(); await started; await tor.disable(); @@ -161,16 +273,16 @@ void main() { test('restart during a pending start never overlaps instances', () async { final first = tor.start(torDataDirPath: dataDir.path); - await native.waitForStarts(1); + final firstFailure = expectLater(first, throwsException); + await native.waitForBootstraps(1); final stopped = tor.stop(); final second = tor.start(torDataDirPath: dataDir.path); - native.pendingStarts[0].complete(); - await first; + await firstFailure; await stopped; - await native.waitForStarts(2); - native.pendingStarts[1].complete(); + await native.waitForBootstraps(2); + native.pendingBootstraps[1].complete(); await second; expect(tor.status, TorStatus.on); @@ -181,4 +293,450 @@ void main() { expect(native.liveClients, isEmpty); expect(native.liveProxies, isEmpty); }); + + test('stop then start on a running instance restarts Tor', () async { + await startAndBootstrap(tor, native, dataDir.path); + final firstClient = native.liveClients.single; + + // Neither call is awaited before the next, as a toggle handler does. + final stopped = tor.stop(); + final restarted = tor.start(torDataDirPath: dataDir.path); + await stopped; + // The queued restart begins as soon as the stop completes. + expect(tor.status, isNot(TorStatus.on)); + + await native.waitForBootstraps(2); + native.pendingBootstraps[1].complete(); + await restarted; + + // Before the fix start() saw the stale "on" status, returned at once, and + // the queued stop then left Tor off while the caller thought it was on. + expect(tor.status, TorStatus.on); + expect(tor.port, 19050); + expect(native.liveClients.single, isNot(firstClient)); + expect(native.maxLiveClients, 1); + await tor.stop(); + }); + + test('a start queued before stop is cancelled by it', () async { + await startAndBootstrap(tor, native, dataDir.path); + + final queued = tor.start(torDataDirPath: dataDir.path); + final stopped = tor.stop(); + + await expectLater(queued, throwsA(isA())); + await stopped; + expect(tor.status, TorStatus.off); + expect(native.liveClients, isEmpty); + }); + + test('a start cancelled by an immediate stop leaves Tor off', () async { + final started = tor.start(torDataDirPath: dataDir.path); + expect(tor.status, TorStatus.starting); + final stopped = tor.stop(); + + await expectLater(started, throwsA(isA())); + await stopped; + expect(tor.status, TorStatus.off); + expect(native.startDirectories, isEmpty); + }); + + test('a running instance rejects a different data directory', () async { + await startAndBootstrap(tor, native, dataDir.path); + final other = await Directory.systemTemp.createTemp('tor-ordering-other-'); + addTearDown(() => other.delete(recursive: true)); + + await expectLater( + tor.start(torDataDirPath: other.path), + throwsA(isA()), + ); + + // The rejection must not tear down the running instance. + expect(tor.status, TorStatus.on); + expect(native.liveClients, hasLength(1)); + expect(native.startDirectories, hasLength(1)); + await tor.stop(); + }); + + test('status stays on until the native client is released', () async { + await startAndBootstrap(tor, native, dataDir.path); + final gate = native.freeGate = Completer(); + + final stopped = tor.stop(); + while (native.liveProxies.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 5)); + } + + // The proxy is gone but the client, and so its files, is still live. + expect(native.liveClients, hasLength(1)); + expect(tor.status, TorStatus.on); + expect( + () => tor.port, + throwsA( + isA().having( + (e) => e.toString(), + 'message', + 'Exception: Tor proxy is not running', + ), + ), + ); + await expectLater( + tor.setClientDormant(true), + throwsA(isA()), + ); + + gate.complete(); + await stopped; + expect(tor.status, TorStatus.off); + expect(native.liveClients, isEmpty); + }); + + test('a failed free is retried by the next stop', () async { + await startAndBootstrap(tor, native, dataDir.path); + native.freeSpawnFailures = 1; + + await expectLater(tor.stop(), throwsException); + expect(tor.status, TorStatus.on); + expect(native.liveClients, hasLength(1)); + await tor.stop(); + expect(tor.status, TorStatus.off); + expect(native.liveClients, isEmpty); + }); + + test('a failed free is released before the next start', () async { + await startAndBootstrap(tor, native, dataDir.path); + native.freeSpawnFailures = 1; + + await expectLater(tor.stop(), throwsException); + expect(tor.status, TorStatus.on); + expect(native.liveClients, hasLength(1)); + await startAndBootstrap(tor, native, dataDir.path); + expect(native.maxLiveClients, 1); + expect(native.startDirectories, hasLength(2)); + await tor.stop(); + }); + + test('a failed proxy stop is retried', () async { + await startAndBootstrap(tor, native, dataDir.path); + native.stopProxySpawnFailures = 1; + + await expectLater(tor.stop(), throwsException); + expect(tor.status, TorStatus.on); + expect(native.liveProxies, hasLength(1)); + expect(native.liveClients, hasLength(1)); + expect(native.teardownOrder, isEmpty); + await tor.stop(); + expect(native.liveProxies, isEmpty); + expect(native.liveClients, isEmpty); + expect(native.teardownOrder, ['stopProxy', 'freeClient']); + expect(tor.status, TorStatus.off); + }); + + test('a failed proxy stop is released before the next start', () async { + await startAndBootstrap(tor, native, dataDir.path); + native.stopProxySpawnFailures = 1; + await expectLater(tor.stop(), throwsException); + expect(native.liveClients, hasLength(1)); + await startAndBootstrap(tor, native, dataDir.path); + expect(native.maxLiveClients, 1); + expect(native.startDirectories, hasLength(2)); + expect(native.teardownOrder, ['stopProxy', 'freeClient']); + await tor.stop(); + }); + + test('failed startup cleanup never reports on and is retried', () async { + native.freeSpawnFailures = 1; + final started = tor.start(torDataDirPath: dataDir.path); + final failed = expectLater(started, throwsException); + await native.waitForBootstraps(1); + native.pendingBootstraps[0].completeError(Exception('bootstrap failed')); + await failed; + expect(tor.status, TorStatus.starting); + expect(native.liveClients, hasLength(1)); + await startAndBootstrap(tor, native, dataDir.path); + expect(native.maxLiveClients, 1); + expect(native.startDirectories, hasLength(2)); + await tor.stop(); + }); + + test('stop releases the proxy before the client', () async { + await startAndBootstrap(tor, native, dataDir.path); + + await tor.stop(); + + expect(native.teardownOrder, ['stopProxy', 'freeClient']); + }); + + test( + 'a sub-millisecond timeout is rejected before the native start', + () async { + await expectLater( + tor.start( + torDataDirPath: dataDir.path, + bootstrapTimeout: const Duration(microseconds: 500), + ), + throwsA(isA()), + ); + + expect(native.startDirectories, isEmpty); + expect(tor.status, TorStatus.off); + }, + ); + + test('trailing separators do not change the data directory', () async { + final slashed = '${dataDir.path}${Platform.pathSeparator}'; + final first = tor.start(torDataDirPath: slashed); + final second = tor.start(torDataDirPath: dataDir.path); + await native.waitForBootstraps(1); + + native.pendingBootstraps[0].complete(); + await Future.wait([first, second]); + + expect(native.startDirectories, hasLength(1)); + await tor.start(torDataDirPath: slashed); + expect(tor.status, TorStatus.on); + await tor.stop(); + }); + + test('a symlink and its target share one start', () async { + final sep = Platform.pathSeparator; + final real = Directory('${dataDir.path}${sep}real')..createSync(); + final link = Link('${dataDir.path}${sep}link')..createSync(real.path); + + final first = tor.start(torDataDirPath: link.path); + final second = tor.start(torDataDirPath: real.path); + await native.waitForBootstraps(1); + native.pendingBootstraps[0].complete(); + await Future.wait([first, second]); + + expect(native.startDirectories, hasLength(1)); + await tor.stop(); + }, skip: Platform.isWindows ? 'symlinks need elevated privileges' : false); + + test('a parent segment after a symlink follows the link', () async { + final sep = Platform.pathSeparator; + final target = Directory('${dataDir.path}${sep}a${sep}target') + ..createSync(recursive: true); + Link('${dataDir.path}${sep}link').createSync(target.path); + + // Lexically this is dataDir/sibling; the file system resolves it to + // dataDir/a/sibling. + await startAndBootstrap( + tor, + native, + '${dataDir.path}${sep}link$sep..${sep}sibling', + ); + + final physical = Directory('${dataDir.path}${sep}a${sep}sibling') + .resolveSymbolicLinksSync(); + expect(native.startDirectories[0], '$physical/tor_state'); + expect(Directory('${dataDir.path}${sep}sibling').existsSync(), isFalse); + await tor.stop(); + }, skip: Platform.isWindows ? 'symlinks need elevated privileges' : false); + + test('an unusable data directory fails while it is resolved', () async { + final sep = Platform.pathSeparator; + final file = File('${dataDir.path}${sep}file')..createSync(); + + // The error names the data directory itself, not a subdirectory that a + // queued start failed to create. + await expectLater( + tor.start(torDataDirPath: file.path), + throwsA( + isA().having((e) => e.path, 'path', file.path), + ), + ); + + expect(tor.status, TorStatus.off); + expect(native.startDirectories, isEmpty); + // The failure is not left behind as a pending start. + await startAndBootstrap(tor, native, dataDir.path); + await tor.stop(); + }); + + test('a pending start rejects a different data directory', () async { + final first = tor.start(torDataDirPath: dataDir.path); + await native.waitForBootstraps(1); + final other = await Directory.systemTemp.createTemp('tor-ordering-other-'); + addTearDown(() => other.delete(recursive: true)); + + final second = tor.start(torDataDirPath: other.path); + final secondFailure = expectLater(second, throwsA(isA())); + native.pendingBootstraps[0].complete(); + await first; + await secondFailure; + + expect(tor.status, TorStatus.on); + expect(native.startDirectories, hasLength(1)); + await tor.stop(); + }); + + test( + 'directory preparation reports starting and preserves running status', + () async { + final started = tor.start(torDataDirPath: dataDir.path); + expect(tor.status, TorStatus.starting); + // The queued start has begun, but filesystem I/O cannot finish until the + // event loop advances beyond this microtask. + await Future.value(); + expect(tor.status, TorStatus.starting); + expect(native.startDirectories, isEmpty); + await native.waitForBootstraps(1); + native.pendingBootstraps[0].complete(); + await started; + + final reused = tor.start(torDataDirPath: dataDir.path); + await Future.value(); + expect(tor.status, TorStatus.on); + await reused; + expect(native.startDirectories, hasLength(1)); + await tor.stop(); + }, + ); + + test('start defers data directory creation to the queue', () async { + final fresh = Directory('${dataDir.path}/fresh'); + final started = tor.start(torDataDirPath: fresh.path); + expect(fresh.existsSync(), isFalse); + await native.waitForBootstraps(1); + native.pendingBootstraps[0].complete(); + await started; + await tor.stop(); + }); + + test( + 'a start with a different timeout reuses the running instance', + () async { + final first = tor.start(torDataDirPath: dataDir.path); + final second = tor.start( + torDataDirPath: dataDir.path, + bootstrapTimeout: const Duration(seconds: 17), + ); + final both = Future.wait([first, second]); + await native.waitForBootstraps(1); + native.pendingBootstraps[0].complete(); + await both; + + expect(native.bootstrapTimeouts, hasLength(1)); + expect(tor.status, TorStatus.on); + await tor.stop(); + }, + ); + + test('stop cancels every queued start', () async { + final gate = native.startGate = Completer(); + final first = tor.start(torDataDirPath: dataDir.path); + final firstFailure = expectLater(first, throwsA(isA())); + while (native.startDirectories.isEmpty) { + await Future.delayed(const Duration(milliseconds: 5)); + } + final second = tor.start( + torDataDirPath: '${dataDir.path}${Platform.pathSeparator}', + ); + final secondFailure = expectLater( + second.timeout(const Duration(seconds: 2)), + throwsA(isA()), + ); + final stopped = tor.stop(); + gate.complete(); + await firstFailure; + await secondFailure; + await stopped; + + expect(tor.status, TorStatus.off); + expect(native.liveClients, isEmpty); + expect(native.startDirectories, hasLength(1)); + }); + + test('cleanup failure does not replace the bootstrap failure', () async { + native.freeError = Exception('cleanup failed'); + final started = tor.start(torDataDirPath: dataDir.path); + await native.waitForBootstraps(1); + native.pendingBootstraps[0].completeError(Exception('bootstrap failed')); + + await expectLater( + started, + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('bootstrap failed'), + ), + ), + ); + expect(tor.status, TorStatus.starting); + expect(native.freeError, same(tor.lastCleanupError)); + expect(tor.lastCleanupStackTrace, isNotNull); + }); + + for (final loggingThrows in [false, true]) { + test( + 'cleanup failure is reported without masking the typed start error (logging throws: $loggingThrows)', + () async { + final messages = []; + const primary = TorBootstrapTimeout( + Duration(seconds: 1), + 'deadline passed', + ); + native.freeSpawnFailures = 1; + final started = runZoned( + () => tor.start(torDataDirPath: dataDir.path), + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) { + messages.add(line); + if (loggingThrows) throw StateError('log sink failed'); + }, + ), + ); + final failure = expectLater(started, throwsA(same(primary))); + await native.waitForBootstraps(1); + native.pendingBootstraps[0].completeError(primary); + await failure; + expect(messages, hasLength(1)); + expect(messages.single, contains('freeClient isolate failed to spawn')); + expect(tor.lastCleanupError, isNotNull); + expect(tor.lastCleanupStackTrace, isNotNull); + await tor.stop(); + expect(tor.status, TorStatus.off); + }, + ); + } + + test( + 'stop before client creation prevents bootstrap from starting', + () async { + final gate = native.startGate = Completer(); + final started = tor.start(torDataDirPath: dataDir.path); + final startFailure = expectLater( + started, + throwsA(isA()), + ); + while (native.startDirectories.isEmpty) { + await Future.delayed(const Duration(milliseconds: 5)); + } + + final stopped = tor.stop(); + gate.complete(); + await startFailure; + await stopped; + + expect(native.pendingBootstraps, isEmpty); + expect(native.liveClients, isEmpty); + expect(native.liveProxies, isEmpty); + }, + ); + + test('forwards a finite bootstrap timeout', () async { + const timeout = Duration(seconds: 17); + final started = tor.start( + torDataDirPath: dataDir.path, + bootstrapTimeout: timeout, + ); + await native.waitForBootstraps(1); + + expect(native.bootstrapTimeouts, [timeout]); + native.pendingBootstraps[0].complete(); + await started; + await tor.stop(); + }); } diff --git a/test/tor_lifecycle_test.dart b/test/tor_lifecycle_test.dart index bbd2acd9..65a7bcd0 100644 --- a/test/tor_lifecycle_test.dart +++ b/test/tor_lifecycle_test.dart @@ -59,6 +59,56 @@ void main() { ); }); + group('native bootstrap status', () { + const native = TorNative(); + Directory? dir; + TorHandles? handles; + + setUp(() async { + dir = null; + handles = null; + dir = await Directory.systemTemp.createTemp('tor-bootstrap-status-'); + // Creating the client does not touch the network. + handles = await native.start( + 0, + '${dir!.path}/state', + '${dir!.path}/cache', + ); + }); + + tearDown(() async { + final createdHandles = handles; + if (createdHandles != null) { + await native.stopProxy(createdHandles.proxy); + await native.freeClient(createdHandles.client); + } + final createdDir = dir; + if (createdDir != null) { + await createdDir.delete(recursive: true); + } + }); + + test('a passed deadline throws TorBootstrapTimeout', () async { + const timeout = Duration(milliseconds: 1); + await expectLater( + native.bootstrap(handles!.client, timeout), + throwsA( + isA() + .having((e) => e.timeout, 'timeout', timeout) + .having((e) => e.message, 'message', contains('timed out')), + ), + ); + }); + + test('a cancelled bootstrap throws TorStartCancelled', () async { + native.cancelBootstrap(handles!.client); + await expectLater( + native.bootstrap(handles!.client, const Duration(minutes: 1)), + throwsA(isA()), + ); + }); + }); + test( 'stop resets state and allows a fresh start', () async { From bc90701a9eb68e125e43f213487cde7792752fc7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 14 Oct 2026 15:37:24 -0500 Subject: [PATCH 21/47] fix(tor): preserve literal native data directory paths CfgPath::new expanded dollar variables and tilde prefixes in caller-provided paths, rejecting otherwise valid directory names. Use CfgPath::new_literal for both state and cache paths. Cover literal directory creation and cleanup with a native regression and describe the behavior in the release notes. --- CHANGELOG.md | 2 ++ rust/src/lib.rs | 28 ++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5131c190..7dbce53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,8 @@ * `tor_proxy_stop` waits for the SOCKS accept loop to exit before returning, so the listeners and its reference to the client are released, and Dart runs it on a worker isolate. +* Use the data directories literally; names containing `$` or `~` were + expanded as variables and rejected. * When the OS assigns the SOCKS port and its IPv6 loopback twin is held by another process, try up to three more ports, then fail to start with an error saying the twin stayed occupied. Serving IPv4 alone there would let diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 52dc5bc6..bef0d8b4 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -164,8 +164,10 @@ pub unsafe extern "C" fn tor_start( let mut cfg_builder = TorClientConfig::builder(); cfg_builder .storage() - .state_dir(CfgPath::new(state_dir.to_owned())) - .cache_dir(CfgPath::new(cache_dir.to_owned())); + // The paths come from the caller as-is; `CfgPath::new` would expand + // `$VAR` and `~` and reject directories whose names contain them. + .state_dir(CfgPath::new_literal(state_dir)) + .cache_dir(CfgPath::new_literal(cache_dir)); cfg_builder.address_filter().allow_onion_addrs(true); let cfg = unwrap_or_return!(cfg_builder.build(), err_ret); @@ -909,6 +911,28 @@ mod tests { drop(runtime); } + #[test] + fn tor_start_uses_the_data_directories_literally() { + let root = scratch_dir("literal-paths"); + // `CfgPath::new` treated this as a variable reference and failed. + let state_path = root.join("$state"); + let cache_path = root.join("~cache"); + let state = CString::new(state_path.to_str().unwrap()).unwrap(); + let cache = CString::new(cache_path.to_str().unwrap()).unwrap(); + + let result = unsafe { tor_start(0, state.as_ptr(), cache.as_ptr()) }; + let error = crate::error::take_last_error().map(|e| e.to_string()); + assert!(!result.client.is_null(), "{error:?}"); + unsafe { + tor_proxy_stop(result.proxy); + tor_client_free(result.client); + } + + assert!(state_path.is_dir(), "state dir was not created literally"); + assert!(cache_path.is_dir(), "cache dir was not created literally"); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn ipv6_bind_fallback_retries_only_os_assigned_ports() { let in_use = io::Error::from(io::ErrorKind::AddrInUse); From c08a6588232cabcd26b99909b0ce7b137c9c64a2 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 15:47:36 -0500 Subject: [PATCH 22/47] fix(hook): fingerprint the vendored crates in rust/patches sourceFingerprint() hashed rust/src but not rust/patches, so editing the vendored saturating-time crate left the prebuilt fingerprint unchanged. A stale prebuilt would still verify and Flutter's hook cache would not rebuild. Hash and track rust/patches the same way as rust/src. Source builds declare the same inputs too: cargo's dep-info, which RustBuilder tracks, lists only this crate's own sources. --- CHANGELOG.md | 2 ++ hook/build.dart | 10 +++++----- hook/prebuilt.dart | 10 ++++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbce53a..4ce671d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## 0.2.0 * Bump arti to 2.6.0; needs Rust 1.91. +* Include the vendored crates in `rust/patches/` in the prebuilt source + fingerprint, so editing them invalidates prebuilts and the hook cache. * Fix `tor_client_bootstrap` and `tor_client_set_dormant` freeing the client handle Dart still holds. Add `tor_client_free`; `Tor.stop` now releases the client handle. diff --git a/hook/build.dart b/hook/build.dart index 04c6eee8..ef2600c3 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -9,11 +9,11 @@ Future main(List args) async { if (!input.config.buildCodeAssets) return; if (await usePrebuilt(input, output)) return; - output.dependencies.addAll([ - input.packageRoot.resolve('rust/Cargo.toml'), - input.packageRoot.resolve('rust/Cargo.lock'), - input.packageRoot.resolve('rust/rust-toolchain.toml'), - ]); + // Cargo's dep-info, which RustBuilder tracks, omits the vendored crates in + // rust/patches, so declare the inputs the prebuilt fingerprint covers. + output.dependencies.addAll( + (await sourceFingerprint(input.packageRoot)).dependencies, + ); await const RustBuilder( assetName: 'tor_ffi_plugin_bindings_generated.dart', diff --git a/hook/prebuilt.dart b/hook/prebuilt.dart index 460a5c77..88b6d6f6 100644 --- a/hook/prebuilt.dart +++ b/hook/prebuilt.dart @@ -43,12 +43,14 @@ Future<({String hash, List dependencies})> sourceFingerprint( 'hook/build.dart', 'lib/tor_ffi_plugin_bindings_generated.dart', 'rust/src/', + 'rust/patches/', ]) root.resolve(path), - await for (final entry in Directory.fromUri( - root.resolve('rust/src/'), - ).list(recursive: true, followLinks: false)) - entry.uri, + for (final dir in ['rust/src/', 'rust/patches/']) + await for (final entry in Directory.fromUri( + root.resolve(dir), + ).list(recursive: true, followLinks: false)) + entry.uri, ]..sort((a, b) => a.path.compareTo(b.path)); final hashes = StringBuffer(); for (final uri in dependencies) { From 62f1a88bf3e357c16af73b4bd53404eac9ed6e30 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 17:17:13 -0500 Subject: [PATCH 23/47] fix(hook): skip hidden files and hash bytes in the source fingerprint sourceFingerprint() decoded every file under rust/src and rust/patches as UTF-8, so a stray .DS_Store failed the build. Source builds now use the fingerprint for their dependencies, which spread the failure from prebuilt builds to every build. Even a decodable stray file changed the hash and made a valid prebuilt manifest look mismatched. Skip entries with a hidden path segment, and hash raw bytes with CRLF folded to LF. The hash of valid sources is unchanged. --- CHANGELOG.md | 2 ++ hook/prebuilt.dart | 24 ++++++++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce671d1..71fe998e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,8 @@ status to `off` if no native handles remain. * Route `Tor.hello` through the injectable native layer so test instances can substitute the greeting without calling the real library. +* Skip hidden files such as `.DS_Store` in the native source fingerprint and + hash sources as bytes, so stray files no longer fail or invalidate builds. ## 0.1.0 diff --git a/hook/prebuilt.dart b/hook/prebuilt.dart index 88b6d6f6..2f92affe 100644 --- a/hook/prebuilt.dart +++ b/hook/prebuilt.dart @@ -31,6 +31,7 @@ Future fileHash(File file) async => // Hash paths and contents so additions, deletions and ABI changes invalidate // prebuilts. Track directories as well as files for Flutter's hook cache. +// Hidden entries such as .DS_Store are not native inputs and are skipped. Future<({String hash, List dependencies})> sourceFingerprint( Uri root, ) async { @@ -50,7 +51,7 @@ Future<({String hash, List dependencies})> sourceFingerprint( await for (final entry in Directory.fromUri( root.resolve(dir), ).list(recursive: true, followLinks: false)) - entry.uri, + if (!_isHidden(entry.uri, root)) entry.uri, ]..sort((a, b) => a.path.compareTo(b.path)); final hashes = StringBuffer(); for (final uri in dependencies) { @@ -62,12 +63,11 @@ Future<({String hash, List dependencies})> sourceFingerprint( throw StateError('Native source symlinks are unsupported: $uri'); } if (type == FileSystemEntityType.directory) continue; - // Git checkouts may use CRLF on Windows; native inputs are text files. - final text = (await File.fromUri( - uri, - ).readAsString()).replaceAll('\r\n', '\n'); + // Git checkouts may use CRLF on Windows. Hash bytes rather than decoded + // text so a file that is not UTF-8 cannot fail the build. + final bytes = _lfLineEndings(await File.fromUri(uri).readAsBytes()); hashes.writeln( - '${uri.path.substring(root.path.length)}:${sha256.convert(utf8.encode(text))}', + '${uri.path.substring(root.path.length)}:${sha256.convert(bytes)}', ); } return ( @@ -76,6 +76,18 @@ Future<({String hash, List dependencies})> sourceFingerprint( ); } +bool _isHidden(Uri entry, Uri root) => entry.path + .substring(root.path.length) + .split('/') + .any((segment) => segment.startsWith('.')); + +/// [bytes] with each CRLF replaced by LF. +List _lfLineEndings(List bytes) => [ + for (var i = 0; i < bytes.length; i++) + if (bytes[i] != 0x0d || i + 1 == bytes.length || bytes[i + 1] != 0x0a) + bytes[i], +]; + String rustTarget(CodeConfig code) => switch (( code.targetOS, code.targetArchitecture, From 51b74f180f696e04222d5ec59ec572708601be6b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 17:50:18 -0500 Subject: [PATCH 24/47] fix(hook): harden and optimize source fingerprinting Exclude vendored Cargo output from fingerprints and avoid hashing all Rust inputs during source builds while retaining full prebuilt verification. --- .gitignore | 4 ++ CHANGELOG.md | 4 ++ hook/build.dart | 6 +-- hook/prebuilt.dart | 71 ++++++++++++++++++------------- test/source_fingerprint_test.dart | 57 +++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 34 deletions(-) create mode 100644 test/source_fingerprint_test.dart diff --git a/.gitignore b/.gitignore index faa341d4..23086091 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ build/ rust/target/.rustc_info.json rust/target/debug/ rust/target/CACHEDIR.TAG + +# Cargo output from building a vendored crate on its own. +rust/patches/*/target/ +rust/patches/*/Cargo.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 71fe998e..fac73c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ * Bump arti to 2.6.0; needs Rust 1.91. * Include the vendored crates in `rust/patches/` in the prebuilt source fingerprint, so editing them invalidates prebuilts and the hook cache. +* Leave Cargo output from building a vendored crate on its own (`target/` and + `Cargo.lock`) out of the native source fingerprint. * Fix `tor_client_bootstrap` and `tor_client_set_dormant` freeing the client handle Dart still holds. Add `tor_client_free`; `Tor.stop` now releases the client handle. @@ -99,6 +101,8 @@ can substitute the greeting without calling the real library. * Skip hidden files such as `.DS_Store` in the native source fingerprint and hash sources as bytes, so stray files no longer fail or invalidate builds. +* Avoid hashing all Rust inputs during source builds; full fingerprints remain + required when selecting prebuilts. ## 0.1.0 diff --git a/hook/build.dart b/hook/build.dart index ef2600c3..19d6fd1c 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -9,11 +9,7 @@ Future main(List args) async { if (!input.config.buildCodeAssets) return; if (await usePrebuilt(input, output)) return; - // Cargo's dep-info, which RustBuilder tracks, omits the vendored crates in - // rust/patches, so declare the inputs the prebuilt fingerprint covers. - output.dependencies.addAll( - (await sourceFingerprint(input.packageRoot)).dependencies, - ); + output.dependencies.addAll(await sourceDependencies(input.packageRoot)); await const RustBuilder( assetName: 'tor_ffi_plugin_bindings_generated.dart', diff --git a/hook/prebuilt.dart b/hook/prebuilt.dart index 2f92affe..c21a8212 100644 --- a/hook/prebuilt.dart +++ b/hook/prebuilt.dart @@ -29,30 +29,30 @@ String releaseFileName(String target) => '$target-${libraryName(target)}'; Future fileHash(File file) async => (await sha256.bind(file.openRead()).first).toString(); +/// Native inputs to track for Flutter's hook cache. +/// +/// Directories are included so additions and deletions invalidate the build. +/// Hidden entries are not native inputs and are skipped. +Future> sourceDependencies(Uri root) async => [ + for (final path in [ + 'rust/Cargo.toml', + 'rust/Cargo.lock', + 'rust/rust-toolchain.toml', + 'rust/build.rs', + 'hook/build.dart', + 'lib/tor_ffi_plugin_bindings_generated.dart', + 'rust/src/', + ]) + root.resolve(path), + ...await _sourceEntries(Directory.fromUri(root.resolve('rust/src/'))), +]..sort((a, b) => a.path.compareTo(b.path)); + // Hash paths and contents so additions, deletions and ABI changes invalidate -// prebuilts. Track directories as well as files for Flutter's hook cache. -// Hidden entries such as .DS_Store are not native inputs and are skipped. +// prebuilts. Future<({String hash, List dependencies})> sourceFingerprint( Uri root, ) async { - final dependencies = [ - for (final path in [ - 'rust/Cargo.toml', - 'rust/Cargo.lock', - 'rust/rust-toolchain.toml', - 'rust/build.rs', - 'hook/build.dart', - 'lib/tor_ffi_plugin_bindings_generated.dart', - 'rust/src/', - 'rust/patches/', - ]) - root.resolve(path), - for (final dir in ['rust/src/', 'rust/patches/']) - await for (final entry in Directory.fromUri( - root.resolve(dir), - ).list(recursive: true, followLinks: false)) - if (!_isHidden(entry.uri, root)) entry.uri, - ]..sort((a, b) => a.path.compareTo(b.path)); + final dependencies = await sourceDependencies(root); final hashes = StringBuffer(); for (final uri in dependencies) { final type = await FileSystemEntity.type( @@ -76,17 +76,30 @@ Future<({String hash, List dependencies})> sourceFingerprint( ); } -bool _isHidden(Uri entry, Uri root) => entry.path - .substring(root.path.length) - .split('/') - .any((segment) => segment.startsWith('.')); +/// Entries under [directory], without descending into skipped directories. +Future> _sourceEntries(Directory directory) async => [ + await for (final entry in directory.list(followLinks: false)) + if (!_isSkipped(entry)) ...[ + entry.uri, + if (entry is Directory) ...await _sourceEntries(entry), + ], +]; + +/// Whether [entry] is hidden, such as .DS_Store. +bool _isSkipped(FileSystemEntity entry) { + final name = entry.uri.pathSegments.lastWhere((s) => s.isNotEmpty); + return name.startsWith('.'); +} /// [bytes] with each CRLF replaced by LF. -List _lfLineEndings(List bytes) => [ - for (var i = 0; i < bytes.length; i++) - if (bytes[i] != 0x0d || i + 1 == bytes.length || bytes[i + 1] != 0x0a) - bytes[i], -]; +List _lfLineEndings(List bytes) { + if (!bytes.contains(0x0d)) return bytes; + return [ + for (var i = 0; i < bytes.length; i++) + if (bytes[i] != 0x0d || i + 1 == bytes.length || bytes[i + 1] != 0x0a) + bytes[i], + ]; +} String rustTarget(CodeConfig code) => switch (( code.targetOS, diff --git a/test/source_fingerprint_test.dart b/test/source_fingerprint_test.dart new file mode 100644 index 00000000..deb7bb70 --- /dev/null +++ b/test/source_fingerprint_test.dart @@ -0,0 +1,57 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../hook/prebuilt.dart'; + +void main() { + test('source dependencies are reusable without calculating hashes', () async { + final root = await Directory.systemTemp.createTemp('tor-sources-'); + addTearDown(() => root.delete(recursive: true)); + for (final path in [ + 'rust/Cargo.toml', + 'rust/Cargo.lock', + 'rust/rust-toolchain.toml', + 'rust/build.rs', + 'hook/build.dart', + 'lib/tor_ffi_plugin_bindings_generated.dart', + 'rust/src/lib.rs', + ]) { + final file = File.fromUri(root.uri.resolve(path)); + await file.parent.create(recursive: true); + await file.writeAsString(path); + } + + final dependencies = await sourceDependencies(root.uri); + final fingerprint = await sourceFingerprint(root.uri); + + expect(dependencies, fingerprint.dependencies); + expect(dependencies, contains(root.uri.resolve('rust/src/lib.rs'))); + expect(fingerprint.hash, hasLength(64)); + }); + + test('CRLF line endings do not change the fingerprint', () async { + final root = await Directory.systemTemp.createTemp('tor-sources-'); + addTearDown(() => root.delete(recursive: true)); + for (final path in [ + 'rust/Cargo.toml', + 'rust/Cargo.lock', + 'rust/rust-toolchain.toml', + 'rust/build.rs', + 'hook/build.dart', + 'lib/tor_ffi_plugin_bindings_generated.dart', + 'rust/src/lib.rs', + ]) { + final file = File.fromUri(root.uri.resolve(path)); + await file.parent.create(recursive: true); + await file.writeAsString(path); + } + final file = File.fromUri(root.uri.resolve('rust/src/lib.rs')); + await file.writeAsBytes('a\nb\r\nc\r'.codeUnits); + final lf = await sourceFingerprint(root.uri); + await file.writeAsBytes('a\r\nb\r\nc\r'.codeUnits); + final crlf = await sourceFingerprint(root.uri); + + expect(crlf.hash, lf.hash); + }); +} From 16ca224746bd7ff11d40b2a2e7822bd9dd79f9b7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 11:36:49 -0500 Subject: [PATCH 25/47] chore(deps): upgrade Arti to 2.7.0 Use the 0.47.0 Arti crates and Rust 1.92.0. Rebase the Windows clock-resolution fix onto saturating-time 0.5.0, which still needs the no-progress guard, and exercise 100ns rounding on every host. Verified Rust tests, patched-crate tests, Clippy, and 11 offline Flutter tests (one live-network test skipped); Dart analysis is clean. Do not pin tor-config directly; nothing in the crate uses it. --- CHANGELOG.md | 5 +- rust/Cargo.lock | 343 +++++++++---------- rust/Cargo.toml | 15 +- rust/patches/saturating-time/Cargo.toml | 19 +- rust/patches/saturating-time/README.md | 23 +- rust/patches/saturating-time/src/internal.rs | 44 ++- rust/patches/saturating-time/src/lib.rs | 68 +++- rust/rust-toolchain.toml | 2 +- rust/tests/saturating_time.rs | 2 +- 9 files changed, 308 insertions(+), 213 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fac73c5c..23b37d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## 0.2.0 -* Bump arti to 2.6.0; needs Rust 1.91. +* Bump arti to 2.7.0 and its supporting crates to 0.47.0; needs Rust 1.92. +* Rebase the Windows clock-resolution fix onto `saturating-time` 0.5.0; + the upstream release still hangs while computing `SystemTime` limits. * Include the vendored crates in `rust/patches/` in the prebuilt source fingerprint, so editing them invalidates prebuilts and the hook cache. * Leave Cargo output from building a vendored crate on its own (`target/` and @@ -99,6 +101,7 @@ status to `off` if no native handles remain. * Route `Tor.hello` through the injectable native layer so test instances can substitute the greeting without calling the real library. +* Drop the unused `tor-config` dependency. * Skip hidden files such as `.DS_Store` in the native source fingerprint and hash sources as bytes, so stray files no longer fail or invalidate builds. * Avoid hashing all Rust inputs during source builds; full fingerprints remain diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7933bda3..ff2c2216 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -151,15 +151,15 @@ checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" [[package]] name = "arti" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2d88fb27c29401cac3d384ef7c3998915192b9e08fda9823e02ca7d578be2e" +checksum = "a567e05cf614b579609d958e41b54602eea6983bccfec46d1e933a45605dd813" dependencies = [ "anyhow", "arti-client", "cfg-if", "clap 4.5.7", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_builder_fork_arti", "derive_more", "extend", @@ -169,7 +169,7 @@ dependencies = [ "hickory-proto", "humantime", "humantime-serde", - "itertools 0.15.0", + "itertools", "libc", "notify", "paste", @@ -208,14 +208,14 @@ dependencies = [ [[package]] name = "arti-client" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aafcbc506657d886b27ef2d8e06a99edd9014b28130c798a3013935b46983097" +checksum = "43e6531f26fe6f44d71dabdc63b7845bae81547cc738aa942f0e3e482f776388" dependencies = [ "anyhow", "async-trait", "cfg-if", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_builder_fork_arti", "derive_more", "educe", @@ -535,9 +535,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "caret" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" +checksum = "a3135adcc2ec978dbad556b41b7c71e0be7625d70b79159a8a19b5879a761115" [[package]] name = "cbindgen" @@ -1063,11 +1063,11 @@ dependencies = [ [[package]] name = "derive-deftly" -version = "1.11.5" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e" +checksum = "e64c6955def7f25b8c3becad9e1762ff6abc3ea62704b0883284c771149b45a6" dependencies = [ - "derive-deftly-macros 1.11.5", + "derive-deftly-macros 1.12.1", "heck 0.5.0", ] @@ -1078,8 +1078,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357422a457ccb850dc8f1c1680e0670079560feaad6c2e247e3f345c4fab8a3f" dependencies = [ "heck 0.5.0", - "indexmap 1.9.3", - "itertools 0.14.0", + "indexmap 2.14.2", + "itertools", "proc-macro-crate", "proc-macro2", "quote", @@ -1091,19 +1091,19 @@ dependencies = [ [[package]] name = "derive-deftly-macros" -version = "1.11.5" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" +checksum = "36e10ac4bd47bf8d24a4852f001df23b7acbbca6e59d0adec0c2e2c4409dfe5b" dependencies = [ "heck 0.5.0", - "indexmap 1.9.3", - "itertools 0.14.0", + "indexmap 2.14.2", + "itertools", "proc-macro-crate", "proc-macro2", "quote", "sha3", - "strum 0.27.2", - "syn 2.0.105", + "strum 0.28.0", + "syn 3.0.5", "unicode-ident", "void", ] @@ -1222,7 +1222,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1522,9 +1522,9 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.15.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9" +checksum = "bc7f210768dd30e30895a6024be8472615bd7b6caec6df4f4105fc03e7608bdd" dependencies = [ "derive_builder_fork_arti", "dirs 6.0.0", @@ -1539,9 +1539,9 @@ dependencies = [ [[package]] name = "fslock-guard" -version = "0.8.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1" +checksum = "8082ded4af6b2e8e75d0ff71848ad7649d1ddff369a718e8e1db918795967bce" dependencies = [ "libc", "thiserror 2.0.20", @@ -1581,9 +1581,9 @@ dependencies = [ [[package]] name = "futures-copy" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ac0c1a7354d4bbc3264ba752342f2cb8c606c4897fed4ff88a6f266b9dc2388" +checksum = "853fa8b969b511030e15d9d51db878103b4f1a92d27cb30c63069ae9412f0546" dependencies = [ "extend", "futures", @@ -2130,15 +2130,6 @@ version = "1.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.15.0" @@ -2473,7 +2464,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2590,9 +2581,9 @@ dependencies = [ [[package]] name = "oneshot-fused-workaround" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2" +checksum = "40e2e88d3d699c52530078d715a6b5b10b3010afe570cfdc0240fee1c9d764ba" dependencies = [ "futures", ] @@ -3151,9 +3142,9 @@ dependencies = [ [[package]] name = "retry-error" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0" +checksum = "90a0dea62e6084ede74beea58ee263fd2d70bdb59260a3bf3ede338dfb76f5cc" dependencies = [ "humantime", "web-time", @@ -3305,9 +3296,9 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "safelog" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029" +checksum = "3e091498d7a0a629f344befcf15b1f823a24d5a83d584feeacacc7c764e67c11" dependencies = [ "derive_more", "educe", @@ -3336,7 +3327,10 @@ dependencies = [ [[package]] name = "saturating-time" -version = "0.4.0" +version = "0.5.0" +dependencies = [ + "web-time-compat", +] [[package]] name = "schannel" @@ -3639,15 +3633,14 @@ dependencies = [ [[package]] name = "slotmap-careful" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac" +checksum = "7dae6673c710ab011fc8d2ff604b16201df0a59f943976d911d83bbac2a1854f" dependencies = [ "paste", "serde", "slotmap", "thiserror 2.0.20", - "void", ] [[package]] @@ -3663,7 +3656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4145,12 +4138,12 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31d9f28de36ed7646f0f03835ce2e306e2c5df8a1784a2cac27bd549cdc07599" +checksum = "64e39ddd4c352b8e3166ea0ec49b82a33ad9eeae8bdbbf76e1d2f1e856a6316b" dependencies = [ "cfg-if", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "educe", "extend", "futures", @@ -4171,17 +4164,17 @@ dependencies = [ [[package]] name = "tor-basic-utils" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2bd42110ae0faea1d446b35899aa5c3591cd31a94b593aa9264f00baba41da" +checksum = "e44c7b006f22966d44a2a0a6c1ee71e472c62182c7f0460077df3294d52f23c4" dependencies = [ - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "educe", "extend", "getrandom 0.4.3", "hex", - "itertools 0.15.0", + "itertools", "libc", "paste", "rand 0.10.2", @@ -4197,12 +4190,12 @@ dependencies = [ [[package]] name = "tor-bytes" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73de2ba54a125c27a440616f2d806a171bcb4e30013de166c7dc0a79bde44ab4" +checksum = "ccfed3adc9db61334445e921b95c26c896994db64e031c31e5cc48bd73e57ac7" dependencies = [ "bytes", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "digest", "educe", "getrandom 0.4.3", @@ -4215,18 +4208,18 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750" +checksum = "b2d6398fd085306de5af739899d0390674b8646d4ff6cfb69d65f40a5f9d88da" dependencies = [ "amplify", "bitflags 2.9.1", "bytes", "caret", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "educe", - "itertools 0.15.0", + "itertools", "paste", "rand 0.10.2", "smallvec", @@ -4246,15 +4239,14 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2cec6ae3abafc382e126a26e74f000bffbbf2c9b1a8c9ec26bb84463f0120b4" +checksum = "98dec924caf6e9548b4860878373132bf89536eb0d1658666df12dff72b855cd" dependencies = [ "caret", "derive_builder_fork_arti", "derive_more", "digest", - "saturating-time", "thiserror 2.0.20", "tor-bytes", "tor-checkable", @@ -4265,15 +4257,15 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cef673b170f10790ff81d2f190e0a8150e5c9683c2c8f4c09652a6954c0e5ab" +checksum = "431ba53bf020ea8681ef0c2fbf0f0f854997fca9afe1b2c081f41f14ec00cf5d" dependencies = [ "async-trait", "base64ct", "caret", "cfg-if", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "educe", "futures", @@ -4308,12 +4300,12 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535" +checksum = "e58a50a2dbf5d21758c9e2dbaaf085403670b8bf52475c815dd1185698b7af9a" dependencies = [ "humantime", - "itertools 0.15.0", + "itertools", "signature", "thiserror 2.0.20", "tor-llcrypto", @@ -4322,14 +4314,14 @@ dependencies = [ [[package]] name = "tor-circmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cf1eb6c2e3184c9e51c746df81f1a890ba7b663ac418debe2dac3756d840c8a" +checksum = "8b6b55747c690e0c931c5a081640ffdca6d1003c01b57b8102afcbdbaf3ec3fd" dependencies = [ "amplify", "async-trait", "cfg-if", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_builder_fork_arti", "derive_more", "downcast-rs", @@ -4337,7 +4329,7 @@ dependencies = [ "educe", "futures", "humantime-serde", - "itertools 0.15.0", + "itertools", "once_cell", "oneshot-fused-workaround", "pin-project", @@ -4372,13 +4364,13 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8ca5684100c9223590c11685f3f39c4e4c0037c65daddc26927b3ae4cc893d" +checksum = "b849c69c695b9534cf945c0422b85eaf5760bb6141a73c47b72fd6f95ea4d719" dependencies = [ "amplify", "cfg-if", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_builder_fork_arti", "educe", "either", @@ -4387,7 +4379,7 @@ dependencies = [ "fs-mistrust", "futures", "humantime-serde", - "itertools 0.15.0", + "itertools", "notify", "paste", "postage", @@ -4407,9 +4399,9 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbed06099bacbdbb1d91cfff1032863896d6097c33eece067ab20756e4a6fa2b" +checksum = "3b7f10bfd0d0fb159a8abc13d2593625bfd9f9a394715605262236d01bc5500f" dependencies = [ "directories", "serde", @@ -4421,12 +4413,12 @@ dependencies = [ [[package]] name = "tor-config-shared" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93dbc005cca78ea51b726c8da13fda60329dd57bc2ccd97efe674dbb84d5eee4" +checksum = "00e73d2fdf5506c79d878bfd1863585a22c3696e1bac64f5f00ce7e7bdf8ab12" dependencies = [ "amplify", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "serde", "tor-config", "tor-config-path", @@ -4434,9 +4426,9 @@ dependencies = [ [[package]] name = "tor-consdiff" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9760d0051159a257b1d0065615ad6bc023da4e178d41ff5640222a29852de848" +checksum = "a6ed097865bfb6d453d9258620cbc952cf7481e9dcfc8901071ee1b8506cea86" dependencies = [ "derive_more", "digest", @@ -4451,9 +4443,9 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403a79a0d00de6c78f15773a284e790459cff6983449e8578490e6b5dfb2afcd" +checksum = "f942f4ebaf2ac5275e48d02d77abe1616ce2e8854f032a1b0698ede0566dddfc" dependencies = [ "async-compression", "base64ct", @@ -4463,8 +4455,7 @@ dependencies = [ "http", "httparse", "httpdate", - "itertools 0.15.0", - "memchr", + "itertools", "thiserror 2.0.20", "tor-circmgr", "tor-error", @@ -4480,12 +4471,12 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5cd2a4f641ecb007cc50914bb1b46128de0975aba08baa56497c8e03c71c71" +checksum = "c16065e5166e529492d1be856e1e40316e2532053027c6b7ab5a4a53c14d8c51" dependencies = [ "base64ct", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "getset", "humantime", "humantime-serde", @@ -4501,9 +4492,9 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "805da4e947311285ce0782ab129df318701c2c08046c316086cb6bb3eb7a463e" +checksum = "34ea5e77b43bf1ef5fa213a89cb67080f218df6b418c82527e6bb9862a39aef5" dependencies = [ "async-trait", "base64ct", @@ -4518,7 +4509,7 @@ dependencies = [ "hex", "humantime", "humantime-serde", - "itertools 0.15.0", + "itertools", "memmap2", "oneshot-fused-workaround", "paste", @@ -4558,9 +4549,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f29e906f9f333a6e32e32afac9fb6712ac340191eb3292295b3337682b2dad" +checksum = "ca01de513501849b2298e4f16f8760832ef43344b10fe17600daed9a35128b24" dependencies = [ "derive_more", "futures", @@ -4576,9 +4567,9 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b45ecd95dbf79070f9e477e1d84b22e47f03b2ab2f853abe31887daa9090cf0" +checksum = "72965a7044f573bb121467404d423d296592dd3cef87ad7aca4c96cc2b65e75a" dependencies = [ "derive_more", "thiserror 2.0.20", @@ -4587,13 +4578,13 @@ dependencies = [ [[package]] name = "tor-guardmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e84beedbf276ce4ea170206b38ca01c995dbf1b9f678e9708f50e2e71dbfef4" +checksum = "87694603d6dcee5e0b050f0e7ac8be86348eb78ecb90c36ee8b70bd5822f3eff" dependencies = [ "amplify", "base64ct", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_builder_fork_arti", "derive_more", "dyn-clone", @@ -4601,7 +4592,7 @@ dependencies = [ "futures", "humantime", "humantime-serde", - "itertools 0.15.0", + "itertools", "num_enum", "oneshot-fused-workaround", "pin-project", @@ -4632,18 +4623,18 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eba66be03b5eb899185e5b4416d8893fdb09a97b33db9e450042f06e2550f0e" +checksum = "42addb7f2a7a86e813df223ed4485f2fb61b5736aff040cb5e8f551f83dfade0" dependencies = [ "async-trait", "cfg-if", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "educe", "either", "futures", - "itertools 0.15.0", + "itertools", "oneshot-fused-workaround", "postage", "rand 0.10.2", @@ -4678,17 +4669,17 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118ff6a69bfa111b6e8d087ffb107ea9cbcd5d4469295cd7ada1b3cf19ba5a0f" +checksum = "6613c5336b71ebd34b8d90c233a7b6b06a0b81899d0545c656b0cac6f24e54a2" dependencies = [ "data-encoding", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "digest", "hex", "humantime", - "itertools 0.15.0", + "itertools", "paste", "rand 0.10.2", "safelog", @@ -4709,11 +4700,11 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1861b5eebab93515a0ab2401540f3d5a9fb376f1b1535d5793dcd697fb19f928" +checksum = "5534581a7f347ffcade7ea32ecccdbc035d36468bc8c03b0dce83363b6d41801" dependencies = [ - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "downcast-rs", "paste", @@ -4731,14 +4722,15 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3088a573dadebb34acacb30d686751e2a94d12ccf9d6647946ab6c6b4ad5a0e" +checksum = "e269210a2ba305bf6524990a3c37aab4529b92a01f3d15ef6af23c067c8eb2c4" dependencies = [ "amplify", "arrayvec", "cfg-if", - "derive-deftly 1.11.5", + "data-encoding", + "derive-deftly 1.12.1", "derive_builder_fork_arti", "derive_more", "downcast-rs", @@ -4747,7 +4739,7 @@ dependencies = [ "glob-match", "humantime", "inventory", - "itertools 0.15.0", + "itertools", "rand 0.10.2", "safelog", "serde", @@ -4772,18 +4764,18 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8d3a2aaf3bb67110218b9ff1718cd7d6c7e62226e2fee1a18efa6aa19ff07c" +checksum = "d287978bd5e1ffbe7ca2eb8daadb4e06e42d084bdad26bea4d4bbe1ff877e10f" dependencies = [ "base64ct", "by_address", "caret", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_builder_fork_arti", "derive_more", "hex", - "itertools 0.15.0", + "itertools", "safelog", "serde", "serde_with", @@ -4799,16 +4791,16 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e" +checksum = "95f921a609ff73736e8e21d11dbee6617cd7568841790f2e3327295627a51b75" dependencies = [ "aes", "base64ct", "ctr", "curve25519-dalek", "der-parser", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "digest", "ed25519-dalek", @@ -4842,9 +4834,9 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f8696c8ea9090059d91eea287628b5df0b3b4fba433179d893e348857857544" +checksum = "2096a87f0691f0781bf564249c524d62b5338c3954ba1c6ef10ec90df1d97733" dependencies = [ "futures", "humantime", @@ -4858,18 +4850,18 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03786604757846a5f936285eef397e3fc0ed455893dc5012ceafdf455ada0d8d" +checksum = "96853fc516264fe2ebefbe40d276b6a3b84e15fc08b74fd7a85e2c5df9f11c37" dependencies = [ "cfg-if", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "dyn-clone", "educe", "extend", "futures", - "itertools 0.15.0", + "itertools", "paste", "pin-project", "serde", @@ -4890,21 +4882,21 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c" +checksum = "f5e5712bfe2995a60eddcc32d07e2e38a42120318a8f5411d621bf0a8fa77219" dependencies = [ - "derive-deftly 1.11.5", - "itertools 0.15.0", + "derive-deftly 1.12.1", + "itertools", "paste", "void", ] [[package]] name = "tor-netdir" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798" +checksum = "cd22ffd4d6aa9389ec8e20e9b0d579d61101682c5ec8379115a388e473e83297" dependencies = [ "async-trait", "bitflags 2.9.1", @@ -4913,7 +4905,7 @@ dependencies = [ "futures", "hex", "humantime", - "itertools 0.15.0", + "itertools", "num_enum", "rand 0.10.2", "serde", @@ -4935,14 +4927,14 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b6abd7aaeb2bdbb3fbf760aab9aaa4e8e1a0f8b84d93a834df5a16882faa31e" +checksum = "78dc8a397a86fd00b17607941b204281dd8a77a6018ca8eedc257bdccb4f3aa7" dependencies = [ "amplify", "base64ct", "cipher 0.5.2", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_builder_fork_arti", "derive_more", "digest", @@ -4954,7 +4946,7 @@ dependencies = [ "humantime", "ipnet", "iprange", - "itertools 0.15.0", + "itertools", "memchr", "paste", "phf", @@ -4988,18 +4980,18 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f2a41d13a685e181361cfc3bdf25d319b96b56f5806594b6b8ed760d0099ed" +checksum = "e7a3bc60cbe74bf41cd63e2506a9a16bd52047668771578b12ff2453ec4c7522" dependencies = [ - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "extend", "filetime", "fs-mistrust", "fslock-guard", "futures", - "itertools 0.15.0", + "itertools", "oneshot-fused-workaround", "paste", "sanitize-filename", @@ -5007,7 +4999,6 @@ dependencies = [ "serde_json", "thiserror 2.0.20", "time", - "tor-async-utils", "tor-basic-utils", "tor-error", "tracing", @@ -5017,9 +5008,9 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880" +checksum = "fceb9d4017849440584e541054b6a26517655a3ae48942b6e67476c5376262c3" dependencies = [ "aes", "amplify", @@ -5032,7 +5023,7 @@ dependencies = [ "cipher 0.5.2", "coarsetime", "ctr", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_builder_fork_arti", "derive_more", "digest", @@ -5042,7 +5033,7 @@ dependencies = [ "futures-util", "hkdf", "hmac", - "itertools 0.15.0", + "itertools", "nonany", "oneshot-fused-workaround", "pin-project", @@ -5086,12 +5077,12 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab080a7551561bd56c0c2e3578b7114f0853700b96e43613fabc1fe06e5d885" +checksum = "3e69fbc196208a9d6232d2c38e32806f3bb064792fd84ac6ee12b5ba1e482240" dependencies = [ "caret", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "paste", "serde_with", "thiserror 2.0.20", @@ -5101,17 +5092,17 @@ dependencies = [ [[package]] name = "tor-ptmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523678f0839a2429386518d5e1c5b26cfaac77b1cbf44a8d8f5b14377e2025a7" +checksum = "0068103033ae2dcedf88691c4758b1e483dd31d325e4891f5e0d3bb41f24ecbc" dependencies = [ "async-trait", "cfg-if", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_builder_fork_arti", "fs-mistrust", "futures", - "itertools 0.15.0", + "itertools", "oneshot-fused-workaround", "serde", "thiserror 2.0.20", @@ -5131,9 +5122,9 @@ dependencies = [ [[package]] name = "tor-relay-selection" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07a5c22b7cfd59c40e38d4e111906793c0d65cf881e31efe3a83cac42887b1c9" +checksum = "e420365ef1e89ee93b34b5ab71a6aa840cadcbe04e2ac4b72043846fb2f9e002" dependencies = [ "rand 0.10.2", "serde", @@ -5145,9 +5136,9 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "542f4e13054bbba8d764ee24178fd889f77afb59dca7c6ae0a370604118c20b6" +checksum = "99036f7a4fa9735e0beac7f241592da1a072c94a018e4fa702001700f5922291" dependencies = [ "amplify", "async-native-tls", @@ -5180,19 +5171,19 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cb78b3a39634e46c1bc795f62fca17b4d8a9404fead18750592a03216e1da3" +checksum = "9140515718465f1ede62fa33e96cc929acf075a3893cb349d1c606976c846a21" dependencies = [ "amplify", "assert_matches", "async-trait", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "educe", "futures", "humantime", - "itertools 0.15.0", + "itertools", "oneshot-fused-workaround", "pin-project", "priority-queue", @@ -5210,13 +5201,12 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f96e25e5d36b4aff59af40ec86f7ebb227a32b2f726ef1bc8cccf927f8fb31a" +checksum = "62d1f5c19f2339b2634556ed35865844276ab889708b482dc351fa18184c5b24" dependencies = [ - "amplify", "caret", - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "educe", "safelog", "subtle", @@ -5227,11 +5217,11 @@ dependencies = [ [[package]] name = "tor-units" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a562c031f6c1f9d22f7bbcfbe715840d0e2cfe5e59d1296cc26000c17130555" +checksum = "aa976f4c84404c9aaf7e8690fe30904a69084d0069ff7b2dfc0c58919d65eebd" dependencies = [ - "derive-deftly 1.11.5", + "derive-deftly 1.12.1", "derive_more", "serde", "thiserror 2.0.20", @@ -5255,7 +5245,6 @@ dependencies = [ "saturating-time", "security-framework", "tokio", - "tor-config", "tor-rtcompat", ] @@ -5578,9 +5567,9 @@ dependencies = [ [[package]] name = "web-time-compat" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7" +checksum = "b1c38a07cb4a2705c91feaedc9013a65481ec1b38d818b885d642b9f06ec6edd" dependencies = [ "web-time", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b08966f4..d4935018 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -15,10 +15,9 @@ crate-type = ["cdylib", "staticlib"] [dependencies] lazy_static = "1.4" tokio = { version = "1", features = ["full"] } -arti-client = { version = "0.46.0", features = ["static", "onion-service-client"] } -arti = { version = "2.6.0", features = ["experimental-api", "static"] } -tor-rtcompat = { version = "0.46.0", features = ["static", "native-tls", "tokio"] } -tor-config = "=0.46.0" +arti-client = { version = "0.47.0", features = ["static", "onion-service-client"] } +arti = { version = "2.7.0", features = ["experimental-api", "static"] } +tor-rtcompat = { version = "0.47.0", features = ["static", "native-tls", "tokio"] } log = "0.4.20" lzma-sys = { version = "0.1.20", features = ["static"] } #android_log-sys = "0.3.1" @@ -37,11 +36,11 @@ cbindgen = "= 0.24.3" glob = "0.3.1" [dev-dependencies] -saturating-time = "0.4.0" +saturating-time = "0.5.0" -# saturating-time 0.4.0 (via tor-netdoc) never finishes computing the minimum +# saturating-time 0.5.0 (via tor-netdoc) never finishes computing the minimum # SystemTime on Windows, which hangs consensus parsing and therefore bootstrap. -# The vendored copy only changes `find_limit` to stop on steps below the -# platform's clock resolution. +# The vendored copy guards `find_limit` against steps below the platform's +# clock resolution and tests the same rounding behavior on every platform. [patch.crates-io] saturating-time = { path = "patches/saturating-time" } diff --git a/rust/patches/saturating-time/Cargo.toml b/rust/patches/saturating-time/Cargo.toml index 5ef0e19d..d8a61881 100644 --- a/rust/patches/saturating-time/Cargo.toml +++ b/rust/patches/saturating-time/Cargo.toml @@ -11,10 +11,13 @@ [package] edition = "2024" -rust-version = "1.85" +rust-version = "1.92" name = "saturating-time" -version = "0.4.0" -authors = ["Clara Engler"] +version = "0.5.0" +authors = [ + "The Tor Project, Inc.", + "Clara Engler ", +] build = false autolib = false autobins = false @@ -35,11 +38,17 @@ categories = [ "rust-patterns", ] license = "MIT OR Apache-2.0" -repository = "https://codeberg.org/cve/saturating-time" +repository = "https://gitlab.torproject.org/tpo/core/arti.git" + +[package.metadata.docs.rs] +all-features = true [features] -nightly = [] +full = ["web-time-compat/full"] [lib] name = "saturating_time" path = "src/lib.rs" + +[dependencies.web-time-compat] +version = "0.3.0" diff --git a/rust/patches/saturating-time/README.md b/rust/patches/saturating-time/README.md index 74d463a5..72000cb4 100644 --- a/rust/patches/saturating-time/README.md +++ b/rust/patches/saturating-time/README.md @@ -32,16 +32,13 @@ However, this crate implements this trait for two structures: ## Example -Add the following to your `Cargo.toml`: -```toml -[dependencies] -saturating-time = "0.4.0" -``` +Add `saturating-time` to your `Cargo.toml`. -If you use Rust nightly, you may want to do: +If you use Rust nightly, you may want to additionally configure the following +in your `.cargo/config.toml`: ```toml -[dependencies] -saturating-time = { version = "0.4.0", features = ["nightly"] } +[build] +rustflags = ["--cfg", "saturating_time_nightly"] ``` Now, you can use `saturating-time` in your code: @@ -95,7 +92,15 @@ None yet. [^5]: [^6]: +## History + +Up until (and including) version 0.4.0, `saturating-time` was maintained as an +independent project hosted on [Codeberg](https://codeberg.org/cve/saturating-time). + +Starting with version 0.5.0, it has been incorporated into the +[Arti](https://arti.torproject.org) and is therefore maintained by +*The Tor Project, Inc.* + ## License and Copyright This crate is licensed under `MIT OR Apache-2.0`. -See the respective `LICENSE-*` files in the repository for more information. diff --git a/rust/patches/saturating-time/src/internal.rs b/rust/patches/saturating-time/src/internal.rs index 0c7cf8fa..5a6bd468 100644 --- a/rust/patches/saturating-time/src/internal.rs +++ b/rust/patches/saturating-time/src/internal.rs @@ -73,7 +73,8 @@ impl SaturatingTime for SystemTime { impl SaturatingTime for Instant { fn anchor() -> Self { - Self::now() + use web_time_compat::InstantExt; + Self::get() } fn max_value() -> Self { @@ -127,9 +128,8 @@ fn find_min() -> T { /// 2. If [`None`] or unchanged, and `step == 1ns`, return `res`. /// 3. Else, set `step` to `MAX{1ns, step / 2}` and continue. /// -/// A step below the platform's clock resolution (100ns for Windows' -/// [`SystemTime`]) rounds to zero and leaves `res` unchanged, so it is treated -/// like [`None`]. Otherwise the loop would never terminate on such platforms. +/// Steps below the platform clock resolution can leave `res` unchanged. +/// Treat those steps as overflow so the search terminates on Windows. fn find_limit(f: F) -> T where T: SaturatingTime + PartialEq, @@ -147,13 +147,12 @@ where let next = f(&res, step); match next { Some(st) if st != res => { - // (2.1) If Some and changed, set res to the returned value and - // continue. - res = st + // (2.1) Advance only when the clock value changes. + res = st; } Some(_) | None => { if step == ONE_NS { - // (2.2) If None or unchanged and step == 1ns, return res. + // (2.2) Stop when no representable step remains. return res; } else { // (2.3) Else, set step to MAX{1ns, step / 2}. @@ -166,6 +165,21 @@ where #[cfg(test)] mod tests { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + use std::{ fmt::Debug, ops::{Add, Sub}, @@ -173,6 +187,20 @@ mod tests { use super::*; + #[test] + fn limit_search_handles_clock_resolution_below_one_nanosecond_precision() { + let calls = std::cell::Cell::new(0); + let minimum = find_limit::(|time, step| { + calls.set(calls.get() + 1); + assert!(calls.get() < 1000, "limit search stopped making progress"); + // Model Windows SystemTime's 100ns resolution on every platform. + let rounded = Duration::new(step.as_secs(), step.subsec_nanos() / 100 * 100); + time.checked_sub(rounded) + }); + assert!(minimum < SystemTime::UNIX_EPOCH); + assert!(minimum.checked_sub(Duration::from_nanos(100)).is_none()); + } + /// Checks whether the minimum and maximum values are correct. fn min_max() where diff --git a/rust/patches/saturating-time/src/lib.rs b/rust/patches/saturating-time/src/lib.rs index cb24b64a..03b3e239 100644 --- a/rust/patches/saturating-time/src/lib.rs +++ b/rust/patches/saturating-time/src/lib.rs @@ -1,7 +1,54 @@ #![doc = include_str!("../README.md")] +// @@ begin lint list maintained by maint/add_warning @@ +#![allow(renamed_and_removed_lints)] // @@REMOVE_WHEN(ci_arti_stable) +#![allow(unknown_lints)] // @@REMOVE_WHEN(ci_arti_nightly) +#![warn(missing_docs)] +#![warn(noop_method_call)] +#![warn(unreachable_pub)] +#![warn(clippy::all)] +#![deny(clippy::await_holding_lock)] +#![deny(clippy::cargo_common_metadata)] +#![deny(clippy::cast_lossless)] +#![deny(clippy::checked_conversions)] +#![allow(clippy::cognitive_complexity)] // See arti#2556 +#![deny(clippy::debug_assert_with_mut_call)] +#![deny(clippy::exhaustive_enums)] +#![deny(clippy::exhaustive_structs)] +#![deny(clippy::expl_impl_clone_on_copy)] +#![deny(clippy::fallible_impl_from)] +#![deny(clippy::implicit_clone)] +#![deny(clippy::large_stack_arrays)] +#![warn(clippy::manual_ok_or)] +#![deny(clippy::missing_docs_in_private_items)] +#![warn(clippy::needless_borrow)] +#![warn(clippy::needless_pass_by_value)] +#![warn(clippy::option_option)] +#![deny(clippy::print_stderr)] +#![deny(clippy::print_stdout)] +#![warn(clippy::rc_buffer)] +#![deny(clippy::ref_option_ref)] +#![warn(clippy::semicolon_if_nothing_returned)] +#![warn(clippy::trait_duplication_in_bounds)] +#![deny(clippy::unchecked_time_subtraction)] +#![deny(clippy::unnecessary_wraps)] +#![warn(clippy::unseparated_literal_suffix)] +#![deny(clippy::unwrap_used)] +#![deny(clippy::mod_module_files)] +#![allow(clippy::let_unit_value)] // This can reasonably be done for explicitness +#![allow(clippy::uninlined_format_args)] +#![allow(clippy::significant_drop_in_scrutinee)] // arti/-/merge_requests/588/#note_2812945 +#![allow(clippy::result_large_err)] // temporary workaround for arti#587 +#![allow(clippy::needless_raw_string_hashes)] // complained-about code is fine, often best +#![allow(clippy::needless_lifetimes)] // See arti#1765 +#![allow(mismatched_lifetime_syntaxes)] // temporary workaround for arti#2060 +#![allow(clippy::collapsible_if)] // See arti#2342 +#![deny(clippy::unused_async)] +#![deny(clippy::string_slice)] // See arti#2571 +//! +#![allow(unexpected_cfgs)] #![forbid(unsafe_code)] #![cfg_attr( - feature = "nightly", + saturating_time_nightly, feature(time_systemtime_limits, time_saturating_systemtime) )] @@ -157,7 +204,7 @@ pub trait SaturatingTime: internal::SaturatingTime { } // Use nightly implementation if compiled with the nightly feature. -#[cfg(feature = "nightly")] +#[cfg(saturating_time_nightly)] impl SaturatingTime for SystemTime { fn max_value() -> Self { Self::MAX @@ -181,7 +228,7 @@ impl SaturatingTime for SystemTime { } // Otherwise, use the default one. -#[cfg(not(feature = "nightly"))] +#[cfg(not(saturating_time_nightly))] impl SaturatingTime for SystemTime {} impl SaturatingTime for Instant { @@ -193,6 +240,21 @@ impl SaturatingTime for Instant { #[cfg(test)] mod tests { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::mixed_attributes_style)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_time_subtraction)] + #![allow(clippy::useless_vec)] + #![allow(clippy::needless_pass_by_value)] + #![allow(clippy::string_slice)] // See arti#2571 + //! + use super::*; use crate::internal; use std::{ diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml index 1af0a9b3..b95b4403 100644 --- a/rust/rust-toolchain.toml +++ b/rust/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.91.1" +channel = "1.92.0" profile = "minimal" targets = [ diff --git a/rust/tests/saturating_time.rs b/rust/tests/saturating_time.rs index 3a445c45..9b3fe3ed 100644 --- a/rust/tests/saturating_time.rs +++ b/rust/tests/saturating_time.rs @@ -1,5 +1,5 @@ //! tor-netdoc reads `SystemTime::min_value()` while parsing every consensus. -//! Upstream saturating-time 0.4.0 never finishes computing it on Windows, +//! Upstream saturating-time 0.5.0 never finishes computing it on Windows, //! where `SystemTime` has 100ns resolution, so bootstrap hung forever. use saturating_time::SaturatingTime; From 751e63abbc20c6edb0ab50d24e5c0bd3af2fd230 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 11:36:49 -0500 Subject: [PATCH 26/47] chore(example): update SOCKS sockets and handle Tor lifecycle The example used the old socket API, duplicated initial startup, and left async lifecycle failures uncaught. Adopt socks_socket 2.0.0, await request writes and shutdown, and display start and stop exceptions for retry. Keep Stop available after failed release, disable requests during teardown, clear errors after success, and guard state updates after disposal. Cover write failures, lifecycle error display, retry and disposal with widget tests. --- CHANGELOG.md | 6 + example/lib/main.dart | 104 +++++++++------ example/pubspec.lock | 4 +- example/pubspec.yaml | 2 +- example/test/tor_start_test.dart | 221 +++++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 46 deletions(-) create mode 100644 example/test/tor_start_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b37d10..abb067ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## 0.2.0 +* Update the example to socks_socket 2.0.0, await Tor shutdown, and show start + and stop exception text so failures allow a retry. Keep Stop available after + a failed release and clear errors after success. Ignore asynchronous state + updates after the widget is disposed. Await SOCKS request writes so async + write failures reach the example's connection error handler. + * Bump arti to 2.7.0 and its supporting crates to 0.47.0; needs Rust 1.92. * Rebase the Windows clock-resolution fix onto `saturating-time` 0.5.0; the upstream release still hangs while computing `SystemTime` limits. diff --git a/example/lib/main.dart b/example/lib/main.dart index adf630bf..cc9d6a0c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -24,7 +24,10 @@ class MyApp extends StatelessWidget { } class Home extends StatefulWidget { - const Home({super.key}); + const Home({super.key, this.tor, this.moneroSocketFactory}); + + final Tor? tor; + final Future Function(int proxyPort)? moneroSocketFactory; @override State createState() => _MyAppState(); @@ -33,6 +36,10 @@ class Home extends StatefulWidget { class _MyAppState extends State { // Flag to track if tor has started. bool torIsRunning = false; + String? torError; + bool _torStopFailed = false; + + Tor get _tor => widget.tor ?? Tor.instance; // Set the default text for the host input field. final hostController = TextEditingController(text: 'https://icanhazip.com/'); @@ -68,21 +75,7 @@ class _MyAppState extends State { @override void initState() { super.initState(); - unawaited(init()); - } - - Future init() async { - // Start the Tor daemon. - await Tor.instance.start( - torDataDirPath: (await getApplicationSupportDirectory()).path, - ); - - // Toggle started flag. - setState(() { - torIsRunning = Tor.instance.status == TorStatus.on; // Update flag - }); - - debugPrint('Done awaiting; tor should be running'); + unawaited(startTor()); } @override @@ -94,17 +87,47 @@ class _MyAppState extends State { } Future startTor() async { - // Start the Tor daemon. - await Tor.instance.start( - torDataDirPath: (await getApplicationSupportDirectory()).path, - ); + try { + // Start the Tor daemon. + await _tor.start( + torDataDirPath: (await getApplicationSupportDirectory()).path, + ); - // Toggle started flag. - setState(() { - torIsRunning = Tor.instance.status == TorStatus.on; // Update flag - }); + if (!mounted) return; + setState(() { + torIsRunning = _tor.status == TorStatus.on; + torError = null; + _torStopFailed = false; + }); + debugPrint('Done awaiting; tor should be running'); + } catch (error) { + debugPrint('Unable to start Tor: $error'); + if (!mounted) return; + setState(() { + torIsRunning = false; + torError = 'Unable to start Tor: $error'; + }); + } + } - debugPrint('Done awaiting; tor should be running'); + Future stopTor() async { + try { + await _tor.disable(); + if (!mounted) return; + setState(() { + torIsRunning = false; + torError = null; + _torStopFailed = false; + }); + } catch (error) { + debugPrint('Unable to stop Tor: $error'); + if (!mounted) return; + setState(() { + torIsRunning = false; + torError = 'Unable to stop Tor: $error'; + _torStopFailed = true; + }); + } } Future testMoneroOnionNodeConnection() async { @@ -128,11 +151,13 @@ class _MyAppState extends State { .split("/") .last; // Extract the path - var socksSocket = await SOCKSSocket.create( - proxyHost: InternetAddress.loopbackIPv4.address, - proxyPort: Tor.instance.port, - sslEnabled: false, - ); + var socksSocket = + await (widget.moneroSocketFactory?.call(_tor.port) ?? + SOCKSSocket.create( + proxyHost: InternetAddress.loopbackIPv4.address, + proxyPort: _tor.port, + sslEnabled: false, + )); await socksSocket.connect(); await socksSocket.connectTo(host, port); @@ -151,7 +176,7 @@ class _MyAppState extends State { '\r\n' '$body'; - socksSocket.write(request); + await socksSocket.write(request); debugPrint("Request sent: $request"); await for (var response in socksSocket.inputStream) { @@ -214,19 +239,12 @@ class _MyAppState extends State { child: const Text("Start tor"), ), TextButton( - onPressed: !torIsRunning - ? null - : () async { - Tor.instance.disable(); - await Tor.instance.stop(); - setState(() { - torIsRunning = false; - }); - }, + onPressed: torIsRunning || _torStopFailed ? stopTor : null, child: const Text("Stop tor"), ), ], ), + if (torError != null) Text(torError!), Row( children: [ // Host input field. @@ -254,7 +272,7 @@ class _MyAppState extends State { SocksTCPClient.assignToHttpClient(client, [ ProxySettings( InternetAddress.loopbackIPv4, - Tor.instance.port, + _tor.port, password: null, ), // TODO get from tor's config file. ]); @@ -291,7 +309,7 @@ class _MyAppState extends State { // Instantiate a socks socket at localhost and on the port selected by the tor service. var socksSocket = await SOCKSSocket.create( proxyHost: InternetAddress.loopbackIPv4.address, - proxyPort: Tor.instance.port, + proxyPort: _tor.port, sslEnabled: true, // For SSL connections. ); @@ -379,7 +397,7 @@ class _MyAppState extends State { // Instantiate a socks socket at localhost and on the port selected by the tor service. var socksSocket = await SOCKSSocket.create( proxyHost: InternetAddress.loopbackIPv4.address, - proxyPort: Tor.instance.port, + proxyPort: _tor.port, sslEnabled: !domain.endsWith( ".onion", ), // For SSL connections. diff --git a/example/pubspec.lock b/example/pubspec.lock index 76d4fc6e..2252ad0d 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -340,10 +340,10 @@ packages: dependency: "direct main" description: name: socks_socket - sha256: "127e919f67de316850d3f1e811ca339c4b85b31e52d1af3838cb5469cb5397f7" + sha256: "09149ed1278584554aeacc60c7aeec5bd80154e6ce1136f00d97485d09d5a338" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "2.0.0" source_span: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b575db77..9becdabf 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: cupertino_icons: ^1.0.2 socks5_proxy: 1.0.3+dev.3 path_provider: ^2.1.6 - socks_socket: ^1.1.0 + socks_socket: ^2.0.0 dev_dependencies: flutter_test: diff --git a/example/test/tor_start_test.dart b/example/test/tor_start_test.dart new file mode 100644 index 00000000..0f0ad722 --- /dev/null +++ b/example/test/tor_start_test.dart @@ -0,0 +1,221 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tor_ffi_plugin_example/main.dart'; +import 'package:socks_socket/socks_socket.dart'; +import 'package:tor_ffi_plugin/tor_ffi_plugin.dart'; + +class _ExampleTorNative extends TorNative { + int freeCalls = 0; + int freeFailures = 0; + Completer? freeGate; + + @override + Future start(int port, String stateDir, String cacheDir) async => + ( + client: Pointer.fromAddress(1), + proxy: Pointer.fromAddress(2), + port: 19050, + ); + + @override + Future bootstrap(Pointer client, Duration timeout) async {} + + @override + void startProxy(Pointer proxy) {} + + @override + Future stopProxy(Pointer proxy) async {} + + @override + Future freeClient(Pointer client) async { + if (client == nullptr) return; + freeCalls++; + final gate = freeGate; + freeGate = null; + await gate?.future; + if (freeFailures > 0) { + freeFailures--; + throw Exception('client release failed'); + } + } +} + +class _FailedWriteSocket implements SOCKSSocket { + bool readStarted = false; + + @override + Future connect() async {} + + @override + Future connectTo(String host, int port) async {} + + @override + Future write(Object? object, {bool newline = false}) async { + throw Exception('request write failed'); + } + + @override + Stream> get inputStream { + readStarted = true; + return const Stream.empty(); + } + + @override + Future close() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('plugins.flutter.io/path_provider'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + Future<(Tor, _ExampleTorNative)> pumpRunningTor( + WidgetTester tester, { + Future Function(int)? moneroSocketFactory, + }) async { + final directory = Directory.systemTemp.createTempSync('tor-example-stop-'); + final native = _ExampleTorNative(); + final tor = Tor.withNative(native); + addTearDown(() => directory.deleteSync(recursive: true)); + messenger.setMockMethodCallHandler(channel, (_) async => directory.path); + await tester.pumpWidget( + MaterialApp( + home: Home(tor: tor, moneroSocketFactory: moneroSocketFactory), + ), + ); + for ( + var attempt = 0; + tor.status != TorStatus.on && attempt < 200; + attempt++ + ) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 5)), + ); + await tester.pump(); + } + expect(tor.status, TorStatus.on); + await tester.pumpAndSettle(); + return (tor, native); + } + + testWidgets('a failed Monero request write reaches the error handler', ( + tester, + ) async { + final socket = _FailedWriteSocket(); + await pumpRunningTor(tester, moneroSocketFactory: (_) async => socket); + final messages = []; + final originalDebugPrint = debugPrint; + debugPrint = (message, {wrapWidth}) { + if (message != null) messages.add(message); + }; + try { + final button = find.text('Test Monero onion node connection'); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(socket.readStarted, isFalse); + expect( + messages, + contains( + contains( + 'Error connecting to Monero onion node: Exception: request write failed', + ), + ), + ); + } finally { + debugPrint = originalDebugPrint; + } + }); + + testWidgets('a stop failure is shown and can be retried', (tester) async { + final (tor, native) = await pumpRunningTor(tester); + native.freeFailures = 1; + + await tester.tap(find.text('Stop tor')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(tor.status, TorStatus.on); + expect( + find.text('Unable to stop Tor: Exception: client release failed'), + findsOneWidget, + ); + final requestButton = find.widgetWithText( + TextButton, + 'Test Monero onion node connection', + ); + expect(tester.widget(requestButton).onPressed, isNull); + final stopButton = find.widgetWithText(TextButton, 'Stop tor'); + expect(tester.widget(stopButton).onPressed, isNotNull); + + await tester.tap(stopButton); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(native.freeCalls, 2); + expect(find.textContaining('Unable to stop Tor:'), findsNothing); + expect(tester.widget(stopButton).onPressed, isNull); + }); + + testWidgets('a stop failure after disposal does not update state', ( + tester, + ) async { + final (_, native) = await pumpRunningTor(tester); + final gate = native.freeGate = Completer(); + + await tester.tap(find.text('Stop tor')); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + gate.completeError(Exception('client release failed after disposal')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('a start failure is shown and the retry dialog closes', ( + tester, + ) async { + final error = PlatformException( + code: 'support-directory-unavailable', + message: 'Application support directory is unavailable', + ); + messenger.setMockMethodCallHandler(channel, (_) async => throw error); + + await tester.pumpWidget(const MyApp()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Unable to start Tor: $error'), findsOneWidget); + + await tester.tap(find.text('Start tor')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byType(Dialog), findsNothing); + expect(find.text('Unable to start Tor: $error'), findsOneWidget); + }); + + testWidgets('a start failure after disposal does not update state', ( + tester, + ) async { + final directory = Completer(); + messenger.setMockMethodCallHandler(channel, (_) => directory.future); + + await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(const SizedBox()); + directory.completeError( + PlatformException(code: 'support-directory-unavailable'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); +} From 40092fa7d6799bb18f3bbe441a9a61a0f9c0e184 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 13 Oct 2026 11:03:22 -0500 Subject: [PATCH 27/47] build(rust): update advisory-affected crates Update semver-compatible lockfile entries for anyhow, event-listener, memmap2, keccak, rand, bytemuck, and subtle. This resolves the fixable unsoundness and yanked-version findings in the current RustSec database. --- rust/Cargo.lock | 42 ++++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ff2c2216..fbe410ab 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -139,9 +139,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.86" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arrayvec" @@ -517,9 +517,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.16.1" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b236fc92302c97ed75b38da1f4917b5cdda4984745740f153a5d3059e48d725e" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -719,15 +719,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -1103,7 +1094,7 @@ dependencies = [ "quote", "sha3", "strum 0.28.0", - "syn 3.0.5", + "syn 2.0.105", "unicode-ident", "void", ] @@ -1389,11 +1380,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.3.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2216,9 +2206,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ "cpufeatures 0.2.12", ] @@ -2347,9 +2337,9 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memmap2" -version = "0.9.4" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -2488,7 +2478,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -2981,9 +2971,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", @@ -3785,9 +3775,9 @@ dependencies = [ [[package]] name = "subtle" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0208408ba0c3df17ed26eb06992cb1a1268d41b2c0e12e65203fbe3972cee5" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" From 87c727eb4c03f685ac8e97b9e035314e741e43c1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 13 Oct 2026 11:12:35 -0500 Subject: [PATCH 28/47] build(rust): vendor the reviewed time dependency fork Keep the reviewed saturating-time 0.5.0 fork vendored under rust/patches at cypherstack/tor e4c225d, so source builds fetch only from crates.io and do not depend on a git branch staying alive. Track it in the prebuilt source fingerprint, but leave out the target directory and Cargo.lock that building the crate on its own leaves behind, and ignore both in git. --- .gitignore | 1 + CHANGELOG.md | 10 +-- hook/prebuilt.dart | 11 ++- rust/Cargo.toml | 4 - rust/patches/saturating-time/Cargo.toml | 54 +++--------- rust/patches/saturating-time/VENDORED.md | 1 + rust/patches/saturating-time/src/internal.rs | 7 +- test/source_fingerprint_test.dart | 86 +++++++++++++------- 8 files changed, 85 insertions(+), 89 deletions(-) create mode 100644 rust/patches/saturating-time/VENDORED.md diff --git a/.gitignore b/.gitignore index 23086091..cab43c36 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ build/ rust/target/.rustc_info.json rust/target/debug/ +rust/target/release/ rust/target/CACHEDIR.TAG # Cargo output from building a vendored crate on its own. diff --git a/CHANGELOG.md b/CHANGELOG.md index abb067ff..067906a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,10 @@ write failures reach the example's connection error handler. * Bump arti to 2.7.0 and its supporting crates to 0.47.0; needs Rust 1.92. -* Rebase the Windows clock-resolution fix onto `saturating-time` 0.5.0; - the upstream release still hangs while computing `SystemTime` limits. -* Include the vendored crates in `rust/patches/` in the prebuilt source - fingerprint, so editing them invalidates prebuilts and the hook cache. -* Leave Cargo output from building a vendored crate on its own (`target/` and - `Cargo.lock`) out of the native source fingerprint. +* Vendor a reviewed `saturating-time` 0.5.0 fork under `rust/patches/`, with + regression coverage for coarse platform clocks. Source builds fetch only + from crates.io, and the prebuilt source fingerprint covers the patch but + not Cargo output left by building it on its own. * Fix `tor_client_bootstrap` and `tor_client_set_dormant` freeing the client handle Dart still holds. Add `tor_client_free`; `Tor.stop` now releases the client handle. diff --git a/hook/prebuilt.dart b/hook/prebuilt.dart index c21a8212..9de39713 100644 --- a/hook/prebuilt.dart +++ b/hook/prebuilt.dart @@ -42,9 +42,11 @@ Future> sourceDependencies(Uri root) async => [ 'hook/build.dart', 'lib/tor_ffi_plugin_bindings_generated.dart', 'rust/src/', + 'rust/patches/', ]) root.resolve(path), ...await _sourceEntries(Directory.fromUri(root.resolve('rust/src/'))), + ...await _sourceEntries(Directory.fromUri(root.resolve('rust/patches/'))), ]..sort((a, b) => a.path.compareTo(b.path)); // Hash paths and contents so additions, deletions and ABI changes invalidate @@ -85,10 +87,15 @@ Future> _sourceEntries(Directory directory) async => [ ], ]; -/// Whether [entry] is hidden, such as .DS_Store. +/// Whether [entry] is hidden, such as .DS_Store, or Cargo output left by +/// building a vendored crate on its own: its `target` directory, or its +/// lockfile, which Cargo ignores for dependencies. bool _isSkipped(FileSystemEntity entry) { final name = entry.uri.pathSegments.lastWhere((s) => s.isNotEmpty); - return name.startsWith('.'); + if (name.startsWith('.') || name == 'Cargo.lock') return true; + return entry is Directory && + name == 'target' && + File.fromUri(entry.parent.uri.resolve('Cargo.toml')).existsSync(); } /// [bytes] with each CRLF replaced by LF. diff --git a/rust/Cargo.toml b/rust/Cargo.toml index d4935018..9ac2bcbf 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -38,9 +38,5 @@ glob = "0.3.1" [dev-dependencies] saturating-time = "0.5.0" -# saturating-time 0.5.0 (via tor-netdoc) never finishes computing the minimum -# SystemTime on Windows, which hangs consensus parsing and therefore bootstrap. -# The vendored copy guards `find_limit` against steps below the platform's -# clock resolution and tests the same rounding behavior on every platform. [patch.crates-io] saturating-time = { path = "patches/saturating-time" } diff --git a/rust/patches/saturating-time/Cargo.toml b/rust/patches/saturating-time/Cargo.toml index d8a61881..f3ecc576 100644 --- a/rust/patches/saturating-time/Cargo.toml +++ b/rust/patches/saturating-time/Cargo.toml @@ -1,54 +1,20 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - [package] -edition = "2024" -rust-version = "1.92" name = "saturating-time" version = "0.5.0" -authors = [ - "The Tor Project, Inc.", - "Clara Engler ", -] -build = false -autolib = false -autobins = false -autoexamples = false -autotests = false -autobenches = false +authors = ["The Tor Project, Inc.", "Clara Engler "] +edition = "2024" +rust-version = "1.92" description = "A trait for limits and saturations on types inside std::time" -readme = "README.md" -keywords = [ - "time", - "system-time", - "instant", - "saturating", -] -categories = [ - "date-and-time", - "os", - "rust-patterns", -] -license = "MIT OR Apache-2.0" repository = "https://gitlab.torproject.org/tpo/core/arti.git" +license = "MIT OR Apache-2.0" +keywords = ["time", "system-time", "instant", "saturating"] +categories = ["date-and-time", "os", "rust-patterns"] -[package.metadata.docs.rs] -all-features = true +[dependencies] +web-time-compat = "0.3.0" [features] full = ["web-time-compat/full"] -[lib] -name = "saturating_time" -path = "src/lib.rs" - -[dependencies.web-time-compat] -version = "0.3.0" +[package.metadata.docs.rs] +all-features = true diff --git a/rust/patches/saturating-time/VENDORED.md b/rust/patches/saturating-time/VENDORED.md new file mode 100644 index 00000000..7ac1f26b --- /dev/null +++ b/rust/patches/saturating-time/VENDORED.md @@ -0,0 +1 @@ +Vendored from https://github.com/cypherstack/tor.git at rev e4c225da252ac866e9edc642ec218b0c4d2f6e52 (branch deps/saturating-time-0.5), crate saturating-time 0.5.0. diff --git a/rust/patches/saturating-time/src/internal.rs b/rust/patches/saturating-time/src/internal.rs index 5a6bd468..aaf5762e 100644 --- a/rust/patches/saturating-time/src/internal.rs +++ b/rust/patches/saturating-time/src/internal.rs @@ -129,7 +129,7 @@ fn find_min() -> T { /// 3. Else, set `step` to `MAX{1ns, step / 2}` and continue. /// /// Steps below the platform clock resolution can leave `res` unchanged. -/// Treat those steps as overflow so the search terminates on Windows. +/// Treat those steps as overflow so the search terminates. fn find_limit(f: F) -> T where T: SaturatingTime + PartialEq, @@ -188,15 +188,16 @@ mod tests { use super::*; #[test] - fn limit_search_handles_clock_resolution_below_one_nanosecond_precision() { + fn limit_search_handles_coarse_clock_resolution() { let calls = std::cell::Cell::new(0); let minimum = find_limit::(|time, step| { calls.set(calls.get() + 1); assert!(calls.get() < 1000, "limit search stopped making progress"); - // Model Windows SystemTime's 100ns resolution on every platform. + // Model a 100ns SystemTime resolution on every platform. let rounded = Duration::new(step.as_secs(), step.subsec_nanos() / 100 * 100); time.checked_sub(rounded) }); + assert!(minimum < SystemTime::UNIX_EPOCH); assert!(minimum.checked_sub(Duration::from_nanos(100)).is_none()); } diff --git a/test/source_fingerprint_test.dart b/test/source_fingerprint_test.dart index deb7bb70..e25de4f5 100644 --- a/test/source_fingerprint_test.dart +++ b/test/source_fingerprint_test.dart @@ -4,23 +4,31 @@ import 'package:flutter_test/flutter_test.dart'; import '../hook/prebuilt.dart'; +/// A minimal package tree containing every tracked native input. +Future sourceTree() async { + final root = await Directory.systemTemp.createTemp('tor-sources-'); + addTearDown(() => root.delete(recursive: true)); + for (final path in [ + 'rust/Cargo.toml', + 'rust/Cargo.lock', + 'rust/rust-toolchain.toml', + 'rust/build.rs', + 'hook/build.dart', + 'lib/tor_ffi_plugin_bindings_generated.dart', + 'rust/src/lib.rs', + 'rust/patches/saturating-time/Cargo.toml', + 'rust/patches/saturating-time/src/lib.rs', + ]) { + final file = File.fromUri(root.uri.resolve(path)); + await file.parent.create(recursive: true); + await file.writeAsString(path); + } + return root; +} + void main() { test('source dependencies are reusable without calculating hashes', () async { - final root = await Directory.systemTemp.createTemp('tor-sources-'); - addTearDown(() => root.delete(recursive: true)); - for (final path in [ - 'rust/Cargo.toml', - 'rust/Cargo.lock', - 'rust/rust-toolchain.toml', - 'rust/build.rs', - 'hook/build.dart', - 'lib/tor_ffi_plugin_bindings_generated.dart', - 'rust/src/lib.rs', - ]) { - final file = File.fromUri(root.uri.resolve(path)); - await file.parent.create(recursive: true); - await file.writeAsString(path); - } + final root = await sourceTree(); final dependencies = await sourceDependencies(root.uri); final fingerprint = await sourceFingerprint(root.uri); @@ -30,22 +38,20 @@ void main() { expect(fingerprint.hash, hasLength(64)); }); + test('a change to a vendored patch changes the fingerprint', () async { + final root = await sourceTree(); + const patched = 'rust/patches/saturating-time/src/lib.rs'; + final before = await sourceFingerprint(root.uri); + + expect(before.dependencies, contains(root.uri.resolve(patched))); + await File.fromUri(root.uri.resolve(patched)).writeAsString('changed'); + final after = await sourceFingerprint(root.uri); + + expect(after.hash, isNot(before.hash)); + }); + test('CRLF line endings do not change the fingerprint', () async { - final root = await Directory.systemTemp.createTemp('tor-sources-'); - addTearDown(() => root.delete(recursive: true)); - for (final path in [ - 'rust/Cargo.toml', - 'rust/Cargo.lock', - 'rust/rust-toolchain.toml', - 'rust/build.rs', - 'hook/build.dart', - 'lib/tor_ffi_plugin_bindings_generated.dart', - 'rust/src/lib.rs', - ]) { - final file = File.fromUri(root.uri.resolve(path)); - await file.parent.create(recursive: true); - await file.writeAsString(path); - } + final root = await sourceTree(); final file = File.fromUri(root.uri.resolve('rust/src/lib.rs')); await file.writeAsBytes('a\nb\r\nc\r'.codeUnits); final lf = await sourceFingerprint(root.uri); @@ -54,4 +60,24 @@ void main() { expect(crlf.hash, lf.hash); }); + + test('cargo output beside a vendored patch is not an input', () async { + final root = await sourceTree(); + final before = await sourceFingerprint(root.uri); + + // What `cargo test` leaves behind when run inside the vendored crate. + const crate = 'rust/patches/saturating-time/'; + final target = Directory.fromUri(root.uri.resolve('${crate}target/debug/')); + await target.create(recursive: true); + await File.fromUri(target.uri.resolve('libsaturating_time.rlib')) + .writeAsString('built'); + await File.fromUri(root.uri.resolve('${crate}Cargo.lock')) + .writeAsString('# lock'); + final after = await sourceFingerprint(root.uri); + + expect(after.hash, before.hash); + expect(after.dependencies, isNot(contains(target.uri))); + // The package's own lockfile is still an input. + expect(after.dependencies, contains(root.uri.resolve('rust/Cargo.lock'))); + }); } From 4d133563828f3bfe75eb6d72b37027386023eafd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 13 Oct 2026 11:19:06 -0500 Subject: [PATCH 29/47] ci: validate native targets and packaged apps Check Rust and Dart, root and example tests, Windows time/IPv6/file release, and packaged applications on pull requests. Reserve the full native target matrix for release tags and manual dispatches. Audit dependencies using a cached pinned executable without granting verification write access. Share pinned toolchain installation and desktop library loading checks. Keep packaged macOS export validation. Keep Cargo's default release stripping. Measured on macOS 26 with Xcode 26.6, Rust 1.92.0 and Flutter 3.47.2: the default, "debuginfo" and "symbols" settings keep every exported tor_* function on aarch64 and x86_64, in plain, prebuilt-style and hook builds, and the packaged example starts Tor. rustc runs rust-objcopy --strip-debug (or --discard-all for "symbols"), which leaves global symbols, and Flutter strips packaged dylibs with strip -x -S anyway. --- .github/workflows/native-prebuilts.yml | 190 +++++++++++++++--- CHANGELOG.md | 4 + README.md | 4 +- .../macos/Runner.xcodeproj/project.pbxproj | 9 - tool/ci/check_macos_app.sh | 11 + tool/ci/install_rust.sh | 8 + tool/ci/smoke_native.py | 11 + tool/ci/test_windows.sh | 6 + tool/ci/windows_paths.ps1 | 8 + 9 files changed, 209 insertions(+), 42 deletions(-) create mode 100644 tool/ci/check_macos_app.sh create mode 100644 tool/ci/install_rust.sh create mode 100644 tool/ci/smoke_native.py create mode 100644 tool/ci/test_windows.sh create mode 100644 tool/ci/windows_paths.ps1 diff --git a/.github/workflows/native-prebuilts.yml b/.github/workflows/native-prebuilts.yml index b59a6523..0deaf3c4 100644 --- a/.github/workflows/native-prebuilts.yml +++ b/.github/workflows/native-prebuilts.yml @@ -1,6 +1,7 @@ -name: Build native prebuilts +name: Native and package validation on: + pull_request: push: tags: ['native-*'] workflow_dispatch: @@ -10,11 +11,85 @@ permissions: concurrency: group: native-prebuilts-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + verify: + name: Rust, Dart, and lifecycle tests + runs-on: ubuntu-22.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - name: Install Linux prerequisites + run: | + sudo apt-get update + sudo apt-get install -y build-essential libgtk-3-dev liblzma-dev ninja-build pkg-config perl + - name: Install pinned Rust toolchain + run: bash tool/ci/install_rust.sh --component clippy,rustfmt + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: rust + - uses: subosito/flutter-action@1508160852fb97248640997f7cfb38da241df0ba # v2.9.1 + with: + flutter-version: '3.47.2' + channel: stable + - name: Verify Rust + run: | + cargo fmt --manifest-path rust/Cargo.toml --check + cargo test --locked --all-targets --manifest-path rust/Cargo.toml + cargo clippy --locked --all-targets --manifest-path rust/Cargo.toml -- -D warnings + - name: Verify Dart and native lifecycle + run: | + flutter pub get + flutter analyze + flutter test + - name: Test example widgets + working-directory: example + run: flutter test + - name: Cache pinned cargo-audit binary + id: cargo-audit-cache + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: ~/.cargo/bin/cargo-audit + key: cargo-audit-${{ runner.os }}-${{ runner.arch }}-0.22.2-${{ hashFiles('rust/rust-toolchain.toml') }} + - name: Install pinned cargo-audit + if: steps.cargo-audit-cache.outputs.cache-hit != 'true' + run: cargo install cargo-audit --version 0.22.2 --locked + - name: Audit Rust dependencies + run: | + cargo audit --file rust/Cargo.lock \ + --deny unsound \ + --deny yanked \ + --ignore RUSTSEC-2021-0145 \ + --ignore RUSTSEC-2023-0071 + + windows-tests: + if: github.event_name == 'pull_request' + name: Windows time, IPv6, and file release tests + runs-on: windows-2022 + timeout-minutes: 60 + env: + NATIVE_TARGET: x86_64-pc-windows-msvc + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - name: Use short Windows build paths + shell: pwsh + run: ./tool/ci/windows_paths.ps1 + - name: Install pinned Rust toolchain + run: bash tool/ci/install_rust.sh --target "$NATIVE_TARGET" + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: rust + key: windows-tests + cache-targets: false + - name: Test time limits, IPv6 fallback, and file release + run: bash tool/ci/test_windows.sh + build: - if: github.event.deleted != true + if: github.event_name != 'pull_request' && github.event.deleted != true name: ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 @@ -42,17 +117,11 @@ jobs: run: shell: bash steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Use short Windows build paths if: runner.os == 'Windows' shell: pwsh - run: | - git config --global core.longpaths true - New-Item -ItemType Directory -Force C:\c, C:\t, C:\tmp | Out-Null - "CARGO_HOME=C:/c" >> $env:GITHUB_ENV - "CARGO_TARGET_DIR=C:/t" >> $env:GITHUB_ENV - "TEMP=C:\tmp" >> $env:GITHUB_ENV - "TMP=C:\tmp" >> $env:GITHUB_ENV - - uses: actions/checkout@v6 + run: ./tool/ci/windows_paths.ps1 - name: Install Linux prerequisites if: runner.os == 'Linux' run: | @@ -64,11 +133,8 @@ jobs: "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" 'ndk;28.2.13676358' echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> "$GITHUB_ENV" - name: Install pinned Rust toolchain - run: | - toolchain=$(sed -n 's/^channel = "\([^"]*\)".*/\1/p' rust/rust-toolchain.toml) - rustup toolchain install "$toolchain" --profile minimal --target "$NATIVE_TARGET" - echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" - - uses: Swatinem/rust-cache@v2 + run: bash tool/ci/install_rust.sh --target "$NATIVE_TARGET" + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: rust key: ${{ matrix.target }} @@ -107,44 +173,104 @@ jobs: esac mkdir -p artifacts cp "$CARGO_TARGET_DIR/$NATIVE_TARGET/release/$library" "artifacts/$NATIVE_TARGET-$library" + - name: Test time limits, IPv6 fallback, and file release + if: runner.os == 'Windows' + run: bash tool/ci/test_windows.sh - name: Smoke test desktop library if: ${{ !contains(matrix.target, 'android') && !contains(matrix.target, '-ios') }} - run: | - python - <<'PY' - import ctypes - from pathlib import Path - library = ctypes.CDLL(str(next(Path('artifacts').iterdir()).resolve())) - library.tor_proxy_stop.argtypes = [ctypes.c_void_p] - library.tor_proxy_stop.restype = None - library.tor_proxy_stop(None) - PY - - uses: actions/upload-artifact@v4 + run: python tool/ci/smoke_native.py artifacts/* + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: prebuilt-${{ matrix.target }} path: artifacts/* if-no-files-found: error + package: + if: github.event_name == 'pull_request' + name: Package example (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - {platform: linux, runner: ubuntu-22.04} + - {platform: android, runner: ubuntu-22.04} + - {platform: ios, runner: macos-15} + - {platform: macos, runner: macos-15} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - name: Install Linux prerequisites + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y build-essential clang cmake libgtk-3-dev liblzma-dev ninja-build pkg-config perl + - name: Install Android Java + if: matrix.platform == 'android' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: '17' + - name: Install Android NDK + if: matrix.platform == 'android' + run: | + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" 'ndk;28.2.13676358' + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> "$GITHUB_ENV" + - name: Install pinned Rust toolchain + run: | + case "${{ matrix.platform }}" in + linux) target=x86_64-unknown-linux-gnu ;; + android) target=aarch64-linux-android ;; + ios) target=aarch64-apple-ios ;; + # flutter build macos is universal, so the hook also builds x86_64. + macos) target=aarch64-apple-darwin,x86_64-apple-darwin ;; + esac + bash tool/ci/install_rust.sh --target "$target" + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: rust + key: package-${{ matrix.platform }} + - uses: subosito/flutter-action@1508160852fb97248640997f7cfb38da241df0ba # v2.9.1 + with: + flutter-version: '3.47.2' + channel: stable + - name: Build packaged example + working-directory: example + run: | + case "${{ matrix.platform }}" in + linux) flutter build linux --release ;; + android) flutter build apk --release --target-platform android-arm64 ;; + ios) flutter build ios --release --no-codesign ;; + macos) flutter build macos --release ;; + esac + - name: Load packaged macOS library + if: matrix.platform == 'macos' + run: bash tool/ci/check_macos_app.sh + release: - needs: build + if: github.event_name != 'pull_request' && github.event.deleted != true + needs: [verify, build] runs-on: ubuntu-22.04 permissions: contents: write steps: - - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: prebuilt-* path: artifacts merge-multiple: true - - uses: subosito/flutter-action@v2 + - uses: subosito/flutter-action@1508160852fb97248640997f7cfb38da241df0ba # v2.9.1 with: flutter-version: '3.47.2' channel: stable - cache: true - run: flutter pub get - name: Generate manifest run: dart --packages=.dart_tool/package_config.json tool/prebuilt_manifest.dart artifacts - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: native-release path: artifacts/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 067906a6..c0aeac0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,10 @@ hash sources as bytes, so stray files no longer fail or invalidate builds. * Avoid hashing all Rust inputs during source builds; full fingerprints remain required when selecting prebuilts. +* Validate packaged macOS framework loading and audit Rust dependencies in CI + without granting write access to the workflow token. +* Keep Cargo's default release stripping on every platform. On macOS it + retains every exported `tor_*` symbol, and the packaged app starts Tor. ## 0.1.0 diff --git a/README.md b/README.md index 4fa304b0..5c767157 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ Prebuilts use dynamic linking without sanitizers and cover: Push a new `native-*` tag (for example `native-0.1.0-1`) to build and publish all targets. Release notes contain the hook settings above with the actual pin. Existing releases are not overwritten. Manual workflow runs on branches only -upload artifacts. To generate the manifest locally, put the target-named +upload artifacts. Pull requests run Rust/Dart tests, Windows time/IPv6/file-release +tests, and example packaging checks; the full cross-build matrix runs for tags +and manual dispatches. To generate the manifest locally, put the target-named libraries in `artifacts/`, run `flutter pub get`, then: ```sh diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 800a7e85..ddde0c38 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -549,16 +549,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 4DQKUWSG6C; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; name = Profile; @@ -677,16 +674,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 4DQKUWSG6C; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; }; @@ -699,16 +693,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 4DQKUWSG6C; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; name = Release; diff --git a/tool/ci/check_macos_app.sh b/tool/ci/check_macos_app.sh new file mode 100644 index 00000000..b6374da4 --- /dev/null +++ b/tool/ci/check_macos_app.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Check that the packaged macOS example's library exports and loads. +set -euo pipefail +cd "$(dirname "$0")/../.." +library=$(find -L example/build/macos/Build/Products/Release \ + -type f \ + -path '*/Contents/Frameworks/tor_ffi_plugin.framework/tor_ffi_plugin' \ + -print -quit) +test -n "$library" +nm -gj "$library" | grep -Fx _tor_proxy_stop +python3 tool/ci/smoke_native.py "$library" diff --git a/tool/ci/install_rust.sh b/tool/ci/install_rust.sh new file mode 100644 index 00000000..22710520 --- /dev/null +++ b/tool/ci/install_rust.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Install the repository toolchain, with caller-selected components/targets. +set -euo pipefail +cd "$(dirname "$0")/../.." +toolchain=$(sed -n 's/^channel = "\([^"]*\)".*/\1/p' rust/rust-toolchain.toml) +test -n "$toolchain" +rustup toolchain install "$toolchain" --profile minimal "$@" +echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" diff --git a/tool/ci/smoke_native.py b/tool/ci/smoke_native.py new file mode 100644 index 00000000..ec7cd6ea --- /dev/null +++ b/tool/ci/smoke_native.py @@ -0,0 +1,11 @@ +"""Load an exact artifact and check its null-safe proxy-stop entry point.""" + +import ctypes +import sys +from pathlib import Path + +library = ctypes.CDLL(str(Path(sys.argv[1]).resolve(strict=True))) +library.tor_proxy_stop.argtypes = [ctypes.c_void_p] +library.tor_proxy_stop.restype = None +library.tor_proxy_stop(None) +print(f"Loaded native library: {sys.argv[1]}") diff --git a/tool/ci/test_windows.sh b/tool/ci/test_windows.sh new file mode 100644 index 00000000..edd9dd6d --- /dev/null +++ b/tool/ci/test_windows.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Run the Windows-only time, IPv6, and file release tests for $NATIVE_TARGET. +set -euo pipefail +cd "$(dirname "$0")/../.." +cargo test --locked --manifest-path rust/Cargo.toml --target "$NATIVE_TARGET" --test saturating_time +cargo test --locked --manifest-path rust/Cargo.toml --target "$NATIVE_TARGET" --lib -- ipv6 closes_its_files diff --git a/tool/ci/windows_paths.ps1 b/tool/ci/windows_paths.ps1 new file mode 100644 index 00000000..0f746b68 --- /dev/null +++ b/tool/ci/windows_paths.ps1 @@ -0,0 +1,8 @@ +# Point cargo, target, and temp directories at short paths on Windows runners. +$ErrorActionPreference = 'Stop' +git config --global core.longpaths true +New-Item -ItemType Directory -Force C:\c, C:\t, C:\tmp | Out-Null +"CARGO_HOME=C:/c" >> $env:GITHUB_ENV +"CARGO_TARGET_DIR=C:/t" >> $env:GITHUB_ENV +"TEMP=C:\tmp" >> $env:GITHUB_ENV +"TMP=C:\tmp" >> $env:GITHUB_ENV From 5f44b07222735d3cf838ba7431e767aa0ace768d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 16 Oct 2026 15:38:46 -0500 Subject: [PATCH 30/47] fix(example): close connections on failure and validate the Bitcoin field The Bitcoin onion button checked the HTTP onion field, so a Bitcoin entry without a port threw from int.parse and left its socket open. Each test now closes its socket or HTTP client in a finally block. --- CHANGELOG.md | 3 + example/lib/main.dart | 330 +++++++++++++++++-------------- example/test/tor_start_test.dart | 6 +- 3 files changed, 187 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0aeac0d..f3e6dd4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ a failed release and clear errors after success. Ignore asynchronous state updates after the widget is disposed. Await SOCKS request writes so async write failures reach the example's connection error handler. + The Bitcoin onion test validates its own field and reports a missing port + instead of throwing, and every example connection closes its socket or + HTTP client when the request fails. * Bump arti to 2.7.0 and its supporting crates to 0.47.0; needs Rust 1.92. * Vendor a reviewed `saturating-time` 0.5.0 fork under `rust/patches/`, with diff --git a/example/lib/main.dart b/example/lib/main.dart index cc9d6a0c..32afe8cc 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -83,6 +83,8 @@ class _MyAppState extends State { // Clean up the controller when the widget is disposed. hostController.dispose(); onionController.dispose(); + bitcoinOnionController.dispose(); + moneroOnionController.dispose(); super.dispose(); } @@ -130,7 +132,18 @@ class _MyAppState extends State { } } + // Close a socket without letting a failing close() hide the original error. + Future _closeQuietly(SOCKSSocket? socksSocket) async { + if (socksSocket == null) return; + try { + await socksSocket.close(); + } catch (e) { + debugPrint("Error closing socket: $e"); + } + } + Future testMoneroOnionNodeConnection() async { + SOCKSSocket? socksSocket; try { // Validate the onion address. if (!moneroOnionController.text.contains(".onion")) { @@ -151,7 +164,7 @@ class _MyAppState extends State { .split("/") .last; // Extract the path - var socksSocket = + socksSocket = await (widget.moneroSocketFactory?.call(_tor.port) ?? SOCKSSocket.create( proxyHost: InternetAddress.loopbackIPv4.address, @@ -184,10 +197,137 @@ class _MyAppState extends State { debugPrint("Response received: $result"); break; } - - await socksSocket.close(); } catch (e, s) { debugPrint("Error connecting to Monero onion node: $e\n$s"); + } finally { + // Close the socket, also on failure. + await _closeQuietly(socksSocket); + } + } + + Future testStackWalletSslConnection() async { + SOCKSSocket? socksSocket; + try { + // Instantiate a socks socket at localhost and on the port selected by the tor service. + socksSocket = await SOCKSSocket.create( + proxyHost: InternetAddress.loopbackIPv4.address, + proxyPort: _tor.port, + sslEnabled: true, // For SSL connections. + ); + + // Connect to the socks instantiated above. + await socksSocket.connect(); + + // Connect to bitcoin.stackwallet.com on port 50002 via socks socket. + // + // Note that this is an SSL example. + await socksSocket.connectTo('bitcoin.stackwallet.com', 50002); + + // Send a server features command to the connected socket, see method for more specific usage example.. + await socksSocket.sendServerFeaturesCommand(); + + // You should see a server response printed to the console. + // + // Example response: + // `flutter: secure responseData: { + // "id": "0", + // "jsonrpc": "2.0", + // "result": { + // "cashtokens": true, + // "dsproof": true, + // "genesis_hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + // "hash_function": "sha256", + // "hosts": { + // "bitcoin.stackwallet.com": { + // "ssl_port": 50002, + // "tcp_port": 50001, + // "ws_port": 50003, + // "wss_port": 50004 + // } + // }, + // "protocol_max": "1.5", + // "protocol_min": "1.4", + // "pruning": null, + // "server_version": "Fulcrum 1.9.1" + // } + // } + } catch (e, s) { + debugPrint("Error connecting to bitcoin.stackwallet.com: $e\n$s"); + } finally { + // Close the socket, also on failure. + await _closeQuietly(socksSocket); + } + } + + Future testBitcoinOnionNodeConnection() async { + SOCKSSocket? socksSocket; + try { + // Validate the onion address. + if (!bitcoinOnionController.text.contains(".onion")) { + debugPrint("Invalid onion address"); + return; + } else if (!bitcoinOnionController.text.contains(":")) { + debugPrint("Invalid onion address (needs port)"); + return; + } + + final String domain = bitcoinOnionController.text.split(":").first; + final int? port = int.tryParse( + bitcoinOnionController.text.split(":").last, + ); + if (port == null) { + debugPrint("Invalid onion address (invalid port)"); + return; + } + + // Instantiate a socks socket at localhost and on the port selected by the tor service. + socksSocket = await SOCKSSocket.create( + proxyHost: InternetAddress.loopbackIPv4.address, + proxyPort: _tor.port, + sslEnabled: !domain.endsWith(".onion"), // For SSL connections. + ); + + // Connect to the socks instantiated above. + await socksSocket.connect(); + + // Connect to onion node via socks socket. + // + // Note that this is an SSL example. + await socksSocket.connectTo(domain, port); + + // Send a server features command to the connected socket, see method for more specific usage example.. + await socksSocket.sendServerFeaturesCommand(); + + // You should see a server response printed to the console. + // + // Example response: + // `flutter: secure responseData: { + // "id": "0", + // "jsonrpc": "2.0", + // "result": { + // "cashtokens": true, + // "dsproof": true, + // "genesis_hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + // "hash_function": "sha256", + // "hosts": { + // "bitcoin.stackwallet.com": { + // "ssl_port": 50002, + // "tcp_port": 50001, + // "ws_port": 50003, + // "wss_port": 50004 + // } + // }, + // "protocol_max": "1.5", + // "protocol_min": "1.4", + // "pruning": null, + // "server_version": "Fulcrum 1.9.1" + // } + // } + } catch (e, s) { + debugPrint("Error connecting to Bitcoin onion node: $e\n$s"); + } finally { + // Close the socket, also on failure. + await _closeQuietly(socksSocket); } } @@ -267,35 +407,40 @@ class _MyAppState extends State { // Create HttpClient object final client = HttpClient(); - - // Assign connection factory. - SocksTCPClient.assignToHttpClient(client, [ - ProxySettings( - InternetAddress.loopbackIPv4, - _tor.port, - password: null, - ), // TODO get from tor's config file. - ]); - - // GET request. - final request = await client.getUrl( - Uri.parse(hostController.text), - ); - final response = await request.close(); - - // Print response. - var responseString = await utf8.decodeStream( - response, - ); - debugPrint(responseString); - // If host input left to default icanhazip.com, a Tor - // exit node IP should be printed to the console. - // - // https://check.torproject.org is also good for - // doublechecking torability. - - // Close client - client.close(); + try { + // Assign connection factory. + SocksTCPClient.assignToHttpClient(client, [ + ProxySettings( + InternetAddress.loopbackIPv4, + _tor.port, + password: null, + ), // TODO get from tor's config file. + ]); + + // GET request. + final request = await client.getUrl( + Uri.parse(hostController.text), + ); + final response = await request.close(); + + // Print response. + var responseString = await utf8.decodeStream( + response, + ); + debugPrint(responseString); + // If host input left to default icanhazip.com, a Tor + // exit node IP should be printed to the console. + // + // https://check.torproject.org is also good for + // doublechecking torability. + } catch (e, s) { + debugPrint( + "Error making proxied request: $e\n$s", + ); + } finally { + // Close client, also on failure. + client.close(); + } } : null, child: const Text("Make proxied request"), @@ -304,59 +449,7 @@ class _MyAppState extends State { ), spacerSmall, TextButton( - onPressed: torIsRunning - ? () async { - // Instantiate a socks socket at localhost and on the port selected by the tor service. - var socksSocket = await SOCKSSocket.create( - proxyHost: InternetAddress.loopbackIPv4.address, - proxyPort: _tor.port, - sslEnabled: true, // For SSL connections. - ); - - // Connect to the socks instantiated above. - await socksSocket.connect(); - - // Connect to bitcoin.stackwallet.com on port 50002 via socks socket. - // - // Note that this is an SSL example. - await socksSocket.connectTo( - 'bitcoin.stackwallet.com', - 50002, - ); - - // Send a server features command to the connected socket, see method for more specific usage example.. - await socksSocket.sendServerFeaturesCommand(); - - // You should see a server response printed to the console. - // - // Example response: - // `flutter: secure responseData: { - // "id": "0", - // "jsonrpc": "2.0", - // "result": { - // "cashtokens": true, - // "dsproof": true, - // "genesis_hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", - // "hash_function": "sha256", - // "hosts": { - // "bitcoin.stackwallet.com": { - // "ssl_port": 50002, - // "tcp_port": 50001, - // "ws_port": 50003, - // "wss_port": 50004 - // } - // }, - // "protocol_max": "1.5", - // "protocol_min": "1.4", - // "pruning": null, - // "server_version": "Fulcrum 1.9.1" - // } - // } - - // Close the socket. - await socksSocket.close(); - } - : null, + onPressed: torIsRunning ? testStackWalletSslConnection : null, child: const Text( "Connect to bitcoin.stackwallet.com:50002 (SSL) via socks socket", ), @@ -377,72 +470,7 @@ class _MyAppState extends State { spacerSmall, TextButton( onPressed: torIsRunning - ? () async { - // Validate the onion address.www - if (!onionController.text.contains(".onion")) { - debugPrint("Invalid onion address"); - return; - } else if (!onionController.text.contains(":")) { - debugPrint("Invalid onion address (needs port)"); - return; - } - - String domain = bitcoinOnionController.text - .split(":") - .first; - int port = int.parse( - bitcoinOnionController.text.split(":").last, - ); - - // Instantiate a socks socket at localhost and on the port selected by the tor service. - var socksSocket = await SOCKSSocket.create( - proxyHost: InternetAddress.loopbackIPv4.address, - proxyPort: _tor.port, - sslEnabled: !domain.endsWith( - ".onion", - ), // For SSL connections. - ); - - // Connect to the socks instantiated above. - await socksSocket.connect(); - - // Connect to onion node via socks socket. - // - // Note that this is an SSL example. - await socksSocket.connectTo(domain, port); - - // Send a server features command to the connected socket, see method for more specific usage example.. - await socksSocket.sendServerFeaturesCommand(); - - // You should see a server response printed to the console. - // - // Example response: - // `flutter: secure responseData: { - // "id": "0", - // "jsonrpc": "2.0", - // "result": { - // "cashtokens": true, - // "dsproof": true, - // "genesis_hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", - // "hash_function": "sha256", - // "hosts": { - // "bitcoin.stackwallet.com": { - // "ssl_port": 50002, - // "tcp_port": 50001, - // "ws_port": 50003, - // "wss_port": 50004 - // } - // }, - // "protocol_max": "1.5", - // "protocol_min": "1.4", - // "pruning": null, - // "server_version": "Fulcrum 1.9.1" - // } - // } - - // Close the socket. - await socksSocket.close(); - } + ? testBitcoinOnionNodeConnection // A mutex should be added to this example to prevent // multiple connections from being made at once. TODO : null, diff --git a/example/test/tor_start_test.dart b/example/test/tor_start_test.dart index 0f0ad722..dc0bde23 100644 --- a/example/test/tor_start_test.dart +++ b/example/test/tor_start_test.dart @@ -47,6 +47,7 @@ class _ExampleTorNative extends TorNative { class _FailedWriteSocket implements SOCKSSocket { bool readStarted = false; + bool closed = false; @override Future connect() async {} @@ -66,7 +67,9 @@ class _FailedWriteSocket implements SOCKSSocket { } @override - Future close() async {} + Future close() async { + closed = true; + } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); @@ -126,6 +129,7 @@ void main() { await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect(socket.readStarted, isFalse); + expect(socket.closed, isTrue); expect( messages, contains( From ca38b69705400af5a13450a25003fee7ffa1d723 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 19 Oct 2026 08:26:06 -0800 Subject: [PATCH 31/47] fix(tor): retry the IPv6 twin when Windows denies access to it A process holding [::]:P with SO_EXCLUSIVEADDRUSE makes binding [::1]:P fail with WSAEACCES (10013) rather than WSAEADDRINUSE, and a port in a Hyper-V or WinNAT excluded range fails the same way. An OS-assigned port then failed to start instead of trying another port. Treat 10013 as an occupied twin on Windows only; EACCES on Unix has unrelated causes. Caller-requested ports still fail, and the retry limit is unchanged. --- rust/src/lib.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index bef0d8b4..ca85bcfc 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -506,7 +506,7 @@ fn bind_localhost( /// The error reported when IPv6 loopback could not be bound on `ipv6`. fn ipv6_bind_error(requested_port: u16, ipv6: SocketAddr, error: io::Error) -> io::Error { - let reason = if requested_port == 0 && error.kind() == io::ErrorKind::AddrInUse { + let reason = if requested_port == 0 && ipv6_twin_is_occupied(&error) { format!( "the IPv6 twin of the assigned port was occupied after {} attempts ({error})", IPV6_BIND_RETRIES + 1 @@ -536,12 +536,29 @@ fn ipv6_bind_fallback(requested_port: u16, retries_left: u32, error: &io::Error) // Only an OS-assigned port can be swapped for another one. A port the // caller asked for is reported as occupied, as it is for IPv4. Serving // IPv4 alone would leave `[::1]` on the published port to another process. - if requested_port == 0 && error.kind() == io::ErrorKind::AddrInUse && retries_left > 0 { + if requested_port == 0 && ipv6_twin_is_occupied(error) && retries_left > 0 { return Ipv6Fallback::Retry; } Ipv6Fallback::Fail } +/// Whether another process holds the IPv6 loopback port. +fn ipv6_twin_is_occupied(error: &io::Error) -> bool { + if error.kind() == io::ErrorKind::AddrInUse { + return true; + } + + // A process holding `[::]` with SO_EXCLUSIVEADDRUSE makes binding `[::1]` + // on that port fail with WSAEACCES (10013), not WSAEADDRINUSE. A port in + // an excluded range (Hyper-V, WinNAT) fails the same way. + #[cfg(windows)] + if error.raw_os_error() == Some(10013) { + return true; + } + + false +} + /// Whether IPv6 loopback is unavailable on this host. IPv4 remains usable in /// this case, so the SOCKS proxy should still start. fn ipv6_is_unavailable(error: &io::Error) -> bool { @@ -951,6 +968,12 @@ mod tests { #[cfg(windows)] { + // WSAEACCES: the IPv6 twin is held with SO_EXCLUSIVEADDRUSE. + let exclusive = io::Error::from_raw_os_error(10013); + assert_eq!(ipv6_bind_fallback(0, 2, &exclusive), Ipv6Fallback::Retry); + assert_eq!(ipv6_bind_fallback(0, 0, &exclusive), Ipv6Fallback::Fail); + assert_eq!(ipv6_bind_fallback(9050, 2, &exclusive), Ipv6Fallback::Fail); + let no_ipv6 = io::Error::from_raw_os_error(10047); assert_eq!(ipv6_bind_fallback(0, 0, &no_ipv6), Ipv6Fallback::Ipv4Only); assert_eq!( From 4af25123fc697db8d55a38c5b2b852b6a5c10b54 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 19 Oct 2026 08:28:01 -0800 Subject: [PATCH 32/47] fix(rust): regenerate the C header when the crate source changes The CARGO_TARGET_DIR rerun directive replaced Cargo's default of rerunning the build script on any package change, so cbindgen stopped seeing new or changed exports and ffigen produced bindings from a stale header. Rerun when src or Cargo.toml changes as well. --- rust/build.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/build.rs b/rust/build.rs index 15376643..6f693daa 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -18,6 +18,8 @@ fn main() { .join(format!("{}.h", package_name)) .display() .to_string(); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=Cargo.toml"); println!("cargo:rerun-if-env-changed=CARGO_TARGET_DIR"); println!("cargo:rustc-env=TOR_FFI_PLUGIN_HEADER={}", output_file); From 953e9ff8463b58391c0201e38fe296ba2042f473 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 19 Oct 2026 08:28:15 -0800 Subject: [PATCH 33/47] ci: package the example app on Windows Pull requests built the packaged example on Linux, Android, iOS and macOS but not Windows. Build it with flutter build windows, which compiles the plugin through the native assets hook, and load the bundled DLL to check that it was packaged with its exports. --- .github/workflows/native-prebuilts.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/native-prebuilts.yml b/.github/workflows/native-prebuilts.yml index 0deaf3c4..ef344187 100644 --- a/.github/workflows/native-prebuilts.yml +++ b/.github/workflows/native-prebuilts.yml @@ -198,6 +198,7 @@ jobs: - {platform: android, runner: ubuntu-22.04} - {platform: ios, runner: macos-15} - {platform: macos, runner: macos-15} + - {platform: windows, runner: windows-2022} defaults: run: shell: bash @@ -227,6 +228,7 @@ jobs: ios) target=aarch64-apple-ios ;; # flutter build macos is universal, so the hook also builds x86_64. macos) target=aarch64-apple-darwin,x86_64-apple-darwin ;; + windows) target=x86_64-pc-windows-msvc ;; esac bash tool/ci/install_rust.sh --target "$target" - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -245,10 +247,14 @@ jobs: android) flutter build apk --release --target-platform android-arm64 ;; ios) flutter build ios --release --no-codesign ;; macos) flutter build macos --release ;; + windows) flutter build windows --release ;; esac - name: Load packaged macOS library if: matrix.platform == 'macos' run: bash tool/ci/check_macos_app.sh + - name: Load packaged Windows library + if: matrix.platform == 'windows' + run: python tool/ci/smoke_native.py example/build/windows/x64/runner/Release/tor_ffi_plugin.dll release: if: github.event_name != 'pull_request' && github.event.deleted != true From 5646ece51035d9a3366e572c3767f631802d4a91 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 10:48:55 -0500 Subject: [PATCH 34/47] fix(tor): cancel a start overtaken by a stop while resolving its directory A stop queued while _start awaited the data directory was not checked before the "already running" early return, so the start reported success and the queued stop then turned Tor off. --- CHANGELOG.md | 5 ++++- lib/tor_ffi_plugin.dart | 7 +++++++ test/tor_lifecycle_ordering_test.dart | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e6dd4f..8b3367b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,7 +72,10 @@ * `Tor.start` after an unawaited `Tor.stop` restarts Tor instead of returning at once and letting the queued stop leave it off. Starts always queue behind earlier calls; a start with a different data directory than the running - instance fails with a `StateError` and leaves it running. + instance fails with a `StateError` and leaves it running. A `stop` issued + while a start is resolving its data directory cancels that start with + `TorStartCancelled` rather than letting it report success just before the + stop turns Tor off. * `Tor.status` stays `on` while releasing a running instance, including when release fails and handles remain for retry. Failed startup cleanup stays `starting`; the next `start` or `stop` retries teardown before creating a new diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index 3727fecf..a89981b5 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -303,6 +303,13 @@ class Tor { rethrow; } + // A stop queued while the directory was resolving runs next. Returning + // success here would report Tor as running just before that stop turns + // it off. + if (operation.cancelRequested) { + throw const TorStartCancelled(); + } + if (_status == TorStatus.on && !_releasePending) { if (_activeDataDirPath == dataDirPath) { return; diff --git a/test/tor_lifecycle_ordering_test.dart b/test/tor_lifecycle_ordering_test.dart index 5166c0a4..abac7973 100644 --- a/test/tor_lifecycle_ordering_test.dart +++ b/test/tor_lifecycle_ordering_test.dart @@ -330,6 +330,27 @@ void main() { expect(native.liveClients, isEmpty); }); + test( + 'a stop during a reusing start cancels it instead of reporting on', + () async { + await startAndBootstrap(tor, native, dataDir.path); + + final reused = tor.start(torDataDirPath: dataDir.path); + // The queued start is now resolving the data directory, which cannot + // finish until the event loop advances beyond this microtask. + await Future.value(); + final stopped = tor.stop(); + + // Before the fix the start saw the running instance and returned success, + // and the queued stop then turned Tor off right after. + await expectLater(reused, throwsA(isA())); + await stopped; + expect(tor.status, TorStatus.off); + expect(native.liveClients, isEmpty); + expect(native.cancelCalls, 0); + }, + ); + test('a start cancelled by an immediate stop leaves Tor off', () async { final started = tor.start(torDataDirPath: dataDir.path); expect(tor.status, TorStatus.starting); From 7a54d821e190ddd14694c221ad668d7a4e2d4b44 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 10:49:31 -0500 Subject: [PATCH 35/47] fix(tor): keep the caller's duration in TorBootstrapTimeout The exception was built from the millisecond deadline sent to Rust, so its timeout did not equal a bootstrapTimeout with sub-millisecond parts. --- CHANGELOG.md | 3 ++- lib/tor_ffi_plugin.dart | 15 +++++++-------- test/tor_lifecycle_test.dart | 4 +++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b3367b4..d9eba684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,8 @@ matching error text. C enumerators are prefixed with `TorBootstrapStatus_` to avoid collisions with application enums; their numeric values and Dart names are unchanged. The header also supports C++ callers. A passed deadline throws the new `TorBootstrapTimeout` - instead of a plain `Exception`. + instead of a plain `Exception`; its `timeout` is the `bootstrapTimeout` + passed to `start`, not the deadline rounded to milliseconds. * Report Arti's full cause chain on bootstrap failure in the error message Dart receives, without repeating a cause already included as the exact suffix of its parent's message. diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index a89981b5..3dcbc2ff 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -95,14 +95,15 @@ class TorNative { /// cancellation, [TorBootstrapTimeout] when [timeout] elapses, and the Rust /// error on failure. Future bootstrap(Pointer client, Duration timeout) async { - final timeoutMillis = timeout.inMilliseconds; - await Isolate.run(() => _bootstrapInIsolate(client.address, timeoutMillis)); + final address = client.address; + await Isolate.run(() => _bootstrapInIsolate(address, timeout)); } - static void _bootstrapInIsolate(int client, int timeoutMillis) { + static void _bootstrapInIsolate(int client, Duration timeout) { final code = bindings.tor_client_bootstrap( Pointer.fromAddress(client), - timeoutMillis, + // The native deadline has millisecond resolution. + timeout.inMilliseconds, ); switch (bindings.TorBootstrapStatus.fromValue(code)) { case bindings.TorBootstrapStatus.Ok: @@ -111,10 +112,8 @@ class TorNative { Tor._takeRustError(); throw const TorStartCancelled(); case bindings.TorBootstrapStatus.TimedOut: - throw TorBootstrapTimeout( - Duration(milliseconds: timeoutMillis), - Tor._takeRustError(), - ); + // Report the deadline as the caller passed it, not rounded. + throw TorBootstrapTimeout(timeout, Tor._takeRustError()); case bindings.TorBootstrapStatus.Failed: Tor.throwRustException(); case bindings.TorBootstrapStatus.NullClient: diff --git a/test/tor_lifecycle_test.dart b/test/tor_lifecycle_test.dart index 65a7bcd0..b57f1a7c 100644 --- a/test/tor_lifecycle_test.dart +++ b/test/tor_lifecycle_test.dart @@ -89,7 +89,9 @@ void main() { }); test('a passed deadline throws TorBootstrapTimeout', () async { - const timeout = Duration(milliseconds: 1); + // Not a whole number of milliseconds: the exception must carry the + // duration as passed, not the rounded native deadline. + const timeout = Duration(microseconds: 1500); await expectLater( native.bootstrap(handles!.client, timeout), throwsA( From 37cde926b151c58beb90ae18d5a7e0fd2cca6ef1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 10:49:31 -0500 Subject: [PATCH 36/47] refactor(tor): cancel bootstrap through the retained client handle _StartOperation.client duplicated _clientPtr. stop now cancels on _clientPtr while starting and no release has begun, which _stop marks before its first await, so a client being freed is never touched. --- lib/tor_ffi_plugin.dart | 14 ++++++++------ test/tor_lifecycle_ordering_test.dart | 28 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/lib/tor_ffi_plugin.dart b/lib/tor_ffi_plugin.dart index 3dcbc2ff..37972865 100644 --- a/lib/tor_ffi_plugin.dart +++ b/lib/tor_ffi_plugin.dart @@ -341,7 +341,6 @@ class Tor { // Retain the native handles before bootstrap so stop can cancel it. _clientPtr = tor.client; _proxyPtr = tor.proxy; - operation.client = tor.client; if (operation.cancelRequested) { throw const TorStartCancelled(); @@ -363,7 +362,6 @@ class Tor { } catch (error, stackTrace) { // Release anything the native start produced before bootstrap failed; // the next start would otherwise overwrite these handles. - operation.client = nullptr; try { await _stop(); } catch (cleanupError, cleanupStackTrace) { @@ -401,11 +399,16 @@ class Tor { Future stop() { for (final pending in _pendingStarts) { pending.cancelRequested = true; - if (pending.client != nullptr) { - _native.cancelBootstrap(pending.client); - } } _pendingStarts.clear(); + // A bootstrap can only be in progress while starting on a client that no + // _stop has begun releasing. _stop marks the handles before its first + // await, so this never touches a client that is being freed. + if (_status == TorStatus.starting && + _clientPtr != nullptr && + !_releasePending) { + _native.cancelBootstrap(_clientPtr); + } return _enqueue(_stop); } @@ -493,5 +496,4 @@ class _StartOperation { final Duration bootstrapTimeout; late final Future future; bool cancelRequested = false; - Pointer client = nullptr; } diff --git a/test/tor_lifecycle_ordering_test.dart b/test/tor_lifecycle_ordering_test.dart index abac7973..141901db 100644 --- a/test/tor_lifecycle_ordering_test.dart +++ b/test/tor_lifecycle_ordering_test.dart @@ -328,6 +328,8 @@ void main() { await stopped; expect(tor.status, TorStatus.off); expect(native.liveClients, isEmpty); + // Nothing was bootstrapping, so there was nothing to cancel natively. + expect(native.cancelCalls, 0); }); test( @@ -351,6 +353,32 @@ void main() { }, ); + test('stop during failed-start cleanup leaves the client alone', () async { + final gate = native.freeGate = Completer(); + final started = tor.start(torDataDirPath: dataDir.path); + final failure = expectLater(started, throwsException); + await native.waitForBootstraps(1); + native.pendingBootstraps[0].completeError(Exception('bootstrap failed')); + while (native.createdProxies.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 5)); + } + + // The proxy is gone and the client is being freed; status is still + // starting and the handle is still set. + expect(tor.status, TorStatus.starting); + expect(native.liveClients, hasLength(1)); + final stopped = tor.stop(); + // Cancelling a bootstrap on a client that is being freed would race the + // free, so stop must not touch the handle. + expect(native.cancelCalls, 0); + + gate.complete(); + await failure; + await stopped; + expect(tor.status, TorStatus.off); + expect(native.liveClients, isEmpty); + }); + test('a start cancelled by an immediate stop leaves Tor off', () async { final started = tor.start(torDataDirPath: dataDir.path); expect(tor.status, TorStatus.starting); From fdfcd6c12143483f8c4a85b825550b973c0efe9f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 10:49:31 -0500 Subject: [PATCH 37/47] test(rust): remove the scratch directories client tests leave behind unbootstrapped_client_handle and the proxy stop test created state directories under the system temp directory on every run. --- rust/src/lib.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index ca85bcfc..010e66c0 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -634,10 +634,12 @@ mod tests { (client, runtime) } - /// An unbootstrapped client as the opaque pointer Dart would receive. - fn unbootstrapped_client_handle() -> *mut c_void { - let (client, runtime) = unbootstrapped_client(&scratch_dir("client")); - into_client_handle(client, runtime) + /// An unbootstrapped client as the opaque pointer Dart would receive, with + /// the directory to remove once the handle is freed. + fn unbootstrapped_client_handle() -> (*mut c_void, PathBuf) { + let root = scratch_dir("client"); + let (client, runtime) = unbootstrapped_client(&root); + (into_client_handle(client, runtime), root) } #[test] @@ -663,7 +665,7 @@ mod tests { #[test] fn client_handle_survives_repeated_calls_until_freed() { - let handle = unbootstrapped_client_handle(); + let (handle, root) = unbootstrapped_client_handle(); // Before the fix every call through the handle reconstructed and // dropped the owning Box, so the second call here was a use after @@ -673,11 +675,12 @@ mod tests { tor_client_set_dormant(handle, false); tor_client_free(handle); } + std::fs::remove_dir_all(root).unwrap(); } #[test] fn cancelled_bootstrap_returns_without_network_and_preserves_the_handle() { - let handle = unbootstrapped_client_handle(); + let (handle, root) = unbootstrapped_client_handle(); unsafe { tor_client_cancel_bootstrap(handle); @@ -691,6 +694,7 @@ mod tests { } let error = crate::error::take_last_error().unwrap().to_string(); assert!(error.contains("cancelled"), "{error}"); + std::fs::remove_dir_all(root).unwrap(); } #[test] @@ -749,7 +753,7 @@ mod tests { #[test] fn bootstrap_status_codes_are_stable() { - let handle = unbootstrapped_client_handle(); + let (handle, root) = unbootstrapped_client_handle(); unsafe { // A zero deadline expires before an offline bootstrap can finish. @@ -764,6 +768,7 @@ mod tests { tor_client_free(handle); } + std::fs::remove_dir_all(root).unwrap(); assert_eq!(TorBootstrapStatus::Ok as i32, 0); assert_eq!(TorBootstrapStatus::Failed as i32, 3); assert_eq!(TorBootstrapStatus::NullClient as i32, 4); @@ -908,7 +913,8 @@ mod tests { #[test] fn stopping_the_proxy_releases_its_listeners_and_client_reference() { - let (client, runtime) = unbootstrapped_client(&scratch_dir("proxy-stop")); + let root = scratch_dir("proxy-stop"); + let (client, runtime) = unbootstrapped_client(&root); let arti_rt = runtime.arti().unwrap(); let rt = proxy_runtime().unwrap(); let (listeners, port) = bind_localhost(rt, &arti_rt, 0).unwrap(); @@ -926,6 +932,7 @@ mod tests { TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); drop(client); drop(runtime); + std::fs::remove_dir_all(root).unwrap(); } #[test] From 1a220329c4c13361ee76acf6e36211faf3aede8f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 10:49:42 -0500 Subject: [PATCH 38/47] perf(rust): run each client on two worker threads Tor traffic is I/O bound and storage uses the blocking pool, so tokio's default of one worker per core left most of them idle for every client. --- CHANGELOG.md | 3 +++ rust/src/lib.rs | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9eba684..df33b3e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,9 @@ not be deleted on Windows. If the release itself fails, `stop` throws and the next `start` or `stop` retries it before opening a new client. If proxy release fails, retain its client too and retry the proxy before freeing it. +* Each client runs on its own runtime with two worker threads, created only + after the SOCKS port is bound. Before, every `tor_start` built a pool with + one thread per CPU core, even when the port turned out to be taken. * `Tor.start` after an unawaited `Tor.stop` restarts Tor instead of returning at once and letting the queued stop leave it off. Starts always queue behind earlier calls; a start with a different data directory than the running diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 010e66c0..412826f6 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -36,6 +36,11 @@ lazy_static! { /// How long freeing a client waits for its background tasks to finish. const CLIENT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +/// Worker threads on each client's runtime. Tor traffic is I/O bound and +/// storage runs on the blocking pool, so tokio's default of one thread per +/// core would sit idle. +const CLIENT_WORKER_THREADS: usize = 2; + /// How many more OS-assigned ports to try when another process holds the /// IPv6 twin of the assigned port, before failing. const IPV6_BIND_RETRIES: u32 = 3; @@ -58,7 +63,10 @@ struct ClientRuntime(Option); impl ClientRuntime { fn new() -> io::Result { Ok(Self(Some( - Builder::new_multi_thread().enable_all().build()?, + Builder::new_multi_thread() + .worker_threads(CLIENT_WORKER_THREADS) + .enable_all() + .build()?, ))) } From 0aa0449df111394de0ede83e3df508102cadb918 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 10:49:42 -0500 Subject: [PATCH 39/47] perf(rust): bind the SOCKS port before building the client runtime A taken port no longer costs a thread pool. Arti's tokio listen binds on the runtime that is current, so bind_localhost takes an arti handle for the proxy runtime itself instead of the client's. --- rust/src/lib.rs | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 412826f6..80f1a38b 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -160,15 +160,15 @@ pub unsafe extern "C" fn tor_start( let state_dir = unwrap_or_return!(CStr::from_ptr(state_dir).to_str(), err_ret); let cache_dir = unwrap_or_return!(CStr::from_ptr(cache_dir).to_str(), err_ret); + // Reserve the port before creating the client's threads, initializing + // storage or bootstrapping. On any later failure these listeners are + // dropped and the port is released. + let rt = unwrap_or_return!(proxy_runtime(), err_ret); + let (listeners, socks_port) = unwrap_or_return!(bind_localhost(rt, socks_port), err_ret); + let client_runtime = unwrap_or_return!(ClientRuntime::new(), err_ret); let runtime = unwrap_or_return!(client_runtime.arti(), err_ret); - // Reserve the port before initializing storage or bootstrapping. On any - // later failure these listeners are dropped and the port is released. - let rt = unwrap_or_return!(proxy_runtime(), err_ret); - let (listeners, socks_port) = - unwrap_or_return!(bind_localhost(rt, &runtime, socks_port), err_ret); - let mut cfg_builder = TorClientConfig::builder(); cfg_builder .storage() @@ -475,12 +475,10 @@ fn start_proxy( /// This blocks on `rt` so the listeners are registered with the reactor that /// later runs the accept loop, and so the caller sees a bind failure before /// the port is published. -fn bind_localhost( - rt: &Runtime, - runtime: &TokioNativeTlsRuntime, - requested_port: u16, -) -> io::Result<(Vec, u16)> { +fn bind_localhost(rt: &Runtime, requested_port: u16) -> io::Result<(Vec, u16)> { rt.block_on(async { + // Arti binds through the runtime that is current, which is `rt`. + let runtime = TokioNativeTlsRuntime::current()?; let options = TcpListenOptions::default(); let mut retries_left = if requested_port == 0 { IPV6_BIND_RETRIES @@ -831,9 +829,8 @@ mod tests { fn bind_localhost_reports_an_occupied_port() { let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); let port = occupied.local_addr().unwrap().port(); - let arti_rt = TokioNativeTlsRuntime::create().unwrap(); - let err = match bind_localhost(proxy_runtime().unwrap(), &arti_rt, port) { + let err = match bind_localhost(proxy_runtime().unwrap(), port) { Ok(_) => panic!("bound port {port} while it was occupied"), Err(err) => err, }; @@ -888,7 +885,6 @@ mod tests { #[test] fn bind_localhost_holds_the_port_until_dropped() { - let arti_rt = TokioNativeTlsRuntime::create().unwrap(); let rt = proxy_runtime().unwrap(); // A requested port must be honored and held. Another test may take the @@ -900,7 +896,7 @@ mod tests { .local_addr() .unwrap() .port(); - match bind_localhost(rt, &arti_rt, port) { + match bind_localhost(rt, port) { Ok((listeners, bound_port)) => { assert_eq!(bound_port, port); Some((listeners, port)) @@ -923,9 +919,8 @@ mod tests { fn stopping_the_proxy_releases_its_listeners_and_client_reference() { let root = scratch_dir("proxy-stop"); let (client, runtime) = unbootstrapped_client(&root); - let arti_rt = runtime.arti().unwrap(); let rt = proxy_runtime().unwrap(); - let (listeners, port) = bind_localhost(rt, &arti_rt, 0).unwrap(); + let (listeners, port) = bind_localhost(rt, 0).unwrap(); let mut proxy = Box::new(ProxyHandle::new(Arc::clone(&client), listeners)); proxy.start(rt); // Let the accept loop start polling its listeners. @@ -1029,9 +1024,7 @@ mod tests { #[test] fn bind_localhost_allocates_and_reports_one_port() { - let arti_rt = TokioNativeTlsRuntime::create().unwrap(); - - let (listeners, port) = bind_localhost(proxy_runtime().unwrap(), &arti_rt, 0).unwrap(); + let (listeners, port) = bind_localhost(proxy_runtime().unwrap(), 0).unwrap(); assert_ne!(port, 0); assert!( From 06c6c208ffdfd44ac2d090ed2fdf637f5faeb559 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 10:49:42 -0500 Subject: [PATCH 40/47] build(rust): rerun build.rs when the NDK location changes Explicit rerun-if-changed directives made Cargo watch only those inputs, so switching NDKs kept the old clang runtime search path. --- CHANGELOG.md | 3 +++ rust/build.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index df33b3e1..da0f2881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,9 @@ hash sources as bytes, so stray files no longer fail or invalidate builds. * Avoid hashing all Rust inputs during source builds; full fingerprints remain required when selecting prebuilts. +* `rust/build.rs` declares its rerun triggers explicitly and includes + `ANDROID_NDK_HOME` and `CC_x86_64_linux_android`, so an x86_64 Android + build picks up a switched NDK without a clean build. * Validate packaged macOS framework loading and audit Rust dependencies in CI without granting write access to the workflow token. * Keep Cargo's default release stripping on every platform. On macOS it diff --git a/rust/build.rs b/rust/build.rs index 6f693daa..1591af2a 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -21,6 +21,9 @@ fn main() { println!("cargo:rerun-if-changed=src"); println!("cargo:rerun-if-changed=Cargo.toml"); println!("cargo:rerun-if-env-changed=CARGO_TARGET_DIR"); + // android_on_linux_check reads these to find the NDK's clang runtime. + println!("cargo:rerun-if-env-changed=ANDROID_NDK_HOME"); + println!("cargo:rerun-if-env-changed=CC_x86_64_linux_android"); println!("cargo:rustc-env=TOR_FFI_PLUGIN_HEADER={}", output_file); let config = Config { From d9d117b740df01c2fbb44795b7e68c64c9984283 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 10:49:42 -0500 Subject: [PATCH 41/47] refactor(hook): list the tracked source directories once --- hook/prebuilt.dart | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/hook/prebuilt.dart b/hook/prebuilt.dart index 9de39713..b6c321b8 100644 --- a/hook/prebuilt.dart +++ b/hook/prebuilt.dart @@ -29,24 +29,28 @@ String releaseFileName(String target) => '$target-${libraryName(target)}'; Future fileHash(File file) async => (await sha256.bind(file.openRead()).first).toString(); +/// Native source files tracked as a whole. +const _sourceFiles = [ + 'rust/Cargo.toml', + 'rust/Cargo.lock', + 'rust/rust-toolchain.toml', + 'rust/build.rs', + 'hook/build.dart', + 'lib/tor_ffi_plugin_bindings_generated.dart', +]; + +/// Native source trees whose entries are tracked recursively. +const _sourceDirectories = ['rust/src/', 'rust/patches/']; + /// Native inputs to track for Flutter's hook cache. /// /// Directories are included so additions and deletions invalidate the build. /// Hidden entries are not native inputs and are skipped. Future> sourceDependencies(Uri root) async => [ - for (final path in [ - 'rust/Cargo.toml', - 'rust/Cargo.lock', - 'rust/rust-toolchain.toml', - 'rust/build.rs', - 'hook/build.dart', - 'lib/tor_ffi_plugin_bindings_generated.dart', - 'rust/src/', - 'rust/patches/', - ]) + for (final path in [..._sourceFiles, ..._sourceDirectories]) root.resolve(path), - ...await _sourceEntries(Directory.fromUri(root.resolve('rust/src/'))), - ...await _sourceEntries(Directory.fromUri(root.resolve('rust/patches/'))), + for (final path in _sourceDirectories) + ...await _sourceEntries(Directory.fromUri(root.resolve(path))), ]..sort((a, b) => a.path.compareTo(b.path)); // Hash paths and contents so additions, deletions and ABI changes invalidate From 6174517ac8b18acb5df1d74383992152395e0dcb Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 10:49:42 -0500 Subject: [PATCH 42/47] refactor(example): derive button state from Tor.status Stop stays available while status is not off, so a failed release can be retried. Requests also need the port, which a failed stop has already closed while Tor still reports on. --- CHANGELOG.md | 3 ++- example/lib/main.dart | 42 +++++++++++++++++++----------------------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da0f2881..35579f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ write failures reach the example's connection error handler. The Bitcoin onion test validates its own field and reports a missing port instead of throwing, and every example connection closes its socket or - HTTP client when the request fails. + HTTP client when the request fails. The buttons follow `Tor.status`, so + Stop stays available while the native handles are held. * Bump arti to 2.7.0 and its supporting crates to 0.47.0; needs Rust 1.92. * Vendor a reviewed `saturating-time` 0.5.0 fork under `rust/patches/`, with diff --git a/example/lib/main.dart b/example/lib/main.dart index 32afe8cc..b7ae2bd4 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -34,13 +34,22 @@ class Home extends StatefulWidget { } class _MyAppState extends State { - // Flag to track if tor has started. - bool torIsRunning = false; String? torError; - bool _torStopFailed = false; Tor get _tor => widget.tor ?? Tor.instance; + // Whether requests can use the proxy. After a failed stop the client is + // still held, so Tor reports on, but its proxy has already shut down and + // `port` throws. + bool get torIsRunning { + if (_tor.status != TorStatus.on) return false; + try { + return _tor.port > 0; + } on Exception { + return false; + } + } + // Set the default text for the host input field. final hostController = TextEditingController(text: 'https://icanhazip.com/'); // https://check.torproject.org is another good option. @@ -96,19 +105,12 @@ class _MyAppState extends State { ); if (!mounted) return; - setState(() { - torIsRunning = _tor.status == TorStatus.on; - torError = null; - _torStopFailed = false; - }); + setState(() => torError = null); debugPrint('Done awaiting; tor should be running'); } catch (error) { debugPrint('Unable to start Tor: $error'); if (!mounted) return; - setState(() { - torIsRunning = false; - torError = 'Unable to start Tor: $error'; - }); + setState(() => torError = 'Unable to start Tor: $error'); } } @@ -116,19 +118,11 @@ class _MyAppState extends State { try { await _tor.disable(); if (!mounted) return; - setState(() { - torIsRunning = false; - torError = null; - _torStopFailed = false; - }); + setState(() => torError = null); } catch (error) { debugPrint('Unable to stop Tor: $error'); if (!mounted) return; - setState(() { - torIsRunning = false; - torError = 'Unable to stop Tor: $error'; - _torStopFailed = true; - }); + setState(() => torError = 'Unable to stop Tor: $error'); } } @@ -379,7 +373,9 @@ class _MyAppState extends State { child: const Text("Start tor"), ), TextButton( - onPressed: torIsRunning || _torStopFailed ? stopTor : null, + // Also while starting, or when a failed release left the + // native handles held, so the stop can be retried. + onPressed: _tor.status == TorStatus.off ? null : stopTor, child: const Text("Stop tor"), ), ], From 6aee8977eeb5a00c28e5b0519e1618bb5e7789ec Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 10:49:42 -0500 Subject: [PATCH 43/47] refactor(example): share the server features request --- example/lib/main.dart | 116 +++++++++++++++--------------------------- 1 file changed, 42 insertions(+), 74 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index b7ae2bd4..d158fbdf 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -199,23 +199,27 @@ class _MyAppState extends State { } } - Future testStackWalletSslConnection() async { + // Ask an Electrum server for its features through the proxy. + Future _testServerFeatures( + String host, + int port, { + required bool ssl, + required String label, + }) async { SOCKSSocket? socksSocket; try { // Instantiate a socks socket at localhost and on the port selected by the tor service. socksSocket = await SOCKSSocket.create( proxyHost: InternetAddress.loopbackIPv4.address, proxyPort: _tor.port, - sslEnabled: true, // For SSL connections. + sslEnabled: ssl, // For SSL connections. ); // Connect to the socks instantiated above. await socksSocket.connect(); - // Connect to bitcoin.stackwallet.com on port 50002 via socks socket. - // - // Note that this is an SSL example. - await socksSocket.connectTo('bitcoin.stackwallet.com', 50002); + // Connect to the server via socks socket. + await socksSocket.connectTo(host, port); // Send a server features command to the connected socket, see method for more specific usage example.. await socksSocket.sendServerFeaturesCommand(); @@ -246,83 +250,47 @@ class _MyAppState extends State { // } // } } catch (e, s) { - debugPrint("Error connecting to bitcoin.stackwallet.com: $e\n$s"); + debugPrint("Error connecting to $label: $e\n$s"); } finally { // Close the socket, also on failure. await _closeQuietly(socksSocket); } } - Future testBitcoinOnionNodeConnection() async { - SOCKSSocket? socksSocket; - try { - // Validate the onion address. - if (!bitcoinOnionController.text.contains(".onion")) { - debugPrint("Invalid onion address"); - return; - } else if (!bitcoinOnionController.text.contains(":")) { - debugPrint("Invalid onion address (needs port)"); - return; - } - - final String domain = bitcoinOnionController.text.split(":").first; - final int? port = int.tryParse( - bitcoinOnionController.text.split(":").last, - ); - if (port == null) { - debugPrint("Invalid onion address (invalid port)"); - return; - } - - // Instantiate a socks socket at localhost and on the port selected by the tor service. - socksSocket = await SOCKSSocket.create( - proxyHost: InternetAddress.loopbackIPv4.address, - proxyPort: _tor.port, - sslEnabled: !domain.endsWith(".onion"), // For SSL connections. - ); - - // Connect to the socks instantiated above. - await socksSocket.connect(); - - // Connect to onion node via socks socket. - // - // Note that this is an SSL example. - await socksSocket.connectTo(domain, port); + // Connect to bitcoin.stackwallet.com on port 50002 via socks socket. + // + // Note that this is an SSL example. + Future testStackWalletSslConnection() => _testServerFeatures( + 'bitcoin.stackwallet.com', + 50002, + ssl: true, + label: 'bitcoin.stackwallet.com', + ); - // Send a server features command to the connected socket, see method for more specific usage example.. - await socksSocket.sendServerFeaturesCommand(); + Future testBitcoinOnionNodeConnection() async { + // Validate the onion address. + if (!bitcoinOnionController.text.contains(".onion")) { + debugPrint("Invalid onion address"); + return; + } else if (!bitcoinOnionController.text.contains(":")) { + debugPrint("Invalid onion address (needs port)"); + return; + } - // You should see a server response printed to the console. - // - // Example response: - // `flutter: secure responseData: { - // "id": "0", - // "jsonrpc": "2.0", - // "result": { - // "cashtokens": true, - // "dsproof": true, - // "genesis_hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", - // "hash_function": "sha256", - // "hosts": { - // "bitcoin.stackwallet.com": { - // "ssl_port": 50002, - // "tcp_port": 50001, - // "ws_port": 50003, - // "wss_port": 50004 - // } - // }, - // "protocol_max": "1.5", - // "protocol_min": "1.4", - // "pruning": null, - // "server_version": "Fulcrum 1.9.1" - // } - // } - } catch (e, s) { - debugPrint("Error connecting to Bitcoin onion node: $e\n$s"); - } finally { - // Close the socket, also on failure. - await _closeQuietly(socksSocket); + final String domain = bitcoinOnionController.text.split(":").first; + final int? port = int.tryParse(bitcoinOnionController.text.split(":").last); + if (port == null) { + debugPrint("Invalid onion address (invalid port)"); + return; } + + // Connect to the onion node via socks socket. + await _testServerFeatures( + domain, + port, + ssl: !domain.endsWith(".onion"), + label: 'Bitcoin onion node', + ); } @override From e36c8999f2283a8110ba898d9afa8fd3ce121bc5 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 11:15:10 -0500 Subject: [PATCH 44/47] build(rust): pin the toolchain to Rust 1.99.1 CI, the hook and the README all read rust/rust-toolchain.toml, so the pin is the only place the version lives. The vendored saturating-time crate keeps its 1.92 rust-version floor. --- CHANGELOG.md | 3 ++- rust/rust-toolchain.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35579f1a..a6da37a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ HTTP client when the request fails. The buttons follow `Tor.status`, so Stop stays available while the native handles are held. -* Bump arti to 2.7.0 and its supporting crates to 0.47.0; needs Rust 1.92. +* Bump arti to 2.7.0 and its supporting crates to 0.47.0; the pinned + toolchain is Rust 1.99.1. * Vendor a reviewed `saturating-time` 0.5.0 fork under `rust/patches/`, with regression coverage for coarse platform clocks. Source builds fetch only from crates.io, and the prebuilt source fingerprint covers the patch but diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml index b95b4403..edcf233f 100644 --- a/rust/rust-toolchain.toml +++ b/rust/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.92.0" +channel = "1.99.1" profile = "minimal" targets = [ From b007b05fee6c6664aa21cd001ee966250bcdf209 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 12:12:43 -0500 Subject: [PATCH 45/47] fix(example): allow network access in the macOS release build Release.entitlements enabled the App Sandbox without the network entitlements DebugProfile.entitlements grants, so the release app could not bind its SOCKS listener and Tor failed to start with "Operation not permitted". Grant network.client and network.server as in debug. --- CHANGELOG.md | 4 +++- example/macos/Runner/Release.entitlements | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6da37a7..c91fa616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ The Bitcoin onion test validates its own field and reports a missing port instead of throwing, and every example connection closes its socket or HTTP client when the request fails. The buttons follow `Tor.status`, so - Stop stays available while the native handles are held. + Stop stays available while the native handles are held. The sandboxed + macOS release build may open network connections and the local SOCKS + listener, so Tor starts outside debug builds too. * Bump arti to 2.7.0 and its supporting crates to 0.47.0; the pinned toolchain is Rust 1.99.1. diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements index 852fa1a4..7a2230dc 100644 --- a/example/macos/Runner/Release.entitlements +++ b/example/macos/Runner/Release.entitlements @@ -4,5 +4,9 @@ com.apple.security.app-sandbox + com.apple.security.network.client + + com.apple.security.network.server + From dec20e7632ed79c640c2e3bf71427666a5bab126 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 09:53:53 -0800 Subject: [PATCH 46/47] fix(rust): rerun build.rs per source file so the hook can cache rerun-if-changed=src put the bare src directory in Cargo's dep-info. native_toolchain_rust passes those entries to the hook runner as files, and a file that does not exist counts as modified now, so every Flutter build reran the hook and logged "File modified during build. Build must be rerun." List each source file instead; adding a module edits a file that is already listed. --- rust/build.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rust/build.rs b/rust/build.rs index 1591af2a..761043a9 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -18,7 +18,12 @@ fn main() { .join(format!("{}.h", package_name)) .display() .to_string(); - println!("cargo:rerun-if-changed=src"); + // List files rather than src itself: Cargo copies the directory into the + // dep-info, where the Flutter hook runner takes it for a missing file and + // reruns the hook on every build. Adding a module edits a listed file. + for source in glob("src/**/*.rs").unwrap() { + println!("cargo:rerun-if-changed={}", source.unwrap().display()); + } println!("cargo:rerun-if-changed=Cargo.toml"); println!("cargo:rerun-if-env-changed=CARGO_TARGET_DIR"); // android_on_linux_check reads these to find the NDK's clang runtime. From 94bbc0355be8876b01cdbc7460c58645ba324d8a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 20 Oct 2026 09:53:53 -0800 Subject: [PATCH 47/47] test(tor): run the symlink tests on Windows when links can be made The tests skipped on Windows for privileges that Developer Mode does not need. Probe once and skip only when a link cannot be created. Win32 removes a .. segment before following a link, so the parent-segment test keeps its POSIX expectation and gains a Windows twin that expects the lexical directory. --- test/tor_lifecycle_ordering_test.dart | 58 +++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/test/tor_lifecycle_ordering_test.dart b/test/tor_lifecycle_ordering_test.dart index 141901db..7e9dd9b4 100644 --- a/test/tor_lifecycle_ordering_test.dart +++ b/test/tor_lifecycle_ordering_test.dart @@ -133,6 +133,23 @@ Future startAndBootstrap( expect(tor.status, TorStatus.on); } +/// A skip reason when this machine cannot create symlinks, otherwise false. +/// +/// Windows allows them only with Developer Mode or elevation. +final Object symlinkSkip = () { + final probe = Directory.systemTemp.createTempSync('tor-symlink-'); + final sep = Platform.pathSeparator; + try { + final target = Directory('${probe.path}${sep}target')..createSync(); + Link('${probe.path}${sep}link').createSync(target.path); + return false; + } on FileSystemException { + return 'creating symlinks needs Developer Mode or elevation'; + } finally { + probe.deleteSync(recursive: true); + } +}(); + void main() { late Directory dataDir; late FakeTorNative native; @@ -560,28 +577,55 @@ void main() { expect(native.startDirectories, hasLength(1)); await tor.stop(); - }, skip: Platform.isWindows ? 'symlinks need elevated privileges' : false); + }, skip: symlinkSkip); + + test( + 'a parent segment after a symlink follows the link', + () async { + final sep = Platform.pathSeparator; + final target = Directory('${dataDir.path}${sep}a${sep}target') + ..createSync(recursive: true); + Link('${dataDir.path}${sep}link').createSync(target.path); + + // Lexically this is dataDir/sibling; the file system resolves it to + // dataDir/a/sibling. + await startAndBootstrap( + tor, + native, + '${dataDir.path}${sep}link$sep..${sep}sibling', + ); - test('a parent segment after a symlink follows the link', () async { + final physical = Directory('${dataDir.path}${sep}a${sep}sibling') + .resolveSymbolicLinksSync(); + expect(native.startDirectories[0], '$physical/tor_state'); + expect(Directory('${dataDir.path}${sep}sibling').existsSync(), isFalse); + await tor.stop(); + }, + skip: Platform.isWindows ? 'Windows resolves .. before links' : symlinkSkip, + ); + + test('on Windows a parent segment after a symlink is lexical', () async { final sep = Platform.pathSeparator; final target = Directory('${dataDir.path}${sep}a${sep}target') ..createSync(recursive: true); Link('${dataDir.path}${sep}link').createSync(target.path); - // Lexically this is dataDir/sibling; the file system resolves it to - // dataDir/a/sibling. + // Win32 normalizes the path before the file system sees the link. await startAndBootstrap( tor, native, '${dataDir.path}${sep}link$sep..${sep}sibling', ); - final physical = Directory('${dataDir.path}${sep}a${sep}sibling') + final physical = Directory('${dataDir.path}${sep}sibling') .resolveSymbolicLinksSync(); expect(native.startDirectories[0], '$physical/tor_state'); - expect(Directory('${dataDir.path}${sep}sibling').existsSync(), isFalse); + expect( + Directory('${dataDir.path}${sep}a${sep}sibling').existsSync(), + isFalse, + ); await tor.stop(); - }, skip: Platform.isWindows ? 'symlinks need elevated privileges' : false); + }, skip: Platform.isWindows ? symlinkSkip : 'Win32 path semantics'); test('an unusable data directory fails while it is resolved', () async { final sep = Platform.pathSeparator;