From 58b0febe426424aac666e7eb42e5f75ffaed0656 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:38:20 +0400 Subject: [PATCH] feat: Windows RAM profiles --- AGENTS.md | 1 + e2e/coverage-map.mjs | 1 + e2e/tests/entities.test.mjs | 11 + src-tauri/Cargo.lock | 349 +++++- src-tauri/Cargo.toml | 11 + src-tauri/build.rs | 7 + src-tauri/src/bin/proxy_server.rs | 36 + src-tauri/src/browser_runner.rs | 6 +- src-tauri/src/ephemeral_dirs.rs | 122 +- src-tauri/src/lib.rs | 2 + src-tauri/src/ramfs/host.rs | 447 +++++++ src-tauri/src/ramfs/mod.rs | 303 +++++ src-tauri/src/ramfs/state.rs | 50 + src-tauri/src/ramfs/tree.rs | 1200 +++++++++++++++++++ src-tauri/src/ramfs/winfsp_fs.rs | 511 ++++++++ src/components/create-profile-dialog.tsx | 3 + src/components/ephemeral-storage-notice.tsx | 65 + src/generated/licenses.json | 16 + src/i18n/locales/de.json | 5 + src/i18n/locales/en.json | 5 + src/i18n/locales/es.json | 5 + src/i18n/locales/fr.json | 5 + src/i18n/locales/ja.json | 5 + src/i18n/locales/ko.json | 5 + src/i18n/locales/pt.json | 5 + src/i18n/locales/ru.json | 5 + src/i18n/locales/tr.json | 5 + src/i18n/locales/vi.json | 5 + src/i18n/locales/zh.json | 5 + 29 files changed, 3087 insertions(+), 109 deletions(-) create mode 100644 src-tauri/src/ramfs/host.rs create mode 100644 src-tauri/src/ramfs/mod.rs create mode 100644 src-tauri/src/ramfs/state.rs create mode 100644 src-tauri/src/ramfs/tree.rs create mode 100644 src-tauri/src/ramfs/winfsp_fs.rs create mode 100644 src/components/ephemeral-storage-notice.tsx diff --git a/AGENTS.md b/AGENTS.md index d9a2493c..97d43bcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ donutbrowser/ │ │ ├── feedback.rs # Feedback to the Donut team from the dialog and the send_feedback MCP tool, optional redacted logs │ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler) │ │ ├── vpn/ # WireGuard tunnels +│ │ ├── ramfs/ # Windows RAM volume for ephemeral profiles: in-memory file system on WinFsp, hosted by donut-proxy │ │ ├── xray/ # Xray share links (VLESS, VMess, Trojan, Hysteria2): parse, export, client config │ │ ├── xray_worker_runner.rs # Xray-core sidecar lifecycle │ │ ├── xray_worker_storage.rs # Private Xray worker state and runtime files diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 9258b046..929991eb 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -78,6 +78,7 @@ export const commandCoverage = { "profile_totp::add_profile_totp", "profile_totp::delete_profile_totp", "profile_totp::get_profile_totp_codes", + "ephemeral_dirs::get_ephemeral_storage_status", "rename_profile", "detect_existing_profiles", "import_browser_profiles", diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index accdfab1..269dc4a2 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -1522,6 +1522,17 @@ test("a storage state file moves cookies and Local Storage into a closed profile }), /LOCAL_STORAGE_EPHEMERAL/, ); + + const storage = await app.invoke("get_ephemeral_storage_status"); + assert.deepEqual(Object.keys(storage).sort(), ["in_memory", "reason"]); + if (process.platform === "win32") { + assert.equal(storage.in_memory, storage.reason === null); + if (storage.reason !== null) { + assert.match(storage.reason, /^winfsp_(missing|error)$/); + } + } else { + assert.deepEqual(storage, { in_memory: true, reason: null }); + } }); }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4b4f52a3..93586de1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -182,7 +182,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -193,7 +193,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -224,7 +224,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -470,7 +470,7 @@ dependencies = [ "smappservice-rs", "thiserror 2.0.21", "windows-registry", - "windows-result", + "windows-result 0.4.1", ] [[package]] @@ -508,7 +508,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -601,6 +601,26 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -957,7 +977,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -966,6 +986,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfb" version = "0.10.0" @@ -1056,7 +1085,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1091,6 +1120,17 @@ dependencies = [ "inout 0.2.4", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clap" version = "4.6.7" @@ -1672,7 +1712,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1837,7 +1877,9 @@ dependencies = [ "utoipa", "utoipa-axum", "uuid", - "windows", + "windows 0.62.2", + "winfsp", + "winfsp-sys", "winreg", "wiremock", "yaml-rust2", @@ -2071,7 +2113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2560,7 +2602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3119,7 +3161,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -3424,6 +3466,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3476,7 +3527,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3666,7 +3717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -3711,6 +3762,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.16" @@ -3907,6 +3968,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3990,7 +4057,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4073,6 +4140,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -4625,7 +4702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -4679,7 +4756,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5009,6 +5086,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -5266,7 +5353,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -5621,7 +5708,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6161,6 +6248,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -6289,7 +6382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6377,6 +6470,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "static_vcruntime" version = "3.0.0" @@ -6522,7 +6621,7 @@ dependencies = [ "objc2-core-foundation", "objc2-io-kit", "objc2-open-directory", - "windows", + "windows 0.62.2", ] [[package]] @@ -6593,8 +6692,8 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] @@ -6676,7 +6775,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.62.2", ] [[package]] @@ -6806,7 +6905,7 @@ dependencies = [ "tracing", "url", "windows-registry", - "windows-result", + "windows-result 0.4.1", ] [[package]] @@ -6925,7 +7024,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.21", "url", - "windows", + "windows 0.62.2", "zbus", ] @@ -7005,7 +7104,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.62.2", ] [[package]] @@ -7030,7 +7129,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.62.2", "wry", ] @@ -7090,7 +7189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" dependencies = [ "thiserror 2.0.21", - "windows", + "windows 0.62.2", "windows-version", ] @@ -7101,10 +7200,10 @@ 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.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7590,7 +7689,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7600,7 +7699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", - "nom", + "nom 8.0.0", "petgraph", ] @@ -7669,7 +7768,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8160,8 +8259,8 @@ checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -8182,8 +8281,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ "thiserror 2.0.21", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -8192,6 +8291,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -8214,7 +8319,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8235,20 +8340,42 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "windows-version", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -8257,7 +8384,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -8268,9 +8408,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -8279,9 +8430,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", - "windows-link", - "windows-threading", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -8306,20 +8457,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", - "windows-link", + "windows-core 0.62.2", + "windows-link 0.2.1", ] [[package]] @@ -8328,9 +8495,18 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -8339,7 +8515,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -8348,7 +8533,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -8384,7 +8569,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -8424,7 +8609,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -8435,13 +8620,22 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -8450,7 +8644,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -8591,6 +8785,31 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winfsp" +version = "0.13.1+winfsp-2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5132f201f1a350673ec9eb7d13be9162e16ea057a44043ef64635aa749b16c53" +dependencies = [ + "bytemuck", + "parking_lot", + "paste", + "static_assertions", + "thiserror 2.0.21", + "widestring", + "windows 0.61.3", + "winfsp-sys", +] + +[[package]] +name = "winfsp-sys" +version = "0.12.1+winfsp-2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d42acc30c105f8d33507f556398237c738b681271c775a3b94eefd29d2b8c77e" +dependencies = [ + "bindgen", +] + [[package]] name = "winnow" version = "0.5.40" @@ -8616,7 +8835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8710,8 +8929,8 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d02b6fdf..e274be2d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -182,7 +182,18 @@ windows = { version = "0.62", features = [ # CryptUnprotectData, for unwrapping the source browser's os_crypt key from # `Local State` during profile import. "Win32_Security_Cryptography", + # The RAM volume (src/ramfs): its root security descriptor, and loading the + # installed WinFsp DLL by full path. + "Win32_Security_Authorization", + "Win32_System_LibraryLoader", ] } +# WinFsp, for the RAM volume of ephemeral profiles. Donut does not ship the +# driver; the DLL is delay-loaded and used only when it is installed. +winfsp = { version = "0.13", default-features = false } +winfsp-sys = "0.12" + +[target.'cfg(windows)'.build-dependencies] +winfsp = { version = "0.13", default-features = false, features = ["delayload"] } [dev-dependencies] tempfile = "3.27.0" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index e80af3c1..4e5b03b9 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -15,6 +15,13 @@ fn main() { println!("cargo:rustc-link-lib=framework=CoreServices"); } + // WinFsp's DLL loads at the first call into it, so Donut starts on machines + // that do not have WinFsp (src/ramfs). + #[cfg(windows)] + if build_target.contains("windows") { + winfsp::build::winfsp_link_delayload(); + } + // Inject build version based on environment variables set by CI if let Ok(build_tag) = std::env::var("BUILD_TAG") { // Custom BUILD_TAG takes highest priority (used for nightly builds) diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index 93b0c370..e10ecaf6 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -271,6 +271,31 @@ async fn main() { .help("Direct path to the Xray worker config JSON file"), ), ) + .subcommand( + Command::new("ramfs") + .about("Serve the in-memory volume of ephemeral profiles until killed (Windows with WinFsp; internal use)") + .arg( + Arg::new("mount") + .long("mount") + .required(true) + .value_parser(clap::value_parser!(std::path::PathBuf)) + .help("Directory to mount the volume on; it must not exist"), + ) + .arg( + Arg::new("capacity") + .long("capacity") + .required(true) + .value_parser(clap::value_parser!(u64)) + .help("Volume size in bytes"), + ) + .arg( + Arg::new("state") + .long("state") + .required(true) + .value_parser(clap::value_parser!(std::path::PathBuf)) + .help("File that records the mounted volume"), + ), + ) .get_matches(); if let Some(proxy_matches) = matches.subcommand_matches("proxy") { @@ -645,6 +670,17 @@ async fn main() { ); process::exit(1); } + } else if let Some(ramfs_matches) = matches.subcommand_matches("ramfs") { + let mount = ramfs_matches + .get_one::("mount") + .expect("mount is required"); + let capacity = *ramfs_matches + .get_one::("capacity") + .expect("capacity is required"); + let state = ramfs_matches + .get_one::("state") + .expect("state is required"); + process::exit(donutbrowser_lib::ramfs::host_main(mount, capacity, state)); } else { eprintln!("No command specified"); process::exit(1); diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 112d109c..907e9978 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -680,7 +680,11 @@ impl BrowserRunner { Some(prepared) } else { if profile.ephemeral { - crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string()) + // Can mount a RAM disk or start the RAM volume's host and wait for it. + let id = profile.id.to_string(); + tokio::task::spawn_blocking(move || crate::ephemeral_dirs::create_ephemeral_dir(&id)) + .await + .map_err(|e| -> Box { e.to_string().into() })? .map_err(|e| -> Box { e.into() })?; } None diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index 05f0d5ef..4a8c7415 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -87,7 +87,7 @@ fn earlier_base_exists() -> bool { #[cfg(target_os = "macos")] let ram = Path::new(MACOS_RAM_DISK).is_dir(); #[cfg(target_os = "windows")] - let ram = existing_windows_ramdisk().is_some(); + let ram = existing_windows_ramdisk().is_some() || crate::ramfs::live_volume().is_some(); #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] let ram = false; ram || disk_fallback_base().exists() @@ -96,10 +96,11 @@ fn earlier_base_exists() -> bool { /// Get or create the base directory for ephemeral profiles, and report whether /// it is actually RAM-backed. /// -/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: imdisk -/// RAM disk, which is a third-party driver this app does not ship, so on most -/// Windows machines the disk fallback is the normal path rather than an edge -/// case. Callers must treat `Disk` as a downgrade and erase accordingly. +/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: a RAM +/// disk the person made, else the WinFsp volume (`crate::ramfs`). WinFsp is a +/// driver this app does not ship, so on many Windows machines the disk +/// fallback is the normal path rather than an edge case. Callers must treat +/// `Disk` as a downgrade and erase accordingly. fn get_ephemeral_base_dir() -> Result<(PathBuf, EphemeralBacking), String> { if let Some(base) = ephemeral_base_override() { std::fs::create_dir_all(&base) @@ -203,44 +204,14 @@ fn existing_windows_ramdisk() -> Option { .find(|base| base.is_dir()) } +/// A RAM disk the person made is used as it is. Creating one needs admin +/// rights, so Donut does not try; it mounts its WinFsp volume instead. #[cfg(target_os = "windows")] fn get_or_create_windows_ramdisk() -> Result { if let Some(base) = existing_windows_ramdisk() { return Ok(base); } - - // Try to create a RAM disk using imdisk (open-source RAM disk driver) - for letter in ['R', 'Q', 'P', 'O'] { - let drive = format!("{}:", letter); - if PathBuf::from(format!("{}\\", drive)).exists() { - continue; - } - - let output = std::process::Command::new("imdisk") - .args(["-a", "-s", "256M", "-m", &drive, "-p", "/fs:ntfs /q /y"]) - .output(); - - match output { - Ok(out) if out.status.success() => { - let base = PathBuf::from(format!("{}\\DonutEphemeral", drive)); - std::fs::create_dir_all(&base) - .map_err(|e| format!("Failed to create dir on RAM disk: {e}"))?; - log::info!("RAM disk created path={} size_mb=256", base.display()); - return Ok(base); - } - Ok(out) => { - log::debug!( - "imdisk failed drive={drive} err=\"{}\"", - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Err(e) => { - return Err(format!("imdisk not available: {e}")); - } - } - } - - Err("Could not create Windows RAM disk".to_string()) + crate::ramfs::ensure_volume() } pub fn create_ephemeral_dir(profile_id: &str) -> Result { @@ -483,20 +454,37 @@ fn sweep_disk_fallback_residue(current_base: &Path) { } } -/// Whether the macOS RAM disk is mounted. `None` on other systems. +/// Whether the macOS RAM disk or the Windows RAM volume is mounted. `None` +/// where neither can exist. pub fn ram_disk_mounted() -> Option { #[cfg(target_os = "macos")] return Some(Path::new(MACOS_RAM_DISK).is_dir()); - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] + return crate::ramfs::winfsp_available() + .ok() + .map(|()| crate::ramfs::live_volume().is_some()); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] None } -/// Detach the macOS RAM disk when no profile directory is on it. Nothing else -/// detaches it, so it would stay mounted until the Mac restarts. +/// Detach the macOS RAM disk, or stop the Windows RAM volume's host, when no +/// profile directory is on it. Nothing else does, so the macOS disk would stay +/// mounted until the Mac restarts, and the Windows host would keep running. /// /// Runs at exit, where `hdiutil detach` takes seconds, so the detach is /// started and not waited for: it finishes after Donut is gone. pub fn detach_unused_ram_disk() { + #[cfg(target_os = "windows")] + { + if ephemeral_base_override().is_some() { + return; + } + if let Some(volume) = crate::ramfs::live_volume() { + if holds_no_profile_dirs(&volume.mount) { + crate::ramfs::stop_host(volume.pid, "unused_at_exit"); + } + } + } #[cfg(target_os = "macos")] { use std::process::Stdio; @@ -522,7 +510,7 @@ pub fn detach_unused_ram_disk() { } /// False when `path` cannot be read, so a missing volume is never detached. -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] fn holds_no_profile_dirs(path: &Path) -> bool { std::fs::read_dir(path).is_ok_and(|entries| { entries @@ -557,6 +545,41 @@ fn cleanup_legacy_dirs() { } } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct EphemeralStorageStatus { + in_memory: bool, + /// `winfsp_missing` or `winfsp_error` when not in memory. + reason: Option<&'static str>, +} + +/// Whether ephemeral profiles keep their data in memory on this computer. +/// Mounts nothing. +#[tauri::command] +pub async fn get_ephemeral_storage_status() -> EphemeralStorageStatus { + storage_status() +} + +fn storage_status() -> EphemeralStorageStatus { + #[cfg(target_os = "windows")] + { + if existing_windows_ramdisk().is_none() { + let reason = match crate::ramfs::status() { + crate::ramfs::Status::InMemory => None, + crate::ramfs::Status::WinfspMissing => Some("winfsp_missing"), + crate::ramfs::Status::WinfspError => Some("winfsp_error"), + }; + return EphemeralStorageStatus { + in_memory: reason.is_none(), + reason, + }; + } + } + EphemeralStorageStatus { + in_memory: true, + reason: None, + } +} + pub fn get_effective_profile_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf { if profile.ephemeral || profile.password_protected { if let Some(dir) = get_ephemeral_dir(&profile.id.to_string()) { @@ -736,6 +759,19 @@ mod tests { assert!(get_ephemeral_dir(&id).is_none()); } + #[test] + fn storage_status_is_in_memory_or_names_its_reason() { + let status = serde_json::to_value(storage_status()).unwrap(); + let in_memory = status["in_memory"].as_bool().unwrap(); + let reason = status["reason"].as_str(); + assert_eq!(in_memory, reason.is_none()); + #[cfg(not(target_os = "windows"))] + assert!(in_memory); + if let Some(reason) = reason { + assert!(["winfsp_missing", "winfsp_error"].contains(&reason)); + } + } + #[test] #[serial_test::serial] fn removing_an_unknown_profile_succeeds_without_doing_anything() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 355547fa..99029f4f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -175,6 +175,7 @@ pub mod proxy_runner; pub mod proxy_server; pub mod proxy_storage; pub mod proxy_udp; +pub mod ramfs; mod recipes; mod recorder; mod remote_exit; @@ -3725,6 +3726,7 @@ pub fn run_with_builder( unlock_profile, lock_profile, is_profile_locked, + ephemeral_dirs::get_ephemeral_storage_status, sync::restore_points::list_restore_points, sync::restore_points::create_restore_point, sync::restore_points::delete_restore_point, diff --git a/src-tauri/src/ramfs/host.rs b/src-tauri/src/ramfs/host.rs new file mode 100644 index 00000000..72e90620 --- /dev/null +++ b/src-tauri/src/ramfs/host.rs @@ -0,0 +1,447 @@ +//! The process that serves the RAM volume (`donut-proxy ramfs`), and the WinFsp +//! loading it needs. The volume lives exactly as long as this process. +//! +//! WinFsp is a file system driver that Donut does not ship. When it is +//! installed, an ordinary user can mount a user-mode file system on a +//! directory; this process mounts one that keeps everything in its own memory. + +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use windows::core::{PCWSTR, PWSTR}; +use windows::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL}; +use windows::Win32::Security::{PSECURITY_DESCRIPTOR, TOKEN_QUERY, TOKEN_USER}; +use winfsp::host::{FileSystemHost, VolumeParams}; + +use super::state::VolumeState; +use super::tree::{now, ALLOCATION_UNIT, MAX_NAME_LEN}; +use super::winfsp_fs::RamFs; + +#[cfg(target_arch = "x86_64")] +pub const DLL_NAME: &str = "winfsp-x64.dll"; +#[cfg(target_arch = "aarch64")] +pub const DLL_NAME: &str = "winfsp-a64.dll"; +#[cfg(target_arch = "x86")] +pub const DLL_NAME: &str = "winfsp-x86.dll"; + +/// How long Windows may answer free-space questions from its cache. +const VOLUME_INFO_TIMEOUT_MS: u32 = 1000; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Unavailable { + Missing, + Broken(String), +} + +impl std::fmt::Display for Unavailable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Unavailable::Missing => f.write_str("winfsp not installed"), + Unavailable::Broken(e) => write!(f, "winfsp unusable: {e}"), + } + } +} + +fn wide(text: &std::ffi::OsStr) -> Vec { + text.encode_wide().chain(std::iter::once(0)).collect() +} + +fn install_dir() -> Option { + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + let machine = RegKey::predef(HKEY_LOCAL_MACHINE); + ["SOFTWARE\\WOW6432Node\\WinFsp", "SOFTWARE\\WinFsp"] + .iter() + .find_map(|key| { + machine + .open_subkey(key) + .ok()? + .get_value::("InstallDir") + .ok() + }) + .map(PathBuf::from) +} + +/// Load the installed WinFsp DLL by its full path, then let the crate bind to +/// it. Nothing may call into WinFsp before this succeeds: the DLL is +/// delay-loaded, and a missing DLL would end the process at the first call. +pub fn load() -> Result<(), Unavailable> { + use windows::Win32::System::LibraryLoader::LoadLibraryW; + static LOADED: OnceLock<()> = OnceLock::new(); + if LOADED.get().is_some() { + return Ok(()); + } + let dll = install_dir() + .map(|dir| dir.join("bin").join(DLL_NAME)) + .filter(|dll| dll.is_file()) + .ok_or(Unavailable::Missing)?; + let name = wide(dll.as_os_str()); + unsafe { LoadLibraryW(PCWSTR(name.as_ptr())) } + .map_err(|e| Unavailable::Broken(format!("load {}: {e}", dll.display())))?; + winfsp::winfsp_init().map_err(|e| Unavailable::Broken(format!("init: {e:?}")))?; + let _ = LOADED.set(()); + Ok(()) +} + +fn user_sid() -> Result { + use windows::Win32::Security::Authorization::ConvertSidToStringSidW; + use windows::Win32::Security::{GetTokenInformation, TokenUser}; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + unsafe { + let mut token = HANDLE::default(); + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) + .map_err(|e| format!("process token: {e}"))?; + let mut len = 0u32; + let _ = GetTokenInformation(token, TokenUser, None, 0, &mut len); + // u64 elements keep the buffer aligned for TOKEN_USER. + let mut buffer = vec![0u64; (len as usize).div_ceil(8)]; + let read = GetTokenInformation( + token, + TokenUser, + Some(buffer.as_mut_ptr().cast()), + len, + &mut len, + ); + let _ = CloseHandle(token); + read.map_err(|e| format!("token user: {e}"))?; + let user = &*buffer.as_ptr().cast::(); + let mut text = PWSTR::null(); + ConvertSidToStringSidW(user.User.Sid, &mut text).map_err(|e| format!("user sid: {e}"))?; + let sid = text.to_string(); + let _ = LocalFree(Some(HLOCAL(text.0.cast()))); + sid.map_err(|e| format!("user sid: {e}")) + } +} + +/// The root directory's descriptor: the user, SYSTEM and Administrators, as in +/// the user's own profile folder, inherited by everything created below. +fn root_security() -> Result, String> { + use windows::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + let sid = user_sid()?; + let sddl = + format!("O:{sid}G:{sid}D:P(A;OICI;FA;;;S-1-5-18)(A;OICI;FA;;;S-1-5-32-544)(A;OICI;FA;;;{sid})"); + let sddl = wide(std::ffi::OsStr::new(&sddl)); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + let mut len = 0u32; + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR(sddl.as_ptr()), + SDDL_REVISION_1, + &mut descriptor, + Some(&mut len), + ) + .map_err(|e| format!("root security: {e}"))?; + let bytes = std::slice::from_raw_parts(descriptor.0.cast::(), len as usize).to_vec(); + let _ = LocalFree(Some(HLOCAL(descriptor.0))); + Ok(bytes) + } +} + +fn volume_params() -> VolumeParams { + let mut params = VolumeParams::new(); + params + .sector_size(ALLOCATION_UNIT as u16) + .sectors_per_allocation_unit(1) + .max_component_length(MAX_NAME_LEN as u16) + .volume_creation_time(now()) + .volume_serial_number(now() as u32) + .file_info_timeout(u32::MAX) + .case_sensitive_search(false) + .case_preserved_names(true) + .unicode_on_disk(true) + .persistent_acls(true) + .post_cleanup_when_modified_only(true) + .post_disposition_only_when_necessary(true) + .supports_posix_unlink_rename(true) + .reparse_points(true) + .filesystem_name("NTFS"); + // Free space must stay current: Chromium sizes its caches and quotas from + // it. The crate sets the timeout but not the flag that makes it count. + // SAFETY: VolumeParams is a transparent wrapper of FSP_FSCTL_VOLUME_PARAMS. + let raw = unsafe { + &mut *(&mut params as *mut VolumeParams).cast::() + }; + raw.set_VolumeInfoTimeoutValid(1); + raw.VolumeInfoTimeout = VOLUME_INFO_TIMEOUT_MS; + params +} + +/// A mounted volume. Dropping it unmounts it and frees everything on it. +pub struct Volume { + _host: FileSystemHost, +} + +pub fn mount_volume(mount: &Path, capacity: u64) -> Result { + load().map_err(|e| e.to_string())?; + let fs = RamFs::new(capacity, root_security()?); + let mut host: FileSystemHost = + FileSystemHost::new(volume_params(), fs).map_err(|e| format!("create: {e}"))?; + host + .mount(mount.as_os_str()) + .map_err(|e| format!("mount: {e}"))?; + host.start().map_err(|e| format!("start: {e}"))?; + Ok(Volume { _host: host }) +} + +/// WinFsp creates the mount point itself, so the path must not exist. A +/// crashed host can leave an empty directory or a dead junction there; either +/// is removed. Anything with contents is left alone. +fn clear_mount_point(mount: &Path) -> Result<(), String> { + match std::fs::symlink_metadata(mount) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("mount point unreadable: {e}")), + Ok(_) => std::fs::remove_dir(mount).map_err(|e| format!("mount point in use: {e}")), + } +} + +/// Mount, record the volume in `state_file`, and serve until killed. +pub fn host_main(mount: &Path, capacity: u64, state_file: &Path) -> i32 { + let started = std::time::Instant::now(); + let pid = std::process::id(); + let prepared = mount + .parent() + .map_or(Ok(()), std::fs::create_dir_all) + .map_err(|e| format!("mount parent: {e}")) + .and_then(|()| clear_mount_point(mount)); + let volume = match prepared.and_then(|()| mount_volume(mount, capacity)) { + Ok(volume) => volume, + Err(e) => { + log::error!( + "Ram volume mount failed mount={} pid={pid} err=\"{e}\"", + mount.display() + ); + return 1; + } + }; + let state = VolumeState { + pid, + mount: mount.to_path_buf(), + capacity, + }; + if let Err(e) = state.write(state_file) { + log::error!( + "Ram volume state write failed path={} pid={pid} err=\"{e}\"", + state_file.display() + ); + return 1; + } + log::info!( + "Ram volume mounted mount={} capacity_mb={} pid={pid} elapsed_ms={}", + mount.display(), + capacity >> 20, + started.elapsed().as_millis() + ); + let _volume = volume; + loop { + std::thread::park(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + + fn free_bytes(path: &Path) -> u64 { + use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + let mut name = path.as_os_str().to_os_string(); + name.push("\\"); + let name = wide(&name); + let mut free = 0u64; + unsafe { GetDiskFreeSpaceExW(PCWSTR(name.as_ptr()), Some(&mut free), None, None) }.unwrap(); + free + } + + fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut file = std::fs::File::create(path)?; + file.write_all(bytes)?; + file.sync_all() + } + + fn wait_for_volume_info() { + std::thread::sleep(std::time::Duration::from_millis( + VOLUME_INFO_TIMEOUT_MS as u64 + 200, + )); + } + + /// Run after installing WinFsp: + /// `cargo test --lib ramfs::host -- --ignored --nocapture` + #[test] + #[ignore = "needs WinFsp installed"] + fn a_mounted_volume_serves_files_like_a_disk() { + let root = tempfile::tempdir().unwrap(); + let mount = root.path().join("ephemeral-ram"); + let capacity: u64 = 64 << 20; + let volume = mount_volume(&mount, capacity).unwrap(); + + let network = mount.join("profile\\Default\\Network"); + std::fs::create_dir_all(&network).unwrap(); + std::fs::write(network.join("Cookies"), b"a=1").unwrap(); + assert_eq!( + std::fs::read(mount.join("PROFILE\\default\\NETWORK\\cookies")).unwrap(), + b"a=1" + ); + let listed: Vec = std::fs::read_dir(mount.join("profile\\default")) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(listed, ["Network"]); + + // An atomic write: a temporary file moved over the old one. + let prefs = mount.join("profile\\Default\\Preferences"); + let temp = mount.join("profile\\Default\\Preferences.tmp"); + std::fs::write(&prefs, b"old").unwrap(); + std::fs::write(&temp, b"new").unwrap(); + std::fs::rename(&temp, &prefs).unwrap(); + assert_eq!(std::fs::read(&prefs).unwrap(), b"new"); + assert!(!temp.exists()); + + // Replace and delete a file that another handle still reads. + let mut held = std::fs::File::open(&prefs).unwrap(); + std::fs::write(&temp, b"newer").unwrap(); + std::fs::rename(&temp, &prefs).unwrap(); + assert_eq!(std::fs::read(&prefs).unwrap(), b"newer"); + let mut old = String::new(); + held.read_to_string(&mut old).unwrap(); + assert_eq!(old, "new"); + std::fs::remove_file(&prefs).unwrap(); + assert!(!prefs.exists()); + drop(held); + + // A delete-on-close file goes when its handle closes. + let scratch = mount.join("profile\\Default\\scratch"); + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .custom_flags(FILE_FLAG_DELETE_ON_CLOSE) + .open(&scratch) + .unwrap(); + file.write_all(b"temp").unwrap(); + assert!(scratch.exists()); + } + assert!(!scratch.exists()); + + // Windows may refuse to move a directory with an open file in it, as NTFS + // does. Either way the open file keeps its data and one path finds it. + let history = mount.join("profile\\Default\\History"); + std::fs::write(&history, b"visits").unwrap(); + let mut open_child = std::fs::File::open(&history).unwrap(); + let moved = std::fs::rename( + mount.join("profile\\Default"), + mount.join("profile\\Profile 1"), + ); + let mut visits = String::new(); + open_child.read_to_string(&mut visits).unwrap(); + assert_eq!(visits, "visits"); + drop(open_child); + let default = match moved { + Ok(()) => mount.join("profile\\Profile 1"), + Err(e) => { + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); + mount.join("profile\\Default") + } + }; + assert_eq!(std::fs::read(default.join("History")).unwrap(), b"visits"); + let network = default.join("Network"); + + // An ACE added to a folder stays and passes to new files, as Chromium's + // sandbox needs for the Network folder. AC is ALL APPLICATION PACKAGES. + let sddl = |path: &Path| { + let script = format!("(Get-Acl -LiteralPath '{}').Sddl", path.display()); + let out = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", &script]) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).into_owned() + }; + let granted = std::process::Command::new("icacls") + .arg(&network) + .args(["/grant", "*S-1-15-2-1:(OI)(CI)(F)"]) + .output() + .unwrap(); + assert!(granted.status.success(), "{granted:?}"); + let tokens = network.join("Trust Tokens"); + std::fs::write(&tokens, b"x").unwrap(); + assert!(sddl(&network).contains(";;;AC)"), "{}", sddl(&network)); + assert!(sddl(&tokens).contains(";;;AC)"), "{}", sddl(&tokens)); + + // A plain file answers "not a reparse point". + { + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::ERROR_NOT_A_REPARSE_POINT; + use windows::Win32::System::IO::DeviceIoControl; + const FSCTL_GET_REPARSE_POINT: u32 = 0x0009_00A8; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(network.join("Cookies")) + .unwrap(); + let mut buffer = vec![0u8; 16 * 1024]; + let mut returned = 0u32; + let queried = unsafe { + DeviceIoControl( + HANDLE(file.as_raw_handle()), + FSCTL_GET_REPARSE_POINT, + None, + 0, + Some(buffer.as_mut_ptr().cast()), + buffer.len() as u32, + Some(&mut returned), + None, + ) + }; + assert_eq!( + queried.unwrap_err().code(), + ERROR_NOT_A_REPARSE_POINT.to_hresult() + ); + } + + // Free space follows what is written and deleted. + wait_for_volume_info(); + let before = free_bytes(&mount); + assert!(before < capacity); + let cache = mount.join("profile\\cache"); + write_synced(&cache, &vec![7u8; 8 << 20]).unwrap(); + wait_for_volume_info(); + let during = free_bytes(&mount); + assert!( + before - during >= 8 << 20, + "before={before} during={during}" + ); + std::fs::remove_file(&cache).unwrap(); + wait_for_volume_info(); + assert_eq!(free_bytes(&mount), before); + + let huge = mount.join("profile\\huge"); + assert!(write_synced(&huge, &vec![0u8; capacity as usize + 1]).is_err()); + let _ = std::fs::remove_file(&huge); + + std::fs::remove_dir_all(mount.join("profile")).unwrap(); + assert_eq!(std::fs::read_dir(&mount).unwrap().count(), 0); + + drop(volume); + assert!(!mount.exists()); + } + + #[test] + fn a_stale_mount_point_is_cleared_but_contents_are_kept() { + let root = tempfile::tempdir().unwrap(); + let mount = root.path().join("ephemeral-ram"); + assert!(clear_mount_point(&mount).is_ok()); + std::fs::create_dir(&mount).unwrap(); + assert!(clear_mount_point(&mount).is_ok()); + assert!(!mount.exists()); + std::fs::create_dir(&mount).unwrap(); + std::fs::write(mount.join("keep"), b"x").unwrap(); + assert!(clear_mount_point(&mount).is_err()); + assert!(mount.join("keep").exists()); + } +} diff --git a/src-tauri/src/ramfs/mod.rs b/src-tauri/src/ramfs/mod.rs new file mode 100644 index 00000000..c0212098 --- /dev/null +++ b/src-tauri/src/ramfs/mod.rs @@ -0,0 +1,303 @@ +//! In-memory storage for ephemeral and password-protected profiles on Windows. +//! +//! Windows has no tmpfs, and the RAM disk drivers that exist need admin rights. +//! WinFsp, when the person has installed it, lets an ordinary user mount a +//! file system served by a process: `donut-proxy ramfs` serves one that keeps +//! every byte in its own memory. It is a separate process because a browser +//! can outlive the app, and its profile must stay readable until it exits. + +pub mod state; +pub mod tree; + +#[cfg(windows)] +mod host; +#[cfg(windows)] +mod winfsp_fs; + +pub use state::VolumeState; + +const MIN_CAPACITY: u64 = 512 << 20; +const MAX_CAPACITY: u64 = 4 << 30; + +/// A quarter of the RAM, between 512 MiB and 4 GiB. +pub fn volume_capacity(total_ram: u64) -> u64 { + (total_ram / 4).clamp(MIN_CAPACITY, MAX_CAPACITY) +} + +/// Why the volume cannot be used, or `Ok` when WinFsp is ready. +pub fn winfsp_available() -> Result<(), String> { + #[cfg(windows)] + { + host::load().map_err(|e| e.to_string()) + } + #[cfg(not(windows))] + { + Err("winfsp is for windows only".to_string()) + } +} + +/// What the ephemeral-storage notice shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + InMemory, + WinfspMissing, + WinfspError, +} + +/// `donut-proxy ramfs`: mount the volume and serve it until killed. +pub fn host_main(mount: &std::path::Path, capacity: u64, state_file: &std::path::Path) -> i32 { + #[cfg(windows)] + { + host::host_main(mount, capacity, state_file) + } + #[cfg(not(windows))] + { + log::error!( + "Ram volume mount failed mount={} capacity_mb={} state={} err=\"windows only\"", + mount.display(), + capacity >> 20, + state_file.display() + ); + 2 + } +} + +#[cfg(windows)] +pub use windows_client::{ensure_volume, live_volume, status, stop_host}; + +#[cfg(windows)] +mod windows_client { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Mutex, PoisonError}; + use std::time::{Duration, Instant}; + + use super::host::{self, Unavailable}; + use super::{volume_capacity, Status, VolumeState}; + + const MOUNT_WAIT: Duration = Duration::from_secs(10); + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + + /// Set when WinFsp loaded but the last start of the volume failed. + static LAST_START_FAILED: AtomicBool = AtomicBool::new(false); + + fn mount_dir() -> PathBuf { + crate::app_dirs::data_dir().join("ephemeral-ram") + } + + fn state_path() -> PathBuf { + crate::app_dirs::data_dir().join("ephemeral-ram.json") + } + + fn log_path() -> PathBuf { + std::env::temp_dir().join("donut-ramfs.log") + } + + fn host_alive(pid: u32) -> bool { + use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System}; + let pid = sysinfo::Pid::from_u32(pid); + let mut system = System::new(); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + true, + ProcessRefreshKind::nothing(), + ); + system.process(pid).is_some_and(|process| { + crate::proxy_storage::snapshot_process_is_alive(process) + && process + .name() + .to_string_lossy() + .to_ascii_lowercase() + .starts_with("donut-proxy") + }) + } + + /// A live mount point is a junction into the volume, and it lists. + fn mount_answers(mount: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + std::fs::symlink_metadata(mount) + .is_ok_and(|meta| meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) + && std::fs::read_dir(mount).is_ok() + } + + /// The volume of this data directory, when its host runs and it answers. + pub fn live_volume() -> Option { + let state = VolumeState::read(&state_path())?; + (state.mount == mount_dir() && host_alive(state.pid) && mount_answers(&state.mount)) + .then_some(state) + } + + pub fn status() -> Status { + match host::load() { + Err(Unavailable::Missing) => Status::WinfspMissing, + Err(Unavailable::Broken(_)) => Status::WinfspError, + Ok(()) if LAST_START_FAILED.load(Ordering::Relaxed) => Status::WinfspError, + Ok(()) => Status::InMemory, + } + } + + /// The mounted volume's path, starting its host when no live one exists. + pub fn ensure_volume() -> Result { + static STARTING: Mutex<()> = Mutex::new(()); + let _starting = STARTING.lock().unwrap_or_else(PoisonError::into_inner); + host::load().map_err(|e| e.to_string())?; + if live_volume().is_some() { + LAST_START_FAILED.store(false, Ordering::Relaxed); + return Ok(mount_dir()); + } + let started = start_host(); + LAST_START_FAILED.store(started.is_err(), Ordering::Relaxed); + started + } + + fn start_host() -> Result { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + const DETACHED_PROCESS: u32 = 0x00000008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let started = Instant::now(); + let mount = mount_dir(); + let capacity = { + let mut system = sysinfo::System::new(); + system.refresh_memory(); + volume_capacity(system.total_memory()) + }; + let exe = crate::proxy_runner::find_sidecar_executable("donut-proxy") + .map_err(|e| format!("donut-proxy not found: {e}"))?; + let log = + crate::app_dirs::create_owner_only(&log_path()).map_or_else(|_| Stdio::null(), Stdio::from); + let mut child = Command::new(exe) + .arg("ramfs") + .arg("--mount") + .arg(&mount) + .arg("--capacity") + .arg(capacity.to_string()) + .arg("--state") + .arg(state_path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW) + .spawn() + .map_err(|e| format!("ramfs host spawn: {e}"))?; + let pid = child.id(); + + loop { + if live_volume().is_some_and(|state| state.pid == pid) { + log::info!( + "Ram volume host started pid={pid} mount={} capacity_mb={} elapsed_ms={}", + mount.display(), + capacity >> 20, + started.elapsed().as_millis() + ); + return Ok(mount); + } + if let Ok(Some(status)) = child.try_wait() { + return Err(format!( + "ramfs host exited ({status}) log={}", + log_path().display() + )); + } + if started.elapsed() > MOUNT_WAIT { + let _ = child.kill(); + return Err(format!( + "ramfs host did not mount in {}s log={}", + MOUNT_WAIT.as_secs(), + log_path().display() + )); + } + std::thread::sleep(Duration::from_millis(100)); + } + } + + /// End the host, which unmounts the volume and frees its memory. Not waited + /// for, because this runs at exit. + pub fn stop_host(pid: u32, reason: &str) { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}; + let stopped = unsafe { + OpenProcess(PROCESS_TERMINATE, false, pid).and_then(|process| { + let result = TerminateProcess(process, 0); + let _ = CloseHandle(process); + result + }) + }; + match stopped { + Ok(()) => { + let _ = std::fs::remove_file(state_path()); + log::info!("Ram volume host stopped pid={pid} reason={reason}"); + } + Err(e) => log::warn!("Ram volume host stop failed pid={pid} reason={reason} err=\"{e}\""), + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn a_missing_winfsp_is_reported_and_never_called() { + match host::load() { + Ok(()) => assert_ne!(status(), Status::WinfspMissing), + Err(Unavailable::Missing) => { + assert_eq!(status(), Status::WinfspMissing); + assert_eq!(ensure_volume(), Err("winfsp not installed".to_string())); + } + Err(Unavailable::Broken(e)) => panic!("{e}"), + } + } + + /// Run after installing WinFsp and building the sidecar: + /// `cargo build --bin donut-proxy`, then + /// `cargo test --lib ramfs::windows_client -- --ignored --nocapture` + #[test] + #[ignore = "needs WinFsp installed and a built donut-proxy"] + fn the_sidecar_host_serves_a_volume_until_stopped() { + let data = tempfile::tempdir().unwrap(); + let _data = crate::app_dirs::set_test_data_dir(data.path().to_path_buf()); + let mount = ensure_volume().unwrap(); + assert_eq!(mount, data.path().join("ephemeral-ram")); + assert_eq!(status(), Status::InMemory); + std::fs::write(mount.join("probe"), b"x").unwrap(); + + let volume = live_volume().unwrap(); + assert_eq!(ensure_volume().unwrap(), mount); + assert_eq!(live_volume().unwrap().pid, volume.pid); + assert_eq!(std::fs::read(mount.join("probe")).unwrap(), b"x"); + + stop_host(volume.pid, "test"); + let deadline = Instant::now() + Duration::from_secs(5); + while live_volume().is_some() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + assert!(live_volume().is_none()); + assert!(!mount.join("probe").exists()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capacity_is_a_quarter_of_ram_within_bounds() { + assert_eq!(volume_capacity(1 << 30), MIN_CAPACITY); + assert_eq!(volume_capacity(8 << 30), 2 << 30); + assert_eq!(volume_capacity(64 << 30), MAX_CAPACITY); + assert_eq!(volume_capacity(0), MIN_CAPACITY); + } + + #[cfg(not(windows))] + #[test] + fn other_systems_have_no_volume_host() { + assert!(winfsp_available().is_err()); + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + host_main(&dir.path().join("m"), MIN_CAPACITY, &dir.path().join("s")), + 2 + ); + } +} diff --git a/src-tauri/src/ramfs/state.rs b/src-tauri/src/ramfs/state.rs new file mode 100644 index 00000000..767d0eb3 --- /dev/null +++ b/src-tauri/src/ramfs/state.rs @@ -0,0 +1,50 @@ +//! The file the volume host writes once its volume is mounted, so the app can +//! find a volume that an earlier run started. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VolumeState { + pub pid: u32, + pub mount: PathBuf, + pub capacity: u64, +} + +impl VolumeState { + /// Written to a temporary file first, so a reader never sees half of it. + pub fn write(&self, path: &Path) -> std::io::Result<()> { + let temporary = path.with_extension("tmp"); + std::fs::write(&temporary, serde_json::to_vec(self)?)?; + std::fs::rename(&temporary, path) + } + + pub fn read(path: &Path) -> Option { + serde_json::from_slice(&std::fs::read(path).ok()?).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn state_round_trips_and_unreadable_state_is_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ephemeral-ram.json"); + assert_eq!(VolumeState::read(&path), None); + + let state = VolumeState { + pid: 42, + mount: dir.path().join("ephemeral-ram"), + capacity: 512 << 20, + }; + state.write(&path).unwrap(); + assert_eq!(VolumeState::read(&path), Some(state)); + assert!(!path.with_extension("tmp").exists()); + + std::fs::write(&path, b"{").unwrap(); + assert_eq!(VolumeState::read(&path), None); + } +} diff --git a/src-tauri/src/ramfs/tree.rs b/src-tauri/src/ramfs/tree.rs new file mode 100644 index 00000000..d9834338 --- /dev/null +++ b/src-tauri/src/ramfs/tree.rs @@ -0,0 +1,1200 @@ +//! The file tree of the RAM volume. It holds no WinFsp types: names are UTF-16 +//! path parts like Windows sends them, so the tests run on every OS. +//! +//! File contents never go back to the allocator with data in them. A buffer is +//! zeroed before it is freed, before it is replaced by a larger or smaller one, +//! and in the part a truncation cuts off. + +use std::collections::{BTreeMap, HashMap}; +use std::ops::Bound; + +pub type NodeId = u64; +pub const ROOT: NodeId = 1; + +pub const ATTR_READONLY: u32 = 0x1; +pub const ATTR_DIRECTORY: u32 = 0x10; +pub const ATTR_ARCHIVE: u32 = 0x20; +pub const ATTR_NORMAL: u32 = 0x80; + +pub const ALLOCATION_UNIT: u64 = 512; +pub const MAX_NAME_LEN: usize = 255; + +const SEPARATOR: u16 = b'\\' as u16; +const DOT: &[u16] = &[b'.' as u16]; +const DOT_DOT: &[u16] = &[b'.' as u16, b'.' as u16]; +/// A growing file gets spare room of its own size, up to this much, so a file +/// written in small appends is not copied on every append. +const MAX_GROWTH_STEP: u64 = 8 << 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FsError { + NotFound, + PathNotFound, + NameCollision, + NameInvalid, + NotADirectory, + IsADirectory, + DirectoryNotEmpty, + AccessDenied, + CannotDelete, + DiskFull, + EndOfFile, + InvalidParameter, +} + +pub type FsResult = Result; + +/// Times are Windows FILETIME values: 100 ns units since 1601-01-01. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Info { + pub attributes: u32, + pub allocation_size: u64, + pub file_size: u64, + pub creation_time: u64, + pub last_access_time: u64, + pub last_write_time: u64, + pub change_time: u64, + pub index_number: u64, +} + +/// Time changes for `Tree::set_basic`. `None` keeps the current value. +#[derive(Debug, Clone, Copy, Default)] +pub struct BasicInfo { + pub attributes: Option, + pub creation_time: Option, + pub last_access_time: Option, + pub last_write_time: Option, + pub change_time: Option, +} + +pub fn now() -> u64 { + const UNIX_EPOCH_AS_FILETIME: u64 = 116_444_736_000_000_000; + let since_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + UNIX_EPOCH_AS_FILETIME + (since_unix.as_nanos() / 100) as u64 +} + +fn wipe(bytes: &mut [u8]) { + bytes.fill(0); + std::hint::black_box(bytes); +} + +fn round_up(size: u64) -> u64 { + size.div_ceil(ALLOCATION_UNIT) * ALLOCATION_UNIT +} + +/// File contents. `bytes.len()` is the file size, `allocation` the size +/// Windows sees as allocated, and `room` the memory actually held, which the +/// volume charges. `bytes` never reallocates on its own, because a +/// reallocation would free the old buffer without zeroing it. +#[derive(Default)] +struct Data { + bytes: Vec, + allocation: u64, + room: u64, +} + +impl Data { + fn with_room(room: u64) -> Self { + Data { + bytes: Vec::with_capacity(room as usize), + allocation: room, + room, + } + } + + fn len(&self) -> u64 { + self.bytes.len() as u64 + } + + /// Move the contents to a buffer of `room` bytes. The caller first + /// truncates to at most `room`. + fn move_to(&mut self, room: u64) { + debug_assert!(self.len() <= room); + let mut next = Vec::with_capacity(room as usize); + next.extend_from_slice(&self.bytes); + wipe(&mut self.bytes); + self.bytes = next; + self.room = room; + } + + fn truncate(&mut self, len: u64) { + if len < self.len() { + wipe(&mut self.bytes[len as usize..]); + self.bytes.truncate(len as usize); + } + } + + /// Extend with zeros. The caller first makes `room` large enough. + fn extend(&mut self, len: u64) { + debug_assert!(len <= self.room && self.bytes.capacity() as u64 >= len); + if len > self.len() { + self.bytes.resize(len as usize, 0); + } + } +} + +impl Drop for Data { + fn drop(&mut self) { + wipe(&mut self.bytes); + } +} + +struct Node { + name: Vec, + /// `None` for the root and for a node unlinked while a handle still has it + /// open. + parent: Option, + /// `Some` for a directory: its children by case-folded name. + children: Option, NodeId>>, + data: Data, + attributes: u32, + creation_time: u64, + last_access_time: u64, + last_write_time: u64, + change_time: u64, + security: Vec, + opens: u32, +} + +impl Node { + fn is_dir(&self) -> bool { + self.children.is_some() + } +} + +/// Windows compares names case-insensitively with an upcase table of single +/// UTF-16 units. This maps each unit the same way where Unicode gives a single +/// BMP uppercase form. +pub fn fold(name: &[u16]) -> Vec { + name + .iter() + .map(|&unit| { + if unit < 0x80 { + return (unit as u8).to_ascii_uppercase() as u16; + } + let Some(c) = char::from_u32(unit as u32) else { + return unit; + }; + let mut upper = c.to_uppercase(); + match (upper.next(), upper.next()) { + (Some(u), None) if (u as u32) <= 0xFFFF => u as u16, + _ => unit, + } + }) + .collect() +} + +fn components(path: &[u16]) -> impl Iterator { + path + .split(|&unit| unit == SEPARATOR) + .filter(|part| !part.is_empty()) +} + +/// Split `\a\b\name` into `\a\b` and `name`. +fn split_parent(path: &[u16]) -> FsResult<(&[u16], &[u16])> { + let end = path + .iter() + .rposition(|&unit| unit != SEPARATOR) + .map(|last| last + 1) + .ok_or(FsError::NameInvalid)?; + let path = &path[..end]; + let start = path + .iter() + .rposition(|&unit| unit == SEPARATOR) + .map(|sep| sep + 1) + .unwrap_or(0); + Ok((&path[..start], &path[start..])) +} + +fn check_name(name: &[u16]) -> FsResult<()> { + if name.is_empty() + || name.len() > MAX_NAME_LEN + || name == DOT + || name == DOT_DOT + || name.contains(&0) + { + return Err(FsError::NameInvalid); + } + Ok(()) +} + +fn normalized_attributes(attributes: u32, directory: bool) -> u32 { + let attributes = attributes & !(ATTR_DIRECTORY | ATTR_NORMAL); + if directory { + attributes | ATTR_DIRECTORY + } else { + attributes + } +} + +pub struct Tree { + nodes: HashMap, + next_id: NodeId, + capacity: u64, + used: u64, +} + +impl Tree { + pub fn new(capacity: u64, root_security: Vec) -> Self { + let time = now(); + let root = Node { + name: Vec::new(), + parent: None, + children: Some(BTreeMap::new()), + data: Data::default(), + attributes: ATTR_DIRECTORY, + creation_time: time, + last_access_time: time, + last_write_time: time, + change_time: time, + security: root_security, + opens: 0, + }; + Tree { + nodes: HashMap::from([(ROOT, root)]), + next_id: ROOT + 1, + capacity, + used: 0, + } + } + + pub fn capacity(&self) -> u64 { + self.capacity + } + + pub fn used(&self) -> u64 { + self.used + } + + pub fn free(&self) -> u64 { + self.capacity.saturating_sub(self.used) + } + + fn node(&self, id: NodeId) -> FsResult<&Node> { + self.nodes.get(&id).ok_or(FsError::InvalidParameter) + } + + fn node_mut(&mut self, id: NodeId) -> FsResult<&mut Node> { + self.nodes.get_mut(&id).ok_or(FsError::InvalidParameter) + } + + fn file_mut(&mut self, id: NodeId) -> FsResult<&mut Node> { + let node = self.node_mut(id)?; + if node.is_dir() { + return Err(FsError::IsADirectory); + } + Ok(node) + } + + pub fn lookup(&self, path: &[u16]) -> FsResult { + let mut id = ROOT; + let mut parts = components(path).peekable(); + while let Some(part) = parts.next() { + let children = self + .node(id)? + .children + .as_ref() + .ok_or(FsError::PathNotFound)?; + match children.get(&fold(part)) { + Some(&child) => id = child, + None if parts.peek().is_none() => return Err(FsError::NotFound), + None => return Err(FsError::PathNotFound), + } + } + Ok(id) + } + + fn lookup_dir(&self, path: &[u16]) -> FsResult { + let id = self.lookup(path).map_err(|e| match e { + FsError::NotFound => FsError::PathNotFound, + other => other, + })?; + if !self.node(id)?.is_dir() { + return Err(FsError::PathNotFound); + } + Ok(id) + } + + pub fn info(&self, id: NodeId) -> FsResult { + let node = self.node(id)?; + Ok(Info { + attributes: if node.attributes == 0 { + ATTR_NORMAL + } else { + node.attributes + }, + allocation_size: node.data.allocation, + file_size: node.data.len(), + creation_time: node.creation_time, + last_access_time: node.last_access_time, + last_write_time: node.last_write_time, + change_time: node.change_time, + index_number: id, + }) + } + + pub fn is_dir(&self, id: NodeId) -> FsResult { + Ok(self.node(id)?.is_dir()) + } + + pub fn security(&self, id: NodeId) -> FsResult<&[u8]> { + Ok(&self.node(id)?.security) + } + + pub fn set_security(&mut self, id: NodeId, security: Vec) -> FsResult<()> { + let node = self.node_mut(id)?; + node.security = security; + node.change_time = now(); + Ok(()) + } + + /// The path from the root, with each name in its stored case. + pub fn full_path(&self, id: NodeId) -> FsResult> { + let mut names = Vec::new(); + let mut current = self.node(id)?; + while let Some(parent) = current.parent { + names.push(current.name.as_slice()); + current = self.node(parent)?; + } + if names.is_empty() { + return Ok(vec![SEPARATOR]); + } + let mut path = Vec::new(); + for name in names.into_iter().rev() { + path.push(SEPARATOR); + path.extend_from_slice(name); + } + Ok(path) + } + + /// Change the space charged to a file from `from` to `to` bytes. + fn charge(&mut self, from: u64, to: u64) -> FsResult<()> { + let used = self.used - from; + if to > from && used + to > self.capacity { + return Err(FsError::DiskFull); + } + self.used = used + to; + Ok(()) + } + + /// Create a file or directory and open it once. + pub fn create( + &mut self, + path: &[u16], + directory: bool, + attributes: u32, + security: Vec, + allocation: u64, + ) -> FsResult { + let (parent_path, name) = split_parent(path)?; + check_name(name)?; + let parent = self.lookup_dir(parent_path)?; + let key = fold(name); + if self + .node(parent)? + .children + .as_ref() + .is_some_and(|children| children.contains_key(&key)) + { + return Err(FsError::NameCollision); + } + let room = if directory { 0 } else { round_up(allocation) }; + self.charge(0, room)?; + + let id = self.next_id; + self.next_id += 1; + let time = now(); + let attributes = if directory { + normalized_attributes(attributes, true) + } else { + normalized_attributes(attributes, false) | ATTR_ARCHIVE + }; + self.nodes.insert( + id, + Node { + name: name.to_vec(), + parent: Some(parent), + children: directory.then(BTreeMap::new), + data: Data::with_room(room), + attributes, + creation_time: time, + last_access_time: time, + last_write_time: time, + change_time: time, + security, + opens: 1, + }, + ); + if let Some(children) = self.node_mut(parent)?.children.as_mut() { + children.insert(key, id); + } + Ok(id) + } + + pub fn open(&mut self, id: NodeId) -> FsResult<()> { + self.node_mut(id)?.opens += 1; + Ok(()) + } + + /// Drop one open. An unlinked node goes when its last open does. + pub fn close(&mut self, id: NodeId) { + let Ok(node) = self.node_mut(id) else { + return; + }; + node.opens = node.opens.saturating_sub(1); + if node.opens == 0 && node.parent.is_none() && id != ROOT { + self.release(id); + } + } + + fn release(&mut self, id: NodeId) { + if let Some(node) = self.nodes.remove(&id) { + self.used -= node.data.room; + } + } + + pub fn overwrite( + &mut self, + id: NodeId, + attributes: u32, + replace_attributes: bool, + allocation: u64, + ) -> FsResult<()> { + let allocation = round_up(allocation); + self.file_mut(id)?.data.truncate(0); + self.fit_room(id, allocation, false)?; + let node = self.file_mut(id)?; + node.data.allocation = allocation; + let attributes = normalized_attributes(attributes, false) | ATTR_ARCHIVE; + node.attributes = if replace_attributes { + attributes + } else { + node.attributes | attributes + }; + let time = now(); + node.last_access_time = time; + node.last_write_time = time; + node.change_time = time; + Ok(()) + } + + pub fn read(&self, id: NodeId, offset: u64, out: &mut [u8]) -> FsResult { + let node = self.node(id)?; + if node.is_dir() { + return Err(FsError::IsADirectory); + } + let bytes = &node.data.bytes; + if offset >= bytes.len() as u64 { + return Err(FsError::EndOfFile); + } + let start = offset as usize; + let count = out.len().min(bytes.len() - start); + out[..count].copy_from_slice(&bytes[start..start + count]); + Ok(count) + } + + /// Write `data` at `offset`, or at the end of the file. A constrained write + /// (paging I/O) never changes the file size. + pub fn write( + &mut self, + id: NodeId, + offset: u64, + data: &[u8], + to_end_of_file: bool, + constrained: bool, + ) -> FsResult { + let size = self.file_mut(id)?.data.len(); + let (offset, count) = if constrained { + if offset >= size { + return Ok(0); + } + (offset, (data.len() as u64).min(size - offset)) + } else { + let offset = if to_end_of_file { size } else { offset }; + let end = offset + .checked_add(data.len() as u64) + .ok_or(FsError::InvalidParameter)?; + if end > size { + self.set_file_size(id, end)?; + } + (offset, data.len() as u64) + }; + let node = self.file_mut(id)?; + let start = offset as usize; + let count = count as usize; + node.data.bytes[start..start + count].copy_from_slice(&data[..count]); + Ok(count) + } + + /// Hold `room` bytes for a file, or at least `room` with spare room for a + /// growing file. Without `grow`, room beyond `room` is given back. + fn fit_room(&mut self, id: NodeId, room: u64, grow: bool) -> FsResult<()> { + let current = self.file_mut(id)?.data.room; + let target = if room > current { + let spare = round_up(room.max(current + current.min(MAX_GROWTH_STEP))); + if grow && self.charge(current, spare).is_ok() { + spare + } else { + self.charge(current, room)?; + room + } + } else if room < current && !grow { + self.charge(current, room)?; + room + } else { + return Ok(()); + }; + self.file_mut(id)?.data.move_to(target); + Ok(()) + } + + /// Set the end of file. Growing past the allocation grows it to the next + /// allocation unit, with spare room, so small extensions do not copy the + /// file each time. + pub fn set_file_size(&mut self, id: NodeId, size: u64) -> FsResult<()> { + if size > self.file_mut(id)?.data.allocation { + let allocation = round_up(size); + self.fit_room(id, allocation, true)?; + self.file_mut(id)?.data.allocation = allocation; + } + let data = &mut self.file_mut(id)?.data; + if size < data.len() { + data.truncate(size); + } else { + data.extend(size); + } + Ok(()) + } + + /// Set the allocation size. Less than the file size cuts the file to fit. + /// Memory is given back when the allocation falls below half of it. + pub fn set_allocation_size(&mut self, id: NodeId, allocation: u64) -> FsResult<()> { + let allocation = round_up(allocation); + let data = &self.file_mut(id)?.data; + let room = data.room; + if allocation == data.allocation { + return Ok(()); + } + if allocation > room { + self.fit_room(id, allocation, true)?; + } + let data = &mut self.file_mut(id)?.data; + data.truncate(allocation); + data.allocation = allocation; + if allocation < room / 2 { + self.fit_room(id, allocation, false)?; + } + Ok(()) + } + + /// Fit the allocation and memory to the file size, as NTFS does when the + /// last handle of a changed file closes. + pub fn trim_allocation(&mut self, id: NodeId) -> FsResult<()> { + if self.node(id)?.is_dir() { + return Ok(()); + } + let allocation = round_up(self.file_mut(id)?.data.len()); + self.file_mut(id)?.data.allocation = allocation; + self.fit_room(id, allocation, false) + } + + pub fn set_basic(&mut self, id: NodeId, basic: BasicInfo) -> FsResult<()> { + let node = self.node_mut(id)?; + if let Some(attributes) = basic.attributes { + node.attributes = normalized_attributes(attributes, node.is_dir()); + } + if let Some(time) = basic.creation_time { + node.creation_time = time; + } + if let Some(time) = basic.last_access_time { + node.last_access_time = time; + } + if let Some(time) = basic.last_write_time { + node.last_write_time = time; + } + if let Some(time) = basic.change_time { + node.change_time = time; + } + Ok(()) + } + + /// The updates Windows asks for when the last handle of a changed file + /// closes. + pub fn touch( + &mut self, + id: NodeId, + access: bool, + write: bool, + change: bool, + archive: bool, + ) -> FsResult<()> { + let node = self.node_mut(id)?; + let time = now(); + if access { + node.last_access_time = time; + } + if write { + node.last_write_time = time; + } + if change { + node.change_time = time; + } + if archive && !node.is_dir() { + node.attributes |= ATTR_ARCHIVE; + } + Ok(()) + } + + pub fn can_delete(&self, id: NodeId) -> FsResult<()> { + let node = self.node(id)?; + if id == ROOT { + return Err(FsError::AccessDenied); + } + if node + .children + .as_ref() + .is_some_and(|children| !children.is_empty()) + { + return Err(FsError::DirectoryNotEmpty); + } + if node.attributes & ATTR_READONLY != 0 { + return Err(FsError::CannotDelete); + } + Ok(()) + } + + /// Take a node out of its directory. Open handles keep its contents until + /// they close. A directory that still has children stays. + pub fn unlink(&mut self, id: NodeId) -> bool { + let Ok(node) = self.node(id) else { + return false; + }; + let Some(parent) = node.parent else { + return false; + }; + if node + .children + .as_ref() + .is_some_and(|children| !children.is_empty()) + { + return false; + } + let key = fold(&node.name); + let opens = node.opens; + if let Ok(parent) = self.node_mut(parent) { + if let Some(children) = parent.children.as_mut() { + children.remove(&key); + } + } + if let Ok(node) = self.node_mut(id) { + node.parent = None; + } + if opens == 0 { + self.release(id); + } + true + } + + pub fn rename(&mut self, id: NodeId, new_path: &[u16], replace: bool) -> FsResult<()> { + let (parent_path, name) = split_parent(new_path)?; + check_name(name)?; + let new_parent = self.lookup_dir(parent_path)?; + let node = self.node(id)?; + let old_parent = node.parent.ok_or(FsError::NotFound)?; + let old_key = fold(&node.name); + if node.is_dir() { + let mut ancestor = Some(new_parent); + while let Some(current) = ancestor { + if current == id { + return Err(FsError::InvalidParameter); + } + ancestor = self.node(current)?.parent; + } + } + + let key = fold(name); + let existing = self + .node(new_parent)? + .children + .as_ref() + .and_then(|children| children.get(&key).copied()); + if let Some(existing) = existing.filter(|&existing| existing != id) { + if !replace { + return Err(FsError::NameCollision); + } + let target = self.node(existing)?; + if target.is_dir() || target.attributes & ATTR_READONLY != 0 { + return Err(FsError::AccessDenied); + } + self.unlink(existing); + } + + if let Some(children) = self.node_mut(old_parent)?.children.as_mut() { + children.remove(&old_key); + } + if let Some(children) = self.node_mut(new_parent)?.children.as_mut() { + children.insert(key, id); + } + let node = self.node_mut(id)?; + node.parent = Some(new_parent); + node.name = name.to_vec(); + node.change_time = now(); + Ok(()) + } + + /// Visit a directory's entries in case-insensitive order: `.` and `..` + /// first (not in the root), then the children. With a marker, start after + /// the entry of that name. `visit` returns false to stop. + pub fn for_each_entry( + &self, + dir: NodeId, + marker: Option<&[u16]>, + mut visit: impl FnMut(&[u16], Info) -> bool, + ) -> FsResult<()> { + let node = self.node(dir)?; + let children = node.children.as_ref().ok_or(FsError::NotADirectory)?; + let (dot, dot_dot, after) = match marker { + None => (true, true, None), + Some(m) if m == DOT => (false, true, None), + Some(m) if m == DOT_DOT => (false, false, None), + Some(m) => (false, false, Some(fold(m))), + }; + if dir != ROOT { + if dot && !visit(DOT, self.info(dir)?) { + return Ok(()); + } + if dot_dot && !visit(DOT_DOT, self.info(node.parent.unwrap_or(dir))?) { + return Ok(()); + } + } + let start = match &after { + Some(key) => Bound::Excluded(key.as_slice()), + None => Bound::Unbounded, + }; + for (_, &child) in children.range::<[u16], _>((start, Bound::Unbounded)) { + if !visit(&self.node(child)?.name, self.info(child)?) { + break; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn w(path: &str) -> Vec { + path.encode_utf16().collect() + } + + fn tree() -> Tree { + Tree::new(1 << 20, b"root-sd".to_vec()) + } + + fn file(tree: &mut Tree, path: &str) -> NodeId { + tree.create(&w(path), false, 0, b"sd".to_vec(), 0).unwrap() + } + + fn dir(tree: &mut Tree, path: &str) -> NodeId { + tree.create(&w(path), true, 0, b"sd".to_vec(), 0).unwrap() + } + + fn names(tree: &Tree, id: NodeId, marker: Option<&str>) -> Vec { + let marker = marker.map(w); + let mut out = Vec::new(); + tree + .for_each_entry(id, marker.as_deref(), |name, _| { + out.push(String::from_utf16_lossy(name)); + true + }) + .unwrap(); + out + } + + fn read_all(tree: &Tree, id: NodeId) -> Vec { + let size = tree.info(id).unwrap().file_size as usize; + let mut out = vec![0; size]; + if size > 0 { + assert_eq!(tree.read(id, 0, &mut out).unwrap(), size); + } + out + } + + #[test] + fn names_match_without_case_and_keep_their_case() { + let mut tree = tree(); + let dir_id = dir(&mut tree, "\\Default"); + let id = file(&mut tree, "\\Default\\Cookies-Journal"); + assert_eq!(tree.lookup(&w("\\DEFAULT\\cookies-journal")), Ok(id)); + assert_eq!(tree.lookup(&w("\\")), Ok(ROOT)); + assert_eq!( + String::from_utf16_lossy(&tree.full_path(id).unwrap()), + "\\Default\\Cookies-Journal" + ); + assert_eq!(tree.full_path(ROOT).unwrap(), w("\\")); + assert_eq!( + tree.create(&w("\\default\\COOKIES-journal"), false, 0, Vec::new(), 0), + Err(FsError::NameCollision) + ); + assert_eq!( + tree.lookup(&w("\\Default\\Missing")), + Err(FsError::NotFound) + ); + assert_eq!(tree.lookup(&w("\\Missing\\x")), Err(FsError::PathNotFound)); + assert_eq!( + tree.lookup(&w("\\Default\\Cookies-Journal\\x")), + Err(FsError::PathNotFound) + ); + assert_eq!(fold(&w("straße ÄÖ")), w("STRAßE ÄÖ")); + assert!(tree.is_dir(dir_id).unwrap()); + } + + #[test] + fn create_refuses_bad_names_and_missing_parents() { + let mut tree = tree(); + file(&mut tree, "\\file"); + for (path, error) in [ + ("\\missing\\x", FsError::PathNotFound), + ("\\file\\x", FsError::PathNotFound), + ("\\", FsError::NameInvalid), + ("\\..", FsError::NameInvalid), + ] { + assert_eq!( + tree.create(&w(path), false, 0, Vec::new(), 0), + Err(error), + "{path}" + ); + } + let long = format!("\\{}", "a".repeat(MAX_NAME_LEN + 1)); + assert_eq!( + tree.create(&w(&long), false, 0, Vec::new(), 0), + Err(FsError::NameInvalid) + ); + } + + #[test] + fn writes_extend_reads_stop_at_the_end_and_paging_writes_never_extend() { + let mut tree = tree(); + let id = file(&mut tree, "\\data"); + assert_eq!(tree.write(id, 0, b"hello", false, false), Ok(5)); + assert_eq!(tree.write(id, 8, b"world", false, false), Ok(5)); + assert_eq!(read_all(&tree, id), b"hello\0\0\0world"); + assert_eq!(tree.write(id, 0, b"!", true, false), Ok(1)); + assert_eq!(read_all(&tree, id), b"hello\0\0\0world!"); + + let mut out = [0u8; 4]; + assert_eq!(tree.read(id, 12, &mut out), Ok(2)); + assert_eq!(&out[..2], b"d!"); + assert_eq!(tree.read(id, 14, &mut out), Err(FsError::EndOfFile)); + + assert_eq!(tree.write(id, 12, b"XYZ", false, true), Ok(2)); + assert_eq!(tree.write(id, 20, b"XYZ", false, true), Ok(0)); + assert_eq!(read_all(&tree, id), b"hello\0\0\0worlXY"); + } + + #[test] + fn space_is_charged_by_allocation_and_given_back() { + let mut tree = Tree::new(4096, Vec::new()); + let id = file(&mut tree, "\\a"); + tree.write(id, 0, &[7; 1000], false, false).unwrap(); + assert_eq!(tree.info(id).unwrap().allocation_size, 1024); + tree.write(id, 1000, &[7; 100], false, false).unwrap(); + let info = tree.info(id).unwrap(); + assert_eq!(info.file_size, 1100); + assert_eq!(info.allocation_size, 1536); + // The memory held has spare room for more appends, and is what counts. + assert_eq!(tree.used(), 2048); + assert_eq!(tree.free(), 4096 - 2048); + + tree.trim_allocation(id).unwrap(); + assert_eq!(tree.info(id).unwrap().allocation_size, 1536); + assert_eq!(tree.used(), 1536); + assert_eq!(read_all(&tree, id), vec![7; 1100]); + + tree.set_file_size(id, 100).unwrap(); + assert_eq!(read_all(&tree, id), vec![7; 100]); + tree.set_file_size(id, 200).unwrap(); + let mut expected = vec![7; 100]; + expected.resize(200, 0); + assert_eq!(read_all(&tree, id), expected); + + tree.set_allocation_size(id, 0).unwrap(); + assert_eq!(tree.info(id).unwrap().file_size, 0); + assert_eq!(tree.used(), 0); + } + + #[test] + fn small_extensions_get_spare_room_until_the_last_close() { + let mut tree = tree(); + let id = file(&mut tree, "\\LOG"); + for size in (512..=66048).step_by(512) { + tree.set_file_size(id, size).unwrap(); + } + assert_eq!(tree.info(id).unwrap().allocation_size, 66048); + // The room doubled as the file grew, so the data moved a few times, not + // once per extension. + assert_eq!(tree.used(), 131072); + tree.trim_allocation(id).unwrap(); + assert_eq!(tree.used(), 66048); + } + + #[test] + fn a_full_volume_refuses_growth_but_uses_the_last_bytes() { + let mut tree = Tree::new(2048, Vec::new()); + let id = file(&mut tree, "\\a"); + tree.write(id, 0, &[1; 1500], false, false).unwrap(); + // Spare room would pass the cap, so the file gets exactly what it needs. + tree.write(id, 1500, &[2; 548], false, false).unwrap(); + assert_eq!(tree.used(), 2048); + assert_eq!(tree.free(), 0); + assert_eq!( + tree.write(id, 2048, &[3; 1], false, false), + Err(FsError::DiskFull) + ); + assert_eq!( + tree.create(&w("\\b"), false, 0, Vec::new(), 512), + Err(FsError::DiskFull) + ); + assert_eq!(tree.info(id).unwrap().file_size, 2048); + } + + #[test] + fn overwrite_empties_the_file_and_resets_its_attributes() { + let mut tree = tree(); + let id = file(&mut tree, "\\a"); + tree.write(id, 0, &[1; 3000], false, false).unwrap(); + tree + .set_basic( + id, + BasicInfo { + attributes: Some(ATTR_READONLY), + ..Default::default() + }, + ) + .unwrap(); + tree.overwrite(id, 0x2, false, 0).unwrap(); + let info = tree.info(id).unwrap(); + assert_eq!(info.file_size, 0); + assert_eq!(info.allocation_size, 0); + assert_eq!(info.attributes, ATTR_READONLY | 0x2 | ATTR_ARCHIVE); + assert_eq!(tree.used(), 0); + + tree.overwrite(id, 0, true, 1000).unwrap(); + let info = tree.info(id).unwrap(); + assert_eq!(info.attributes, ATTR_ARCHIVE); + assert_eq!(info.allocation_size, 1024); + } + + #[test] + fn attributes_follow_the_node_kind() { + let mut tree = tree(); + let dir_id = dir(&mut tree, "\\d"); + let id = file(&mut tree, "\\f"); + assert_eq!(tree.info(dir_id).unwrap().attributes, ATTR_DIRECTORY); + assert_eq!(tree.info(id).unwrap().attributes, ATTR_ARCHIVE); + for target in [dir_id, id] { + tree + .set_basic( + target, + BasicInfo { + attributes: Some(ATTR_NORMAL), + last_write_time: Some(42), + ..Default::default() + }, + ) + .unwrap(); + } + assert_eq!(tree.info(dir_id).unwrap().attributes, ATTR_DIRECTORY); + assert_eq!(tree.info(id).unwrap().attributes, ATTR_NORMAL); + assert_eq!(tree.info(id).unwrap().last_write_time, 42); + tree.touch(id, false, true, false, true).unwrap(); + assert_eq!(tree.info(id).unwrap().attributes, ATTR_ARCHIVE); + assert!(tree.info(id).unwrap().last_write_time > 42); + } + + #[test] + fn an_unlinked_file_stays_readable_until_its_last_handle_closes() { + let mut tree = tree(); + let id = file(&mut tree, "\\LOCK"); + tree.write(id, 0, b"locked", false, false).unwrap(); + tree.open(id).unwrap(); + assert!(tree.unlink(id)); + assert_eq!(tree.lookup(&w("\\LOCK")), Err(FsError::NotFound)); + assert_eq!(read_all(&tree, id), b"locked"); + + let again = file(&mut tree, "\\lock"); + assert_ne!(again, id); + tree.close(id); + assert_eq!(read_all(&tree, id), b"locked"); + tree.close(id); + assert_eq!(tree.info(id), Err(FsError::InvalidParameter)); + assert_eq!(tree.used(), tree.info(again).unwrap().allocation_size); + } + + #[test] + fn deletes_check_children_and_the_read_only_flag() { + let mut tree = tree(); + let parent = dir(&mut tree, "\\d"); + let child = file(&mut tree, "\\d\\f"); + assert_eq!(tree.can_delete(parent), Err(FsError::DirectoryNotEmpty)); + assert!(!tree.unlink(parent)); + assert_eq!(tree.can_delete(ROOT), Err(FsError::AccessDenied)); + tree + .set_basic( + child, + BasicInfo { + attributes: Some(ATTR_READONLY), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(tree.can_delete(child), Err(FsError::CannotDelete)); + + tree.close(child); + assert!(tree.unlink(child)); + assert_eq!(tree.info(child), Err(FsError::InvalidParameter)); + assert_eq!(tree.can_delete(parent), Ok(())); + assert!(tree.unlink(parent)); + assert_eq!(names(&tree, ROOT, None), Vec::::new()); + } + + #[test] + fn rename_moves_replaces_and_changes_case() { + let mut tree = tree(); + dir(&mut tree, "\\a"); + dir(&mut tree, "\\b"); + let temp = file(&mut tree, "\\a\\Preferences.tmp"); + tree.write(temp, 0, b"new", false, false).unwrap(); + let old = file(&mut tree, "\\b\\Preferences"); + tree.write(old, 0, b"old", false, false).unwrap(); + + assert_eq!( + tree.rename(temp, &w("\\b\\preferences"), false), + Err(FsError::NameCollision) + ); + tree.rename(temp, &w("\\b\\Preferences"), true).unwrap(); + assert_eq!(tree.lookup(&w("\\b\\PREFERENCES")), Ok(temp)); + assert_eq!( + tree.lookup(&w("\\a\\Preferences.tmp")), + Err(FsError::NotFound) + ); + // The replaced file is still open, so it still reads. + assert_eq!(read_all(&tree, old), b"old"); + tree.close(old); + assert_eq!(tree.info(old), Err(FsError::InvalidParameter)); + + tree.rename(temp, &w("\\b\\PREFERENCES"), false).unwrap(); + assert_eq!( + names(&tree, tree.lookup(&w("\\b")).unwrap(), None)[2], + "PREFERENCES" + ); + + assert_eq!( + tree.rename(temp, &w("\\missing\\x"), false), + Err(FsError::PathNotFound) + ); + let used = tree.used(); + tree.close(temp); + assert_eq!(tree.used(), used); + } + + #[test] + fn a_directory_moves_with_its_open_children() { + let mut tree = tree(); + let parent = dir(&mut tree, "\\Default"); + let child = file(&mut tree, "\\Default\\History"); + tree.write(child, 0, b"visits", false, false).unwrap(); + tree.rename(parent, &w("\\Profile 1"), false).unwrap(); + assert_eq!(read_all(&tree, child), b"visits"); + assert_eq!(tree.lookup(&w("\\profile 1\\HISTORY")), Ok(child)); + assert_eq!( + tree.lookup(&w("\\Default\\History")), + Err(FsError::PathNotFound) + ); + assert_eq!( + String::from_utf16_lossy(&tree.full_path(child).unwrap()), + "\\Profile 1\\History" + ); + } + + #[test] + fn rename_refuses_to_replace_a_directory_or_move_into_itself() { + let mut tree = tree(); + let outer = dir(&mut tree, "\\outer"); + dir(&mut tree, "\\outer\\inner"); + let other = file(&mut tree, "\\other"); + assert_eq!( + tree.rename(other, &w("\\outer"), true), + Err(FsError::AccessDenied) + ); + assert_eq!( + tree.rename(outer, &w("\\outer\\inner\\x"), false), + Err(FsError::InvalidParameter) + ); + tree.rename(outer, &w("\\moved"), false).unwrap(); + assert!(tree.lookup(&w("\\moved\\inner")).is_ok()); + assert_eq!( + String::from_utf16_lossy( + &tree + .full_path(tree.lookup(&w("\\moved\\INNER")).unwrap()) + .unwrap() + ), + "\\moved\\inner" + ); + } + + #[test] + fn listing_is_sorted_without_case_and_resumes_after_a_marker() { + let mut tree = tree(); + let parent = dir(&mut tree, "\\d"); + for name in ["b", "C", "a", "D"] { + file(&mut tree, &format!("\\d\\{name}")); + } + assert_eq!(names(&tree, parent, None), [".", "..", "a", "b", "C", "D"]); + assert_eq!(names(&tree, parent, Some(".")), ["..", "a", "b", "C", "D"]); + assert_eq!(names(&tree, parent, Some("..")), ["a", "b", "C", "D"]); + assert_eq!(names(&tree, parent, Some("B")), ["C", "D"]); + // A marker whose entry is gone still resumes in order. + assert_eq!(names(&tree, parent, Some("bb")), ["C", "D"]); + assert_eq!(names(&tree, ROOT, None), ["d"]); + + let mut seen = 0; + tree + .for_each_entry(parent, None, |_, _| { + seen += 1; + seen < 3 + }) + .unwrap(); + assert_eq!(seen, 3); + let file_id = tree.lookup(&w("\\d\\a")).unwrap(); + assert_eq!( + tree.for_each_entry(file_id, None, |_, _| true), + Err(FsError::NotADirectory) + ); + } + + #[test] + fn security_descriptors_are_kept_per_node() { + let mut tree = tree(); + let id = file(&mut tree, "\\Network"); + assert_eq!(tree.security(ROOT).unwrap(), b"root-sd"); + assert_eq!(tree.security(id).unwrap(), b"sd"); + tree.set_security(id, b"with-acl".to_vec()).unwrap(); + assert_eq!(tree.security(id).unwrap(), b"with-acl"); + } + + #[test] + fn truncation_zeroes_the_cut_bytes() { + let mut data = Data::with_room(1024); + data.extend(1024); + data.bytes.fill(0xAA); + data.truncate(10); + // Every byte of the buffer was written above, so all of it may be read. + let whole = unsafe { std::slice::from_raw_parts(data.bytes.as_ptr(), 1024) }; + assert!(whole[..10].iter().all(|&b| b == 0xAA)); + assert!(whole[10..].iter().all(|&b| b == 0)); + } +} diff --git a/src-tauri/src/ramfs/winfsp_fs.rs b/src-tauri/src/ramfs/winfsp_fs.rs new file mode 100644 index 00000000..58b1b1de --- /dev/null +++ b/src-tauri/src/ramfs/winfsp_fs.rs @@ -0,0 +1,511 @@ +//! The WinFsp side of the RAM volume: each request maps onto `Tree`. +//! +//! WinFsp's kernel driver handles byte-range locks, memory-mapped files, the +//! cache and change notifications; this layer keeps names, data and security +//! descriptors. + +use std::ffi::c_void; +use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError}; + +use windows::Win32::Foundation::{ + STATUS_ACCESS_DENIED, STATUS_BUFFER_OVERFLOW, STATUS_CANNOT_DELETE, STATUS_DIRECTORY_NOT_EMPTY, + STATUS_DISK_FULL, STATUS_END_OF_FILE, STATUS_FILE_IS_A_DIRECTORY, STATUS_INSUFFICIENT_RESOURCES, + STATUS_INVALID_DEVICE_REQUEST, STATUS_INVALID_PARAMETER, STATUS_NOT_A_DIRECTORY, + STATUS_NOT_A_REPARSE_POINT, STATUS_OBJECT_NAME_COLLISION, STATUS_OBJECT_NAME_INVALID, + STATUS_OBJECT_NAME_NOT_FOUND, STATUS_OBJECT_PATH_NOT_FOUND, +}; +use windows::Win32::Security::{GetSecurityDescriptorLength, PSECURITY_DESCRIPTOR}; +use winfsp::constants::FspCleanupFlags; +use winfsp::filesystem::{ + DirInfo, DirMarker, FileInfo, FileSecurity, FileSystemContext, ModificationDescriptor, + OpenFileInfo, VolumeInfo, WideNameInfo, +}; +use winfsp::{FspError, U16CStr}; +use winfsp_sys::{FILE_ACCESS_RIGHTS, FILE_FLAGS_AND_ATTRIBUTES}; + +use super::tree::{BasicInfo, FsError, Info, NodeId, Tree, ROOT}; + +const FILE_DIRECTORY_FILE: u32 = 0x1; +const FILE_NON_DIRECTORY_FILE: u32 = 0x40; +const INVALID_FILE_ATTRIBUTES: u32 = u32::MAX; +const VOLUME_LABEL: &str = "DonutEphemeral"; + +/// WinFsp reads a security descriptor by name into a 1024-byte buffer, and this +/// binding cannot report that more room is needed. Larger descriptors are +/// refused when they are set, so no open ever fails on one. +pub const MAX_SECURITY_LEN: usize = 1024; + +impl From for FspError { + fn from(error: FsError) -> Self { + let status = match error { + FsError::NotFound => STATUS_OBJECT_NAME_NOT_FOUND, + FsError::PathNotFound => STATUS_OBJECT_PATH_NOT_FOUND, + FsError::NameCollision => STATUS_OBJECT_NAME_COLLISION, + FsError::NameInvalid => STATUS_OBJECT_NAME_INVALID, + FsError::NotADirectory => STATUS_NOT_A_DIRECTORY, + FsError::IsADirectory => STATUS_FILE_IS_A_DIRECTORY, + FsError::DirectoryNotEmpty => STATUS_DIRECTORY_NOT_EMPTY, + FsError::AccessDenied => STATUS_ACCESS_DENIED, + FsError::CannotDelete => STATUS_CANNOT_DELETE, + FsError::DiskFull => STATUS_DISK_FULL, + FsError::EndOfFile => STATUS_END_OF_FILE, + FsError::InvalidParameter => STATUS_INVALID_PARAMETER, + }; + FspError::NTSTATUS(status.0) + } +} + +fn status(status: windows::Win32::Foundation::NTSTATUS) -> FspError { + FspError::NTSTATUS(status.0) +} + +fn fill(out: &mut FileInfo, info: Info) { + out.file_attributes = info.attributes; + out.reparse_tag = 0; + out.allocation_size = info.allocation_size; + out.file_size = info.file_size; + out.creation_time = info.creation_time; + out.last_access_time = info.last_access_time; + out.last_write_time = info.last_write_time; + out.change_time = info.change_time; + out.index_number = info.index_number; + out.hard_links = 0; + out.ea_size = 0; +} + +/// One open of a file or directory. +pub struct Handle { + id: NodeId, +} + +pub struct RamFs { + tree: Mutex, +} + +impl RamFs { + pub fn new(capacity: u64, root_security: Vec) -> Self { + RamFs { + tree: Mutex::new(Tree::new(capacity, root_security)), + } + } + + /// A callback that panicked poisons the lock; the tree stays consistent + /// because every change finishes before the next can fail. + fn tree(&self) -> MutexGuard<'_, Tree> { + self.tree.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn opened( + &self, + tree: &Tree, + id: NodeId, + file_info: &mut OpenFileInfo, + ) -> winfsp::Result { + fill(file_info.as_mut(), tree.info(id)?); + // The stored case of every name, so Windows reports what was created. + let path = tree.full_path(id)?; + if path.len() * 2 < file_info.normalized_name_size() as usize { + file_info.set_normalized_name(&path, None); + } + Ok(Handle { id }) + } +} + +/// The address of `FspSetSecurityDescriptor` inside the WinFsp DLL. WinFsp +/// frees a descriptor by comparing the function that made it to its own +/// exports, and the delay-load stub this binary links has another address. +fn set_security_descriptor_export() -> Option i32> { + use windows::core::{PCSTR, PCWSTR}; + use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress}; + static EXPORT: OnceLock i32>> = OnceLock::new(); + *EXPORT.get_or_init(|| { + let name: Vec = super::host::DLL_NAME + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let module = unsafe { GetModuleHandleW(PCWSTR(name.as_ptr())) }.ok()?; + let export = + unsafe { GetProcAddress(module, PCSTR(c"FspSetSecurityDescriptor".as_ptr().cast())) }?; + // SAFETY: WinFsp's FspDeleteSecurityDescriptor takes this function as an + // untyped `NTSTATUS (*)()` and only compares it. + Some(unsafe { + std::mem::transmute:: isize, unsafe extern "C" fn() -> i32>( + export, + ) + }) + }) +} + +/// Apply a SetSecurity request to a stored descriptor. +fn modified_security( + current: &[u8], + security_information: u32, + modification: &ModificationDescriptor, +) -> winfsp::Result> { + let export = set_security_descriptor_export().ok_or(status(STATUS_INVALID_DEVICE_REQUEST))?; + // The input parameter is not const, so it gets a private copy. + let mut input = current.to_vec(); + let mut output: winfsp_sys::PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + let result = unsafe { + winfsp_sys::FspSetSecurityDescriptor( + input.as_mut_ptr().cast(), + security_information, + modification.as_mut_ptr(), + &mut output, + ) + }; + if result < 0 { + return Err(FspError::NTSTATUS(result)); + } + let len = unsafe { GetSecurityDescriptorLength(PSECURITY_DESCRIPTOR(output)) } as usize; + let updated = unsafe { std::slice::from_raw_parts(output.cast::(), len) }.to_vec(); + unsafe { winfsp_sys::FspDeleteSecurityDescriptor(output, Some(export)) }; + Ok(updated) +} + +impl FileSystemContext for RamFs { + type FileContext = Handle; + + fn get_security_by_name( + &self, + file_name: &U16CStr, + security_descriptor: Option<&mut [c_void]>, + _reparse_point_resolver: impl FnOnce(&U16CStr) -> Option, + ) -> winfsp::Result { + let tree = self.tree(); + let id = tree.lookup(file_name.as_slice())?; + let attributes = tree.info(id)?.attributes; + let security = tree.security(id)?; + if let Some(buffer) = security_descriptor { + if buffer.len() < security.len() { + // Never STATUS_BUFFER_OVERFLOW: WinFsp would retry with the same size. + return Err(status(STATUS_INSUFFICIENT_RESOURCES)); + } + unsafe { + std::ptr::copy_nonoverlapping( + security.as_ptr(), + buffer.as_mut_ptr().cast::(), + security.len(), + ) + }; + } + Ok(FileSecurity { + reparse: false, + sz_security_descriptor: security.len() as u64, + attributes, + }) + } + + fn open( + &self, + file_name: &U16CStr, + create_options: u32, + _granted_access: FILE_ACCESS_RIGHTS, + file_info: &mut OpenFileInfo, + ) -> winfsp::Result { + let mut tree = self.tree(); + let id = tree.lookup(file_name.as_slice())?; + let is_dir = tree.is_dir(id)?; + if create_options & FILE_DIRECTORY_FILE != 0 && !is_dir { + return Err(FsError::NotADirectory.into()); + } + if create_options & FILE_NON_DIRECTORY_FILE != 0 && is_dir { + return Err(FsError::IsADirectory.into()); + } + tree.open(id)?; + self.opened(&tree, id, file_info) + } + + fn close(&self, context: Handle) { + self.tree().close(context.id); + } + + fn create( + &self, + file_name: &U16CStr, + create_options: u32, + _granted_access: FILE_ACCESS_RIGHTS, + file_attributes: FILE_FLAGS_AND_ATTRIBUTES, + security_descriptor: Option<&[c_void]>, + allocation_size: u64, + extra_buffer: Option<&[u8]>, + extra_buffer_is_reparse_point: bool, + file_info: &mut OpenFileInfo, + ) -> winfsp::Result { + if extra_buffer_is_reparse_point && extra_buffer.is_some_and(|buffer| !buffer.is_empty()) { + return Err(status(STATUS_ACCESS_DENIED)); + } + let mut tree = self.tree(); + let security = match security_descriptor { + Some(descriptor) => { + unsafe { std::slice::from_raw_parts(descriptor.as_ptr().cast::(), descriptor.len()) } + .to_vec() + } + None => tree.security(ROOT)?.to_vec(), + }; + if security.len() > MAX_SECURITY_LEN { + return Err(status(STATUS_INSUFFICIENT_RESOURCES)); + } + let id = tree.create( + file_name.as_slice(), + create_options & FILE_DIRECTORY_FILE != 0, + file_attributes, + security, + allocation_size, + )?; + self.opened(&tree, id, file_info) + } + + fn cleanup(&self, context: &Handle, _file_name: Option<&U16CStr>, flags: u32) { + let mut tree = self.tree(); + let flagged = |flag: FspCleanupFlags| flag.is_flagged(flags); + if flagged(FspCleanupFlags::FspCleanupSetAllocationSize) { + let _ = tree.trim_allocation(context.id); + } + let _ = tree.touch( + context.id, + flagged(FspCleanupFlags::FspCleanupSetLastAccessTime), + flagged(FspCleanupFlags::FspCleanupSetLastWriteTime), + flagged(FspCleanupFlags::FspCleanupSetChangeTime), + flagged(FspCleanupFlags::FspCleanupSetArchiveBit), + ); + if flagged(FspCleanupFlags::FspCleanupDelete) { + tree.unlink(context.id); + } + } + + fn flush(&self, context: Option<&Handle>, file_info: &mut FileInfo) -> winfsp::Result<()> { + if let Some(context) = context { + fill(file_info, self.tree().info(context.id)?); + } + Ok(()) + } + + fn get_file_info(&self, context: &Handle, file_info: &mut FileInfo) -> winfsp::Result<()> { + fill(file_info, self.tree().info(context.id)?); + Ok(()) + } + + fn get_security( + &self, + context: &Handle, + security_descriptor: Option<&mut [c_void]>, + ) -> winfsp::Result { + let tree = self.tree(); + let security = tree.security(context.id)?; + if let Some(buffer) = security_descriptor { + if buffer.len() < security.len() { + return Err(status(STATUS_BUFFER_OVERFLOW)); + } + unsafe { + std::ptr::copy_nonoverlapping( + security.as_ptr(), + buffer.as_mut_ptr().cast::(), + security.len(), + ) + }; + } + Ok(security.len() as u64) + } + + fn set_security( + &self, + context: &Handle, + security_information: u32, + modification_descriptor: ModificationDescriptor, + ) -> winfsp::Result<()> { + let mut tree = self.tree(); + let updated = modified_security( + tree.security(context.id)?, + security_information, + &modification_descriptor, + )?; + if updated.len() > MAX_SECURITY_LEN { + return Err(status(STATUS_INSUFFICIENT_RESOURCES)); + } + tree.set_security(context.id, updated)?; + Ok(()) + } + + fn overwrite( + &self, + context: &Handle, + file_attributes: FILE_FLAGS_AND_ATTRIBUTES, + replace_file_attributes: bool, + allocation_size: u64, + _extra_buffer: Option<&[u8]>, + file_info: &mut FileInfo, + ) -> winfsp::Result<()> { + let mut tree = self.tree(); + tree.overwrite( + context.id, + file_attributes, + replace_file_attributes, + allocation_size, + )?; + fill(file_info, tree.info(context.id)?); + Ok(()) + } + + fn read_directory( + &self, + context: &Handle, + _pattern: Option<&U16CStr>, + marker: DirMarker, + buffer: &mut [u8], + ) -> winfsp::Result { + let tree = self.tree(); + let mut cursor = 0u32; + let mut entry: DirInfo = DirInfo::new(); + let mut full = false; + tree.for_each_entry(context.id, marker.inner(), |name, info| { + entry.reset(); + fill(entry.file_info_mut(), info); + if entry.set_name_raw(name).is_err() { + return true; + } + if !entry.append_to_buffer(buffer, &mut cursor) { + full = true; + return false; + } + true + })?; + if !full { + ::finalize_buffer(buffer, &mut cursor); + } + Ok(cursor) + } + + fn rename( + &self, + context: &Handle, + _file_name: &U16CStr, + new_file_name: &U16CStr, + replace_if_exists: bool, + ) -> winfsp::Result<()> { + self + .tree() + .rename(context.id, new_file_name.as_slice(), replace_if_exists)?; + Ok(()) + } + + fn set_basic_info( + &self, + context: &Handle, + file_attributes: u32, + creation_time: u64, + last_access_time: u64, + last_write_time: u64, + last_change_time: u64, + file_info: &mut FileInfo, + ) -> winfsp::Result<()> { + let set = |time: u64| (time != 0).then_some(time); + let mut tree = self.tree(); + tree.set_basic( + context.id, + BasicInfo { + attributes: (file_attributes != INVALID_FILE_ATTRIBUTES).then_some(file_attributes), + creation_time: set(creation_time), + last_access_time: set(last_access_time), + last_write_time: set(last_write_time), + change_time: set(last_change_time), + }, + )?; + fill(file_info, tree.info(context.id)?); + Ok(()) + } + + fn set_delete( + &self, + context: &Handle, + _file_name: &U16CStr, + delete_file: bool, + ) -> winfsp::Result<()> { + if delete_file { + self.tree().can_delete(context.id)?; + } + Ok(()) + } + + fn set_file_size( + &self, + context: &Handle, + new_size: u64, + set_allocation_size: bool, + file_info: &mut FileInfo, + ) -> winfsp::Result<()> { + let mut tree = self.tree(); + if set_allocation_size { + tree.set_allocation_size(context.id, new_size)?; + } else { + tree.set_file_size(context.id, new_size)?; + } + fill(file_info, tree.info(context.id)?); + Ok(()) + } + + fn read(&self, context: &Handle, buffer: &mut [u8], offset: u64) -> winfsp::Result { + Ok(self.tree().read(context.id, offset, buffer)? as u32) + } + + fn write( + &self, + context: &Handle, + buffer: &[u8], + offset: u64, + write_to_eof: bool, + constrained_io: bool, + file_info: &mut FileInfo, + ) -> winfsp::Result { + let mut tree = self.tree(); + let written = tree.write(context.id, offset, buffer, write_to_eof, constrained_io)?; + fill(file_info, tree.info(context.id)?); + Ok(written as u32) + } + + fn get_volume_info(&self, out_volume_info: &mut VolumeInfo) -> winfsp::Result<()> { + let tree = self.tree(); + out_volume_info.total_size = tree.capacity(); + out_volume_info.free_size = tree.free(); + out_volume_info.set_volume_label(VOLUME_LABEL); + Ok(()) + } + + // The volume holds no reparse points, but it answers for them as NTFS does, + // because callers treat "not a reparse point" and "not supported" apart. + fn get_reparse_point_by_name( + &self, + _file_name: &U16CStr, + _is_directory: bool, + _buffer: &mut [u8], + ) -> winfsp::Result { + Err(status(STATUS_NOT_A_REPARSE_POINT)) + } + + fn get_reparse_point( + &self, + _context: &Handle, + _file_name: &U16CStr, + _buffer: &mut [u8], + ) -> winfsp::Result { + Err(status(STATUS_NOT_A_REPARSE_POINT)) + } + + fn set_reparse_point( + &self, + _context: &Handle, + _file_name: &U16CStr, + _buffer: &[u8], + ) -> winfsp::Result<()> { + Err(status(STATUS_ACCESS_DENIED)) + } + + fn delete_reparse_point( + &self, + _context: &Handle, + _file_name: &U16CStr, + _buffer: &[u8], + ) -> winfsp::Result<()> { + Err(status(STATUS_NOT_A_REPARSE_POINT)) + } +} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index a9848812..c5bd8083 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -26,6 +26,7 @@ import { LuTriangleAlert, } from "react-icons/lu"; import { DnsServerField } from "@/components/dns-server-field"; +import { EphemeralStorageNotice } from "@/components/ephemeral-storage-notice"; import { LoadingButton } from "@/components/loading-button"; import { ProfileGlyph } from "@/components/profile-glyph"; import { ProxyFormDialog } from "@/components/proxy-form-dialog"; @@ -1088,6 +1089,8 @@ export function CreateProfileDialog({ /> + {(ephemeral || enablePassword) && } + {enablePassword && (
diff --git a/src/components/ephemeral-storage-notice.tsx b/src/components/ephemeral-storage-notice.tsx new file mode 100644 index 00000000..25a7f40e --- /dev/null +++ b/src/components/ephemeral-storage-notice.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuTriangleAlert } from "react-icons/lu"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface EphemeralStorageStatus { + in_memory: boolean; + reason: "winfsp_missing" | "winfsp_error" | null; +} + +const WINFSP_DOWNLOAD_URL = "https://winfsp.dev/rel/"; + +/** Shown when this computer cannot keep ephemeral profiles in memory. */ +export function EphemeralStorageNotice({ className }: { className?: string }) { + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + + useEffect(() => { + let active = true; + invoke("get_ephemeral_storage_status") + .then((next) => { + if (active) setStatus(next); + }) + .catch((error: unknown) => { + console.error("Failed to read ephemeral storage status", error); + }); + return () => { + active = false; + }; + }, []); + + if (!status || status.in_memory) return null; + + return ( +
+ +

+ {t( + status.reason === "winfsp_error" + ? "ephemeralStorage.winfspError" + : "ephemeralStorage.onDisk", + )}{" "} + +

+
+ ); +} diff --git a/src/generated/licenses.json b/src/generated/licenses.json index cb44c476..83caa329 100644 --- a/src/generated/licenses.json +++ b/src/generated/licenses.json @@ -2667,6 +2667,10 @@ "name": "stable_deref_trait", "license": "Apache-2.0 OR MIT" }, + { + "name": "static_assertions", + "license": "Apache-2.0 OR MIT" + }, { "name": "strict-num", "license": "MIT" @@ -3155,6 +3159,10 @@ "name": "weezl", "license": "Apache-2.0 OR MIT" }, + { + "name": "widestring", + "license": "Apache-2.0 OR MIT" + }, { "name": "winapi", "license": "Apache-2.0 OR MIT" @@ -3231,6 +3239,14 @@ "name": "windows_x86_64_msvc", "license": "Apache-2.0 OR MIT" }, + { + "name": "winfsp", + "license": "GPL-3.0" + }, + { + "name": "winfsp-sys", + "license": "GPL-3.0" + }, { "name": "winnow", "license": "MIT" diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 3c26d6e4..bdbd1b84 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -4721,5 +4721,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}} startet ohne die eigenen Fingerprint-Änderungen", "fingerprintEditsNeedPro": "Deine Änderungen bleiben gespeichert und gelten wieder mit einem aktiven Pro-Abo." + }, + "ephemeralStorage": { + "onDisk": "Auf diesem Computer schreiben flüchtige Profile ihre Daten auf den Datenträger. Donut löscht sie, wenn der Browser geschlossen wird. Installieren Sie WinFsp, um die Daten im Arbeitsspeicher zu halten.", + "winfspError": "WinFsp ist nicht gestartet, daher schreiben flüchtige Profile ihre Daten auf den Datenträger. Donut löscht sie, wenn der Browser geschlossen wird.", + "download": "WinFsp herunterladen" } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 061c85e6..335f6211 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -4721,5 +4721,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}} launches without its custom fingerprint changes", "fingerprintEditsNeedPro": "Your changes are kept and apply again with an active Pro plan." + }, + "ephemeralStorage": { + "onDisk": "On this computer, ephemeral profiles write their data to disk. Donut erases it when the browser closes. Install WinFsp to keep the data in memory.", + "winfspError": "WinFsp did not start, so ephemeral profiles write their data to disk. Donut erases it when the browser closes.", + "download": "Download WinFsp" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index bd5a8b56..004a264b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -4771,5 +4771,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}} se inicia sin sus cambios personalizados de huella digital", "fingerprintEditsNeedPro": "Tus cambios se conservan y vuelven a aplicarse con un plan Pro activo." + }, + "ephemeralStorage": { + "onDisk": "En este equipo, los perfiles efímeros escriben sus datos en el disco. Donut los borra cuando se cierra el navegador. Instala WinFsp para mantener los datos en memoria.", + "winfspError": "WinFsp no se inició, así que los perfiles efímeros escriben sus datos en el disco. Donut los borra cuando se cierra el navegador.", + "download": "Descargar WinFsp" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4c92a2ed..4d695e14 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -4771,5 +4771,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}} démarre sans ses modifications d'empreinte personnalisées", "fingerprintEditsNeedPro": "Vos modifications sont conservées et s'appliquent de nouveau avec un forfait Pro actif." + }, + "ephemeralStorage": { + "onDisk": "Sur cet ordinateur, les profils éphémères écrivent leurs données sur le disque. Donut les efface à la fermeture du navigateur. Installez WinFsp pour garder les données en mémoire.", + "winfspError": "WinFsp n'a pas démarré, donc les profils éphémères écrivent leurs données sur le disque. Donut les efface à la fermeture du navigateur.", + "download": "Télécharger WinFsp" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1909cd02..e74bfbb8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -4721,5 +4721,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}} はカスタムのフィンガープリント変更なしで起動します", "fingerprintEditsNeedPro": "変更は保存されており、有効な Pro プランで再び適用されます。" + }, + "ephemeralStorage": { + "onDisk": "このコンピューターでは、一時的なプロファイルはデータをディスクに書き込みます。ブラウザを閉じると Donut がそのデータを消去します。データをメモリに保持するには WinFsp をインストールしてください。", + "winfspError": "WinFsp が起動しなかったため、一時的なプロファイルはデータをディスクに書き込みます。ブラウザを閉じると Donut がそのデータを消去します。", + "download": "WinFsp をダウンロード" } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 7af6e77f..da527e5b 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -4721,5 +4721,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}}은(는) 사용자 지정 지문 변경 없이 실행됩니다", "fingerprintEditsNeedPro": "변경 사항은 보관되며 활성 Pro 플랜에서 다시 적용됩니다." + }, + "ephemeralStorage": { + "onDisk": "이 컴퓨터에서는 임시 프로필이 데이터를 디스크에 기록합니다. 브라우저를 닫으면 Donut이 데이터를 지웁니다. 데이터를 메모리에 유지하려면 WinFsp를 설치하세요.", + "winfspError": "WinFsp가 시작되지 않아 임시 프로필이 데이터를 디스크에 기록합니다. 브라우저를 닫으면 Donut이 데이터를 지웁니다.", + "download": "WinFsp 다운로드" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index bd25632b..d6724460 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -4771,5 +4771,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}} inicia sem as alterações personalizadas de impressão digital", "fingerprintEditsNeedPro": "Suas alterações ficam guardadas e voltam a valer com um plano Pro ativo." + }, + "ephemeralStorage": { + "onDisk": "Neste computador, os perfis efêmeros gravam seus dados no disco. O Donut os apaga quando o navegador fecha. Instale o WinFsp para manter os dados na memória.", + "winfspError": "O WinFsp não iniciou, então os perfis efêmeros gravam seus dados no disco. O Donut os apaga quando o navegador fecha.", + "download": "Baixar o WinFsp" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index cd186f60..e6f211e5 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -4821,5 +4821,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}} запускается без ваших изменений отпечатка", "fingerprintEditsNeedPro": "Изменения сохранены и снова применятся с активным тарифом Pro." + }, + "ephemeralStorage": { + "onDisk": "На этом компьютере временные профили записывают данные на диск. Donut стирает их, когда браузер закрывается. Установите WinFsp, чтобы хранить данные в памяти.", + "winfspError": "WinFsp не запустился, поэтому временные профили записывают данные на диск. Donut стирает их, когда браузер закрывается.", + "download": "Скачать WinFsp" } } diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 43205ba2..efae0663 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -4721,5 +4721,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}} özel parmak izi değişiklikleri olmadan başlatılıyor", "fingerprintEditsNeedPro": "Değişiklikleriniz saklanır ve etkin bir Pro planıyla yeniden uygulanır." + }, + "ephemeralStorage": { + "onDisk": "Bu bilgisayarda geçici profiller verilerini diske yazar. Donut, tarayıcı kapandığında bu verileri siler. Verileri bellekte tutmak için WinFsp'yi yükleyin.", + "winfspError": "WinFsp başlamadı, bu yüzden geçici profiller verilerini diske yazar. Donut, tarayıcı kapandığında bu verileri siler.", + "download": "WinFsp'yi indir" } } diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index a569799d..0ada982b 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -4721,5 +4721,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}} khởi chạy mà không có các thay đổi vân tay tùy chỉnh", "fingerprintEditsNeedPro": "Các thay đổi của bạn được giữ lại và sẽ áp dụng lại khi có gói Pro đang hoạt động." + }, + "ephemeralStorage": { + "onDisk": "Trên máy tính này, hồ sơ tạm thời ghi dữ liệu vào ổ đĩa. Donut xóa dữ liệu đó khi trình duyệt đóng. Hãy cài WinFsp để giữ dữ liệu trong bộ nhớ.", + "winfspError": "WinFsp không khởi động được, nên hồ sơ tạm thời ghi dữ liệu vào ổ đĩa. Donut xóa dữ liệu đó khi trình duyệt đóng.", + "download": "Tải WinFsp" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6f227b34..a1ebbd59 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -4721,5 +4721,10 @@ "launchNotice": { "fingerprintEditsNotApplied": "{{name}} 将在不含自定义指纹更改的情况下启动", "fingerprintEditsNeedPro": "你的更改已保留,在 Pro 套餐有效时会再次生效。" + }, + "ephemeralStorage": { + "onDisk": "在这台电脑上,临时配置文件会把数据写入磁盘。浏览器关闭时 Donut 会清除这些数据。安装 WinFsp 可将数据保留在内存中。", + "winfspError": "WinFsp 未能启动,因此临时配置文件会把数据写入磁盘。浏览器关闭时 Donut 会清除这些数据。", + "download": "下载 WinFsp" } }