refactor: cleanup

This commit is contained in:
zhom
2026-09-09 10:09:14 +04:00
parent 598d3bd513
commit dd42d46753
249 changed files with 67417 additions and 6659 deletions
+178 -103
View File
@@ -28,6 +28,17 @@ dependencies = [
"inout 0.2.2",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes"
version = "0.9.3"
@@ -39,6 +50,20 @@ dependencies = [
"cpufeatures 0.3.1",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead 0.5.2",
"aes 0.8.4",
"cipher 0.4.4",
"ctr 0.9.2",
"ghash 0.5.1",
"subtle",
]
[[package]]
name = "aes-gcm"
version = "0.11.1"
@@ -46,11 +71,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
dependencies = [
"aead 0.6.1",
"aes",
"aes 0.9.3",
"cipher 0.5.2",
"ctr",
"ctr 0.10.1",
"ctutils",
"ghash",
"ghash 0.6.0",
]
[[package]]
@@ -477,30 +502,6 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "axum"
version = "0.8.9"
@@ -714,7 +715,7 @@ dependencies = [
"aead 0.5.2",
"base64 0.22.1",
"blake2 0.10.6",
"chacha20poly1305 0.10.1",
"chacha20poly1305",
"hex",
"hmac 0.12.1",
"ip_network",
@@ -726,7 +727,7 @@ dependencies = [
"rand_core 0.6.4",
"ring",
"tracing",
"untrusted 0.9.0",
"untrusted",
"x25519-dalek",
]
@@ -1006,7 +1007,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cipher 0.5.2",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
@@ -1020,22 +1020,10 @@ dependencies = [
"aead 0.5.2",
"chacha20 0.9.1",
"cipher 0.4.4",
"poly1305 0.8.0",
"poly1305",
"zeroize",
]
[[package]]
name = "chacha20poly1305"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb"
dependencies = [
"aead 0.6.1",
"chacha20 0.10.2",
"cipher 0.5.2",
"poly1305 0.9.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -1131,15 +1119,6 @@ dependencies = [
"error-code",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
@@ -1177,6 +1156,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
@@ -1418,6 +1403,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "ctr"
version = "0.10.1"
@@ -1568,6 +1562,16 @@ dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1616,7 +1620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"const-oid 0.10.2",
"crypto-common 0.2.2",
"ctutils",
]
@@ -1725,8 +1729,8 @@ dependencies = [
name = "donutbrowser"
version = "0.30.0"
dependencies = [
"aes",
"aes-gcm",
"aes 0.9.3",
"aes-gcm 0.11.1",
"argon2",
"async-socks5",
"async-trait",
@@ -1752,6 +1756,7 @@ dependencies = [
"hyper",
"hyper-util",
"image",
"jsonc-parser",
"lazy_static",
"libc",
"log",
@@ -1759,6 +1764,7 @@ dependencies = [
"maxminddb",
"mime_guess",
"msi-extract",
"native-tls",
"nix",
"objc2",
"objc2-app-kit",
@@ -1798,9 +1804,10 @@ dependencies = [
"tempfile",
"thiserror 2.0.20",
"tokio",
"tokio-native-tls",
"tokio-tungstenite 0.30.0",
"tokio-util",
"toml 1.1.4+spec-1.1.0",
"toml_edit 0.25.13+spec-1.1.0",
"tower",
"tower-http 0.7.1",
"url",
@@ -1891,6 +1898,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -2282,12 +2299,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.34"
@@ -2543,13 +2554,23 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval 0.6.2",
]
[[package]]
name = "ghash"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
dependencies = [
"polyval",
"polyval 0.7.3",
]
[[package]]
@@ -2856,6 +2877,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac 0.12.1",
]
[[package]]
name = "hkdf"
version = "0.13.0"
@@ -3511,6 +3541,15 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "jsonc-parser"
version = "0.33.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0560e3f9a9a03ea6b6e90b41138c5db9e21526c99eb192c1a26c68176593285"
dependencies = [
"serde_json",
]
[[package]]
name = "jsonptr"
version = "0.6.3"
@@ -3756,12 +3795,12 @@ dependencies = [
[[package]]
name = "md-5"
version = "0.11.0"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest 0.11.3",
"digest 0.10.7",
]
[[package]]
@@ -4678,6 +4717,16 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -4748,16 +4797,6 @@ dependencies = [
"universal-hash 0.5.1",
]
[[package]]
name = "poly1305"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
dependencies = [
"cpufeatures 0.3.1",
"universal-hash 0.6.1",
]
[[package]]
name = "polycool"
version = "0.4.0"
@@ -4767,6 +4806,18 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash 0.5.1",
]
[[package]]
name = "polyval"
version = "0.7.3"
@@ -5323,10 +5374,23 @@ dependencies = [
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted 0.9.0",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "ring-compat"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccce7bae150b815f0811db41b8312fcb74bffa4cab9cee5429ee00f356dd5bd4"
dependencies = [
"aead 0.5.2",
"ed25519",
"generic-array",
"pkcs8",
"ring",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
@@ -5435,7 +5499,7 @@ checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted 0.9.0",
"untrusted",
]
[[package]]
@@ -5527,13 +5591,13 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sealed"
version = "0.7.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"
checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 2.0.119",
]
[[package]]
@@ -5542,11 +5606,11 @@ version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
dependencies = [
"aes",
"aes 0.9.3",
"cbc",
"futures-util",
"getrandom 0.4.3",
"hkdf",
"hkdf 0.13.0",
"hybrid-array",
"num",
"once_cell",
@@ -5883,11 +5947,11 @@ dependencies = [
[[package]]
name = "shadowsocks"
version = "1.25.0"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2065b026dbe4f47048eca384adf07f693bf901050d80abd3adb7b2422709b2d"
checksum = "482831bf9d55acf3c98e211b6c852c3dfdf1d1b0d23fdf1d887c5a4b2acad4e4"
dependencies = [
"base64 0.23.1",
"base64 0.22.1",
"blake3",
"byte_string",
"bytes",
@@ -5916,18 +5980,18 @@ dependencies = [
[[package]]
name = "shadowsocks-crypto"
version = "0.8.0"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b404a62ebea5003f44b9a2748dee7555c5f9ae39896508e466a2a91ee6fa5df5"
checksum = "3d038a3d17586f1c1ab3c1c3b9e4d5ef8fba98fb3890ad740c8487038b2e2ca5"
dependencies = [
"aes-gcm",
"aws-lc-rs",
"aes-gcm 0.10.3",
"cfg-if",
"chacha20poly1305 0.11.0",
"hkdf",
"chacha20poly1305",
"hkdf 0.12.4",
"md-5",
"rand 0.10.2",
"sha1 0.11.0",
"rand 0.9.5",
"ring-compat",
"sha1 0.10.7",
]
[[package]]
@@ -5978,6 +6042,12 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -6113,13 +6183,23 @@ dependencies = [
[[package]]
name = "spin"
version = "0.12.3"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10"
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
@@ -6807,7 +6887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -7176,6 +7256,7 @@ dependencies = [
"indexmap 2.14.2",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.4",
]
@@ -7513,12 +7594,6 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
+19 -2
View File
@@ -30,6 +30,8 @@ resvg = "0.48"
[dependencies]
serde_json = "1"
# Runtime as well as build time: the per-profile window badge is rendered at launch.
resvg = "0.48"
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2"
@@ -47,6 +49,16 @@ env_logger = "0.11"
directories = "6"
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "stream", "socks", "charset", "http2", "system-proxy"] }
# The `httpstls` upstream type wraps the hop to the proxy in TLS before a single
# byte of CONNECT or Proxy-Authorization is written. native-tls (not rustls) on
# purpose: reqwest above already terminates its proxy TLS through native-tls, so
# both the browser tunnel and the check-button probe consult the same platform
# trust store. A rustls tunnel plus a native-tls probe would mean a proxy that
# passes the check and then fails in the browser. Both crates already build
# today as transitive deps of reqwest and tokio-tungstenite; this adds an edge,
# not a crate.
native-tls = "0.2"
tokio-native-tls = "0.3"
tokio = { version = "1", features = ["full", "sync"] }
tokio-util = "0.7"
sysinfo = "0.39"
@@ -88,7 +100,11 @@ cbc = "0.2"
ring = "0.17"
subtle = "2"
sha2 = "0.11"
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
# Held below 1.25 on purpose. From 1.25 the `aead-cipher` feature hard-enables the
# aws-lc crypto backend (a C/assembly library needing cmake, and NASM on Windows)
# alongside the `ring` this crate already ships through boringtun. Moving up is a
# build-toolchain decision for every platform, not a routine dependency refresh.
shadowsocks = { version = ">=1.24, <1.25", default-features = false, features = ["aead-cipher"] }
hyper = { version = "1.10", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
@@ -100,7 +116,6 @@ async-socks5 = "0.6"
tokio-tungstenite = { version = "0.30", features = ["native-tls"] }
rusqlite = { version = "0.40", features = ["bundled"] }
serde_yaml = "0.9"
toml = "1.1"
thiserror = "2.0"
regex-lite = "0.1"
tempfile = "3"
@@ -116,6 +131,8 @@ image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
sys-locale = "0.3"
jsonc-parser = { version = "0.33", features = ["cst", "serde_json"] }
toml_edit = "0.25"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["signal", "process"] }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+319 -9
View File
@@ -61,10 +61,121 @@ fn log_dir_for(root: Option<PathBuf>, portable: Option<&PathBuf>) -> Option<Path
/// File name `tauri-plugin-window-state` persists geometry under.
pub const WINDOW_STATE_FILENAME: &str = ".window-state.json";
/// File name of the pointer that records a data directory the user chose in
/// Settings.
pub const DATA_ROOT_POINTER_FILENAME: &str = "data-root.json";
static CUSTOM_DATA_ROOT: OnceLock<Option<PathBuf>> = OnceLock::new();
/// Where the pointer to a user-chosen data directory lives.
///
/// It must never sit inside `data_dir()` itself: a move deletes the old
/// directory once the copy verifies, which would take the pointer with it and
/// send the next start back to the platform default. Every branch below
/// therefore resolves OUTSIDE the data directory it points at.
///
/// - With `DONUTBROWSER_DATA_ROOT` set, `<root>/data-root.json`, a sibling of
/// `<root>/data`. An isolated run (the E2E harness) then keeps its own
/// pointer and can never read, or write, the real machine's.
/// - In portable mode, `<exe dir>/data-root.json`, beside `<exe dir>/data`, so
/// the choice travels with the install.
/// - Otherwise the platform preference directory, which is a different root
/// from `data_local_dir` on macOS, Linux and Windows alike.
pub fn data_root_pointer_file() -> PathBuf {
data_root_pointer_file_for(
data_root(),
portable_dir(),
base_dirs().preference_dir().join(app_name()),
)
}
/// Split out from `data_root_pointer_file` so the precedence is testable
/// without a `.portable` marker or process-wide environment mutation.
fn data_root_pointer_file_for(
root: Option<PathBuf>,
portable: Option<&PathBuf>,
preference_dir: PathBuf,
) -> PathBuf {
if let Some(root) = root {
return root.join(DATA_ROOT_POINTER_FILENAME);
}
if let Some(dir) = portable {
return dir.join(DATA_ROOT_POINTER_FILENAME);
}
preference_dir.join(DATA_ROOT_POINTER_FILENAME)
}
/// Read a pointer file written by a previous "move data directory".
///
/// A missing, unreadable, malformed, empty or relative entry resolves to
/// `None`. Falling back to the platform default is always better than
/// resolving every profile, binary and setting to a path that cannot exist.
pub fn read_data_root_pointer(file: &std::path::Path) -> Option<PathBuf> {
let content = std::fs::read_to_string(file).ok()?;
let parsed: serde_json::Value = match serde_json::from_str(&content) {
Ok(value) => value,
Err(e) => {
log::warn!(
"Ignoring the data directory pointer at {}: it is not valid JSON ({e})",
file.display()
);
return None;
}
};
let path = PathBuf::from(parsed.get("path")?.as_str()?);
if path.as_os_str().is_empty() || !path.is_absolute() {
log::warn!(
"Ignoring the data directory pointer at {}: {} is not an absolute path",
file.display(),
path.display()
);
return None;
}
Some(path)
}
/// Record a data directory for the next start. Written atomically, because a
/// truncated pointer read at startup would silently drop the user back onto
/// the platform default with an empty profile list.
pub fn write_data_root_pointer(
file: &std::path::Path,
path: &std::path::Path,
) -> std::io::Result<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::json!({ "path": path.to_string_lossy() }).to_string();
let temp = file.with_extension("json.tmp");
std::fs::write(&temp, body.as_bytes())?;
std::fs::rename(&temp, file)
}
/// Forget a recorded data directory, returning the app to the default.
pub fn clear_data_root_pointer(file: &std::path::Path) -> std::io::Result<()> {
match std::fs::remove_file(file) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
/// The data directory a previous move chose, read once per process.
///
/// Cached deliberately. Every open handle, cached path and loaded manager in a
/// running app points at the directory it started on, so a move must take
/// effect at the NEXT start and never mid-session.
pub fn custom_data_root() -> Option<&'static PathBuf> {
CUSTOM_DATA_ROOT
.get_or_init(|| read_data_root_pointer(&data_root_pointer_file()))
.as_ref()
}
/// True when app state has been moved off the platform default location, by
/// portable mode or by either directory override.
/// portable mode, either directory override, or a data directory the user
/// chose in Settings.
fn state_is_relocated() -> bool {
std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty())
|| custom_data_root().is_some()
|| data_root().is_some()
|| portable_dir().is_some()
}
@@ -80,8 +191,34 @@ fn state_is_relocated() -> bool {
/// host machine. If a future plugin version sanitises the name to a bare file
/// component this silently reverts to the default directory, which is why the
/// first-run probe in `lib.rs` reads this same function rather than assuming.
///
/// A relocation that resolves to a relative path is rejected: see
/// `window_state_override_for`.
pub fn window_state_path_override() -> Option<PathBuf> {
state_is_relocated().then(|| data_dir().join(WINDOW_STATE_FILENAME))
window_state_override_for(state_is_relocated(), data_dir())
}
/// Split out from `window_state_path_override` so the absolute-path rule is
/// testable without mutating process-wide environment variables.
///
/// A relative override is worse than no override: the plugin would resolve it
/// against `app_config_dir` and write into an intermediate directory it never
/// creates, so every save fails with ENOENT and is swallowed by the plugin's
/// fire-and-forget exit handler. Falling back to the platform default at least
/// persists geometry.
fn window_state_override_for(relocated: bool, data_dir: PathBuf) -> Option<PathBuf> {
if !relocated {
return None;
}
let path = data_dir.join(WINDOW_STATE_FILENAME);
if !path.is_absolute() {
log::warn!(
"Ignoring relative window-state override {}: the plugin resolves its filename against app_config_dir, so geometry would never persist. Set DONUTBROWSER_DATA_DIR/DONUTBROWSER_DATA_ROOT to an absolute path.",
path.display()
);
return None;
}
Some(path)
}
/// Where the window-state file actually is, override or not. Used for the
@@ -115,19 +252,66 @@ pub fn data_dir() -> PathBuf {
}
}
if let Ok(dir) = std::env::var("DONUTBROWSER_DATA_DIR") {
return PathBuf::from(dir);
}
data_dir_for(
std::env::var_os("DONUTBROWSER_DATA_DIR")
.filter(|v| !v.is_empty())
.map(PathBuf::from),
custom_data_root(),
data_root(),
portable_dir(),
base_dirs().data_local_dir().join(app_name()),
)
}
if let Some(root) = data_root() {
/// The data directory resolution order, split out so it can be tested without
/// mutating process-wide environment variables.
///
/// `DONUTBROWSER_DATA_DIR` stays on top: it names an exact directory and is the
/// bluntest override there is. The directory the user picked in Settings comes
/// next, ahead of `DONUTBROWSER_DATA_ROOT` and portable mode, because both of
/// those are defaults for where state *would* live and an explicit choice
/// outranks a default. It cannot break an isolated run, because the pointer it
/// is read from lives under that same `DONUTBROWSER_DATA_ROOT`.
fn data_dir_for(
env_data_dir: Option<PathBuf>,
custom_root: Option<&PathBuf>,
env_data_root: Option<PathBuf>,
portable: Option<&PathBuf>,
platform_default: PathBuf,
) -> PathBuf {
if let Some(dir) = env_data_dir {
return dir;
}
if let Some(dir) = custom_root {
return dir.clone();
}
if let Some(root) = env_data_root {
return root.join("data");
}
if let Some(dir) = portable_dir() {
if let Some(dir) = portable {
return dir.join("data");
}
platform_default
}
base_dirs().data_local_dir().join(app_name())
/// Where the data directory would resolve with no user choice recorded. Shown
/// in Settings so a person can see what they moved away from.
pub fn default_data_dir() -> PathBuf {
data_dir_for(
std::env::var_os("DONUTBROWSER_DATA_DIR")
.filter(|v| !v.is_empty())
.map(PathBuf::from),
None,
data_root(),
portable_dir(),
base_dirs().data_local_dir().join(app_name()),
)
}
/// True when an environment override decides the data directory, so a
/// directory chosen in Settings would be recorded but not used.
pub fn data_dir_forced_by_environment() -> bool {
std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty())
}
pub fn cache_dir() -> PathBuf {
@@ -389,6 +573,27 @@ mod tests {
}
}
#[test]
fn window_state_override_rejects_a_relative_data_dir() {
// `DONUTBROWSER_DATA_ROOT=don-state` (or a relative DATA_DIR) would hand the
// plugin a relative filename it resolves against app_config_dir, into a
// directory nothing creates. Falling back to the default keeps geometry.
assert_eq!(
window_state_override_for(true, PathBuf::from("don-state/data")),
None
);
assert_eq!(window_state_override_for(true, PathBuf::from("")), None);
// temp_dir is absolute on every platform; a hard-coded "/tmp/..." is not
// absolute on Windows, where these tests also run.
let relocated = std::env::temp_dir().join("donut-relocated");
assert_eq!(
window_state_override_for(true, relocated.clone()),
Some(relocated.join(WINDOW_STATE_FILENAME))
);
assert_eq!(window_state_override_for(false, relocated), None);
}
#[test]
fn window_state_follows_a_relocated_data_dir() {
let tmp = PathBuf::from("/tmp/donut-relocated");
@@ -413,6 +618,111 @@ mod tests {
assert!(portable.join("cache").starts_with(&portable));
}
#[test]
fn data_dir_resolution_order_puts_the_chosen_directory_under_the_exact_override() {
let env_dir = PathBuf::from("/env/exact");
let chosen = PathBuf::from("/Volumes/Big/DonutBrowser");
let env_root = PathBuf::from("/env/root");
let portable = PathBuf::from("/stick");
let default = PathBuf::from("/home/user/.local/share/DonutBrowser");
// DONUTBROWSER_DATA_DIR names an exact directory and outranks everything.
assert_eq!(
data_dir_for(
Some(env_dir.clone()),
Some(&chosen),
Some(env_root.clone()),
Some(&portable),
default.clone(),
),
env_dir
);
// The directory the user picked beats both defaults-for-where-state-lives.
assert_eq!(
data_dir_for(
None,
Some(&chosen),
Some(env_root.clone()),
Some(&portable),
default.clone(),
),
chosen
);
// With nothing chosen the existing order is untouched.
assert_eq!(
data_dir_for(
None,
None,
Some(env_root.clone()),
Some(&portable),
default.clone(),
),
env_root.join("data")
);
assert_eq!(
data_dir_for(None, None, None, Some(&portable), default.clone()),
portable.join("data")
);
assert_eq!(
data_dir_for(None, None, None, None, default.clone()),
default
);
}
#[test]
fn the_pointer_never_lives_inside_the_directory_it_points_at() {
let root = PathBuf::from("/tmp/donut-root");
let portable = PathBuf::from("/tmp/donut-portable");
let preference = PathBuf::from("/home/user/.config/DonutBrowser");
// With DONUTBROWSER_DATA_ROOT the data dir is <root>/data, so a sibling
// file survives deleting it — and an isolated run reads only its own.
let with_root =
data_root_pointer_file_for(Some(root.clone()), Some(&portable), preference.clone());
assert_eq!(with_root, root.join(DATA_ROOT_POINTER_FILENAME));
assert!(!with_root.starts_with(root.join("data")));
let with_portable = data_root_pointer_file_for(None, Some(&portable), preference.clone());
assert_eq!(with_portable, portable.join(DATA_ROOT_POINTER_FILENAME));
assert!(!with_portable.starts_with(portable.join("data")));
assert_eq!(
data_root_pointer_file_for(None, None, preference.clone()),
preference.join(DATA_ROOT_POINTER_FILENAME)
);
}
#[test]
fn a_written_pointer_reads_back_and_a_broken_one_falls_back() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("nested").join(DATA_ROOT_POINTER_FILENAME);
let target = std::env::temp_dir().join("donut-moved-root");
assert_eq!(read_data_root_pointer(&file), None, "missing file");
write_data_root_pointer(&file, &target).unwrap();
assert_eq!(read_data_root_pointer(&file), Some(target.clone()));
// A relative path would be resolved against whatever the working directory
// happens to be, which is not a place app state can live.
write_data_root_pointer(&file, std::path::Path::new("relative/root")).unwrap();
assert_eq!(read_data_root_pointer(&file), None, "relative path");
std::fs::write(&file, b"not json at all").unwrap();
assert_eq!(read_data_root_pointer(&file), None, "malformed file");
std::fs::write(&file, br#"{"other":"key"}"#).unwrap();
assert_eq!(read_data_root_pointer(&file), None, "no path entry");
write_data_root_pointer(&file, &target).unwrap();
clear_data_root_pointer(&file).unwrap();
assert_eq!(read_data_root_pointer(&file), None, "cleared");
// Clearing an absent pointer is not an error; the caller has nothing to fix.
clear_data_root_pointer(&file).unwrap();
}
#[test]
fn test_subdirectory_helpers() {
assert!(profiles_dir().ends_with("profiles"));
+14
View File
@@ -309,6 +309,19 @@ impl AutoUpdater {
// Check if profile is currently running
if profile.process_id.is_some() {
// A pending entry is matched on the profile's current version alone,
// so recording one for an older build would downgrade the profile the
// moment it closes, and would pin that older binary against cleanup.
if !self.is_version_newer(new_version, &profile.version) {
log::debug!(
"Not queuing {} for running profile {}: not newer than {}",
new_version,
profile.name,
profile.version
);
continue;
}
// Store as pending update so it gets applied when browser closes
log::info!(
"Profile {} is running, storing pending update {} -> {}",
@@ -662,6 +675,7 @@ mod tests {
last_sync: None,
host_os: None,
ephemeral: false,
temporary: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
+1 -1
View File
@@ -102,7 +102,7 @@ async fn main() {
.arg(
Arg::new("type")
.long("type")
.help("Proxy type (http, https, socks4, socks5, ss)"),
.help("Proxy type (http, https, httpstls, socks4, socks5, ss)"),
)
.arg(
Arg::new("port")
+1
View File
@@ -727,6 +727,7 @@ mod tests {
last_sync: None,
host_os: None,
ephemeral: false,
temporary: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
+96 -18
View File
@@ -33,6 +33,18 @@ async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<(
lock.lock_owned().await
}
fn emit_launch_stage(profile: &BrowserProfile, stage: &str, error: Option<&str>) {
let _ = events::emit(
"profile-launch-stage",
serde_json::json!({
"id": profile.id.to_string(),
"stage": stage,
"timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64,
"error": error,
}),
);
}
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -208,6 +220,10 @@ impl BrowserRunner {
.map_err(|e| format!("Failed to get executable path for {}: {e}", profile.browser).into())
}
/// One argument per thing a launch decides, and they are all independent:
/// grouping them into a struct would only move the same list one level out,
/// and the one caller shape that repeats already has `LaunchOptions`.
#[allow(clippy::too_many_arguments)]
async fn launch_browser_internal(
&self,
app_handle: tauri::AppHandle,
@@ -215,6 +231,7 @@ impl BrowserRunner {
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
kind: crate::wayfern_manager::LaunchKind,
gate: &crate::launch_gate::FingerprintGate,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Handle Wayfern profiles using WayfernManager
@@ -228,6 +245,7 @@ impl BrowserRunner {
WayfernConfig::default()
});
emit_launch_stage(profile, "network", None);
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
let mut upstream_proxy = self
.resolve_launch_proxy(profile)
@@ -292,9 +310,16 @@ impl BrowserRunner {
vpn_id: String,
created: bool,
profile_name: String,
/// This launch's own hold on the worker, kept until the guard goes out
/// of scope so a sibling launch cannot stop the worker while this one
/// is still between adoption and publishing its browser PID.
claim: Option<crate::vpn_worker_runner::VpnLaunchClaim>,
}
impl Drop for VpnLaunchGuard {
fn drop(&mut self) {
// Released before anything reads the claims, or this launch would
// count itself as a reason to keep the worker it just failed to use.
drop(self.claim.take());
let Some(worker_id) = self.worker_id.take() else {
return;
};
@@ -333,6 +358,7 @@ impl BrowserRunner {
vpn_id: vpn_id.clone(),
created: started.created,
profile_name: profile.name.clone(),
claim: Some(started.claim),
});
if let Some(port) = started.config.local_port {
upstream_proxy = Some(ProxySettings {
@@ -361,6 +387,12 @@ impl BrowserRunner {
// unpack, and the browser process, so a blocked launch has nothing to
// undo beyond the two workers whose guards are already armed above.
//
// The group's bookmarks are written before the gate rather than inside
// it: the gate returns early for several kinds of profile and answers a
// question ("may this launch proceed"), while this is a preparation step
// every spawn needs, including a profile with no route to check.
crate::group_bookmarks::sync_for_launch(profile);
// Run concurrently with the blocklist compile so the added wall clock is
// max(), not sum().
let (blocklist, gate_result) = tokio::join!(
@@ -522,21 +554,20 @@ impl BrowserRunner {
// launch at all, because nothing tells the user to stop using it.
//
// Structured rather than prose, because the most common failure is the
// browser refusing a generation once the account's hourly quota is
// spent. That has to reach the user as an explanation; a raw CDP string
// is not one, and the frontend only translates a coded error.
// browser refusing a generation outright. That has to reach the user as
// an explanation; a raw CDP string is not one, and the frontend only
// translates a coded error.
let generated = self
.wayfern_manager
.generate_fingerprint_config(&app_handle, profile, &config_for_generation)
.await
.map_err(|e| {
let detail = e.to_string();
// BOTH refusal texts, because this path serves BOTH releases. 151
// says "Fingerprint generation limit reached for this account.";
// the shipped 150 browser says "Too many profiles are being
// created." Matching only the 151 wording leaves a quota-blocked
// 150 user staring at a raw CDP string, which is the exact defect
// this mapping exists to remove.
// BOTH refusal texts, because a profile may be on either browser
// version. Older builds word the generation-limit refusal
// differently, and matching only one wording leaves those users
// staring at a raw CDP string, which is the exact defect this
// mapping exists to remove.
if detail.contains("generation limit reached") || detail.contains("Too many profiles") {
crate::backend_error_with_detail("WAYFERN_GENERATION_LIMIT_REACHED", detail)
} else {
@@ -650,6 +681,7 @@ impl BrowserRunner {
let profile_path_str = profile_data_path.to_string_lossy().to_string();
// Install extensions if an extension group is assigned
emit_launch_stage(profile, "extensions", None);
let mut extension_paths = Vec::new();
if updated_profile.extension_group_id.is_some() {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
@@ -673,6 +705,7 @@ impl BrowserRunner {
// Get proxy URL from config
let proxy_url = wayfern_config.proxy.as_deref();
emit_launch_stage(profile, "starting", None);
let wayfern_result = self
.wayfern_manager
.launch_wayfern(
@@ -686,6 +719,7 @@ impl BrowserRunner {
&extension_paths,
remote_debugging_port,
headless,
kind,
)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
@@ -874,6 +908,7 @@ impl BrowserRunner {
url,
remote_debugging_port,
headless,
crate::wayfern_manager::LaunchKind::Automation,
gate,
)
.await
@@ -966,7 +1001,15 @@ impl BrowserRunner {
} else {
log::info!("Launching new browser instance - browser not running");
self
.launch_browser_internal(app_handle.clone(), &final_profile, url, None, false, gate)
.launch_browser_internal(
app_handle.clone(),
&final_profile,
url,
None,
false,
crate::wayfern_manager::LaunchKind::Interactive,
gate,
)
.await
}
}
@@ -1003,7 +1046,7 @@ impl BrowserRunner {
// "Stop this profile" has to mean the browser that is actually running, and
// for a profile on the leased fleet that browser is not on this machine.
// Without this, stopping reported success, killed nothing, and left the
// session running to its two-hour cap — billing the user for every minute
// session running to its maximum duration — spending the user's allowance
// and holding their profile lock the whole time.
if self.stop_remote_session_for(&app_handle, profile).await? {
return Ok(());
@@ -1039,10 +1082,9 @@ impl BrowserRunner {
crate::remote_session::end_remote_session(&session_id)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
// Surfaced rather than swallowed. The backend refuses to retire a
// session it could not stop on the fleet, so a failure here means the
// browser is STILL RUNNING; reporting success would tell the user their
// profile is free when a host is still writing to it.
// Surfaced rather than swallowed. A failure here means the browser is
// STILL RUNNING; reporting success would tell the user their profile is
// free when a remote host is still writing to it.
log::warn!("Failed to stop remote session {session_id}: {e}");
e.to_error_json().into()
})?;
@@ -1051,8 +1093,8 @@ impl BrowserRunner {
// the profile into "pending sync" and starts the pull, so the user is not
// handed back a profile directory that predates the session they just ran.
//
// The session's own profile lock is released by the backend when it retires
// the row; nothing is released from here, because this client never held it.
// The session's own profile lock is released by the server; nothing is
// released from here, because this client never held it.
crate::remote_session::note_session_stopped(app_handle, &session_id);
Ok(true)
}
@@ -1409,6 +1451,22 @@ impl BrowserRunner {
&profile.id.to_string(),
);
// A temporary profile exists for one automation run, so the run ending
// is what ends it. Destroyed rather than trashed: nothing here is worth
// restoring, and a trash full of automation leftovers is its own bug.
if profile.temporary {
match self
.profile_manager
.delete_profile_permanently(&app_handle, &profile.id.to_string())
{
Ok(()) => log::info!(
"Deleted temporary profile {} now that its browser has stopped",
profile.name
),
Err(e) => log::warn!("Could not delete temporary profile {}: {e}", profile.name),
}
}
log::info!(
"Wayfern process cleanup completed for profile: {} (ID: {})",
profile.name,
@@ -1632,6 +1690,26 @@ pub async fn launch_browser_profile_impl(
profile: BrowserProfile,
url: Option<String>,
options: LaunchOptions,
) -> Result<BrowserProfile, String> {
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
emit_launch_stage(&profile, "queued", None);
let result = launch_browser_profile_tracked(app_handle, profile.clone(), url, options).await;
match &result {
Ok(_) => emit_launch_stage(&profile, "running", None),
Err(error) => emit_launch_stage(
&profile,
"failed",
Some(&crate::wrap_backend_error(error, "Browser launch failed")),
),
}
result
}
async fn launch_browser_profile_tracked(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
options: LaunchOptions,
) -> Result<BrowserProfile, String> {
let LaunchOptions {
remote_debugging_port,
@@ -1644,7 +1722,7 @@ pub async fn launch_browser_profile_impl(
profile.name,
profile.id
);
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
emit_launch_stage(&profile, "preparing", None);
if profile.is_cross_os() {
return Err(format!(
+133 -71
View File
@@ -2,21 +2,19 @@
//!
//! Until this module existed, every automation tool answered "where is this
//! browser?" by reading a LOCAL debugging port out of the LOCAL profile
//! directory. A profile launched on a leased host has no local port and no
//! directory. A profile launched on a remote host has no local port and no
//! local process, so a customer who paid for remote execution could start a
//! session and then do nothing with it — the one thing the feature exists for.
//!
//! There is exactly one resolver here, [`resolve`], and one connection type,
//! [`CdpConnection`]. Tools ask for a target and get either a page socket on
//! this machine or a relayed socket to the fleet; nothing above this module
//! branches on which. That is deliberate: a parallel set of remote-only tools
//! would drift from the local ones within a release.
//! this machine or a relayed socket to a remote browser; nothing above this
//! module branches on which. That is deliberate: a parallel set of remote-only
//! tools would drift from the local ones within a release.
//!
//! The remote arm reaches donutbrowser-infra with the USER's own access token.
//! The desktop holds no fleet credential and knows no fleet hostname — infra
//! verifies the session belongs to the caller and relays onward with its own
//! service credential. That boundary is why this is a relay and not a direct
//! connection.
//! The remote arm reaches the cloud API with the USER's own access token, and
//! never holds any credential or hostname belonging to the machine the browser
//! runs on. That boundary is why this is a relay and not a direct connection.
use crate::profile::types::BrowserProfile;
use serde_json::Value;
@@ -30,16 +28,15 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
/// How long the WebSocket handshake may take.
///
/// A remote attach crosses desktop → infra → wayfern → agent → the VM, so this
/// is far longer than a loopback connect needs. It matches the relay's own
/// upstream handshake budget: waiting longer than the server does can only
/// report a timeout the server already reported.
/// A remote attach crosses several networks before it reaches the browser, so
/// this is far longer than a loopback connect needs. Waiting longer than the
/// server does can only report a timeout the server already reported.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
/// How long one CDP command may wait for its reply.
///
/// Without a cap, a browser that never answers holds the caller until the
/// socket dies — 90 seconds on the relay, indefinitely on loopback. An
/// socket dies — a bounded wait remotely, indefinitely on loopback. An
/// automation client that hangs is worse than one that fails.
const COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
@@ -51,10 +48,9 @@ const CONNECT_RETRY_BASE: Duration = Duration::from_millis(400);
/// Ceiling on a relayed CDP message.
///
/// Matches the relay's client-facing cap, which matches the fleet's upstream
/// frame cap. Lower, and a screenshot the server was willing to carry is
/// dropped on arrival; higher buys nothing, because the frame never crosses the
/// relay in the first place.
/// Matches the frame cap the remote endpoint enforces. Lower, and a screenshot
/// the server was willing to carry is dropped on arrival; higher buys nothing,
/// because the frame never crosses the network in the first place.
const REMOTE_MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
/// Command ids for the two messages the remote arm sends before any tool does.
@@ -72,9 +68,9 @@ pub enum CdpTarget {
/// A browser on this machine. The URL is a PAGE-level socket, so commands
/// carry no CDP session id.
Local { ws_url: String },
/// A browser on the fleet, reached through the infra relay. The relay bridges
/// a BROWSER-level socket, so the connection attaches to a page and stamps
/// every subsequent message with the resulting session id.
/// A browser running remotely, reached through the cloud API. The remote
/// endpoint exposes a BROWSER-level socket, so the connection attaches to a
/// page and stamps every subsequent message with the resulting session id.
Remote {
ws_url: String,
bearer: String,
@@ -83,7 +79,7 @@ pub enum CdpTarget {
}
impl CdpTarget {
/// True when this browser is on the leased fleet rather than this machine.
/// True when this browser is on a remote host rather than this machine.
pub fn is_remote(&self) -> bool {
matches!(self, Self::Remote { .. })
}
@@ -105,9 +101,9 @@ impl CdpTarget {
/// broken one.
#[derive(Debug)]
pub enum CdpError {
/// Nothing is listening, or the relay could not reach the browser.
/// Nothing is listening, or the browser could not be reached.
Unreachable(String),
/// The relay refused the credential.
/// The credential was refused.
Unauthorized(String),
/// The session exists but is not in a state that can be driven.
NotDrivable(String),
@@ -133,9 +129,9 @@ impl CdpError {
/// Whether a fresh connection attempt could plausibly succeed.
///
/// A refused credential and a session that is still provisioning are answers,
/// not failures. Retrying either spends the caller's time and, on the relay,
/// burns one of the four attachments a session is allowed — so the retry can
/// make the next honest attempt fail too.
/// not failures. Retrying either spends the caller's time and counts against
/// the session's attachment budget — so the retry can make the next honest
/// attempt fail too.
fn is_retryable(&self) -> bool {
matches!(self, Self::Unreachable(_) | Self::Transport(_))
}
@@ -185,11 +181,11 @@ impl Patience {
/// one crosses two networks.
///
/// The local check is deliberately split in two. One cheap probe decides the
/// arm, so a profile running on the fleet is not held behind twenty-five
/// seconds of local retries; only once remote has been ruled out does the local
/// probe spend its full budget waiting for a browser that is still starting.
/// The same split covers a stale `process_id` left by a crash — nothing answers
/// on the recorded port, so the fleet session is found instead of a dead one.
/// arm, so a profile running remotely is not held behind twenty-five seconds of
/// local retries; only once remote has been ruled out does the local probe
/// spend its full budget waiting for a browser that is still starting. The same
/// split covers a stale `process_id` left by a crash — nothing answers on the
/// recorded port, so the remote session is found instead of a dead one.
pub async fn resolve(profile: &BrowserProfile) -> Result<CdpTarget, ResolveError> {
if profile.browser != "wayfern" {
return Err(ResolveError::Unsupported(format!(
@@ -304,12 +300,30 @@ async fn local_page_ws_url(profile: &BrowserProfile, patience: Patience) -> Opti
}
/// Pick a drivable page from what `/json` lists on a local browser.
///
/// DRIVABLE, not merely first. This used to take the first `type == "page"` and
/// then reach for its socket, so a first entry without a
/// `webSocketDebuggerUrl`, a page another client is already attached to, which
/// Chromium omits the field for, made the whole call answer None and every
/// browser tool fail, while a perfectly drivable second tab sat right behind it.
/// The user sees "no page target found in browser" on a browser plainly showing
/// pages.
///
/// `devtools://` is excluded for the same reason [`pick_remote_page_target`]
/// excludes it: attaching there drives the inspector rather than the site, which
/// reports success and moves nothing. The two functions answer the same question
/// off different payload shapes, so they must not disagree about what counts.
pub fn pick_local_page_socket(targets: &[Value]) -> Option<String> {
targets
.iter()
.find(|t| t.get("type").and_then(Value::as_str) == Some("page"))
.and_then(|t| t.get("webSocketDebuggerUrl"))
.and_then(Value::as_str)
.filter(|t| t.get("type").and_then(Value::as_str) == Some("page"))
.filter(|t| {
!t.get("url")
.and_then(Value::as_str)
.unwrap_or_default()
.starts_with("devtools://")
})
.find_map(|t| t.get("webSocketDebuggerUrl").and_then(Value::as_str))
.map(str::to_string)
}
@@ -414,10 +428,9 @@ impl CdpConnection {
/// Turn a hang-up into the error it means.
///
/// The relay's close codes are its whole vocabulary: 1008 is "that credential
/// is no good", 1013 is "come back when the session is up". Reporting either
/// as a generic transport failure throws away the only actionable thing the
/// server said.
/// The close codes carry the only actionable thing the server says: 1008
/// means the credential was refused, 1013 means the session is not up yet.
/// Reporting either as a generic transport failure throws that away.
pub fn closed_error(&self, context: &str) -> CdpError {
match &self.closed {
Some(info) if info.reason.is_empty() => {
@@ -476,20 +489,20 @@ impl CdpConnection {
/// Hang up politely so the peer releases its side immediately.
///
/// On the relay every open socket costs a real stream on the leased host and
/// counts against the session's attachment cap, so dropping the TCP
/// connection and letting it time out is not good enough.
/// A remote socket that is not closed keeps consuming the session's
/// attachment budget, so dropping the TCP connection and letting it time out
/// is not good enough.
pub async fn close(mut self) {
let _ = self.stream.close(None).await;
}
/// Move a browser-level socket onto a page.
///
/// The relay bridges `/devtools/browser/<id>`. Every tool here speaks
/// `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers with
/// `'Page.navigate' wasn't found`. Attaching flat, and stamping the resulting
/// session id onto everything after it, is what makes the tools this app
/// already has work remotely without a single per-tool change.
/// The remote endpoint exposes `/devtools/browser/<id>`. Every tool here
/// speaks `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers
/// with `'Page.navigate' wasn't found`. Attaching flat, and stamping the
/// resulting session id onto everything after it, is what makes the tools
/// this app already has work remotely without a single per-tool change.
async fn attach_to_page(&mut self) -> Result<(), CdpError> {
let targets = self
.call(
@@ -641,8 +654,8 @@ pub async fn run_command_awaiting_load(
///
/// This is what "open a URL in that profile" means once the browser is already
/// up, wherever it is. A remote session navigates its existing page rather than
/// opening a tab: a tab opened on a leased host that nobody can see or close is
/// not a feature, it is litter on hardware the user is paying for by the hour.
/// opening a tab: a tab opened on a remote host that nobody can see or close is
/// not a feature, it is litter on time the user is paying for by the hour.
pub async fn navigate(target: &CdpTarget, url: &str, timeout_secs: u64) -> Result<(), CdpError> {
run_command_awaiting_load(
target,
@@ -730,9 +743,9 @@ pub type RelaySocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
/// target from it and stamp a session id onto messages it did not address.
///
/// This is what makes a remote session usable from outside the app at all. The
/// relay only accepts the user's cloud credential, which no API consumer holds
/// and none should — so the socket is opened here, with the credential this
/// process already has, and proxied to the caller.
/// endpoint only accepts the user's cloud credential, which no API consumer
/// holds and none should — so the socket is opened here, with the credential
/// this process already has, and proxied to the caller.
pub async fn open_relay_socket(session_id: &str) -> Result<RelaySocket, CdpError> {
let endpoint = crate::remote_session::cdp_endpoint(session_id)
.await
@@ -782,7 +795,7 @@ fn endpoint_lookup_error(err: crate::remote_session::RemoteSessionError) -> CdpE
}
}
/// Frame limits for a relay socket. Matches the relay's own client-facing cap.
/// Frame limits for a relay socket. Matches the cap the remote endpoint sets.
pub fn relay_socket_config() -> WebSocketConfig {
WebSocketConfig::default()
.max_message_size(Some(REMOTE_MAX_MESSAGE_BYTES))
@@ -935,12 +948,61 @@ mod tests {
assert!(pick_local_page_socket(&[]).is_none());
}
#[test]
fn a_page_that_cannot_be_driven_does_not_hide_the_one_that_can() {
// Chromium omits `webSocketDebuggerUrl` for a page another client is
// already attached to. Committing to the FIRST page and then reaching for
// its socket answered None for the whole browser, so every browser tool
// failed with "no page target found" while a drivable tab sat behind it.
let attached_first = vec![
serde_json::json!({ "type": "page", "url": "https://example.com/" }),
serde_json::json!({
"type": "page",
"url": "https://example.com/two",
"webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/B"
}),
];
assert_eq!(
pick_local_page_socket(&attached_first).as_deref(),
Some("ws://127.0.0.1:1/devtools/page/B")
);
// And the inspector is not a site. Attaching here drives DevTools itself -
// the failure `pick_remote_page_target` already documents, which reports
// success and moves nothing. The two pickers answer the same question off
// different payloads and must not disagree.
let devtools_first = vec![
serde_json::json!({
"type": "page",
"url": "devtools://devtools/bundled/devtools_app.html",
"webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/DEVTOOLS"
}),
serde_json::json!({
"type": "page",
"url": "https://example.com/",
"webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/REAL"
}),
];
assert_eq!(
pick_local_page_socket(&devtools_first).as_deref(),
Some("ws://127.0.0.1:1/devtools/page/REAL")
);
// A listing with pages but nothing drivable still answers None rather than
// handing back a non-page socket.
let nothing_drivable = vec![
serde_json::json!({ "type": "page", "url": "https://example.com/" }),
serde_json::json!({ "type": "worker", "webSocketDebuggerUrl": "ws://x/w" }),
];
assert!(pick_local_page_socket(&nothing_drivable).is_none());
}
#[test]
fn a_remote_frame_addresses_the_page_and_a_local_one_does_not() {
// A page-level command sent on the relay's BROWSER socket comes back as
// A page-level command sent on a BROWSER-level socket comes back as
// "'Page.navigate' wasn't found". One missing sessionId on one message is
// enough to make a single tool fail while every other tool works — a
// partial failure that reads as a flaky VM.
// partial failure that reads as a flaky remote browser.
let remote = cdp_frame(
Some("SESSION-42"),
7,
@@ -959,9 +1021,9 @@ mod tests {
#[test]
fn a_relay_close_says_what_the_caller_should_do_about_it() {
// These codes are the relay's entire vocabulary. Collapsing them into one
// transport failure is how "your session is still provisioning" and "you
// are signed out" both become "something went wrong".
// These codes carry the whole answer. Collapsing them into one transport
// failure is how "your session is still provisioning" and "you are signed
// out" both become "something went wrong".
assert!(matches!(
classify_close(1008, "x".into()),
CdpError::Unauthorized(_)
@@ -1038,9 +1100,9 @@ mod tests {
#[test]
fn a_session_that_is_over_is_not_reported_as_a_broken_gateway() {
// Observed against the real backend: attaching to a session the user had
// just stopped answered 502, so a CDP client read "this is finished" as
// "the gateway is down" and retried it.
// A session the user has already stopped, or one that is not theirs, must
// read as a 404: there is no browser at this address. Collapsing it into
// "unreachable" makes an automation client retry a finished session.
use crate::remote_session::RemoteSessionError;
assert!(matches!(
endpoint_lookup_error(RemoteSessionError::Other(
@@ -1090,7 +1152,7 @@ mod tests {
#[test]
fn a_hasty_probe_tries_once_and_a_patient_one_waits() {
// The split is what stops a profile running on the fleet from being held
// The split is what stops a profile running remotely from being held
// behind twenty-five seconds of local retries before anyone looks remote.
assert_eq!(Patience::Immediate.attempts(10), 1);
assert_eq!(Patience::WaitForLaunch.attempts(10), 10);
@@ -1121,7 +1183,7 @@ mod tests {
/// Hang up the way a session that is not yet up does.
RefuseAsNotDrivable,
/// Answer the navigation, then drop the socket before the load event,
/// the way a relay does when the browser it bridges dies mid-navigation.
/// the way the remote endpoint does when its browser dies mid-navigation.
DropAfterNavigateReply,
/// Drop the socket without answering the navigation.
DropBeforeNavigateReply,
@@ -1134,7 +1196,7 @@ mod tests {
/// The CDP session id the fake relay hands out for a flat attach.
const FAKE_CDP_SESSION: &str = "CDP-SESSION-1";
/// A stand-in for the infra relay bridged onto a browser-level socket.
/// A stand-in for the remote endpoint bridged onto a browser-level socket.
///
/// Answers `Target.getTargets` and `Target.attachToTarget` exactly as a real
/// browser endpoint does, then echoes each command back so the test can read
@@ -1243,8 +1305,8 @@ mod tests {
}
// The browser died mid-navigation: drop the socket without a close
// frame, the way a relay does when the VM it bridges goes away. The
// command's reply is already in the client's hands.
// frame, the way the remote endpoint does when the browser behind it
// goes away. The command's reply is already in the client's hands.
if behaviour == RelayBehaviour::DropAfterNavigateReply && method == "Page.navigate" {
break;
}
@@ -1275,10 +1337,10 @@ mod tests {
#[tokio::test]
async fn a_relayed_page_command_is_attached_and_stamped_with_its_session() {
// This is the whole feature. The relay bridges /devtools/browser/<id>, so
// without the flat attach and the sessionId stamp every existing tool
// answers "'Page.navigate' wasn't found" and a paid remote session cannot
// be used for anything.
// This is the whole feature. The remote endpoint exposes
// /devtools/browser/<id>, so without the flat attach and the sessionId
// stamp every existing tool answers "'Page.navigate' wasn't found" and a
// paid remote session cannot be used for anything.
let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await;
let target = CdpTarget::Remote {
ws_url,
@@ -1368,7 +1430,7 @@ mod tests {
#[tokio::test]
async fn a_session_that_is_not_up_yet_is_reported_as_such_not_as_a_broken_one() {
// 1013 is the relay saying "come back when it is live". Surfacing it as a
// 1013 means "come back when it is live". Surfacing it as a
// transport failure would send an automation client into a retry loop
// against a session that is doing exactly what it should.
let (ws_url, _server) = fake_relay(RelayBehaviour::RefuseAsNotDrivable).await;
+594 -62
View File
@@ -2,7 +2,6 @@ use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Key, Nonce,
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use chrono::Utc;
use lazy_static::lazy_static;
use rand::RngExt;
@@ -15,18 +14,18 @@ use tokio::sync::Mutex;
use crate::browser::ProxySettings;
use crate::proxy_manager::PROXY_MANAGER;
use crate::settings_manager::SettingsManager;
use crate::settings_manager::{SettingsManager, StoredMcpRemoteKey};
use crate::sync;
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
/// backend's DEFAULT_REQUESTS_PER_HOUR.
/// Default per-hour cap on local automation API / MCP requests, used when the
/// cloud API has not sent one.
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
/// Capability + limit set the account is entitled to, derived from its plan.
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
/// Mirrors the entitlement set the cloud API sends. Features are gated on these
/// flags instead of a single "is paid?" boolean, so a plan like "solo" (cloud
/// backup + nightly cookie bot, no automation, no fingerprint editing, no
/// hands-on remote session) is just data here.
@@ -54,6 +53,23 @@ pub struct Entitlements {
/// control must read THIS rather than `remote_browser_hours > 0`.
#[serde(rename = "remoteInteractive", default)]
pub remote_interactive: bool,
/// Whether the plan may drive THIS desktop from Donut cloud: the remote MCP
/// endpoint and the API in front of it.
///
/// Read only by the UI. The bridge itself never gates on this: the relay
/// decides who may send work, and a cached entitlement that is a refresh
/// cycle out of date must not be what refuses a customer their own machine.
#[serde(rename = "remoteControl", default)]
pub remote_control: bool,
/// Whether the plan may run the browsing agent: a goal the cloud pursues on
/// one profile, on this desktop or on a leased host.
///
/// Read only by the UI, and never back-filled from `browser_automation`. A
/// backend too old to send this key is a backend with no `api/agent` routes
/// to be entitled to, so `false` is the true answer rather than a gap to
/// guess at — the same reasoning `remote_control` is held to.
#[serde(rename = "agentAutomation", default)]
pub agent_automation: bool,
#[serde(rename = "profileLimit", default)]
pub profile_limit: i64,
#[serde(rename = "requestsPerHour", default)]
@@ -83,17 +99,26 @@ fn derive_entitlements(
team_collaboration: false,
cookie_bot: false,
remote_interactive: false,
remote_control: false,
agent_automation: false,
profile_limit: 0,
requests_per_hour: 0,
remote_browser_hours: 0,
};
}
// Tuple order: (browser_automation, cross_os_fingerprints, cloud_backup,
// team_collaboration, cookie_bot, remote_interactive).
// team_collaboration, cookie_bot, remote_interactive, remote_control,
// agent_automation).
//
// pro and any unrecognized paid plan -> pro-level (never team). Solo is the
// one row where cookie_bot and browser_automation disagree, which is why
// cookie_bot can no longer be derived from browser_automation below.
//
// remote_control is enterprise-only, and is withheld from the unrecognized
// row rather than granted with the rest. Everything else here defaults
// generous so a comped account is never locked out of what it is paying for;
// an internet-facing hook into this machine is the one capability where
// guessing "probably yes" is not the safe direction to guess in.
let (
browser_automation,
cross_os_fingerprints,
@@ -101,10 +126,13 @@ fn derive_entitlements(
team_collaboration,
cookie_bot,
remote_interactive,
remote_control,
agent_automation,
) = match plan {
"solo" => (false, false, true, false, true, false),
"team" | "enterprise" => (true, true, true, true, true, true),
_ => (true, true, true, false, true, true),
"solo" => (false, false, true, false, true, false, false, false),
"enterprise" => (true, true, true, true, true, true, true, true),
"team" => (true, true, true, true, true, true, false, true),
_ => (true, true, true, false, true, true, false, true),
};
Entitlements {
active,
@@ -114,6 +142,8 @@ fn derive_entitlements(
team_collaboration,
cookie_bot,
remote_interactive,
remote_control,
agent_automation,
profile_limit,
requests_per_hour: if browser_automation {
DEFAULT_REQUESTS_PER_HOUR
@@ -151,10 +181,15 @@ pub struct CloudUser {
pub team_name: Option<String>,
#[serde(rename = "teamRole", default)]
pub team_role: Option<String>,
/// The plan this account is served under. A team member's `plan` stays
/// `"free"` (the owner pays) while the backend resolves this to the owner's
/// tier. `default` keeps the login response and older backends deserializing;
/// read it through `effective_plan()`.
#[serde(rename = "effectivePlan", default)]
pub effective_plan: Option<String>,
// This desktop session's position among the user's active devices, oldest
// first. Ordinal 1 is the primary device — the only one that can run browser
// automation. `default` keeps older login/state payloads (which lack these
// fields) deserializing cleanly.
// first, as the cloud API reports it. Shown in the UI. `default` keeps older
// login/state payloads (which lack these fields) deserializing cleanly.
#[serde(rename = "deviceOrdinal", default)]
pub device_ordinal: Option<i64>,
#[serde(rename = "deviceCount", default)]
@@ -168,6 +203,13 @@ pub struct CloudUser {
}
impl CloudUser {
/// The plan the account is actually served under: `effectivePlan` when the
/// backend sent one, else the row's own `plan`. Gates that ask "is this a
/// paid / team account" read this; billing-only surfaces keep `plan`.
pub fn effective_plan(&self) -> &str {
self.effective_plan.as_deref().unwrap_or(&self.plan)
}
/// Authoritative entitlements: the server-sent set when present, else derived
/// locally from the plan fields (keeps older cached state / backends working).
pub fn entitlements(&self) -> Entitlements {
@@ -227,6 +269,51 @@ struct SyncTokenResponse {
sync_token: String,
}
/// Prefix of a remote MCP credential. Only a key carrying this prefix may ever
/// be stored here; a credential of any other kind is rejected.
pub const MCP_KEY_PREFIX: &str = "dmk_";
/// A freshly minted remote MCP credential. The plaintext `key` is shown by the
/// server exactly once, in this response.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpKeyGrant {
pub id: String,
pub token_prefix: String,
pub key: String,
}
/// What a key endpoint answered once the request itself went through.
///
/// `api_call_with_retry` reads a 401 out of the ERROR string and refreshes the
/// session once, so only a 401 (and a transport failure) may be an `Err`. Every
/// other refusal travels as a value, so it reaches the code mapping below
/// instead of being mistaken for a dead session.
enum McpKeyAnswer<T> {
Granted(T),
Refused { status: u16, body: String },
}
/// The `{"code"}` the UI shows for a refused credential mint.
///
/// A 409 on `POST /api/mcp/keys` is the per-account cap, whether or not the
/// server bothered to name it; everything else is "not right now", carrying
/// the server's message as the detail so the log says why.
fn mcp_key_refusal(status: u16, body: &str) -> String {
if status == 409 {
return crate::backend_error("MCP_REMOTE_KEY_LIMIT");
}
let message = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| {
v.get("message")
.and_then(|m| m.as_str())
.map(std::string::ToString::to_string)
})
.unwrap_or_else(|| body.to_string());
crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", format!("{status}: {message}"))
}
#[derive(Debug, Deserialize)]
struct WayfernTokenResponse {
token: String,
@@ -304,17 +391,9 @@ impl CloudAuthManager {
let vault_password = Self::get_vault_password();
let salt_bytes: [u8; 16] = rand::rng().random();
let salt =
SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?;
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length".to_string())?;
let salt = crate::sync::encryption::encode_salt(&salt_bytes);
let key_bytes =
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let nonce_bytes: [u8; 12] = rand::rng().random();
@@ -366,7 +445,7 @@ impl CloudAuthManager {
}
let salt_bytes = &file_data[offset..offset + salt_len];
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?;
offset += salt_len;
if offset + 12 > file_data.len() {
@@ -395,15 +474,8 @@ impl CloudAuthManager {
let ciphertext = &file_data[offset..offset + ciphertext_len];
let vault_password = Self::get_vault_password();
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length".to_string())?;
let key_bytes =
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let plaintext = cipher
@@ -560,9 +632,9 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
// The backend returns { message, code, … } for 4xx (e.g. the 3-device
// limit or a temporary security block). Surface the human-readable
// message rather than the raw JSON so the sign-in screen is clear.
// The cloud API returns { message, code, … } for 4xx. Surface the
// human-readable message rather than the raw JSON so the sign-in screen
// is clear.
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
@@ -672,10 +744,206 @@ impl CloudAuthManager {
pub async fn invalidate_session(&self) {
log::warn!("Invalidating session — clearing all auth state");
PROXY_MANAGER.remove_cloud_proxy();
// Same reason `logout` does it: left running, the bridge reconnects with a
// credential that no longer exists, fails, and backs off into an
// "unauthorized" the account page shows to somebody whose session simply
// expired. This is the AUTOMATIC twin of logout, reached when the
// background refresh loop gives up, and it was the one teardown path that
// did not close the bridge.
crate::mcp_remote::stop(None);
// The stored `dmk_` key belongs to the account that just left this
// machine, and the agent configs would keep presenting it. There is no
// session left to revoke it with, so only the local copy goes; the key
// itself is retired from the account page.
Self::forget_mcp_key_locally("after the session expired");
self.clear_auth().await;
let _ = crate::events::emit_empty("cloud-auth-expired");
}
/// Ask the server whether this account may drive a desktop remotely.
///
/// The AUTHORITATIVE answer, and the only one that is correct for a team
/// member: a team member's locally cached plan does not describe what their
/// seat is entitled to, so the server is asked rather than guessed at.
///
/// It cannot be inferred from the socket either. A connected bridge only
/// proves the plan is active, and nothing about this capability.
pub async fn fetch_remote_control_entitlement(&self) -> Result<bool, String> {
self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/mcp/status");
let client = self.client.clone();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to read remote-control status: {e}"))?;
if !response.status().is_success() {
let status = response.status();
return Err(format!("Remote-control status failed ({status})"));
}
let body: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse remote-control status: {e}"))?;
Ok(
body
.get("entitled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
)
}
})
.await
}
/// Mint a remote MCP credential for this account.
///
/// The server caps how many live keys one account may hold and answers a mint
/// past that cap with a 409; the rotation command handles that by retiring the
/// key it is replacing first. The plaintext in the answer is the only copy
/// there will ever be.
pub async fn create_mcp_key(&self, label: &str) -> Result<McpKeyGrant, String> {
let answer = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/mcp/keys");
let client = self.client.clone();
let label = label.to_string();
async move {
let response = client
.post(&url)
.header("Authorization", format!("Bearer {access_token}"))
.json(&serde_json::json!({ "label": label }))
.send()
.await
.map_err(|e| format!("Failed to request an MCP credential: {e}"))?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 401 {
// Worded so `api_call_with_retry` recognises it and refreshes once.
return Err(format!(
"MCP credential request failed (401 Unauthorized): {body}"
));
}
if !status.is_success() {
return Ok(McpKeyAnswer::Refused {
status: status.as_u16(),
body,
});
}
serde_json::from_str::<McpKeyGrant>(&body)
.map(McpKeyAnswer::Granted)
.map_err(|e| format!("Failed to parse the MCP credential response: {e}"))
}
})
.await
.map_err(|e| crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", e))?;
match answer {
McpKeyAnswer::Granted(grant) if grant.key.starts_with(MCP_KEY_PREFIX) => Ok(grant),
// A credential of any other kind must never be stored as if it were ours,
// however well it would authenticate.
McpKeyAnswer::Granted(_) => Err(crate::backend_error_with_detail(
"MCP_REMOTE_KEY_UNAVAILABLE",
"the server issued a credential of an unexpected shape",
)),
McpKeyAnswer::Refused { status, body } => Err(mcp_key_refusal(status, &body)),
}
}
/// Revoke a remote MCP credential by id. A key the server no longer knows
/// (404) counts as revoked: that is the state the caller wanted.
pub async fn revoke_mcp_key(&self, key_id: &str) -> Result<(), String> {
let answer = self
.api_call_with_retry(|access_token| {
let url = format!(
"{CLOUD_API_URL}/api/mcp/keys/{}",
urlencoding::encode(key_id)
);
let client = self.client.clone();
async move {
let response = client
.delete(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to revoke the MCP credential: {e}"))?;
let status = response.status();
if status.as_u16() == 401 {
let body = response.text().await.unwrap_or_default();
return Err(format!(
"MCP credential revocation failed (401 Unauthorized): {body}"
));
}
if status.is_success() || status.as_u16() == 404 {
return Ok(McpKeyAnswer::Granted(()));
}
Ok(McpKeyAnswer::Refused {
status: status.as_u16(),
body: response.text().await.unwrap_or_default(),
})
}
})
.await
.map_err(|e| crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", e))?;
match answer {
McpKeyAnswer::Granted(()) => Ok(()),
McpKeyAnswer::Refused { status, body } => Err(crate::backend_error_with_detail(
"MCP_REMOTE_KEY_UNAVAILABLE",
format!("{status}: {body}"),
)),
}
}
/// Retire the stored remote MCP credential on sign-out.
///
/// Best effort, and the local copy goes regardless. The revoke needs the
/// session that is about to be deleted, so this runs before `clear_auth`;
/// if it fails the key stays live server-side and the account page can
/// revoke it, but a signed-out desktop must not keep a credential that
/// belongs to the account that just left it.
async fn retire_mcp_key_on_logout(&self) {
let settings = SettingsManager::instance();
// Stringified at once: the settings error is a `Box<dyn Error>`, which is
// not `Send`, and the command future this runs in has to be.
let stored = settings.get_mcp_remote_key().map_err(|e| e.to_string());
match stored {
Ok(Some(StoredMcpRemoteKey { id: Some(id), .. })) => {
if let Err(e) = self.revoke_mcp_key(&id).await {
log::warn!(
"Could not revoke the remote MCP credential on logout; revoke it from the account page: {e}"
);
}
}
Ok(Some(StoredMcpRemoteKey { id: None, .. })) => {
log::warn!(
"The remote MCP credential has no stored id, so it cannot be revoked from here; revoke it from the account page"
);
}
Ok(None) => return,
Err(e) => {
log::warn!("Could not read the remote MCP credential on logout: {e}");
}
}
Self::forget_mcp_key_locally("on logout");
}
/// Drop the local copy of the remote MCP credential: the storage half of
/// `retire_mcp_key_on_logout`, shared with the expiry path, which has no
/// session left to revoke with. Logs rather than fails, because the
/// credential is leaving with the session either way.
fn forget_mcp_key_locally(when: &str) {
if let Err(e) = SettingsManager::instance().remove_mcp_remote_key() {
log::warn!("Could not forget the remote MCP credential {when}: {e}");
}
}
pub async fn fetch_profile(&self) -> Result<CloudUser, String> {
let user = self
.api_call_with_retry(|access_token| {
@@ -765,6 +1033,16 @@ impl CloudAuthManager {
// Disconnect profile lock manager
crate::team_lock::PROFILE_LOCK.disconnect().await;
// Hang up the remote-control bridge before the credential it authenticated
// with is deleted. Left running it would reconnect, fail, and back off into
// an "unauthorized" the account page shows to somebody who has simply
// signed out, and it would hold the account's bridge slot meanwhile.
crate::mcp_remote::stop(None);
// Before the session is closed server-side and the tokens are deleted:
// both of those take away the only thing that can revoke it.
self.retire_mcp_key_on_logout().await;
// Try to call the logout API (best-effort)
if let Ok(Some(access_token)) = Self::load_access_token() {
let refresh_token = Self::load_refresh_token().ok().flatten();
@@ -1293,11 +1571,20 @@ impl CloudAuthManager {
// Reconnect profile lock manager if needed
if let Some(auth_state) = CLOUD_AUTH.get_user().await {
if auth_state.user.plan != "free" && !crate::team_lock::PROFILE_LOCK.is_connected().await {
if auth_state.user.effective_plan() != "free"
&& !crate::team_lock::PROFILE_LOCK.is_connected().await
{
crate::team_lock::PROFILE_LOCK.connect().await;
}
}
// And the remote-control bridge, for the same reason one tick below it
// reconnects the profile lock: a setting that says "on" and a bridge that
// is not running is a disagreement only something periodic can notice.
// `mcp_remote::start` is idempotent, so a healthy bridge costs a load of
// the settings file every ten minutes and nothing else.
ensure_remote_bridge(&app_handle).await;
// Sync cloud proxy credentials
CLOUD_AUTH.sync_cloud_proxy().await;
@@ -1323,11 +1610,10 @@ impl CloudAuthManager {
}
}
/// Whether a rejected wayfern-token request was refused by one of the
/// device-family rules (automation is pinned to the primary desktop session)
/// rather than by the plan's capabilities.
/// Whether a rejected wayfern-token request was refused by one of the device
/// rules rather than by the plan's capabilities.
///
/// Matches on the backend's message because that is the only thing that
/// Matches on the server's message because that is the only thing that
/// distinguishes them: both arrive as a bare 403. Only these two are a state
/// the user can clear themselves, which is what the toast asks them to do.
fn is_device_restriction(error: &str) -> bool {
@@ -1380,10 +1666,22 @@ pub async fn cloud_exchange_device_code(
) -> Result<CloudAuthState, String> {
let mut state = CLOUD_AUTH.exchange_device_code(&code).await?;
// The login response carries the row's own plan and entitlements only: no
// team membership and no `effectivePlan`. For an invited member that reads
// as a free account, and it stayed that way until the ten-minute loop got
// round to `/api/auth/me`. Resolve the served plan here so the sync token,
// the wayfern token and the profile lock below all see the seat. Best
// effort: a failure leaves the login response in place.
match CLOUD_AUTH.fetch_profile().await {
Ok(user) => state.user = user,
Err(e) => log::warn!("Post-login profile refresh failed: {e}"),
}
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
log::info!(
"Post-login: plan={}, has_active_subscription={}",
"Post-login: plan={}, effective_plan={}, has_active_subscription={}",
state.user.plan,
state.user.effective_plan(),
has_subscription
);
@@ -1406,10 +1704,19 @@ pub async fn cloud_exchange_device_code(
CLOUD_AUTH.sync_cloud_proxy().await;
// Connect profile lock manager for paid users
if state.user.plan != "free" {
if state.user.effective_plan() != "free" {
crate::team_lock::PROFILE_LOCK.connect().await;
}
// Reopen the remote-control bridge the user had switched on.
//
// Signing out stops the bridge but deliberately does NOT clear the setting:
// it is a preference, not a session. So without this, "sign out, sign back
// in" left remote control switched on in Settings and dead in fact until the
// app was restarted, which is the worst of both: the UI says yes and the
// account page says no desktop is connected.
ensure_remote_bridge(&app_handle).await;
let _ = crate::events::emit_empty("cloud-auth-changed");
let _ = &app_handle;
@@ -1417,6 +1724,42 @@ pub async fn cloud_exchange_device_code(
Ok(state)
}
/// Open the remote-control bridge if the user asked for it and is signed in.
///
/// Idempotent, and safe to call from anywhere: `mcp_remote::start` returns
/// immediately when the bridge is already up.
///
/// The signed-in half matters as much as the setting. A bridge opened without a
/// credential cannot authenticate, so it would spend its life in the terminal
/// backoff band reporting "not signed in" on the account page: an error
/// describing nothing the user did.
pub(crate) async fn ensure_remote_bridge(app_handle: &tauri::AppHandle) {
if crate::mcp_remote::is_running() {
return;
}
// The same two gates as `start_mcp_remote_bridge`. This helper runs at
// boot, on sign-in and on the reconnect tick, and it used to check only the
// sign-in half: a desktop whose terms acceptance had been withdrawn (or
// never given, with the flag seeded on disk) opened an internet-facing
// bridge into a browser the user had not agreed to automate.
if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() {
return;
}
if !CLOUD_AUTH.is_logged_in().await {
return;
}
let enabled = crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|settings| settings.mcp_remote_enabled)
.unwrap_or(false);
if enabled {
log::info!(
"[mcp-remote] Remote control is enabled and the account is signed in; opening the bridge"
);
crate::mcp_remote::start(app_handle.clone());
}
}
#[tauri::command]
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
Ok(CLOUD_AUTH.get_user().await.map(|mut state| {
@@ -1452,6 +1795,13 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
CLOUD_AUTH.logout().await?;
// Stop the remote session-events stream. Its credential is now invalid, so
// the SSE connection would fail its next credential re-check anyway — but
// until it did, its reconnect loop keeps retrying every 1..60s against a
// signed-out account. The frontend store stops its own subscription on
// logout; nothing stopped the Rust stream, so a sign-out left it churning.
crate::remote_session::stop_session_events();
// Always clear the stored sync URL and token on cloud logout. While the
// user was signed in, the cloud auth flow populated these with the hosted
// sync server's URL + a server-issued token — leaving them in place would
@@ -1522,10 +1872,32 @@ struct ProxyUsageResponse {
limit_mb: i64,
#[serde(rename = "remainingMb")]
remaining_mb: i64,
#[serde(rename = "recurringLimitMb", default)]
recurring_limit_mb: i64,
#[serde(rename = "extraLimitMb", default)]
extra_limit_mb: i64,
// Optional rather than defaulted to 0 so an omitted half of the split stays
// distinguishable from a backend that genuinely reports zero for it.
#[serde(rename = "recurringLimitMb")]
recurring_limit_mb: Option<i64>,
#[serde(rename = "extraLimitMb")]
extra_limit_mb: Option<i64>,
}
/// Combine the live usage response with the cached account snapshot, each half
/// of the recurring/extra split falling back on its own.
///
/// Gating both halves on `recurringLimitMb` made an omitted `extraLimitMb`
/// report 0 against a total that included it, and threw away a live
/// `extraLimitMb` whenever the recurring half happened to be 0.
fn merge_proxy_usage(
usage: &ProxyUsageResponse,
cached_recurring: i64,
cached_extra: i64,
) -> CloudProxyUsage {
CloudProxyUsage {
used_mb: usage.used_mb,
limit_mb: usage.limit_mb,
remaining_mb: usage.remaining_mb,
recurring_limit_mb: usage.recurring_limit_mb.unwrap_or(cached_recurring),
extra_limit_mb: usage.extra_limit_mb.unwrap_or(cached_extra),
}
}
#[tauri::command]
@@ -1578,21 +1950,11 @@ pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String>
})
.await
{
Ok(usage) => Ok(Some(CloudProxyUsage {
used_mb: usage.used_mb,
limit_mb: usage.limit_mb,
remaining_mb: usage.remaining_mb,
recurring_limit_mb: if usage.recurring_limit_mb > 0 {
usage.recurring_limit_mb
} else {
cached_recurring
},
extra_limit_mb: if usage.recurring_limit_mb > 0 {
usage.extra_limit_mb
} else {
cached_extra
},
})),
Ok(usage) => Ok(Some(merge_proxy_usage(
&usage,
cached_recurring,
cached_extra,
))),
Err(e) => {
log::warn!("Failed to fetch live proxy usage, falling back to cached: {e}");
// Fallback to cached values
@@ -1651,6 +2013,32 @@ mod tests {
);
}
#[test]
fn the_agent_follows_browser_automation_and_is_never_derived_from_it() {
// Solo funds a nightly bot and nothing that drives a browser by hand, so
// it does not get the agent either.
assert!(!active_solo().agent_automation);
for plan in ["pro", "team", "enterprise", "some-comped-plan"] {
let derived = derive_entitlements(plan, Some("monthly"), "active", 50);
assert!(derived.agent_automation, "{plan} should get the agent");
}
// An inactive subscription buys nothing, whatever the plan says.
assert!(!derive_entitlements("pro", Some("monthly"), "canceled", 50).agent_automation);
}
#[test]
fn a_backend_that_never_heard_of_the_agent_reports_no_agent() {
// The whole point of `default` here: an older backend's entitlements object
// must decode, and the missing key must read as "no agent routes exist"
// rather than being back-filled from browser automation.
let older: Entitlements = serde_json::from_str(
r#"{"active":true,"browserAutomation":true,"cloudBackup":true,"profileLimit":50}"#,
)
.unwrap();
assert!(older.active && older.browser_automation);
assert!(!older.agent_automation);
}
#[test]
fn wayfern_token_is_gated_on_automation_not_on_being_paid() {
// The regression this guards: gating the mint on `active` asked for a token
@@ -1662,6 +2050,109 @@ mod tests {
assert!(pro.active && pro.browser_automation);
}
#[test]
fn a_refused_credential_mint_maps_to_the_codes_the_ui_knows() {
let limit: serde_json::Value = serde_json::from_str(&mcp_key_refusal(
409,
r#"{"message":"Too many MCP keys","code":"MCP_KEY_LIMIT","statusCode":409}"#,
))
.unwrap();
assert_eq!(limit["code"], "MCP_REMOTE_KEY_LIMIT");
// The cap is the only thing a 409 on that route means, named or not.
let unnamed: serde_json::Value = serde_json::from_str(&mcp_key_refusal(409, "")).unwrap();
assert_eq!(unnamed["code"], "MCP_REMOTE_KEY_LIMIT");
let other: serde_json::Value = serde_json::from_str(&mcp_key_refusal(
429,
r#"{"message":"Too many requests","statusCode":429}"#,
))
.unwrap();
assert_eq!(other["code"], "MCP_REMOTE_KEY_UNAVAILABLE");
assert_eq!(other["params"]["detail"], "429: Too many requests");
// A body that is not JSON still reaches the log verbatim.
let plain: serde_json::Value =
serde_json::from_str(&mcp_key_refusal(502, "bad gateway")).unwrap();
assert_eq!(plain["params"]["detail"], "502: bad gateway");
}
#[test]
fn the_boot_and_sign_in_paths_require_the_terms_like_the_command_does() {
// `ensure_remote_bridge` is reached from boot, sign-in and the reconnect
// tick, none of which pass through `start_mcp_remote_bridge`, so the
// command's terms gate protects only the toggle. The helper must carry
// the same gate itself.
let source = include_str!("cloud_auth.rs");
let helper = source
.split("pub(crate) async fn ensure_remote_bridge(")
.nth(1)
.expect("ensure_remote_bridge must exist");
let body = &helper[..helper.find("\n}").unwrap_or(helper.len())];
let terms = body
.find("is_terms_accepted()")
.expect("ensure_remote_bridge must check the Wayfern terms");
let start = body
.find("crate::mcp_remote::start(")
.expect("ensure_remote_bridge must be what starts the bridge");
assert!(terms < start, "the terms gate must sit ahead of the start");
}
#[test]
fn logout_retires_the_remote_credential_before_the_session_is_gone() {
// The revoke needs the access token; `clear_auth` deletes it and the
// `/api/auth/logout` call may invalidate it server-side. Both must come
// after.
let source = include_str!("cloud_auth.rs");
let logout = source
.split("pub async fn logout(&self)")
.nth(1)
.expect("logout must exist");
let body = &logout[..logout.find("\n }").unwrap_or(logout.len())];
let retire = body
.find("retire_mcp_key_on_logout()")
.expect("logout must retire the remote MCP credential");
let api_logout = body
.find("/api/auth/logout")
.expect("logout must still call the logout endpoint");
let clear = body
.find("clear_auth()")
.expect("logout must still clear the session");
assert!(
retire < api_logout,
"revoke before the server closes the session"
);
assert!(retire < clear, "revoke before the tokens are deleted");
}
#[test]
fn session_expiry_forgets_the_remote_credential_without_a_network_revoke() {
// The automatic twin of logout: the refresh loop gave up, so the session
// is already dead and there is nothing to revoke with. The local copy
// still has to go, or the agents keep presenting a key that belongs to an
// account this machine is no longer signed in to.
let source = include_str!("cloud_auth.rs");
let expiry = source
.split("pub async fn invalidate_session(&self)")
.nth(1)
.expect("invalidate_session must exist");
let body = &expiry[..expiry.find("\n }").unwrap_or(expiry.len())];
let forget = body
.find("forget_mcp_key_locally(")
.expect("invalidate_session must forget the remote MCP credential");
let clear = body
.find("clear_auth()")
.expect("invalidate_session must still clear the session");
assert!(
forget < clear,
"forget the credential before the auth state is torn down"
);
assert!(
!body.contains("revoke_mcp_key("),
"a dead session has nothing to revoke with; the call could only fail"
);
}
#[test]
fn only_the_device_rules_read_as_a_restriction() {
assert!(is_device_restriction(
@@ -1679,4 +2170,45 @@ mod tests {
"Wayfern token request failed (500 Internal Server Error): "
));
}
fn usage_response(recurring: Option<i64>, extra: Option<i64>) -> ProxyUsageResponse {
ProxyUsageResponse {
used_mb: 40,
limit_mb: 600,
remaining_mb: 560,
recurring_limit_mb: recurring,
extra_limit_mb: extra,
}
}
#[test]
fn each_half_of_the_proxy_limit_falls_back_on_its_own() {
let both = merge_proxy_usage(&usage_response(Some(500), Some(100)), 400, 0);
assert_eq!(both.used_mb, 40);
assert_eq!(both.limit_mb, 600);
assert_eq!(both.remaining_mb, 560);
assert_eq!(both.recurring_limit_mb, 500);
assert_eq!(both.extra_limit_mb, 100);
// A backend that does not report the split at all keeps the cached one.
let neither = merge_proxy_usage(&usage_response(None, None), 500, 100);
assert_eq!(neither.recurring_limit_mb, 500);
assert_eq!(neither.extra_limit_mb, 100);
// An omitted extra half must not be read off the recurring half, which is
// how a cached 100 MB top-up used to vanish from the split.
let recurring_only = merge_proxy_usage(&usage_response(Some(500), None), 400, 100);
assert_eq!(recurring_only.recurring_limit_mb, 500);
assert_eq!(recurring_only.extra_limit_mb, 100);
// A fresh extra allowance survives a recurring half of zero.
let extra_only = merge_proxy_usage(&usage_response(Some(0), Some(250)), 500, 100);
assert_eq!(extra_only.recurring_limit_mb, 0);
assert_eq!(extra_only.extra_limit_mb, 250);
// And a spent top-up reported as a live zero is not resurrected from cache.
let spent_extra = merge_proxy_usage(&usage_response(Some(500), Some(0)), 500, 100);
assert_eq!(spent_extra.recurring_limit_mb, 500);
assert_eq!(spent_extra.extra_limit_mb, 0);
}
}
+10 -10
View File
@@ -1,4 +1,4 @@
//! Turning a donutbrowser-infra HTTP failure into a stable, translatable code.
//! Turning a cloud API HTTP failure into a stable, translatable code.
//!
//! Every cloud transport in this crate flattens its failures through
//! `api_call_with_retry`, which needs a `String` so it can sniff for a 401.
@@ -55,7 +55,7 @@ pub struct FailureCodes {
/// The desktop has no cloud session at all.
pub const NOT_SIGNED_IN: &str = "CLOUD_NOT_SIGNED_IN";
/// The request never reached donutbrowser-infra.
/// The request never reached the cloud API.
pub const UNREACHABLE: &str = "CLOUD_UNREACHABLE";
/// The backend answered, but with nothing the user can act on.
pub const UNAVAILABLE: &str = "CLOUD_REQUEST_FAILED";
@@ -243,8 +243,8 @@ mod tests {
#[test]
fn the_backends_own_code_wins_over_the_status_default() {
// The status table is a fallback for gateway pages. When infra names the
// failure, that name is the one the user's locale has a string for.
// The status table is a fallback for gateway pages. When the server names
// the failure, that name is the one the user's locale has a string for.
let failure = classify(403, r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#, CODES);
assert_eq!(failure.code, "COOKIE_BOT_NOT_ENTITLED");
assert_eq!(failure.status, 403);
@@ -261,8 +261,8 @@ mod tests {
#[test]
fn capacity_and_rate_limits_are_never_reported_as_a_fault() {
// 503 is "come back in a minute" — the fleet is four Windows hosts wide,
// so a busy fleet is normal and must not look like an outage.
// 503 is "come back in a minute" — remote capacity is finite, so a busy
// period is normal and must not look like an outage.
assert_eq!(classify(503, "", CODES).code, NO_CAPACITY);
assert_eq!(classify(429, "", CODES).code, RATE_LIMITED);
}
@@ -298,10 +298,10 @@ mod tests {
#[test]
fn nested_params_are_read_because_that_is_the_shape_cookie_bot_sends() {
// `body(code, params)` in cookie-bot.errors.ts returns `{code, params}`,
// which Nest serialises verbatim. Reading only the top level dropped every
// interpolated value: the timezone the user typed, the site limit, the
// hours a team had actually spent.
// The cookie-bot routes send every interpolated value nested under
// `params`. Reading only the top level dropped every one of them: the
// timezone the user typed, the site limit, the hours a team had actually
// spent.
let failure = classify(
400,
r#"{"code":"COOKIE_BOT_INVALID_TIMEZONE","params":{"timezone":"Europe/Nowhere"}}"#,
+120 -74
View File
@@ -1,10 +1,8 @@
//! Cookie-bot transport.
//!
//! The bot warms a profile's cookies overnight by driving it on a leased
//! remote host. NONE of that lives here: the schedule, the calendar maths, the
//! preset expansion, the site ordering, the dwell and scroll model, the pooled
//! budget and the nightly dispatcher are all held by donutbrowser-infra and
//! the Wayfern manager.
//! The bot warms a profile's cookies overnight by driving it on a remote host.
//! None of that behaviour lives here: the schedule and everything the bot
//! actually does are owned by the cloud API.
//!
//! This module is the wire only. It sends the user's own scalars — when to
//! run, for how long, which of their sites, which server-issued preset id —
@@ -19,10 +17,10 @@ use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use std::time::Duration;
/// Operating systems the fleet can lease. Linux is refused by the manager, so
/// refusing it here turns a nightly failure at 02:00 into a refusal at the
/// moment the user picks the profile.
pub const BOT_PLATFORMS: [&str; 2] = ["windows", "macos"];
/// Operating systems a remote run can be scheduled on. Anything else (a mobile
/// OS, a typo) has no host, so it is refused here rather than as a failed run
/// at 02:00.
pub const BOT_PLATFORMS: [&str; 3] = ["windows", "macos", "linux"];
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -139,7 +137,7 @@ pub struct CookieBotSchedule {
#[serde(default)]
pub slots: Vec<CookieBotSlot>,
pub timezone: String,
/// Server-issued preset id. Opaque here — what it expands to is infra's.
/// Server-issued preset id. Opaque here — what it expands to is the server's.
pub preset: String,
/// The template the sites came from, or `None` for the user's own list.
///
@@ -164,9 +162,9 @@ pub struct CookieBotSchedule {
pub encrypted_sync: bool,
#[serde(default)]
pub has_proxy: bool,
/// Whether that exit is one a leased fleet host could dial. Defaults to false
/// on an older server that does not send it, which reads as "not reachable"
/// and is the safe direction.
/// Whether that exit is one a remote host could dial. Defaults to false on an
/// older server that does not send it, which reads as "not reachable" and is
/// the safe direction.
#[serde(default)]
pub proxy_remote_reachable: bool,
#[serde(default)]
@@ -252,7 +250,7 @@ pub struct CookieBotScheduleInput {
// Defaulting is safe in exactly one direction: `bool::default()` is false, so
// an unstamped input reads as "no sync, no proxy" and is REFUSED. The failure
// this must never have is the opposite one, a defaulted `has_proxy: true`
// warming a profile out of the fleet's own datacenter address.
// warming a profile out of the remote host's own address.
#[serde(default)]
pub sync_enabled: bool,
#[serde(default)]
@@ -344,8 +342,8 @@ pub struct CookieBotRun {
#[serde(default)]
pub max_minutes: u32,
/// How many browser sessions this night is split into, and which one is
/// running. A night longer than one session's cap is checkpointed at each
/// boundary, and "chunk 2 of 3" is the only honest way to report that.
/// running. "chunk 2 of 3" is the only honest way to report a night the
/// server split.
#[serde(default)]
pub chunks_total: u32,
#[serde(default)]
@@ -403,10 +401,8 @@ pub struct CookieBotPreset {
/// A server-owned browsing template: a named answer to "what is this profile
/// for", which the user picks INSTEAD of typing a site list.
///
/// Carries no URLs, and must not gain any. The pool a template draws from is
/// server-side for the same reason a preset's browsing model is: a published
/// list is one a retailer can filter, and each profile is given its own sample
/// so the template never becomes a fleet-wide fingerprint.
/// Carries no URLs, and must not gain any: the site pool a template draws from
/// is server-owned, and a published list is one a retailer can filter.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotTemplate {
pub id: String,
@@ -506,8 +502,8 @@ pub struct RemoteHoursMember {
pub bot_hours: f64,
}
/// The single pooled remote-hour budget. Bot and interactive hours share it;
/// the breakdown is reporting, never a sub-cap.
/// The remote-hour budget as the server reports it, with the bot/interactive
/// breakdown it sends.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RemoteHoursQuota {
pub granted_hours: f64,
@@ -606,15 +602,15 @@ pub struct CookieBotUsage {
///
/// The server is authoritative — it re-checks all of this and owns the parts
/// the client cannot see — but a profile that can never qualify should never
/// reach a confirm dialog, an hour of quota or a leased host. Returns the
/// reach a confirm dialog, an hour of quota or a remote host. Returns the
/// `{"code":…}` string a Tauri command surfaces directly.
pub fn bot_precondition(
profile: &BrowserProfile,
exit: &crate::remote_exit::ExitReachability,
) -> Result<(), String> {
if !profile.is_sync_enabled() {
// The host materialises the profile by pulling it from donut-sync. A
// local-only profile has nothing there, so there is no path to a run.
// A remote run obtains the profile through sync, so a local-only profile
// has nothing there and there is no path to a run.
return Err(error("COOKIE_BOT_REQUIRES_CLOUD_SYNC", &[]));
}
if profile.is_encrypted_sync() {
@@ -632,16 +628,16 @@ pub fn bot_precondition(
));
}
if profile.proxy_id.is_none() && profile.vpn_id.is_none() {
// Without one the run egresses from the fleet's own datacenter address.
// Hours of traffic from a hosting ASN is worse for the profile's identity
// than not warming it at all.
// Without one the run egresses from the remote host's own address instead
// of the user's exit, which is worse for the profile's identity than not
// warming it at all.
return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[]));
}
// ...and the exit has to be one the leased host can reach. The profile and its
// proxy record are pulled onto the fleet with no address rewriting, so
// 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH
// tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the
// run fails an hour has been leased and billed.
// ...and the exit has to be one a remote host can reach. Addresses are not
// rewritten in transit, so a proxy recorded as 127.0.0.1 arrives meaning THAT
// machine's own loopback — an ordinary mistake (an SSH tunnel, a local MITM
// proxy, a locally-run SOCKS client) that costs the user an hour of quota
// before it fails.
//
// Taken as an ARGUMENT rather than resolved here, for the same reason
// `ProfileState` is required rather than defaulted: resolving it needs the
@@ -649,6 +645,18 @@ pub fn bot_precondition(
// no test can set up and every caller silently depends on. `exit_reachability`
// is the one place that resolution happens; this stays a pure predicate over
// facts it is handed.
// A protocol a remote host cannot speak is a PERMANENT refusal, and it has to
// say so in its own words. A VLESS server is publicly routable, so the
// reachability question answers "yes" and the older message ("use a proxy with
// a public address") sends the user to fix an address that was never wrong; an
// enrolment accepted on that answer then fails remotely, once per scheduled
// run, until someone notices.
if let crate::remote_exit::ExitReachability::UnsupportedKind { kind, .. } = exit {
return Err(error(
"COOKIE_BOT_PROXY_KIND_UNSUPPORTED",
&[("kind", kind.as_str())],
));
}
if !exit.is_remote() {
return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[]));
}
@@ -660,8 +668,8 @@ pub fn bot_precondition(
/// The server holds the schedule; the PROFILE lives in the user's sync
/// namespace, so `sync_enabled`, `has_proxy` and the rest are only knowable
/// here. It requires them on every write rather than defaulting them, because
/// a defaulted `has_proxy` is a profile warmed out of the fleet's own
/// datacenter address.
/// a defaulted `has_proxy` is a profile warmed out of the remote host's own
/// address.
///
/// Derived in one place so the Tauri, REST and MCP call sites cannot drift into
/// three different answers about the same profile.
@@ -674,21 +682,20 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState {
has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(),
// ...and, separately, whether anyone OTHER than this machine could use it.
// `has_proxy` answers "did the user bring an exit"; this answers "is that
// exit an address a leased host can dial". They disagree for every local
// proxy, which is the case that used to be accepted and then fail on the
// fleet. See `remote_exit`.
// exit an address a remote host can dial". They disagree for every local
// proxy, which is the case that used to be accepted and then fail remotely.
// See `remote_exit`.
proxy_remote_reachable: exit_reachability(profile).is_remote(),
// Always false: this data model has no mobile/touch profile. `resolved_os`
// yields only windows, macos or linux, and `bot_precondition` already
// refuses everything but the first two. Reported rather than omitted so the
// server keeps one required shape, and it stays authoritative it sees the
// real fingerprint on the host and can still refuse a run this cannot know
// to reject.
// yields only windows, macos or linux, and all three are supported
// remotely. Reported rather than omitted so the server keeps one
// required shape, and it stays authoritative: it sees the real fingerprint
// on the host and can still refuse a run this cannot know to reject.
touch_fingerprint: false,
// A VPN is one persistent tunnel, so the night's chunks share an exit. A
// stored proxy may rotate per connection, and claiming stickiness we cannot
// guarantee is worse than declining it: the server's fallback is to run the
// night as a single chunk, which is the safe answer either way.
// guarantee is worse than declining it, so the conservative answer is the
// safe one either way.
sticky_exit: profile.vpn_id.is_some(),
}
}
@@ -700,7 +707,7 @@ pub struct ProfileState {
pub sync_enabled: bool,
pub encrypted_sync: bool,
pub has_proxy: bool,
/// Whether that exit is an address a leased fleet host can dial.
/// Whether that exit is an address a remote host can dial.
pub proxy_remote_reachable: bool,
pub touch_fingerprint: bool,
pub sticky_exit: bool,
@@ -713,7 +720,8 @@ pub struct ProfileState {
/// even then would have to re-derive what the browser will actually dial.
///
/// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is
/// what the browser is pointed at, and it is the address the fleet has to reach.
/// what the browser is pointed at, and it is the address a remote host has to
/// reach.
pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability {
use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability};
@@ -916,7 +924,7 @@ pub async fn update_profile_state(
///
/// The server refuses a run on the copy the desktop last declared —
/// `has_proxy: false` is `proxy_required`, and that check exists because a run
/// without an exit node egresses from the leased host's own datacenter address.
/// without an exit node egresses from the remote host's own address.
/// Nothing but a full schedule write refreshed that copy, so detaching a proxy
/// from an enrolled profile left `has_proxy: true` on the row and the night ran
/// anyway. This closes that gap at the moment the profile changes.
@@ -1046,10 +1054,10 @@ pub async fn run_now(
/// Stop a run that is still going.
///
/// A 503 here means the fleet could not be reached and the browser is still
/// up, so the run stays `running` rather than being marked cancelled under a
/// live browser — retiring a row while something is still writing the cookie
/// jar is the two-writer case the profile lock exists to prevent.
/// A 503 here means the remote host could not be reached and the browser is
/// still up, so the run stays `running` rather than being marked cancelled
/// under a live browser — retiring a row while something is still writing the
/// cookie jar is the two-writer case the profile lock exists to prevent.
pub async fn cancel_run(run_id: &str) -> Result<CookieBotRun, CookieBotError> {
let envelope: RunEnvelope = request(
reqwest::Method::DELETE,
@@ -1294,7 +1302,7 @@ fn http() -> &'static reqwest::Client {
///
/// Built here rather than left to the HTTP client so a profile id or a keyset
/// cursor containing a `&` cannot smuggle a second parameter into the request.
fn with_query(url: &str, query: &[(String, String)]) -> String {
pub(crate) fn with_query(url: &str, query: &[(String, String)]) -> String {
if query.is_empty() {
return url.to_string();
}
@@ -1430,9 +1438,9 @@ mod tests {
#[test]
fn a_local_only_profile_has_no_path_to_a_run() {
// The host obtains the profile from donut-sync. Without sync there is
// nothing to pull, so the run would warm an empty browser and then push
// that emptiness over the user's real profile.
// A remote run obtains the profile through sync, so a local-only profile
// has nothing there: the run would warm an empty browser and then push that
// emptiness over the user's real profile.
let mut profile = eligible_profile();
profile.sync_mode = SyncMode::Disabled;
let err = bot_precondition(&profile, &ExitReachability::Remote)
@@ -1452,19 +1460,37 @@ mod tests {
}
#[test]
fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
fn an_os_the_fleet_cannot_lease_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
let mut profile = eligible_profile();
profile.host_os = Some("linux".to_string());
profile.host_os = Some("android".to_string());
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("linux has no host to lease");
.expect_err("android has no host to lease");
let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope");
assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM");
assert_eq!(
parsed["params"]["platform"], "linux",
parsed["params"]["platform"], "android",
"the message must name the platform that cannot run"
);
}
#[test]
fn a_linux_profile_passes_the_platform_check() {
// Linux is a supported remote platform, so a linux profile is judged on
// the same preconditions as the other two rather than refused for its OS.
let mut profile = eligible_profile();
profile.host_os = Some("linux".to_string());
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
// ...and it reaches the NEXT precondition when it fails one: the refusal a
// linux profile with no exit gets is the exit-node code, not the platform
// code.
profile.proxy_id = None;
profile.vpn_id = None;
let err = bot_precondition(&profile, &ExitReachability::None)
.expect_err("datacenter egress must be refused for linux as for any OS");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE");
}
#[test]
fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() {
let mut profile = eligible_profile();
@@ -1522,10 +1548,32 @@ mod tests {
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
#[test]
fn a_proxy_kind_the_fleet_cannot_dial_gets_its_own_refusal() {
// The repeated-nightly-failure case. This must NOT collapse into
// REQUIRES_REMOTE_EXIT_NODE: that sentence tells the user their proxy's
// address is unreachable, and a VLESS server's address is perfectly
// reachable — the fix is a different protocol, not a different address.
let err = bot_precondition(
&eligible_profile(),
&ExitReachability::UnsupportedKind {
kind: "VLESS".to_string(),
source: "proxy",
},
)
.expect_err("no fleet host runs the xray sidecar VLESS needs");
assert_eq!(code_of(&err), "COOKIE_BOT_PROXY_KIND_UNSUPPORTED");
// The protocol travels in `params` so the sentence can name it rather than
// saying "this proxy type" and leaving the user to guess which one.
let parsed: serde_json::Value = serde_json::from_str(&err).expect("an error envelope");
assert_eq!(parsed["params"]["kind"], "VLESS");
}
#[test]
fn an_exit_we_could_not_read_is_refused_too() {
// Fails closed. Refusing a working setup costs one support question;
// accepting a broken one burns a leased hour and damages an identity.
// accepting a broken one burns an hour of quota and damages an identity.
let err = bot_precondition(
&eligible_profile(),
&ExitReachability::Unknown {
@@ -1538,8 +1586,7 @@ mod tests {
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
/// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView`
/// in donutbrowser-infra's `cookie-bot.service.ts` builds it.
/// A verbatim schedule payload, field for field, as the cloud API sends it.
const SERVER_SCHEDULE_VIEW: &str = r#"{
"profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true,
"run_at_minute":120,"days_mask":127,
@@ -1555,10 +1602,9 @@ mod tests {
#[test]
fn the_schedule_payload_matches_what_the_backend_sends() {
// Pinned against the Schedule shape in donutbrowser-infra's
// cookie-bot controller. A field name that drifts makes every read fail
// at the decode step, which surfaces as "something went wrong" with no
// hint that the contract moved.
// Pinned against the schedule shape the cloud API serves. A field name
// that drifts makes every read fail at the decode step, which surfaces as
// "something went wrong" with no hint that the contract moved.
let schedule: CookieBotSchedule = serde_json::from_str(SERVER_SCHEDULE_VIEW)
.expect("the backend's schedule payload must deserialize");
@@ -1737,7 +1783,7 @@ mod tests {
#[test]
fn the_run_payload_matches_what_the_backend_sends() {
// Verbatim `CookieBotRunView`, as `toRunViews` builds it. `max_minutes`,
// Verbatim run payload, exactly as the cloud API serves it. `max_minutes`,
// `chunks_total`, `chunk_index`, `dispatch_after` and `team_id` were all
// already on the wire and all silently discarded, so a multi-chunk night
// could not be reported as one.
@@ -1968,9 +2014,9 @@ mod tests {
#[test]
fn the_preset_list_carries_ids_not_behaviour() {
// If this type ever gained a site list, a dwell range or a step
// programme, the browsing model would have leaked into the open-source
// client. Ids and a rough duration are all that may cross.
// If this type ever gained the parameters that describe what a preset
// actually does, the server-owned browsing model would have leaked into the
// open-source client. Ids and a rough duration are all that may cross.
let presets: CookieBotPresetList = serde_json::from_str(
r#"{"presets":[{"id":"balanced","typical_minutes":35,"recommended":true}],
"default_preset":"balanced"}"#,
@@ -1988,9 +2034,9 @@ mod tests {
#[test]
fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() {
// The pool is server-owned for the same reason a preset's browsing model
// is. If this type ever gained a `sites` field the curation would be
// published, and a published list is one a retailer can filter.
// The site pool is server-owned. If this type ever gained a `sites` field
// the curation would be published, and a published list is one a retailer
// can filter.
let presets: CookieBotPresetList = serde_json::from_str(
r#"{"presets":[],"default_preset":"balanced",
"templates":[{"id":"low-intent-purchaser","site_count":32,
File diff suppressed because it is too large Load Diff
+184 -54
View File
@@ -375,14 +375,25 @@ impl DownloadedBrowsersRegistry {
}
/// Get all browsers and versions referenced by active profiles
/// Every (browser, version) something still needs.
///
/// A TRASHED profile counts. Its browser directory is exactly what a restore
/// puts back into use, and removing the binary underneath it would turn an
/// undo into a gigabyte download, quietly, days after the delete.
pub fn get_active_browser_versions(
&self,
profiles: &[crate::profile::BrowserProfile],
) -> Vec<(String, String)> {
profiles
let mut versions: Vec<(String, String)> = profiles
.iter()
.map(|profile| (profile.browser.clone(), profile.version.clone()))
.collect()
.collect();
versions.extend(
crate::profile::trash::list_entries(&crate::profile::trash::trash_dir())
.into_iter()
.map(|(profile, _)| (profile.browser, profile.version)),
);
versions
}
/// Verify that all registered browsers actually exist on disk and clean up stale entries
@@ -693,6 +704,73 @@ impl DownloadedBrowsersRegistry {
Ok(cleaned_up)
}
/// Update every stale profile of one browser to `latest_version`, then drop
/// the version binaries that leaves unused.
///
/// The update and cleanup passes deliberately sit outside the classification
/// loop. Running them inside it replayed every already-processed profile on
/// each iteration, so N profiles cost N(N+1)/2 metadata rewrites and just as
/// many `profile-updated` events. Taking both actions as callbacks also keeps
/// the pass exercisable without a `tauri::AppHandle`.
fn consolidate_profiles_for_browser(
browser_name: &str,
browser_profiles: &[&BrowserProfile],
latest_version: &str,
update_profile: &mut dyn FnMut(&BrowserProfile) -> Result<(), String>,
remove_version: &mut dyn FnMut(&str) -> Result<(), String>,
) -> Vec<String> {
let mut consolidated = Vec::new();
let mut profiles_to_update = Vec::new();
let mut older_versions_to_remove = std::collections::HashSet::<String>::new();
for profile in browser_profiles {
if profile.version != latest_version {
// Only update if profile is not currently running
if profile.process_id.is_none() {
profiles_to_update.push(*profile);
older_versions_to_remove.insert(profile.version.clone());
} else {
log::info!(
"Skipping version update for running profile: {} ({})",
profile.name,
profile.version
);
}
}
}
// Update profiles to latest version
for profile in &profiles_to_update {
match update_profile(profile) {
Ok(()) => {
consolidated.push(format!(
"Updated profile '{}' from {} to {}",
profile.name, profile.version, latest_version
));
}
Err(e) => {
log::error!("Failed to update profile '{}': {}", profile.name, e);
}
}
}
// Remove older version binaries that are no longer needed
for old_version in &older_versions_to_remove {
log::info!("Consolidating: removing old version {browser_name} {old_version}");
match remove_version(old_version.as_str()) {
Ok(()) => {
consolidated.push(format!("Removed old version: {browser_name} {old_version}"));
log::info!("Successfully removed old version: {browser_name} {old_version}");
}
Err(e) => {
log::error!("Failed to cleanup old version {browser_name} {old_version}: {e}");
}
}
}
consolidated
}
/// Consolidate browser versions - keep only the latest version per browser
pub fn consolidate_browser_versions(
&self,
@@ -755,58 +833,24 @@ impl DownloadedBrowsersRegistry {
let latest_version = &available_versions[0];
log::info!("Latest available version for {browser_name}: {latest_version}");
// Check which profiles need to be updated to the latest version
let mut profiles_to_update = Vec::new();
let mut older_versions_to_remove = std::collections::HashSet::<String>::new();
for profile in browser_profiles {
if profile.version != *latest_version {
// Only update if profile is not currently running
if profile.process_id.is_none() {
profiles_to_update.push(profile);
older_versions_to_remove.insert(profile.version.clone());
} else {
log::info!(
"Skipping version update for running profile: {} ({})",
profile.name,
profile.version
);
}
}
// Update profiles to latest version
for profile in &profiles_to_update {
match self.profile_manager.update_profile_version(
app_handle,
&profile.id.to_string(),
latest_version,
) {
Ok(_) => {
consolidated.push(format!(
"Updated profile '{}' from {} to {}",
profile.name, profile.version, latest_version
));
}
Err(e) => {
log::error!("Failed to update profile '{}': {}", profile.name, e);
}
}
}
// Remove older version binaries that are no longer needed
for old_version in &older_versions_to_remove {
log::info!("Consolidating: removing old version {browser_name} {old_version}");
match self.cleanup_failed_download(browser_name, old_version) {
Ok(_) => {
consolidated.push(format!("Removed old version: {browser_name} {old_version}"));
log::info!("Successfully removed old version: {browser_name} {old_version}");
}
Err(e) => {
log::error!("Failed to cleanup old version {browser_name} {old_version}: {e}");
}
}
}
}
let mut consolidated_for_browser = Self::consolidate_profiles_for_browser(
browser_name,
browser_profiles,
latest_version,
&mut |profile: &BrowserProfile| -> Result<(), String> {
self
.profile_manager
.update_profile_version(app_handle, &profile.id.to_string(), latest_version)
.map(|_| ())
.map_err(|e| e.to_string())
},
&mut |old_version: &str| -> Result<(), String> {
self
.cleanup_failed_download(browser_name, old_version)
.map_err(|e| e.to_string())
},
);
consolidated.append(&mut consolidated_for_browser);
}
// Save registry after consolidation
@@ -1061,6 +1105,47 @@ lazy_static::lazy_static! {
mod tests {
use super::*;
#[test]
fn a_trashed_profile_still_counts_as_a_reason_to_keep_its_browser() {
let root = tempfile::tempdir().unwrap();
let _guard = crate::app_dirs::set_test_data_dir(root.path().to_path_buf());
let registry = DownloadedBrowsersRegistry::new();
assert!(registry.get_active_browser_versions(&[]).is_empty());
// What `trash_profile` leaves behind: one directory per profile holding
// the profile it archived.
let profile = crate::profile::BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: "wayfern".to_string(),
version: "152.0.7977.64".to_string(),
..Default::default()
};
let entry = crate::profile::trash::trash_dir().join(profile.id.to_string());
std::fs::create_dir_all(&entry).unwrap();
std::fs::write(
entry.join("profile.json"),
serde_json::to_vec(&profile).unwrap(),
)
.unwrap();
std::fs::write(
entry.join("manifest.json"),
serde_json::json!({
"deleted_at": 1,
"expires_at": 2,
"size_bytes": 0,
"original_name": "Trashed",
})
.to_string(),
)
.unwrap();
assert_eq!(
registry.get_active_browser_versions(&[]),
vec![("wayfern".to_string(), "152.0.7977.64".to_string())],
"removing the binary under a trashed profile turns an undo into a download"
);
}
#[test]
fn test_registry_creation() {
// Create a mock profile manager for testing
@@ -1395,6 +1480,51 @@ mod tests {
"Browser should not be considered downloaded when files don't exist on disk"
);
}
#[test]
fn test_consolidate_profiles_for_browser_acts_once_per_profile() {
let profile = |name: &str, version: &str, process_id: Option<u32>| BrowserProfile {
id: uuid::Uuid::new_v4(),
name: name.to_string(),
browser: "testbrowser".to_string(),
version: version.to_string(),
process_id,
..Default::default()
};
let stale_a = profile("stale-a", "139.0", None);
let stale_b = profile("stale-b", "139.0", None);
let older = profile("older", "138.0", None);
let running = profile("running", "139.0", Some(4242));
let current = profile("current", "140.0", None);
let profiles = [&stale_a, &stale_b, &older, &running, &current];
let mut updated: Vec<String> = Vec::new();
let mut removed: Vec<String> = Vec::new();
let consolidated = DownloadedBrowsersRegistry::consolidate_profiles_for_browser(
"testbrowser",
&profiles,
"140.0",
&mut |p: &BrowserProfile| -> Result<(), String> {
updated.push(p.name.clone());
Ok(())
},
&mut |version: &str| -> Result<(), String> {
removed.push(version.to_string());
Ok(())
},
);
// Every stale, stopped profile is updated exactly once - the loop used to
// re-update each of them once per remaining profile.
assert_eq!(updated, vec!["stale-a", "stale-b", "older"]);
removed.sort();
assert_eq!(removed, vec!["138.0", "139.0"]);
assert_eq!(consolidated.len(), updated.len() + removed.len());
}
}
#[tauri::command]
+29
View File
@@ -945,8 +945,37 @@ impl Downloader {
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries
{
let app_handle_for_update = app_handle.clone();
let browser_for_update = browser_str.clone();
let version_for_update = version.clone();
tauri::async_runtime::spawn(async move {
let auto_updater = crate::auto_updater::AutoUpdater::instance();
// A profile that is open right now cannot be switched to the new binary
// yet, so it only gets a pending update. That entry has to exist before
// cleanup runs: cleanup keeps a version only while it is in use or
// pending, and would otherwise delete what was just downloaded.
match auto_updater
.auto_update_profile_versions(
&app_handle_for_update,
&browser_for_update,
&version_for_update,
)
.await
{
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Applied {browser_for_update} {version_for_update} to profiles: {updated:?}"
);
}
}
Err(e) => {
log::error!(
"Failed to apply {browser_for_update} {version_for_update} to profiles: {e}"
);
}
}
match auto_updater.update_profiles_to_latest_installed(&app_handle_for_update) {
Ok(updated) => {
if !updated.is_empty() {
+1
View File
@@ -432,6 +432,7 @@ mod tests {
last_sync: None,
host_os: None,
ephemeral,
temporary: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
+600
View File
@@ -0,0 +1,600 @@
//! Import a Chromium extension from a link instead of a file.
//!
//! Three inputs are accepted: a Chrome Web Store detail URL, the bare
//! 32-character extension id from one, and a direct `.crx`/`.zip` URL. All
//! three resolve to a single archive download, whose payload is normalised to
//! the plain ZIP that `extension_manager` already stores, so nothing
//! downstream (assignment, groups, per-profile staging, sync) has to know an
//! extension arrived over the network.
use serde::{Deserialize, Serialize};
use url::Url;
/// Matches the body limit the REST extension routes accept for an upload
/// (`api_server::DefaultBodyLimit::max(64 MiB)`). A link import and a file
/// upload land in the same store, so they get the same ceiling.
pub const MAX_EXTENSION_BYTES: u64 = 64 * 1024 * 1024;
const CRX_MAGIC: &[u8; 4] = b"Cr24";
const ZIP_MAGIC: &[u8; 4] = b"PK\x03\x04";
const MAX_REDIRECTS: usize = 5;
/// The last-resort `prodversion` for the Web Store endpoint, used only when no
/// Wayfern build is downloaded and no version cache exists yet — a fresh
/// install that has never fetched a browser. Every other path reads the real
/// installed version, so this is a floor, not the normal answer.
const FALLBACK_PRODUCT_VERSION: &str = "120.0.0.0";
fn err(code: &str) -> String {
crate::backend_error(code)
}
/// What a link resolves to before anything is fetched.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExtensionSource {
/// A Chrome Web Store product id, downloaded through the update service.
WebStore(String),
/// An archive served directly.
Direct(Url),
}
/// A downloaded, validated extension archive, staged in the frontend exactly
/// like a picked file so the user confirms a real name and version before it
/// is stored.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FetchedExtension {
pub file_name: String,
pub file_data: Vec<u8>,
/// The manifest's own name, with any `__MSG_key__` placeholder resolved.
pub name: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
/// The URL the bytes actually came from, so the staged form can name it.
pub source_url: String,
/// True when the id was resolved through the Chrome Web Store update
/// service rather than downloaded from a link the user typed in full.
pub from_web_store: bool,
}
/// A Chrome extension id is 32 characters drawn from `a`-`p`: the store
/// re-encodes the first 128 bits of the packing key's SHA-256 with that
/// alphabet, so anything outside it is not an id however long it is.
pub fn parse_extension_id(candidate: &str) -> Option<String> {
let trimmed = candidate.trim();
if trimmed.len() != 32 {
return None;
}
let lowered = trimmed.to_ascii_lowercase();
lowered
.bytes()
.all(|b| b.is_ascii_lowercase() && b <= b'p')
.then_some(lowered)
}
fn web_store_id_from_path(url: &Url) -> Option<String> {
let segments: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect();
// `/detail/<slug>/<id>` on the current store, `/webstore/detail/<slug>/<id>`
// on the legacy host, and both allow the slug to be omitted. Rather than
// encoding every shape, take the first segment that is a real id.
segments
.iter()
.find_map(|segment| parse_extension_id(segment))
}
fn is_web_store_host(host: &str) -> bool {
matches!(
host,
"chromewebstore.google.com" | "chrome.google.com" | "www.chrome.google.com"
)
}
fn path_is_archive(url: &Url) -> bool {
let path = url.path().to_ascii_lowercase();
path.ends_with(".crx") || path.ends_with(".zip")
}
/// Loopback plain HTTP is accepted only in the `e2e` build, where the suite
/// serves its own CRX fixture from a local server. A shipped build has no such
/// path, so every real import crosses TLS.
fn scheme_is_allowed(url: &Url) -> bool {
if url.scheme() == "https" {
return true;
}
cfg!(feature = "e2e") && url.scheme() == "http" && host_is_loopback(url)
}
fn host_is_loopback(url: &Url) -> bool {
match url.host() {
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
Some(url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
None => false,
}
}
/// Classify what the user typed. Anything that is not one of the three
/// accepted shapes is refused here, before a single byte is requested.
pub fn parse_extension_source(input: &str) -> Result<ExtensionSource, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(err("EXTENSION_URL_INVALID"));
}
if let Some(id) = parse_extension_id(trimmed) {
return Ok(ExtensionSource::WebStore(id));
}
let url = Url::parse(trimmed).map_err(|_| err("EXTENSION_URL_INVALID"))?;
if !scheme_is_allowed(&url) {
return Err(err("EXTENSION_URL_INVALID"));
}
if let Some(host) = url.host_str() {
if is_web_store_host(host) {
return web_store_id_from_path(&url)
.map(ExtensionSource::WebStore)
.ok_or_else(|| err("EXTENSION_URL_INVALID"));
}
}
if path_is_archive(&url) {
return Ok(ExtensionSource::Direct(url));
}
Err(err("EXTENSION_URL_INVALID"))
}
/// The `nacl_arch` the Web Store update service expects for this machine. It
/// picks between architecture-specific builds of the same extension, so a
/// wrong value hands back a package the browser cannot load.
pub fn nacl_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "x86-64",
"x86" => "x86-32",
"aarch64" => "arm64",
"arm" => "arm",
_ => "x86-64",
}
}
/// Newest Chromium version this machine actually has, because the Web Store
/// serves a package built for the requesting browser and a version it does not
/// recognise is answered with an error rather than a CRX.
pub fn chromium_product_version() -> String {
let downloaded = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance()
.get_downloaded_versions("wayfern");
if let Some(version) = newest_version(&downloaded) {
return version;
}
let cached = crate::browser_version_manager::BrowserVersionManager::instance()
.get_cached_browser_versions("wayfern")
.unwrap_or_default();
newest_version(&cached).unwrap_or_else(|| FALLBACK_PRODUCT_VERSION.to_string())
}
/// Highest dotted-numeric version in `versions`. Neither the registry nor the
/// version cache promises an order, and a lexical max reads `9.x` as newer
/// than `151.x`.
fn newest_version(versions: &[String]) -> Option<String> {
versions
.iter()
.filter(|version| !version.trim().is_empty())
.max_by_key(|version| version_key(version))
.cloned()
}
fn version_key(version: &str) -> [u64; 4] {
let mut parts = [0u64; 4];
for (slot, piece) in parts.iter_mut().zip(version.split('.')) {
*slot = piece.trim().parse().unwrap_or(0);
}
parts
}
/// The Chrome Web Store update service, the endpoint Chromium itself uses to
/// fetch a package on demand. It needs the product id, the ABI, and a Chromium
/// version, and answers with a redirect to the CRX.
pub fn web_store_download_url(id: &str, product_version: &str, nacl_arch: &str) -> String {
format!(
"https://clients2.google.com/service/update2/crx\
?response=redirect&acceptformat=crx3&prodversion={product}&nacl_arch={arch}\
&x=id%3D{id}%26installsource%3Dondemand%26uc",
product = urlencoding::encode(product_version),
arch = urlencoding::encode(nacl_arch),
id = id,
)
}
/// Unwrap a CRX3 container to the ZIP it carries.
///
/// A `.crx` is not a ZIP with a different name: it is `Cr24`, a little-endian
/// format version, a little-endian header length, that many bytes of protobuf
/// signature header, and only then the ZIP. Storing the whole file as if it
/// were an archive leaves every reader to guess where the ZIP starts.
pub fn crx3_zip_payload(data: &[u8]) -> Result<&[u8], String> {
if data.len() < 16 || &data[0..4] != CRX_MAGIC {
return Err(err("EXTENSION_NOT_AN_EXTENSION"));
}
let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
if version != 3 {
// CRX2 has a different header (two length fields, no protobuf) and has not
// been accepted by Chromium for years. Refusing is more useful than
// guessing at an offset.
return Err(err("EXTENSION_NOT_AN_EXTENSION"));
}
let header_len = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
let start = 12usize
.checked_add(header_len)
.ok_or_else(|| err("EXTENSION_NOT_AN_EXTENSION"))?;
let payload = data
.get(start..)
.ok_or_else(|| err("EXTENSION_NOT_AN_EXTENSION"))?;
if payload.len() < 4 || &payload[0..4] != ZIP_MAGIC {
return Err(err("EXTENSION_NOT_AN_EXTENSION"));
}
Ok(payload)
}
/// Normalise downloaded bytes to the plain ZIP the store keeps. A CRX3 is
/// unwrapped; a ZIP passes through; anything else is refused.
pub fn archive_payload(data: &[u8]) -> Result<&[u8], String> {
if data.len() >= 4 && &data[0..4] == ZIP_MAGIC {
return Ok(data);
}
crx3_zip_payload(data)
}
fn redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
if !scheme_is_allowed(attempt.url()) {
// A store redirect that leaves TLS would download the package in the
// clear, and the package is executable code. Stopping here surfaces the
// final response instead of following it.
return attempt.stop();
}
if attempt.previous().len() > MAX_REDIRECTS {
return attempt.stop();
}
attempt.follow()
})
}
async fn download_archive(url: &str) -> Result<Vec<u8>, String> {
use futures_util::StreamExt;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(15))
.redirect(redirect_policy())
.build()
.map_err(|_| err("EXTENSION_DOWNLOAD_FAILED"))?;
let response = client
.get(url)
.header("User-Agent", "Mozilla/5.0 (compatible; donutbrowser)")
.send()
.await
.map_err(|e| {
log::warn!("Extension download request failed: {e}");
err("EXTENSION_DOWNLOAD_FAILED")
})?;
if !response.status().is_success() {
log::warn!("Extension download answered HTTP {}", response.status());
return Err(err("EXTENSION_DOWNLOAD_FAILED"));
}
// A redirect the policy stopped surfaces here as a 3xx, which
// `is_success` already rejects; the final URL is checked again so a
// same-status hop can never slip through.
if !scheme_is_allowed(response.url()) {
return Err(err("EXTENSION_DOWNLOAD_FAILED"));
}
if response
.content_length()
.is_some_and(|len| len > MAX_EXTENSION_BYTES)
{
return Err(err("EXTENSION_TOO_LARGE"));
}
let mut buffer: Vec<u8> = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
log::warn!("Extension download stream failed: {e}");
err("EXTENSION_DOWNLOAD_FAILED")
})?;
// A server is free to lie about, or omit, Content-Length, so the ceiling
// is enforced against what actually arrives.
if buffer.len() as u64 + chunk.len() as u64 > MAX_EXTENSION_BYTES {
return Err(err("EXTENSION_TOO_LARGE"));
}
buffer.extend_from_slice(&chunk);
}
if buffer.is_empty() {
return Err(err("EXTENSION_DOWNLOAD_FAILED"));
}
Ok(buffer)
}
/// What the archive is stored as. The payload written to the store is always
/// the plain ZIP, so a `.crx` link keeps its name but not its extension —
/// calling an unwrapped payload `.crx` would tell every later reader to skip a
/// CRX header that is no longer there.
fn direct_file_name(url: &Url) -> String {
let raw = url
.path_segments()
.and_then(|mut segments| segments.rfind(|s| !s.is_empty()))
.unwrap_or_default();
let stem = raw
.strip_suffix(".crx")
.or_else(|| raw.strip_suffix(".CRX"))
.or_else(|| raw.strip_suffix(".zip"))
.or_else(|| raw.strip_suffix(".ZIP"))
.unwrap_or(raw)
.trim();
if stem.is_empty() {
return "extension.zip".to_string();
}
format!("{stem}.zip")
}
/// Fetch and validate an extension from a link, returning the plain ZIP plus
/// the identity read out of its own manifest.
pub async fn fetch_extension(input: &str) -> Result<FetchedExtension, String> {
let source = parse_extension_source(input)?;
let (download_url, file_name, from_web_store) = match &source {
ExtensionSource::WebStore(id) => (
web_store_download_url(id, &chromium_product_version(), nacl_arch()),
format!("{id}.zip"),
true,
),
ExtensionSource::Direct(url) => (url.to_string(), direct_file_name(url), false),
};
let raw = download_archive(&download_url).await?;
let payload = archive_payload(&raw)?;
// A ZIP that carries no manifest is not an extension, whatever it was
// served as. Refusing here keeps a 404 page or an installer out of the
// store instead of leaving a broken row the user has to work out.
let manifest = crate::extension_manager::read_manifest_from_archive(payload, "zip")
.ok_or_else(|| err("EXTENSION_NOT_AN_EXTENSION"))?;
let (name, version, description, _author, _homepage) =
crate::extension_manager::manifest_metadata(
&manifest,
&crate::extension_manager::ManifestSource::Archive {
data: payload,
file_type: "zip",
},
);
Ok(FetchedExtension {
file_name,
file_data: payload.to_vec(),
name,
version,
description,
source_url: if from_web_store {
// The update-service URL is machine-specific noise; the store page is
// what a user recognises and can open.
match &source {
ExtensionSource::WebStore(id) => {
format!("https://chromewebstore.google.com/detail/{id}")
}
ExtensionSource::Direct(url) => url.to_string(),
}
} else {
download_url
},
from_web_store,
})
}
#[tauri::command]
pub async fn fetch_extension_from_url(url: String) -> Result<FetchedExtension, String> {
fetch_extension(&url).await
}
#[cfg(test)]
mod tests {
use super::*;
fn crx3(header: &[u8], zip: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(CRX_MAGIC);
out.extend_from_slice(&3u32.to_le_bytes());
out.extend_from_slice(&(header.len() as u32).to_le_bytes());
out.extend_from_slice(header);
out.extend_from_slice(zip);
out
}
fn zip_bytes() -> Vec<u8> {
let mut zip = ZIP_MAGIC.to_vec();
zip.extend_from_slice(b"the rest of an archive");
zip
}
#[test]
fn a_crx3_container_yields_exactly_the_zip_it_carries() {
let zip = zip_bytes();
let crx = crx3(&[7u8; 40], &zip);
assert_eq!(crx3_zip_payload(&crx).unwrap(), zip.as_slice());
assert_eq!(archive_payload(&crx).unwrap(), zip.as_slice());
}
#[test]
fn a_file_that_is_not_a_crx_is_refused_rather_than_scanned_for_a_zip() {
let mut wrong_magic = crx3(&[0u8; 8], &zip_bytes());
wrong_magic[0] = b'X';
assert_eq!(
crx3_zip_payload(&wrong_magic).unwrap_err(),
err("EXTENSION_NOT_AN_EXTENSION")
);
assert_eq!(
archive_payload(b"<!doctype html><html>404</html>").unwrap_err(),
err("EXTENSION_NOT_AN_EXTENSION")
);
}
#[test]
fn a_truncated_crx_never_reads_past_its_own_bytes() {
let full = crx3(&[1u8; 32], &zip_bytes());
for cut in [8usize, 12, 20, 40] {
assert!(crx3_zip_payload(&full[..cut.min(full.len())]).is_err());
}
// A header length that runs past the file must not panic or return the
// tail of some other structure.
let mut lying = crx3(&[1u8; 32], &zip_bytes());
lying[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(crx3_zip_payload(&lying).is_err());
}
#[test]
fn a_plain_zip_is_accepted_and_a_crx2_is_not() {
let zip = zip_bytes();
assert_eq!(archive_payload(&zip).unwrap(), zip.as_slice());
let mut crx2 = crx3(&[0u8; 16], &zip);
crx2[4..8].copy_from_slice(&2u32.to_le_bytes());
assert_eq!(
archive_payload(&crx2).unwrap_err(),
err("EXTENSION_NOT_AN_EXTENSION")
);
}
#[test]
fn every_accepted_link_shape_resolves_to_one_source() {
let id = "abcdefghijklmnopabcdefghijklmnop";
for input in [
id,
&format!(" {} ", id.to_ascii_uppercase()),
&format!("https://chromewebstore.google.com/detail/some-slug/{id}"),
&format!("https://chromewebstore.google.com/detail/some-slug/{id}?hl=en"),
&format!("https://chromewebstore.google.com/detail/{id}"),
&format!("https://chrome.google.com/webstore/detail/some-slug/{id}"),
&format!("https://chrome.google.com/webstore/detail/some-slug/{id}/related"),
] {
assert_eq!(
parse_extension_source(input).unwrap(),
ExtensionSource::WebStore(id.to_string()),
"{input}"
);
}
let direct = "https://files.example.com/pack/ublock.crx";
assert_eq!(
parse_extension_source(direct).unwrap(),
ExtensionSource::Direct(Url::parse(direct).unwrap())
);
assert!(matches!(
parse_extension_source("https://files.example.com/pack/ublock.zip?v=2").unwrap(),
ExtensionSource::Direct(_)
));
}
#[test]
fn a_link_that_is_not_an_extension_is_refused_before_anything_is_fetched() {
for input in [
"",
" ",
// 31 and 33 characters, and an id using letters past `p`.
"abcdefghijklmnopabcdefghijklmno",
"abcdefghijklmnopabcdefghijklmnopq",
"abcdefghijklmnopabcdefghijklmnoz",
"not a url at all",
"ftp://files.example.com/ublock.crx",
"file:///etc/passwd",
// The right host, but no product id anywhere in the path.
"https://chromewebstore.google.com/category/extensions",
// An https URL that is not an archive.
"https://files.example.com/downloads",
"https://files.example.com/installer.exe",
] {
assert_eq!(
parse_extension_source(input).unwrap_err(),
err("EXTENSION_URL_INVALID"),
"{input}"
);
}
}
#[test]
fn plain_http_is_refused_outside_the_test_build_and_never_off_loopback() {
let loopback = Url::parse("http://127.0.0.1:8321/fixture.crx").unwrap();
assert_eq!(scheme_is_allowed(&loopback), cfg!(feature = "e2e"));
assert!(!scheme_is_allowed(
&Url::parse("http://files.example.com/ublock.crx").unwrap()
));
assert!(scheme_is_allowed(
&Url::parse("https://files.example.com/ublock.crx").unwrap()
));
assert_eq!(
parse_extension_source("http://files.example.com/ublock.crx").unwrap_err(),
err("EXTENSION_URL_INVALID")
);
}
/// The redirect policy is what makes the scheme guard hold for the whole
/// chain, not only the first request: the Web Store answers with a redirect,
/// so the URL the bytes actually come from is never the one that was typed.
#[test]
fn a_redirect_is_judged_by_the_same_rule_as_the_first_request() {
let policy_allows = |url: &str| scheme_is_allowed(&Url::parse(url).unwrap());
assert!(policy_allows(
"https://clients2.googleusercontent.com/crx/blobs/abc/EXT.crx"
));
// The classic downgrade: an https request answered with a plain-HTTP
// Location. The package is executable code, so the chain stops there.
assert!(!policy_allows("http://mirror.example.com/EXT.crx"));
assert!(!policy_allows("ftp://mirror.example.com/EXT.crx"));
}
#[test]
fn the_size_ceiling_matches_the_upload_route_and_bounds_the_buffer() {
assert_eq!(MAX_EXTENSION_BYTES, 64 * 1024 * 1024);
// The streaming guard is a comparison on running totals; prove the
// arithmetic it relies on rejects the first chunk that crosses the line.
let already = MAX_EXTENSION_BYTES - 10;
assert!(already + 11 > MAX_EXTENSION_BYTES);
assert!(already + 10 <= MAX_EXTENSION_BYTES);
}
#[test]
fn the_web_store_url_carries_the_id_the_abi_and_the_installed_version() {
let url = web_store_download_url("abcdefghijklmnopabcdefghijklmnop", "151.0.7922.76", "arm64");
assert!(url.starts_with("https://clients2.google.com/service/update2/crx?"));
assert!(url.contains("prodversion=151.0.7922.76"));
assert!(url.contains("nacl_arch=arm64"));
assert!(url.contains("id%3Dabcdefghijklmnopabcdefghijklmnop"));
assert!(url.contains("acceptformat=crx3"));
}
#[test]
fn the_newest_installed_version_wins_over_a_lexically_larger_one() {
let versions = vec![
"9.0.1.0".to_string(),
"151.0.7922.76".to_string(),
"147.0.7727.138".to_string(),
];
assert_eq!(newest_version(&versions).unwrap(), "151.0.7922.76");
assert_eq!(newest_version(&[]), None);
}
#[test]
fn a_direct_download_names_the_stored_file_a_zip() {
assert_eq!(
direct_file_name(&Url::parse("https://files.example.com/pack/ublock.crx").unwrap()),
"ublock.zip"
);
assert_eq!(
direct_file_name(&Url::parse("https://files.example.com/pack/ublock.zip").unwrap()),
"ublock.zip"
);
assert_eq!(
direct_file_name(&Url::parse("https://files.example.com/.crx").unwrap()),
"extension.zip"
);
}
}
+37 -4
View File
@@ -24,6 +24,9 @@ fn default_source_kind() -> String {
pub struct Extension {
pub id: String,
pub name: String,
/// The archive's identity, kept separately from the user's editable name.
#[serde(default)]
pub manifest_name: Option<String>,
pub file_name: String,
pub file_type: String,
pub browser_compatibility: Vec<String>,
@@ -258,7 +261,7 @@ fn extract_manifest_metadata(file_data: &[u8], file_type: &str) -> ManifestMetad
}
}
fn manifest_metadata(
pub(crate) fn manifest_metadata(
manifest: &serde_json::Value,
source: &ManifestSource<'_>,
) -> ManifestMetadata {
@@ -630,7 +633,8 @@ impl ExtensionManager {
let ext = Extension {
id: uuid::Uuid::new_v4().to_string(),
name: Self::resolve_name(name, manifest_name)?,
name: Self::resolve_name(name, manifest_name.clone())?,
manifest_name,
file_name: file_name.clone(),
file_type,
browser_compatibility,
@@ -677,7 +681,8 @@ impl ExtensionManager {
let ext = Extension {
id: uuid::Uuid::new_v4().to_string(),
name: Self::resolve_name(name, manifest_name)?,
name: Self::resolve_name(name, manifest_name.clone())?,
manifest_name,
file_name: absolute
.file_name()
.map(|n| n.to_string_lossy().to_string())
@@ -1001,6 +1006,7 @@ impl ExtensionManager {
if let Some(h) = homepage_url {
ext.homepage_url = Some(h);
}
ext.manifest_name = manifest_name.clone();
if let Some(mn) = manifest_name {
if !explicit_name_provided && !mn.trim().is_empty() {
ext.name = mn;
@@ -1678,7 +1684,8 @@ impl ExtensionManager {
let (manifest_name, version, description, author, homepage_url) = metadata;
let mut updated = ext.clone();
let mut changed = false;
let mut changed = updated.manifest_name != manifest_name;
updated.manifest_name = manifest_name.clone();
// The name is user-editable, so it is only touched when what is stored is
// an unresolved placeholder.
@@ -2307,6 +2314,7 @@ mod tests {
.unwrap();
assert_eq!(ext.name, "uBlock Origin Lite");
assert_eq!(ext.manifest_name.as_deref(), Some("uBlock Origin Lite"));
assert_eq!(
ext.description.as_deref(),
Some("An efficient content blocker.")
@@ -2314,6 +2322,31 @@ mod tests {
assert_eq!(ext.version.as_deref(), Some("1.2.3"));
}
#[test]
fn manifest_identity_backfill_preserves_an_explicit_name_and_edit_time() {
let tmp = tempfile::tempdir().unwrap();
let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf());
let mgr = ExtensionManager::new();
let mut ext = mgr
.add_extension(
"fallback".to_string(),
"ublock.zip".to_string(),
localized_extension_zip(),
)
.unwrap();
ext.name = "My blocker".to_string();
ext.manifest_name = None;
mgr.update_extension_internal(&ext).unwrap();
mgr.ensure_icons_extracted();
let restored = mgr.get_extension(&ext.id).unwrap();
assert_eq!(restored.name, "My blocker");
assert_eq!(
restored.manifest_name.as_deref(),
Some("uBlock Origin Lite")
);
assert_eq!(restored.updated_at, ext.updated_at);
}
#[test]
fn a_stored_placeholder_is_repaired_rather_than_shown_to_the_user() {
let tmp = tempfile::tempdir().unwrap();
+393 -29
View File
@@ -6,6 +6,12 @@
//! language. A mismatch (e.g. a US fingerprint behind a German exit IP) is a
//! strong anti-bot tell even though the real device never leaks.
//!
//! Every comparison has three outcomes, never two: the dimensions agree, they
//! disagree, or nothing was compared because the fingerprint declares no value
//! to compare against. That third state is reported, never folded into the
//! first, "we checked and it matches" and "we checked nothing" are different
//! claims, and only one of them has been earned.
//!
//! This module only measures. Deciding what a mismatch *means* for a launch —
//! block, warn, or ignore — belongs to `launch_gate`, which calls
//! `probe_and_check_consistency` before the browser is spawned. Launches never
@@ -89,12 +95,31 @@ lazy_static::lazy_static! {
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
}
/// The dimensions an exit is compared on, in report order.
pub const CHECKED_DIMENSIONS: [&str; 2] = ["timezone", "language"];
/// The outcome of comparing a measured exit against a fingerprint.
///
/// Three states, deliberately distinct, because collapsing the third into the
/// first is how a launch came to report a match it never made:
///
/// * **agree**, `checked`, `consistent`, nothing in `unverified`;
/// * **disagree**, `checked`, not `consistent`, the offenders in `mismatches`;
/// * **not compared**, the dimension is named in `unverified`, and if nothing
/// at all could be compared then `checked` is false.
///
/// `consistent` alone never means "verified": it is also true when there was
/// nothing to compare. Read it together with `checked` and `unverified`, or
/// call [`ConsistencyResult::is_mismatch`] / [`ConsistencyResult::is_verified`].
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ConsistencyResult {
/// True when everything we could check lines up (or there was nothing to
/// check — no proxy assigned).
/// True when no dimension that was actually compared disagreed. Also true
/// when nothing was compared at all, so this is a claim about what was
/// measured, never a claim that anything was.
pub consistent: bool,
/// True when we actually reached an exit node and compared something.
/// True when we reached an exit node **and** compared at least one dimension
/// against it. False both when no exit was measured and when one was measured
/// but the fingerprint declared nothing to compare it against.
pub checked: bool,
pub exit_ip: Option<String>,
pub exit_country_code: Option<String>,
@@ -103,6 +128,20 @@ pub struct ConsistencyResult {
pub fingerprint_language: Option<String>,
/// One of "timezone", "language" — the dimensions that disagree.
pub mismatches: Vec<String>,
/// One of "timezone", "language", dimensions the exit supplied a value for
/// but that were never compared, because the fingerprint declares no value of
/// its own (or, for language, because the exit country has no CLDR data).
///
/// Not a mismatch: a launch is never blocked on one, because a fingerprint
/// whose geolocation probe failed legitimately carries no location at all.
/// `wayfern_manager::apply_geolocation` writes nothing rather than inventing
/// `America/New_York`, and `wayfern_manager::launch_fingerprint_payload`
/// forwards that absence to the browser rather than filling it back in, so
/// "not compared" describes what the launch actually presents. But not a pass
/// either, these dimensions are unverified and must never be reported to the
/// user as agreeing.
#[serde(default)]
pub unverified: Vec<String>,
}
impl ConsistencyResult {
@@ -116,8 +155,48 @@ impl ConsistencyResult {
fingerprint_timezone: None,
fingerprint_language: None,
mismatches: Vec::new(),
unverified: Vec::new(),
}
}
/// A positively measured disagreement, the only state that may stop a
/// launch.
pub fn is_mismatch(&self) -> bool {
self.checked && !self.consistent
}
/// Every dimension the exit offered was compared and agreed. The only state
/// that has earned the word "consistent" in front of a user.
pub fn is_verified(&self) -> bool {
self.checked && self.consistent && self.unverified.is_empty()
}
}
/// Dimensions this profile can never be verified on, whatever exit it turns out
/// to use, because its stored fingerprint declares no value to compare.
///
/// Pure and local: no exit measurement, no I/O, so the pre-launch report can
/// state it before a single worker starts. A lower bound on what a real probe
/// will report as unverified, the exit's own country can also leave the
/// language uncomparable, and that is not knowable from here.
pub fn unverifiable_dimensions(profile: &BrowserProfile) -> Vec<String> {
let (fp_tz, fp_lang) = fingerprint_locale(profile);
let mut out = Vec::new();
if fp_tz.is_none() {
out.push("timezone".to_string());
}
if fp_lang.is_none() {
out.push("language".to_string());
}
out
}
/// True when measuring the exit can still verify at least one dimension of this
/// profile's fingerprint. False means a probe would compare nothing, so telling
/// the user "Donut will check it while starting" would be a promise it cannot
/// keep.
pub fn can_verify_anything(profile: &BrowserProfile) -> bool {
unverifiable_dimensions(profile).len() < CHECKED_DIMENSIONS.len()
}
/// Whether this upstream can carry a probe request at all.
@@ -126,7 +205,7 @@ impl ConsistencyResult {
/// rather than guessed at.
fn probe_url(settings: &crate::browser::ProxySettings) -> Option<String> {
match settings.proxy_type.to_lowercase().as_str() {
"http" | "https" | "socks4" | "socks5" => Some(
"http" | "https" | "httpstls" | "socks4" | "socks5" => Some(
crate::proxy_manager::ProxyManager::build_probe_proxy_url(settings),
),
_ => None,
@@ -141,32 +220,35 @@ fn probe_url(settings: &crate::browser::ProxySettings) -> Option<String> {
/// any table naming one "expected" language per country flags fingerprints
/// Donut itself produced — roughly 10% of US profiles legitimately get `es-US`
/// and ~23% of Canadian ones get `fr-CA`. `None` means the country has no CLDR
/// data and the check is skipped.
/// data, so the language cannot be judged either way, the caller reports that
/// dimension as unverified rather than counting it as a match.
fn language_matches_country(cc: &str, language: &str) -> Option<bool> {
crate::geolocation::locale_selector()?.region_speaks(cc, language)
}
/// Extract (timezone, language) from a profile's stored location, or from its
/// legacy fingerprint payload when it still stores one.
///
/// Read through `WayfernManager::fingerprint_object`, the same accessor the
/// launcher uses to build the device it hands the browser, so both stored
/// shapes, the bare object and the legacy `{ "fingerprint": {...} }` wrapper
/// old profiles carry, are read identically on both sides. Reading only the
/// top level here made a wrapped fingerprint report "declares no timezone",
/// which sent the check down the not-compared path on exactly the profiles old
/// enough to have the wrapper, while the launch presented the timezone nested
/// one level down. Sharing the accessor is what keeps the two from drifting
/// apart again.
fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<String>) {
let Some(config) = &profile.wayfern_config else {
let Some(fp) = profile
.wayfern_config
.as_ref()
.and_then(|config| config.location.as_deref().or(config.fingerprint.as_deref()))
.and_then(crate::wayfern_manager::WayfernManager::fingerprint_object)
else {
return (None, None);
};
let Some(fp_str) = config.location.as_ref().or(config.fingerprint.as_ref()) else {
return (None, None);
};
let Ok(fp) = serde_json::from_str::<serde_json::Value>(fp_str) else {
return (None, None);
};
let timezone = fp
.get("timezone")
.and_then(|v| v.as_str())
.map(str::to_string);
let language = fp
.get("language")
.and_then(|v| v.as_str())
.map(str::to_string);
(timezone, language)
let read = |key: &str| fp.get(key).and_then(|v| v.as_str()).map(str::to_string);
(read("timezone"), read("language"))
}
/// A mutex whose poison is not fatal.
@@ -187,28 +269,58 @@ pub fn compare_exit_to_fingerprint(
) -> ConsistencyResult {
let (fp_tz, fp_lang) = fingerprint_locale(profile);
let mut mismatches = Vec::new();
let mut unverified = Vec::new();
let mut compared = 0usize;
if let (Some(exit), Some(fp)) = (&exit_timezone, &fp_tz) {
if !exit.eq_ignore_ascii_case(fp) {
mismatches.push("timezone".to_string());
// Three outcomes per dimension, never two. An exit whose timezone the
// fingerprint does not declare is NOT agreement: nothing was compared, and
// folding that into "consistent" is a green light this check has not earned.
// "Not compared" is also a claim about the launch, not just about this
// function: it is only honest because the launcher hands the browser no
// timezone either (`wayfern_manager::launch_fingerprint_payload`). If it ever
// starts supplying one again, that value is what this must compare against -
// reporting "nothing was compared" while a location ships is the one outcome
// neither side may produce.
if let Some(exit) = &exit_timezone {
match &fp_tz {
Some(fp) => {
compared += 1;
if !exit.eq_ignore_ascii_case(fp) {
mismatches.push("timezone".to_string());
}
}
None => unverified.push("timezone".to_string()),
}
}
if let (Some(cc), Some(lang)) = (&exit_country_code, &fp_lang) {
if language_matches_country(cc, lang) == Some(false) {
mismatches.push("language".to_string());
// Language has one extra way to be uncomparable: a country CLDR has no data
// for answers `None`, which is no more a match than a missing fingerprint
// language is.
if let Some(cc) = &exit_country_code {
match fp_lang
.as_ref()
.and_then(|lang| language_matches_country(cc, lang))
{
Some(plausible) => {
compared += 1;
if !plausible {
mismatches.push("language".to_string());
}
}
None => unverified.push("language".to_string()),
}
}
ConsistencyResult {
consistent: mismatches.is_empty(),
checked: true,
checked: compared > 0,
exit_ip,
exit_country_code,
exit_timezone,
fingerprint_timezone: fp_tz,
fingerprint_language: fp_lang,
mismatches,
unverified,
}
}
@@ -223,6 +335,14 @@ fn cached_exit(key: &ExitCacheKey) -> Option<CachedExit> {
.cloned()
}
/// The exit IP the launch gate last measured for this profile's route, while
/// it is still fresh. Cache-only: the gate probes on an interactive launch and
/// an automation launch never probes, so a miss here is "unknown", not "direct".
pub fn cached_exit_ip(profile: &BrowserProfile) -> Option<String> {
let key = exit_cache_key(profile)?;
cached_exit(&key).and_then(|cached| cached.ip)
}
/// Cache-only check. Never performs I/O, so it is safe to call before a launch
/// and for every profile in a bulk run. Returns an unchecked result on a miss.
pub fn check_profile_consistency_cached(profile: &BrowserProfile) -> ConsistencyResult {
@@ -593,8 +713,252 @@ mod tests {
Some("DE".into()),
None,
);
assert!(result.consistent);
assert!(!result.is_mismatch());
assert!(result.mismatches.is_empty());
// ...but skipping every dimension is not a pass, and must not be dressed
// as one.
assert!(!result.is_verified());
assert!(!result.checked);
assert_eq!(result.unverified, vec!["timezone", "language"]);
}
fn profile_with_raw_fingerprint(fingerprint: serde_json::Value) -> BrowserProfile {
let mut profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "p".into(),
browser: "wayfern".into(),
..Default::default()
};
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
fingerprint: Some(fingerprint.to_string()),
..Default::default()
});
profile
}
#[test]
fn a_fingerprint_with_no_timezone_is_unverified_never_a_match() {
// The regression. Generation no longer invents `America/New_York` when the
// geolocation probe fails, so a fingerprint can legitimately carry no
// timezone. The comparison then has nothing to compare, and reporting that
// as agreement is a green light the check never earned, on the one
// dimension that carries the real signal.
let profile = profile_with_raw_fingerprint(serde_json::json!({ "language": "de-DE" }));
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
Some("1.2.3.4".into()),
);
assert!(
result.unverified.contains(&"timezone".to_string()),
"an undeclared timezone must be reported as unverified, got {result:?}"
);
assert!(
!result.is_verified(),
"nothing compared the timezone, so this must not read as consistent"
);
// The language WAS compared and agreed, so the exit counts as checked...
assert!(result.checked);
assert!(result.mismatches.is_empty());
// ...but a dimension nobody compared is never a reason to stop a launch.
assert!(!result.is_mismatch());
}
#[test]
fn a_fingerprint_with_no_locale_at_all_is_not_checked() {
// Both dimensions undeclared: the exit was reached, and still nothing was
// compared. `checked` has to say so, because every consumer reads it as
// "there is a measurement here worth acting on".
let profile = profile_with_raw_fingerprint(serde_json::json!({ "platform": "Win32" }));
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
Some("1.2.3.4".into()),
);
assert!(!result.checked);
assert!(!result.is_verified());
assert!(!result.is_mismatch());
assert_eq!(result.unverified, vec!["timezone", "language"]);
}
#[test]
fn a_legacy_wrapped_fingerprint_is_read_the_way_the_launcher_reads_it() {
// The launcher accepts `{"fingerprint": {...}}` as well as the bare object,
// so reading only the top level here answered "this profile declares no
// timezone" for a profile whose launch presents one. The check then skipped
// the dimension carrying the real signal, on exactly the profiles old
// enough to still have the wrapper.
let profile = profile_with_raw_fingerprint(serde_json::json!({
"fingerprint": { "timezone": "America/New_York", "language": "en-US" }
}));
assert_eq!(
unverifiable_dimensions(&profile),
Vec::<String>::new(),
"a wrapped fingerprint declares both dimensions"
);
assert!(can_verify_anything(&profile));
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
Some("1.2.3.4".into()),
);
assert_eq!(
result.fingerprint_timezone.as_deref(),
Some("America/New_York"),
"the nested timezone must be the one compared, got {result:?}"
);
assert_eq!(result.fingerprint_language.as_deref(), Some("en-US"));
assert!(result.checked);
assert!(
result.mismatches.contains(&"timezone".to_string()),
"a US timezone behind a German exit must flag, got {result:?}"
);
assert!(result.is_mismatch());
assert!(
result.unverified.is_empty(),
"both dimensions were declared, so nothing is unverified: {result:?}"
);
}
#[test]
fn a_wrapped_fingerprint_with_no_timezone_is_still_unverified() {
// The other half: unwrapping must not turn "declares nothing" into a pass.
let profile = profile_with_raw_fingerprint(serde_json::json!({
"fingerprint": { "language": "de-DE" }
}));
assert_eq!(unverifiable_dimensions(&profile), vec!["timezone"]);
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
None,
);
assert_eq!(result.unverified, vec!["timezone"]);
assert!(!result.is_verified());
assert!(!result.is_mismatch());
}
#[test]
fn a_language_the_country_has_no_cldr_data_for_is_unverified() {
// The other way a comparison can silently not happen. `ZZ` has no CLDR
// entry, so the language was never judged; the timezone still was.
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("ZZ".into()),
None,
);
assert!(result.checked, "the timezone was compared");
assert_eq!(result.unverified, vec!["language"]);
assert!(!result.is_verified());
assert!(!result.is_mismatch());
}
#[test]
fn only_a_fully_compared_agreement_reads_as_verified() {
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
Some("1.2.3.4".into()),
);
assert!(result.is_verified());
assert!(result.unverified.is_empty());
assert!(!result.is_mismatch());
}
#[test]
fn a_measured_mismatch_is_still_the_only_blocking_state() {
let profile = profile_with_fingerprint("America/New_York", "en-US");
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
Some("1.2.3.4".into()),
);
assert!(result.is_mismatch());
assert!(!result.is_verified());
// A verdict that blocks must not be diluted into "unverified".
assert!(result.unverified.is_empty());
}
#[test]
fn skip_reads_as_neither_verified_nor_mismatched() {
let result = ConsistencyResult::skip();
assert!(!result.is_verified());
assert!(!result.is_mismatch());
assert!(result.unverified.is_empty());
}
#[test]
fn unverifiable_dimensions_are_answered_without_measuring_anything() {
// Pure and local, so the pre-launch report can say "this cannot be checked"
// before a single worker starts.
assert_eq!(
unverifiable_dimensions(&profile_with_fingerprint("Europe/Berlin", "de-DE")),
Vec::<String>::new()
);
assert_eq!(
unverifiable_dimensions(&profile_with_raw_fingerprint(
serde_json::json!({ "language": "de-DE" })
)),
vec!["timezone"]
);
assert_eq!(
unverifiable_dimensions(&profile_with_raw_fingerprint(
serde_json::json!({ "platform": "Win32" })
)),
CHECKED_DIMENSIONS.to_vec()
);
}
#[test]
fn a_probe_that_could_compare_nothing_is_not_pending_work() {
assert!(can_verify_anything(&profile_with_fingerprint(
"Europe/Berlin",
"de-DE"
)));
// One dimension left is still worth probing for.
assert!(can_verify_anything(&profile_with_raw_fingerprint(
serde_json::json!({ "language": "de-DE" })
)));
// Nothing left: promising the user the launch will check it would be a
// promise the gate cannot keep.
assert!(!can_verify_anything(&profile_with_raw_fingerprint(
serde_json::json!({ "platform": "Win32" })
)));
}
#[test]
fn an_unverified_dimension_survives_serialization_to_the_ui() {
let profile = profile_with_raw_fingerprint(serde_json::json!({ "language": "de-DE" }));
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
None,
);
let encoded = serde_json::to_value(&result).expect("serializable");
assert_eq!(encoded["unverified"], serde_json::json!(["timezone"]));
// Older payloads without the field must still decode, defaulting to "we
// were told nothing", not to a silent pass.
let legacy: ConsistencyResult = serde_json::from_str(
r#"{"consistent":true,"checked":false,"exit_ip":null,"exit_country_code":null,
"exit_timezone":null,"fingerprint_timezone":null,"fingerprint_language":null,
"mismatches":[]}"#,
)
.expect("legacy payloads stay decodable");
assert!(legacy.unverified.is_empty());
assert!(!legacy.is_verified());
}
#[test]
+72 -11
View File
@@ -53,6 +53,15 @@ impl GeoIPDownloader {
Ok(Self::get_cache_dir().join("GeoLite2-City.mmdb"))
}
/// Where the autonomous-system database lives. It is the only MaxMind file
/// that carries an organisation for an address, which is what a proxy check
/// reports as the exit's ISP; the city database has no such field. Same
/// release, same publisher, fetched by the same code — it is simply a second
/// asset off the download the city database already comes from.
pub fn get_asn_mmdb_file_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
Ok(Self::get_cache_dir().join("GeoLite2-ASN.mmdb"))
}
pub fn is_geoip_database_available() -> bool {
if let Ok(mmdb_path) = Self::get_mmdb_file_path() {
mmdb_path.exists()
@@ -99,12 +108,15 @@ impl GeoIPDownloader {
}
fn find_city_mmdb_asset(&self, release: &GithubRelease) -> Option<String> {
for asset in &release.assets {
if asset.name.ends_with("-City.mmdb") {
return Some(asset.browser_download_url.clone());
}
}
None
Self::find_mmdb_asset(release, "-City.mmdb")
}
fn find_mmdb_asset(release: &GithubRelease, suffix: &str) -> Option<String> {
release
.assets
.iter()
.find(|asset| asset.name.ends_with(suffix))
.map(|asset| asset.browser_download_url.clone())
}
pub async fn download_geoip_database(
@@ -148,14 +160,26 @@ impl GeoIPDownloader {
#[cfg(not(feature = "e2e"))]
let fixture_url: Option<String> = None;
let download_url = if let Some(url) = fixture_url {
url
#[cfg(feature = "e2e")]
let asn_fixture_url = std::env::var("DONUT_E2E_GEOIP_ASN_DOWNLOAD_URL")
.ok()
.filter(|url| !url.is_empty());
#[cfg(not(feature = "e2e"))]
let asn_fixture_url: Option<String> = None;
// The ASN asset comes off the same release as the city one, so the release
// is kept rather than looked up twice.
let (download_url, asn_url) = if let Some(url) = fixture_url {
(url, asn_fixture_url)
} else {
let releases = self.fetch_geoip_releases().await?;
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
self
.find_city_mmdb_asset(latest_release)
.ok_or("No compatible GeoIP database asset found")?
(
self
.find_city_mmdb_asset(latest_release)
.ok_or("No compatible GeoIP database asset found")?,
Self::find_mmdb_asset(latest_release, "-ASN.mmdb"),
)
};
// Create cache directory
@@ -250,6 +274,15 @@ impl GeoIPDownloader {
.as_secs();
let _ = fs::write(&timestamp_path, now.to_string()).await;
// The autonomous-system database, best effort. It only feeds the exit
// organisation a proxy check reports, so a failure here must never fail
// the download that fingerprint geolocation actually depends on.
if let Some(url) = asn_url {
if let Err(e) = self.download_asn_database(&url).await {
log::warn!("Failed to download the GeoIP ASN database: {e}");
}
}
// Emit completion
let _ = events::emit(
"geoip-download-progress",
@@ -267,6 +300,34 @@ impl GeoIPDownloader {
Ok(())
}
/// Fetch the ASN database to a temp file and rename it into place, so a
/// half-written file is never left where a lookup would read it.
async fn download_asn_database(
&self,
url: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let path = Self::get_asn_mmdb_file_path()?;
let temp_path = path.with_extension("mmdb.downloading");
let _ = fs::remove_file(&temp_path).await;
let response = self.client.get(url).send().await?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()).into());
}
let mut file = fs::File::create(&temp_path).await?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
file.write_all(&chunk?).await?;
}
file.flush().await?;
drop(file);
fs::rename(&temp_path, &path).await?;
Ok(())
}
async fn fetch_geoip_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
+78
View File
@@ -261,6 +261,84 @@ fn normalize_locale(locale: &str) -> Locale {
Locale { language, region }
}
/// What the bundled MaxMind data says about an exit address, beyond the
/// city and country a check already reports.
///
/// Everything here is read from the databases already on disk. A proxy check
/// must not hand the exit address to a third-party lookup service: that would
/// tell an outside party which addresses this machine is testing, which is the
/// opposite of what the proxy is for.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExitInsight {
pub timezone: Option<String>,
/// The ISP, the registered organisation, or the autonomous system's
/// organisation, whichever the installed databases carry. `None` means "not
/// known", never "none".
pub organization: Option<String>,
}
/// Read timezone and organisation for `ip` out of the local databases.
///
/// Never fails: a missing database, an unroutable address or a record without
/// the field all resolve to `None`, because "unknown" is the honest answer and
/// a check should still report everything else it learned.
pub fn lookup_exit_insight(ip: &str) -> ExitInsight {
let Ok(ip_addr) = IpAddr::from_str(ip) else {
return ExitInsight::default();
};
let mut insight = ExitInsight::default();
if let Ok(path) = GeoIPDownloader::get_mmdb_file_path() {
if let Ok(reader) = Reader::open_readfile(&path) {
if let Ok(lookup) = reader.lookup(ip_addr) {
if let Ok(Some(city)) = lookup.decode::<geoip2::City>() {
insight.timezone = city.location.time_zone.map(|tz| tz.to_string());
}
}
// The City database carries no organisation, but the same reader decodes
// one when the file in place is an ISP or Enterprise database instead.
if let Ok(lookup) = reader.lookup(ip_addr) {
if let Ok(Some(isp)) = lookup.decode::<geoip2::Isp>() {
insight.organization = first_non_empty([
isp.isp,
isp.organization,
isp.autonomous_system_organization,
]);
}
}
}
}
if insight.organization.is_none() {
insight.organization = lookup_asn_organization(ip_addr);
}
insight
}
/// The autonomous system's organisation, from the ASN database that ships
/// alongside the city one. Absent on an install that has only ever fetched the
/// city database, which is why the caller treats `None` as "unknown".
fn lookup_asn_organization(ip_addr: IpAddr) -> Option<String> {
let path = GeoIPDownloader::get_asn_mmdb_file_path().ok()?;
if !path.exists() {
return None;
}
let reader = Reader::open_readfile(&path).ok()?;
let asn: geoip2::Asn = reader.lookup(ip_addr).ok()?.decode().ok()??;
first_non_empty([asn.autonomous_system_organization])
}
fn first_non_empty<const N: usize>(candidates: [Option<&str>; N]) -> Option<String> {
candidates
.into_iter()
.flatten()
.map(str::trim)
.find(|value| !value.is_empty())
.map(|value| value.to_string())
}
pub fn get_geolocation(ip: &str) -> Result<Geolocation, GeolocationError> {
let mmdb_path =
GeoIPDownloader::get_mmdb_file_path().map_err(|_| GeolocationError::DatabaseNotFound)?;
File diff suppressed because it is too large Load Diff
+52
View File
@@ -4,11 +4,16 @@ use std::fs;
use std::sync::Mutex;
use crate::events;
use crate::group_bookmarks::GroupBookmark;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileGroup {
pub id: String,
pub name: String,
/// Bookmarks every profile in this group carries. Written into the profile's
/// Chromium `Bookmarks` file before each launch; see `group_bookmarks`.
#[serde(default)]
pub bookmarks: Vec<GroupBookmark>,
#[serde(default)]
pub sync_enabled: bool,
#[serde(default)]
@@ -25,6 +30,8 @@ pub struct GroupWithCount {
pub name: String,
pub count: usize,
#[serde(default)]
pub bookmark_count: usize,
#[serde(default)]
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
@@ -104,6 +111,7 @@ impl GroupManager {
let group = ProfileGroup {
id: uuid::Uuid::new_v4().to_string(),
name,
bookmarks: Vec::new(),
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
@@ -195,6 +203,7 @@ impl GroupManager {
if let Some(existing) = groups_data.groups.iter_mut().find(|g| g.id == group.id) {
existing.name = group.name.clone();
existing.bookmarks = group.bookmarks.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
@@ -212,6 +221,7 @@ impl GroupManager {
if let Some(existing) = groups_data.groups.iter_mut().find(|g| g.id == group.id) {
existing.name = group.name.clone();
existing.bookmarks = group.bookmarks.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
@@ -294,6 +304,47 @@ impl GroupManager {
Ok(())
}
/// Replace a group's shared bookmark list.
///
/// Bumps `updated_at` because this is a real user edit, which is what sync's
/// last-write-wins reconcile reads; `last_sync` is bookkeeping and must not
/// decide direction.
pub fn set_group_bookmarks(
&self,
_app_handle: &tauri::AppHandle,
id: &str,
bookmarks: Vec<GroupBookmark>,
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
let mut groups_data = self.load_groups_data()?;
let group = groups_data
.groups
.iter_mut()
.find(|g| g.id == id)
.ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?;
group.bookmarks = bookmarks;
group.updated_at = Some(crate::proxy_manager::now_secs());
let updated_group = group.clone();
self.save_groups_data(&groups_data)?;
if let Err(e) = events::emit_empty("groups-changed") {
log::error!("Failed to emit groups-changed event: {e}");
}
if updated_group.sync_enabled {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = updated_group.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_group_sync(id).await;
});
}
}
Ok(updated_group)
}
pub fn get_groups_with_profile_counts(
&self,
profiles: &[crate::profile::BrowserProfile],
@@ -318,6 +369,7 @@ impl GroupManager {
id: group.id,
name: group.name,
count,
bookmark_count: group.bookmarks.len(),
sync_enabled: group.sync_enabled,
last_sync: group.last_sync,
});
+140 -9
View File
@@ -1,4 +1,10 @@
//! The pre-spawn launch gate.
//! The pre-spawn gate.
//!
//! Runs on every real browser spawn, after the upstream has been normalized
//! and before any worker, decrypted copy or browser process exists. It answers
//! one question — may this launch proceed — and changes nothing else; the
//! launch path does its own preparation (the group's bookmarks, the blocklist)
//! around it.
//!
//! Two findings can stop a launch being what the user expects:
//!
@@ -126,11 +132,46 @@ fn mismatch_error(result: &ConsistencyResult, token: &str) -> String {
"fingerprintTimezone": result.fingerprint_timezone.clone().unwrap_or_default(),
"fingerprintLanguage": result.fingerprint_language.clone().unwrap_or_default(),
"mismatches": result.mismatches.join(","),
"unverified": result.unverified.join(","),
}
})
.to_string()
}
/// Say which of the three states a non-blocking check landed in.
///
/// Only a measured disagreement stops a launch. The other two both continue -
/// and telling them apart is the whole point, because "the exit and the
/// fingerprint agree" and "nothing was compared" are different claims and the
/// second used to be reported as the first.
///
/// Reaching an exit and finding nothing to compare is deliberately not a block:
/// a fingerprint whose geolocation probe failed carries no location at all,
/// since generation stopped inventing one, and refusing to start those profiles
/// would break profiles that are legitimately in that state. It is not a pass
/// either, so the launch says what it could not check.
fn report_consistency(profile: &BrowserProfile, result: &ConsistencyResult) {
if result.is_verified() {
log::debug!(
"Fingerprint gate: {} agrees with its exit on every dimension",
profile.name
);
return;
}
if result.unverified.is_empty() {
return;
}
log::warn!(
"Fingerprint gate: {} reached its exit but could not verify {}; \
the fingerprint declares no value to compare against",
profile.name,
result.unverified.join(", ")
);
if let Err(e) = crate::events::emit("fingerprint-consistency-unverified", result) {
log::warn!("Failed to emit fingerprint consistency notice: {e}");
}
}
fn gate_disabled() -> bool {
crate::settings_manager::SettingsManager::instance()
.load_settings()
@@ -180,7 +221,8 @@ async fn enforce_direct_exit(
return Ok(());
}
};
if !result.checked || result.consistent {
if !result.is_mismatch() {
report_consistency(profile, &result);
return Ok(());
}
@@ -188,8 +230,7 @@ async fn enforce_direct_exit(
Err(mismatch_error(&result, &token))
}
/// The enforcing gate. Called from the launch pipeline once the upstream is
/// normalized and before anything expensive or user-visible happens.
/// The pre-spawn stage: prepare the profile, then gate the launch.
///
/// Fails **open** on every degradation — probe failure, timeout, missing geo
/// database, private exit IP. The gate blocks only on a positively measured
@@ -260,7 +301,10 @@ pub async fn enforce_fingerprint_gate(
}
};
if !result.checked || result.consistent {
if !result.is_mismatch() {
// A mismatch carries its own report, and the dialog it opens already lists
// whatever went uncompared alongside it.
report_consistency(profile, &result);
return Ok(());
}
@@ -294,9 +338,15 @@ pub struct PreLaunchChecks {
pub scan_state: String,
/// Cache-only; `checked` is false when the exit has not been measured yet.
pub consistency: ConsistencyResult,
/// True when the enforcing gate will still probe during the launch, so the
/// UI can say the check is not finished rather than implying it passed.
/// True when the enforcing gate will still probe during the launch AND that
/// probe can actually compare something, so the UI can say the check is not
/// finished rather than implying it passed.
pub exit_probe_pending: bool,
/// Dimensions no probe can ever verify for this profile, because its
/// fingerprint declares no value to compare. Answered locally, with no
/// measurement. Informational and never a block, but the launch must not
/// read as verified on a dimension nothing will compare.
pub exit_unverified: Vec<String>,
/// An extension holding the `proxy` permission is present, so any exit
/// measurement describes a route the browser may not take. Informational
/// only — it never relaxes the block.
@@ -357,12 +407,22 @@ pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaun
.as_ref()
.is_some_and(|k| crate::launch_gate_prefs::fingerprint_ack_matches(&profile, &k.identity));
let blocking = consistency.checked && !consistency.consistent && !already_acked;
let blocking = consistency.is_mismatch() && !already_acked;
let consent_token = match (&key, blocking) {
(Some(k), true) => Some(mint_consent(&profile, &k.identity)),
_ => None,
};
// Only meaningful when an exit check is going to happen at all: an
// acknowledged profile, a disabled gate, or a profile with no route never
// measures an exit, so there is nothing it failed to verify.
let gate_will_measure = !disabled && !already_acked && key.is_some();
let exit_unverified = if gate_will_measure {
fingerprint_consistency::unverifiable_dimensions(&profile)
} else {
Vec::new()
};
Ok(PreLaunchChecks {
vpn_extensions,
scan_state: scan.scan_state,
@@ -371,7 +431,14 @@ pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaun
} else {
ConsistencyResult::skip()
},
exit_probe_pending: !disabled && !already_acked && key.is_some() && !blocking,
// A probe that can compare nothing is not pending work. Reporting it as
// pending promises the user Donut "will check it while starting and stop if
// it doesn't match", which is the same unearned assurance in a second
// costume.
exit_probe_pending: gate_will_measure
&& !blocking
&& fingerprint_consistency::can_verify_anything(&profile),
exit_unverified,
exit_measurement_unreliable,
consent_token,
})
@@ -484,6 +551,7 @@ mod tests {
fingerprint_timezone: Some("America/New_York".into()),
fingerprint_language: Some("en-US".into()),
mismatches: vec!["timezone".into(), "language".into()],
unverified: Vec::new(),
};
let encoded = mismatch_error(&result, "tok");
let parsed: serde_json::Value = serde_json::from_str(&encoded).unwrap();
@@ -493,6 +561,69 @@ mod tests {
assert_eq!(parsed["params"]["fingerprintTimezone"], "America/New_York");
// params values must be strings for the frontend's interpolation.
assert_eq!(parsed["params"]["mismatches"], "timezone,language");
assert_eq!(parsed["params"]["unverified"], "");
}
#[test]
fn mismatch_error_carries_what_it_could_not_verify_too() {
// One dimension disagreed and the other was never compared. The dialog
// rebuilds its finding from these params, so dropping `unverified` here
// would make the rebuilt result claim a clean bill on a dimension nothing
// looked at.
let result = ConsistencyResult {
consistent: false,
checked: true,
exit_ip: Some("1.2.3.4".into()),
exit_country_code: Some("DE".into()),
exit_timezone: Some("Europe/Berlin".into()),
fingerprint_timezone: Some("America/New_York".into()),
fingerprint_language: None,
mismatches: vec!["timezone".into()],
unverified: vec!["language".into()],
};
let parsed: serde_json::Value = serde_json::from_str(&mismatch_error(&result, "tok")).unwrap();
assert_eq!(parsed["params"]["mismatches"], "timezone");
assert_eq!(parsed["params"]["unverified"], "language");
}
#[test]
fn an_unverified_dimension_is_never_a_mismatch_and_never_blocks() {
// The N7 shape at the gate: an exit was reached, the fingerprint declares
// no timezone, so nothing was compared. That must not stop a launch...
let result = ConsistencyResult {
consistent: true,
checked: false,
exit_ip: Some("1.2.3.4".into()),
exit_country_code: Some("DE".into()),
exit_timezone: Some("Europe/Berlin".into()),
fingerprint_timezone: None,
fingerprint_language: None,
mismatches: Vec::new(),
unverified: vec!["timezone".into(), "language".into()],
};
assert!(
!result.is_mismatch(),
"an unverified dimension must not block"
);
// ...and must not be reported as a clean check either.
assert!(!result.is_verified());
}
#[test]
fn a_profile_that_can_verify_nothing_reports_no_pending_probe() {
// `exit_probe_pending` promises the user the launch "will check it while
// starting and stop if it doesn't match". A fingerprint with no locale at
// all leaves the probe nothing to compare, so that promise cannot be kept.
let bare = profile_with(r#"{"platform":"Win32"}"#);
assert!(!fingerprint_consistency::can_verify_anything(&bare));
assert_eq!(
fingerprint_consistency::unverifiable_dimensions(&bare),
vec!["timezone", "language"]
);
let located = profile_with(r#"{"timezone":"Europe/Berlin","language":"de-DE"}"#);
assert!(fingerprint_consistency::can_verify_anything(&located));
assert!(fingerprint_consistency::unverifiable_dimensions(&located).is_empty());
}
#[tokio::test]
+974 -167
View File
File diff suppressed because it is too large Load Diff
+34 -3
View File
@@ -8,8 +8,15 @@ static PRIVATE_KEY_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----")
.expect("valid private-key regex")
});
static BEARER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+").expect("valid bearer regex"));
/// Every HTTP auth scheme that carries its credential as a single token after
/// the scheme name, not just `Bearer`. SECRET_RE cannot reach these: its value
/// class stops at the space between the scheme and the credential, so a
/// `Basic`/`NTLM` blob used to survive into an exported log verbatim. Digest's
/// quoted-parameter form (`response="..."`) is out of scope.
static AUTH_SCHEME_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b(Bearer|Basic|Token|Digest|Negotiate|NTLM)\s+[A-Za-z0-9._~+/=-]+")
.expect("valid auth-scheme regex")
});
static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)\b(api[_-]?key|authorization|password|passwd|private[_-]?key|proxy[_-]?(password|username)|refresh[_-]?token|secret|token|username)\b\s*[:=]\s*[^\s,;]+",
@@ -41,7 +48,9 @@ pub fn url_label(value: &str) -> String {
pub fn text(value: &str) -> String {
let redacted = PRIVATE_KEY_RE.replace_all(value, "<redacted-private-key>");
let redacted = URL_RE.replace_all(&redacted, "<redacted-url>");
let redacted = BEARER_RE.replace_all(&redacted, "Bearer <redacted-secret>");
// Must stay ahead of SECRET_RE, which would otherwise consume
// `Authorization: Basic` and leave the credential with no scheme to match.
let redacted = AUTH_SCHEME_RE.replace_all(&redacted, "${1} <redacted-secret>");
let redacted = SECRET_RE.replace_all(&redacted, "<redacted-secret>");
let redacted = EMAIL_RE.replace_all(&redacted, "<redacted-email>");
let redacted = UNIX_HOME_RE.replace_all(&redacted, "/<redacted-home>");
@@ -85,6 +94,28 @@ mod tests {
}
}
#[test]
fn redacts_non_bearer_authorization_credentials() {
let headers = [
("Authorization: Basic ", "dXNlcjpwYXNzd29yZA=="),
("Proxy-Authorization: Basic ", "cHJveHk6c2VjcmV0"),
("Authorization: Token ", "gh_example_credential"),
("authorization: bearer ", "lower-case-credential"),
("WWW-Authenticate: NTLM ", "TlRMTVNTUAAB"),
];
for (header, credential) in headers {
let output = text(&format!("{header}{credential}"));
assert!(
!output.contains(credential),
"log output leaked {credential}"
);
}
// The scheme survives wherever the header name is not itself redacted, so a
// log still says which kind of authentication was in play.
assert!(text("WWW-Authenticate: NTLM TlRMTVNTUAAB").contains("NTLM"));
}
#[test]
fn url_labels_retain_only_the_scheme() {
assert_eq!(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6003 -380
View File
File diff suppressed because it is too large Load Diff
+81 -24
View File
@@ -4,38 +4,87 @@ use std::path::Path;
use std::process::Command;
/// True if a process command line refers to `profile_path` as a real browser
/// profile/data-dir argument, NOT merely a substring. A bare `contains` match
/// force-killed unrelated processes that happened to mention the path (editors,
/// `tail`, a terminal that `cd`'d there, or another profile whose path has this
/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager.
/// profile/data-dir argument. Only the `--user-data-dir=<path>` /
/// `-profile=<path>` flag form counts, because the results feed a SIGKILL loop:
/// a substring match, or the path accepted as a standalone argv token, also
/// caught unrelated processes that legitimately name the directory (`du -sh
/// <profile>`, `tar czf backup.tgz <profile>`, an editor, a sibling profile
/// whose path has this one as a prefix). `browser.rs` only ever emits the flag
/// form, so nothing Donut launches is missed.
///
/// Only the macOS and Linux process-kill paths use this; Windows has no
/// `find_processes_by_profile_path`, so gate it to avoid a dead-code error there.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
for (i, arg) in args.iter().enumerate() {
// Exact argument equality (some launchers pass the path as its own arg).
if *arg == profile_path {
return true;
}
// `--user-data-dir=<path>` (Chromium/Wayfern) or `-profile=<path>`.
if let Some(val) = arg
cmd.iter().filter_map(|a| a.to_str()).any(|arg| {
arg
.strip_prefix("--user-data-dir=")
.or_else(|| arg.strip_prefix("-profile="))
{
if val == profile_path {
return true;
}
}
// Flag followed by the path as the next argument.
if (*arg == "-profile" || *arg == "--user-data-dir")
&& args.get(i + 1).is_some_and(|next| *next == profile_path)
{
return true;
}
.is_some_and(|val| val == profile_path)
})
}
/// The profile sweep only ever wants browser processes. Every other sweep in
/// the app (`wayfern_manager`, `profile::manager`) already filters on the
/// executable name; without it a stray match here becomes a SIGKILL on an
/// unrelated process.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn is_browser_process_name(name: &std::ffi::OsStr) -> bool {
let exe_name = name.to_string_lossy().to_lowercase();
exe_name.contains("wayfern") || exe_name.contains("chromium") || exe_name.contains("chrome")
}
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
mod profile_path_match_tests {
use super::{cmd_matches_profile_path, is_browser_process_name};
use std::ffi::{OsStr, OsString};
fn cmd(args: &[&str]) -> Vec<OsString> {
args.iter().map(OsString::from).collect()
}
#[test]
fn bare_path_argument_does_not_match() {
let profile = "/tmp/donut/profiles/work";
assert!(!cmd_matches_profile_path(
&cmd(&["du", "-sh", profile]),
profile
));
assert!(!cmd_matches_profile_path(
&cmd(&["tar", "czf", "backup.tgz", profile]),
profile
));
}
#[test]
fn user_data_dir_flag_matches() {
let profile = "/tmp/donut/profiles/work";
assert!(cmd_matches_profile_path(
&cmd(&["wayfern", &format!("--user-data-dir={profile}")]),
profile
));
assert!(cmd_matches_profile_path(
&cmd(&["wayfern", &format!("-profile={profile}")]),
profile
));
}
#[test]
fn a_sibling_profile_prefix_does_not_match() {
let profile = "/tmp/donut/profiles/work";
assert!(!cmd_matches_profile_path(
&cmd(&["wayfern", "--user-data-dir=/tmp/donut/profiles/work-2"]),
profile
));
}
#[test]
fn only_browser_executables_are_swept() {
assert!(is_browser_process_name(OsStr::new("Wayfern Helper")));
assert!(is_browser_process_name(OsStr::new("chromium")));
assert!(!is_browser_process_name(OsStr::new("du")));
assert!(!is_browser_process_name(OsStr::new("rsync")));
}
false
}
// Platform-specific modules
@@ -197,6 +246,10 @@ pub mod macos {
continue;
}
if !is_browser_process_name(process.name()) {
continue;
}
if cmd_matches_profile_path(cmd, profile_path) {
pids.push(pid.as_u32());
}
@@ -704,6 +757,10 @@ pub mod linux {
continue;
}
if !is_browser_process_name(process.name()) {
continue;
}
if cmd_matches_profile_path(cmd, profile_path) {
pids.push(pid.as_u32());
}
+39 -4
View File
@@ -20,6 +20,10 @@ const PROFILE_KEEP: &[&str] = &[
"Extension Scripts",
"Extension Cookies",
"Local Extension Settings",
// Backs chrome.storage.sync for every extension. Without a Chrome Sync
// account this directory is the only copy of that data, so wiping it resets
// every extension to defaults on each close.
"Sync Extension Settings",
"Managed Extension Settings",
// Preferences hold the extension registry + user settings; deleting them
// disables every installed extension, so they stay.
@@ -149,6 +153,33 @@ mod tests {
fs::create_dir_all(dir.join(name)).unwrap();
}
/// The per-extension settings stores. Wiping any of them resets every
/// installed extension to its defaults on the next launch.
const EXTENSION_STORES: [&str; 3] = [
"Local Extension Settings",
"Sync Extension Settings",
"Managed Extension Settings",
];
fn seed_extension_stores(dir: &Path) {
for store in EXTENSION_STORES {
let store_dir = dir.join(store).join("abcdefghijklmnop");
fs::create_dir_all(&store_dir).unwrap();
fs::write(store_dir.join("000003.ldb"), "settings").unwrap();
}
}
fn assert_extension_stores_survived(dir: &Path) {
for store in EXTENSION_STORES {
let file = dir.join(store).join("abcdefghijklmnop").join("000003.ldb");
assert_eq!(
fs::read_to_string(&file).ok().as_deref(),
Some("settings"),
"{store} must survive clear-on-close"
);
}
}
#[test]
fn clears_root_profile_layout_keeping_extensions_and_bookmarks() {
let tmp = TempDir::new().unwrap();
@@ -160,7 +191,7 @@ mod tests {
touch(dir, "Web Data");
touch(dir, "Login Data");
mkdir(dir, "Extensions");
mkdir(dir, "Local Extension Settings");
seed_extension_stores(dir);
mkdir(dir, "Cache");
mkdir(dir, "Network");
touch(&dir.join("Network"), "Cookies");
@@ -175,7 +206,7 @@ mod tests {
assert!(dir.join("Preferences").exists());
assert!(dir.join("Bookmarks").exists());
assert!(dir.join("Extensions").exists());
assert!(dir.join("Local Extension Settings").exists());
assert_extension_stores_survived(dir);
assert!(!dir.join("History").exists());
assert!(!dir.join("Web Data").exists());
assert!(!dir.join("Login Data").exists());
@@ -196,6 +227,7 @@ mod tests {
touch(&default, "Bookmarks");
touch(&default, "History");
mkdir(&default, "Extensions");
seed_extension_stores(&default);
mkdir(&default, "IndexedDB");
clear_user_data_dir(dir);
@@ -205,6 +237,7 @@ mod tests {
assert!(default.join("Preferences").exists());
assert!(default.join("Bookmarks").exists());
assert!(default.join("Extensions").exists());
assert_extension_stores_survived(&default);
assert!(!default.join("History").exists());
assert!(!default.join("IndexedDB").exists());
}
@@ -222,7 +255,7 @@ mod tests {
touch(&default, "Bookmarks");
touch(&default, "History");
mkdir(&default, "Extensions");
mkdir(&default, "Local Extension Settings");
seed_extension_stores(&default);
mkdir(&default, "IndexedDB");
clear_user_data_dir(dir);
@@ -230,7 +263,7 @@ mod tests {
assert!(default.exists(), "the profile dir must survive");
assert!(default.join("Bookmarks").exists());
assert!(default.join("Extensions").exists());
assert!(default.join("Local Extension Settings").exists());
assert_extension_stores_survived(&default);
// Browsing data inside it is still cleared.
assert!(!default.join("History").exists());
assert!(!default.join("IndexedDB").exists());
@@ -244,10 +277,12 @@ mod tests {
let p2 = dir.join("Profile 2");
touch(&p2, "Bookmarks");
touch(&p2, "History");
seed_extension_stores(&p2);
clear_user_data_dir(dir);
assert!(p2.join("Bookmarks").exists());
assert_extension_stores_survived(&p2);
assert!(!p2.join("History").exists());
}
+370 -98
View File
@@ -24,6 +24,17 @@ fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
fs::rename(&tmp, path)
}
/// Collapse an empty proxy/VPN id to `None`.
///
/// REST and MCP clients send `""` to detach a proxy or VPN, since omitting the
/// field means "leave unchanged". Stored as `Some("")` it resolves to no
/// upstream while every `proxy_id.is_some()` check still reads the profile as
/// routed, so the launch gate probes a direct exit and can refuse the launch of
/// a profile that has no proxy at all.
fn normalize_network_id(id: Option<String>) -> Option<String> {
id.filter(|id| !id.is_empty())
}
pub struct ProfileManager {
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
}
@@ -90,6 +101,12 @@ impl ProfileManager {
);
}
// Normalize before the mutual-exclusion check, not per caller: REST, MCP,
// the importer and the Tauri commands all funnel through here, and a client
// saying "neither" with two empty strings must not read as "both".
let proxy_id = normalize_network_id(proxy_id);
let vpn_id = normalize_network_id(vpn_id);
if proxy_id.is_some() && vpn_id.is_some() {
return Err("Cannot set both proxy_id and vpn_id".into());
}
@@ -208,7 +225,13 @@ impl ProfileManager {
browser: browser.to_string(),
version: version.to_string(),
proxy_id: proxy_id.clone(),
vpn_id: None,
// Carried, not None. Fingerprint generation reads this to decide
// whether the profile routes its traffic at all; hardcoding None made
// a VPN profile look direct, so its geolocation probe went out from
// the user's real address and that location was baked into the
// fingerprint, which the launch-time gate then rejects as a mismatch
// the profile should never have had.
vpn_id: vpn_id.clone(),
launch_hook: launch_hook.clone(),
process_id: None,
last_launch: None,
@@ -223,6 +246,7 @@ impl ProfileManager {
last_sync: None,
host_os: None,
ephemeral: false,
temporary: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
@@ -332,6 +356,7 @@ impl ProfileManager {
last_sync: None,
host_os: Some(get_host_os()),
ephemeral,
temporary: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
@@ -506,70 +531,168 @@ impl ProfileManager {
Ok(profile)
}
/// Delete a profile the recoverable way: it is moved to the trash and can
/// be restored until it expires. Ephemeral profiles have nothing to keep
/// and are destroyed outright.
pub fn delete_profile(
&self,
app_handle: &tauri::AppHandle,
profile_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
log::info!("Attempting to delete profile with ID: {profile_id}");
self.remove_profile(app_handle, profile_id, false, true)
}
// Find the profile by ID
/// Destroy a profile and its data for good, bypassing the trash.
/// Mark a freshly created profile as belonging to one automation run.
///
/// Set after creation rather than threaded through every creation signature:
/// only the REST and MCP paths can ask for it, and both already hold the
/// profile they just made. Implies `ephemeral`, because a disposable profile
/// must not leave a data directory on real disk either.
pub fn mark_profile_temporary(
&self,
profile_id: &str,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
let mut profile = self.find_profile(profile_id)?;
profile.temporary = true;
profile.ephemeral = true;
self.save_profile(&profile)?;
Ok(profile)
}
/// Which temporary profiles a startup sweep should destroy.
///
/// A temporary profile is destroyed when its browser stops, so one still
/// here at startup either outlived a crash or is being used by a browser
/// this app did not start. `is_running` decides between the two, and is a
/// parameter so the rule can be tested without a process table.
pub fn temporary_profiles_to_sweep(
profiles: &[BrowserProfile],
is_running: impl Fn(u32) -> bool,
) -> Vec<String> {
profiles
.iter()
.filter(|profile| profile.temporary)
.filter(|profile| !profile.process_id.is_some_and(&is_running))
.map(|profile| profile.id.to_string())
.collect()
}
/// Destroy every temporary profile that no live browser is using.
///
/// Runs at startup: a crash, a kill -9 or a power cut leaves a temporary
/// profile behind, and nothing else would ever remove it. Returns how many
/// were destroyed.
pub fn sweep_temporary_profiles(&self, app_handle: &tauri::AppHandle) -> usize {
let Ok(profiles) = self.list_profiles() else {
return 0;
};
let stale =
Self::temporary_profiles_to_sweep(&profiles, crate::proxy_storage::is_process_running);
let mut swept = 0;
for profile_id in stale {
match self.delete_profile_permanently(app_handle, &profile_id) {
Ok(()) => {
swept += 1;
log::info!("Swept temporary profile {profile_id} left by an earlier run");
}
Err(e) => log::warn!("Could not sweep temporary profile {profile_id}: {e}"),
}
}
swept
}
pub fn delete_profile_permanently(
&self,
app_handle: &tauri::AppHandle,
profile_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
self.remove_profile(app_handle, profile_id, true, true)
}
fn find_profile(&self, profile_id: &str) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let profile = profiles
self
.list_profiles()?
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found").into())
}
// Check if browser is running (cross-OS profiles can't be running locally)
if profile.process_id.is_some() && !profile.is_cross_os() {
return Err(
"Cannot delete profile while browser is running. Please stop the browser first.".into(),
);
/// The one removal path. `permanent` destroys the directory; otherwise it
/// moves to the trash. Either way the cloud sees a delete (tombstone) and
/// any team lock is released, so a trashed profile is indistinguishable
/// from a deleted one for every other device.
fn remove_profile(
&self,
app_handle: &tauri::AppHandle,
profile_id: &str,
permanent: bool,
emit_events: bool,
) -> Result<(), Box<dyn std::error::Error>> {
log::info!("Attempting to delete profile with ID: {profile_id} (permanent: {permanent})");
let profile = self.find_profile(profile_id)?;
if crate::profile::trash::is_running_locally(&profile) {
return Err(crate::backend_error("PROFILE_RUNNING").into());
}
// Launch-gate acknowledgements are keyed by profile id and are not synced,
// so nothing else would ever clean them up.
crate::launch_gate_prefs::forget_profile(profile_id);
// An ephemeral profile keeps its data in RAM; there is nothing to trash.
let permanent = permanent || profile.ephemeral;
// Deleting the profile never touched its ephemeral directory, so a
// decrypted or in-memory copy outlived the profile it belonged to with
// nothing left that knew to reap it. The running-browser guard above only
// rejects a live process_id, and the keep-decrypted path deliberately
// clears process_id while leaving the plaintext tree populated. No-ops
// when the profile has no ephemeral directory.
// A decrypted or in-memory copy must not outlive the profile it belonged
// to. The running-browser guard above only rejects a live process, and
// the keep-decrypted path deliberately clears process_id while leaving
// the plaintext tree populated. No-ops when there is no ephemeral dir.
crate::ephemeral_dirs::remove_ephemeral_dir(profile_id);
if profile.password_protected {
crate::profile::encryption::drop_cached_key(&profile.id);
}
// Per-domain traffic history lives outside the profile directory, so it
// survives the delete otherwise. It is already zero-overwritten on removal.
crate::traffic_stats::delete_traffic_stats(profile_id);
// Remember sync mode before deleting local files
let was_sync_enabled = profile.is_sync_enabled();
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
// Delete the entire UUID directory (contains both metadata.json and profile data)
if profile_uuid_dir.exists() {
log::info!("Deleting profile directory: {}", profile_uuid_dir.display());
fs::remove_dir_all(&profile_uuid_dir)?;
log::info!("Profile directory deleted successfully");
}
// Verify deletion was successful
if profile_uuid_dir.exists() {
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
if permanent {
self.forget_profile_side_state(profile_id);
if profile_uuid_dir.exists() {
log::info!("Deleting profile directory: {}", profile_uuid_dir.display());
fs::remove_dir_all(&profile_uuid_dir)?;
}
if profile_uuid_dir.exists() {
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
}
} else {
let _guard = crate::profile::trash::mutation_lock();
crate::profile::trash::trash_profile(
&profiles_dir,
&crate::profile::trash::trash_dir(),
&profile,
crate::profile::trash::configured_retention_days(),
crate::proxy_manager::now_secs(),
)?;
}
log::info!(
"Profile '{}' (ID: {}) deleted successfully",
"Profile '{}' (ID: {}) {} successfully",
profile.name,
profile_id
profile_id,
if permanent {
"deleted"
} else {
"moved to trash"
}
);
// If sync was enabled, also delete from S3
// The browser is not running, so the team lock is normally released
// already; this only drops a lock a crash left behind.
let lock_profile = profile.clone();
tauri::async_runtime::spawn(async move {
crate::team_lock::release_team_lock_if_needed(&lock_profile).await;
});
// From the cloud's point of view a trashed profile is deleted.
if was_sync_enabled {
let profile_id_owned = profile_id.to_string();
let app_handle_clone = app_handle.clone();
@@ -593,24 +716,132 @@ impl ProfileManager {
});
}
// Rebuild tag suggestions after deletion
if emit_events {
self.after_profiles_removed(!permanent);
}
Ok(())
}
/// State that lives outside the profile directory and only makes sense
/// while the profile can still come back. Dropped when it cannot.
fn forget_profile_side_state(&self, profile_id: &str) {
// Launch-gate acknowledgements are keyed by profile id and are not synced,
// so nothing else would ever clean them up.
crate::launch_gate_prefs::forget_profile(profile_id);
// Per-domain traffic history is zero-overwritten on removal.
crate::traffic_stats::delete_traffic_stats(profile_id);
}
/// Bookkeeping after one or more profiles left the live list.
fn after_profiles_removed(&self, trashed: bool) {
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
});
// Always perform cleanup after profile deletion to remove unused binaries
if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() {
log::warn!("Warning: Failed to cleanup unused binaries after profile deletion: {e}");
}
// Emit profile deletion event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
if trashed {
if let Err(e) = events::emit_empty("trash-changed") {
log::warn!("Warning: Failed to emit trash-changed event: {e}");
}
}
}
/// Move a trashed profile back into the live list under its original id.
///
/// Sync is NOT re-enabled here: the caller (the Tauri command) routes the
/// restored profile through `set_profile_sync_mode`, which clears the
/// tombstone the trash wrote and queues the re-upload.
pub fn restore_trashed_profile(
&self,
profile_id: &str,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
let _guard = crate::profile::trash::mutation_lock();
let live = self.list_profiles()?;
let groups: std::collections::HashSet<String> = crate::group_manager::GROUP_MANAGER
.lock()
.map(|gm| {
gm.get_all_groups()
.unwrap_or_default()
.into_iter()
.map(|g| g.id)
.collect()
})
.unwrap_or_default();
let profile = crate::profile::trash::restore_profile(
&self.get_profiles_dir(),
&crate::profile::trash::trash_dir(),
profile_id,
&live,
&|group_id| groups.contains(group_id),
crate::proxy_manager::now_secs(),
)?;
// The normal save path, so tag suggestions pick the profile up again.
self.save_profile(&profile)?;
log::info!(
"Profile '{}' (ID: {}) restored from trash",
profile.name,
profile_id
);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
if let Err(e) = events::emit_empty("trash-changed") {
log::warn!("Warning: Failed to emit trash-changed event: {e}");
}
Ok(profile)
}
pub fn purge_trashed_profile(&self, profile_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let _guard = crate::profile::trash::mutation_lock();
crate::profile::trash::purge_entry(&crate::profile::trash::trash_dir(), profile_id)?;
self.after_trash_purged(std::slice::from_ref(&profile_id.to_string()));
Ok(())
}
/// Destroy every trashed profile. Returns how many were removed.
pub fn empty_trash(&self) -> Result<usize, Box<dyn std::error::Error>> {
let _guard = crate::profile::trash::mutation_lock();
let purged = crate::profile::trash::purge_all(&crate::profile::trash::trash_dir())?;
self.after_trash_purged(&purged);
Ok(purged.len())
}
/// Destroy every trashed profile whose retention has run out. Returns how
/// many were removed.
pub fn purge_expired_trash(&self) -> usize {
let _guard = crate::profile::trash::mutation_lock();
let purged = crate::profile::trash::purge_expired(
&crate::profile::trash::trash_dir(),
crate::proxy_manager::now_secs(),
);
self.after_trash_purged(&purged);
purged.len()
}
fn after_trash_purged(&self, purged_ids: &[String]) {
if purged_ids.is_empty() {
return;
}
for id in purged_ids {
self.forget_profile_side_state(id);
}
if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() {
log::warn!("Warning: Failed to cleanup unused binaries after purging the trash: {e}");
}
if let Err(e) = events::emit_empty("trash-changed") {
log::warn!("Warning: Failed to emit trash-changed event: {e}");
}
}
/// Delete a profile from the local filesystem only, without triggering remote sync deletion.
/// Used when a profile was deleted on another device and the local copy should be cleaned up.
pub fn delete_profile_local_only(
@@ -1001,66 +1232,26 @@ impl ProfileManager {
Ok(profile)
}
/// Trash several profiles at once. Every profile is checked before any of
/// them moves, so one running browser blocks the whole batch instead of
/// leaving it half done.
pub fn delete_multiple_profiles(
&self,
app_handle: &tauri::AppHandle,
profile_ids: Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let profiles = self.list_profiles()?;
let mut sync_enabled_ids: Vec<String> = Vec::new();
for profile_id in profile_ids {
let profile_uuid = uuid::Uuid::parse_str(&profile_id)
.map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profile = profiles
.iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if browser is running (cross-OS profiles can't be running locally)
if profile.process_id.is_some() && !profile.is_cross_os() {
return Err(
format!(
"Cannot delete profile '{}' while browser is running. Please stop the browser first.",
profile.name
)
.into(),
);
}
// Track sync-enabled profiles for remote deletion
if profile.is_sync_enabled() {
sync_enabled_ids.push(profile_id.clone());
}
// Delete the profile
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
if profile_uuid_dir.exists() {
std::fs::remove_dir_all(&profile_uuid_dir)?;
for profile_id in &profile_ids {
let profile = self.find_profile(profile_id)?;
if crate::profile::trash::is_running_locally(&profile) {
return Err(crate::backend_error("PROFILE_RUNNING").into());
}
}
// Delete sync-enabled profiles from S3
if !sync_enabled_ids.is_empty() {
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
if let Ok(engine) = crate::sync::SyncEngine::create_from_settings(&app_handle_clone).await {
for profile_id in sync_enabled_ids {
if let Err(e) = engine.delete_profile(&profile_id).await {
log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
}
}
}
});
}
// Emit profile deletion event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
for profile_id in &profile_ids {
self.remove_profile(app_handle, profile_id, false, false)?;
}
self.after_profiles_removed(true);
Ok(())
}
@@ -1140,6 +1331,7 @@ impl ProfileManager {
last_sync: None,
host_os: Some(get_host_os()),
ephemeral: false,
temporary: false,
extension_group_id: source.extension_group_id,
proxy_bypass_rules: source.proxy_bypass_rules,
created_by_id: None,
@@ -1295,6 +1487,7 @@ impl ProfileManager {
profile_id: &str,
proxy_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
let proxy_id = normalize_network_id(proxy_id);
// Find the profile by ID
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|_| -> Box<dyn std::error::Error + Send + Sync> {
@@ -1334,8 +1527,8 @@ impl ProfileManager {
// The cookie bot refuses a run on a profile with no exit node, using the
// copy of that fact the desktop last declared. Detaching a proxy has to
// move that copy, or tonight's run egresses from the leased host's own
// datacenter address.
// move that copy, or tonight's run egresses from the remote host's own
// address instead of the user's exit.
crate::cookie_bot::report_profile_state(&profile);
// Auto-enable sync for new proxy if profile has sync enabled
@@ -1367,6 +1560,7 @@ impl ProfileManager {
profile_id: &str,
vpn_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
let vpn_id = normalize_network_id(vpn_id);
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|_| -> Box<dyn std::error::Error + Send + Sync> {
format!("Invalid profile ID: {profile_id}").into()
@@ -1747,6 +1941,48 @@ mod tests {
(profile_manager, temp_dir)
}
fn temporary_profile(name: &str, process_id: Option<u32>) -> BrowserProfile {
BrowserProfile {
id: uuid::Uuid::new_v4(),
name: name.to_string(),
browser: "wayfern".to_string(),
temporary: true,
ephemeral: true,
process_id,
..BrowserProfile::default()
}
}
#[test]
fn a_startup_sweep_takes_the_temporary_profiles_nothing_is_running() {
let ordinary = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "Kept".to_string(),
..BrowserProfile::default()
};
let crashed = temporary_profile("Crashed", Some(4242));
let never_started = temporary_profile("Never started", None);
let live = temporary_profile("Live", Some(4243));
let profiles = vec![
ordinary.clone(),
crashed.clone(),
never_started.clone(),
live.clone(),
];
let swept = ProfileManager::temporary_profiles_to_sweep(&profiles, |pid| pid == 4243);
assert_eq!(
swept,
vec![crashed.id.to_string(), never_started.id.to_string()],
"a live browser keeps its profile; an ordinary profile is never swept"
);
// Nothing running at all: every temporary profile goes, and only those.
let swept = ProfileManager::temporary_profiles_to_sweep(&profiles, |_| false);
assert_eq!(swept.len(), 3);
assert!(!swept.contains(&ordinary.id.to_string()));
}
#[test]
fn test_profile_manager_creation() {
let (_manager, _temp_dir) = create_test_profile_manager();
@@ -1785,6 +2021,26 @@ mod tests {
);
}
#[test]
fn empty_network_ids_normalize_to_none() {
assert_eq!(normalize_network_id(Some(String::new())), None);
assert_eq!(normalize_network_id(None), None);
assert_eq!(
normalize_network_id(Some("proxy-1".to_string())),
Some("proxy-1".to_string())
);
// A client saying "neither" with two empty strings must not trip the
// mutual-exclusion check in create_profile_with_group.
assert_eq!(
(
normalize_network_id(Some(String::new())),
normalize_network_id(Some(String::new()))
),
(None, None)
);
}
#[test]
fn test_normalize_launch_hook_accepts_http_and_https() {
let http =
@@ -2128,11 +2384,27 @@ pub fn clone_profile(profile_id: String, name: Option<String>) -> Result<Browser
.map_err(|e| format!("Failed to clone profile: {e}"))
}
/// Move a profile to the trash. `permanent: true` destroys it instead.
#[tauri::command]
pub fn delete_profile(app_handle: tauri::AppHandle, profile_id: String) -> Result<(), String> {
ProfileManager::instance()
.delete_profile(&app_handle, &profile_id)
.map_err(|e| format!("Failed to delete profile: {e}"))
pub fn delete_profile(
app_handle: tauri::AppHandle,
profile_id: String,
permanent: Option<bool>,
) -> Result<(), String> {
let manager = ProfileManager::instance();
let result = if permanent.unwrap_or(false) {
manager.delete_profile_permanently(&app_handle, &profile_id)
} else {
manager.delete_profile(&app_handle, &profile_id)
};
result.map_err(|e| {
let msg = e.to_string();
if msg.starts_with('{') {
msg
} else {
format!("Failed to delete profile: {msg}")
}
})
}
lazy_static::lazy_static! {
+2
View File
@@ -2,6 +2,8 @@ pub mod clear_on_close;
pub mod encryption;
pub mod manager;
pub mod password;
pub mod portable;
pub mod trash;
pub mod types;
pub use manager::ProfileManager;
+68 -1
View File
@@ -17,7 +17,7 @@ use crate::sync::manifest::DEFAULT_EXCLUDE_PATTERNS;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
/// Build a JSON error payload with just a code.
@@ -53,6 +53,33 @@ lazy_static::lazy_static! {
/// Per-profile failed unlock attempt tracking for rate-limiting.
static ref FAILED_ATTEMPTS: Mutex<HashMap<uuid::Uuid, FailureRecord>> = Mutex::new(HashMap::new());
/// Per-profile lock serializing the whole check-lockout -> verify -> record
/// window. `check_lockout` and `record_failed_attempt` each take and release
/// `FAILED_ATTEMPTS` independently, with an Argon2 verification between them,
/// so without this a burst of concurrent attempts all read the same stale
/// count before any of them increments it and one lockout window admits as
/// many guesses as there are worker threads.
static ref ATTEMPT_LOCKS: Mutex<HashMap<uuid::Uuid, Arc<tokio::sync::Mutex<()>>>> =
Mutex::new(HashMap::new());
}
/// The attempt lock for one profile. The std map lock is released before the
/// caller awaits the returned lock, so it is never held across an await.
///
/// A poisoned map degrades to serialized rather than silently unserialized.
fn attempt_lock(profile_id: &uuid::Uuid) -> Arc<tokio::sync::Mutex<()>> {
let mut guard = ATTEMPT_LOCKS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// An entry only the map itself still references has no attempt in progress,
// so dropping it here keeps a long-lived process from accumulating one lock
// per profile ever touched. A live holder always keeps the count above 1.
guard.retain(|_, lock| Arc::strong_count(lock) > 1);
guard
.entry(*profile_id)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
@@ -309,6 +336,10 @@ pub async fn verify_profile_password(profile_id: String, password: String) -> Re
if !profile.password_protected {
return Err(err_code("PROFILE_NOT_PROTECTED"));
}
// Bound, never dropped early: it must cover check_lockout through the
// record/clear branches below. See `attempt_lock`.
let attempt = attempt_lock(&id);
let _attempt_guard = attempt.lock().await;
if let Err(secs) = check_lockout(&id) {
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
}
@@ -338,6 +369,10 @@ pub async fn unlock_profile(profile_id: String, password: String) -> Result<(),
if !profile.password_protected {
return Err(err_code("PROFILE_NOT_PROTECTED"));
}
// Bound, never dropped early: it must cover check_lockout through the
// record/clear branches below. See `attempt_lock`.
let attempt = attempt_lock(&id);
let _attempt_guard = attempt.lock().await;
if let Err(secs) = check_lockout(&id) {
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
}
@@ -399,6 +434,10 @@ pub async fn change_profile_password(
return Err(err_code("PROFILE_RUNNING"));
}
// Bound, never dropped early: it must cover check_lockout through the
// record/clear branches below. See `attempt_lock`.
let attempt = attempt_lock(&id);
let _attempt_guard = attempt.lock().await;
if let Err(secs) = check_lockout(&id) {
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
}
@@ -450,6 +489,10 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
return Err(err_code("PROFILE_RUNNING"));
}
// Bound, never dropped early: it must cover check_lockout through the
// record/clear branches below. See `attempt_lock`.
let attempt = attempt_lock(&id);
let _attempt_guard = attempt.lock().await;
if let Err(secs) = check_lockout(&id) {
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
}
@@ -1255,6 +1298,30 @@ mod tests {
clear_failed_attempts(&profile.id);
}
#[tokio::test]
async fn attempt_lock_serializes_one_profile_without_blocking_others() {
let a = uuid::Uuid::new_v4();
let b = uuid::Uuid::new_v4();
// One lock per profile is what turns check-lockout -> verify -> record
// into a critical section instead of a check-then-act race.
assert!(Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&a)));
assert!(!Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&b)));
let held = attempt_lock(&a);
let guard = held.lock().await;
assert!(
attempt_lock(&a).try_lock().is_err(),
"a concurrent attempt on the same profile must wait for the window"
);
assert!(
attempt_lock(&b).try_lock().is_ok(),
"a different profile must not be serialized behind it"
);
drop(guard);
assert!(attempt_lock(&a).try_lock().is_ok());
}
#[test]
fn lockout_schedule_progression() {
use std::time::Duration;
+765
View File
@@ -0,0 +1,765 @@
//! Moving a profile between machines.
//!
//! An export is one zip: a manifest, the profile's configuration, and
//! optionally its browser data directory. An import creates a NEW profile from
//! it, with a fresh id, so importing an export twice gives two profiles rather
//! than a conflict or a silent overwrite.
//!
//! What deliberately does NOT travel:
//! - the process id, the last-launch time and the cloud-sync bookkeeping,
//! which describe the machine that exported, not the profile;
//! - the caches, which Chromium rebuilds and which are most of the bytes;
//! - the browser binary, which the importing machine downloads for itself;
//! - a password-protected profile's data, because its at-rest key belongs to
//! the exporting machine's keychain and the bytes would be unreadable
//! anywhere else. Its configuration exports, its data does not, and the
//! export says so rather than shipping an archive nobody can open.
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use crate::profile::types::BrowserProfile;
/// Bumped when the archive layout changes in a way an older build cannot read.
const FORMAT_VERSION: u32 = 1;
const MANIFEST_ENTRY: &str = "manifest.json";
const PROFILE_ENTRY: &str = "profile.json";
const DATA_PREFIX: &str = "data/";
/// A profile directory is browsing history, cookies and extension state. Past
/// this size an export is almost certainly a mistake (a cache directory that
/// escaped the prune, say), and writing gigabytes to a user's Downloads folder
/// without saying why is worse than refusing.
const MAX_DATA_BYTES: u64 = 4 * 1024 * 1024 * 1024;
/// What one archive says about itself.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortableManifest {
pub format_version: u32,
/// The app that wrote it, for a bug report.
pub exported_by: String,
pub exported_at: u64,
/// The profile's name at export time. The id is deliberately absent: an
/// import mints a new one, and carrying the old id invites a caller to
/// "restore" over a live profile.
pub profile_name: String,
pub browser: String,
pub version: String,
/// Whether `data/` is present. False for a configuration-only export and for
/// a password-protected profile, whose bytes cannot travel.
pub includes_data: bool,
/// Why the data is absent, when it is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_omitted_reason: Option<String>,
}
/// What an import found in an archive, before it creates anything.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortablePreview {
pub manifest: PortableManifest,
/// The proxy the exporting machine had assigned, by name, when it had one.
/// An import never links a proxy by id: ids are local to a machine.
pub proxy_name: Option<String>,
pub group_name: Option<String>,
pub tags: Vec<String>,
}
fn err(context: &str, detail: impl std::fmt::Display) -> String {
crate::backend_error_with_detail("PROFILE_EXPORT_FAILED", format!("{context}: {detail}"))
}
fn import_err(detail: impl std::fmt::Display) -> String {
crate::backend_error_with_detail("PROFILE_IMPORT_FAILED", detail.to_string())
}
/// The configuration an export carries: the profile as stored, minus
/// everything that describes this machine or this moment.
pub fn exportable_config(profile: &BrowserProfile) -> serde_json::Value {
let mut value = serde_json::to_value(profile).unwrap_or(serde_json::Value::Null);
if let Some(object) = value.as_object_mut() {
for machine_local in [
"id",
"process_id",
"last_launch",
"last_sync",
"encryption_salt",
"created_by_id",
"created_by_email",
"proxy_id",
"vpn_id",
"group_id",
"extension_group_id",
"temporary",
] {
object.remove(machine_local);
}
// A password-protected profile's data cannot travel, so the flag must not
// either: an imported profile with the flag set and no key is unopenable.
object.insert("password_protected".to_string(), serde_json::json!(false));
object.insert("sync_mode".to_string(), serde_json::json!("Disabled"));
}
value
}
/// Every file under `dir`, relative to it, skipping the cache directories and
/// the launcher's own per-launch documents.
fn collect_files(dir: &Path) -> Result<Vec<(String, PathBuf)>, String> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> {
let entries = fs::read_dir(dir).map_err(|e| err("could not read the profile directory", e))?;
for entry in entries.flatten() {
let path = entry.path();
let Ok(relative) = path.strip_prefix(root) else {
continue;
};
let relative = relative.to_string_lossy().replace('\\', "/");
if is_excluded(&relative) {
continue;
}
let file_type = entry
.file_type()
.map_err(|e| err("could not stat a file", e))?;
if file_type.is_symlink() {
// A symlink in an archive is either useless on the other machine or a
// way out of the extraction directory. Neither travels.
continue;
}
if file_type.is_dir() {
walk(root, &path, out)?;
} else if file_type.is_file() {
out.push((relative, path));
}
}
Ok(())
}
let mut files = Vec::new();
walk(dir, dir, &mut files)?;
files.sort_by(|a, b| a.0.cmp(&b.0));
Ok(files)
}
/// Whether a path inside the profile directory is left out of an export.
pub fn is_excluded(relative: &str) -> bool {
const CACHE_SEGMENTS: [&str; 10] = [
"Cache",
"Code Cache",
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnCache",
"DawnGraphiteCache",
"GraphiteDawnCache",
"CacheStorage",
"ScriptCache",
];
const LAUNCH_FILES: [&str; 3] = [
"wayfern-identity.json",
"wayfern-persona.json",
"window-icon.png",
];
const SINGLETONS: [&str; 3] = ["SingletonLock", "SingletonSocket", "SingletonCookie"];
let segments: Vec<&str> = relative.split('/').collect();
if segments
.iter()
.any(|segment| CACHE_SEGMENTS.contains(segment))
{
return true;
}
let Some(name) = segments.last() else {
return true;
};
LAUNCH_FILES.contains(name) || SINGLETONS.contains(name) || name.ends_with(".tmp")
}
/// Write an export archive for `profile` to `destination`.
///
/// `data_dir` is the profile's browser directory; `include_data` false writes
/// a configuration-only archive, which is the small one worth emailing.
pub fn export_to(
profile: &BrowserProfile,
data_dir: &Path,
destination: &Path,
include_data: bool,
proxy_name: Option<String>,
group_name: Option<String>,
) -> Result<PortableManifest, String> {
let data_omitted_reason = if !include_data {
Some("the export was asked for without the browser data".to_string())
} else if profile.password_protected {
Some(
"the profile is password protected, and its data is encrypted with a key held by the exporting machine".to_string(),
)
} else if !data_dir.is_dir() {
Some("the profile has no browser data yet".to_string())
} else {
None
};
let carries_data = data_omitted_reason.is_none();
let manifest = PortableManifest {
format_version: FORMAT_VERSION,
exported_by: format!("Donut Browser {}", env!("CARGO_PKG_VERSION")),
exported_at: crate::proxy_manager::now_secs(),
profile_name: profile.name.clone(),
browser: profile.browser.clone(),
version: profile.version.clone(),
includes_data: carries_data,
data_omitted_reason,
};
let mut preview = serde_json::to_value(&manifest).map_err(|e| err("manifest", e))?;
if let Some(object) = preview.as_object_mut() {
object.insert("proxy_name".to_string(), serde_json::json!(proxy_name));
object.insert("group_name".to_string(), serde_json::json!(group_name));
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|e| err("could not create the destination folder", e))?;
}
let file = fs::File::create(destination).map_err(|e| err("could not create the archive", e))?;
let mut writer = zip::ZipWriter::new(file);
let options: zip::write::FileOptions<'_, ()> =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
writer
.start_file(MANIFEST_ENTRY, options)
.map_err(|e| err("manifest", e))?;
writer
.write_all(
serde_json::to_string_pretty(&preview)
.map_err(|e| err("manifest", e))?
.as_bytes(),
)
.map_err(|e| err("manifest", e))?;
writer
.start_file(PROFILE_ENTRY, options)
.map_err(|e| err("profile", e))?;
writer
.write_all(
serde_json::to_string_pretty(&exportable_config(profile))
.map_err(|e| err("profile", e))?
.as_bytes(),
)
.map_err(|e| err("profile", e))?;
if carries_data {
let mut written = 0u64;
for (relative, absolute) in collect_files(data_dir)? {
let data = match fs::read(&absolute) {
Ok(data) => data,
// A browser file can vanish between the walk and the read; that is not
// a reason to fail an export of everything else.
Err(e) => {
log::warn!("Skipping {} in the export: {e}", absolute.display());
continue;
}
};
written = written.saturating_add(data.len() as u64);
if written > MAX_DATA_BYTES {
return Err(crate::backend_error("PROFILE_EXPORT_TOO_LARGE"));
}
writer
.start_file(format!("{DATA_PREFIX}{relative}"), options)
.map_err(|e| err(&relative, e))?;
writer.write_all(&data).map_err(|e| err(&relative, e))?;
}
}
writer
.finish()
.map_err(|e| err("could not finish the archive", e))?;
Ok(manifest)
}
fn open_archive(path: &Path) -> Result<zip::ZipArchive<fs::File>, String> {
let file = fs::File::open(path).map_err(|e| import_err(format!("could not open it: {e}")))?;
zip::ZipArchive::new(file).map_err(|e| import_err(format!("it is not a readable archive: {e}")))
}
fn read_entry<R: Read + Seek>(
archive: &mut zip::ZipArchive<R>,
name: &str,
) -> Result<String, String> {
let mut entry = archive
.by_name(name)
.map_err(|_| import_err(format!("the archive has no {name}")))?;
let mut body = String::new();
entry
.read_to_string(&mut body)
.map_err(|e| import_err(format!("{name} could not be read: {e}")))?;
Ok(body)
}
/// What an archive holds, without creating anything.
pub fn preview(path: &Path) -> Result<PortablePreview, String> {
let mut archive = open_archive(path)?;
let manifest_json = read_entry(&mut archive, MANIFEST_ENTRY)?;
let manifest: PortableManifest = serde_json::from_str(&manifest_json)
.map_err(|e| import_err(format!("its manifest is malformed: {e}")))?;
if manifest.format_version > FORMAT_VERSION {
return Err(crate::backend_error_with_detail(
"PROFILE_IMPORT_TOO_NEW",
manifest.format_version.to_string(),
));
}
let extra: serde_json::Value = serde_json::from_str(&manifest_json).unwrap_or_default();
let profile_json = read_entry(&mut archive, PROFILE_ENTRY)?;
let stored: serde_json::Value = serde_json::from_str(&profile_json)
.map_err(|e| import_err(format!("its profile is malformed: {e}")))?;
Ok(PortablePreview {
manifest,
proxy_name: extra["proxy_name"].as_str().map(str::to_string),
group_name: extra["group_name"].as_str().map(str::to_string),
tags: stored["tags"]
.as_array()
.map(|tags| {
tags
.iter()
.filter_map(|tag| tag.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
})
}
/// The profile an import should create: the archive's configuration under a
/// fresh id and the given name, with nothing carried over from the exporting
/// machine.
pub fn imported_profile(
archive_profile: &serde_json::Value,
name: &str,
) -> Result<BrowserProfile, String> {
let mut value = archive_profile.clone();
let object = value
.as_object_mut()
.ok_or_else(|| import_err("its profile is not an object"))?;
object.insert(
"id".to_string(),
serde_json::json!(uuid::Uuid::new_v4().to_string()),
);
object.insert("name".to_string(), serde_json::json!(name));
object.insert("process_id".to_string(), serde_json::Value::Null);
object.insert("last_launch".to_string(), serde_json::Value::Null);
object.insert("last_sync".to_string(), serde_json::Value::Null);
object.insert("encryption_salt".to_string(), serde_json::Value::Null);
object.insert("password_protected".to_string(), serde_json::json!(false));
object.insert("temporary".to_string(), serde_json::json!(false));
object.insert("sync_mode".to_string(), serde_json::json!("Disabled"));
object.insert(
"host_os".to_string(),
serde_json::json!(crate::profile::types::get_host_os()),
);
object.insert(
"created_at".to_string(),
serde_json::json!(crate::proxy_manager::now_secs()),
);
object.insert(
"updated_at".to_string(),
serde_json::json!(crate::proxy_manager::now_secs()),
);
serde_json::from_value(value).map_err(|e| import_err(format!("its profile is unusable: {e}")))
}
/// Extract the archive's `data/` into `data_dir`.
///
/// Every entry is checked to land inside `data_dir`: an archive is untrusted
/// input, and `../` in a name is how an extraction writes over a user's files.
pub fn extract_data(path: &Path, data_dir: &Path) -> Result<usize, String> {
let mut archive = open_archive(path)?;
fs::create_dir_all(data_dir)
.map_err(|e| import_err(format!("could not create the profile directory: {e}")))?;
let root = data_dir
.canonicalize()
.map_err(|e| import_err(format!("could not resolve the profile directory: {e}")))?;
let mut restored = 0;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|e| import_err(format!("could not read entry {index}: {e}")))?;
if entry.is_dir() {
continue;
}
let Some(name) = entry.enclosed_name() else {
return Err(crate::backend_error("PROFILE_IMPORT_UNSAFE_ARCHIVE"));
};
let name = name.to_string_lossy().replace('\\', "/");
let Some(relative) = name.strip_prefix(DATA_PREFIX) else {
continue;
};
if relative.is_empty() || is_excluded(relative) {
continue;
}
let destination = root.join(relative);
if !destination.starts_with(&root) {
return Err(crate::backend_error("PROFILE_IMPORT_UNSAFE_ARCHIVE"));
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.map_err(|e| import_err(format!("could not create {}: {e}", parent.display())))?;
}
let mut file = fs::File::create(&destination)
.map_err(|e| import_err(format!("could not write {}: {e}", destination.display())))?;
std::io::copy(&mut entry, &mut file)
.map_err(|e| import_err(format!("could not write {}: {e}", destination.display())))?;
restored += 1;
}
Ok(restored)
}
/// Pick a name no live profile carries: the archive's own when it is free,
/// otherwise `name (imported)`, `name (imported 2)`, and so on.
pub fn unique_imported_name(name: &str, taken: &[String]) -> String {
let normalized: Vec<String> = taken.iter().map(|n| n.trim().to_lowercase()).collect();
let is_taken = |candidate: &str| normalized.contains(&candidate.trim().to_lowercase());
if !is_taken(name) {
return name.to_string();
}
let mut attempt = 1u32;
loop {
let candidate = if attempt == 1 {
format!("{name} (imported)")
} else {
format!("{name} (imported {attempt})")
};
if !is_taken(&candidate) {
return candidate;
}
attempt += 1;
}
}
/// Write an export of `profile_id` to `destination`.
#[tauri::command]
pub async fn export_profile(
profile_id: String,
destination: String,
include_data: Option<bool>,
) -> Result<PortableManifest, String> {
let manager = crate::profile::ProfileManager::instance();
let profile = manager
.list_profiles()
.map_err(|e| err("could not read the profiles", e))?
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))?;
// An export reads the whole profile directory; a browser writing to it at
// the same time produces an archive of half-written databases.
if profile
.process_id
.is_some_and(crate::proxy_storage::is_process_running)
{
return Err(crate::backend_error("PROFILE_RUNNING"));
}
let data_dir = manager
.get_profiles_dir()
.join(profile.id.to_string())
.join("profile");
let proxy_name = profile.proxy_id.as_deref().and_then(|id| {
crate::proxy_manager::PROXY_MANAGER
.get_stored_proxies()
.into_iter()
.find(|proxy| proxy.id == id)
.map(|proxy| proxy.name)
});
let group_name = profile.group_id.as_deref().and_then(|id| {
let manager = crate::group_manager::GROUP_MANAGER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
manager
.get_all_groups()
.ok()?
.into_iter()
.find(|group| group.id == id)
.map(|group| group.name)
});
export_to(
&profile,
&data_dir,
Path::new(&destination),
include_data.unwrap_or(true),
proxy_name,
group_name,
)
}
/// What an archive holds, so the user can decide before anything is created.
#[tauri::command]
pub fn preview_profile_archive(path: String) -> Result<PortablePreview, String> {
preview(Path::new(&path))
}
/// Create a profile from an archive.
#[tauri::command]
pub async fn import_profile_archive(
path: String,
name: Option<String>,
) -> Result<BrowserProfile, String> {
let archive_path = Path::new(&path);
let details = preview(archive_path)?;
let manager = crate::profile::ProfileManager::instance();
let existing = manager
.list_profiles()
.map_err(|e| import_err(format!("could not read the profiles: {e}")))?;
let taken: Vec<String> = existing.iter().map(|p| p.name.clone()).collect();
let wanted = name
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty())
.unwrap_or(&details.manifest.profile_name);
if wanted.is_empty() {
return Err(crate::backend_error("NAME_CANNOT_BE_EMPTY"));
}
let mut archive = open_archive(archive_path)?;
let stored: serde_json::Value =
serde_json::from_str(&read_entry(&mut archive, PROFILE_ENTRY)?)
.map_err(|e| import_err(format!("its profile is malformed: {e}")))?;
drop(archive);
let profile = imported_profile(&stored, &unique_imported_name(wanted, &taken))?;
let data_dir = manager
.get_profiles_dir()
.join(profile.id.to_string())
.join("profile");
fs::create_dir_all(&data_dir)
.map_err(|e| import_err(format!("could not create the profile directory: {e}")))?;
if details.manifest.includes_data {
if let Err(e) = extract_data(archive_path, &data_dir) {
// Nothing half-imported is left behind: the profile was never saved, so
// removing its directory removes every trace of the attempt.
let _ = fs::remove_dir_all(data_dir.parent().unwrap_or(&data_dir));
return Err(e);
}
}
manager.save_profile(&profile).map_err(|e| {
let _ = fs::remove_dir_all(data_dir.parent().unwrap_or(&data_dir));
import_err(format!("could not save it: {e}"))
})?;
let _ = crate::events::emit("profiles-changed", serde_json::json!({}));
Ok(profile)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample() -> BrowserProfile {
BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "Shop".to_string(),
browser: "wayfern".to_string(),
version: "152.0.7977.64".to_string(),
proxy_id: Some("proxy-1".to_string()),
group_id: Some("group-1".to_string()),
tags: vec!["eu".to_string()],
process_id: Some(4242),
last_launch: Some(1000),
created_by_email: Some("someone@example.com".to_string()),
..BrowserProfile::default()
}
}
fn seed_data(dir: &Path) {
fs::create_dir_all(dir.join("Default/Network")).unwrap();
fs::write(dir.join("Default/Network/Cookies"), b"cookie-db").unwrap();
fs::write(dir.join("Local State"), b"{}").unwrap();
fs::create_dir_all(dir.join("Default/Cache")).unwrap();
fs::write(dir.join("Default/Cache/data_0"), vec![0u8; 4096]).unwrap();
fs::write(dir.join("wayfern-identity.json"), b"{}").unwrap();
fs::write(dir.join("SingletonLock"), b"lock").unwrap();
}
fn entries(path: &Path) -> Vec<String> {
let mut archive = open_archive(path).unwrap();
(0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect()
}
#[test]
fn an_export_carries_the_profile_and_its_data_but_not_the_machine() {
let root = TempDir::new().unwrap();
let data = root.path().join("profile");
seed_data(&data);
let archive = root.path().join("shop.donutprofile");
let profile = sample();
let manifest = export_to(
&profile,
&data,
&archive,
true,
Some("Residential EU".to_string()),
Some("Clients".to_string()),
)
.unwrap();
assert!(manifest.includes_data);
assert_eq!(manifest.profile_name, "Shop");
let names = entries(&archive);
assert!(names.contains(&"manifest.json".to_string()));
assert!(names.contains(&"profile.json".to_string()));
assert!(names.contains(&"data/Default/Network/Cookies".to_string()));
assert!(
!names.iter().any(|n| n.contains("Cache")),
"caches are rebuilt by the browser and must not travel: {names:?}"
);
assert!(
!names.iter().any(|n| n.contains("wayfern-identity.json")),
"the launcher rewrites its own documents every launch: {names:?}"
);
assert!(!names.iter().any(|n| n.contains("SingletonLock")));
let preview = preview(&archive).unwrap();
assert_eq!(preview.proxy_name.as_deref(), Some("Residential EU"));
assert_eq!(preview.group_name.as_deref(), Some("Clients"));
assert_eq!(preview.tags, vec!["eu".to_string()]);
}
#[test]
fn the_exported_configuration_drops_what_belongs_to_this_machine() {
let config = exportable_config(&sample());
for gone in [
"id",
"process_id",
"last_launch",
"proxy_id",
"group_id",
"created_by_email",
"encryption_salt",
] {
assert!(config.get(gone).is_none(), "{gone} must not travel");
}
assert_eq!(config["password_protected"], serde_json::json!(false));
assert_eq!(config["sync_mode"], serde_json::json!("Disabled"));
assert_eq!(config["version"], serde_json::json!("152.0.7977.64"));
}
#[test]
fn a_password_protected_profile_exports_its_configuration_and_says_why_not_its_data() {
let root = TempDir::new().unwrap();
let data = root.path().join("profile");
seed_data(&data);
let archive = root.path().join("locked.donutprofile");
let mut profile = sample();
profile.password_protected = true;
let manifest = export_to(&profile, &data, &archive, true, None, None).unwrap();
assert!(!manifest.includes_data);
assert!(manifest
.data_omitted_reason
.as_deref()
.unwrap()
.contains("password protected"));
assert!(!entries(&archive).iter().any(|n| n.starts_with("data/")));
}
#[test]
fn an_import_is_a_new_profile_that_owes_nothing_to_the_exporter() {
let source = sample();
let config = exportable_config(&source);
let imported = imported_profile(&config, "Shop (imported)").unwrap();
assert_ne!(imported.id, source.id);
assert_eq!(imported.name, "Shop (imported)");
assert_eq!(imported.version, source.version);
assert_eq!(imported.tags, source.tags);
assert_eq!(imported.process_id, None);
assert_eq!(imported.proxy_id, None, "a proxy id is local to a machine");
assert_eq!(imported.group_id, None);
assert!(!imported.password_protected);
assert!(!imported.temporary);
assert!(imported.created_at.is_some());
// Twice from one archive gives two profiles, not a conflict.
let again = imported_profile(&config, "Shop (imported)").unwrap();
assert_ne!(again.id, imported.id);
}
#[test]
fn extraction_restores_the_data_and_refuses_to_escape_the_profile_directory() {
let root = TempDir::new().unwrap();
let data = root.path().join("profile");
seed_data(&data);
let archive = root.path().join("shop.donutprofile");
export_to(&sample(), &data, &archive, true, None, None).unwrap();
let restored_dir = root.path().join("restored");
let restored = extract_data(&archive, &restored_dir).unwrap();
assert!(restored >= 2);
assert_eq!(
fs::read(restored_dir.join("Default/Network/Cookies")).unwrap(),
b"cookie-db"
);
assert!(!restored_dir.join("Default/Cache/data_0").exists());
// A hand-made archive with a traversing entry is refused outright.
let hostile = root.path().join("hostile.donutprofile");
{
let file = fs::File::create(&hostile).unwrap();
let mut writer = zip::ZipWriter::new(file);
let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
writer.start_file("manifest.json", options).unwrap();
writer.write_all(b"{}").unwrap();
writer.start_file("data/../../escaped", options).unwrap();
writer.write_all(b"nope").unwrap();
writer.finish().unwrap();
}
let target = root.path().join("target");
assert!(extract_data(&hostile, &target)
.unwrap_err()
.contains("PROFILE_IMPORT_UNSAFE_ARCHIVE"));
assert!(!root.path().join("escaped").exists());
}
#[test]
fn an_archive_from_a_newer_build_is_refused_by_name() {
let root = TempDir::new().unwrap();
let archive = root.path().join("future.donutprofile");
{
let file = fs::File::create(&archive).unwrap();
let mut writer = zip::ZipWriter::new(file);
let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
writer.start_file("manifest.json", options).unwrap();
writer
.write_all(
serde_json::json!({
"format_version": FORMAT_VERSION + 1,
"exported_by": "Donut Browser 99.0.0",
"exported_at": 1,
"profile_name": "Future",
"browser": "wayfern",
"version": "999",
"includes_data": false,
})
.to_string()
.as_bytes(),
)
.unwrap();
writer.finish().unwrap();
}
assert!(preview(&archive)
.unwrap_err()
.contains("PROFILE_IMPORT_TOO_NEW"));
}
#[test]
fn a_file_that_is_not_an_archive_is_a_coded_error_not_a_panic() {
let root = TempDir::new().unwrap();
let bogus = root.path().join("notes.txt");
fs::write(&bogus, b"just some text").unwrap();
assert!(preview(&bogus)
.unwrap_err()
.contains("PROFILE_IMPORT_FAILED"));
}
}
+917
View File
@@ -0,0 +1,917 @@
//! Recoverable delete for profiles.
//!
//! A deleted profile is moved to `<data root>/trash/<profile_id>/` instead of
//! being destroyed, so an accidental delete of a profile that carries logins
//! can be undone. Each entry holds:
//!
//! - `profile.json`: the full `BrowserProfile` at the moment of deletion.
//! - `manifest.json`: when it was trashed, when it expires, how big it is.
//! - `profile/`: the profile's own data directory, moved as is. Chromium's
//! cache-only directories are pruned first; the browser rebuilds them on
//! the next launch, so keeping them would only make the trash heavy.
//!
//! A password-protected profile is moved in its encrypted at-rest form and
//! stays protected while it sits here. Ephemeral profiles never land here;
//! their data lives in RAM and is gone the moment the browser exits.
//!
//! From the cloud's point of view a trashed profile is deleted: the sync
//! tombstone is written by the same path a permanent delete uses. Restoring
//! re-registers the profile under its original id and routes it through the
//! normal sync-enable path so it wins the stale tombstone.
use crate::profile::types::BrowserProfile;
use crate::profile::ProfileManager;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
pub const DEFAULT_RETENTION_DAYS: u32 = 30;
pub const MIN_RETENTION_DAYS: u32 = 1;
pub const MAX_RETENTION_DAYS: u32 = 365;
/// How often expired entries are swept while the app runs.
pub const PURGE_INTERVAL_SECS: u64 = 6 * 60 * 60;
const SECS_PER_DAY: u64 = 24 * 60 * 60;
const PROFILE_FILE: &str = "profile.json";
const MANIFEST_FILE: &str = "manifest.json";
const DATA_DIR: &str = "profile";
const METADATA_FILE: &str = "metadata.json";
const RESTORED_SUFFIX: &str = "(restored)";
/// Chromium directories that only ever hold caches, relative to the profile
/// data directory. Every one of them is recreated by the browser on demand.
const CACHE_DIRS: [&str; 8] = [
"Cache",
"Code Cache",
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnCache",
"Service Worker/CacheStorage",
"Service Worker/ScriptCache",
];
/// Serialises every trash mutation so a restore cannot interleave with a
/// purge of the same entry.
static TRASH_MUTATION: Mutex<()> = Mutex::new(());
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashManifest {
pub deleted_at: u64,
pub expires_at: u64,
pub size_bytes: u64,
pub original_name: String,
}
/// What the Trash page shows for one entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashedProfileSummary {
pub id: String,
pub name: String,
pub browser: String,
pub version: String,
pub deleted_at: u64,
pub expires_at: u64,
pub size_bytes: u64,
#[serde(default)]
pub group_id: Option<String>,
pub password_protected: bool,
}
pub fn trash_dir() -> PathBuf {
crate::app_dirs::data_dir().join("trash")
}
pub fn mutation_lock() -> MutexGuard<'static, ()> {
TRASH_MUTATION
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn clamp_retention_days(days: u32) -> u32 {
days.clamp(MIN_RETENTION_DAYS, MAX_RETENTION_DAYS)
}
/// The retention the user configured, already clamped to the allowed range.
pub fn configured_retention_days() -> u32 {
crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|settings| clamp_retention_days(settings.trash_retention_days))
.unwrap_or(DEFAULT_RETENTION_DAYS)
}
fn err_internal(e: impl std::fmt::Display) -> String {
crate::backend_error_with_detail("INTERNAL_ERROR", e)
}
fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
let json = serde_json::to_string_pretty(value).map_err(err_internal)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json).map_err(err_internal)?;
fs::rename(&tmp, path).map_err(err_internal)
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
let content = fs::read_to_string(path).map_err(err_internal)?;
serde_json::from_str(&content).map_err(err_internal)
}
/// Remove the cache-only directories from a profile data directory. Returns
/// the directories that were actually removed.
pub fn prune_cache_dirs(data_dir: &Path) -> Vec<PathBuf> {
let mut removed = Vec::new();
for relative in CACHE_DIRS {
let dir = data_dir.join(relative);
if !dir.is_dir() {
continue;
}
match fs::remove_dir_all(&dir) {
Ok(()) => removed.push(dir),
Err(e) => log::warn!("Could not prune cache dir {}: {e}", dir.display()),
}
}
removed
}
/// Total size of every regular file under `path`. Symlinks are not followed.
pub fn dir_size(path: &Path) -> u64 {
let Ok(entries) = fs::read_dir(path) else {
return 0;
};
entries
.flatten()
.map(|entry| {
let path = entry.path();
match fs::symlink_metadata(&path) {
Ok(meta) if meta.is_dir() => dir_size(&path),
Ok(meta) if meta.is_file() => meta.len(),
_ => 0,
}
})
.sum()
}
fn copy_dir_recursive(from: &Path, to: &Path) -> std::io::Result<()> {
fs::create_dir_all(to)?;
for entry in fs::read_dir(from)? {
let entry = entry?;
let source = entry.path();
let target = to.join(entry.file_name());
let meta = fs::symlink_metadata(&source)?;
if meta.is_dir() {
copy_dir_recursive(&source, &target)?;
} else if meta.is_file() {
fs::copy(&source, &target)?;
}
}
Ok(())
}
/// Move a directory: a rename when both sides share a volume, otherwise a
/// copy followed by removal of the source. A failed copy leaves the source
/// untouched and no half-written target behind.
pub fn move_dir(from: &Path, to: &Path) -> std::io::Result<()> {
if let Some(parent) = to.parent() {
fs::create_dir_all(parent)?;
}
match fs::rename(from, to) {
Ok(()) => Ok(()),
Err(rename_error) => {
log::info!(
"Rename of {} failed ({rename_error}); copying instead",
from.display()
);
if let Err(copy_error) = copy_dir_recursive(from, to) {
let _ = fs::remove_dir_all(to);
return Err(copy_error);
}
fs::remove_dir_all(from)
}
}
}
/// Move a profile's directory into the trash and record when it expires.
///
/// `profiles_dir/<id>/` becomes `trash_root/<id>/`; `metadata.json` is
/// replaced by `profile.json` (the struct handed in, with any process id
/// cleared) and `manifest.json` is added. An older trash entry under the same
/// id is dropped: ids survive a restore, so the profile being trashed now is
/// the newer copy.
pub fn trash_profile(
profiles_dir: &Path,
trash_root: &Path,
profile: &BrowserProfile,
retention_days: u32,
now: u64,
) -> Result<TrashManifest, String> {
let id = profile.id.to_string();
let source_dir = profiles_dir.join(&id);
let target_dir = trash_root.join(&id);
fs::create_dir_all(trash_root).map_err(err_internal)?;
if target_dir.exists() {
fs::remove_dir_all(&target_dir).map_err(err_internal)?;
}
if !profile.password_protected {
let removed = prune_cache_dirs(&source_dir.join(DATA_DIR));
if !removed.is_empty() {
log::info!(
"Pruned {} cache director{} from profile {id} before trashing",
removed.len(),
if removed.len() == 1 { "y" } else { "ies" }
);
}
}
if source_dir.exists() {
move_dir(&source_dir, &target_dir).map_err(err_internal)?;
} else {
fs::create_dir_all(&target_dir).map_err(err_internal)?;
}
let _ = fs::remove_file(target_dir.join(METADATA_FILE));
let mut stored = profile.clone();
stored.process_id = None;
write_json(&target_dir.join(PROFILE_FILE), &stored)?;
let manifest = TrashManifest {
deleted_at: now,
expires_at: now.saturating_add(u64::from(clamp_retention_days(retention_days)) * SECS_PER_DAY),
size_bytes: dir_size(&target_dir.join(DATA_DIR)),
original_name: profile.name.clone(),
};
write_json(&target_dir.join(MANIFEST_FILE), &manifest)?;
Ok(manifest)
}
/// Read one entry. `TRASH_ENTRY_NOT_FOUND` when there is no such entry.
pub fn read_entry(
trash_root: &Path,
profile_id: &str,
) -> Result<(BrowserProfile, TrashManifest), String> {
let entry_dir = trash_root.join(profile_id);
let profile_file = entry_dir.join(PROFILE_FILE);
let manifest_file = entry_dir.join(MANIFEST_FILE);
if !profile_file.is_file() || !manifest_file.is_file() {
return Err(crate::backend_error("TRASH_ENTRY_NOT_FOUND"));
}
Ok((read_json(&profile_file)?, read_json(&manifest_file)?))
}
/// Every readable entry, newest deletion first. Unreadable entries are
/// skipped with a warning rather than hiding the whole trash.
pub fn list_entries(trash_root: &Path) -> Vec<(BrowserProfile, TrashManifest)> {
let Ok(entries) = fs::read_dir(trash_root) else {
return Vec::new();
};
let mut listed: Vec<(BrowserProfile, TrashManifest)> = entries
.flatten()
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| {
let name = entry.file_name();
let id = name.to_string_lossy();
match read_entry(trash_root, &id) {
Ok(found) => Some(found),
Err(e) => {
log::warn!("Skipping unreadable trash entry {id}: {e}");
None
}
}
})
.collect();
listed.sort_by_key(|(_, manifest)| std::cmp::Reverse(manifest.deleted_at));
listed
}
pub fn summaries(trash_root: &Path) -> Vec<TrashedProfileSummary> {
list_entries(trash_root)
.into_iter()
.map(|(profile, manifest)| TrashedProfileSummary {
id: profile.id.to_string(),
name: profile.name,
browser: profile.browser,
version: profile.version,
deleted_at: manifest.deleted_at,
expires_at: manifest.expires_at,
size_bytes: manifest.size_bytes,
group_id: profile.group_id,
password_protected: profile.password_protected,
})
.collect()
}
fn normalized_name(name: &str) -> String {
name.trim().to_lowercase()
}
/// Pick a name that no live profile carries: the original when it is free,
/// otherwise `name (restored)`, `name (restored 2)`, and so on.
pub fn unique_restored_name(name: &str, taken: &HashSet<String>) -> String {
if !taken.contains(&normalized_name(name)) {
return name.to_string();
}
let mut attempt = 1u32;
loop {
let candidate = if attempt == 1 {
format!("{name} {RESTORED_SUFFIX}")
} else {
format!(
"{name} {} {attempt})",
RESTORED_SUFFIX.trim_end_matches(')')
)
};
if !taken.contains(&normalized_name(&candidate)) {
return candidate;
}
attempt += 1;
}
}
/// Move a trashed profile back under `profiles_dir` and return the profile as
/// it must be saved: same id, identity, proxy and tags; the group only when it
/// still exists; a fresh `updated_at` so it wins any stale sync tombstone.
///
/// `TRASH_RESTORE_CONFLICT` when a live profile already carries the id.
pub fn restore_profile(
profiles_dir: &Path,
trash_root: &Path,
profile_id: &str,
live_profiles: &[BrowserProfile],
group_exists: &dyn Fn(&str) -> bool,
now: u64,
) -> Result<BrowserProfile, String> {
let (mut profile, _manifest) = read_entry(trash_root, profile_id)?;
if live_profiles.iter().any(|live| live.id == profile.id) {
return Err(crate::backend_error("TRASH_RESTORE_CONFLICT"));
}
let target_dir = profiles_dir.join(profile_id);
if target_dir.exists() {
// Nothing registered lives here (a registered profile has metadata.json
// and would have been caught above), so this is leftover garbage.
log::warn!(
"Removing stale directory {} before restoring profile {profile_id}",
target_dir.display()
);
fs::remove_dir_all(&target_dir).map_err(err_internal)?;
}
let taken: HashSet<String> = live_profiles
.iter()
.map(|live| normalized_name(&live.name))
.collect();
profile.name = unique_restored_name(&profile.name, &taken);
if let Some(group_id) = profile.group_id.clone() {
if !group_exists(&group_id) {
profile.group_id = None;
}
}
profile.process_id = None;
profile.updated_at = Some(now);
let entry_dir = trash_root.join(profile_id);
move_dir(&entry_dir, &target_dir).map_err(err_internal)?;
let _ = fs::remove_file(target_dir.join(PROFILE_FILE));
let _ = fs::remove_file(target_dir.join(MANIFEST_FILE));
write_json(&target_dir.join(METADATA_FILE), &profile)?;
Ok(profile)
}
/// Destroy one entry for good. `TRASH_ENTRY_NOT_FOUND` when absent.
pub fn purge_entry(trash_root: &Path, profile_id: &str) -> Result<(), String> {
let entry_dir = trash_root.join(profile_id);
if !entry_dir.is_dir() {
return Err(crate::backend_error("TRASH_ENTRY_NOT_FOUND"));
}
fs::remove_dir_all(&entry_dir).map_err(err_internal)
}
/// Destroy every entry. Returns the ids that were removed.
pub fn purge_all(trash_root: &Path) -> Result<Vec<String>, String> {
let ids: Vec<String> = list_entries(trash_root)
.into_iter()
.map(|(profile, _)| profile.id.to_string())
.collect();
for id in &ids {
purge_entry(trash_root, id)?;
}
Ok(ids)
}
/// Destroy every entry whose expiry has passed. Returns the ids removed.
pub fn purge_expired(trash_root: &Path, now: u64) -> Vec<String> {
list_entries(trash_root)
.into_iter()
.filter(|(_, manifest)| manifest.expires_at <= now)
.filter_map(|(profile, _)| {
let id = profile.id.to_string();
match purge_entry(trash_root, &id) {
Ok(()) => Some(id),
Err(e) => {
log::warn!("Could not purge expired trash entry {id}: {e}");
None
}
}
})
.collect()
}
/// A profile whose browser process is alive on this machine cannot be
/// trashed: its data directory is in use. A stale process id (the browser
/// crashed) does not count, and a cross-OS profile can never be running here.
pub fn is_running_locally(profile: &BrowserProfile) -> bool {
profile
.process_id
.is_some_and(crate::proxy_storage::is_process_running)
&& !profile.is_cross_os()
}
fn command_error(e: Box<dyn std::error::Error>, context: &str) -> String {
let msg = e.to_string();
if msg.starts_with('{') {
msg
} else {
format!("{context}: {msg}")
}
}
/// Sweep expired entries now and again every `PURGE_INTERVAL_SECS`.
pub fn start_expiry_sweeper() {
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(PURGE_INTERVAL_SECS));
loop {
interval.tick().await;
let purged = ProfileManager::instance().purge_expired_trash();
if purged > 0 {
log::info!(
"Purged {purged} expired trash entr{}",
if purged == 1 { "y" } else { "ies" }
);
}
}
});
}
#[tauri::command]
pub fn list_trashed_profiles() -> Result<Vec<TrashedProfileSummary>, String> {
Ok(summaries(&trash_dir()))
}
#[tauri::command]
pub async fn restore_trashed_profile(
app_handle: tauri::AppHandle,
profile_id: String,
) -> Result<BrowserProfile, String> {
let manager = ProfileManager::instance();
let mut profile = manager
.restore_trashed_profile(&profile_id)
.map_err(|e| command_error(e, "Failed to restore profile"))?;
if profile.is_sync_enabled() {
// The cloud saw a delete (a tombstone was written when the profile was
// trashed). Re-enabling through the normal path clears that tombstone
// and queues the re-upload. When that path refuses (sync no longer
// configured, a cross-OS copy), sync is switched off on the restored
// profile so the next reconcile keeps the local copy instead of
// honouring the tombstone.
let mode = if profile.is_encrypted_sync() {
"Encrypted"
} else {
"Regular"
};
if let Err(e) =
crate::sync::set_profile_sync_mode(app_handle.clone(), profile_id.clone(), mode.to_string())
.await
{
log::warn!("Restored profile {profile_id} could not re-enable sync ({e}); leaving sync off");
profile.sync_mode = crate::profile::types::SyncMode::Disabled;
manager
.save_profile(&profile)
.map_err(|e| command_error(e, "Failed to save restored profile"))?;
let _ = crate::events::emit_empty("profiles-changed");
}
}
Ok(profile)
}
#[tauri::command]
pub fn purge_trashed_profile(profile_id: String) -> Result<(), String> {
ProfileManager::instance()
.purge_trashed_profile(&profile_id)
.map_err(|e| command_error(e, "Failed to delete trashed profile"))
}
#[tauri::command]
pub fn empty_trash() -> Result<usize, String> {
ProfileManager::instance()
.empty_trash()
.map_err(|e| command_error(e, "Failed to empty trash"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wayfern_manager::WayfernConfig;
use tempfile::TempDir;
const NOW: u64 = 1_700_000_000;
fn sample_profile(name: &str) -> BrowserProfile {
BrowserProfile {
id: uuid::Uuid::new_v4(),
name: name.to_string(),
browser: "wayfern".to_string(),
version: "150.0.7871.100".to_string(),
proxy_id: Some("proxy-1".to_string()),
group_id: Some("group-1".to_string()),
tags: vec!["shop".to_string(), "eu".to_string()],
release_type: "stable".to_string(),
wayfern_config: Some(WayfernConfig {
identity_id: Some("identity-42".to_string()),
identity_overrides: Some(r#"{"userAgent":"custom"}"#.to_string()),
location: Some(r#"{"timezone":"Europe/Berlin"}"#.to_string()),
..WayfernConfig::default()
}),
updated_at: Some(NOW - 1000),
..BrowserProfile::default()
}
}
/// Lay out `profiles/<id>/{metadata.json, profile/...}` the way the app does.
fn seed_profile(root: &Path, profile: &BrowserProfile, with_caches: bool) -> PathBuf {
let profiles_dir = root.join("profiles");
let uuid_dir = profiles_dir.join(profile.id.to_string());
let data_dir = uuid_dir.join("profile");
fs::create_dir_all(data_dir.join("Default")).unwrap();
fs::write(data_dir.join("Default").join("Cookies"), b"cookie-db").unwrap();
fs::write(data_dir.join("Local State"), b"{}").unwrap();
if with_caches {
for relative in CACHE_DIRS {
let dir = data_dir.join(relative);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("blob"), vec![0u8; 512]).unwrap();
}
}
fs::write(
uuid_dir.join("metadata.json"),
serde_json::to_string_pretty(profile).unwrap(),
)
.unwrap();
profiles_dir
}
fn group_exists(_: &str) -> bool {
true
}
#[test]
fn trash_and_restore_round_trip_keeps_identity_and_data() {
let root = TempDir::new().unwrap();
let profile = sample_profile("Shop Account");
let profiles_dir = seed_profile(root.path(), &profile, true);
let trash_root = root.path().join("trash");
let manifest = trash_profile(&profiles_dir, &trash_root, &profile, 30, NOW).unwrap();
assert_eq!(manifest.deleted_at, NOW);
assert_eq!(manifest.expires_at, NOW + 30 * SECS_PER_DAY);
assert_eq!(manifest.original_name, "Shop Account");
assert!(manifest.size_bytes > 0);
let uuid_dir = profiles_dir.join(profile.id.to_string());
assert!(!uuid_dir.exists(), "the live directory must be gone");
let entry_dir = trash_root.join(profile.id.to_string());
assert!(entry_dir.join("profile.json").is_file());
assert!(entry_dir.join("manifest.json").is_file());
assert!(!entry_dir.join("metadata.json").exists());
assert_eq!(
fs::read(entry_dir.join("profile").join("Default").join("Cookies")).unwrap(),
b"cookie-db"
);
for relative in CACHE_DIRS {
assert!(
!entry_dir.join("profile").join(relative).exists(),
"{relative} must be pruned before the move"
);
}
let listed = summaries(&trash_root);
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, profile.id.to_string());
assert_eq!(listed[0].name, "Shop Account");
assert_eq!(listed[0].group_id.as_deref(), Some("group-1"));
assert!(!listed[0].password_protected);
let restored = restore_profile(
&profiles_dir,
&trash_root,
&profile.id.to_string(),
&[],
&group_exists,
NOW + 60,
)
.unwrap();
assert_eq!(restored.id, profile.id);
assert_eq!(restored.name, "Shop Account");
assert_eq!(restored.proxy_id.as_deref(), Some("proxy-1"));
assert_eq!(restored.group_id.as_deref(), Some("group-1"));
assert_eq!(restored.tags, vec!["shop", "eu"]);
assert_eq!(restored.updated_at, Some(NOW + 60));
let config = restored.wayfern_config.as_ref().unwrap();
assert_eq!(config.identity_id.as_deref(), Some("identity-42"));
assert_eq!(
config.identity_overrides.as_deref(),
Some(r#"{"userAgent":"custom"}"#)
);
assert_eq!(
config.location.as_deref(),
Some(r#"{"timezone":"Europe/Berlin"}"#)
);
assert!(!entry_dir.exists(), "the trash entry must be gone");
assert!(uuid_dir.join("metadata.json").is_file());
assert!(!uuid_dir.join("profile.json").exists());
assert!(!uuid_dir.join("manifest.json").exists());
assert_eq!(
fs::read(uuid_dir.join("profile").join("Default").join("Cookies")).unwrap(),
b"cookie-db"
);
let on_disk: BrowserProfile =
serde_json::from_str(&fs::read_to_string(uuid_dir.join("metadata.json")).unwrap()).unwrap();
assert_eq!(on_disk.id, profile.id);
assert_eq!(on_disk.updated_at, Some(NOW + 60));
assert!(summaries(&trash_root).is_empty());
}
#[test]
fn restore_appends_suffix_when_a_live_profile_has_the_name() {
let root = TempDir::new().unwrap();
let profile = sample_profile("Shop Account");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
let mut twin = sample_profile("shop account");
twin.id = uuid::Uuid::new_v4();
let mut second_twin = sample_profile("Shop Account (restored)");
second_twin.id = uuid::Uuid::new_v4();
let restored = restore_profile(
&profiles_dir,
&trash_root,
&profile.id.to_string(),
&[twin, second_twin],
&group_exists,
NOW,
)
.unwrap();
assert_eq!(restored.name, "Shop Account (restored 2)");
assert_eq!(restored.id, profile.id);
}
#[test]
fn unique_restored_name_prefers_the_original() {
let taken: HashSet<String> = ["other".to_string()].into_iter().collect();
assert_eq!(unique_restored_name("Mine", &taken), "Mine");
let taken: HashSet<String> = ["mine".to_string()].into_iter().collect();
assert_eq!(unique_restored_name("Mine", &taken), "Mine (restored)");
}
#[test]
fn restore_refuses_when_a_live_profile_has_the_same_id() {
let root = TempDir::new().unwrap();
let profile = sample_profile("Shop Account");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
let err = restore_profile(
&profiles_dir,
&trash_root,
&profile.id.to_string(),
std::slice::from_ref(&profile),
&group_exists,
NOW,
)
.unwrap_err();
assert!(err.contains("TRASH_RESTORE_CONFLICT"), "{err}");
assert_eq!(summaries(&trash_root).len(), 1, "the entry must survive");
}
#[test]
fn restore_and_purge_of_a_missing_entry_report_not_found() {
let root = TempDir::new().unwrap();
let trash_root = root.path().join("trash");
let err = restore_profile(
&root.path().join("profiles"),
&trash_root,
"does-not-exist",
&[],
&group_exists,
NOW,
)
.unwrap_err();
assert!(err.contains("TRASH_ENTRY_NOT_FOUND"), "{err}");
let err = purge_entry(&trash_root, "does-not-exist").unwrap_err();
assert!(err.contains("TRASH_ENTRY_NOT_FOUND"), "{err}");
}
#[test]
fn restore_clears_the_group_when_it_no_longer_exists() {
let root = TempDir::new().unwrap();
let profile = sample_profile("Grouped");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
let restored = restore_profile(
&profiles_dir,
&trash_root,
&profile.id.to_string(),
&[],
&|_| false,
NOW,
)
.unwrap();
assert_eq!(restored.group_id, None);
assert_eq!(restored.proxy_id.as_deref(), Some("proxy-1"));
}
#[test]
fn password_protected_entry_is_moved_as_is() {
let root = TempDir::new().unwrap();
let mut profile = sample_profile("Vault");
profile.password_protected = true;
profile.encryption_salt = Some("salt".to_string());
let profiles_dir = seed_profile(root.path(), &profile, true);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
let entry_dir = trash_root.join(profile.id.to_string());
for relative in CACHE_DIRS {
assert!(
entry_dir.join("profile").join(relative).exists(),
"an encrypted tree is never pruned ({relative})"
);
}
assert!(summaries(&trash_root)[0].password_protected);
let restored = restore_profile(
&profiles_dir,
&trash_root,
&profile.id.to_string(),
&[],
&group_exists,
NOW,
)
.unwrap();
assert!(restored.password_protected);
assert_eq!(restored.encryption_salt.as_deref(), Some("salt"));
}
#[test]
fn expiry_purge_removes_only_expired_entries() {
let root = TempDir::new().unwrap();
let old = sample_profile("Old");
let fresh = sample_profile("Fresh");
let profiles_dir = seed_profile(root.path(), &old, false);
seed_profile(root.path(), &fresh, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &old, 1, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &fresh, 30, NOW).unwrap();
assert_eq!(summaries(&trash_root).len(), 2);
assert!(purge_expired(&trash_root, NOW + SECS_PER_DAY - 1).is_empty());
let purged = purge_expired(&trash_root, NOW + SECS_PER_DAY);
assert_eq!(purged, vec![old.id.to_string()]);
let remaining = summaries(&trash_root);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].id, fresh.id.to_string());
assert!(!trash_root.join(old.id.to_string()).exists());
}
#[test]
fn retention_is_clamped_to_the_allowed_range() {
assert_eq!(clamp_retention_days(0), MIN_RETENTION_DAYS);
assert_eq!(clamp_retention_days(30), 30);
assert_eq!(clamp_retention_days(10_000), MAX_RETENTION_DAYS);
let root = TempDir::new().unwrap();
let profile = sample_profile("Clamped");
let profiles_dir = seed_profile(root.path(), &profile, false);
let manifest =
trash_profile(&profiles_dir, &root.path().join("trash"), &profile, 0, NOW).unwrap();
assert_eq!(manifest.expires_at, NOW + SECS_PER_DAY);
}
#[test]
fn empty_trash_removes_every_entry() {
let root = TempDir::new().unwrap();
let first = sample_profile("First");
let second = sample_profile("Second");
let profiles_dir = seed_profile(root.path(), &first, false);
seed_profile(root.path(), &second, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &first, 7, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &second, 7, NOW + 1).unwrap();
let listed = summaries(&trash_root);
assert_eq!(listed[0].name, "Second", "newest deletion is listed first");
let mut purged = purge_all(&trash_root).unwrap();
purged.sort();
let mut expected = vec![first.id.to_string(), second.id.to_string()];
expected.sort();
assert_eq!(purged, expected);
assert!(summaries(&trash_root).is_empty());
}
#[test]
fn trashing_a_profile_again_replaces_the_older_entry() {
let root = TempDir::new().unwrap();
let profile = sample_profile("Twice");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
restore_profile(
&profiles_dir,
&trash_root,
&profile.id.to_string(),
&[],
&group_exists,
NOW,
)
.unwrap();
let uuid_dir = profiles_dir.join(profile.id.to_string());
fs::write(uuid_dir.join("profile").join("Local State"), b"newer").unwrap();
// Simulate a leftover entry that a crash left behind under the same id.
fs::create_dir_all(trash_root.join(profile.id.to_string())).unwrap();
fs::write(trash_root.join(profile.id.to_string()).join("stale"), b"x").unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW + 5).unwrap();
let entry_dir = trash_root.join(profile.id.to_string());
assert!(!entry_dir.join("stale").exists());
assert_eq!(
fs::read(entry_dir.join("profile").join("Local State")).unwrap(),
b"newer"
);
assert_eq!(summaries(&trash_root).len(), 1);
}
#[test]
fn unreadable_entries_are_skipped_not_fatal() {
let root = TempDir::new().unwrap();
let profile = sample_profile("Good");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
let broken = trash_root.join("broken-entry");
fs::create_dir_all(&broken).unwrap();
fs::write(broken.join("profile.json"), b"not json").unwrap();
fs::write(broken.join("manifest.json"), b"{}").unwrap();
let listed = summaries(&trash_root);
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "Good");
}
#[test]
fn move_dir_copies_when_a_rename_is_impossible() {
let root = TempDir::new().unwrap();
let from = root.path().join("from");
fs::create_dir_all(from.join("nested")).unwrap();
fs::write(from.join("nested").join("file"), b"payload").unwrap();
let to = root.path().join("to");
copy_dir_recursive(&from, &to).unwrap();
assert_eq!(
fs::read(to.join("nested").join("file")).unwrap(),
b"payload"
);
assert_eq!(dir_size(&to), 7);
move_dir(&from, &root.path().join("moved")).unwrap();
assert!(!from.exists());
assert_eq!(
fs::read(root.path().join("moved").join("nested").join("file")).unwrap(),
b"payload"
);
}
#[test]
fn running_check_uses_a_live_process() {
let mut profile = sample_profile("Running");
profile.process_id = Some(std::process::id());
assert!(is_running_locally(&profile));
// A cross-OS profile can never be running on this machine.
profile.host_os = Some(if cfg!(target_os = "macos") {
"linux".to_string()
} else {
"macos".to_string()
});
assert!(!is_running_locally(&profile));
let mut idle = sample_profile("Idle");
idle.process_id = None;
assert!(!is_running_locally(&idle));
}
}
+7
View File
@@ -58,6 +58,13 @@ pub struct BrowserProfile {
pub host_os: Option<String>, // OS where profile was created ("macos", "windows", "linux")
#[serde(default)]
pub ephemeral: bool,
/// A profile that exists for one automation run. REST and MCP create it,
/// the browser stopping destroys it, and a startup sweep destroys any that
/// outlived a crash. Always ephemeral as well, so nothing it browses ever
/// reaches real disk. Never trashed: a disposable profile has nothing to
/// restore, and keeping one would defeat the point of asking for it.
#[serde(default)]
pub temporary: bool,
#[serde(default)]
pub extension_group_id: Option<String>,
#[serde(default)]
+10 -11
View File
@@ -1,17 +1,16 @@
//! Key material for profile import.
//!
//! Wayfern deliberately does not use the OS keyring. Every `os_crypt_async`
//! key provider is patched to read (or mint) `<user-data-dir>/os_crypt_key`
//! instead, so a profile directory is self-contained and portable. See
//! `wayfern/patches/extra/fingerprint/components-os_crypt-async-browser-*`.
//! Wayfern keeps os_crypt key material in `<user-data-dir>/os_crypt_key`
//! rather than the OS keyring, so a profile directory is self-contained and
//! portable.
//!
//! That portability is exactly why an imported Chrome profile carries nothing:
//! its secrets are sealed with a key held in the macOS Keychain / Windows DPAPI
//! / the Freedesktop secret service, and Wayfern never looks there. Import has
//! to open the source's lock and re-seal everything with Wayfern's.
//!
//! The on-disk format is per-platform and NOT interchangeable, matching the
//! provider that owns each tag in the patched Chromium 151 tree:
//! The on-disk format is per-platform and NOT interchangeable. The tag in each
//! record selects the derivation:
//!
//! | Host | `os_crypt_key` | Derivation | Cipher | Tag |
//! |---------|---------------------|-------------------------------------|--------------|-------|
@@ -248,10 +247,10 @@ impl TargetKey {
/// Read the existing `os_crypt_key`, or mint and persist one.
///
/// Writing eagerly at import time — rather than letting the first launch do
/// it — is deliberate. The mac and Linux patches have no `else` branch when
/// the write fails, so the browser would run on an in-memory key that dies
/// with the process and orphans everything it wrote. Failing here instead
/// turns that silent data loss into a visible import error.
/// it — is deliberate: a key the browser cannot persist would live only in
/// memory, die with the process, and orphan everything written with it.
/// Failing here instead turns that silent data loss into a visible import
/// error.
pub fn ensure(user_data_dir: &Path) -> Result<Self, String> {
let key_file = user_data_dir.join(KEY_FILE_NAME);
@@ -476,7 +475,7 @@ mod tests {
#[cfg(not(target_os = "windows"))]
{
// Wayfern writes base64(16 random bytes) = 24 ASCII chars.
// The non-Windows key file is base64(16 random bytes) = 24 ASCII chars.
assert_eq!(contents.len(), 24);
let text = String::from_utf8(contents).expect("ascii");
assert!(
+2
View File
@@ -983,6 +983,7 @@ impl ProfileImporter {
last_sync: None,
host_os: None,
ephemeral: false,
temporary: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
@@ -1063,6 +1064,7 @@ impl ProfileImporter {
last_sync: None,
host_os: Some(get_host_os()),
ephemeral: false,
temporary: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
+442
View File
@@ -0,0 +1,442 @@
//! Handing a fleet of profiles one proxy each.
//!
//! Fifty profiles and fifty residential proxies is fifty dialogs by hand. This
//! pairs them positionally instead — profile 1 to proxy 1, profile 2 to proxy
//! 2 — and it never wraps around: when the two lists differ in length the
//! remainder is reported rather than reused, because silently giving two
//! profiles the same exit is the one outcome a fleet owner is buying separate
//! proxies to avoid.
//!
//! The pairing rule lives here as one pure function so the dialog's preview,
//! the counts it shows, and the assignment that is finally applied all come
//! from the same code. The apply step takes explicit pairs, so a caller that
//! wants a different arrangement — REST, MCP, or a user who ticked boxes by
//! hand — is not forced through the default.
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use utoipa::ToSchema;
/// One profile and the proxy it should end up on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ProxyPair {
pub profile_id: String,
pub proxy_id: String,
}
/// A profile as the pairing rule sees it.
#[derive(Debug, Clone)]
pub struct ProfileCandidate {
pub id: String,
/// Its browser is alive on this machine, so its proxy cannot be changed.
pub running: bool,
}
/// What a distribution would do, before anything is written.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DistributionPlan {
/// The assignments, in the order the profiles were given.
pub pairs: Vec<ProxyPair>,
/// Chosen profiles that no proxy was left for.
pub unpaired_profile_ids: Vec<String>,
/// Chosen proxies that no profile was left for.
pub unused_proxy_ids: Vec<String>,
/// Chosen profiles refused because their browser is running.
pub running_profile_ids: Vec<String>,
/// Chosen proxies withheld because a profile outside this distribution
/// already uses them and sharing was not allowed.
pub shared_proxy_ids: Vec<String>,
}
/// Pair profiles to proxies one to one.
///
/// `assigned_elsewhere` is the set of proxies held by profiles that are not
/// part of this distribution. With `allow_sharing` off those proxies are taken
/// out of the pool, so a run cannot quietly put a second profile behind an exit
/// that is already in use. A proxy listed twice by the caller is the same
/// hazard and is deduplicated the same way.
pub fn plan(
profiles: &[ProfileCandidate],
proxy_ids: &[String],
allow_sharing: bool,
assigned_elsewhere: &HashSet<String>,
) -> DistributionPlan {
let mut plan = DistributionPlan::default();
let mut eligible = Vec::with_capacity(profiles.len());
for profile in profiles {
if profile.running {
plan.running_profile_ids.push(profile.id.clone());
} else {
eligible.push(profile.id.clone());
}
}
let mut pool: Vec<String> = Vec::with_capacity(proxy_ids.len());
let mut seen: HashSet<&str> = HashSet::new();
for proxy_id in proxy_ids {
if !seen.insert(proxy_id.as_str()) {
// The same proxy twice in one list is sharing spelled differently.
if !allow_sharing {
plan.shared_proxy_ids.push(proxy_id.clone());
continue;
}
}
if !allow_sharing && assigned_elsewhere.contains(proxy_id) {
plan.shared_proxy_ids.push(proxy_id.clone());
continue;
}
pool.push(proxy_id.clone());
}
let paired = eligible.len().min(pool.len());
for index in 0..paired {
plan.pairs.push(ProxyPair {
profile_id: eligible[index].clone(),
proxy_id: pool[index].clone(),
});
}
plan.unpaired_profile_ids = eligible[paired..].to_vec();
plan.unused_proxy_ids = pool[paired..].to_vec();
plan
}
/// What happened to one profile in an apply.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ProxyAssignmentResult {
pub profile_id: String,
pub proxy_id: String,
pub ok: bool,
/// A `{"code": ...}` payload when `ok` is false, otherwise null.
pub error: Option<String>,
}
fn failed(pair: &ProxyPair, code: &str) -> ProxyAssignmentResult {
ProxyAssignmentResult {
profile_id: pair.profile_id.clone(),
proxy_id: pair.proxy_id.clone(),
ok: false,
error: Some(serde_json::json!({ "code": code }).to_string()),
}
}
/// Apply explicit pairs, one profile at a time, and report each outcome.
///
/// A profile that cannot be moved never stops the rest: fifty assignments in
/// which the one running profile fails is a useful answer, and an all-or-
/// nothing abort halfway through a fleet is not.
pub async fn apply_pairs(
app_handle: tauri::AppHandle,
pairs: &[ProxyPair],
) -> Vec<ProxyAssignmentResult> {
let manager = crate::profile::ProfileManager::instance();
let known_proxies: HashSet<String> = crate::proxy_manager::PROXY_MANAGER
.get_stored_proxies()
.into_iter()
.map(|proxy| proxy.id)
.collect();
let mut results = Vec::with_capacity(pairs.len());
let mut already_paired: HashSet<&str> = HashSet::new();
for pair in pairs {
if !already_paired.insert(pair.profile_id.as_str()) {
// Two proxies for one profile is not an assignment, it is a mistake in
// the request, and quietly applying the last one hides it.
results.push(failed(pair, "PROFILE_PAIRED_TWICE"));
continue;
}
if !known_proxies.contains(&pair.proxy_id) {
results.push(failed(pair, "PROXY_NOT_FOUND"));
continue;
}
// Re-read on every pair: an earlier assignment in this same batch, or a
// browser someone started while the dialog was open, has to be visible.
let profile = match manager.list_profiles() {
Ok(profiles) => profiles
.into_iter()
.find(|profile| profile.id.to_string() == pair.profile_id),
Err(e) => {
log::warn!("Could not list profiles while distributing proxies: {e}");
None
}
};
let Some(profile) = profile else {
results.push(failed(pair, "PROFILE_NOT_FOUND"));
continue;
};
if crate::profile::trash::is_running_locally(&profile) {
results.push(failed(pair, "PROFILE_RUNNING"));
continue;
}
match manager
.update_profile_proxy(
app_handle.clone(),
&pair.profile_id,
Some(pair.proxy_id.clone()),
)
.await
{
Ok(_) => results.push(ProxyAssignmentResult {
profile_id: pair.profile_id.clone(),
proxy_id: pair.proxy_id.clone(),
ok: true,
error: None,
}),
Err(e) => results.push(ProxyAssignmentResult {
profile_id: pair.profile_id.clone(),
proxy_id: pair.proxy_id.clone(),
ok: false,
error: Some(e.to_string()),
}),
}
}
results
}
/// Build the pairing rule's view of the world from what is on disk.
fn candidates(
profile_ids: &[String],
) -> Result<(Vec<ProfileCandidate>, HashSet<String>), Box<dyn std::error::Error>> {
let profiles = crate::profile::ProfileManager::instance().list_profiles()?;
let chosen: HashSet<&str> = profile_ids.iter().map(String::as_str).collect();
let assigned_elsewhere = profiles
.iter()
.filter(|profile| !chosen.contains(profile.id.to_string().as_str()))
.filter_map(|profile| profile.proxy_id.clone())
.collect();
let ordered = profile_ids
.iter()
.map(|id| {
let found = profiles.iter().find(|p| p.id.to_string() == *id);
ProfileCandidate {
id: id.clone(),
// A profile that is not on disk cannot be launched either, so it is
// simply never paired; the plan reports it as unpaired.
running: found.is_none_or(crate::profile::trash::is_running_locally),
}
})
.collect();
Ok((ordered, assigned_elsewhere))
}
/// Tauri command: what would happen, without touching anything.
#[tauri::command]
pub async fn plan_proxy_distribution(
profile_ids: Vec<String>,
proxy_ids: Vec<String>,
allow_sharing: bool,
) -> Result<DistributionPlan, String> {
let (profiles, assigned_elsewhere) = candidates(&profile_ids).map_err(|e| e.to_string())?;
Ok(plan(
&profiles,
&proxy_ids,
allow_sharing,
&assigned_elsewhere,
))
}
/// Tauri command: apply the pairs and report every profile's outcome.
#[tauri::command]
pub async fn distribute_proxies_to_profiles(
app_handle: tauri::AppHandle,
pairs: Vec<ProxyPair>,
) -> Result<Vec<ProxyAssignmentResult>, String> {
Ok(apply_pairs(app_handle, &pairs).await)
}
#[cfg(test)]
mod tests {
use super::*;
fn free(id: &str) -> ProfileCandidate {
ProfileCandidate {
id: id.to_string(),
running: false,
}
}
fn running(id: &str) -> ProfileCandidate {
ProfileCandidate {
id: id.to_string(),
running: true,
}
}
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|v| v.to_string()).collect()
}
fn assigned(values: &[&str]) -> HashSet<String> {
values.iter().map(|v| v.to_string()).collect()
}
#[test]
fn equal_lists_pair_one_to_one_in_order() {
let plan = plan(
&[free("p1"), free("p2"), free("p3")],
&ids(&["x1", "x2", "x3"]),
false,
&HashSet::new(),
);
assert_eq!(
plan.pairs,
vec![
ProxyPair {
profile_id: "p1".into(),
proxy_id: "x1".into()
},
ProxyPair {
profile_id: "p2".into(),
proxy_id: "x2".into()
},
ProxyPair {
profile_id: "p3".into(),
proxy_id: "x3".into()
},
]
);
assert!(plan.unpaired_profile_ids.is_empty());
assert!(plan.unused_proxy_ids.is_empty());
}
#[test]
fn more_profiles_than_proxies_leaves_the_remainder_alone() {
// The failure this guards: wrapping around, which would put p3 and p4 on
// the same exits as p1 and p2 without anyone asking for it.
let plan = plan(
&[free("p1"), free("p2"), free("p3"), free("p4")],
&ids(&["x1", "x2"]),
false,
&HashSet::new(),
);
assert_eq!(plan.pairs.len(), 2);
assert_eq!(plan.unpaired_profile_ids, ids(&["p3", "p4"]));
assert!(plan.unused_proxy_ids.is_empty());
}
#[test]
fn more_proxies_than_profiles_reports_the_leftovers() {
let plan = plan(
&[free("p1")],
&ids(&["x1", "x2", "x3"]),
false,
&HashSet::new(),
);
assert_eq!(plan.pairs.len(), 1);
assert_eq!(plan.unused_proxy_ids, ids(&["x2", "x3"]));
assert!(plan.unpaired_profile_ids.is_empty());
}
#[test]
fn a_proxy_another_profile_holds_is_withheld_until_sharing_is_allowed() {
let profiles = [free("p1"), free("p2")];
let proxies = ids(&["x1", "x2"]);
let elsewhere = assigned(&["x1"]);
let strict = plan(&profiles, &proxies, false, &elsewhere);
assert_eq!(strict.shared_proxy_ids, ids(&["x1"]));
assert_eq!(
strict.pairs,
vec![ProxyPair {
profile_id: "p1".into(),
proxy_id: "x2".into()
}]
);
assert_eq!(strict.unpaired_profile_ids, ids(&["p2"]));
let permissive = plan(&profiles, &proxies, true, &elsewhere);
assert!(permissive.shared_proxy_ids.is_empty());
assert_eq!(permissive.pairs.len(), 2);
assert_eq!(permissive.pairs[0].proxy_id, "x1");
}
#[test]
fn the_same_proxy_listed_twice_is_sharing_too() {
let strict = plan(
&[free("p1"), free("p2")],
&ids(&["x1", "x1"]),
false,
&HashSet::new(),
);
assert_eq!(strict.pairs.len(), 1);
assert_eq!(strict.shared_proxy_ids, ids(&["x1"]));
assert_eq!(strict.unpaired_profile_ids, ids(&["p2"]));
let permissive = plan(
&[free("p1"), free("p2")],
&ids(&["x1", "x1"]),
true,
&HashSet::new(),
);
assert_eq!(permissive.pairs.len(), 2);
assert_eq!(permissive.pairs[1].proxy_id, "x1");
}
#[test]
fn a_running_profile_is_named_and_never_paired() {
let plan = plan(
&[free("p1"), running("p2"), free("p3")],
&ids(&["x1", "x2"]),
false,
&HashSet::new(),
);
assert_eq!(plan.running_profile_ids, ids(&["p2"]));
assert_eq!(
plan.pairs,
vec![
ProxyPair {
profile_id: "p1".into(),
proxy_id: "x1".into()
},
// p3 takes the second proxy: the running profile is skipped, it does
// not consume a proxy and leave a hole behind it.
ProxyPair {
profile_id: "p3".into(),
proxy_id: "x2".into()
},
]
);
assert!(plan.unpaired_profile_ids.is_empty());
}
#[test]
fn every_profile_running_pairs_nothing_and_frees_every_proxy() {
let plan = plan(
&[running("p1"), running("p2")],
&ids(&["x1", "x2"]),
false,
&HashSet::new(),
);
assert!(plan.pairs.is_empty());
assert_eq!(plan.running_profile_ids, ids(&["p1", "p2"]));
assert_eq!(plan.unused_proxy_ids, ids(&["x1", "x2"]));
}
#[test]
fn a_profiles_own_proxy_is_not_treated_as_someone_elses() {
// p1 already sits on x1. Because p1 is part of this distribution, x1 is
// not "assigned elsewhere", so it stays in the pool and the run can
// reshuffle it rather than refusing to touch it.
let plan = plan(
&[free("p1"), free("p2")],
&ids(&["x1", "x2"]),
false,
&HashSet::new(),
);
assert!(plan.shared_proxy_ids.is_empty());
assert_eq!(plan.pairs.len(), 2);
}
#[test]
fn nothing_chosen_produces_an_empty_plan() {
let plan = plan(&[], &[], false, &HashSet::new());
assert_eq!(plan, DistributionPlan::default());
}
}
+514 -7
View File
@@ -1,6 +1,7 @@
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
@@ -93,6 +94,76 @@ pub struct ProxyCheckResult {
pub country_code: Option<String>,
pub timestamp: u64,
pub is_valid: bool,
/// The exit's ISP or registered organisation, read from the local MaxMind
/// databases. `None` means the databases carry none, never "no ISP".
#[serde(default)]
pub isp: Option<String>,
/// The exit's own timezone, the value a fingerprint is matched against.
#[serde(default)]
pub timezone: Option<String>,
/// Whether the proxy carries UDP, which decides whether WebRTC can be
/// routed through it at all. Receipts written before this existed
/// deserialize as `Unknown`, which is the truth about them.
#[serde(default)]
pub udp: crate::proxy_udp::UdpSupport,
/// How long the whole check took, end to end.
#[serde(default)]
pub latency_ms: Option<u64>,
}
/// One line of a proxy's check log. Deliberately smaller than
/// `ProxyCheckResult`: this is a trail, not a cache, so it keeps what a user
/// reads down a list and nothing that would make the file grow without bound.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProxyCheckHistoryEntry {
pub timestamp: u64,
pub ok: bool,
#[serde(default)]
pub ip: Option<String>,
#[serde(default)]
pub country: Option<String>,
#[serde(default)]
pub country_code: Option<String>,
#[serde(default)]
pub isp: Option<String>,
#[serde(default)]
pub udp: crate::proxy_udp::UdpSupport,
#[serde(default)]
pub latency_ms: Option<u64>,
}
/// How many checks a proxy remembers. Old enough entries stop being evidence
/// and the file has to stay small enough to read on every popover open.
pub const PROXY_CHECK_HISTORY_LIMIT: usize = 50;
#[derive(Debug, Default, Serialize, Deserialize)]
struct ProxyCheckHistory {
#[serde(default)]
entries: Vec<ProxyCheckHistoryEntry>,
}
/// Prepend `entry` and drop anything past the limit. Newest first is both the
/// order the list is read in and the order that makes the cap mean "the last
/// 50 checks" rather than "the first 50".
fn push_history_entry(entries: &mut Vec<ProxyCheckHistoryEntry>, entry: ProxyCheckHistoryEntry) {
entries.insert(0, entry);
entries.truncate(PROXY_CHECK_HISTORY_LIMIT);
}
#[derive(Serialize, Deserialize)]
struct CachedProxyCheck {
settings_hash: [u8; 32],
result: ProxyCheckResult,
}
impl CachedProxyCheck {
fn settings_hash(settings: &ProxySettings) -> Result<[u8; 32], serde_json::Error> {
Ok(Sha256::digest(serde_json::to_vec(settings)?).into())
}
fn for_settings(self, settings: &ProxySettings) -> Option<ProxyCheckResult> {
(self.settings_hash == Self::settings_hash(settings).ok()?).then_some(self.result)
}
}
pub const CLOUD_PROXY_ID: &str = "cloud-included-proxy";
@@ -303,17 +374,25 @@ impl ProxyManager {
Err(_) => return None,
};
serde_json::from_str::<ProxyCheckResult>(&content).ok()
let settings = self.get_proxy_settings_by_id(proxy_id)?;
serde_json::from_str::<CachedProxyCheck>(&content)
.ok()?
.for_settings(&settings)
}
// Save proxy check result to cache
fn save_proxy_check_cache(
&self,
proxy_id: &str,
settings: &ProxySettings,
result: &ProxyCheckResult,
) -> Result<(), Box<dyn std::error::Error>> {
let cache_file = self.get_proxy_check_cache_file(proxy_id)?;
let content = serde_json::to_string_pretty(result)?;
let cache = CachedProxyCheck {
settings_hash: CachedProxyCheck::settings_hash(settings)?,
result: result.clone(),
};
let content = serde_json::to_string_pretty(&cache)?;
crate::app_dirs::write_owner_only(&cache_file, content.as_bytes())?;
Ok(())
}
@@ -450,9 +529,72 @@ impl ProxyManager {
if proxy_file.exists() {
fs::remove_file(proxy_file)?;
}
let history_file = self.get_proxy_history_file_path(proxy_id);
if history_file.exists() {
// The trail is about a proxy that no longer exists, and it names the
// addresses that proxy exited from. It goes with the config.
fs::remove_file(history_file)?;
}
Ok(())
}
/// The check trail sits in a `history` folder beside the proxy configs
/// rather than next to them: `load_stored_proxies` reads every `*.json` in
/// the proxies directory and warns about anything that is not a proxy, so a
/// sibling file would log a parse failure per proxy on every start.
fn get_proxy_history_dir(&self) -> PathBuf {
self.get_proxies_dir().join("history")
}
fn get_proxy_history_file_path(&self, proxy_id: &str) -> PathBuf {
self
.get_proxy_history_dir()
.join(format!("{proxy_id}.json"))
}
/// Every remembered check for a proxy, newest first.
pub fn get_proxy_check_history(&self, proxy_id: &str) -> Vec<ProxyCheckHistoryEntry> {
let path = self.get_proxy_history_file_path(proxy_id);
let Ok(content) = fs::read_to_string(&path) else {
return Vec::new();
};
match serde_json::from_str::<ProxyCheckHistory>(&content) {
Ok(mut history) => {
history.entries.truncate(PROXY_CHECK_HISTORY_LIMIT);
history.entries
}
Err(e) => {
log::warn!("Failed to parse proxy check history {path:?}: {e}");
Vec::new()
}
}
}
/// Append one check to a proxy's trail.
fn record_proxy_check(&self, proxy_id: &str, entry: ProxyCheckHistoryEntry) {
let mut entries = self.get_proxy_check_history(proxy_id);
push_history_entry(&mut entries, entry);
let dir = self.get_proxy_history_dir();
if let Err(e) = fs::create_dir_all(&dir) {
log::warn!("Failed to create the proxy check history directory: {e}");
return;
}
match serde_json::to_string_pretty(&ProxyCheckHistory { entries }) {
Ok(content) => {
// Owner-only: the trail records which addresses this machine exits
// from, which is exactly what the proxy exists to keep private.
if let Err(e) = crate::app_dirs::write_owner_only(
&self.get_proxy_history_file_path(proxy_id),
content.as_bytes(),
) {
log::warn!("Failed to write the proxy check history: {e}");
}
}
Err(e) => log::warn!("Failed to serialize the proxy check history: {e}"),
}
}
fn normalize_proxy_settings(mut proxy_settings: ProxySettings) -> Result<ProxySettings, String> {
if !proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
proxy_settings.vless_uri = None;
@@ -1159,12 +1301,58 @@ impl ProxyManager {
/// the exit rather than on this machine. Resolving locally would leak the
/// real DNS and, behind a split-horizon resolver, can reach a different host
/// than the browser would.
///
/// `httpstls` becomes `https://` because reqwest has no such scheme; both
/// spellings mean TLS to the proxy followed by CONNECT, so the probe still
/// crosses the same encrypted hop the browser will.
pub fn build_probe_proxy_url(proxy_settings: &ProxySettings) -> String {
let url = Self::build_proxy_url(proxy_settings);
if proxy_settings.proxy_type.eq_ignore_ascii_case("socks5") {
return url.replacen("socks5://", "socks5h://", 1);
}
url
crate::proxy_storage::reqwest_upstream_url(&url)
}
/// Prove the TLS hop to an `httpstls` proxy can actually be established, so
/// a certificate that does not verify is reported as exactly that.
///
/// Returns the coded error the frontend translates. There is intentionally no
/// "connect anyway" path: verification is the property that makes this proxy
/// type resistant to an active man-in-the-middle rather than only to a
/// passive one, so a failure here is fatal by design.
async fn verify_upstream_tls(proxy_settings: &ProxySettings) -> Result<(), String> {
let host = proxy_settings.host.clone();
let port = proxy_settings.port;
let addr = format!("{host}:{port}");
let attempt = async {
let tcp = tokio::net::TcpStream::connect((host.as_str(), port))
.await
.map_err(|e| e.to_string())?;
let connector = tokio_native_tls::TlsConnector::from(
native_tls::TlsConnector::new().map_err(|e| e.to_string())?,
);
connector
.connect(host.as_str(), tcp)
.await
.map(|_| ())
.map_err(|e| e.to_string())
};
let detail = match tokio::time::timeout(std::time::Duration::from_secs(15), attempt).await {
Ok(Ok(())) => return Ok(()),
Ok(Err(detail)) => detail,
Err(_) => "timed out".to_string(),
};
log::warn!("TLS handshake with upstream proxy {addr} failed: {detail}");
Err(
serde_json::json!({
"code": "PROXY_TLS_HANDSHAKE_FAILED",
"params": { "proxy": addr }
})
.to_string(),
)
}
// Check if a proxy is valid by routing through a temporary donut-proxy process.
@@ -1190,6 +1378,29 @@ impl ProxyManager {
proxy_settings.clone()
};
let upstream_url = Self::build_proxy_url(&effective_proxy_settings);
let started = std::time::Instant::now();
// Whether the proxy carries UDP is asked of the same endpoint the browser
// dials. Spawned rather than awaited here so it runs alongside the exit
// lookup: a check should not take twice as long to answer twice as much,
// and for a VLESS proxy the probe has to reach the local worker while it
// is still up.
let probe_settings = effective_proxy_settings.clone();
let udp_probe = tauri::async_runtime::spawn(async move {
crate::proxy_udp::probe_udp_support(&probe_settings).await
});
// The dominant failure for a TLS-wrapped hop is a certificate that will not
// verify: the provider publishes a bare IP, or serves a self-signed cert.
// Through the worker that surfaces as a generic "could not connect", which
// sends users hunting for the wrong problem. One handshake, on this type
// only, so no existing proxy type can regress.
if effective_proxy_settings
.proxy_type
.eq_ignore_ascii_case("httpstls")
{
Self::verify_upstream_tls(&effective_proxy_settings).await?;
}
// Try process-based check first (identical to browser launch path).
// If the proxy worker fails to start (e.g. Gatekeeper, antivirus, signing
@@ -1235,7 +1446,24 @@ impl ProxyManager {
"Proxy worker failed to start ({}), falling back to direct check",
err_msg
);
ip_utils::fetch_public_ip(Some(&upstream_url)).await
// reqwest cannot parse Donut's own `httpstls` scheme; without the
// rewrite every fallback check on that type dies as "Invalid proxy"
// rather than telling the user anything true. Deliberately not
// `build_probe_proxy_url` here: that would also flip existing SOCKS5
// fallbacks to `socks5h`, an unrelated behaviour change.
let fallback_url = crate::proxy_storage::reqwest_upstream_url(&upstream_url);
// Only when reqwest can genuinely route through it. For `ss`,
// `vless`, or any scheme it does not know, `Proxy::all` succeeds and
// then matches nothing, so this "fallback check" fetched the
// MACHINE'S OWN address, reported it as the proxy's exit, and marked
// the proxy valid. Answering "could not check" is the honest result.
if crate::proxy_storage::reqwest_can_proxy(&fallback_url) {
ip_utils::fetch_public_ip(Some(&fallback_url)).await
} else {
Err(ip_utils::IpError::Network(format!(
"Could not start a proxy worker ({err_msg}), and this proxy type cannot be checked directly"
)))
}
}
}
};
@@ -1246,6 +1474,9 @@ impl ProxyManager {
let ip = match ip_result {
Ok(ip) => ip,
Err(e) => {
let udp = udp_probe
.await
.unwrap_or(crate::proxy_udp::UdpSupport::Unknown);
let failed_result = ProxyCheckResult {
ip: String::new(),
city: None,
@@ -1253,8 +1484,13 @@ impl ProxyManager {
country_code: None,
timestamp: Self::get_current_timestamp(),
is_valid: false,
isp: None,
timezone: None,
udp,
latency_ms: Some(started.elapsed().as_millis() as u64),
};
let _ = self.save_proxy_check_cache(proxy_id, &failed_result);
let _ = self.save_proxy_check_cache(proxy_id, proxy_settings, &failed_result);
self.record_proxy_check(proxy_id, Self::history_entry(&failed_result));
let err_str = e.to_string();
let user_message = Self::classify_proxy_error(&err_str, proxy_settings);
@@ -1266,6 +1502,14 @@ impl ProxyManager {
let (city, country, country_code): (Option<String>, Option<String>, Option<String>) =
Self::get_ip_geolocation(&ip).await.unwrap_or_default();
// The ISP and the timezone come off the databases already on disk. Handing
// an exit address to an outside lookup service to learn them would tell
// that service which addresses this machine is testing.
let insight = crate::geolocation::lookup_exit_insight(&ip);
let udp = udp_probe
.await
.unwrap_or(crate::proxy_udp::UdpSupport::Unknown);
// Create successful result
let result = ProxyCheckResult {
ip: ip.clone(),
@@ -1274,14 +1518,34 @@ impl ProxyManager {
country_code,
timestamp: Self::get_current_timestamp(),
is_valid: true,
isp: insight.organization,
timezone: insight.timezone,
udp,
latency_ms: Some(started.elapsed().as_millis() as u64),
};
// Save to cache
let _ = self.save_proxy_check_cache(proxy_id, &result);
let _ = self.save_proxy_check_cache(proxy_id, proxy_settings, &result);
self.record_proxy_check(proxy_id, Self::history_entry(&result));
Ok(result)
}
/// The trail line for a finished check. Built from the receipt rather than
/// assembled twice, so the list can never disagree with the last result.
fn history_entry(result: &ProxyCheckResult) -> ProxyCheckHistoryEntry {
ProxyCheckHistoryEntry {
timestamp: result.timestamp,
ok: result.is_valid,
ip: (!result.ip.is_empty()).then(|| result.ip.clone()),
country: result.country.clone(),
country_code: result.country_code.clone(),
isp: result.isp.clone(),
udp: result.udp,
latency_ms: result.latency_ms,
}
}
// Get cached proxy check result
pub fn get_cached_proxy_check(&self, proxy_id: &str) -> Option<ProxyCheckResult> {
self.load_proxy_check_cache(proxy_id)
@@ -1449,7 +1713,14 @@ impl ProxyManager {
}
// Check for protocol prefix using strip_prefix
let (protocol, rest) = if let Some(rest) = line.strip_prefix("http://") {
let (protocol, rest) = if let Some(rest) = line.strip_prefix("httpstls://") {
// Must be tested before `http://`, which is not a prefix of it but reads
// as though it could be at a glance. Deliberately NOT folded into
// `https://`: provider lists routinely paste `https://user:pass@host:port`
// for a plaintext CONNECT endpoint, so mapping that to the TLS type would
// break real imports.
("httpstls", rest)
} else if let Some(rest) = line.strip_prefix("http://") {
("http", rest)
} else if let Some(rest) = line.strip_prefix("https://") {
("https", rest)
@@ -2526,6 +2797,63 @@ mod tests {
Ok(proxy_binary)
}
#[test]
fn cached_checks_do_not_survive_route_or_credential_edits() {
let settings = ProxySettings {
proxy_type: "http".into(),
host: "127.0.0.1".into(),
port: 8080,
username: Some("user".into()),
password: Some("secret".into()),
vless_uri: None,
};
let result = ProxyCheckResult {
ip: "203.0.113.1".into(),
city: None,
country: None,
country_code: None,
timestamp: 123,
is_valid: true,
isp: None,
timezone: None,
udp: crate::proxy_udp::UdpSupport::Unknown,
latency_ms: None,
};
let encoded = serde_json::to_string(&CachedProxyCheck {
settings_hash: CachedProxyCheck::settings_hash(&settings).unwrap(),
result: result.clone(),
})
.unwrap();
assert!(!encoded.contains("secret"));
let decode = |current: &ProxySettings| {
serde_json::from_str::<CachedProxyCheck>(&encoded)
.unwrap()
.for_settings(current)
};
assert_eq!(decode(&settings).unwrap().ip, result.ip);
let mut changed = settings.clone();
changed.password = Some("rotated".into());
assert!(decode(&changed).is_none());
changed = settings.clone();
changed.host = "other.example".into();
assert!(decode(&changed).is_none());
changed = settings.clone();
changed.port = 1080;
assert!(decode(&changed).is_none());
changed = settings.clone();
changed.proxy_type = "socks5".into();
assert!(decode(&changed).is_none());
changed = settings.clone();
changed.username = Some("another-user".into());
assert!(decode(&changed).is_none());
changed = settings.clone();
changed.vless_uri = Some("changed-route".into());
assert!(decode(&changed).is_none());
assert!(
serde_json::from_str::<CachedProxyCheck>(&serde_json::to_string(&result).unwrap()).is_err()
);
}
#[test]
fn test_proxy_settings_validation() {
// Test valid proxy settings
@@ -3525,6 +3853,82 @@ mod tests {
assert_eq!(url, "http://justuser@host.io:3128");
}
#[test]
fn probe_proxy_url_maps_httpstls_to_the_scheme_reqwest_understands() {
// The browser tunnel dials `httpstls` itself, but the check button and the
// fingerprint probe go through reqwest, which has never heard of it. Both
// spellings mean TLS-to-the-proxy, so the probe still crosses the encrypted
// hop rather than silently falling back to a plaintext one.
let url = ProxyManager::build_probe_proxy_url(&ProxySettings {
proxy_type: "httpstls".to_string(),
host: "proxy.example.com".to_string(),
port: 443,
username: Some("user".to_string()),
password: Some("p@ss".to_string()),
vless_uri: None,
});
assert_eq!(url, "https://user:p%40ss@proxy.example.com:443");
}
#[test]
fn probe_proxy_url_still_forces_remote_dns_for_socks5() {
// Pinning the pre-existing behaviour: adding the httpstls rewrite must not
// disturb the socks5h rewrite that keeps DNS off this machine.
let url = ProxyManager::build_probe_proxy_url(&ProxySettings {
proxy_type: "socks5".to_string(),
host: "proxy.example.com".to_string(),
port: 1080,
username: None,
password: None,
vless_uri: None,
});
assert_eq!(url, "socks5h://proxy.example.com:1080");
}
#[test]
fn probe_proxy_url_leaves_the_plaintext_types_alone() {
for proxy_type in ["http", "https", "socks4"] {
let url = ProxyManager::build_probe_proxy_url(&ProxySettings {
proxy_type: proxy_type.to_string(),
host: "proxy.example.com".to_string(),
port: 8080,
username: None,
password: None,
vless_uri: None,
});
assert_eq!(url, format!("{proxy_type}://proxy.example.com:8080"));
}
}
#[test]
fn parse_txt_proxies_round_trips_the_tls_scheme_without_stealing_https() {
// `httpstls://` must parse as its own type...
let results =
ProxyManager::parse_txt_proxies("httpstls://admin:secret@proxy.example.com:443\n");
match &results[0] {
ProxyParseResult::Parsed(p) => {
assert_eq!(p.proxy_type, "httpstls");
assert_eq!(p.host, "proxy.example.com");
assert_eq!(p.port, 443);
assert_eq!(p.username.as_deref(), Some("admin"));
assert_eq!(p.password.as_deref(), Some("secret"));
}
other => panic!("Expected Parsed, got {other:?}"),
}
// ...and `https://` must keep meaning the plaintext CONNECT type. Provider
// lists paste it for endpoints that do no TLS at all, so promoting it here
// would break real imports and claim an encrypted hop that is not there.
let results = ProxyManager::parse_txt_proxies("https://admin:secret@proxy.example.com:8443\n");
match &results[0] {
ProxyParseResult::Parsed(p) => {
assert_eq!(p.proxy_type, "https");
assert_eq!(p.port, 8443);
}
other => panic!("Expected Parsed, got {other:?}"),
}
}
fn valid_vless_uri() -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
@@ -3571,6 +3975,109 @@ mod tests {
assert!(!error.contains(&invalid));
}
fn history_entry(timestamp: u64, ok: bool) -> ProxyCheckHistoryEntry {
ProxyCheckHistoryEntry {
timestamp,
ok,
ip: ok.then(|| format!("203.0.113.{}", timestamp % 250)),
country: ok.then(|| "Netherlands".to_string()),
country_code: ok.then(|| "NL".to_string()),
isp: ok.then(|| "Example Telecom B.V.".to_string()),
udp: crate::proxy_udp::UdpSupport::Yes,
latency_ms: Some(timestamp),
}
}
#[test]
fn the_check_trail_keeps_the_last_fifty_newest_first_and_survives_a_restart() {
let temp = tempfile::tempdir().unwrap();
let _data_guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
let manager = ProxyManager::new();
let proxy_id = "trail-proxy";
assert!(manager.get_proxy_check_history(proxy_id).is_empty());
for timestamp in 1..=(PROXY_CHECK_HISTORY_LIMIT as u64 + 12) {
manager.record_proxy_check(proxy_id, history_entry(timestamp, timestamp % 4 != 0));
}
let stored = manager.get_proxy_check_history(proxy_id);
assert_eq!(stored.len(), PROXY_CHECK_HISTORY_LIMIT);
// Newest first, and the cap drops the OLDEST checks rather than refusing
// to record new ones.
assert_eq!(stored[0].timestamp, PROXY_CHECK_HISTORY_LIMIT as u64 + 12);
assert_eq!(stored[PROXY_CHECK_HISTORY_LIMIT - 1].timestamp, 13);
assert!(stored
.windows(2)
.all(|pair| pair[0].timestamp > pair[1].timestamp));
// A fresh manager reads the same trail off disk, with every field intact.
let reopened = ProxyManager::new().get_proxy_check_history(proxy_id);
assert_eq!(reopened, stored);
let newest = &reopened[0];
assert_eq!(newest.isp.as_deref(), Some("Example Telecom B.V."));
assert_eq!(newest.country_code.as_deref(), Some("NL"));
assert_eq!(newest.udp, crate::proxy_udp::UdpSupport::Yes);
assert_eq!(
newest.latency_ms,
Some(PROXY_CHECK_HISTORY_LIMIT as u64 + 12)
);
assert!(reopened.iter().any(|entry| !entry.ok));
// The trail lives beside the configs without being mistaken for one: the
// loader reads every `*.json` in the proxies directory.
let history_file = manager.get_proxy_history_file_path(proxy_id);
assert!(history_file.starts_with(manager.get_proxies_dir()));
assert_ne!(history_file, manager.get_proxy_file_path(proxy_id));
assert!(ProxyManager::new().get_stored_proxies().is_empty());
}
#[test]
fn a_check_that_failed_records_that_it_failed_rather_than_an_empty_exit() {
let failure = ProxyCheckResult {
ip: String::new(),
city: None,
country: None,
country_code: None,
timestamp: 1700,
is_valid: false,
isp: None,
timezone: None,
udp: crate::proxy_udp::UdpSupport::No,
latency_ms: Some(42),
};
let entry = ProxyManager::history_entry(&failure);
assert!(!entry.ok);
assert_eq!(entry.ip, None);
assert_eq!(entry.udp, crate::proxy_udp::UdpSupport::No);
assert_eq!(entry.latency_ms, Some(42));
}
#[test]
fn deleting_a_proxy_takes_its_check_trail_with_it() {
let temp = tempfile::tempdir().unwrap();
let _data_guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
let manager = ProxyManager::new();
let stored = StoredProxy::new(
"Doomed".to_string(),
ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port: 1080,
username: None,
password: None,
vless_uri: None,
},
);
manager.save_proxy(&stored).unwrap();
manager.record_proxy_check(&stored.id, history_entry(9, true));
assert!(manager.get_proxy_history_file_path(&stored.id).exists());
manager.delete_proxy_file(&stored.id).unwrap();
assert!(!manager.get_proxy_history_file_path(&stored.id).exists());
assert!(manager.get_proxy_check_history(&stored.id).is_empty());
}
#[test]
fn vless_stored_proxy_persistence_and_exports_preserve_the_canonical_uri() {
let temp = tempfile::tempdir().unwrap();
+674 -84
View File
@@ -2,6 +2,7 @@ use crate::proxy_storage::ProxyConfig;
use crate::traffic_stats::{get_traffic_tracker, init_traffic_tracker, LiveTrafficTracker};
use http_body_util::{BodyExt, Full};
use hyper::body::Bytes;
use hyper::header::{HeaderName, HeaderValue};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
@@ -204,14 +205,18 @@ impl<S: AsyncWrite + Unpin> AsyncWrite for CountingStream<S> {
}
}
// Wrapper to prepend consumed bytes to a stream
struct PrependReader {
// Wrapper to prepend consumed bytes to a stream.
//
// Generic over the inner stream rather than fixed to `TcpStream`: the upstream
// hop is a bare socket for `http`/`https` but a `TlsStream<TcpStream>` for
// `httpstls`, and both need the same coalesced-payload replay.
struct PrependReader<S> {
prepended: Vec<u8>,
prepended_pos: usize,
inner: TcpStream,
inner: S,
}
impl AsyncRead for PrependReader {
impl<S: AsyncRead + Unpin> AsyncRead for PrependReader<S> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
@@ -231,7 +236,7 @@ impl AsyncRead for PrependReader {
}
}
impl AsyncWrite for PrependReader {
impl<S: AsyncWrite + Unpin> AsyncWrite for PrependReader<S> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
@@ -466,9 +471,23 @@ async fn connect_via_socks(
}
}
/// How the body of a buffered response is framed on the wire.
enum BufferedBody {
/// The body follows the header block in `bytes` exactly as the upstream sent
/// it.
AsSent,
/// The upstream used `Transfer-Encoding: chunked`; this is the de-framed body.
Dechunked(Vec<u8>),
/// The upstream declared chunked but the framing never completed. Nothing can
/// be forwarded: the chunk-size lines are not body bytes, and a half-decoded
/// body reaches the browser as a complete-looking short one.
BrokenChunks,
}
/// A buffered HTTP response read off a raw upstream stream.
struct BufferedHttpResponse {
bytes: Vec<u8>,
body: BufferedBody,
/// True when the read stopped at `MAX_HTTP_HEADER_BUFFER` /
/// `MAX_HTTP_RESPONSE_BUFFER` rather than at the end of the response, so
/// `bytes` holds only a prefix. Callers must fail the request instead of
@@ -478,6 +497,117 @@ struct BufferedHttpResponse {
truncated: bool,
}
/// Progress of a chunked body walk.
enum ChunkedState {
/// The terminating zero-length chunk was reached.
Complete,
/// Well-formed so far, but the terminating chunk has not arrived yet.
Incomplete,
/// The framing itself is broken, so no further byte of it can be trusted.
Malformed,
}
/// Decode as much of a `Transfer-Encoding: chunked` body as `body` holds,
/// appending the payload to `out` and advancing `cursor` past every chunk
/// consumed in full. Carrying the cursor across reads keeps a body that arrives
/// in many pieces a single linear walk instead of one per read.
///
/// Any trailer section after the zero-length chunk is dropped; hyper re-derives
/// the framing of the response it sends.
fn decode_chunked(body: &[u8], cursor: &mut usize, out: &mut Vec<u8>) -> ChunkedState {
loop {
let rest = &body[*cursor..];
let Some(line_end) = rest.windows(2).position(|w| w == b"\r\n") else {
return ChunkedState::Incomplete;
};
let Ok(header) = std::str::from_utf8(&rest[..line_end]) else {
return ChunkedState::Malformed;
};
// A chunk extension (`;name=value`) may follow the size and carries nothing
// this proxy acts on.
let size_text = header.split(';').next().unwrap_or("").trim();
let Ok(size) = usize::from_str_radix(size_text, 16) else {
return ChunkedState::Malformed;
};
// A chunk larger than the whole buffer cap can never be satisfied, and
// rejecting it here keeps the offset arithmetic below overflow-free.
if size > MAX_HTTP_RESPONSE_BUFFER {
return ChunkedState::Malformed;
}
if size == 0 {
return ChunkedState::Complete;
}
let data_start = line_end + 2;
let data_end = data_start + size;
let Some(trailing) = rest.get(data_end..) else {
return ChunkedState::Incomplete;
};
if trailing.len() < 2 {
return ChunkedState::Incomplete;
}
if !trailing.starts_with(b"\r\n") {
return ChunkedState::Malformed;
}
out.extend_from_slice(&rest[data_start..data_end]);
*cursor += data_end + 2;
}
}
/// True when this raw header block declares `Transfer-Encoding: chunked`.
fn declares_chunked(header_block: &[u8]) -> bool {
String::from_utf8_lossy(header_block).lines().any(|line| {
let line = line.to_lowercase();
line.starts_with("transfer-encoding:") && line.contains("chunked")
})
}
/// Headers hyper re-derives for the `Full<Bytes>` body this proxy builds, plus
/// the hop-by-hop set. Forwarding the upstream's own framing would fight
/// hyper's and corrupt every response through these paths.
const NON_FORWARDED_RESPONSE_HEADERS: &[&str] = &[
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"proxy-connection",
"upgrade",
"trailer",
"te",
];
/// Copy an upstream's response headers onto a response assembled from raw
/// bytes. The SOCKS4 and Shadowsocks paths speak HTTP by hand, and without this
/// a redirect loses its `Location`, a sign-in loses its `Set-Cookie` and a
/// compressed body arrives with no `Content-Encoding` to undo it.
///
/// `header_block` is the raw header bytes including the status line; a trailing
/// blank line is tolerated. A line that does not parse is dropped rather than
/// failing the whole response, and `HeaderName`/`HeaderValue` do the rejecting,
/// so a hostile upstream cannot smuggle a header past this.
fn forward_upstream_headers(response: &mut Response<Full<Bytes>>, header_block: &[u8]) {
let block = String::from_utf8_lossy(header_block);
for line in block.split("\r\n").skip(1) {
let Some((name, value)) = line.split_once(':') else {
continue;
};
let name = name.trim();
if NON_FORWARDED_RESPONSE_HEADERS
.iter()
.any(|skipped| name.eq_ignore_ascii_case(skipped))
{
continue;
}
let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value.trim()),
) else {
continue;
};
// `append`, not `insert`: every `Set-Cookie` has to survive.
response.headers_mut().append(name, value);
}
}
/// Read a full HTTP response from `stream` into a buffer: headers first
/// (capped at `MAX_HTTP_HEADER_BUFFER` — a peer streaming data that never
/// contains CRLFCRLF must not grow memory unboundedly), then the body per
@@ -489,6 +619,7 @@ async fn read_http_response_buffer<S: AsyncRead + Unpin>(stream: &mut S) -> Buff
let mut content_length: Option<usize> = None;
let mut is_chunked = false;
let mut truncated = false;
let mut body = BufferedBody::AsSent;
// Read until we have complete headers
loop {
@@ -553,7 +684,38 @@ async fn read_http_response_buffer<S: AsyncRead + Unpin>(stream: &mut S) -> Buff
}
}
}
} else if !is_chunked {
} else if is_chunked {
// A chunked body has no Content-Length, so the framing itself says
// where it ends. Walk it as the bytes arrive, and de-frame it here:
// the chunk-size lines are not body bytes, and forwarding them left
// the browser rendering the framing.
let body_start = pos + 4;
let mut cursor = 0;
let mut decoded = Vec::new();
let state = loop {
match decode_chunked(&response_buffer[body_start..], &mut cursor, &mut decoded) {
ChunkedState::Incomplete => {}
terminal => break terminal,
}
if response_buffer.len() >= MAX_HTTP_RESPONSE_BUFFER {
log::warn!(
"Chunked HTTP response exceeded {} bytes; refusing to forward a truncated response",
MAX_HTTP_RESPONSE_BUFFER
);
truncated = true;
break ChunkedState::Incomplete;
}
match stream.read(&mut temp_buf).await {
Ok(0) => break ChunkedState::Incomplete,
Ok(n) => response_buffer.extend_from_slice(&temp_buf[..n]),
Err(_) => break ChunkedState::Incomplete,
}
};
body = match state {
ChunkedState::Complete => BufferedBody::Dechunked(decoded),
_ => BufferedBody::BrokenChunks,
};
} else {
// No Content-Length and not chunked - read until connection closes
// But limit to reasonable size to avoid memory issues
loop {
@@ -574,8 +736,6 @@ async fn read_http_response_buffer<S: AsyncRead + Unpin>(stream: &mut S) -> Buff
}
}
}
// Note: Chunked encoding is complex to parse manually, so we'll read what we can
// For full chunked support, we'd need a proper HTTP parser
break;
}
}
@@ -588,6 +748,7 @@ async fn read_http_response_buffer<S: AsyncRead + Unpin>(stream: &mut S) -> Buff
BufferedHttpResponse {
bytes: response_buffer,
body,
truncated,
}
}
@@ -821,7 +982,11 @@ async fn handle_http_via_socks4(
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
let response_buffer = buffered.bytes;
let BufferedHttpResponse {
bytes: response_buffer,
body: buffered_body,
..
} = buffered;
// Parse HTTP response
let response_str = String::from_utf8_lossy(&response_buffer);
@@ -840,7 +1005,16 @@ async fn handle_http_via_socks4(
.map(|p| p + 4)
.unwrap_or(response_buffer.len());
let body = response_buffer[header_end..].to_vec();
let body = match buffered_body {
BufferedBody::AsSent => response_buffer[header_end..].to_vec(),
BufferedBody::Dechunked(body) => body,
BufferedBody::BrokenChunks => {
log::error!("Chunked HTTP response via SOCKS4 for {domain} did not decode");
let mut response = Response::new(Full::new(Bytes::from("Malformed upstream response")));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
};
// Record request in traffic tracker
let response_size = body.len() as u64;
@@ -849,7 +1023,11 @@ async fn handle_http_via_socks4(
}
let mut hyper_response = Response::new(Full::new(Bytes::from(body)));
*hyper_response.status_mut() = StatusCode::from_u16(status_code).unwrap();
// A status line carrying something outside 100..=999 must not panic the
// connection task.
*hyper_response.status_mut() =
StatusCode::from_u16(status_code).unwrap_or(StatusCode::BAD_GATEWAY);
forward_upstream_headers(&mut hyper_response, &response_buffer[..header_end]);
Ok(hyper_response)
}
@@ -950,10 +1128,17 @@ async fn handle_http_via_shadowsocks(
tracker.record_request(&domain, raw_req.len() as u64, response_buf.len() as u64);
}
// Parse the raw HTTP response
let response_str = String::from_utf8_lossy(&response_buf);
let header_end = response_str.find("\r\n\r\n").unwrap_or(response_str.len());
let status_line = response_str
// Parse the raw HTTP response. The boundary is found in the raw bytes, not in
// a lossy UTF-8 copy of them, so a body byte that is not valid UTF-8 cannot
// shift the offset the body is sliced at.
let header_end = response_buf
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|p| p + 4)
.unwrap_or(response_buf.len());
let header_block = &response_buf[..header_end];
let header_text = String::from_utf8_lossy(header_block);
let status_line = header_text
.lines()
.next()
.unwrap_or("HTTP/1.1 502 Bad Gateway");
@@ -962,15 +1147,28 @@ async fn handle_http_via_shadowsocks(
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(502);
let body = if header_end + 4 < response_buf.len() {
&response_buf[header_end + 4..]
let raw_body = &response_buf[header_end..];
let body = if declares_chunked(header_block) {
let mut cursor = 0;
let mut decoded = Vec::new();
match decode_chunked(raw_body, &mut cursor, &mut decoded) {
ChunkedState::Complete => decoded,
_ => {
log::error!("Chunked HTTP response via Shadowsocks for {domain} did not decode");
let mut resp = Response::new(Full::new(Bytes::from("Malformed upstream response")));
*resp.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(resp);
}
}
} else {
b""
raw_body.to_vec()
};
let mut hyper_response = Response::new(Full::new(Bytes::from(body.to_vec())));
let mut hyper_response = Response::new(Full::new(Bytes::from(body)));
*hyper_response.status_mut() =
StatusCode::from_u16(status_code).unwrap_or(StatusCode::BAD_GATEWAY);
forward_upstream_headers(&mut hyper_response, header_block);
Ok(hyper_response)
}
@@ -1186,10 +1384,16 @@ fn build_reqwest_client_with_proxy(
let proxy = match scheme {
"http" | "https" => {
// For HTTP/HTTPS proxies, reqwest handles them directly
// Note: HTTPS proxy URLs still use HTTP CONNECT method, reqwest handles TLS automatically
// Both are a plaintext hop to the proxy. `https` is only a provider
// label here; the tunnel path treats it identically to `http`.
Proxy::http(upstream_url)?
}
"httpstls" => {
// TLS to the proxy. reqwest spells that `https://`, which is what
// `reqwest_upstream_url` produces; the scheme rewrite is the whole
// difference, the endpoint and credentials are unchanged.
Proxy::http(crate::proxy_storage::reqwest_upstream_url(upstream_url))?
}
"socks5" => {
// Force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
// upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local,
@@ -1944,8 +2148,8 @@ pub(crate) fn log_throttle(key: &str) -> Option<u64> {
/// and the terminating CRLFCRLF can arrive with destination payload appended
/// (those bytes belong to the tunnel). Reads until the header terminator and
/// returns `(headers, bytes_after_headers)`.
async fn read_upstream_connect_response(
stream: &mut TcpStream,
async fn read_upstream_connect_response<S: AsyncRead + Unpin>(
stream: &mut S,
) -> Result<(String, Vec<u8>), Box<dyn std::error::Error>> {
let mut buffer = Vec::with_capacity(1024);
let mut chunk = [0u8; 4096];
@@ -1978,11 +2182,108 @@ async fn read_upstream_connect_response(
}
}
/// Perform the HTTP CONNECT handshake over an already-established hop to the
/// proxy and return the tunnelled stream.
///
/// Generic over the hop so the identical handshake runs on a bare `TcpStream`
/// (`http`/`https`) and on a `TlsStream<TcpStream>` (`httpstls`). This is the
/// only place `Proxy-Authorization` is written, so whether those credentials
/// cross the network in the clear is decided entirely by which stream the
/// caller hands in, nothing here can weaken it.
async fn connect_via_http_proxy<S: AsyncStream + 'static>(
mut proxy_stream: S,
proxy_host: &str,
proxy_port: u16,
target_host: &str,
target_port: u16,
upstream: &Url,
) -> Result<BoxedAsyncStream, Box<dyn std::error::Error>> {
let mut connect_req = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
);
let (username, password) = upstream_userpass(upstream);
if !username.is_empty() {
use base64::{engine::general_purpose, Engine as _};
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
}
connect_req.push_str("\r\n");
proxy_stream.write_all(connect_req.as_bytes()).await?;
let (response_headers, coalesced) = read_upstream_connect_response(&mut proxy_stream).await?;
let status_line = response_headers.lines().next().unwrap_or("").to_string();
if !response_headers.starts_with("HTTP/1.1 200") && !response_headers.starts_with("HTTP/1.0 200")
{
log::warn!(
"Upstream CONNECT to {}:{} via {}:{} rejected: {}",
target_host,
target_port,
proxy_host,
proxy_port,
status_line
);
return Err(format!("Upstream proxy CONNECT failed: {status_line}").into());
}
log::info!(
"Upstream CONNECT to {}:{} via {}:{} accepted ({})",
target_host,
target_port,
proxy_host,
proxy_port,
status_line
);
if coalesced.is_empty() {
Ok(Box::new(proxy_stream))
} else {
// The upstream packed the destination's first bytes into the same
// segment as its 200. They are tunnel payload, not proxy protocol:
// replay them ahead of the socket so the client sees an unbroken
// stream. Server-speaks-first protocols (SMTP/IMAP/SSH banners)
// reach this reliably.
log::debug!(
"Upstream CONNECT response coalesced {} byte(s) of payload; forwarding",
coalesced.len()
);
Ok(Box::new(PrependReader {
prepended: coalesced,
prepended_pos: 0,
inner: proxy_stream,
}))
}
}
/// Wrap an established TCP hop to the proxy in TLS, verifying the proxy's
/// certificate against `proxy_host`.
///
/// There is deliberately no opportunistic downgrade and no
/// `danger_accept_invalid_certs` escape hatch: a failed handshake is a failed
/// connection. Certificate verification is what makes this hop resistant to an
/// active man-in-the-middle and not merely to a passive sniffer, and a bypass
/// switch would be clicked the first time a provider hands out a bare IP.
async fn tls_wrap_upstream_hop(
tcp: TcpStream,
proxy_host: &str,
) -> Result<tokio_native_tls::TlsStream<TcpStream>, Box<dyn std::error::Error>> {
let connector = tokio_native_tls::TlsConnector::from(native_tls::TlsConnector::new()?);
match tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, connector.connect(proxy_host, tcp)).await {
Ok(result) => Ok(result?),
Err(_) => Err(format!("TLS handshake with upstream proxy {proxy_host} timed out").into()),
}
}
/// Establish a stream to `target_host:target_port`, either directly or through
/// the configured upstream proxy. Shared by the HTTP CONNECT path and the
/// local SOCKS5 server so every upstream type (direct, HTTP/HTTPS CONNECT,
/// SOCKS4/5, Shadowsocks) is dialed in exactly one place. Returns a
/// `BoxedAsyncStream` so the caller can tunnel over any upstream uniformly.
/// TLS-wrapped CONNECT, SOCKS4/5, Shadowsocks) is dialed in exactly one place.
/// Returns a `BoxedAsyncStream` so the caller can tunnel over any upstream
/// uniformly.
pub(crate) async fn connect_to_target_via_upstream(
target_host: &str,
target_port: u16,
@@ -2002,10 +2303,15 @@ pub(crate) async fn connect_to_target_via_upstream(
let scheme = upstream.scheme();
match scheme {
// `https` here is NOT TLS to the proxy: it is a label many providers
// put on a plaintext CONNECT endpoint, and Donut has always treated it
// byte-for-byte like `http`. Changing that would silently break every
// stored `https` proxy, so the encrypted hop is the separate
// `httpstls` scheme below.
"http" | "https" => {
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
let proxy_port = upstream.port().unwrap_or(8080);
let mut proxy_stream = tokio::time::timeout(
let proxy_stream = tokio::time::timeout(
UPSTREAM_DIAL_TIMEOUT,
TcpStream::connect((proxy_host, proxy_port)),
)
@@ -2015,67 +2321,43 @@ pub(crate) async fn connect_to_target_via_upstream(
})??;
configure_tcp(&proxy_stream);
let mut connect_req = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
);
let (username, password) = upstream_userpass(&upstream);
if !username.is_empty() {
use base64::{engine::general_purpose, Engine as _};
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
}
connect_req.push_str("\r\n");
proxy_stream.write_all(connect_req.as_bytes()).await?;
let (response_headers, coalesced) =
read_upstream_connect_response(&mut proxy_stream).await?;
let status_line = response_headers.lines().next().unwrap_or("").to_string();
if !response_headers.starts_with("HTTP/1.1 200")
&& !response_headers.starts_with("HTTP/1.0 200")
{
log::warn!(
"Upstream CONNECT to {}:{} via {}:{} rejected: {}",
target_host,
target_port,
proxy_host,
proxy_port,
status_line
);
return Err(format!("Upstream proxy CONNECT failed: {status_line}").into());
}
log::info!(
"Upstream CONNECT to {}:{} via {}:{} accepted ({})",
target_host,
target_port,
connect_via_http_proxy(
proxy_stream,
proxy_host,
proxy_port,
status_line
);
target_host,
target_port,
&upstream,
)
.await?
}
// TLS to the proxy first, CONNECT second. The target hostname and the
// `Proxy-Authorization` credentials are written only after the
// handshake, so neither reaches the wire in the clear.
"httpstls" => {
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
let proxy_port = upstream.port().unwrap_or(443);
let tcp = tokio::time::timeout(
UPSTREAM_DIAL_TIMEOUT,
TcpStream::connect((proxy_host, proxy_port)),
)
.await
.map_err(|_| {
format!("upstream proxy connect to {proxy_host}:{proxy_port} timed out")
})??;
configure_tcp(&tcp);
if coalesced.is_empty() {
Box::new(proxy_stream)
} else {
// The upstream packed the destination's first bytes into the same
// segment as its 200. They are tunnel payload, not proxy protocol:
// replay them ahead of the socket so the client sees an unbroken
// stream. Server-speaks-first protocols (SMTP/IMAP/SSH banners)
// reach this reliably.
log::debug!(
"Upstream CONNECT response coalesced {} byte(s) of payload; forwarding",
coalesced.len()
);
Box::new(PrependReader {
prepended: coalesced,
prepended_pos: 0,
inner: proxy_stream,
})
}
let tls = tls_wrap_upstream_hop(tcp, proxy_host).await?;
connect_via_http_proxy(
tls,
proxy_host,
proxy_port,
target_host,
target_port,
&upstream,
)
.await?
}
"socks4" | "socks5" => {
let socks_host = upstream.host_str().unwrap_or("127.0.0.1");
@@ -2502,6 +2784,133 @@ mod tests {
assert!(!buf.truncated);
}
/// Frame `pieces` as a chunked body, terminator included.
fn chunked_wire(pieces: &[&str]) -> Vec<u8> {
let mut out = Vec::new();
for piece in pieces {
out.extend_from_slice(format!("{:x}\r\n", piece.len()).as_bytes());
out.extend_from_slice(piece.as_bytes());
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(b"0\r\n\r\n");
out
}
#[tokio::test]
async fn read_http_response_buffer_dechunks_a_chunked_body() {
let (mut writer, mut reader) = tokio::io::duplex(1024);
let mut resp = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec();
resp.extend_from_slice(&chunked_wire(&["hello ", "world"]));
writer.write_all(&resp).await.unwrap();
drop(writer);
let buf = read_http_response_buffer(&mut reader).await;
assert!(!buf.truncated);
match buf.body {
BufferedBody::Dechunked(body) => assert_eq!(body, b"hello world".to_vec()),
_ => panic!("a chunked body must be de-framed before it reaches the browser"),
}
}
#[test]
fn chunked_body_decodes_to_the_payload_alone() {
let wire = chunked_wire(&["hello ", "world"]);
let mut cursor = 0;
let mut out = Vec::new();
assert!(matches!(
decode_chunked(&wire, &mut cursor, &mut out),
ChunkedState::Complete
));
assert_eq!(out, b"hello world".to_vec());
}
#[test]
fn a_chunked_body_split_across_reads_is_walked_once() {
let wire = chunked_wire(&["one", "two"]);
let mut cursor = 0;
let mut out = Vec::new();
// Half the buffer holds the first chunk and part of the second header.
assert!(matches!(
decode_chunked(&wire[..wire.len() / 2], &mut cursor, &mut out),
ChunkedState::Incomplete
));
assert!(matches!(
decode_chunked(&wire, &mut cursor, &mut out),
ChunkedState::Complete
));
assert_eq!(out, b"onetwo".to_vec());
}
#[test]
fn chunk_extensions_are_ignored() {
let mut cursor = 0;
let mut out = Vec::new();
assert!(matches!(
decode_chunked(b"5;name=value\r\nhello\r\n0\r\n\r\n", &mut cursor, &mut out),
ChunkedState::Complete
));
assert_eq!(out, b"hello".to_vec());
}
#[test]
fn a_malformed_chunk_stream_is_rejected_not_half_decoded() {
for wire in [
// A size that is not hexadecimal.
b"zz\r\nnope\r\n0\r\n\r\n".to_vec(),
// Chunk data not followed by its CRLF.
b"5\r\nhelloXX\r\n0\r\n\r\n".to_vec(),
] {
let mut cursor = 0;
let mut out = Vec::new();
assert!(
matches!(
decode_chunked(&wire, &mut cursor, &mut out),
ChunkedState::Malformed
),
"{}",
String::from_utf8_lossy(&wire)
);
}
}
#[test]
fn upstream_response_headers_reach_the_browser() {
let block = b"HTTP/1.1 302 Found\r\n\
Location: https://example.com/next\r\n\
Set-Cookie: a=1; Path=/\r\n\
Set-Cookie: b=2; Path=/\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Content-Length: 17\r\n\
Transfer-Encoding: chunked\r\n\
Connection: keep-alive\r\n\
this line has no colon\r\n\
\r\n";
let mut response = Response::new(Full::new(Bytes::new()));
forward_upstream_headers(&mut response, block);
let headers = response.headers();
assert_eq!(headers.get("location").unwrap(), "https://example.com/next");
assert_eq!(
headers.get("content-type").unwrap(),
"text/html; charset=utf-8"
);
// Every Set-Cookie survives, so a sign-in actually establishes a session.
let cookies: Vec<&str> = headers
.get_all("set-cookie")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(cookies, ["a=1; Path=/", "b=2; Path=/"]);
// hyper re-derives the framing for the body it is handed; the upstream's
// own framing headers would contradict it.
for framing in ["content-length", "transfer-encoding", "connection"] {
assert!(
headers.get(framing).is_none(),
"{framing} must not be forwarded"
);
}
}
#[tokio::test]
async fn read_http_response_buffer_caps_oversized_content_length_body() {
let (mut writer, mut reader) = tokio::io::duplex(64 * 1024);
@@ -2652,6 +3061,187 @@ mod tests {
assert_eq!(domain_stats.bytes_received, download_len as u64);
}
/// Dial `connect_to_target_via_upstream` at a listener that never answers and
/// return the first bytes it puts on the wire.
///
/// The dial cannot complete (nothing on the far end speaks proxy or TLS), and
/// that is the point: what matters is what leaves this machine BEFORE the
/// other side has proved anything.
async fn first_bytes_sent_to_upstream(scheme: &str) -> Vec<u8> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let upstream = format!("{scheme}://donutuser:hunter2secret@127.0.0.1:{port}");
let dial = tokio::spawn(async move {
let matcher = BypassMatcher::new(&[]);
let _ = connect_to_target_via_upstream(
"private-target.example.com",
443,
Some(&upstream),
&matcher,
)
.await;
});
let (mut server, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let n = tokio::time::timeout(std::time::Duration::from_secs(10), server.read(&mut buf))
.await
.expect("upstream saw no bytes at all before the timeout")
.expect("reading from the mock upstream failed");
buf.truncate(n);
dial.abort();
buf
}
#[tokio::test]
async fn httpstls_upstream_negotiates_tls_before_writing_anything_readable() {
// This is the whole point of the type. Nothing readable may reach the wire
// ahead of the TLS handshake: not the CONNECT verb, not the target host,
// and above all not the Proxy-Authorization credentials. The handshake here
// never completes, which proves the credentials never left the machine.
let first = first_bytes_sent_to_upstream("httpstls").await;
assert_eq!(
first.first().copied(),
Some(0x16),
"the first byte must be a TLS handshake record (0x16), got {:02x?}",
&first[..first.len().min(16)]
);
let as_text = String::from_utf8_lossy(&first);
for secret in [
"CONNECT ",
"Proxy-Authorization",
"private-target.example.com",
"donutuser",
"hunter2secret",
] {
assert!(
!as_text.contains(secret),
"{secret:?} reached the wire in the clear on an httpstls upstream"
);
}
}
#[tokio::test]
async fn http_upstream_still_writes_a_plaintext_connect() {
// The counterpart, pinning today's behaviour rather than wishing it away.
// If this ever stops holding, the `http` path changed and every stored
// plaintext proxy changed with it.
let first = first_bytes_sent_to_upstream("http").await;
let as_text = String::from_utf8_lossy(&first);
assert!(
as_text.starts_with("CONNECT private-target.example.com:443 "),
"expected a plaintext CONNECT, got {as_text:?}"
);
assert!(
as_text.contains("Proxy-Authorization: Basic "),
"expected plaintext proxy credentials, got {as_text:?}"
);
}
#[tokio::test]
async fn https_upstream_is_a_plaintext_hop_despite_the_name() {
// `https` is a provider label, not TLS to the proxy. The UI now says so;
// this is the assertion that keeps the code and the copy agreeing.
let first = first_bytes_sent_to_upstream("https").await;
assert!(
String::from_utf8_lossy(&first).starts_with("CONNECT "),
"the `https` type must keep behaving exactly like `http`"
);
assert_ne!(
first.first().copied(),
Some(0x16),
"`https` must not have silently become a TLS hop"
);
}
#[tokio::test]
async fn httpstls_refuses_a_hop_that_answers_in_plaintext() {
// The downgrade case: a proxy (or something sitting in front of it)
// answering the way a plaintext CONNECT endpoint would. There is no
// opportunistic fallback, a failed handshake is a failed connection, or
// the whole type is worth nothing against an active attacker.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut scratch = [0u8; 1024];
let _ = socket.read(&mut scratch).await;
let _ = socket
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await;
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
});
let upstream = format!("httpstls://user:pass@127.0.0.1:{port}");
let matcher = BypassMatcher::new(&[]);
let result = tokio::time::timeout(
std::time::Duration::from_secs(20),
connect_to_target_via_upstream("example.com", 443, Some(&upstream), &matcher),
)
.await
.expect("the dial must not hang");
assert!(
result.is_err(),
"a plaintext answer must not yield a usable tunnel on an httpstls upstream"
);
server.abort();
}
#[tokio::test]
async fn build_reqwest_client_with_proxy_accepts_the_tls_scheme() {
// reqwest rejects the `httpstls` scheme outright, so without the rewrite
// every plain-HTTP request through such a proxy fails to build a client.
build_reqwest_client_with_proxy("httpstls://user:pass@proxy.example.com:443")
.expect("httpstls must build a reqwest client");
build_reqwest_client_with_proxy("http://proxy.example.com:8080")
.expect("http must keep building a reqwest client");
}
#[tokio::test]
async fn prepend_reader_replays_payload_over_a_non_tcp_stream() {
// The coalesced-payload replay has to survive the TLS stream type, not just
// TcpStream. `duplex` stands in for any non-TCP AsyncRead+AsyncWrite.
let (mut peer, inner) = tokio::io::duplex(1024);
peer.write_all(b"-rest-of-stream").await.unwrap();
let mut reader = PrependReader {
prepended: b"replayed-".to_vec(),
prepended_pos: 0,
inner,
};
let mut got = [0u8; 24];
let mut filled = 0;
while filled < got.len() {
let n = reader.read(&mut got[filled..]).await.unwrap();
assert_ne!(n, 0, "stream ended before the expected bytes arrived");
filled += n;
}
assert_eq!(&got[..filled], b"replayed--rest-of-stream");
}
#[tokio::test]
async fn read_upstream_connect_response_works_off_a_non_tcp_stream() {
// Guards the generic bound: a `&mut TcpStream` signature would not compile
// against the TLS stream the httpstls path hands it.
let (mut peer, mut inner) = tokio::io::duplex(1024);
peer
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\nBANNER")
.await
.unwrap();
let (headers, leftover) = read_upstream_connect_response(&mut inner).await.unwrap();
assert!(headers.starts_with("HTTP/1.1 200"));
assert_eq!(leftover, b"BANNER");
}
#[test]
fn test_blocklist_comments_skipped() {
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
+70
View File
@@ -118,6 +118,42 @@ pub fn build_proxy_url(
url
}
/// Rewrite a stored upstream URL into something `reqwest::Proxy` accepts.
///
/// `donut-proxy` dials `httpstls://` itself, so the scheme is Donut's own and
/// reqwest has never heard of it, `Proxy::all` would reject it outright and
/// every probe through such a proxy would die as "Invalid proxy". reqwest's
/// `https://` proxy scheme means exactly what `httpstls` means here (TLS to the
/// proxy, then CONNECT), so the two agree on the wire; only the spelling
/// differs. Every other scheme is passed through untouched.
pub fn reqwest_upstream_url(url: &str) -> String {
match url.strip_prefix("httpstls://") {
Some(rest) => format!("https://{rest}"),
None => url.to_string(),
}
}
/// Whether `reqwest` can actually route a request through this upstream URL.
///
/// An ALLOW-list of the schemes hyper-util's matcher accepts. Anything else
/// makes `reqwest::Proxy::all` SUCCEED and then match nothing, so the request is
/// sent DIRECT with no error and no log line, which is how a geolocation probe
/// and a proxy-check both came to report the machine's own address.
///
/// Callers should pass the url through [`reqwest_upstream_url`] first, so
/// `httpstls` is judged as the `https` it becomes.
pub fn reqwest_can_proxy(url: &str) -> bool {
let scheme = url
.split("://")
.next()
.unwrap_or_default()
.to_ascii_lowercase();
matches!(
scheme.as_str(),
"http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h"
)
}
pub fn get_storage_dir() -> PathBuf {
crate::app_dirs::proxy_workers_dir()
}
@@ -612,6 +648,40 @@ mod tests {
assert_eq!(config.browser_pid_start_time, None);
}
#[test]
fn reqwest_upstream_url_rewrites_only_the_donut_specific_scheme() {
// reqwest cannot parse `httpstls`, so without this rewrite every probe and
// every fallback check through such a proxy dies as "Invalid proxy". The
// credentials, host and port must survive untouched.
assert_eq!(
reqwest_upstream_url("httpstls://user:p%40ss@proxy.example:443"),
"https://user:p%40ss@proxy.example:443"
);
// Everything else is reqwest-native and must pass through byte-for-byte.
// `https` in particular: rewriting it would be a no-op today but pinning it
// here says the plaintext type is deliberately left alone.
for untouched in [
"http://proxy.example:8080",
"https://proxy.example:8080",
"socks5://proxy.example:1080",
"socks5h://proxy.example:1080",
"ss://proxy.example:8388",
"DIRECT",
] {
assert_eq!(reqwest_upstream_url(untouched), untouched);
}
}
#[test]
fn reqwest_upstream_url_only_matches_the_scheme_prefix() {
// A host that merely starts with the scheme text must not be rewritten.
assert_eq!(
reqwest_upstream_url("http://httpstls://weird"),
"http://httpstls://weird"
);
}
#[test]
fn test_is_process_running_returns_false_for_nonexistent_pid() {
// PID 0 is the "System Idle Process" on Windows and sysinfo reports it as running,
+431
View File
@@ -0,0 +1,431 @@
//! Whether a proxy can carry UDP, asked the way the browser would ask.
//!
//! This decides more than it looks like it does: WebRTC is UDP, so a profile
//! on a proxy without `UDP ASSOCIATE` either leaks WebRTC around the proxy or
//! loses it entirely. The answer therefore has to be a fact, not a guess —
//! hence three verdicts, with "unknown" reserved for everything the probe
//! could not establish.
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::browser::ProxySettings;
/// How long the whole handshake gets. A proxy that cannot answer a three-byte
/// greeting and one request inside this is not going to carry a media stream.
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const SOCKS5: u8 = 0x05;
const AUTH_NONE: u8 = 0x00;
const AUTH_USERPASS: u8 = 0x02;
const AUTH_UNACCEPTABLE: u8 = 0xFF;
const CMD_UDP_ASSOCIATE: u8 = 0x03;
const ATYP_IPV4: u8 = 0x01;
const ATYP_DOMAIN: u8 = 0x03;
const ATYP_IPV6: u8 = 0x04;
const REP_SUCCEEDED: u8 = 0x00;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum UdpSupport {
/// The proxy accepted `UDP ASSOCIATE`.
Yes,
/// The proxy cannot carry UDP: it refused the command, or its protocol has
/// no way to carry a datagram at all.
No,
/// Not established. Never reported as `Yes`, and never as `No` either: an
/// unreachable proxy has not proved anything.
#[default]
Unknown,
}
/// The verdict that follows from the protocol alone, before anything is
/// dialled. `None` means the protocol can carry UDP in principle and the
/// proxy itself has to be asked.
///
/// HTTP proxies answer `No` here rather than being probed: CONNECT builds a
/// TCP tunnel and the protocol has no datagram command to send.
pub fn udp_verdict_for_type(proxy_type: &str) -> Option<UdpSupport> {
match proxy_type.trim().to_ascii_lowercase().as_str() {
"socks5" | "socks5h" => None,
// SOCKS4 and SOCKS4a define CONNECT and BIND only.
"http" | "https" | "httpstls" | "socks4" | "socks4a" => Some(UdpSupport::No),
// Shadowsocks and VLESS can carry UDP, but whether this endpoint does is
// not something a SOCKS handshake can answer.
_ => Some(UdpSupport::Unknown),
}
}
/// Map a SOCKS5 reply code onto a verdict.
///
/// Only an accepted association counts as `Yes`. Every other reply is a
/// refusal the proxy stated in answer to the exact request, which is a real
/// `No`. That deliberately includes codes RFC 1928 never defined: a residential
/// gateway tested here answers `UDP ASSOCIATE` with `0xFF`, and calling that
/// "unknown" would leave the column blank for the proxies it matters most for.
///
/// `Unknown` belongs to the cases where the proxy was never actually asked —
/// unreachable, timed out, refused the authentication, or answered something
/// that is not a SOCKS5 reply — and those are decided by the caller before a
/// reply code ever gets here.
pub fn udp_verdict_from_socks_reply(reply: u8) -> UdpSupport {
if reply == REP_SUCCEEDED {
UdpSupport::Yes
} else {
UdpSupport::No
}
}
/// Ask a proxy whether it carries UDP.
///
/// The probe dials the proxy exactly as a launch would — the same host, the
/// same port, the same credentials — and asks for an association it never
/// uses. No datagram is sent and no third-party host is named, so nothing
/// about this check reaches anywhere the browser would not already go.
pub async fn probe_udp_support(settings: &ProxySettings) -> UdpSupport {
if let Some(verdict) = udp_verdict_for_type(&settings.proxy_type) {
return verdict;
}
match tokio::time::timeout(PROBE_TIMEOUT, socks5_udp_associate(settings)).await {
Ok(Ok(verdict)) => verdict,
Ok(Err(e)) => {
log::debug!(
"UDP probe of {}:{} could not complete: {e}",
settings.host,
settings.port
);
UdpSupport::Unknown
}
Err(_) => {
log::debug!("UDP probe of {}:{} timed out", settings.host, settings.port);
UdpSupport::Unknown
}
}
}
async fn socks5_udp_associate(settings: &ProxySettings) -> std::io::Result<UdpSupport> {
let mut stream = tokio::net::TcpStream::connect((settings.host.as_str(), settings.port)).await?;
let credentials = settings
.username
.as_deref()
.filter(|user| !user.is_empty())
.map(|user| (user, settings.password.as_deref().unwrap_or("")));
let greeting: Vec<u8> = match credentials {
Some(_) => vec![SOCKS5, 2, AUTH_NONE, AUTH_USERPASS],
None => vec![SOCKS5, 1, AUTH_NONE],
};
stream.write_all(&greeting).await?;
let mut selection = [0u8; 2];
stream.read_exact(&mut selection).await?;
if selection[0] != SOCKS5 {
return Ok(UdpSupport::Unknown);
}
match selection[1] {
AUTH_NONE => {}
AUTH_USERPASS => {
let Some((user, password)) = credentials else {
return Ok(UdpSupport::Unknown);
};
if !authenticate(&mut stream, user, password).await? {
return Ok(UdpSupport::Unknown);
}
}
AUTH_UNACCEPTABLE => return Ok(UdpSupport::Unknown),
_ => return Ok(UdpSupport::Unknown),
}
// An all-zero address is what a client sends when it does not yet know the
// address it will send datagrams from, which is exactly this case: the
// association is requested and then dropped.
stream
.write_all(&[SOCKS5, CMD_UDP_ASSOCIATE, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0])
.await?;
// Only the version and the reply code are read up front. RFC 1928 says a
// reply carries a bound address as well, but a refusing server does not
// always send one: the residential gateway this was tested against answers
// `05 FF` and closes. Demanding the full four-byte header there turns a
// stated refusal into a read error, and the verdict into "unknown".
let mut head = [0u8; 2];
stream.read_exact(&mut head).await?;
if head[0] != SOCKS5 {
return Ok(UdpSupport::Unknown);
}
let verdict = udp_verdict_from_socks_reply(head[1]);
if verdict == UdpSupport::Yes {
// An accepted association does carry the address to send datagrams to.
// Nothing here uses it, but reading it leaves the socket drained rather
// than closing under a server that is still writing.
let mut tail = [0u8; 2];
if stream.read_exact(&mut tail).await.is_ok() {
let _ = drain_bound_address(&mut stream, tail[1]).await;
}
}
Ok(verdict)
}
async fn authenticate(
stream: &mut tokio::net::TcpStream,
user: &str,
password: &str,
) -> std::io::Result<bool> {
if user.len() > 255 || password.len() > 255 {
return Ok(false);
}
let mut request = Vec::with_capacity(3 + user.len() + password.len());
request.push(0x01);
request.push(user.len() as u8);
request.extend_from_slice(user.as_bytes());
request.push(password.len() as u8);
request.extend_from_slice(password.as_bytes());
stream.write_all(&request).await?;
let mut reply = [0u8; 2];
stream.read_exact(&mut reply).await?;
Ok(reply[1] == 0x00)
}
async fn drain_bound_address(
stream: &mut tokio::net::TcpStream,
address_type: u8,
) -> std::io::Result<()> {
let length = match address_type {
ATYP_IPV4 => 4,
ATYP_IPV6 => 16,
ATYP_DOMAIN => {
let mut len = [0u8; 1];
stream.read_exact(&mut len).await?;
len[0] as usize
}
_ => return Ok(()),
};
let mut scratch = vec![0u8; length + 2];
stream.read_exact(&mut scratch).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// RFC 1928's "command not supported". Production no longer needs to name
/// it — every non-zero reply is a refusal — but a test server has to send
/// something a real proxy would send.
const REP_CMD_NOT_SUPPORTED: u8 = 0x07;
#[test]
fn an_http_proxy_is_answered_without_ever_being_dialled() {
for proxy_type in ["http", "HTTP", "https", "httpstls", "socks4", "socks4a"] {
assert_eq!(
udp_verdict_for_type(proxy_type),
Some(UdpSupport::No),
"{proxy_type}"
);
}
}
#[test]
fn socks5_is_the_only_type_that_gets_probed() {
assert_eq!(udp_verdict_for_type("socks5"), None);
assert_eq!(udp_verdict_for_type("SOCKS5"), None);
assert_eq!(udp_verdict_for_type("socks5h"), None);
}
#[test]
fn a_protocol_no_socks_handshake_can_answer_stays_unknown() {
for proxy_type in ["ss", "vless", "", "something-new"] {
assert_eq!(
udp_verdict_for_type(proxy_type),
Some(UdpSupport::Unknown),
"{proxy_type}"
);
}
}
#[test]
fn only_an_accepted_association_counts_as_yes() {
assert_eq!(udp_verdict_from_socks_reply(0x00), UdpSupport::Yes);
// Every stated refusal is a refusal, including 0xFF, which is not in
// RFC 1928 but is what a real residential gateway answers.
for refused in [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0xFF] {
assert_eq!(
udp_verdict_from_socks_reply(refused),
UdpSupport::No,
"reply {refused:#04x}"
);
}
}
#[test]
fn the_default_verdict_is_unknown_so_an_old_receipt_never_claims_support() {
assert_eq!(UdpSupport::default(), UdpSupport::Unknown);
assert_eq!(serde_json::to_string(&UdpSupport::Yes).unwrap(), "\"yes\"");
assert_eq!(
serde_json::from_str::<UdpSupport>("\"unknown\"").unwrap(),
UdpSupport::Unknown
);
}
#[tokio::test]
async fn a_proxy_that_cannot_be_reached_reports_unknown_not_no() {
let settings = ProxySettings {
proxy_type: "socks5".to_string(),
// Discard port on loopback: nothing is listening, so the dial fails.
host: "127.0.0.1".to_string(),
port: 9,
username: None,
password: None,
vless_uri: None,
};
assert_eq!(probe_udp_support(&settings).await, UdpSupport::Unknown);
}
#[tokio::test]
async fn a_socks5_server_that_accepts_the_association_reports_yes() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
stream.write_all(&[SOCKS5, AUTH_NONE]).await.unwrap();
let mut request = [0u8; 10];
stream.read_exact(&mut request).await.unwrap();
assert_eq!(request[1], CMD_UDP_ASSOCIATE);
stream
.write_all(&[
SOCKS5,
REP_SUCCEEDED,
0x00,
ATYP_IPV4,
127,
0,
0,
1,
0x11,
0x11,
])
.await
.unwrap();
});
let settings = ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port,
username: None,
password: None,
vless_uri: None,
};
assert_eq!(probe_udp_support(&settings).await, UdpSupport::Yes);
}
/// A refusal that arrives as two bytes and a closed socket, which is what a
/// real residential gateway sends. Reading a full reply header here would
/// hit end-of-file and report "unknown" for a proxy that plainly said no.
#[tokio::test]
async fn a_truncated_refusal_is_still_a_refusal() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
stream.write_all(&[SOCKS5, AUTH_NONE]).await.unwrap();
let mut request = [0u8; 10];
stream.read_exact(&mut request).await.unwrap();
stream.write_all(&[SOCKS5, 0xFF]).await.unwrap();
});
let settings = ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port,
username: None,
password: None,
vless_uri: None,
};
assert_eq!(probe_udp_support(&settings).await, UdpSupport::No);
}
/// A server that will not accept the offered authentication never got asked
/// about UDP, so the answer is "unknown", not "no".
#[tokio::test]
async fn a_refused_handshake_reports_unknown() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
stream
.write_all(&[SOCKS5, AUTH_UNACCEPTABLE])
.await
.unwrap();
});
let settings = ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port,
username: None,
password: None,
vless_uri: None,
};
assert_eq!(probe_udp_support(&settings).await, UdpSupport::Unknown);
}
#[tokio::test]
async fn a_socks5_server_that_refuses_the_command_reports_no() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0u8; 4];
stream.read_exact(&mut greeting).await.unwrap();
stream.write_all(&[SOCKS5, AUTH_USERPASS]).await.unwrap();
let mut header = [0u8; 2];
stream.read_exact(&mut header).await.unwrap();
let mut user = vec![0u8; header[1] as usize];
stream.read_exact(&mut user).await.unwrap();
let mut password_len = [0u8; 1];
stream.read_exact(&mut password_len).await.unwrap();
let mut password = vec![0u8; password_len[0] as usize];
stream.read_exact(&mut password).await.unwrap();
assert_eq!(user, b"probe-user");
stream.write_all(&[0x01, 0x00]).await.unwrap();
let mut request = [0u8; 10];
stream.read_exact(&mut request).await.unwrap();
stream
.write_all(&[
SOCKS5,
REP_CMD_NOT_SUPPORTED,
0x00,
ATYP_IPV4,
0,
0,
0,
0,
0,
0,
])
.await
.unwrap();
});
let settings = ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port,
username: Some("probe-user".to_string()),
password: Some("probe-pass".to_string()),
vless_uri: None,
};
assert_eq!(probe_udp_support(&settings).await, UdpSupport::No);
}
}
+560
View File
@@ -0,0 +1,560 @@
//! Recording what a person does, as a recipe.
//!
//! The browser reports real input at the browser-process level
//! (`Wayfern.enableInputCapture` / `Wayfern.inputCaptured`), so a recording
//! sees what the user actually did rather than what a page chose to expose.
//! Each event is turned into the same typed step the agent recipes API
//! validates, so a recording can be saved as a recipe and replayed unchanged.
//!
//! Two rules shape everything here:
//!
//! * **A click becomes a locator, not a coordinate.** Coordinates are useless
//! on the next window size; the element under the pointer is resolved to its
//! role and accessible name, and only falls back to a CSS selector when the
//! element has no name worth matching.
//! * **A password is never recorded.** Typing into a password field produces no
//! step at all and no characters are kept, not even redacted ones: a recipe
//! is stored in the cloud, and a "redacted" field is still a place a secret
//! can end up.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::Mutex as AsyncMutex;
use crate::wayfern_cdp::WayfernSession;
/// Emitted as each step is recognised, so the UI can show the recipe growing.
pub const EVENT_RECORDED_STEP: &str = "recipe-recording-step";
/// Emitted when a recording stops, for any reason including the browser
/// closing under it. Payload: `{ "reason": "stopped" | "browser-gone" }`.
pub const EVENT_RECORDING_ENDED: &str = "recipe-recording-ended";
/// A recording holds at most this many steps. The API refuses a longer recipe,
/// and a recording that silently kept growing would be discarded at save time.
const MAX_STEPS: usize = 200;
/// What one recording has produced so far.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingStatus {
/// The profile being recorded, when one is.
pub profile_id: Option<String>,
pub steps: Vec<Value>,
/// True while the capture is live.
pub recording: bool,
}
#[derive(Default)]
struct RecorderState {
profile_id: Option<String>,
steps: Vec<Value>,
/// Set to stop the reader task; the task also stops when the socket closes.
cancel: Option<tokio::sync::oneshot::Sender<()>>,
/// Text typed into the focused field since the last flush, and whether that
/// field is a password (in which case nothing is kept).
pending_text: String,
pending_target: Option<Value>,
pending_is_password: bool,
}
lazy_static::lazy_static! {
static ref RECORDER: Arc<AsyncMutex<RecorderState>> = Arc::new(AsyncMutex::new(RecorderState::default()));
}
fn err(code: &str) -> String {
json!({ "code": code }).to_string()
}
/// The locator a recorded step should carry for `node`, or a CSS selector when
/// the element has no name to match on.
///
/// Role and name come from the accessibility tree, which is what the resolver
/// on the other side matches against, so a step recorded here resolves there.
pub fn target_from_description(
role: Option<&str>,
name: Option<&str>,
selector: Option<&str>,
) -> Option<Value> {
let role = role.map(str::trim).filter(|role| {
!role.is_empty() && *role != "none" && *role != "generic" && *role != "GenericContainer"
});
let name = name
.map(|name| name.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|name| !name.is_empty() && name.chars().count() <= 500);
if let Some(name) = name {
let mut locator = serde_json::Map::new();
if let Some(role) = role {
locator.insert("role".to_string(), json!(role));
}
locator.insert("name".to_string(), json!(name));
return Some(json!({ "locator": Value::Object(locator) }));
}
// No accessible name: a locator on role alone would match half the page, so
// the selector is the honest handle. Without either there is nothing to
// record and the click is dropped rather than guessed at.
let selector = selector.map(str::trim).filter(|s| !s.is_empty())?;
Some(json!({ "selector": selector }))
}
/// Merge a target (`{selector}` or `{locator}`) into a step object.
fn with_target(mut step: serde_json::Map<String, Value>, target: &Value) -> Value {
if let Some(object) = target.as_object() {
for (key, value) in object {
step.insert(key.clone(), value.clone());
}
}
Value::Object(step)
}
/// Whether this navigation is worth a step of its own.
///
/// A click that follows a link navigates, and recording both would replay the
/// click and then jump to where it already went. Only a navigation the user
/// asked for directly — typed into the address bar, or the first page — is a
/// step, which is what `type: "typed"` and `"other"` mean in the transition.
pub fn navigation_is_user_intent(transition_type: Option<&str>) -> bool {
matches!(
transition_type,
Some("typed") | Some("auto_bookmark") | Some("generated") | Some("keyword")
)
}
/// The step a captured key sequence becomes, or `None` when nothing should be
/// recorded (a password, or no text at all).
pub fn typing_step(text: &str, target: Option<&Value>, is_password: bool) -> Option<Value> {
if is_password || text.is_empty() {
return None;
}
let target = target?;
let mut step = serde_json::Map::new();
step.insert("type".to_string(), json!("type"));
step.insert("text".to_string(), json!(text));
Some(with_target(step, target))
}
/// The step a captured click becomes.
pub fn click_step(target: &Value) -> Value {
let mut step = serde_json::Map::new();
step.insert("type".to_string(), json!("click"));
with_target(step, target)
}
/// The step a user-driven navigation becomes.
pub fn navigate_step(url: &str) -> Option<Value> {
let url = url.trim();
if !(url.starts_with("http://") || url.starts_with("https://")) {
return None;
}
Some(json!({ "type": "navigate", "url": url }))
}
/// Ask the page what sits at a viewport point, and describe it well enough to
/// build a target from.
async fn describe_point(session: &mut WayfernSession, x: f64, y: f64) -> Option<Value> {
let node = session
.call(
"DOM.getNodeForLocation",
json!({ "x": x, "y": y, "includeUserAgentShadowDOM": false }),
)
.await
.ok()?;
let backend_node_id = node.get("backendNodeId").and_then(Value::as_i64)?;
// The accessibility view is what the resolver on the other side matches, so
// the role and name are read from there rather than from the tag and text.
let ax = session
.call(
"Accessibility.getPartialAXTree",
json!({ "backendNodeId": backend_node_id, "fetchRelatives": false }),
)
.await
.ok();
let (role, name) = ax
.as_ref()
.and_then(|ax| ax.get("nodes")?.as_array()?.first().cloned())
.map(|node| {
(
node["role"]["value"].as_str().map(str::to_string),
node["name"]["value"].as_str().map(str::to_string),
)
})
.unwrap_or((None, None));
// A selector for the fallback, and the tag so a password field is known.
let described = session
.call(
"DOM.describeNode",
json!({ "backendNodeId": backend_node_id }),
)
.await
.ok();
let selector = described.as_ref().and_then(|described| {
let node = described.get("node")?;
let attributes = node.get("attributes")?.as_array()?;
let mut id = None;
let mut kind = None;
for pair in attributes.chunks(2) {
match (pair.first()?.as_str()?, pair.get(1)?.as_str()?) {
("id", value) if !value.trim().is_empty() => id = Some(value.to_string()),
("type", value) => kind = Some(value.to_string()),
_ => {}
}
}
let _ = kind;
id.map(|id| format!("#{id}"))
});
Some(json!({
"role": role,
"name": name,
"selector": selector,
"isPassword": described
.as_ref()
.map(is_password_node)
.unwrap_or(false),
}))
}
/// Whether a described node is a password field.
pub fn is_password_node(described: &Value) -> bool {
let Some(attributes) = described["node"]["attributes"].as_array() else {
return false;
};
attributes.chunks(2).any(|pair| {
match (
pair.first().and_then(Value::as_str),
pair.get(1).and_then(Value::as_str),
) {
// The attribute name is the page's to spell: HTML is case-insensitive
// here and a field spelled `TYPE` is still a password field.
(Some(name), Some(value)) if name.eq_ignore_ascii_case("type") => {
value.eq_ignore_ascii_case("password")
}
_ => false,
}
})
}
/// Start recording the profile's browser.
#[tauri::command]
pub async fn start_recipe_recording(
app_handle: tauri::AppHandle,
profile_id: String,
) -> Result<RecordingStatus, String> {
{
let state = RECORDER.lock().await;
if state.profile_id.is_some() {
return Err(err("RECORDING_ALREADY_RUNNING"));
}
}
let profile = crate::profile::ProfileManager::instance()
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))?;
if !crate::wayfern_manager::supports_wayfern_152(&profile.version) {
return Err(err("WAYFERN_152_REQUIRED"));
}
let target = crate::cdp_target::resolve(&profile)
.await
.map_err(|_| crate::backend_error("PROFILE_NOT_RUNNING"))?;
let mut session = WayfernSession::open(&target)
.await
.map_err(|e| crate::backend_error_with_detail("RECORDING_FAILED", e.to_string()))?;
session
.call("DOM.enable", json!({}))
.await
.map_err(|e| crate::backend_error_with_detail("RECORDING_FAILED", e.to_string()))?;
let _ = session.call("Accessibility.enable", json!({})).await;
let _ = session.call("Page.enable", json!({})).await;
session
.call(
"Wayfern.enableInputCapture",
json!({ "trackMouseMove": false }),
)
.await
.map_err(|e| crate::backend_error_with_detail("RECORDING_FAILED", e.to_string()))?;
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
{
let mut state = RECORDER.lock().await;
state.profile_id = Some(profile_id.clone());
state.steps.clear();
state.pending_text.clear();
state.pending_target = None;
state.pending_is_password = false;
state.cancel = Some(cancel_tx);
}
tauri::async_runtime::spawn(async move {
read_events(app_handle, session, cancel_rx).await;
});
Ok(RecordingStatus {
profile_id: Some(profile_id),
steps: Vec::new(),
recording: true,
})
}
/// Read captured input until the recording is stopped or the browser goes.
async fn read_events(
app_handle: tauri::AppHandle,
mut session: WayfernSession,
mut cancel: tokio::sync::oneshot::Receiver<()>,
) {
let reason = loop {
let event = tokio::select! {
_ = &mut cancel => break "stopped",
event = session.await_any_event(
&["Wayfern.inputCaptured", "Page.frameNavigated"],
std::time::Duration::from_secs(3600),
) => event,
};
match event {
Ok(Some((method, params))) => {
handle_event(&app_handle, &mut session, &method, &params).await;
}
// A quiet hour is not a reason to stop; a closed socket is.
Ok(None) => continue,
Err(_) => break "browser-gone",
}
};
let _ = session.call("Wayfern.disableInputCapture", json!({})).await;
session.close().await;
{
let mut state = RECORDER.lock().await;
if reason == "browser-gone" {
state.profile_id = None;
}
state.cancel = None;
}
let _ = crate::events::emit(EVENT_RECORDING_ENDED, json!({ "reason": reason }));
}
async fn handle_event(
app_handle: &tauri::AppHandle,
session: &mut WayfernSession,
method: &str,
params: &Value,
) {
let _ = app_handle;
match method {
"Page.frameNavigated" => {
// Only the main frame, and only when the user asked for it.
if params["frame"]["parentId"].is_string() {
return;
}
if !navigation_is_user_intent(params["frame"]["transitionType"].as_str()) {
return;
}
flush_typing().await;
if let Some(step) = params["frame"]["url"].as_str().and_then(navigate_step) {
push_step(step).await;
}
}
"Wayfern.inputCaptured" => match params["type"].as_str() {
Some("mousedown") => {
let (Some(x), Some(y)) = (params["x"].as_f64(), params["y"].as_f64()) else {
return;
};
if params["button"]
.as_str()
.is_some_and(|button| button != "left")
{
return;
}
flush_typing().await;
let Some(described) = describe_point(session, x, y).await else {
return;
};
let target = target_from_description(
described["role"].as_str(),
described["name"].as_str(),
described["selector"].as_str(),
);
// Remember where the next keystrokes are going, and whether that field
// is one whose characters must never be kept.
{
let mut state = RECORDER.lock().await;
state.pending_target = target.clone();
state.pending_is_password = described["isPassword"].as_bool().unwrap_or(false);
}
if let Some(target) = target {
push_step(click_step(&target)).await;
}
}
Some("char") => {
let Some(text) = params["text"].as_str() else {
return;
};
let mut state = RECORDER.lock().await;
if state.pending_is_password {
return;
}
if state.pending_text.chars().count() < 4000 {
state.pending_text.push_str(text);
}
}
Some("keydown") => {
// A key that submits or moves focus ends the current field's text.
if matches!(params["key"].as_str(), Some("Enter") | Some("Tab")) {
flush_typing().await;
}
}
_ => {}
},
_ => {}
}
}
/// Turn the characters typed so far into a step, if they are worth keeping.
async fn flush_typing() {
let step = {
let mut state = RECORDER.lock().await;
let text = std::mem::take(&mut state.pending_text);
let step = typing_step(
&text,
state.pending_target.as_ref(),
state.pending_is_password,
);
state.pending_is_password = false;
step
};
if let Some(step) = step {
push_step(step).await;
}
}
async fn push_step(step: Value) {
let mut state = RECORDER.lock().await;
if state.profile_id.is_none() || state.steps.len() >= MAX_STEPS {
return;
}
state.steps.push(step.clone());
drop(state);
let _ = crate::events::emit(EVENT_RECORDED_STEP, step);
}
/// What has been recorded so far.
#[tauri::command]
pub async fn get_recipe_recording() -> Result<RecordingStatus, String> {
let state = RECORDER.lock().await;
Ok(RecordingStatus {
profile_id: state.profile_id.clone(),
steps: state.steps.clone(),
recording: state.profile_id.is_some() && state.cancel.is_some(),
})
}
/// Stop recording and hand back the steps.
#[tauri::command]
pub async fn stop_recipe_recording() -> Result<RecordingStatus, String> {
flush_typing().await;
let (steps, profile_id) = {
let mut state = RECORDER.lock().await;
if let Some(cancel) = state.cancel.take() {
let _ = cancel.send(());
}
let steps = std::mem::take(&mut state.steps);
let profile_id = state.profile_id.take();
state.pending_target = None;
state.pending_text.clear();
(steps, profile_id)
};
Ok(RecordingStatus {
profile_id,
steps,
recording: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_click_records_what_the_resolver_can_find_again() {
// A named element travels as a locator, which survives a different window
// size; a coordinate would not.
let target =
target_from_description(Some("button"), Some(" Buy now "), Some("#buy")).unwrap();
assert_eq!(
target,
json!({ "locator": { "role": "button", "name": "Buy now" } }),
"the accessible name is collapsed, and the selector is not needed"
);
assert_eq!(
click_step(&target),
json!({ "type": "click", "locator": { "role": "button", "name": "Buy now" } })
);
// No name: the selector is the only honest handle.
assert_eq!(
target_from_description(Some("generic"), None, Some("#cell")).unwrap(),
json!({ "selector": "#cell" })
);
// A role that matches half the page is not a locator on its own.
assert_eq!(
target_from_description(Some("generic"), Some(" "), Some(" ")),
None
);
assert_eq!(target_from_description(None, None, None), None);
}
#[test]
fn a_password_is_not_recorded_at_all() {
let target = json!({ "selector": "#pass" });
assert_eq!(
typing_step("hunter2", Some(&target), true),
None,
"not even a redacted step: a recipe is stored in the cloud"
);
assert_eq!(
typing_step("hello", Some(&target), false),
Some(json!({ "type": "type", "text": "hello", "selector": "#pass" }))
);
assert_eq!(typing_step("", Some(&target), false), None);
assert_eq!(typing_step("hello", None, false), None);
}
#[test]
fn a_password_field_is_recognised_from_what_the_browser_describes() {
let password = json!({
"node": { "attributes": ["type", "password", "name", "pw"] }
});
assert!(is_password_node(&password));
let text = json!({ "node": { "attributes": ["type", "text"] } });
assert!(!is_password_node(&text));
assert!(!is_password_node(&json!({ "node": {} })));
// Case is the page's choice, not a signal.
assert!(is_password_node(&json!({
"node": { "attributes": ["TYPE", "PASSWORD"] }
})));
}
#[test]
fn only_a_navigation_the_user_asked_for_becomes_a_step() {
assert!(navigation_is_user_intent(Some("typed")));
assert!(navigation_is_user_intent(Some("keyword")));
// A link click is already recorded as the click; recording the landing too
// would replay the click and then jump past whatever it did.
assert!(!navigation_is_user_intent(Some("link")));
assert!(!navigation_is_user_intent(Some("form_submit")));
assert!(!navigation_is_user_intent(None));
assert_eq!(
navigate_step(" https://example.com/shop "),
Some(json!({ "type": "navigate", "url": "https://example.com/shop" }))
);
// A recipe that opens a local file is not a recipe anyone should replay.
assert_eq!(navigate_step("file:///etc/passwd"), None);
assert_eq!(navigate_step("about:blank"), None);
}
}
+117 -30
View File
@@ -2,20 +2,20 @@
//! machine.
//!
//! Remote execution — an interactive remote session or a Cookie Bot night — runs
//! the browser on a leased fleet host, but the PROFILE (and its proxy, and its
//! VPN config) is pulled from the user's sync namespace. Nothing in that
//! handover rewrites addresses, so a proxy recorded as `127.0.0.1:8080` arrives
//! on the fleet host meaning *the fleet host's own loopback*.
//! the browser on a remote host, but the PROFILE (and its proxy, and its VPN
//! config) is pulled from the user's sync namespace. Addresses are not rewritten
//! in transit, so a proxy recorded as `127.0.0.1:8080` arrives meaning *that
//! machine's own loopback*.
//!
//! That is the whole bug this module exists to prevent. The server already
//! refuses a profile with NO exit (`proxy_required`), because a night browsed
//! from the fleet's datacenter address damages an identity rather than building
//! it — but it was asking whether an exit was *configured*, never whether it was
//! *reachable*. A local proxy satisfied the first question and failed the
//! second, so the run was accepted, dispatched, and burned a leased host either
//! erroring out or (worse) egressing direct from the datacenter: exactly the
//! outcome `proxy_required` exists to stop, reached by the one route it did not
//! check.
//! That is the whole bug this module exists to prevent. A profile with NO exit
//! is already refused (`proxy_required`), because a night browsed without the
//! user's own exit damages an identity rather than building it — but "an exit is
//! configured" and "that exit is reachable from somewhere else" are different
//! questions, and only the first was ever asked. A local proxy satisfied it and
//! failed the second, so the run was accepted, dispatched, and burned an hour
//! either erroring out or (worse) egressing from the remote host's own address:
//! exactly the outcome `proxy_required` exists to stop, reached by the one route
//! it did not check.
//!
//! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a
//! locally-run SOCKS client and Donut's own VLESS support all present to the
@@ -24,11 +24,11 @@
//! This module is the single answer, shared by every caller, and it FAILS
//! CLOSED: anything it cannot parse is reported as unreachable. Refusing a
//! working setup costs the user one support question; accepting a broken one
//! costs a burned hour and a damaged profile identity.
//! costs an hour of quota and a damaged profile identity.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// Whether a leased fleet host could dial this profile's exit.
/// Whether a remote host could dial this profile's exit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExitReachability {
/// No proxy and no VPN. The caller's existing "no exit" refusal applies.
@@ -42,6 +42,19 @@ pub enum ExitReachability {
/// Which part of the config it came from: "proxy" or "VPN".
source: &'static str,
},
/// A protocol a remote host has no way to speak, whatever address it names.
///
/// Reachability is the wrong question for these: the server in a VLESS URI is
/// as publicly routable as any other, so the host check passes and the run is
/// accepted, dispatched, and then refused remotely, because dialling VLESS
/// needs a sidecar that is not available there. That is a permanent refusal
/// wearing a transient one's clothes, and every nightly retry pays for it.
UnsupportedKind {
/// The protocol, as the user would name it: "VLESS".
kind: String,
/// Which part of the config it came from: "proxy" or "VPN".
source: &'static str,
},
/// Configured, but this code could not determine the host.
///
/// Treated as unreachable by [`ExitReachability::is_remote`] — see the
@@ -66,6 +79,11 @@ impl ExitReachability {
"The {source} for this profile points at {host}, which only exists on this computer. \
Remote runs happen on our hosts and cannot reach it."
)),
ExitReachability::UnsupportedKind { kind, source } => Some(format!(
"The {source} for this profile is {kind}, which needs the Xray sidecar our hosts do not \
run, so the fleet cannot dial it however reachable its server is. An HTTP, HTTPS, SOCKS \
or WireGuard exit works."
)),
ExitReachability::Unknown { reason, source } => Some(format!(
"The {source} for this profile could not be read ({reason}), so we cannot confirm a \
remote host could use it."
@@ -99,7 +117,7 @@ pub fn host_is_remote_reachable(host: &str) -> bool {
}
// Suffixes reserved for local/private name resolution (RFC 6762 mDNS, RFC
// 8375, and the names router vendors hand out on a LAN). A fleet host
// 8375, and the names router vendors hand out on a LAN). A remote host
// resolving one of these gets its own network's answer, not the user's.
const LOCAL_SUFFIXES: [&str; 7] = [
".local",
@@ -115,7 +133,7 @@ pub fn host_is_remote_reachable(host: &str) -> bool {
}
// A bare single-label name ("my-proxy", "router") is only resolvable through
// a local search domain, so it is no more use to a fleet host than `.local`.
// a local search domain, so it is no more use to a remote host than `.local`.
if !lower.contains('.') {
return false;
}
@@ -257,8 +275,39 @@ pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result<Strin
Ok(host)
}
/// Whether a stored proxy speaks a protocol no remote host can dial.
///
/// Reads the two facts that both mean VLESS, because either one alone is a real
/// record: the picker writes `proxy_type = "vless"`, and a config pasted as a
/// bare URI carries the protocol in `vless_uri` before the type is normalised.
/// Taking only the first would let the second through to the host that refuses
/// it.
fn unsupported_remote_kind(settings: &crate::browser::ProxySettings) -> Option<String> {
let has_uri = settings
.vless_uri
.as_deref()
.is_some_and(|uri| !uri.trim().is_empty());
if settings.proxy_type.eq_ignore_ascii_case("vless") || has_uri {
return Some("VLESS".to_string());
}
None
}
/// Classify a stored proxy.
///
/// Protocol first, address second. A VLESS config names a perfectly routable
/// server, so asking the address question first answers `Remote` for an exit no
/// remote host can use; the kind has to disqualify it before the host is looked
/// at. (`proxy_exit_host` still resolves a VLESS server, because "which machine
/// does this dial" remains a real question for a log line.)
pub fn classify_proxy(settings: &crate::browser::ProxySettings) -> ExitReachability {
if let Some(kind) = unsupported_remote_kind(settings) {
return ExitReachability::UnsupportedKind {
kind,
source: "proxy",
};
}
match proxy_exit_host(settings) {
Err(reason) => ExitReachability::Unknown {
reason,
@@ -380,7 +429,7 @@ mod tests {
#[test]
fn lan_only_names_are_local() {
// A fleet host resolving these gets ITS network's answer, not the user's —
// A remote host resolving these gets ITS network's answer, not the user's —
// which is worse than failing, because it may well succeed against
// something unrelated.
for host in [
@@ -408,27 +457,62 @@ mod tests {
}
#[test]
fn a_vless_proxy_is_judged_by_its_server_not_its_local_port() {
// THE asymmetry. Donut points the browser at a local xray worker, so the
// browser-facing address of every VLESS proxy is 127.0.0.1 — but the stored
// config names a real server, and that is what a fleet host would dial.
// Classifying VLESS off `settings.host` would refuse every VLESS profile.
fn a_vless_proxy_is_refused_however_public_its_server_is() {
// This URI names a routable server, so the address check says Remote and
// the enrolment is accepted; every night after that the run is refused
// remotely, because dialling VLESS needs a sidecar that is not available
// there. The protocol has to disqualify the exit here, where no remote hour
// has been spent yet.
let mut settings = proxy("vless", "127.0.0.1");
settings.vless_uri =
Some("vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?type=tcp#node".into());
assert_eq!(classify_proxy(&settings), ExitReachability::Remote);
assert_eq!(
classify_proxy(&settings),
ExitReachability::UnsupportedKind {
kind: "VLESS".to_string(),
source: "proxy",
}
);
assert!(!classify_proxy(&settings).is_remote());
// The refusal has to name the protocol and the exits that do work, or it
// reads as "your proxy is broken" for a proxy that is fine everywhere else.
let detail = classify_proxy(&settings).refusal_detail().unwrap();
assert!(detail.contains("VLESS"), "{detail}");
assert!(detail.contains("Xray"), "{detail}");
assert!(detail.contains("SOCKS"), "{detail}");
}
#[test]
fn a_vless_uri_pointing_at_loopback_is_still_local() {
fn a_vless_uri_is_refused_even_when_the_type_field_disagrees() {
// A config pasted as a bare URI can land with the type still unnormalised.
// Reading only `proxy_type` would classify this one on `settings.host` —
// which for VLESS is the local xray worker, so it would come back LocalOnly
// and tell the user to swap a proxy whose real problem is its protocol.
let mut settings = proxy("socks5", "1.2.3.4");
settings.vless_uri = Some("vless://uuid@vpn.example.com:443?type=tcp".into());
assert_eq!(
classify_proxy(&settings),
ExitReachability::UnsupportedKind {
kind: "VLESS".to_string(),
source: "proxy",
}
);
}
#[test]
fn a_vless_uri_pointing_at_loopback_is_refused_on_its_kind() {
// Local AND unsupported. Either verdict blocks the run, but the kind is the
// one the user has to act on: fixing the address still leaves an exit no
// remote host can dial.
let mut settings = proxy("vless", "127.0.0.1");
settings.vless_uri = Some("vless://uuid@127.0.0.1:443?type=tcp".into());
assert_eq!(
classify_proxy(&settings),
ExitReachability::LocalOnly {
host: "127.0.0.1".to_string(),
ExitReachability::UnsupportedKind {
kind: "VLESS".to_string(),
source: "proxy",
}
);
@@ -452,9 +536,7 @@ mod tests {
// Unknown must never be treated as usable: the point of the check is that
// we could not confirm reachability, and guessing "yes" reintroduces the
// exact failure it prevents.
let mut settings = proxy("vless", "");
settings.vless_uri = None;
let verdict = classify_proxy(&settings);
let verdict = classify_proxy(&proxy("socks5", " "));
assert!(matches!(verdict, ExitReachability::Unknown { .. }));
assert!(!verdict.is_remote());
@@ -504,6 +586,11 @@ mod tests {
source: "proxy"
}
.is_remote());
assert!(!ExitReachability::UnsupportedKind {
kind: "VLESS".into(),
source: "proxy"
}
.is_remote());
// `None` has no detail: the caller's existing "no exit at all" refusal is
// the better message, and two refusals for one condition read as a bug.
assert!(ExitReachability::None.refusal_detail().is_none());
+47 -16
View File
@@ -1,6 +1,6 @@
//! What a remote session owes this machine, and the gate that collects it.
//!
//! A profile that runs on the leased fleet is written by the host, not here.
//! A profile that runs remotely is written by that host, not here.
//! The host pushes it back to cloud storage when the session ends, and until
//! this machine has pulled that push, the local profile directory is a stale
//! copy of something that has moved on.
@@ -21,9 +21,9 @@
//!
//! Two states, and the difference matters to the user:
//!
//! - [`HandoffState::Running`]: a session is live on the fleet. The profile lock
//! is held server-side, so a launch would be refused anyway; this makes the
//! refusal instant and legible instead of a round trip and a raw string.
//! - [`HandoffState::Running`]: a session is live remotely. The profile lock is
//! held, so a launch would be refused anyway; this makes the refusal instant
//! and legible instead of a round trip and a raw string.
//! - [`HandoffState::PendingSync`]: the session is over, the lock is released,
//! and the work is sitting in cloud storage. This is the window that used to
//! be wide open.
@@ -40,10 +40,10 @@ pub const EVENT_REMOTE_HANDOFF: &str = "remote-handoff-changed";
///
/// The entry survives a failure, so "giving up" only means this burst stops;
/// the next stream event, app start or manual sync tries again. What the retries
/// buy is the common case: the profile lock is released server-side a moment
/// before this machine's cached copy of it expires, and a single attempt would
/// hit `Skipped("profile is locked elsewhere")` and leave the user blocked for
/// no reason.
/// buy is the common case: the profile lock is released a moment before this
/// machine's cached copy of it expires, and a single attempt would hit
/// `Skipped("profile is locked elsewhere")` and leave the user blocked for no
/// reason.
const PULL_ATTEMPTS: u32 = 5;
/// Delay before the second pull attempt. Doubles, capped by [`PULL_RETRY_MAX`].
@@ -53,11 +53,11 @@ const PULL_RETRY_BASE: Duration = Duration::from_secs(2);
/// attempts is guaranteed to span at least one refresh of the lock cache.
const PULL_RETRY_MAX: Duration = Duration::from_secs(45);
/// Where a profile stands with respect to the fleet.
/// Where a profile stands with respect to remote execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HandoffState {
/// A session is live on the fleet right now.
/// A session is live remotely right now.
Running,
/// A session has finished and its work has not been pulled down yet.
PendingSync,
@@ -167,7 +167,7 @@ pub fn state_for(profile_id: &str) -> Option<HandoffState> {
with_store(|store| store.get(profile_id).map(|entry| entry.state))
}
/// The session currently holding this profile on the fleet, if any.
/// The session currently holding this profile remotely, if any.
///
/// Answers for a `provisioning` session too, which the drivable-session index
/// deliberately does not. Stopping a session that has not finished coming up is
@@ -196,7 +196,7 @@ pub fn profile_for_session(session_id: &str) -> Option<String> {
})
}
/// Record that a session is live on the fleet for this profile.
/// Record that a session is live remotely for this profile.
///
/// Written to disk immediately, and this is the point of the whole store: if the
/// app is closed while a session runs, nothing on restart would otherwise
@@ -339,6 +339,22 @@ pub fn resume_pending_pulls(app_handle: &tauri::AppHandle) {
}
}
/// Whether a profile with this id exists on THIS device.
///
/// A transient failure to read the profile list returns `true`, deliberately:
/// the caller only clears a gate when this is `false`, and clearing one during
/// a momentary read error would unblock a local profile whose remote work is
/// genuinely still pending. "Cannot tell" must never mean "gone".
fn profile_exists_locally(profile_id: &str) -> bool {
let Ok(uuid) = uuid::Uuid::parse_str(profile_id) else {
return false;
};
crate::profile::ProfileManager::instance()
.list_profiles()
.map(|profiles| profiles.iter().any(|p| p.id == uuid))
.unwrap_or(true)
}
/// Pull one profile's finished session down, then lift its gate.
///
/// Spawned rather than awaited by its callers: a stream frame and a stop button
@@ -370,6 +386,20 @@ pub fn schedule_pull(app_handle: tauri::AppHandle, profile_id: String) {
}
Ok(_) => unreachable!("is_completed covers every completed outcome"),
Err(e) => {
// A profile that was created and run entirely on a remote host may
// not exist on this device at all: nothing to pull, nothing to gate.
// The pull would fail with "not found" on every attempt and the entry
// would sit in the handoff store for ever. Clear it — the gate only
// protects a LOCAL profile from being opened over unsynced remote
// work, and there is no local profile here.
if !profile_exists_locally(&profile_id) {
log::info!(
"Clearing the post-session gate for profile {profile_id}: it has no local copy \
(created and run remotely), so there is nothing to pull or protect"
);
clear(&profile_id);
return;
}
log::warn!("Post-session pull for profile {profile_id} failed: {e}");
}
}
@@ -478,10 +508,11 @@ mod tests {
#[test]
fn a_closed_session_this_machine_never_watched_does_not_gate_anything() {
// `listForUser` returns closed sessions next to live ones, so the snapshot
// on every reconnect replays every session that ever finished. Treating
// those as fresh handoffs would block the Run button on a perfectly current
// profile at each app start, and block it indefinitely while offline.
// The session listing returns closed sessions next to live ones, so the
// snapshot on every reconnect replays every session that ever finished.
// Treating those as fresh handoffs would block the Run button on a
// perfectly current profile at each app start, and block it indefinitely
// while offline.
let _iso = isolated();
assert!(!note_ended("p1", "s-finished-last-week"));
assert_eq!(state_for("p1"), None);
+219 -55
View File
@@ -1,10 +1,9 @@
//! Launching a profile on a remote VM.
//!
//! The desktop app never talks to the Wayfern manager directly. It asks
//! donutbrowser-infra, which holds the service-account credentials and is the
//! only party that can mint a donut-sync token scoped to this user's namespace.
//! That indirection is the point: a desktop client that could call the manager
//! itself would need credentials capable of launching sessions for anyone.
//! The desktop app never leases a remote host itself. It asks the Donut cloud
//! API, which is the only party holding credentials that can do so. That
//! indirection is the point: a desktop client able to lease directly would need
//! credentials capable of launching sessions for anyone.
use crate::cloud_errors::{self, FailureCodes};
use crate::profile::types::BrowserProfile;
@@ -116,14 +115,35 @@ pub fn idempotency_key(profile_id: &str, attempt: &str) -> String {
format!("run-remote:{profile_id}:{attempt}")
}
/// Connect timeout for every remote-session control call.
const CONTROL_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// Total budget for the launch POST. A host is leased while this request is
/// open, so it is the one call that legitimately takes tens of seconds; without
/// a ceiling a hung server hangs the click for minutes.
const LAUNCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);
/// Total budget for the read, stop and list calls, which are quick or broken.
const CONTROL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// An HTTP client for a control call, with a short connect timeout and a total
/// ceiling, built the same way as the session-events client below rather than
/// the bare `reqwest::Client::new()` these calls used to use — which inherited
/// no timeout at all.
fn control_client(total: std::time::Duration) -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(CONTROL_CONNECT_TIMEOUT)
.timeout(total)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
/// Whether this profile's exit rules out running it on a leased host.
///
/// A session runs on a fleet host that pulls the profile — and its proxy record
/// — out of the user's sync namespace, rewriting no addresses along the way. A
/// proxy stored as `127.0.0.1:8080` therefore arrives meaning THAT host's
/// loopback: the browser either cannot connect and the leased hour is burned on
/// a session that never worked, or it falls through and the user's identity
/// egresses from our datacenter. The Cookie Bot has refused this since
/// A session opens the profile — and its stored proxy — on a remote host
/// exactly as this machine wrote them, addresses and all. A proxy stored as
/// `127.0.0.1:8080` therefore arrives meaning THAT host's loopback: the browser
/// either cannot connect and the leased hour is burned on a session that never
/// worked, or it falls through and the user's identity egresses from a
/// datacenter. The Cookie Bot has refused this since
/// `remote_exit` existed; interactive sessions take the same profile onto the
/// same hosts and did not, so the same mistake cost a leased hour here.
///
@@ -136,13 +156,69 @@ pub fn idempotency_key(profile_id: &str, attempt: &str) -> String {
///
/// Split out from the launch because that is the only testable seam:
/// `exit_reachability` reads this machine's proxy and VPN stores and the launch
/// itself needs a fleet.
/// itself needs a remote host.
/// Local reasons a remote launch is refused, decided from the profile alone.
///
/// Split from the request and from `local_exit_refusal` so it is unit-testable
/// without a network or a sync scheduler, and so both the REST and the MCP
/// entry points get the identical answer from one place.
///
/// - The platform must be one a leased host serves. `resolved_os` can return a
/// fingerprint OS like `android` for a profile with no recorded host OS, and
/// no remote host runs that; refusing here gives a clear message instead of
/// leasing a host and taking a generic failure back.
/// - Sync must have completed at least once. A remote session opens the synced
/// copy and hands it back when it stops, so a profile with sync enabled but
/// `last_sync` still None (the first upload failed, or is only queued) would
/// open EMPTY and then come back over the real local copy as emptiness.
/// `remote_launch_precondition` catches sync being off and a sync in flight;
/// this catches the gap between them.
pub fn local_launch_refusal(
profile: &BrowserProfile,
platform: &str,
) -> Option<RemoteSessionError> {
if !crate::profile::types::is_host_os(platform) {
return Some(RemoteSessionError::Other(
serde_json::json!({
"code": "REMOTE_PLATFORM_UNSUPPORTED",
"params": { "platform": platform },
})
.to_string(),
));
}
if profile.is_sync_enabled() && profile.last_sync.is_none() {
return Some(RemoteSessionError::Other(
serde_json::json!({ "code": "REMOTE_PROFILE_NOT_SYNCED" }).to_string(),
));
}
None
}
fn local_exit_refusal(verdict: &ExitReachability) -> Option<RemoteSessionError> {
match verdict {
// An address anyone can dial, so the leased host can dial it too.
ExitReachability::Remote => None,
// See the second paragraph above: allowed on purpose, not overlooked.
ExitReachability::None => None,
// A protocol no remote host can dial, which is a different sentence from a
// local address: nothing about the ADDRESS is wrong, so "use a proxy with a
// public address" is advice the user cannot act on. VLESS needs a local
// sidecar that is not available remotely, and no retry changes that.
ExitReachability::UnsupportedKind { kind, .. } => {
log::warn!(
"Refusing an interactive remote session: {}",
verdict
.refusal_detail()
.unwrap_or_else(|| format!("the profile's exit is {kind}"))
);
Some(RemoteSessionError::Other(
serde_json::json!({
"code": "REMOTE_PROXY_KIND_UNSUPPORTED",
"params": { "kind": kind },
})
.to_string(),
))
}
// `LocalOnly`, plus `Unknown` — which `remote_exit` produces when it could
// not read the config and which fails closed by design, because "we could
// not confirm it" guessed as "yes" is the failure this whole check exists
@@ -164,7 +240,7 @@ fn local_exit_refusal(verdict: &ExitReachability) -> Option<RemoteSessionError>
}
}
/// Ask donutbrowser-infra to start a remote session for this profile.
/// Ask the cloud API to start a remote session for this profile.
///
/// Goes through `api_call_with_retry` so an expired access token is refreshed
/// and the request retried once, rather than surfacing to the user as a
@@ -182,6 +258,10 @@ pub async fn start_remote_session(
.to_string();
let profile_id = profile.id.to_string();
if let Some(refusal) = local_launch_refusal(profile, &platform) {
return Err(refusal);
}
// Checked here, before the request: the backend is told which profile to
// start but never sees the proxy record, so it cannot derive this — and by
// the time it could, an hour is already leased and billed. Resolving a proxy
@@ -205,7 +285,7 @@ pub async fn start_remote_session(
idempotency_key: key.clone(),
};
async move {
let response = reqwest::Client::new()
let response = control_client(LAUNCH_TIMEOUT)
.post(&endpoint)
.bearer_auth(token)
.json(&body)
@@ -270,13 +350,12 @@ pub fn note_session_stopped(app: &AppHandle, session_id: &str) {
}
}
/// Ask donutbrowser-infra to stop a remote session.
/// Ask the cloud API to stop a remote session.
///
/// Without this the only thing that ends a session is the fleet's own two-hour
/// cap, so every launch bills the full 7200s however briefly it was used — a
/// handful of runs exhausts an allowance meant for a hundred. The backend
/// refuses to retire a row it could not stop on the fleet, so a successful
/// return here means the browser is really down and the profile lock released.
/// Without this a session runs to its maximum duration however briefly it was
/// used, so a handful of runs exhausts an allowance meant for many. A stop the
/// server reports as successful means the browser is really down and the
/// profile lock released.
pub async fn end_remote_session(
session_id: &str,
) -> Result<EndRemoteSessionOutcome, RemoteSessionError> {
@@ -290,7 +369,7 @@ pub async fn end_remote_session(
.api_call_with_retry(|token| {
let endpoint = endpoint.clone();
async move {
let response = reqwest::Client::new()
let response = control_client(CONTROL_TIMEOUT)
.delete(&endpoint)
.bearer_auth(token)
.send()
@@ -327,8 +406,8 @@ pub fn classify_error_string(message: &str) -> RemoteSessionError {
/// A session as the backend currently sees it.
///
/// `POST /api/remote-sessions` hands back the literal string `provisioning`
/// and nothing else, so until this type existed the only way anyone observed a
/// session becoming usable was by reading the production database.
/// and nothing else, so until this type existed the only way to observe a
/// session becoming usable was to keep trying to drive it.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RemoteSessionState {
pub session_id: String,
@@ -338,9 +417,9 @@ pub struct RemoteSessionState {
pub platform: Option<String>,
/// `provisioning` | `ready` | `live` | `closed` | `error`.
///
/// Named `state` because that is what `RemoteSessionView` in
/// donutbrowser-infra actually sends. It carried the name `status` until a
/// real payload was compared against it, and because the field had no
/// Named `state` because that is what the cloud API actually sends. It
/// carried the name `status` until a real payload was compared against it,
/// and because the field had no
/// default, every list and single read failed at `missing field \`status\``
/// and surfaced as CLOUD_UNREACHABLE. The alias keeps the launch reply —
/// which predates the reconciled vocabulary and still says `status` —
@@ -410,7 +489,7 @@ async fn get_json<T: serde::de::DeserializeOwned>(
.api_call_with_retry(|token| {
let endpoint = endpoint.clone();
async move {
let response = reqwest::Client::new()
let response = control_client(CONTROL_TIMEOUT)
.get(&endpoint)
.bearer_auth(token)
.send()
@@ -805,8 +884,8 @@ fn is_heartbeat(kind: &str) -> bool {
/// Turn one decoded frame into the Tauri event and payload it becomes.
///
/// The discriminator lives INSIDE the JSON, not in the SSE `event:` line: Nest
/// only sets `MessageEvent.type` for the heartbeat, so every real frame arrives
/// The discriminator lives INSIDE the JSON, not in the SSE `event:` line: only
/// the heartbeat arrives with an `event:` name, so every real frame arrives
/// as the default `message` event carrying
/// `{"type":"snapshot"|"state"|"progress"|"closed","at":…,"sessions"|"session":…}`.
/// Routing on the event name alone emitted that whole envelope as a session, so
@@ -912,10 +991,10 @@ pub fn session_events_running() -> bool {
async fn run_session_events(app: AppHandle) {
let mut attempt = 0u32;
// Echoed back on reconnect as `Last-Event-ID`, per the SSE spec, IF the
// backend ever labels its frames. It does not today — `stream()` emits no
// `id:` line and keeps no replay buffer — so this stays `None` and nothing is
// resumed. What bounds the loss instead is the stream opening with a full
// snapshot, which re-states every session the caller still owns.
// server ever labels its frames. It does not today — no `id:` line ever
// arrives — so this stays `None` and nothing is resumed. What bounds the loss
// instead is the stream opening with a full snapshot, which re-states every
// session the caller still owns.
let mut last_event_id: Option<String> = None;
while STREAM_RUNNING.load(Ordering::SeqCst) {
@@ -957,7 +1036,11 @@ async fn run_session_events(app: AppHandle) {
/// Spread reconnects so every desktop that lost the same backend does not come
/// back in the same millisecond.
fn jittered(delay: Duration) -> Duration {
///
/// Shared with the MCP remote-control bridge: both reconnect to the same host,
/// so a deployment restart would otherwise bring every desktop back on two
/// synchronised timers instead of one spread one.
pub(crate) fn jittered(delay: Duration) -> Duration {
use rand::RngExt;
let factor = rand::rng().random_range(0.8f64..1.2f64);
delay.mul_f64(factor)
@@ -977,7 +1060,7 @@ async fn sleep_unless_stopped(total: Duration) {
/// Deliberately not the shared one: a total request timeout would kill a
/// healthy stream on schedule, so only the connect phase is bounded and
/// liveness is enforced by the idle timeout instead.
fn stream_client() -> &'static reqwest::Client {
pub(crate) fn stream_client() -> &'static reqwest::Client {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
@@ -1192,11 +1275,10 @@ mod tests {
#[test]
fn the_stop_response_parses_what_the_backend_actually_sends() {
// Pinned against EndRemoteSessionOutcome in donutbrowser-infra
// (apps/backend/src/remote-sessions/remote-sessions.service.ts). A field
// name that does not match makes every stop fail at the decode step, and
// the session then runs to the 2h cap and bills 7200s — the exact defect
// this endpoint exists to fix, reintroduced silently.
// Pinned against a real stop response from the cloud API. A field name that
// does not match makes every stop fail at the decode step, and the session
// then runs to its maximum duration — the exact defect this endpoint exists
// to fix, reintroduced silently.
let outcome: EndRemoteSessionOutcome =
serde_json::from_str(r#"{"session_id":"sess-1","status":"closed","billed_seconds":42}"#)
.expect("the backend's stop payload must deserialize");
@@ -1239,9 +1321,9 @@ mod tests {
#[test]
fn a_local_only_exit_is_refused_before_a_host_is_leased() {
// The profile and its proxy record are copied onto the fleet unrewritten,
// so this loopback address would mean the FLEET's loopback. Accepting the
// launch bills an hour for a session that cannot reach the user's exit.
// The profile's stored proxy travels exactly as written, so this loopback
// address would mean the REMOTE host's loopback. Accepting the launch spends
// an hour on a session that cannot reach the user's exit.
let refusal = local_exit_refusal(&ExitReachability::LocalOnly {
host: "127.0.0.1".to_string(),
source: "proxy",
@@ -1254,6 +1336,23 @@ mod tests {
);
}
#[test]
fn a_proxy_kind_the_fleet_cannot_dial_is_refused_in_its_own_words() {
// Not REMOTE_REQUIRES_REMOTE_EXIT_NODE. A VLESS server is publicly
// routable, so telling this user to "use a proxy with a public address"
// points them at the one part of their config that is already correct.
let refusal = local_exit_refusal(&ExitReachability::UnsupportedKind {
kind: "VLESS".to_string(),
source: "proxy",
})
.expect("no fleet host runs the xray sidecar VLESS needs");
let json: serde_json::Value =
serde_json::from_str(&refusal.to_error_json()).expect("valid envelope");
assert_eq!(json["code"], "REMOTE_PROXY_KIND_UNSUPPORTED");
assert_eq!(json["params"]["kind"], "VLESS");
}
#[test]
fn an_exit_that_could_not_be_read_is_refused_too() {
// `Unknown` is "we could not confirm this works from elsewhere". Treating
@@ -1294,8 +1393,7 @@ mod tests {
assert_eq!(json["params"]["granted"], "200");
}
/// A verbatim `RemoteSessionView`, field for field, as `toView` in
/// donutbrowser-infra's `remote-sessions.service.ts` builds it.
/// A verbatim session payload, field for field, as the cloud API sends it.
///
/// Hand-written JSON is what let this type declare `status`, `ready_at` and
/// `closed_at` while the backend sent `state` and `ended_at`: the test agreed
@@ -1311,7 +1409,7 @@ mod tests {
#[test]
fn the_session_state_payload_matches_what_the_backend_sends() {
// The desktop has been blind between launch and stop; every field here is
// one it could previously only learn by reading the production database.
// one it had no way to observe before.
let state: RemoteSessionState = serde_json::from_str(SERVER_SESSION_VIEW)
.expect("the backend's session payload must deserialize");
@@ -1431,7 +1529,7 @@ mod tests {
#[test]
fn a_heartbeat_is_not_forwarded_to_the_frontend() {
// Emitting one would make every consumer re-render twice a minute for
// nothing. Nest names this one, so it arrives with an `event:` line.
// nothing. This is the one frame that arrives with an `event:` line.
assert!(route_wire(b"event: ping\ndata: {}\n\n").is_empty());
assert!(route_wire(b"event: heartbeat\ndata: {}\n\n").is_empty());
// And the same frame with the discriminator inside the JSON instead.
@@ -1442,11 +1540,10 @@ mod tests {
#[test]
fn the_opening_snapshot_reaches_the_snapshot_event() {
// Byte-for-byte what Nest writes for `{type:'snapshot',at,sessions}`: no
// `event:` line, because the controller only sets MessageEvent.type for the
// ping. Routing on the event NAME sent this to `remote-session-state` as a
// raw envelope, so `remote-session-snapshot` was never emitted at all and
// the live view started empty and stayed empty.
// Byte-for-byte what the server sends for a snapshot: no `event:` line, so
// only the ping carries a name. Routing on the event NAME sent this to
// `remote-session-state` as a raw envelope, so `remote-session-snapshot` was
// never emitted at all and the live view started empty and stayed empty.
let routed = route_wire(
b"data: {\"type\":\"snapshot\",\"at\":\"2026-08-03T00:00:00.000Z\",\"sessions\":[{\"session_id\":\"s1\",\"profile_id\":\"p1\",\"state\":\"live\"}]}\n\n",
);
@@ -1659,8 +1756,8 @@ mod tests {
feed(&transition("sess-1", "p1", "closed", false));
feed(&transition("sess-2", "p1", "live", true));
// Out-of-order frames are normal: the reconciler polls the fleet while
// the user is already starting the next session. A stale close arriving
// Out-of-order frames are normal: the server can report a stale session
// while the user is already starting the next one. A stale close arriving
// after the new session went live must not make a working browser
// unreachable.
feed(&transition("sess-1", "p1", "closed", false));
@@ -1758,7 +1855,7 @@ mod tests {
#[test]
fn the_endpoint_descriptor_matches_what_the_backend_sends() {
// Pinned against `GET /api/remote-sessions/:id/cdp` in donutbrowser-infra.
// Pinned against a real `GET /api/remote-sessions/:id/cdp` response.
// A field name that does not match makes every remote attach fail at the
// decode step, and the desktop reports a live session as undrivable.
let endpoint: CdpEndpoint = serde_json::from_str(
@@ -1819,4 +1916,71 @@ mod tests {
session.state = "ready".to_string();
assert!(!is_drivable(&session));
}
fn remote_profile(
sync: crate::profile::types::SyncMode,
last_sync: Option<u64>,
) -> BrowserProfile {
BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "p".to_string(),
browser: "wayfern".to_string(),
version: "1.0".to_string(),
release_type: "stable".to_string(),
sync_mode: sync,
last_sync,
host_os: Some("macos".to_string()),
..Default::default()
}
}
fn refusal_code(err: &RemoteSessionError) -> Option<String> {
let RemoteSessionError::Other(body) = err else {
return None;
};
let v: serde_json::Value = serde_json::from_str(body).ok()?;
Some(v.get("code")?.as_str()?.to_string())
}
#[test]
fn a_synced_profile_of_its_own_os_is_allowed_to_launch() {
let profile = remote_profile(crate::profile::types::SyncMode::Regular, Some(1));
assert!(local_launch_refusal(&profile, "macos").is_none());
}
#[test]
fn a_non_host_platform_is_refused_and_names_itself() {
let profile = remote_profile(crate::profile::types::SyncMode::Regular, Some(1));
let refusal = local_launch_refusal(&profile, "android").expect("android is refused");
assert_eq!(
refusal_code(&refusal).as_deref(),
Some("REMOTE_PLATFORM_UNSUPPORTED")
);
// The offending platform rides in params so the toast can name it.
let RemoteSessionError::Other(body) = &refusal else {
panic!("expected an Other refusal");
};
let v: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(v["params"]["platform"], "android");
}
#[test]
fn a_profile_that_never_finished_a_sync_is_refused() {
// Sync enabled, but no upload ever completed: pulling it would be pulling
// emptiness, and the push-back would overwrite the real local profile.
let profile = remote_profile(crate::profile::types::SyncMode::Regular, None);
let refusal = local_launch_refusal(&profile, "macos").expect("never-synced is refused");
assert_eq!(
refusal_code(&refusal).as_deref(),
Some("REMOTE_PROFILE_NOT_SYNCED")
);
}
#[test]
fn a_profile_with_sync_off_is_not_refused_by_the_sync_gate_here() {
// Sync being off is `remote_launch_precondition`'s refusal, not this one's,
// so this gate stays silent for it rather than emitting a second message.
let profile = remote_profile(crate::profile::types::SyncMode::Disabled, None);
assert!(local_launch_refusal(&profile, "macos").is_none());
}
}
+333 -355
View File
@@ -6,7 +6,6 @@ use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Key, Nonce,
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use rand::RngExt;
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -50,6 +49,29 @@ pub struct AppSettings {
pub mcp_port: Option<u16>, // Port for MCP server (default 51080)
#[serde(default)]
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
/// Let Donut cloud drive this installation's MCP tools over an outbound
/// bridge, so an agent on the website can control this browser.
///
/// Defaults to OFF and stays off until the user says otherwise. It opens a
/// long-lived socket to Donut cloud and hands the far end the ability to
/// launch and drive profiles, which is not something to switch on for
/// somebody by default because their plan happens to include it.
#[serde(default)]
pub mcp_remote_enabled: bool,
/// The durable `dmk_` credential agents present to the remote MCP endpoint.
///
/// Plaintext, kept in an encrypted file with the same posture as
/// `mcp_token`: loaded into the struct for a frontend settings read (the fx
/// client cannot take the credential from its config file, so the page
/// offers the export line), and stripped by `save_settings` so the settings
/// JSON never carries it. Absent from the wire when there is none, so a
/// settings file written by an older build stays byte-for-byte unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_remote_key: Option<String>,
/// The server-side id of `mcp_remote_key`, so a rotation can revoke exactly
/// the key it replaces. Not a secret; lives in the settings JSON.
#[serde(default)]
pub mcp_remote_key_id: Option<String>,
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)]
@@ -71,6 +93,11 @@ pub struct AppSettings {
/// copy is always re-encrypted regardless of this flag.
#[serde(default)]
pub keep_decrypted_profiles_in_ram: bool,
/// How long a deleted profile stays in the trash before it is purged.
/// Clamped to 1..=365 on save; the sweeper reads it through
/// `profile::trash::configured_retention_days`.
#[serde(default = "default_trash_retention_days")]
pub trash_retention_days: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -87,6 +114,10 @@ fn default_api_port() -> u16 {
10108
}
fn default_trash_retention_days() -> u32 {
crate::profile::trash::DEFAULT_RETENTION_DAYS
}
impl Default for AppSettings {
fn default() -> Self {
Self {
@@ -102,6 +133,9 @@ impl Default for AppSettings {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
mcp_remote_enabled: false,
mcp_remote_key: None,
mcp_remote_key_id: None,
language: None,
window_resize_warning_dismissed: false,
fingerprint_gate_disabled: false,
@@ -109,10 +143,20 @@ impl Default for AppSettings {
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
trash_retention_days: crate::profile::trash::DEFAULT_RETENTION_DAYS,
}
}
}
/// The remote MCP credential as it is kept on this machine.
#[derive(Debug, Clone)]
pub struct StoredMcpRemoteKey {
/// The plaintext `dmk_` key.
pub key: String,
/// The server-side id, when the store that wrote the key also recorded it.
pub id: Option<String>,
}
pub struct SettingsManager;
impl SettingsManager {
@@ -160,8 +204,15 @@ impl SettingsManager {
let settings_dir = self.get_settings_dir();
create_dir_all(&settings_dir)?;
// The remote MCP credential works from anywhere on the internet and has
// its own encrypted file; a struct loaded for the frontend carries it, so
// it is dropped at the one place the JSON gets written rather than at
// every caller that happens to hold such a struct.
let mut on_disk = settings.clone();
on_disk.mcp_remote_key = None;
let settings_file = self.get_settings_file();
let json = serde_json::to_string_pretty(settings)?;
let json = serde_json::to_string_pretty(&on_disk)?;
fs::write(settings_file, json)?;
Ok(())
@@ -198,121 +249,74 @@ impl SettingsManager {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
pub async fn generate_api_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
// Generate a secure random token (base64 encoded for URL safety)
let token_bytes: [u8; 32] = {
use rand::Rng;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
bytes
};
use base64::{engine::general_purpose, Engine as _};
let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes);
// Store token securely
self.store_api_token(app_handle, &token).await?;
Ok(token)
}
pub async fn store_api_token(
&self,
_app_handle: &tauri::AppHandle,
token: &str,
/// Encrypt `secret` into `file` under the vault password.
///
/// One implementation for every secret this manager keeps on disk. The API,
/// MCP and sync tokens each carried their own copy of this routine, and the
/// remote MCP credential would have been the fourth; the file layout is the
/// same for all of them and only the five-byte header tells them apart.
fn encrypt_to_file(
file: &std::path::Path,
header: &[u8; 5],
secret: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Store token in an encrypted file using Argon2 + AES-GCM
let token_file = self.get_settings_dir().join("api_token.dat");
// Create directory if it doesn't exist
if let Some(parent) = token_file.parent() {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let vault_password = Self::get_vault_password();
// Generate a random salt for Argon2
let salt_bytes: [u8; 16] = rand::rng().random();
let salt =
SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?;
// Use Argon2 to derive a 32-byte key from the vault password
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
// Take first 32 bytes for AES-256 key
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let salt = crate::sync::encryption::encode_salt(&salt_bytes);
let key_bytes =
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
// Generate a random nonce
let nonce_bytes: [u8; 12] = rand::rng().random();
let nonce = Nonce::from(nonce_bytes);
// Encrypt the token
let ciphertext = cipher
.encrypt(&nonce, token.as_bytes())
.encrypt(&nonce, secret.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
// Create file data with header, salt, nonce, and encrypted data
let mut file_data = Vec::new();
file_data.extend_from_slice(b"DBAPI"); // 5-byte header
file_data.extend_from_slice(header);
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
// Store salt length and salt
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
// Store nonce (12 bytes for AES-GCM)
file_data.extend_from_slice(&nonce);
// Store ciphertext length and ciphertext
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
std::fs::write(&token_file, file_data)?;
crate::app_dirs::restrict_to_owner(std::path::Path::new(&token_file));
std::fs::write(file, file_data)?;
crate::app_dirs::restrict_to_owner(file);
Ok(())
}
pub async fn get_api_token(
&self,
_app_handle: &tauri::AppHandle,
/// Read back a secret written by `encrypt_to_file`.
///
/// A missing file, a foreign header or a layout this version does not know
/// all read as "no secret" rather than an error, so a stale or damaged file
/// never blocks the feature it belongs to; the caller simply mints again.
fn decrypt_from_file(
file: &std::path::Path,
header: &[u8; 5],
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("api_token.dat");
if !token_file.exists() {
if !file.exists() {
return Ok(None);
}
let file_data = std::fs::read(token_file)?;
let file_data = std::fs::read(file)?;
// Validate header
if file_data.len() < 6 || &file_data[0..5] != b"DBAPI" {
if file_data.len() < 6 || &file_data[0..5] != header {
return Ok(None);
}
let version = file_data[5];
// Only support Argon2 + AES-GCM (version 2)
if version != 2 {
return Ok(None);
}
// Argon2 + AES-GCM decryption
let mut offset = 6;
// Read salt
if offset >= file_data.len() {
return Ok(None);
}
@@ -324,10 +328,9 @@ impl SettingsManager {
}
let salt_bytes = &file_data[offset..offset + salt_len];
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?;
offset += salt_len;
// Read nonce (12 bytes)
if offset + 12 > file_data.len() {
return Ok(None);
}
@@ -337,7 +340,6 @@ impl SettingsManager {
let nonce = Nonce::from(nonce_bytes);
offset += 12;
// Read ciphertext
if offset + 4 > file_data.len() {
return Ok(None);
}
@@ -354,22 +356,11 @@ impl SettingsManager {
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
// Derive key using Argon2
let vault_password = Self::get_vault_password();
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key_bytes =
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
// Decrypt the token
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| "Decryption failed")?;
@@ -380,23 +371,15 @@ impl SettingsManager {
}
}
pub async fn remove_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("api_token.dat");
if token_file.exists() {
std::fs::remove_file(token_file)?;
fn remove_secret_file(file: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
if file.exists() {
std::fs::remove_file(file)?;
}
Ok(())
}
pub async fn generate_mcp_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
/// A fresh 256-bit token, base64url so it is safe in a URL path.
fn random_token() -> String {
let token_bytes: [u8; 32] = {
use rand::Rng;
let mut rng = rand::rng();
@@ -405,7 +388,61 @@ impl SettingsManager {
bytes
};
use base64::{engine::general_purpose, Engine as _};
let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes);
general_purpose::URL_SAFE_NO_PAD.encode(token_bytes)
}
fn api_token_file(&self) -> PathBuf {
self.get_settings_dir().join("api_token.dat")
}
fn mcp_token_file(&self) -> PathBuf {
self.get_settings_dir().join("mcp_token.dat")
}
fn sync_token_file(&self) -> PathBuf {
self.get_settings_dir().join("sync_token.dat")
}
fn mcp_remote_key_file(&self) -> PathBuf {
self.get_settings_dir().join("mcp_remote_key.dat")
}
pub async fn generate_api_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
let token = Self::random_token();
self.store_api_token(app_handle, &token).await?;
Ok(token)
}
pub async fn store_api_token(
&self,
_app_handle: &tauri::AppHandle,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
Self::encrypt_to_file(&self.api_token_file(), b"DBAPI", token)
}
pub async fn get_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
Self::decrypt_from_file(&self.api_token_file(), b"DBAPI")
}
pub async fn remove_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
Self::remove_secret_file(&self.api_token_file())
}
pub async fn generate_mcp_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
let token = Self::random_token();
self.store_mcp_token(app_handle, &token).await?;
Ok(token)
}
@@ -415,142 +452,21 @@ impl SettingsManager {
_app_handle: &tauri::AppHandle,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("mcp_token.dat");
if let Some(parent) = token_file.parent() {
std::fs::create_dir_all(parent)?;
}
let vault_password = Self::get_vault_password();
let salt_bytes: [u8; 16] = rand::rng().random();
let salt =
SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?;
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let nonce_bytes: [u8; 12] = rand::rng().random();
let nonce = Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, token.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut file_data = Vec::new();
file_data.extend_from_slice(b"DBMCP"); // 5-byte header for MCP token
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
file_data.extend_from_slice(&nonce);
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
std::fs::write(&token_file, file_data)?;
crate::app_dirs::restrict_to_owner(std::path::Path::new(&token_file));
Ok(())
Self::encrypt_to_file(&self.mcp_token_file(), b"DBMCP", token)
}
pub async fn get_mcp_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("mcp_token.dat");
if !token_file.exists() {
return Ok(None);
}
let file_data = std::fs::read(token_file)?;
if file_data.len() < 6 || &file_data[0..5] != b"DBMCP" {
return Ok(None);
}
let version = file_data[5];
if version != 2 {
return Ok(None);
}
let mut offset = 6;
if offset >= file_data.len() {
return Ok(None);
}
let salt_len = file_data[offset] as usize;
offset += 1;
if offset + salt_len > file_data.len() {
return Ok(None);
}
let salt_bytes = &file_data[offset..offset + salt_len];
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
offset += salt_len;
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
.try_into()
.map_err(|_| "Invalid nonce length")?;
let nonce = Nonce::from(nonce_bytes);
offset += 12;
if offset + 4 > file_data.len() {
return Ok(None);
}
let ciphertext_len = u32::from_le_bytes([
file_data[offset],
file_data[offset + 1],
file_data[offset + 2],
file_data[offset + 3],
]) as usize;
offset += 4;
if offset + ciphertext_len > file_data.len() {
return Ok(None);
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
let vault_password = Self::get_vault_password();
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| "Decryption failed")?;
match String::from_utf8(plaintext) {
Ok(token) => Ok(Some(token)),
Err(_) => Ok(None),
}
Self::decrypt_from_file(&self.mcp_token_file(), b"DBMCP")
}
pub async fn remove_mcp_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("mcp_token.dat");
if token_file.exists() {
std::fs::remove_file(token_file)?;
}
Ok(())
Self::remove_secret_file(&self.mcp_token_file())
}
pub async fn store_sync_token(
@@ -558,141 +474,66 @@ impl SettingsManager {
_app_handle: &tauri::AppHandle,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("sync_token.dat");
if let Some(parent) = token_file.parent() {
std::fs::create_dir_all(parent)?;
}
let vault_password = Self::get_vault_password();
let salt_bytes: [u8; 16] = rand::rng().random();
let salt =
SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?;
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let nonce_bytes: [u8; 12] = rand::rng().random();
let nonce = Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, token.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut file_data = Vec::new();
file_data.extend_from_slice(b"DBSYN"); // 5-byte header for sync
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
file_data.extend_from_slice(&nonce);
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
std::fs::write(&token_file, file_data)?;
crate::app_dirs::restrict_to_owner(std::path::Path::new(&token_file));
Ok(())
Self::encrypt_to_file(&self.sync_token_file(), b"DBSYN", token)
}
pub async fn get_sync_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("sync_token.dat");
if !token_file.exists() {
return Ok(None);
}
let file_data = std::fs::read(token_file)?;
if file_data.len() < 6 || &file_data[0..5] != b"DBSYN" {
return Ok(None);
}
let version = file_data[5];
if version != 2 {
return Ok(None);
}
let mut offset = 6;
if offset >= file_data.len() {
return Ok(None);
}
let salt_len = file_data[offset] as usize;
offset += 1;
if offset + salt_len > file_data.len() {
return Ok(None);
}
let salt_bytes = &file_data[offset..offset + salt_len];
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
offset += salt_len;
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
.try_into()
.map_err(|_| "Invalid nonce length")?;
let nonce = Nonce::from(nonce_bytes);
offset += 12;
if offset + 4 > file_data.len() {
return Ok(None);
}
let ciphertext_len = u32::from_le_bytes([
file_data[offset],
file_data[offset + 1],
file_data[offset + 2],
file_data[offset + 3],
]) as usize;
offset += 4;
if offset + ciphertext_len > file_data.len() {
return Ok(None);
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
let vault_password = Self::get_vault_password();
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| "Decryption failed")?;
match String::from_utf8(plaintext) {
Ok(token) => Ok(Some(token)),
Err(_) => Ok(None),
}
Self::decrypt_from_file(&self.sync_token_file(), b"DBSYN")
}
pub async fn remove_sync_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("sync_token.dat");
Self::remove_secret_file(&self.sync_token_file())
}
if token_file.exists() {
std::fs::remove_file(token_file)?;
/// Keep the remote MCP credential: the `dmk_` key in its own encrypted file
/// and the server-side key id in the settings JSON, so a later rotation can
/// name the key it is retiring.
///
/// The plaintext is deliberately NOT part of the settings JSON:
/// `save_settings` strips it, and `get_app_settings` is the one reader that
/// loads it back for the frontend, the way the local display tokens are.
pub fn store_mcp_remote_key(
&self,
key: &str,
key_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
Self::encrypt_to_file(&self.mcp_remote_key_file(), b"DBMRK", key)?;
let mut settings = self.load_settings()?;
settings.mcp_remote_key_id = Some(key_id.to_string());
self.save_settings(&settings)
}
/// The stored remote MCP credential, if any: the plaintext key and the id
/// the server knows it by.
///
/// Read with the id from the JSON and the key from its file, so the two
/// cannot disagree: a key file without an id (an interrupted store) still
/// yields the key, and an id without a key file yields nothing at all.
pub fn get_mcp_remote_key(
&self,
) -> Result<Option<StoredMcpRemoteKey>, Box<dyn std::error::Error>> {
let Some(key) = Self::decrypt_from_file(&self.mcp_remote_key_file(), b"DBMRK")? else {
return Ok(None);
};
let id = self.load_settings()?.mcp_remote_key_id;
Ok(Some(StoredMcpRemoteKey { key, id }))
}
/// Drop the remote MCP credential from this machine. Does not revoke it:
/// that is the caller's job, because only the caller knows whether it still
/// has a session to revoke with.
pub fn remove_mcp_remote_key(&self) -> Result<(), Box<dyn std::error::Error>> {
Self::remove_secret_file(&self.mcp_remote_key_file())?;
let mut settings = self.load_settings()?;
if settings.mcp_remote_key_id.take().is_some() {
self.save_settings(&settings)?;
}
Ok(())
}
@@ -732,6 +573,13 @@ pub async fn get_app_settings(app_handle: tauri::AppHandle) -> Result<AppSetting
.await
.map_err(|e| format!("Failed to load MCP token: {e}"))?;
// Same posture as the local tokens: shown so the fx export line can be
// copied, never persisted (see `SettingsManager::save_settings`).
settings.mcp_remote_key = manager
.get_mcp_remote_key()
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
.map(|stored| stored.key);
Ok(settings)
}
@@ -742,6 +590,13 @@ pub async fn save_app_settings(
) -> Result<AppSettings, String> {
let manager = SettingsManager::instance();
// The remote MCP credential is minted by `rotate_mcp_remote_credential` and
// by nothing else. A settings read hands the frontend the plaintext (for the
// fx export line) and the frontend echoes the whole struct back, so the
// field is simply not the frontend's to write: whatever arrived is dropped
// here and the stored key is what the answer below carries.
settings.mcp_remote_key = None;
// Handle API token
if settings.api_enabled {
if let Some(ref token) = settings.api_token {
@@ -790,6 +645,23 @@ pub async fn save_app_settings(
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
settings.mcp_token = Some(token);
// A running local server now answers on a URL the installed clients
// do not know, so they are rewritten. With the server off there is no
// URL to write yet; `McpServer::start` does this when it comes up.
if crate::mcp_server::McpServer::instance()
.get_port()
.is_some()
{
let failed =
crate::reinstall_mcp_agents(&app_handle, crate::mcp_integrations::McpEndpoint::Local)
.await;
if !failed.is_empty() {
log::warn!(
"[settings] Could not refresh the clients pointing at the local server: {}",
failed.join(", ")
);
}
}
}
}
}
@@ -802,14 +674,30 @@ pub async fn save_app_settings(
settings.mcp_token = None;
}
// Preserve server-managed flags that the frontend may not have up-to-date.
// Read directly from file to avoid load_settings' save-on-load behavior.
// Preserve the fields the frontend does not own. Read directly from the
// file to avoid load_settings' save-on-load behavior.
//
// `mcp_remote_enabled` is flipped ONLY by `start_mcp_remote_bridge` and
// `stop_mcp_remote_bridge`, which also start and stop the bridge task. A
// settings save that carried the flag could switch the internet-facing
// bridge on for the next launch without ever going through the sign-in and
// terms gates those commands enforce, or switch it off on disk while the
// task kept running. The key id is bookkeeping for the rotation path and is
// never the frontend's to write.
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
settings.mcp_remote_enabled = current.mcp_remote_enabled;
settings.mcp_remote_key_id = current.mcp_remote_key_id;
}
} else {
settings.mcp_remote_enabled = false;
settings.mcp_remote_key_id = None;
}
settings.trash_retention_days =
crate::profile::trash::clamp_retention_days(settings.trash_retention_days);
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
persist_settings.mcp_token = None;
@@ -828,6 +716,14 @@ pub async fn save_app_settings(
.save_settings(&persist_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
// Answer with what a fresh read would show, the stored credential included,
// so a page that keeps the answer as its settings does not lose the fx
// export line on every save.
settings.mcp_remote_key = manager
.get_mcp_remote_key()
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
.map(|stored| stored.key);
Ok(settings)
}
@@ -1198,6 +1094,9 @@ mod tests {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
mcp_remote_enabled: false,
mcp_remote_key: None,
mcp_remote_key_id: None,
language: None,
window_resize_warning_dismissed: false,
fingerprint_gate_disabled: false,
@@ -1205,6 +1104,7 @@ mod tests {
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
trash_retention_days: 14,
};
let save_result = manager.save_settings(&test_settings);
@@ -1222,6 +1122,84 @@ mod tests {
loaded_settings.theme, "dark",
"Loaded theme should match saved"
);
assert_eq!(loaded_settings.trash_retention_days, 14);
}
#[test]
fn trash_retention_defaults_when_the_settings_file_predates_it() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let settings_dir = manager.get_settings_dir();
create_dir_all(&settings_dir).unwrap();
fs::write(manager.get_settings_file(), r#"{"theme":"light"}"#).unwrap();
let loaded = manager.load_settings().unwrap();
assert_eq!(loaded.theme, "light");
assert_eq!(
loaded.trash_retention_days,
crate::profile::trash::DEFAULT_RETENTION_DAYS
);
}
#[test]
fn the_remote_key_round_trips_and_never_reaches_the_settings_json() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
assert!(manager.get_mcp_remote_key().unwrap().is_none());
manager
.store_mcp_remote_key("dmk_abcdefghijklmnop", "key-1")
.unwrap();
let stored = manager.get_mcp_remote_key().unwrap().expect("stored");
assert_eq!(stored.key, "dmk_abcdefghijklmnop");
assert_eq!(stored.id.as_deref(), Some("key-1"));
// The id is bookkeeping and belongs in the JSON; the key is a credential
// that works from anywhere on the internet and must not.
let json = std::fs::read_to_string(manager.get_settings_file()).unwrap();
assert!(json.contains("\"mcp_remote_key_id\": \"key-1\""), "{json}");
assert!(!json.contains("dmk_abcdefghijklmnop"), "{json}");
assert!(!json.contains("\"mcp_remote_key\""), "{json}");
// A struct loaded for the frontend carries the plaintext (the fx export
// line needs it), so the write path is what keeps it off the disk: saving
// such a struct must not plant the key in the JSON.
let mut settings = manager.load_settings().unwrap();
settings.mcp_remote_key = Some("dmk_abcdefghijklmnop".to_string());
manager.save_settings(&settings).unwrap();
let json = std::fs::read_to_string(manager.get_settings_file()).unwrap();
assert!(!json.contains("dmk_"), "{json}");
assert!(!json.contains("\"mcp_remote_key\""), "{json}");
assert_eq!(
manager
.load_settings()
.unwrap()
.mcp_remote_key_id
.as_deref(),
Some("key-1")
);
manager.remove_mcp_remote_key().unwrap();
assert!(manager.get_mcp_remote_key().unwrap().is_none());
assert!(manager.load_settings().unwrap().mcp_remote_key_id.is_none());
}
#[test]
fn a_key_file_without_an_id_still_yields_the_key() {
// An interrupted store, or a settings file rewritten by an older build
// that did not know the field: the credential is still on disk and still
// valid, so it must still be usable. Only the id is missing, and the
// rotation path treats a missing id as "nothing to revoke".
let (manager, _temp_dir, _guard) = create_test_settings_manager();
manager
.store_mcp_remote_key("dmk_zzzzzzzzzzzz", "key-2")
.unwrap();
let mut settings = manager.load_settings().unwrap();
settings.mcp_remote_key_id = None;
manager.save_settings(&settings).unwrap();
let stored = manager.get_mcp_remote_key().unwrap().expect("stored");
assert_eq!(stored.key, "dmk_zzzzzzzzzzzz");
assert!(stored.id.is_none());
}
#[test]
+2 -1
View File
@@ -188,7 +188,8 @@ fn udp_mode(upstream_url: Option<&str>) -> UdpMode {
Some("DIRECT") => UdpMode::Direct,
Some(url) => match Url::parse(url).ok().map(|u| u.scheme().to_lowercase()) {
Some(scheme) if scheme == "socks5" => UdpMode::Socks5Upstream,
// http / https / socks4 / ss / shadowsocks / anything else: TCP-only.
// http / https / httpstls / socks4 / ss / shadowsocks / anything else:
// TCP-only. An HTTP CONNECT upstream carries no UDP, TLS-wrapped or not.
_ => UdpMode::Refuse,
},
}
+71 -40
View File
@@ -2,8 +2,42 @@ use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Key,
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use argon2::Argon2;
use base64::{
engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD as SALT_B64},
Engine,
};
/// Derive a 32-byte AES key from a password and a raw salt with Argon2id at
/// the crate's default parameters (m=19456 KiB, t=2, p=1, 32-byte output).
///
/// ONE function for every vault in the app, so the parameters can never drift
/// between the sync, settings and cloud-auth stores. Byte-compatible with the
/// PHC-string path used before argon2 0.6: that path hashed the DECODED salt
/// with the same defaults and the key was its 32-byte output, which is exactly
/// what `hash_password_into` produces here. A different parameter set would
/// silently lock every user out of their encrypted data, so the defaults are
/// pinned by the test below rather than trusted.
pub fn derive_vault_key(password: &[u8], salt: &[u8]) -> Result<[u8; 32], String> {
let mut key = [0u8; 32];
Argon2::default()
.hash_password_into(password, salt, &mut key)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
Ok(key)
}
/// The on-disk salt encoding: PHC "B64", the standard alphabet with no
/// padding, exactly what the retired `SaltString` wrote, so files written by
/// earlier builds decode unchanged.
pub fn encode_salt(salt: &[u8]) -> String {
SALT_B64.encode(salt)
}
pub fn decode_salt(salt: &str) -> Result<Vec<u8>, String> {
SALT_B64
.decode(salt)
.map_err(|e| format!("Invalid salt: {e}"))
}
use rand::RngExt;
use std::collections::HashMap;
use std::sync::Mutex;
@@ -57,18 +91,8 @@ pub fn store_e2e_password(password: &str) -> Result<(), String> {
let vault_password = get_vault_password();
let salt_bytes: [u8; 16] = rand::rng().random();
let salt =
SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?;
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let salt = encode_salt(&salt_bytes);
let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let nonce_bytes: [u8; 12] = rand::rng().random();
@@ -133,7 +157,7 @@ pub fn load_e2e_password() -> Result<Option<String>, String> {
.map_err(|_| "Invalid salt encoding")?;
offset += salt_len;
let salt = SaltString::from_b64(salt_str).map_err(|e| format!("Invalid salt: {e}"))?;
let salt_bytes = decode_salt(salt_str)?;
if offset + 12 > file_data.len() {
return Ok(None);
@@ -157,16 +181,7 @@ pub fn load_e2e_password() -> Result<Option<String>, String> {
let ciphertext = &file_data[offset..offset + ciphertext_len];
let vault_password = get_vault_password();
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
@@ -212,18 +227,7 @@ pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8
.decode(profile_salt)
.map_err(|e| format!("Invalid salt encoding: {e}"))?;
let salt = SaltString::encode_b64(&salt_bytes)
.map_err(|e| format!("Failed to create salt string: {e}"))?;
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(user_password.as_bytes(), &salt)
.map_err(|e| format!("Key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let mut key = [0u8; 32];
key.copy_from_slice(&hash_bytes[..32]);
let key = derive_vault_key(user_password.as_bytes(), &salt_bytes)?;
if let Ok(mut cache) = KEY_CACHE.lock() {
cache.insert(cache_key, key);
@@ -364,12 +368,12 @@ pub async fn delete_e2e_password() -> Result<(), String> {
remove_e2e_password()
}
/// On Team plans, only the team owner is allowed to flip the E2E password
/// state — otherwise members could lock each other out by changing the key.
/// Only the team owner may flip the E2E password state — otherwise members
/// could lock each other out by changing the key.
async fn enforce_team_owner_for_encryption_change() -> Result<(), String> {
use crate::cloud_auth::CLOUD_AUTH;
if let Some(state) = CLOUD_AUTH.get_user().await {
if state.user.plan == "team" && state.user.team_role.as_deref() != Some("owner") {
if state.user.effective_plan() == "team" && state.user.team_role.as_deref() != Some("owner") {
return Err("TEAM_OWNER_ONLY".to_string());
}
}
@@ -485,3 +489,30 @@ mod tests {
assert!(decrypt_bytes(&key, &[0u8; 5]).is_err());
}
}
#[cfg(test)]
mod vault_key_tests {
use super::{decode_salt, derive_vault_key, encode_salt};
/// A stored vault is only readable while this vector holds. It pins the
/// Argon2id parameters and the salt encoding together: a dependency bump
/// that changed either would fail here instead of at the user's data.
#[test]
fn vault_key_derivation_is_pinned() {
let key = derive_vault_key(b"correct horse battery staple", &[7u8; 16]).unwrap();
let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
hex,
"799f12b9e17710824482d829835acb69f5a9355bf774c4f07342823b11b90928"
);
}
#[test]
fn salt_encoding_round_trips_without_padding() {
let salt = [0u8, 1, 2, 3, 250, 251, 252, 253, 254, 255, 9, 8, 7, 6, 5, 4];
let encoded = encode_salt(&salt);
assert!(!encoded.contains('='), "PHC B64 carries no padding");
assert_eq!(decode_salt(&encoded).unwrap(), salt);
assert!(decode_salt("not*valid").is_err());
}
}
+248 -62
View File
@@ -192,6 +192,31 @@ fn is_safe_manifest_path(path: &str) -> bool {
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
}
/// Parse an S3 `lastModified` (RFC3339) into unix seconds.
fn rfc3339_secs(value: &str) -> Option<u64> {
DateTime::parse_from_rfc3339(value)
.ok()
.and_then(|dt| u64::try_from(dt.timestamp()).ok())
}
/// Whether a config entity's tombstone must stop this reconcile, given when the
/// tombstone was written and the local entity's own last edit (0 once the local
/// copy is gone).
///
/// A tombstone at least as new as the local edit wins. That keeps last-write-wins
/// intact: an edit made strictly after the delete still uploads. A tombstone
/// with no readable write time is treated as newer, because a skipped sync is
/// retried and a resurrection is not self-correcting.
fn tombstone_outranks_local(exists: bool, written_at: Option<u64>, local_updated_at: u64) -> bool {
if !exists {
return false;
}
match written_at {
Some(secs) => secs >= local_updated_at,
None => true,
}
}
/// Checkpoint all SQLite WAL files in a profile directory.
///
/// When a browser crashes or is killed, SQLite WAL files may contain
@@ -395,9 +420,9 @@ impl SyncProgressTracker {
/// Check if sync is configured (cloud or self-hosted)
pub fn is_sync_configured() -> bool {
// Cloud backup is a plan capability. Every paid plan (incl. the future
// "solo" tier) grants it, but gating on the capability — not just "is paid"
// — keeps this correct if a plan without cloud backup is ever added.
// Cloud backup is a plan capability. Gating on the capability — not just
// "is paid" — keeps this correct if a plan without cloud backup is ever
// added.
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
return true;
}
@@ -528,6 +553,46 @@ impl SyncEngine {
Ok(())
}
/// Whether a remote tombstone forbids syncing this config entity right now.
///
/// Every `sync_X` reconciles local against remote by presence alone, so
/// without this a device that has not yet drained its tombstone queue
/// re-uploads an entity another device just deleted, then downloads its own
/// resurrection back once the drain removes the local copy. The delete never
/// sticks on either side.
///
/// A failed stat is propagated, not swallowed, so the pass is reported as
/// failed rather than as a silent success. A caller that cannot propagate
/// treats the error as blocking: a skipped pass is retried, a resurrection is
/// not self-correcting.
async fn tombstone_blocks(
&self,
kind: &str,
id: &str,
local_updated_at: u64,
) -> SyncResult<bool> {
let tombstone_key = format!("tombstones/{}/{}.json", kind, id);
let stat = match self.client.stat(&tombstone_key).await {
Ok(stat) => stat,
Err(e) => {
log::warn!(
"Could not check {} before syncing {} {}; skipping this pass: {}",
tombstone_key,
kind,
id,
e
);
return Err(e);
}
};
let written_at = stat.last_modified.as_deref().and_then(rfc3339_secs);
let blocked = tombstone_outranks_local(stat.exists, written_at, local_updated_at);
if blocked {
log::info!("Skipping sync of {} {}: deleted remotely", kind, id);
}
Ok(blocked)
}
pub async fn sync_profile(
&self,
app_handle: &tauri::AppHandle,
@@ -551,6 +616,18 @@ impl SyncEngine {
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
bias: DiffBias,
) -> SyncResult<ProfileSyncOutcome> {
self
.sync_profile_inner(app_handle, profile, bias, false)
.await
}
async fn sync_profile_inner(
&self,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
bias: DiffBias,
reencrypt: bool,
) -> SyncResult<ProfileSyncOutcome> {
if profile.is_cross_os() {
log::info!(
@@ -694,14 +771,21 @@ impl SyncEngine {
// Try to download remote manifest
let remote_manifest_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id);
let remote_manifest = self
.download_manifest(&remote_manifest_key, encryption_key.as_ref())
.await?;
let remote_manifest = if reencrypt {
// Compare against an empty manifest locally so every file is rewritten.
// Deleting it remotely would let a device with the previous password
// race this upload and recreate a manifest we can no longer decrypt.
None
} else {
self
.download_manifest(&remote_manifest_key, encryption_key.as_ref())
.await?
};
// Compute diff
let diff = compute_diff_with_bias(&local_manifest, remote_manifest.as_ref(), bias);
if diff.is_empty() {
if diff.is_empty() && !reencrypt {
log::info!("Profile {} is already in sync", profile_id);
let _ = events::emit(
"profile-sync-status",
@@ -1641,18 +1725,25 @@ impl SyncEngine {
let proxies = proxy_manager.get_stored_proxies();
let local_proxy = proxies.iter().find(|p| p.id == proxy_id).cloned();
let local_updated_at = local_proxy.as_ref().and_then(|p| p.updated_at).unwrap_or(0);
if self
.tombstone_blocks("proxies", proxy_id, local_updated_at)
.await?
{
return Ok(());
}
let remote_key = format!("proxies/{}.json", proxy_id);
let stat = self.client.stat(&remote_key).await?;
match (local_proxy, stat.exists) {
(Some(proxy), true) => {
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = proxy.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated > local_updated {
if remote_updated > local_updated_at {
self.download_proxy(proxy_id, app_handle).await?;
} else if local_updated > remote_updated {
} else if local_updated_at > remote_updated {
self.upload_proxy(&proxy).await?;
}
}
@@ -1780,18 +1871,25 @@ impl SyncEngine {
groups.into_iter().find(|g| g.id == group_id)
};
let local_updated_at = local_group.as_ref().and_then(|g| g.updated_at).unwrap_or(0);
if self
.tombstone_blocks("groups", group_id, local_updated_at)
.await?
{
return Ok(());
}
let remote_key = format!("groups/{}.json", group_id);
let stat = self.client.stat(&remote_key).await?;
match (local_group, stat.exists) {
(Some(group), true) => {
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated > local_updated {
if remote_updated > local_updated_at {
self.download_group(group_id, app_handle).await?;
} else if local_updated > remote_updated {
} else if local_updated_at > remote_updated {
self.upload_group(&group).await?;
}
}
@@ -1980,18 +2078,25 @@ impl SyncEngine {
storage.load_config(vpn_id).ok()
};
let local_updated_at = local_vpn.as_ref().and_then(|v| v.updated_at).unwrap_or(0);
if self
.tombstone_blocks("vpns", vpn_id, local_updated_at)
.await?
{
return Ok(());
}
let remote_key = format!("vpns/{}.json", vpn_id);
let stat = self.client.stat(&remote_key).await?;
match (local_vpn, stat.exists) {
(Some(vpn), true) => {
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = vpn.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated > local_updated {
if remote_updated > local_updated_at {
self.download_vpn(vpn_id, app_handle).await?;
} else if local_updated > remote_updated {
} else if local_updated_at > remote_updated {
self.upload_vpn(&vpn).await?;
}
}
@@ -2053,6 +2158,12 @@ impl SyncEngine {
let mut vpn: crate::vpn::VpnConfig = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse VPN JSON: {e}")))?;
// Sync is not a second way in for a config the import path refuses: a
// multi-peer file resolves to whichever peer is listed last at connect
// time, which is not the tunnel its author described.
crate::vpn::VpnStorage::ensure_single_peer(vpn.vpn_type, &vpn.config_data)
.map_err(|e| SyncError::InvalidData(format!("Rejected synced VPN {vpn_id}: {e}")))?;
vpn.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -2125,18 +2236,25 @@ impl SyncEngine {
return Ok(());
}
let local_updated_at = local_ext.as_ref().map_or(0, |e| e.updated_at);
if self
.tombstone_blocks("extensions", ext_id, local_updated_at)
.await?
{
return Ok(());
}
let remote_key = format!("extensions/{}.json", ext_id);
let stat = self.client.stat(&remote_key).await?;
match (local_ext, stat.exists) {
(Some(ext), true) => {
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = ext.updated_at;
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated > local_updated {
if remote_updated > local_updated_at {
self.download_extension(ext_id, app_handle).await?;
} else if local_updated > remote_updated {
} else if local_updated_at > remote_updated {
self.upload_extension(&ext).await?;
}
}
@@ -2317,18 +2435,25 @@ impl SyncEngine {
manager.get_group(group_id).ok()
};
let local_updated_at = local_group.as_ref().map_or(0, |g| g.updated_at);
if self
.tombstone_blocks("extension_groups", group_id, local_updated_at)
.await?
{
return Ok(());
}
let remote_key = format!("extension_groups/{}.json", group_id);
let stat = self.client.stat(&remote_key).await?;
match (local_group, stat.exists) {
(Some(group), true) => {
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.updated_at;
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated > local_updated {
if remote_updated > local_updated_at {
self.download_extension_group(group_id, app_handle).await?;
} else if local_updated > remote_updated {
} else if local_updated_at > remote_updated {
self.upload_extension_group(&group).await?;
}
}
@@ -2980,11 +3105,12 @@ impl SyncEngine {
.iter()
.any(|p| p.id == proxy_id);
if !exists_locally {
let tombstone_key = format!("tombstones/proxies/{}.json", proxy_id);
if let Ok(stat) = self.client.stat(&tombstone_key).await {
if stat.exists {
continue;
}
if self
.tombstone_blocks("proxies", proxy_id, 0)
.await
.unwrap_or(true)
{
continue;
}
log::info!(
"Proxy {} exists remotely but not locally, downloading...",
@@ -3014,11 +3140,12 @@ impl SyncEngine {
.any(|g| g.id == group_id)
};
if !exists_locally {
let tombstone_key = format!("tombstones/groups/{}.json", group_id);
if let Ok(stat) = self.client.stat(&tombstone_key).await {
if stat.exists {
continue;
}
if self
.tombstone_blocks("groups", group_id, 0)
.await
.unwrap_or(true)
{
continue;
}
log::info!(
"Group {} exists remotely but not locally, downloading...",
@@ -3044,11 +3171,12 @@ impl SyncEngine {
storage.load_config(vpn_id).is_ok()
};
if !exists_locally {
let tombstone_key = format!("tombstones/vpns/{}.json", vpn_id);
if let Ok(stat) = self.client.stat(&tombstone_key).await {
if stat.exists {
continue;
}
if self
.tombstone_blocks("vpns", vpn_id, 0)
.await
.unwrap_or(true)
{
continue;
}
log::info!(
"VPN {} exists remotely but not locally, downloading...",
@@ -3081,11 +3209,12 @@ impl SyncEngine {
.any(|e| e.id == ext_id)
};
if !exists_locally {
let tombstone_key = format!("tombstones/extensions/{}.json", ext_id);
if let Ok(stat) = self.client.stat(&tombstone_key).await {
if stat.exists {
continue;
}
if self
.tombstone_blocks("extensions", ext_id, 0)
.await
.unwrap_or(true)
{
continue;
}
log::info!(
"Extension {} exists remotely but not locally, downloading...",
@@ -3116,11 +3245,12 @@ impl SyncEngine {
.any(|g| g.id == group_id)
};
if !exists_locally {
let tombstone_key = format!("tombstones/extension_groups/{}.json", group_id);
if let Ok(stat) = self.client.stat(&tombstone_key).await {
if stat.exists {
continue;
}
if self
.tombstone_blocks("extension_groups", group_id, 0)
.await
.unwrap_or(true)
{
continue;
}
log::info!(
"Extension group {} exists remotely but not locally, downloading...",
@@ -3438,10 +3568,11 @@ pub async fn set_profile_sync_mode(
.save_profile(&profile)
.map_err(|e| format!("Failed to save profile: {e}"))?;
// The bot materialises the profile from donut-sync, so switching sync off (or
// to Encrypted, which the host cannot decrypt) is a refusal reason. The server
// holds only the copy this machine declared; without this, an enrolment keeps
// claiming a syncable profile every night after the user turned sync off.
// A remote run obtains the profile through sync, so a local-only profile has
// nothing there — and switching to Encrypted leaves a copy that cannot be
// decrypted remotely. Either is a refusal reason. The server holds only the
// copy this machine declared; without this, an enrolment keeps claiming a
// syncable profile every night after the user turned sync off.
crate::cookie_bot::report_profile_state(&profile);
let _ = events::emit("profiles-changed", ());
@@ -3697,6 +3828,22 @@ pub async fn pull_profile_after_remote_session(
.map_err(|e| format!("Sync failed: {e}"))
}
/// Drop a stale tombstone for a config entity that is being (re-)enabled for
/// sync.
///
/// `sync_X` refuses to touch an entity whose tombstone is newer than its last
/// edit, so an id that was ever deleted could otherwise never be uploaded
/// again, which matters when an import restores a previously-deleted id. The
/// profile path clears its own tombstone on re-enable for the same reason.
async fn clear_config_tombstone(app_handle: &tauri::AppHandle, kind: &str, id: &str) {
if let Ok(engine) = SyncEngine::create_from_settings(app_handle).await {
let tombstone_key = format!("tombstones/{}/{}.json", kind, id);
if let Err(e) = engine.client.delete(&tombstone_key, None).await {
log::warn!("Failed to clear tombstone {}: {}", tombstone_key, e);
}
}
}
#[tauri::command]
pub async fn set_proxy_sync_enabled(
app_handle: tauri::AppHandle,
@@ -3735,6 +3882,8 @@ pub async fn set_proxy_sync_enabled(
let _ = events::emit("stored-proxies-changed", ());
if enabled {
clear_config_tombstone(&app_handle, "proxies", &proxy_id).await;
let _ = events::emit(
"proxy-sync-status",
serde_json::json!({
@@ -3805,6 +3954,8 @@ pub async fn set_group_sync_enabled(
let _ = events::emit("groups-changed", ());
if enabled {
clear_config_tombstone(&app_handle, "groups", &group_id).await;
let _ = events::emit(
"group-sync-status",
serde_json::json!({
@@ -3877,6 +4028,8 @@ pub async fn set_vpn_sync_enabled(
let _ = events::emit("vpn-configs-changed", ());
if enabled {
clear_config_tombstone(&app_handle, "vpns", &vpn_id).await;
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
@@ -4101,6 +4254,8 @@ pub async fn set_extension_sync_enabled(
let _ = events::emit("extensions-changed", ());
if enabled {
clear_config_tombstone(&app_handle, "extensions", &extension_id).await;
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_sync(extension_id).await;
}
@@ -4143,6 +4298,8 @@ pub async fn set_extension_group_sync_enabled(
let _ = events::emit("extensions-changed", ());
if enabled {
clear_config_tombstone(&app_handle, "extension_groups", &extension_group_id).await;
if let Some(scheduler) = super::get_global_scheduler() {
scheduler
.queue_extension_group_sync(extension_group_id)
@@ -4196,10 +4353,9 @@ pub async fn rollover_encryption_for_all_entities(
let total_profiles = synced_profiles.len();
for (i, profile) in synced_profiles.iter().enumerate() {
let id_str = profile.id.to_string();
// The remote manifest may be encrypted with the previous password. Delete
// only that manifest so the normal sync path treats every local file as an
// upload and rewrites it with the current password. Existing remote files
// remain available until their replacements have uploaded.
// Keep the old manifest present until the re-encrypted files are uploaded.
// Other devices must never interpret a missing manifest as an empty remote
// profile and repopulate it with files encrypted by the previous password.
let key_prefix = SyncEngine::get_team_key_prefix(profile).await;
engine
.upload_profile_metadata(&id_str, profile, &key_prefix)
@@ -4209,14 +4365,8 @@ pub async fn rollover_encryption_for_all_entities(
"Failed to roll over profile metadata {id_str}: {e}"
))
})?;
let manifest_key = format!("{key_prefix}profiles/{id_str}/manifest.json");
engine
.client
.delete(&manifest_key, None)
.await
.map_err(|e| internal_error(format!("Failed to reset profile manifest: {e}")))?;
engine
.sync_profile(&app_handle, profile)
.sync_profile_inner(&app_handle, profile, DiffBias::Auto, true)
.await
.map_err(|e| internal_error(format!("Failed to roll over profile {id_str}: {e}")))?;
let _ = events::emit(
@@ -4497,6 +4647,42 @@ mod tests {
}
}
#[test]
fn test_tombstone_outranks_local() {
// No tombstone: the reconcile runs as before.
assert!(!tombstone_outranks_local(false, Some(500), 100));
// The delete happened after the local edit, so it wins and the entity is
// never re-uploaded. This is the resurrection loop's entry point.
assert!(tombstone_outranks_local(true, Some(500), 100));
// Local copy already gone (updated_at 0): nothing may be downloaded back.
assert!(tombstone_outranks_local(true, Some(500), 0));
// Same second resolves in favour of the delete.
assert!(tombstone_outranks_local(true, Some(500), 500));
// A local edit made strictly after the delete still wins (last-write-wins).
assert!(!tombstone_outranks_local(true, Some(500), 501));
// An unreadable write time fails closed.
assert!(tombstone_outranks_local(true, None, 501));
}
#[test]
fn test_rfc3339_secs() {
assert_eq!(rfc3339_secs("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(rfc3339_secs("2024-01-01T00:00:00Z"), Some(1_704_067_200));
// S3 returns sub-second precision and offsets other than Z.
assert_eq!(
rfc3339_secs("2024-01-01T01:00:00.500+01:00"),
Some(1_704_067_200)
);
assert_eq!(rfc3339_secs("not a date"), None);
// Pre-epoch cannot be a tombstone write time; treat it as unreadable.
assert_eq!(rfc3339_secs("1969-12-31T23:59:59Z"), None);
}
#[test]
fn test_is_safe_manifest_path() {
// Legitimate profile-relative paths are accepted.
+36 -1
View File
@@ -52,7 +52,12 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/LOCK",
"**/*-journal",
"**/*-wal",
"**/*-shm",
"**/SingletonLock",
// Rewritten by donut before every launch from the profile metadata that
// already syncs; uploading it would only duplicate that state.
"**/wayfern-identity.json",
"**/wayfern-persona.json",
"**/SingletonSocket",
"**/SingletonCookie",
"**/Secure Preferences",
@@ -423,7 +428,7 @@ pub enum DiffBias {
/// Remote wins regardless of timestamps.
///
/// Used for exactly one thing: the pull that follows a remote session. A
/// leased host has just written the authoritative copy of this profile, and
/// remote host has just written the authoritative copy of this profile, and
/// the local directory is whatever it was before the session started. If the
/// user launched locally in between, local mtimes are NEWER than the host's
/// push, so `Auto` would upload the stale copy and put every file the host
@@ -674,6 +679,36 @@ mod tests {
);
}
#[test]
fn test_generate_manifest_excludes_sqlite_shm_sidecars() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile_root");
let default_dir = profile_dir.join("profile/Default");
fs::create_dir_all(&default_dir).unwrap();
fs::write(profile_dir.join("Cookies-shm"), "scratch").unwrap();
fs::write(default_dir.join("History-shm"), "scratch").unwrap();
fs::write(default_dir.join("History-wal"), "scratch").unwrap();
fs::write(default_dir.join("History"), "keep").unwrap();
let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
assert!(
!paths.iter().any(|p| p.ends_with("-shm")),
"SQLite -shm sidecars are scratch state and must not sync: {paths:?}"
);
assert!(
!paths.iter().any(|p| p.ends_with("-wal")),
"-wal sidecars stay excluded: {paths:?}"
);
assert!(
paths.contains(&"profile/Default/History"),
"the database itself must still sync: {paths:?}"
);
}
#[test]
fn test_compute_diff_upload_all_when_no_remote() {
let local = SyncManifest {
+1 -1
View File
@@ -34,7 +34,7 @@ pub struct SyncServerCheck {
/// one.
pub storage_ready: Option<bool>,
/// The host the server signs into presigned URLs, when it discloses one.
/// Withheld by cloud deployments on purpose.
/// Optional: a deployment need not publish it.
pub storage_endpoint: Option<String>,
/// Whether that host answered *this device*. `None` when there was nothing
/// to probe.
+48 -1
View File
@@ -458,13 +458,15 @@ impl SyncScheduler {
}
async fn process_pending(&self, app_handle: &tauri::AppHandle) {
// Deletions first. A queued sync for an entity another device deleted would
// otherwise re-upload it from the local copy this tick is about to remove.
self.process_pending_tombstones(app_handle).await;
self.process_pending_profiles(app_handle).await;
self.process_pending_proxies(app_handle).await;
self.process_pending_groups(app_handle).await;
self.process_pending_vpns(app_handle).await;
self.process_pending_extensions(app_handle).await;
self.process_pending_extension_groups(app_handle).await;
self.process_pending_tombstones(app_handle).await;
}
async fn process_pending_profiles(&self, app_handle: &tauri::AppHandle) {
@@ -830,6 +832,29 @@ impl SyncScheduler {
}
}
/// Forget a queued config sync for an entity whose deletion is being applied
/// this tick, so the drain that follows cannot re-upload it.
async fn drop_pending_config_sync(&self, entity_type: &str, entity_id: &str) {
match entity_type {
"proxy" => {
self.pending_proxies.lock().await.remove(entity_id);
}
"group" => {
self.pending_groups.lock().await.remove(entity_id);
}
"vpn" => {
self.pending_vpns.lock().await.remove(entity_id);
}
"extension" => {
self.pending_extensions.lock().await.remove(entity_id);
}
"extension_group" => {
self.pending_extension_groups.lock().await.remove(entity_id);
}
_ => {}
}
}
async fn process_pending_tombstones(&self, _app_handle: &tauri::AppHandle) {
let tombstones: Vec<(String, String)> = {
let mut pending = self.pending_tombstones.lock().await;
@@ -840,6 +865,10 @@ impl SyncScheduler {
return;
}
for (entity_type, entity_id) in &tombstones {
self.drop_pending_config_sync(entity_type, entity_id).await;
}
for (entity_type, entity_id) in tombstones {
log::info!("Processing tombstone for {} {}", entity_type, entity_id);
match entity_type.as_str() {
@@ -990,4 +1019,22 @@ mod tests {
// retired instance coming back to life could only ever be a duplicate.
assert_eq!(scheduler.claim_start_slot(), StartDecision::Retired);
}
#[tokio::test]
async fn test_drop_pending_config_sync_removes_only_the_deleted_entity() {
let scheduler = SyncScheduler::new();
scheduler.queue_proxy_sync("proxy-1".to_string()).await;
scheduler.queue_group_sync("group-1".to_string()).await;
assert!(scheduler.is_sync_in_progress().await);
// A tombstone drops that entity's queued sync, otherwise the drain that
// follows re-uploads the copy this tick is about to delete. Every other
// queued entity is left alone.
scheduler.drop_pending_config_sync("proxy", "proxy-1").await;
assert!(scheduler.pending_proxies.lock().await.is_empty());
assert!(scheduler.pending_groups.lock().await.contains("group-1"));
scheduler.drop_pending_config_sync("group", "group-1").await;
assert!(!scheduler.is_sync_in_progress().await);
}
}
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -279,14 +279,13 @@ impl ProfileLockManager {
}
}
/// Separator the backend puts between a user id and a non-desktop holder's
/// sub-identity. Mirrors `HOLDER_SEPARATOR` in donutbrowser-infra's
/// `profile-locks.service.ts`.
/// Separator the cloud API puts between a user id and a non-desktop holder's
/// sub-identity. Must match the server's holder format exactly.
///
/// A remote VM session takes the lock under `<user id>:vm:<session id>` so it
/// contends with this desktop instead of silently sharing its lock. That makes
/// the holder string the one place a client can tell "a teammate has this open"
/// apart from "this is my own profile, running on the fleet" — two refusals that
/// apart from "this is my own profile, running remotely" — two refusals that
/// need completely different words.
const VM_HOLDER_SEPARATOR: &str = ":vm:";
@@ -381,7 +380,7 @@ mod tests {
#[test]
fn a_users_own_remote_session_is_not_reported_as_a_teammate() {
// The holder for a fleet session is `<user id>:vm:<session id>` and the row
// The holder for a remote session is `<user id>:vm:<session id>` and it
// carries the OWNER's email, so the previous message read "Profile is in use
// by you@example.com" — the user's own address, about their own profile.
let err = lock_conflict_error(
+63
View File
@@ -135,7 +135,13 @@ pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError
current_section = Some("interface");
continue;
}
// Start a fresh map on every [Peer]. This config models exactly one peer,
// so without the reset a second block overwrites only the keys it declares
// and leaves the first peer's remaining values in place, yielding a peer
// (one endpoint, another peer's preshared key) that appears nowhere in the
// file. The last block listed wins, as it did before for the keys it sets.
if line == "[Peer]" {
peer.clear();
current_section = Some("peer");
continue;
}
@@ -207,6 +213,19 @@ pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError
})
}
/// Count the `[Peer]` sections in a WireGuard config.
///
/// `parse_wireguard_config` resolves a multi-peer file to its last peer, which
/// is not the tunnel the user described. Paths that take a config in use this
/// to refuse one; paths that read an already-stored config keep parsing it.
pub fn wireguard_peer_count(content: &str) -> usize {
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
content
.lines()
.filter(|line| line.trim() == "[Peer]")
.count()
}
/// Validate that a WireGuard key is a base64-encoded 32-byte value.
/// Reports the field name and a short preview of the bad value so users can
/// see exactly what went wrong (e.g. a redacted/masked key).
@@ -326,6 +345,50 @@ Endpoint = 1.2.3.4:51820
assert_eq!(config.peer_endpoint, "1.2.3.4:51820");
}
#[test]
fn test_parse_wireguard_config_takes_the_last_peer_whole() {
// Peer A carries a preshared key and a split-tunnel AllowedIPs, peer B
// carries neither. The parsed peer must be B alone: before the per-section
// reset, B's identity inherited A's preshared key and the tunnel handshook
// against a peer that existed in no input block.
let content = r#"
[Interface]
PrivateKey = YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=
Address = 10.0.0.2/24
[Peer]
PublicKey = YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=
Endpoint = a.example.com:51820
AllowedIPs = 10.0.0.0/24
PresharedKey = ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ=
[Peer]
PublicKey = Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2M=
Endpoint = b.example.com:51820
"#;
let config = parse_wireguard_config(content).unwrap();
assert_eq!(
config.peer_public_key,
"Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2M="
);
assert_eq!(config.peer_endpoint, "b.example.com:51820");
assert!(config.preshared_key.is_none());
assert_eq!(config.allowed_ips, vec!["0.0.0.0/0"]);
}
#[test]
fn test_wireguard_peer_count() {
let single = "[Interface]\nPrivateKey = k\n\n[Peer]\nPublicKey = p\n";
let multi = "[Interface]\nPrivateKey = k\n\n[Peer]\nPublicKey = p\n\n[Peer]\nPublicKey = q\n";
assert_eq!(wireguard_peer_count(single), 1);
assert_eq!(wireguard_peer_count(multi), 2);
assert_eq!(wireguard_peer_count("[Interface]\nPrivateKey = k\n"), 0);
// A commented-out header is not a section.
assert_eq!(wireguard_peer_count("# [Peer]\n"), 0);
}
#[test]
fn test_parse_wireguard_missing_private_key() {
let content = r#"
+58
View File
@@ -333,6 +333,25 @@ impl VpnStorage {
}
}
/// Refuse a config that declares more peers than a tunnel can carry.
///
/// A stored config is re-parsed at connect time into a single-peer tunnel, so
/// a file with several `[Peer]` blocks silently routes through whichever peer
/// is listed last and surfaces only as an opaque handshake timeout. Every way
/// a config gets in (manual create, file import, sync download) refuses one;
/// configs already on disk keep connecting exactly as before.
pub fn ensure_single_peer(vpn_type: VpnType, content: &str) -> Result<(), VpnError> {
let peers = match vpn_type {
VpnType::WireGuard => super::config::wireguard_peer_count(content),
};
if peers > 1 {
return Err(VpnError::InvalidWireGuard(format!(
"Config declares {peers} [Peer] sections; exactly one peer is supported"
)));
}
Ok(())
}
/// Create a VPN config manually from validated data
pub fn create_config_manual(
&self,
@@ -345,6 +364,7 @@ impl VpnStorage {
super::parse_wireguard_config(config_data)?;
}
}
Self::ensure_single_peer(vpn_type, config_data)?;
let id = Uuid::new_v4().to_string();
let sync_enabled = crate::sync::is_sync_configured();
@@ -407,6 +427,7 @@ impl VpnStorage {
super::parse_wireguard_config(content)?;
}
}
Self::ensure_single_peer(vpn_type, content)?;
let id = Uuid::new_v4().to_string();
let display_name = name.unwrap_or_else(|| {
@@ -548,4 +569,41 @@ mod tests {
let result = storage.load_config("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_ensure_single_peer() {
let single = "[Interface]\nPrivateKey = k\n\n[Peer]\nPublicKey = p\n";
let multi = "[Interface]\nPrivateKey = k\n\n[Peer]\nPublicKey = p\n\n[Peer]\nPublicKey = q\n";
assert!(VpnStorage::ensure_single_peer(VpnType::WireGuard, single).is_ok());
// A listing hands out an empty config body; it declares no peer to reject.
assert!(VpnStorage::ensure_single_peer(VpnType::WireGuard, "").is_ok());
let err = VpnStorage::ensure_single_peer(VpnType::WireGuard, multi).unwrap_err();
assert!(err.to_string().contains("[Peer]"));
}
#[test]
fn test_import_config_rejects_multi_peer() {
let (storage, _temp) = create_test_storage();
let content = concat!(
"[Interface]\n",
"PrivateKey = YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=\n",
"Address = 10.0.0.2/24\n",
"\n",
"[Peer]\n",
"PublicKey = YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=\n",
"Endpoint = a.example.com:51820\n",
"\n",
"[Peer]\n",
"PublicKey = Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2M=\n",
"Endpoint = b.example.com:51820\n",
);
// Every field parses; the file is refused only because a tunnel built from
// it would silently use the second peer.
assert!(crate::vpn::parse_wireguard_config(content).is_ok());
let imported = storage.import_config(content, "two-peers.conf", None);
assert!(imported.is_err());
}
}
+30 -3
View File
@@ -119,11 +119,14 @@ pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals {
let has = |list: &[&str], name: &str| list.contains(&name);
// MV2 keeps host patterns inside `permissions`; MV3 splits them into
// `host_permissions`. Look in both so one manifest version isn't silently
// under-detected.
// Host patterns can live in any of the four permission keys: MV2 keeps them
// in `permissions` and `optional_permissions`, MV3 splits them out into
// `host_permissions` and `optional_host_permissions`. Look in all four so one
// manifest version isn't silently under-detected. Only real host patterns can
// set the flag (`is_broad_host`), so API strings sharing these arrays are inert.
let all_hosts: Vec<&str> = permissions
.iter()
.chain(optional_permissions.iter())
.chain(host_permissions.iter())
.chain(optional_host_permissions.iter())
.copied()
@@ -461,6 +464,30 @@ mod tests {
);
}
#[test]
fn broad_hosts_detected_from_mv2_optional_permissions() {
// MV2 is where Chrome documents host patterns living in
// `optional_permissions`. Scanning the MV3 optional key but not this one
// dropped the extension from the scan result entirely.
let s = signals_of(json!({
"manifest_version": 2,
"permissions": ["webRequest", "webRequestBlocking"],
"optional_permissions": ["<all_urls>"]
}));
assert!(s.broad_host_permissions);
assert_eq!(
classify(None, &s, vpn_keyword_hit("Free VPN Proxy", None)),
Some("likely")
);
assert!(signal_labels(None, &s, true).contains(&"broadHostPermissions".to_string()));
}
#[test]
fn api_names_in_optional_permissions_are_not_broad_hosts() {
let s = signals_of(json!({ "optional_permissions": ["proxy", "storage"] }));
assert!(!s.broad_host_permissions);
}
#[test]
fn keyword_matching_reads_the_name_broadly_and_the_description_narrowly() {
assert!(vpn_keyword_hit("TouchVPN", None));
+196 -41
View File
@@ -5,7 +5,9 @@ use crate::vpn_worker_storage::{
get_vpn_worker_config, list_vpn_worker_configs, save_vpn_worker_config, vpn_worker_config_path,
VpnWorkerConfig,
};
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::{LazyLock, Mutex};
const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100;
const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 30_000;
@@ -33,6 +35,23 @@ async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool {
)
}
/// Is this worker's recorded process still the same live process?
///
/// Identity-checked whenever a start time was recorded, so a PID the OS has
/// since recycled reads as dead instead of as a live tunnel. Configs written
/// before `pid_start_time` existed fall back to a bare existence check, so the
/// first run after an upgrade does not declare every surviving worker dead.
/// Mirrors `proxy_storage::browser_owner_is_alive`.
pub fn vpn_worker_alive(config: &VpnWorkerConfig) -> bool {
let Some(pid) = config.pid else {
return false;
};
match config.pid_start_time {
Some(start_time) => crate::proxy_storage::process_identity_matches(pid, Some(start_time)),
None => is_process_running(pid),
}
}
fn worker_log_path(id: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("donut-vpn-{}.log", id))
}
@@ -61,7 +80,7 @@ async fn wait_for_vpn_worker_ready(
.await;
if let Some(updated_config) = get_vpn_worker_config(id) {
let process_running = updated_config.pid.map(is_process_running).unwrap_or(false);
let process_running = vpn_worker_alive(&updated_config);
if !process_running && attempts > 2 {
let log_output = read_worker_log(id);
@@ -77,7 +96,7 @@ async fn wait_for_vpn_worker_ready(
attempts += 1;
if tokio::time::Instant::now() >= startup_deadline {
if let Some(config) = get_vpn_worker_config(id) {
let process_running = config.pid.map(is_process_running).unwrap_or(false);
let process_running = vpn_worker_alive(&config);
let log_output = read_worker_log(id);
delete_vpn_worker_config(id);
return Err(
@@ -106,12 +125,66 @@ async fn wait_for_vpn_worker_ready(
/// `xray_worker_runner::XRAY_START_LOCK`.
static VPN_START_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// How many in-flight launches currently hold a worker for each vpn_id.
///
/// A launch is invisible to `vpn_id_in_use_by_running_browser` until its
/// browser PID is persisted, which happens seconds after the worker is adopted:
/// past the fingerprint gate, the local proxy worker, the decrypted profile
/// copy and the browser spawn. Without this, a sibling launch failing inside
/// that window stopped the shared worker out from under the adopter.
static VPN_LAUNCH_CLAIMS: LazyLock<Mutex<HashMap<String, usize>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// The critical section is a map bump that cannot panic, so a poisoned lock
/// carries no torn state worth refusing.
fn launch_claims() -> std::sync::MutexGuard<'static, HashMap<String, usize>> {
VPN_LAUNCH_CLAIMS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// One launch's hold on a VPN worker, taken while `VPN_START_LOCK` is held and
/// released only when the launch scope ends. Strictly RAII: nothing increments
/// the count outside `start_vpn_worker_tracked`, so a panicking launch cannot
/// pin a worker up for good.
pub struct VpnLaunchClaim {
vpn_id: String,
}
impl VpnLaunchClaim {
fn take(vpn_id: &str) -> Self {
*launch_claims().entry(vpn_id.to_string()).or_insert(0) += 1;
Self {
vpn_id: vpn_id.to_string(),
}
}
}
impl Drop for VpnLaunchClaim {
fn drop(&mut self) {
let mut claims = launch_claims();
if let Some(count) = claims.get_mut(&self.vpn_id) {
*count = count.saturating_sub(1);
if *count == 0 {
claims.remove(&self.vpn_id);
}
}
}
}
fn vpn_id_is_claimed_by_launch(vpn_id: &str) -> bool {
launch_claims().get(vpn_id).is_some_and(|count| *count > 0)
}
/// A started VPN worker plus whether *this* call spawned it.
pub struct VpnWorkerStart {
pub config: VpnWorkerConfig,
/// False when an already-running worker was adopted. Only the creator may
/// stop it while unwinding a failed launch.
pub created: bool,
/// Held for the rest of the launch, so a sibling launch failing before this
/// one publishes its browser PID cannot stop the worker underneath it.
pub claim: VpnLaunchClaim,
}
/// Whether any profile with a live browser process is routing through this VPN.
@@ -119,6 +192,11 @@ pub struct VpnWorkerStart {
/// Extracted from the startup sweep so the launch guard and the sweep agree on
/// what "in use" means instead of each carrying its own copy.
pub fn vpn_id_in_use_by_running_browser(vpn_id: &str) -> bool {
// A launch that has taken the worker but has not yet persisted its browser
// PID is invisible to the profile scan below, so consult the claims first.
if vpn_id_is_claimed_by_launch(vpn_id) {
return true;
}
let Ok(profiles) = crate::profile::ProfileManager::instance().list_profiles() else {
// Unable to tell — assume in use rather than tear down a live tunnel.
return true;
@@ -146,33 +224,29 @@ pub async fn start_vpn_worker_tracked(
crate::proxy_runner::ensure_sidecar_version().await?;
for config in list_vpn_worker_configs() {
if let Some(pid) = config.pid {
if !is_process_running(pid) {
delete_vpn_worker_config(&config.id);
}
} else {
if !vpn_worker_alive(&config) {
delete_vpn_worker_config(&config.id);
}
}
// Check if a VPN worker for this vpn_id already exists and is running
if let Some(existing) = find_vpn_worker_by_vpn_id(vpn_id) {
if let Some(pid) = existing.pid {
if is_process_running(pid) {
if vpn_worker_accepting_connections(&existing).await {
return Ok(VpnWorkerStart {
config: existing,
created: false,
});
}
return wait_for_vpn_worker_ready(&existing.id)
.await
.map(|config| VpnWorkerStart {
config,
created: false,
});
if vpn_worker_alive(&existing) {
if vpn_worker_accepting_connections(&existing).await {
return Ok(VpnWorkerStart {
config: existing,
created: false,
claim: VpnLaunchClaim::take(vpn_id),
});
}
return wait_for_vpn_worker_ready(&existing.id)
.await
.map(|config| VpnWorkerStart {
config,
created: false,
claim: VpnLaunchClaim::take(vpn_id),
});
}
// Worker config exists but process is dead, clean up
delete_vpn_worker_config(&existing.id);
@@ -266,6 +340,7 @@ pub async fn start_vpn_worker_tracked(
let mut config_with_pid = config.clone();
config_with_pid.pid = Some(pid);
config_with_pid.pid_start_time = crate::proxy_storage::resolve_process_start_time(pid);
config_with_pid.local_port = Some(local_port);
save_vpn_worker_config(&config_with_pid)?;
@@ -308,6 +383,7 @@ pub async fn start_vpn_worker_tracked(
let mut config_with_pid = config.clone();
config_with_pid.pid = Some(pid);
config_with_pid.pid_start_time = crate::proxy_storage::resolve_process_start_time(pid);
config_with_pid.local_port = Some(local_port);
save_vpn_worker_config(&config_with_pid)?;
@@ -319,6 +395,7 @@ pub async fn start_vpn_worker_tracked(
.map(|config| VpnWorkerStart {
config,
created: true,
claim: VpnLaunchClaim::take(vpn_id),
})
}
@@ -327,26 +404,40 @@ pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error
if let Some(config) = config {
if let Some(pid) = config.pid {
#[cfg(unix)]
{
use std::process::Command;
let _ = Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.output();
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
// Only a PID still pinned to the process this record was written for is
// ours to signal. A record with no start time predates the pinning and
// came from an earlier app run, so its PID cannot be verified either.
if crate::proxy_storage::process_identity_matches(pid, config.pid_start_time) {
#[cfg(unix)]
{
use std::process::Command;
let _ = Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.output();
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
} else if is_process_running(pid) {
// Whatever holds the PID now is either an unrelated process or an
// unverifiable pre-upgrade worker; the record is forgotten instead. A
// real worker in the second case lingers until reboot, which beats
// terminating a stranger.
log::warn!(
"Not signalling VPN worker {id}: PID {pid} cannot be pinned to the recorded process (start time {:?}); forgetting the record",
config.pid_start_time
);
}
}
// Clean up temp config file
@@ -373,3 +464,67 @@ pub async fn stop_all_vpn_workers() -> Result<(), Box<dyn std::error::Error>> {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn worker(pid: Option<u32>, pid_start_time: Option<u64>) -> VpnWorkerConfig {
VpnWorkerConfig {
id: "vpnw_test".to_string(),
vpn_id: "vpn_test".to_string(),
vpn_type: "wireguard".to_string(),
config_file_path: String::new(),
local_port: None,
local_url: None,
pid,
pid_start_time,
}
}
#[test]
fn a_recycled_pid_does_not_read_as_a_live_worker() {
let pid = std::process::id();
let start_time =
crate::proxy_storage::process_start_time(pid).expect("current process should be visible");
assert!(vpn_worker_alive(&worker(Some(pid), Some(start_time))));
// The same PID with a start time it cannot have: the worker that recorded
// it is gone and the OS handed its PID to something else.
assert!(!vpn_worker_alive(&worker(
Some(pid),
Some(start_time.saturating_add(1))
)));
// Written before the field existed, so bare existence is all the
// information the record carries. Upgrading must not reap live workers.
assert!(vpn_worker_alive(&worker(Some(pid), None)));
assert!(!vpn_worker_alive(&worker(None, None)));
assert!(!vpn_worker_alive(&worker(None, Some(start_time))));
}
#[test]
fn a_launch_claim_covers_the_worker_until_every_launch_ends() {
// A vpn_id private to this test, so a parallel test's claims are neither
// observed here nor disturbed by it.
let vpn_id = format!("vpn_claim_test_{}", rand::random::<u32>());
assert!(!vpn_id_is_claimed_by_launch(&vpn_id));
let creator = VpnLaunchClaim::take(&vpn_id);
assert!(vpn_id_is_claimed_by_launch(&vpn_id));
// An adopter joins, then the creator's launch fails: the worker is still
// covered, which is what stops the creator's guard tearing it down.
let adopter = VpnLaunchClaim::take(&vpn_id);
drop(creator);
assert!(vpn_id_is_claimed_by_launch(&vpn_id));
// With no launch left holding it, nothing keeps the worker up. A creator
// whose launch fails alone must still be able to stop what it started.
drop(adopter);
assert!(!vpn_id_is_claimed_by_launch(&vpn_id));
}
}
+6
View File
@@ -12,6 +12,11 @@ pub struct VpnWorkerConfig {
pub local_port: Option<u16>,
pub local_url: Option<String>,
pub pid: Option<u32>,
/// Pins `pid` to one exact process, so a recycled PID cannot make a dead
/// worker look alive or get an unrelated process signalled. Defaulted
/// because configs written before this field existed must still deserialize.
#[serde(default)]
pub pid_start_time: Option<u64>,
}
impl VpnWorkerConfig {
@@ -24,6 +29,7 @@ impl VpnWorkerConfig {
local_port: None,
local_url: None,
pid: None,
pid_start_time: None,
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+393
View File
@@ -0,0 +1,393 @@
//! The person a profile presents as, when a site asks for one.
//!
//! Wayfern shows a "Fill with generated" submenu in any text field, built
//! from a document the launcher writes: `{"fields":[{"label","value"},…]}`.
//! The browser never invents a value, so everything here is donut's.
//!
//! A persona is DERIVED, not stored as prose: the same profile hands the
//! browser the same person on every launch, and two profiles never share one,
//! because every field is a function of the profile's own seed. The user can
//! still edit any field; edits are the only thing that persists.
use serde::{Deserialize, Serialize};
/// A named value the browser offers in its fill submenu.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersonaField {
pub id: String,
pub label: String,
pub value: String,
}
/// The browser truncates a longer submenu; keeping the same bound here means
/// what the user edits is what the browser shows.
const MAX_FIELDS: usize = 24;
/// Bounds that match what the fill submenu will render, so a value is never
/// silently shortened.
const MAX_LABEL_CHARS: usize = 64;
const MAX_VALUE_CHARS: usize = 512;
/// The fields a derived persona carries, in submenu order.
pub const FIELD_IDS: [&str; 9] = [
"full_name",
"first_name",
"last_name",
"email",
"username",
"phone",
"birth_date",
"street_address",
"postal_code",
];
/// FNV-1a with the salt folded into the initial state, then splitmix64, so
/// neighbouring salts do not produce visibly related values.
fn draw(seed: &str, salt: u64) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ salt;
for byte in seed.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
let mut z = hash.wrapping_add(0x9e37_79b9_7f4a_7c15);
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
fn pick<'a>(seed: &str, salt: u64, options: &[&'a str]) -> &'a str {
options[(draw(seed, salt) % options.len() as u64) as usize]
}
const GIVEN_NAMES: [&str; 32] = [
"Amelia", "Noah", "Sofia", "Liam", "Mia", "Lucas", "Emma", "Ethan", "Olivia", "Mateo", "Ava",
"Leon", "Zara", "Hugo", "Nora", "Adam", "Iris", "Felix", "Maya", "Oscar", "Lena", "Rafael",
"Clara", "Milan", "Elif", "Jonas", "Nina", "Tobias", "Rosa", "Kai", "Alma", "Viktor",
];
const FAMILY_NAMES: [&str; 32] = [
"Bennett",
"Novak",
"Marchetti",
"Okafor",
"Lindqvist",
"Haddad",
"Vasquez",
"Ferreira",
"Kowalski",
"Dubois",
"Andersen",
"Rahman",
"Moretti",
"Kaminski",
"Bauer",
"Silva",
"Petrov",
"Nakamura",
"Kelly",
"Weiss",
"Salgado",
"Virtanen",
"Costa",
"Yilmaz",
"Horvat",
"Laurent",
"Fischer",
"Blake",
"Reyes",
"Janssen",
"Meyer",
"Sorensen",
];
const STREETS: [&str; 16] = [
"Maple Avenue",
"Linden Street",
"Harbour Road",
"Kestrel Lane",
"Alder Way",
"Foundry Street",
"Willow Crescent",
"Bridgeway",
"Chandler Street",
"Orchard Row",
"Beacon Hill",
"Cypress Walk",
"Quarry Road",
"Sable Street",
"Juniper Court",
"Pier Lane",
];
const MAIL_HOSTS: [&str; 6] = [
"gmail.com",
"outlook.com",
"proton.me",
"yahoo.com",
"icloud.com",
"fastmail.com",
];
/// A calendar date `years_back` years or so before now, as `YYYY-MM-DD`.
/// Days-in-month is handled by capping at 28, which every month has.
fn birth_date(seed: &str) -> String {
let year = 1970 + (draw(seed, 61) % 36); // 1970..2005: adult in any locale
let month = 1 + (draw(seed, 62) % 12);
let day = 1 + (draw(seed, 63) % 28);
format!("{year:04}-{month:02}-{day:02}")
}
/// Digits only, so the value is usable in a field with any formatting rule.
fn phone(seed: &str) -> String {
let area = 200 + (draw(seed, 71) % 700);
let prefix = 200 + (draw(seed, 72) % 700);
let line = draw(seed, 73) % 10_000;
format!("+1{area:03}{prefix:03}{line:04}")
}
/// Derive the persona a profile presents, in submenu order.
///
/// `seed` must be stable for the profile and unique to it: the identity id
/// when it has one, otherwise the profile id. Nothing here reads the clock or
/// the host, so the same seed reproduces the same person anywhere.
pub fn derive(seed: &str) -> Vec<PersonaField> {
let given = pick(seed, 11, &GIVEN_NAMES);
let family = pick(seed, 12, &FAMILY_NAMES);
let username = format!(
"{}{}{}",
given.to_lowercase(),
family.to_lowercase(),
draw(seed, 21) % 100
);
let email = format!("{username}@{}", pick(seed, 22, &MAIL_HOSTS));
let street = format!(
"{} {}",
1 + (draw(seed, 31) % 200),
pick(seed, 32, &STREETS)
);
let postal = format!("{:05}", draw(seed, 33) % 100_000);
// Ordered by FIELD_IDS, which is what the browser's submenu shows.
let labels = [
"Full name",
"First name",
"Last name",
"Email",
"Username",
"Phone",
"Date of birth",
"Street address",
"Postal code",
];
let values = [
format!("{given} {family}"),
given.to_string(),
family.to_string(),
email,
username,
phone(seed),
birth_date(seed),
street,
postal,
];
FIELD_IDS
.iter()
.zip(labels)
.zip(values)
.map(|((id, label), value)| field(id, label, value))
.collect()
}
fn field(id: &str, label: &str, value: String) -> PersonaField {
PersonaField {
id: id.to_string(),
label: label.to_string(),
value,
}
}
/// Apply the user's edits to a derived persona: an edit replaces the value of
/// the field it names, an unknown id is appended, and a blank value removes
/// the row so the browser never offers an empty entry.
pub fn with_edits(seed: &str, edits: &[PersonaField]) -> Vec<PersonaField> {
let mut fields = derive(seed);
for edit in edits {
let value = edit.value.trim();
match fields.iter().position(|f| f.id == edit.id) {
Some(index) if value.is_empty() => {
fields.remove(index);
}
Some(index) => {
fields[index].value = value.to_string();
if !edit.label.trim().is_empty() {
fields[index].label = edit.label.trim().to_string();
}
}
None if value.is_empty() => {}
None => fields.push(field(
&edit.id,
if edit.label.trim().is_empty() {
&edit.id
} else {
edit.label.trim()
},
value.to_string(),
)),
}
}
fields.truncate(MAX_FIELDS);
for field in &mut fields {
truncate_chars(&mut field.label, MAX_LABEL_CHARS);
truncate_chars(&mut field.value, MAX_VALUE_CHARS);
}
fields
}
fn truncate_chars(text: &mut String, limit: usize) {
if text.chars().count() > limit {
*text = text.chars().take(limit).collect();
}
}
/// The document the browser reads, as it writes it to disk.
pub fn document(fields: &[PersonaField]) -> serde_json::Value {
serde_json::json!({ "fields": fields })
}
/// The person this profile presents, as the browser will offer it: derived
/// from the profile's own seed with the user's edits applied.
///
/// The seed is the identity id when the profile has one and its own id
/// otherwise, which is exactly what the launcher uses, so what this returns is
/// what the next launch writes. `derived_only` asks for the person before any
/// edit, which is what "reset to generated" shows.
#[tauri::command]
pub fn get_profile_persona(
profile_id: String,
derived_only: Option<bool>,
) -> Result<Vec<PersonaField>, String> {
let profile = crate::profile::ProfileManager::instance()
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?
.into_iter()
.find(|profile| profile.id.to_string() == profile_id)
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))?;
let config = profile.wayfern_config.unwrap_or_default();
let seed = config
.identity_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string)
.unwrap_or_else(|| profile.id.to_string());
if derived_only.unwrap_or(false) {
return Ok(derive(&seed));
}
let edits: Vec<PersonaField> = config
.persona
.as_deref()
.map(str::trim)
.filter(|edits| !edits.is_empty())
.and_then(|edits| serde_json::from_str(edits).ok())
.unwrap_or_default();
Ok(with_edits(&seed, &edits))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_persona_is_stable_for_one_seed_and_different_across_seeds() {
let a = derive("3fa85f64-5717-4562-b3fc-2c963f66afa6");
assert_eq!(a, derive("3fa85f64-5717-4562-b3fc-2c963f66afa6"));
let b = derive("9c858901-8a57-4791-81fe-4c455b099bc9");
assert_ne!(a, b);
assert_eq!(
a.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
FIELD_IDS,
"the submenu order is the launcher's decision and must not drift"
);
}
#[test]
fn every_derived_value_is_usable() {
for seed in ["a", "b", "seed-3", "3fa85f64-5717-4562-b3fc-2c963f66afa6"] {
let fields = derive(seed);
let get = |id: &str| {
fields
.iter()
.find(|f| f.id == id)
.map(|f| f.value.clone())
.unwrap()
};
assert!(get("email").contains('@'));
assert!(get("email").starts_with(&get("username")));
assert!(
get("full_name") == format!("{} {}", get("first_name"), get("last_name")),
"the full name must be the two parts it is made of"
);
let phone = get("phone");
assert!(phone.starts_with('+') && phone[1..].chars().all(|c| c.is_ascii_digit()));
let birth = get("birth_date");
assert_eq!(birth.len(), 10);
let day: u32 = birth[8..].parse().unwrap();
assert!((1..=28).contains(&day), "{birth}");
assert!(fields.iter().all(|f| !f.value.trim().is_empty()));
}
}
#[test]
fn an_edit_replaces_one_field_and_a_blank_removes_it() {
let seed = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
let edited = with_edits(
seed,
&[
field("email", "", "me@example.com".into()),
field("phone", "", " ".into()),
field("company", "Company", "Donut".into()),
],
);
assert_eq!(
edited.iter().find(|f| f.id == "email").unwrap().value,
"me@example.com"
);
assert!(edited.iter().all(|f| f.id != "phone"));
let extra = edited.iter().find(|f| f.id == "company").unwrap();
assert_eq!(
(extra.label.as_str(), extra.value.as_str()),
("Company", "Donut")
);
// Everything not edited still comes from the seed.
let derived = derive(seed);
assert_eq!(
edited.iter().find(|f| f.id == "full_name").unwrap().value,
derived.iter().find(|f| f.id == "full_name").unwrap().value
);
}
#[test]
fn edits_cannot_exceed_the_browsers_own_limits() {
let long = "x".repeat(1000);
let edited = with_edits(
"seed",
&(0..40)
.map(|i| field(&format!("extra{i}"), &long, long.clone()))
.collect::<Vec<_>>(),
);
assert_eq!(edited.len(), MAX_FIELDS);
assert!(edited
.iter()
.all(|f| f.label.chars().count() <= MAX_LABEL_CHARS
&& f.value.chars().count() <= MAX_VALUE_CHARS));
}
#[test]
fn the_document_is_the_shape_the_browser_parses() {
let document = document(&derive("seed"));
let fields = document["fields"].as_array().unwrap();
assert_eq!(fields.len(), FIELD_IDS.len());
assert!(fields
.iter()
.all(|f| f["id"].is_string() && f["label"].is_string() && f["value"].is_string()));
}
}
+4
View File
@@ -184,6 +184,10 @@ impl WayfernTermsManager {
}
log::info!("Wayfern terms and conditions accepted successfully");
// The frontend only re-reads the marker when it drove the acceptance
// itself. Anything else that accepts (the REST API, a WebDriver session)
// would leave the blocking dialog open, so the change is announced.
let _ = crate::events::emit_empty("wayfern-terms-accepted");
Ok(())
}
}
+40 -5
View File
@@ -170,11 +170,17 @@ fn validate_endpoint_address(address: &str) -> XrayResult<()> {
if address.parse::<IpAddr>().is_ok() {
return Ok(());
}
Host::parse(address).map_err(|_| XrayError::InvalidField {
field: "address",
reason: "must be a valid hostname or IP address",
})?;
Ok(())
// Canonicality, not just parseability: `Host::parse` percent-decodes and
// punycodes before it validates, so `caf%C3%A9.example.com` parses fine while
// Xray dials the stored string verbatim and never resolves it. Case is the
// one difference that is safe, since parsing only lowercases.
match Host::parse(address) {
Ok(host) if host.to_string().eq_ignore_ascii_case(address) => Ok(()),
_ => Err(XrayError::InvalidField {
field: "address",
reason: "must be a valid hostname or IP address",
}),
}
}
fn validate_server_name(server_name: &str) -> XrayResult<()> {
@@ -300,6 +306,35 @@ mod tests {
}
}
#[test]
fn endpoint_rejects_hosts_xray_would_dial_verbatim() {
// Both reach the sidecar unchanged, so accepting them buys a dead tunnel
// with no import-time error.
for address in ["caf%C3%A9.example.com", "café.example.com"] {
let mut config = valid_config();
config.address = address.to_string();
assert!(
matches!(
config.validate(),
Err(XrayError::InvalidField {
field: "address",
..
})
),
"{address}"
);
}
}
#[test]
fn endpoint_accepts_mixed_case_and_punycode_hosts() {
for address in ["VPN.Example.com", "xn--caf-dma.example.com"] {
let mut config = valid_config();
config.address = address.to_string();
assert_eq!(config.validate(), Ok(()), "{address}");
}
}
#[test]
fn id_must_be_a_uuid() {
let mut config = valid_config();
+36 -1
View File
@@ -55,7 +55,16 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
})?
.to_string();
let address = match url.host().ok_or(XrayError::MissingField("address"))? {
Host::Domain(value) => value.to_string(),
// `vless` is not a special scheme, so `Url` keeps the host exactly as
// written, percent-escapes included, and that string is what the sidecar
// dials. Re-parsing canonicalizes an internationalized host into the
// punycode form that actually resolves.
Host::Domain(value) => Host::parse(value)
.map_err(|_| XrayError::InvalidField {
field: "address",
reason: "must be a valid hostname or IP address",
})?
.to_string(),
Host::Ipv4(value) => value.to_string(),
Host::Ipv6(value) => value.to_string(),
};
@@ -417,6 +426,32 @@ mod tests {
assert_eq!(parsed.name.as_deref(), Some("Home server"));
}
#[test]
fn an_internationalized_host_is_stored_as_punycode() {
let input = uri(&[]).replace("vpn.example.com", "café.example.com");
let parsed = parse_vless_uri(&input).unwrap();
assert_eq!(parsed.config.address, "xn--caf-dma.example.com");
// And the canonical form survives an export/import round trip.
let exported = export_vless_uri(&parsed.config, None).unwrap();
assert_eq!(
parse_vless_uri(&exported).unwrap().config.address,
"xn--caf-dma.example.com"
);
}
#[test]
fn rejects_a_host_that_percent_decodes_into_something_undialable() {
let input = uri(&[]).replace("vpn.example.com", "vpn%2Fexample.com");
assert!(matches!(
parse_vless_uri(&input),
Err(XrayError::InvalidField {
field: "address",
..
})
));
}
#[test]
fn applies_only_safe_optional_defaults() {
let key = public_key();