mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-15 14:15:31 +02:00
refactor: cleanup
This commit is contained in:
Generated
+178
-103
@@ -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
@@ -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
+944
-49
File diff suppressed because it is too large
Load Diff
+319
-9
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(¬hing_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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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, ¤t];
|
||||
|
||||
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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(×tamp_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>> {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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!(
|
||||
|
||||
+1697
-365
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+6003
-380
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms).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
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+837
-75
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+2364
-183
File diff suppressed because it is too large
Load Diff
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user