From ef571acd36735ea88a152945dbfd3de4a97419c2 Mon Sep 17 00:00:00 2001 From: Shunichiro Nomura Date: Sun, 12 Jul 2026 20:57:38 +0900 Subject: [PATCH] Harden DropPoint receiver lifecycle and storage --- Cargo.lock | 490 ++++------ docs/guide/drop-point.md | 12 +- docs/guide/running-an-execution.md | 7 +- src-tauri/Cargo.toml | 11 +- src-tauri/src/commands/drop_point.rs | 798 +++++++++++++--- src-tauri/src/drop_point/client.rs | 767 +++++++++++++-- src-tauri/src/drop_point/crypto.rs | 229 ++++- src-tauri/src/drop_point/manifest.rs | 547 ++++++++--- src-tauri/src/drop_point/mod.rs | 4 +- src-tauri/src/drop_point/multipart.rs | 291 +++++- src-tauri/src/drop_point/secure_fs.rs | 247 +++++ src-tauri/src/drop_point/session.rs | 880 ++++++++++++++---- src-tauri/src/drop_point/storage.rs | 760 +++++++++++++++ src-tauri/src/lib.rs | 27 +- src-tauri/src/persistence/attachment_store.rs | 74 +- src-tauri/src/persistence/execution_store.rs | 185 +++- .../testdata/drop_point/filename-policy.json | 62 ++ .../drop_point/protocol-parsing-policy.json | 30 + src/lib/components/AttachmentField.svelte | 50 +- 19 files changed, 4519 insertions(+), 952 deletions(-) create mode 100644 src-tauri/src/drop_point/secure_fs.rs create mode 100644 src-tauri/src/drop_point/storage.rs create mode 100644 src-tauri/testdata/drop_point/filename-policy.json create mode 100644 src-tauri/testdata/drop_point/protocol-parsing-policy.json diff --git a/Cargo.lock b/Cargo.lock index 0068e02..b07051d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,17 +43,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -160,12 +149,6 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - [[package]] name = "async-broadcast" version = "0.7.2" @@ -259,7 +242,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -294,7 +277,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -397,18 +380,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -449,30 +420,6 @@ dependencies = [ "piper", ] -[[package]] -name = "borsh" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" -dependencies = [ - "once_cell", - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "brotli" version = "8.0.4" @@ -509,40 +456,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byte-unit" -version = "5.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0" -dependencies = [ - "rust_decimal", - "schemars 1.2.1", - "serde", - "utf8-width", -] - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "bytemuck" version = "1.25.1" @@ -759,7 +672,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -953,7 +866,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1013,7 +926,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1036,7 +949,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1047,7 +960,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1094,7 +1007,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1160,7 +1073,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1183,7 +1096,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1261,7 +1174,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "vswhom", "winreg", ] @@ -1305,7 +1218,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1452,7 +1365,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1486,12 +1399,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures-channel" version = "0.3.32" @@ -1545,7 +1452,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1808,7 +1715,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1887,7 +1794,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1895,9 +1802,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] [[package]] name = "hashbrown" @@ -1969,9 +1873,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1979,9 +1883,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -2350,7 +2254,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2378,7 +2282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2519,9 +2423,6 @@ name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -dependencies = [ - "value-bag", -] [[package]] name = "lru-slab" @@ -2584,9 +2485,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -2687,7 +2588,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3041,7 +2942,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3196,7 +3097,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -3269,8 +3170,10 @@ dependencies = [ "log", "mailparse", "procnote-core", + "proptest", "qrcode", "reqwest", + "rustix", "serde", "serde_json", "sha2 0.11.0", @@ -3283,30 +3186,32 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "ts-rs", + "unicode-general-category", + "unicode-normalization", "url", "uuid", + "windows-sys 0.61.2", "x25519-dalek", "zeroize", ] [[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" +name = "proptest" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", ] [[package]] @@ -3334,6 +3239,12 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-xml" version = "0.41.0" @@ -3427,12 +3338,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.7" @@ -3440,10 +3345,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -3465,6 +3380,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -3474,6 +3399,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -3489,6 +3423,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3532,7 +3475,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3564,15 +3507,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -3651,52 +3585,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "rust_decimal" -version = "1.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.7", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - [[package]] name = "rustc-hash" version = "2.1.3" @@ -3727,9 +3615,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "once_cell", @@ -3806,6 +3694,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -3878,7 +3778,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3887,12 +3787,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "security-framework" version = "3.7.0" @@ -3984,7 +3878,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3995,7 +3889,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4019,7 +3913,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4069,7 +3963,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4104,7 +3998,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4156,15 +4050,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -4196,9 +4090,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4312,15 +4206,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -4344,7 +4237,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4408,15 +4301,9 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "target-lexicon" version = "0.12.16" @@ -4513,7 +4400,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "syn 2.0.118", + "syn 2.0.119", "tauri-utils", "thiserror 2.0.18", "time", @@ -4531,7 +4418,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "tauri-codegen", "tauri-utils", ] @@ -4590,18 +4477,17 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-log" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" dependencies = [ "android_logger", - "byte-unit", "fern", "log", "objc2", @@ -4720,7 +4606,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4735,7 +4621,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", ] [[package]] @@ -4795,7 +4681,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4806,7 +4692,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4932,9 +4818,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -4942,7 +4828,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4998,14 +4884,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -5014,14 +4900,14 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -5087,7 +4973,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5147,7 +5033,7 @@ checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "termcolor", ] @@ -5174,6 +5060,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5221,12 +5113,27 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -5286,12 +5193,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf8-width" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5316,12 +5217,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "value-bag" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd4ec1eb1d240636e354a30110a1dfcb37047169a4d9bd6d9d3469df574b5c4" - [[package]] name = "version-compare" version = "0.2.1" @@ -5354,6 +5249,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -5430,7 +5334,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -5563,7 +5467,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5690,7 +5594,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5701,7 +5605,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6028,9 +5932,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -6101,15 +6005,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x11" version = "2.21.0" @@ -6162,7 +6057,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -6195,7 +6090,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.3", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -6210,7 +6105,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "zbus_names", "zvariant", "zvariant_utils", @@ -6223,7 +6118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" dependencies = [ "serde", - "winnow 1.0.3", + "winnow 1.0.4", "zvariant", ] @@ -6244,7 +6139,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6264,7 +6159,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -6273,6 +6168,9 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] [[package]] name = "zerotrie" @@ -6304,14 +6202,14 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" @@ -6322,7 +6220,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.3", + "winnow 1.0.4", "zvariant_derive", "zvariant_utils", ] @@ -6336,7 +6234,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "zvariant_utils", ] @@ -6349,6 +6247,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.118", - "winnow 1.0.3", + "syn 2.0.119", + "winnow 1.0.4", ] diff --git a/docs/guide/drop-point.md b/docs/guide/drop-point.md index 543363b..49ef933 100644 --- a/docs/guide/drop-point.md +++ b/docs/guide/drop-point.md @@ -35,7 +35,7 @@ Set these environment variables before starting Procnote: | Variable | Required | Description | | -------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `PROCNOTE_DROPPOINT_URL` | Yes | Base URL of your DropPoint instance, for example `https://drop.example.com`. Must not include query, fragment, or user info. | +| `PROCNOTE_DROPPOINT_URL` | Yes | Root origin of your DropPoint instance, for example `https://drop.example.com`. Must not include a path prefix, query, fragment, or user info. | | `PROCNOTE_DROPPOINT_API_TOKEN` | Yes | Plaintext DropPoint API token used by Procnote to create receiver-side drop points. | | `PROCNOTE_DROPPOINT_TTL_SECONDS` | No | Requested lifetime, in seconds, for each upload session. Must be a positive integer. | | `PROCNOTE_DROPPOINT_MAX_BYTES` | No | Requested maximum encrypted upload size, in bytes. Must be a positive integer and within the server's configured limit. | @@ -50,7 +50,7 @@ PROCNOTE_DROPPOINT_MAX_BYTES=52428800 \ procnote /path/to/my-workspace ``` -Both required variables must be set. If only one of them is set, Procnote disables DropPoint and logs a configuration warning. +Both required variables must be set to create new DropPoint sessions. If only one is set, Procnote disables new session creation and logs a configuration warning. A previously persisted receiver session can still resume pickup or close from its owner-only private state without retaining the API creation token. ## Use DropPoint During an Execution @@ -60,6 +60,10 @@ When DropPoint is configured, attachment inputs show an **Upload via QR Code** b 2. Ask the sender to scan the QR code with their device. 3. Confirm that the sender's upload page shows the same human-readable drop name shown in Procnote. 4. Wait while the sender selects and uploads files. -5. Procnote imports the uploaded files as local attachments and closes the remote drop point. +5. Procnote authenticates the complete encrypted bundle, installs all files atomically as local attachments, durably records them in the execution log, and only then closes the remote drop point. -If local import fails after upload, Procnote keeps the DropPoint session open until it is cancelled, retried, closed, or expires, so the import can be retried without asking the sender to upload again. +If pickup, local installation, execution-log recording, or remote close fails, Procnote retains resumable private session state and leaves recoverable remote ciphertext available. Use **Retry** (or reopen the same attachment upload after restarting Procnote) to continue without reinstalling an already verified bundle. A relay-reported `expired`, `failed`, or prematurely `closed` state is terminal. + +Recovered files are published together beneath the execution's `attachments/bundle-dp_…/` directory. The directory includes an owner-only `.droppoint-receipt.json` identity receipt used only for crash recovery and conflict detection; the receipt is not shown as an attachment. Procnote never merges into or overwrites a different pre-existing bundle directory. + +Pickup capabilities and recipient private keys are stored atomically with owner-only access in the operating system's application-local data directory, outside the git-friendly procedure workspace. Procnote removes those secrets from private state only after remote close succeeds or a terminal remote outcome has been durably recorded. diff --git a/docs/guide/running-an-execution.md b/docs/guide/running-an-execution.md index d19eb39..583ffa4 100644 --- a/docs/guide/running-an-execution.md +++ b/docs/guide/running-an-execution.md @@ -82,7 +82,10 @@ my-workspace/ ├── template.md └── attachments/ ├── a1b2c3d-report.pdf - └── d4e5f6a-photo.jpg + ├── d4e5f6a-photo.jpg + └── bundle-dp_…/ # Atomically installed DropPoint bundle + ├── .droppoint-receipt.json + └── scan-01.jpg ``` -The `events.jsonl` file is the single source of truth. It is append-only and never modified after creation. +The `events.jsonl` file is the single source of truth. It is append-only and never modified after creation. DropPoint bundle receipts support local crash recovery and are not attachment records themselves. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6de8624..001064a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,10 +40,12 @@ tauri-plugin-log.workspace = true tauri-plugin-opener.workspace = true thiserror.workspace = true ts-rs.workspace = true +unicode-general-category = "1.1.0" +unicode-normalization = "0.1.25" url = "2.5.8" uuid.workspace = true x25519-dalek = { version = "3.0.0", features = ["static_secrets", "getrandom"] } -zeroize = "1.9.0" +zeroize = { version = "1.9.0", features = ["serde"] } [build-dependencies] tauri-build.workspace = true @@ -52,4 +54,11 @@ tauri-build.workspace = true workspace = true [dev-dependencies] +proptest = "1.11.0" tempfile.workspace = true + +[target."cfg(windows)".dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_Storage_FileSystem"] } + +[target."cfg(unix)".dependencies] +rustix = { version = "1.1.4", features = ["fs"] } diff --git a/src-tauri/src/commands/drop_point.rs b/src-tauri/src/commands/drop_point.rs index 808c130..33f5351 100644 --- a/src-tauri/src/commands/drop_point.rs +++ b/src-tauri/src/commands/drop_point.rs @@ -1,23 +1,34 @@ +use std::io::Read; +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; use procnote_core::event::types::ExecutionId; use procnote_core::execution::{ExecutionState, ExecutionStatus, ExecutionStepContent, StepStatus}; use procnote_core::template::types::InputType; use qrcode::QrCode; use qrcode::render::svg; use serde::Serialize; -use std::sync::Arc; - -use chrono::{DateTime, Utc}; use tauri::State; use ts_rs::TS; use url::Url; use url::form_urlencoded; +use zeroize::Zeroizing; use crate::commands::execution::{load_execution_from_disk, summarize}; -use crate::drop_point::client::{CreateDropPointResponse, DropPointClient, DropPointConfig}; +use crate::drop_point::client::{ + CreateDropPointResponse, DropPointClient, DropPointConfig, DropPointStatusResponse, + RemoteTerminal, +}; use crate::drop_point::crypto::{decrypt_bundle, encode_base64url, generate_recipient_key_pair}; use crate::drop_point::multipart::parse_pickup_multipart; -use crate::drop_point::{ActiveDropPointSession, DropPointSessions}; -use crate::persistence::execution_store::{AttachmentBytesSource, ExecutionStore}; +use crate::drop_point::session::{ + ActiveDropPointSession, CompletionOutcome, DropPointSessions, InstalledBundleState, + NewDropPointSession, SessionPhase, +}; +use crate::drop_point::storage::{ + InstalledBundle, encrypted_bundle_identity, install_bundle, verify_installed_bundle, +}; +use crate::persistence::execution_store::{ExecutionStore, InstalledAttachmentSource}; use crate::state::AppState; #[derive(Debug, Serialize, TS)] @@ -44,14 +55,35 @@ pub struct AttachmentDropPointStatus { pub expires_at: String, } +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AttachmentDropPointPollError { + Retryable { message: String }, + Terminal { message: String }, + Fatal { message: String }, +} + +impl From for AttachmentDropPointPollError { + fn from(message: String) -> Self { + Self::Fatal { message } + } +} + #[tauri::command] #[must_use] #[expect( clippy::needless_pass_by_value, reason = "Tauri command handlers require owned parameters" )] -pub fn is_drop_point_configured(state: State<'_, AppState>) -> bool { +pub fn is_drop_point_configured( + state: State<'_, AppState>, + sessions: State<'_, DropPointSessions>, +) -> bool { state.drop_point_client.is_some() + || sessions.has_resumable_sessions().unwrap_or_else(|error| { + log::warn!("failed to inspect resumable DropPoint private state: {error}"); + false + }) } #[tauri::command] @@ -62,8 +94,6 @@ pub async fn start_attachment_drop_point_session( step_id: String, input_id: String, ) -> Result { - let config = configured(&state)?; - let client = configured_client(&state)?; let (execution_state, log_path) = load_execution_from_disk(&state.procedures_dir, execution_id)?; let execution_dir = log_path @@ -72,61 +102,39 @@ pub async fn start_attachment_drop_point_session( .to_path_buf(); validate_attachment_target(&execution_state, &step_id, &input_id)?; - if let Some(previous) = sessions.remove_for_target(execution_id, &step_id, &input_id)? { - match client - .close(&previous.drop_point_id, &previous.pickup_token) - .await - { - Ok(()) => previous.delete_persisted()?, - Err(e) => log::warn!( - "DropPoint close failed while replacing session {}: {}", - previous.session_id, - e - ), - } + if let Some(existing) = sessions.find_for_target(execution_id, &step_id, &input_id)? { + return session_summary(&existing); } + let config = configured(&state)?; + let client = configured_client(&state)?; let (recipient_private_key, recipient_public_key) = generate_recipient_key_pair(); let created = client .create_drop_point() .await - .map_err(|e| e.to_string())?; + .map_err(|error| error.to_string())?; let target = SessionInputTarget { step_id, input_id }; - let session = build_session( + let setup = build_session( &config, created, - recipient_private_key, + &recipient_private_key, recipient_public_key, execution_id, target, execution_dir, + state.procedures_dir.clone(), ); - match session { - Ok((session, summary)) => match sessions.insert(session.clone()) { - Ok(()) => Ok(summary), - Err(reason) => { - if let Err(close_error) = client - .close(&session.drop_point_id, &session.pickup_token) - .await - { - log::warn!( - "DropPoint close failed after session insert error for {}: {}", - session.drop_point_id, - close_error - ); - } - Err(reason) - } - }, - Err(e) => { - if let Err(close_error) = client.close(&e.drop_point_id, &e.pickup_token).await { - log::warn!( - "DropPoint close failed after session setup error for {}: {}", - e.drop_point_id, - close_error - ); - } - Err(e.reason) + + match setup { + Ok((session, summary)) => sessions.insert(&session).map(|()| summary), + Err(error) => { + close_after_setup_failure( + &client, + &error.drop_point_id, + Ok(error.pickup_token.as_str()), + ) + .await; + Err(error.reason) } } } @@ -136,16 +144,45 @@ pub async fn poll_attachment_drop_point_session( state: State<'_, AppState>, sessions: State<'_, DropPointSessions>, session_id: String, -) -> Result { - let client = configured_client(&state)?; +) -> Result { let session = sessions.get(&session_id)?; - let status = client - .status(&session.drop_point_id, &session.pickup_token) + let client = configured_receiver_client(&state, &session)?; + if !session.is_resumable() { + return Err(AttachmentDropPointPollError::Terminal { + message: "DropPoint session is already terminal".to_string(), + }); + } + + let status = match client + .status(&session.drop_point_id, session.pickup_token()?) .await - .map_err(|e| e.to_string())?; + { + Ok(status) => status, + Err(error) => { + if let Some(terminal) = error.terminal() { + finalize_terminal_session(&sessions, &state.procedures_dir, &session, terminal)?; + return Err(AttachmentDropPointPollError::Terminal { + message: error.to_string(), + }); + } + return if error.is_retryable() { + Err(AttachmentDropPointPollError::Retryable { + message: error.to_string(), + }) + } else { + Err(AttachmentDropPointPollError::Fatal { + message: error.to_string(), + }) + }; + } + }; + validate_status_identity(&session, &status)?; + if let Some(terminal) = status.status.terminal() { + finalize_terminal_session(&sessions, &state.procedures_dir, &session, terminal)?; + } Ok(AttachmentDropPointStatus { - status: status.status, + status: status.status.as_str().to_string(), display_name: status.display_name, encrypted_size: status.encrypted_size, dropped_at: status.dropped_at, @@ -163,55 +200,60 @@ pub async fn import_attachment_drop_point_upload( input_id: String, session_id: String, ) -> Result { - let client = configured_client(&state)?; - let session = sessions.take(&session_id)?; - - let import_result = async { - ensure_session_target(&session, execution_id, &step_id, &input_id)?; - let (content_type, body) = client - .pickup(&session.drop_point_id, &session.pickup_token) - .await - .map_err(|e| e.to_string())?; - let (envelope_json, encrypted_payload) = - parse_pickup_multipart(&content_type, &body).map_err(|e| e.to_string())?; - let recovered = decrypt_bundle( - session.recipient_private_key.as_ref(), - &envelope_json, - &encrypted_payload, - ) - .map_err(|e| e.to_string())?; - - let sources = recovered - .into_iter() - .map(|file| AttachmentBytesSource { - filename: file.filename, - bytes: file.data, - }) - .collect(); - let recorded = ExecutionStore::new(state.procedures_dir.clone()) - .record_attachment_bytes_batch(execution_id, &step_id, &input_id, sources)?; - summarize(&recorded.state, &recorded.execution_dir) + let session = sessions.get(&session_id)?; + let client = configured_receiver_client(&state, &session)?; + ensure_session_target(&session, execution_id, &step_id, &input_id)?; + let current_state = validate_session_execution_dir(&state.procedures_dir, &session)?; + if matches!(session.phase, SessionPhase::Waiting) { + validate_attachment_target(¤t_state, &step_id, &input_id)?; } - .await; - - match import_result { - Ok(summary) => { - match client - .close(&session.drop_point_id, &session.pickup_token) - .await - { - Ok(()) => session.delete_persisted()?, - Err(e) => log::warn!( - "DropPoint close failed after local import for session {}: {}", - session.session_id, - e - ), - } + + if matches!(session.phase, SessionPhase::Complete { bundle: None, .. }) { + return Err("DropPoint session ended before a bundle was installed".to_string()); + } + if matches!( + session.phase, + SessionPhase::Complete { + bundle: Some(_), + .. + } + ) { + return record_session_bundle(&state.procedures_dir, &session)?.ok_or_else(|| { + "completed DropPoint session is missing its installed bundle".to_string() + }); + } + + let session = + ensure_bundle_installed(&client, &sessions, &state.procedures_dir, session).await?; + finish_installed_import(&client, &sessions, &state.procedures_dir, &session).await +} + +async fn finish_installed_import( + client: &DropPointClient, + sessions: &DropPointSessions, + procedures_dir: &std::path::Path, + session: &ActiveDropPointSession, +) -> Result { + let summary = record_session_bundle(procedures_dir, session)? + .ok_or_else(|| "DropPoint session has no installed bundle".to_string())?; + let close_pending = session.with_close_pending()?; + sessions.persist(&close_pending)?; + match client + .close(&close_pending.drop_point_id, close_pending.pickup_token()?) + .await + { + Ok(()) => { + sessions + .persist(&close_pending.with_complete(CompletionOutcome::ClosedSuccessfully))?; Ok(summary) } - Err(e) => { - sessions.insert(session)?; - Err(e) + Err(error) => { + if let Some(terminal) = error.terminal() { + finalize_terminal_session(sessions, procedures_dir, &close_pending, terminal)?; + Ok(summary) + } else { + Err(error.to_string()) + } } } } @@ -222,21 +264,216 @@ pub async fn cancel_attachment_drop_point_session( sessions: State<'_, DropPointSessions>, session_id: String, ) -> Result<(), String> { - let client = configured_client(&state)?; - let Ok(session) = sessions.take(&session_id) else { + let session = sessions.get(&session_id)?; + let client = configured_receiver_client(&state, &session)?; + if !session.is_resumable() { return Ok(()); - }; - client - .close(&session.drop_point_id, &session.pickup_token) + } + if session.installed_bundle().is_some() { + record_session_bundle(&state.procedures_dir, &session)?; + } + match client + .close(&session.drop_point_id, session.pickup_token()?) .await - .map_err(|e| e.to_string())?; - session.delete_persisted()?; + { + Ok(()) => sessions.persist(&session.with_complete(CompletionOutcome::ClosedSuccessfully)), + Err(error) => error.terminal().map_or_else( + || Err(error.to_string()), + |terminal| { + finalize_terminal_session(&sessions, &state.procedures_dir, &session, terminal) + }, + ), + } +} + +async fn ensure_bundle_installed( + client: &DropPointClient, + sessions: &DropPointSessions, + procedures_dir: &std::path::Path, + session: ActiveDropPointSession, +) -> Result { + match &session.phase { + SessionPhase::Waiting => { + let pickup = client + .pickup( + &session.drop_point_id, + session.pickup_token()?, + session.max_bytes, + ) + .await; + let (content_type, body) = match pickup { + Ok(pickup) => pickup, + Err(error) => { + if let Some(terminal) = error.terminal() { + finalize_terminal_session(sessions, procedures_dir, &session, terminal)?; + return Err(error.to_string()); + } + if error.is_not_ready() { + return Err("DropPoint pickup is not ready; resume polling".to_string()); + } + if error.is_retryable() { + return Err(format!("retryable DropPoint pickup failure: {error}")); + } + return Err(error.to_string()); + } + }; + let (envelope_json, encrypted_payload) = + parse_pickup_multipart(&content_type, &body, session.max_bytes) + .map_err(|error| error.to_string())?; + let identity = encrypted_bundle_identity(&envelope_json, &encrypted_payload) + .map_err(|error| error.to_string())?; + let private_key = session.recipient_private_key()?; + let recovered = decrypt_bundle(&private_key, &envelope_json, &encrypted_payload) + .map_err(|error| error.to_string())?; + let installed = install_bundle( + &session.execution_dir, + &session.drop_point_id, + &identity, + &recovered, + ) + .map_err(|error| error.to_string())?; + drop(recovered); + let bundle = InstalledBundleState { + identity: installed.identity, + path: installed.path, + }; + let updated = session.with_bundle_installed(bundle); + sessions.persist(&updated)?; + Ok(updated) + } + SessionPhase::BundleInstalled { .. } | SessionPhase::ClosePending { .. } => { + verify_session_bundle(&session)?; + Ok(session) + } + SessionPhase::Complete { .. } => Err("DropPoint session is already terminal".to_string()), + } +} + +fn verify_session_bundle(session: &ActiveDropPointSession) -> Result { + let bundle = session + .installed_bundle() + .ok_or_else(|| "DropPoint session has no installed bundle receipt".to_string())?; + verify_installed_bundle(&bundle.path, &session.drop_point_id, &bundle.identity) + .map_err(|error| error.to_string()) +} + +fn validate_session_execution_dir( + procedures_dir: &std::path::Path, + session: &ActiveDropPointSession, +) -> Result { + let (execution_state, log_path) = + load_execution_from_disk(procedures_dir, session.execution_id)?; + let current_dir = log_path + .parent() + .ok_or_else(|| "event log path has no parent".to_string())? + .canonicalize() + .map_err(|error| format!("failed to canonicalize execution directory: {error}"))?; + let persisted_dir = session.execution_dir.canonicalize().map_err(|error| { + format!("failed to canonicalize persisted execution directory: {error}") + })?; + if current_dir != persisted_dir { + return Err("DropPoint session destination does not match the execution".to_string()); + } + Ok(execution_state) +} + +fn record_session_bundle( + procedures_dir: &std::path::Path, + session: &ActiveDropPointSession, +) -> Result, String> { + if session.installed_bundle().is_none() { + return Ok(None); + } + validate_session_execution_dir(procedures_dir, session)?; + let installed = verify_session_bundle(session)?; + let sources = installed + .files + .iter() + .map(|file| { + Ok(InstalledAttachmentSource { + filename: file.filename.clone(), + relative_path: file.relative_path.clone(), + content_type: detected_safe_content_type(&installed.path.join(&file.filename))?, + sha256: file.sha256.clone(), + }) + }) + .collect::, String>>()?; + let recorded = ExecutionStore::new(procedures_dir.to_path_buf()) + .record_installed_attachment_batch( + session.execution_id, + &session.step_id, + &session.input_id, + sources, + )?; + summarize(&recorded.state, &recorded.execution_dir).map(Some) +} + +fn detected_safe_content_type(path: &std::path::Path) -> Result { + let mut file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let mut header = [0u8; 12]; + let read = file.read(&mut header).map_err(|error| error.to_string())?; + let header = &header[..read]; + let content_type = if header.starts_with(&[0xFF, 0xD8, 0xFF]) { + "image/jpeg" + } else if header.starts_with(b"\x89PNG\r\n\x1a\n") { + "image/png" + } else if header.starts_with(b"GIF87a") || header.starts_with(b"GIF89a") { + "image/gif" + } else if header.len() >= 12 && &header[..4] == b"RIFF" && &header[8..12] == b"WEBP" { + "image/webp" + } else if header.starts_with(b"BM") { + "image/bmp" + } else { + "application/octet-stream" + }; + Ok(content_type.to_string()) +} + +fn finalize_terminal_session( + sessions: &DropPointSessions, + procedures_dir: &std::path::Path, + session: &ActiveDropPointSession, + terminal: RemoteTerminal, +) -> Result<(), String> { + let current = sessions.get(&session.session_id)?; + if current.installed_bundle().is_some() { + record_session_bundle(procedures_dir, ¤t)?; + } + sessions.persist(¤t.with_complete(terminal.into())) +} + +fn validate_status_identity( + session: &ActiveDropPointSession, + status: &DropPointStatusResponse, +) -> Result<(), String> { + let status_expiry = parse_server_datetime(&status.expires_at)?; + if status.display_name != session.display_name + || status_expiry != session.expires_at + || status.encrypted_size > session.max_bytes + { + return Err( + "DropPoint status response does not match persisted receiver state".to_string(), + ); + } Ok(()) } +async fn close_after_setup_failure( + client: &DropPointClient, + drop_point_id: &str, + pickup_token: Result<&str, String>, +) { + let Ok(pickup_token) = pickup_token else { + return; + }; + if let Err(error) = client.close(drop_point_id, pickup_token).await { + log::warn!("DropPoint close failed after local session setup failed: {error}"); + } +} + struct SessionSetupError { drop_point_id: String, - pickup_token: String, + pickup_token: Zeroizing, reason: String, } @@ -245,46 +482,50 @@ struct SessionInputTarget { input_id: String, } +#[expect( + clippy::too_many_arguments, + reason = "constructs one complete durable receiver state" +)] fn build_session( config: &DropPointConfig, created: CreateDropPointResponse, - recipient_private_key: zeroize::Zeroizing<[u8; 32]>, + recipient_private_key: &Zeroizing<[u8; 32]>, recipient_public_key: [u8; 32], execution_id: ExecutionId, target: SessionInputTarget, - execution_dir: std::path::PathBuf, + execution_dir: PathBuf, + workspace_root: PathBuf, ) -> Result<(ActiveDropPointSession, AttachmentDropPointSessionSummary), SessionSetupError> { let drop_point_id = created.drop_point_id; let pickup_token = created.pickup_token; let setup = (|| { let expires_at = parse_server_datetime(&created.expires_at)?; - let qr_url = drop_link_with_fragment( + let drop_link_with_fragment = drop_link_with_fragment( &config.base_url, &created.drop_link, &recipient_public_key, &created.expires_at, )?; - let qr_svg = render_qr_svg(&qr_url)?; let session_id = uuid::Uuid::new_v4().to_string(); - let session = ActiveDropPointSession { - session_id: session_id.clone(), + let session = ActiveDropPointSession::new(NewDropPointSession { + session_id, + base_url: config.base_url.as_str().trim_end_matches('/').to_string(), drop_point_id: drop_point_id.clone(), + display_name: created.display_name, pickup_token: pickup_token.clone(), - recipient_private_key: Arc::new(recipient_private_key), + recipient_private_key: Zeroizing::new(encode_base64url(&**recipient_private_key)), + recipient_public_key: encode_base64url(&recipient_public_key), + drop_link: Zeroizing::new(created.drop_link), + drop_link_with_fragment: Zeroizing::new(drop_link_with_fragment), execution_id, step_id: target.step_id, input_id: target.input_id, expires_at, - execution_dir, - }; - let summary = AttachmentDropPointSessionSummary { - session_id, - display_name: created.display_name, - qr_url, - qr_svg, - expires_at: created.expires_at, max_bytes: created.max_bytes, - }; + execution_dir, + workspace_root, + }); + let summary = session_summary(&session)?; Ok((session, summary)) })(); @@ -295,10 +536,26 @@ fn build_session( }) } +fn session_summary( + session: &ActiveDropPointSession, +) -> Result { + let qr_url = session.drop_link_with_fragment()?.to_string(); + Ok(AttachmentDropPointSessionSummary { + session_id: session.session_id.clone(), + display_name: session.display_name.clone(), + qr_svg: render_qr_svg(&qr_url)?, + qr_url, + expires_at: session + .expires_at + .to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true), + max_bytes: session.max_bytes, + }) +} + fn parse_server_datetime(value: &str) -> Result, String> { DateTime::parse_from_rfc3339(value) .map(|value| value.with_timezone(&Utc)) - .map_err(|e| format!("DropPoint timestamp is invalid: {e}")) + .map_err(|error| format!("DropPoint timestamp is invalid: {error}")) } fn configured(state: &AppState) -> Result { @@ -315,6 +572,18 @@ fn configured_client(state: &AppState) -> Result { .ok_or_else(|| "DropPoint is not configured".to_string()) } +fn configured_receiver_client( + state: &AppState, + session: &ActiveDropPointSession, +) -> Result { + let persisted_origin = Url::parse(&session.base_url) + .map_err(|_| "persisted DropPoint relay origin is invalid".to_string())?; + match &state.drop_point_client { + Some(client) if client.base_url() == &persisted_origin => Ok(client.clone()), + Some(_) | None => DropPointClient::for_receiver(&session.base_url), + } +} + fn validate_attachment_target( state: &ExecutionState, step_id: &str, @@ -369,12 +638,15 @@ fn drop_link_with_fragment( recipient_public_key: &[u8; 32], expires_at: &str, ) -> Result { - let mut url = - Url::parse(drop_link).map_err(|e| format!("DropPoint drop_link is invalid: {e}"))?; - if !same_origin(base_url, &url) { - return Err("DropPoint drop_link origin does not match configured server".to_string()); + let url = Url::parse(drop_link).map_err(|_| "DropPoint drop_link is invalid".to_string())?; + if !crate::drop_point::client::same_origin(base_url, &url) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("DropPoint drop_link is not a fragment-free relay URL".to_string()); } - url.set_fragment(None); let fragment = form_urlencoded::Serializer::new(String::new()) .append_pair("v", "2") .append_pair("pk", &encode_base64url(recipient_public_key)) @@ -383,16 +655,8 @@ fn drop_link_with_fragment( Ok(format!("{url}#{fragment}")) } -fn same_origin(expected: &Url, actual: &Url) -> bool { - actual.username().is_empty() - && actual.password().is_none() - && expected.scheme() == actual.scheme() - && expected.host_str() == actual.host_str() - && expected.port_or_known_default() == actual.port_or_known_default() -} - fn render_qr_svg(value: &str) -> Result { - let code = QrCode::new(value.as_bytes()).map_err(|e| e.to_string())?; + let code = QrCode::new(value.as_bytes()).map_err(|error| error.to_string())?; Ok(code .render::() .min_dimensions(240, 240) @@ -400,3 +664,255 @@ fn render_qr_svg(value: &str) -> Result { .light_color(svg::Color("#ffffff")) .build()) } + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "unwrap is acceptable in tests")] +mod tests { + use std::io::{Read as _, Write as _}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + use std::thread; + use std::time::Duration; + + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use procnote_core::event::types::Event; + use procnote_core::execution::ExecutionState; + use procnote_core::template::parse_template; + + use super::*; + use crate::persistence::event_log::EventLog; + + const TEMPLATE: &str = r"--- +id: drop-point-restart +title: DropPoint Restart +version: 1.0.0 +--- + +## Capture + +```inputs +- id: evidence + label: Evidence + type: attachment +``` +"; + const RECIPIENT_PRIVATE_KEY: &str = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA"; + const RECIPIENT_PUBLIC_KEY: &str = "B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9_AsrhtHHw"; + const ENVELOPE_JSON: &str = concat!( + r#"{"protocol_version":2,"key_agreement":"x25519-hkdf-sha256-aesgcm-raw32","sender_ephemeral_public_key":"ZLEBsdC-WocEvQePmJUAH8A-jp-VIvGI3RKNmEbUhGY","metadata_nonce":"gYKDhIWGh4iJiouM","payload_nonce":"oaKjpKWmp6ipqqus","encrypted_metadata":"RXCd3ShA60Tza36-2nebwQVpV_NcAFlqtswR1p3V2_CXK9RVNjBXH2SER4pzbkLgtZj8Il4yGrid_PJ1BQatt8XhCygqbzWI5SCXUm-dZwSHv_bZSg6mhLJX6ED"#, + r#"E8Uuhr0CYIabnfbDEU1swi_mQ6FshM7aLdi-XQzleiuSNyKclXXGJ-5WbPQI"}"#, + ); + const ENCRYPTED_PAYLOAD: &str = "95kEDw2nrrpQAuknRO8NY2vBLOEvOd2Qjbzwu0aRORaf"; + + #[test] + #[expect( + clippy::too_many_lines, + reason = "the end-to-end restart scenario intentionally keeps every durability phase visible" + )] + fn pickup_install_record_restart_and_close_is_end_to_end_resumable() { + let encrypted_payload = URL_SAFE_NO_PAD.decode(ENCRYPTED_PAYLOAD).unwrap(); + let pickup_body = multipart_body(ENVELOPE_JSON.as_bytes(), &encrypted_payload); + let (base_url, requests, server) = mock_relay(pickup_body); + let config = DropPointConfig::for_test(Url::parse(&base_url).unwrap()); + let client = DropPointClient::new(config).unwrap(); + + let temporary = tempfile::tempdir().unwrap(); + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let workspace = workspace.canonicalize().unwrap(); + let procedure_dir = workspace.join("drop-point-restart"); + std::fs::create_dir(&procedure_dir).unwrap(); + let template_path = procedure_dir.join("template.md"); + std::fs::write(&template_path, TEMPLATE).unwrap(); + let template = parse_template(TEMPLATE).unwrap(); + let mut execution_state = ExecutionState::new(); + let initial_events = execution_state.start(&template).unwrap(); + let execution_id = execution_state.execution_id.unwrap(); + let started_at = initial_events + .iter() + .find_map(|event| match event { + Event::ExecutionStarted { at, .. } => Some(*at), + _ => None, + }) + .unwrap(); + let recorded = ExecutionStore::new(workspace.clone()) + .create_execution( + &template_path, + execution_state, + initial_events, + started_at, + execution_id, + "test".to_string(), + ) + .unwrap(); + + let state_root = temporary.path().join("private/drop-point-sessions"); + let sessions = DropPointSessions::new(state_root.clone(), &workspace).unwrap(); + let expires_at = Utc::now() + chrono::Duration::minutes(10); + let fragment = form_urlencoded::Serializer::new(String::new()) + .append_pair("v", "2") + .append_pair("pk", RECIPIENT_PUBLIC_KEY) + .append_pair( + "exp", + &expires_at.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true), + ) + .finish(); + let session = ActiveDropPointSession::new(NewDropPointSession { + session_id: uuid::Uuid::new_v4().to_string(), + base_url: base_url.clone(), + drop_point_id: "dp_example".to_string(), + display_name: "calm-otter".to_string(), + pickup_token: Zeroizing::new("pick_example".to_string()), + recipient_private_key: Zeroizing::new(RECIPIENT_PRIVATE_KEY.to_string()), + recipient_public_key: RECIPIENT_PUBLIC_KEY.to_string(), + drop_link: Zeroizing::new(format!("{base_url}/drop/drop_example")), + drop_link_with_fragment: Zeroizing::new(format!( + "{base_url}/drop/drop_example#{fragment}" + )), + execution_id, + step_id: "step-0".to_string(), + input_id: "evidence".to_string(), + expires_at, + max_bytes: 1024, + execution_dir: recorded.execution_dir.clone(), + workspace_root: workspace.clone(), + }); + sessions.insert(&session).unwrap(); + + let installed = tauri::async_runtime::block_on(ensure_bundle_installed( + &client, &sessions, &workspace, session, + )) + .unwrap(); + let first_finish = tauri::async_runtime::block_on(finish_installed_import( + &client, &sessions, &workspace, &installed, + )); + assert!(first_finish.is_err()); + drop(sessions); + + let restarted = DropPointSessions::new(state_root, &workspace).unwrap(); + let resumed = restarted.get(&installed.session_id).unwrap(); + assert!(matches!(resumed.phase, SessionPhase::ClosePending { .. })); + let resumed = tauri::async_runtime::block_on(ensure_bundle_installed( + &client, &restarted, &workspace, resumed, + )) + .unwrap(); + tauri::async_runtime::block_on(finish_installed_import( + &client, &restarted, &workspace, &resumed, + )) + .unwrap(); + + let final_state = restarted.get(&resumed.session_id).unwrap(); + assert!(final_state.recipient_private_key().is_err()); + assert!(final_state.pickup_token().is_err()); + let installed_path = final_state.installed_bundle().unwrap().path.clone(); + assert_eq!( + std::fs::read(installed_path.join("scan-01.txt")).unwrap(), + b"hello drop point\n" + ); + let events = EventLog::new(recorded.execution_dir.join("events.jsonl")) + .read() + .unwrap(); + let attachment_events = events + .iter() + .filter_map(|event| match event { + Event::AttachmentsAdded { attachments, .. } => Some(attachments), + _ => None, + }) + .collect::>(); + assert_eq!(attachment_events.len(), 1); + assert_eq!( + attachment_events[0][0].content_type, + "application/octet-stream" + ); + + let observed = requests.into_iter().take(3).collect::>(); + assert_eq!( + observed, + vec![ + ("GET /api/drop-points/dp_example/pickup".to_string(), true), + ("DELETE /api/drop-points/dp_example".to_string(), true), + ("DELETE /api/drop-points/dp_example".to_string(), true), + ] + ); + server.join().unwrap(); + } + + fn multipart_body(envelope: &[u8], payload: &[u8]) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(b"--test-boundary\r\n"); + body.extend_from_slice(b"Content-Disposition: attachment; name=\"envelope\"\r\n"); + body.extend_from_slice(b"Content-Type: application/json\r\n\r\n"); + body.extend_from_slice(envelope); + body.extend_from_slice(b"\r\n--test-boundary\r\n"); + body.extend_from_slice(b"Content-Disposition: attachment; name=\"payload\"\r\n"); + body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n"); + body.extend_from_slice(payload); + body.extend_from_slice(b"\r\n--test-boundary--\r\n"); + body + } + + fn mock_relay( + pickup_body: Vec, + ) -> ( + String, + mpsc::IntoIter<(String, bool)>, + thread::JoinHandle<()>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let server = thread::spawn(move || { + let responses = [ + http_response( + "200 OK", + "multipart/mixed; boundary=test-boundary", + &pickup_body, + ), + http_response( + "500 Internal Server Error", + "application/json", + br#"{"error":{"code":"drop_point_close_failed","message":"temporary"}}"#, + ), + http_response("204 No Content", "application/json", b""), + ]; + for response in responses { + let (mut stream, _) = listener.accept().unwrap(); + let (request_line, authorized) = read_request_metadata(&mut stream); + sender.send((request_line, authorized)).unwrap(); + stream.write_all(&response).unwrap(); + } + }); + (format!("http://{address}"), receiver.into_iter(), server) + } + + fn read_request_metadata(stream: &mut TcpStream) -> (String, bool) { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut bytes = Vec::new(); + let mut buffer = [0u8; 1024]; + while !bytes.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + } + let request = String::from_utf8(bytes).unwrap(); + let request_line = request.lines().next().unwrap().to_string(); + let request_line = request_line.strip_suffix(" HTTP/1.1").unwrap().to_string(); + let authorized = request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer pick_example")); + (request_line, authorized) + } + + fn http_response(status: &str, content_type: &str, body: &[u8]) -> Vec { + let headers = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + [headers.as_bytes(), body].concat() + } +} diff --git a/src-tauri/src/drop_point/client.rs b/src-tauri/src/drop_point/client.rs index 81445b3..bd4249f 100644 --- a/src-tauri/src/drop_point/client.rs +++ b/src-tauri/src/drop_point/client.rs @@ -1,59 +1,95 @@ +use std::fmt; +use std::time::Duration; + +use reqwest::header::{ACCEPT, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; +use unicode_general_category::{GeneralCategory, get_general_category}; use url::Url; +use zeroize::Zeroizing; + +use crate::drop_point::manifest::validate_rfc3339_timestamp; const ENV_URL: &str = "PROCNOTE_DROPPOINT_URL"; const ENV_API_TOKEN: &str = "PROCNOTE_DROPPOINT_API_TOKEN"; const ENV_TTL_SECONDS: &str = "PROCNOTE_DROPPOINT_TTL_SECONDS"; const ENV_MAX_BYTES: &str = "PROCNOTE_DROPPOINT_MAX_BYTES"; const CLIENT_NAME: &str = "procnote"; -const DEFAULT_PICKUP_BODY_LIMIT: u64 = 100 * 1024 * 1024; +const USER_AGENT: &str = "procnote-droppoint-receiver/2"; +const JSON_BODY_LIMIT: u64 = 64 * 1024; const MAX_ERROR_BODY_BYTES: u64 = 8 * 1024; +const MAX_ENVELOPE_BYTES: u64 = 1 << 20; +const MAX_MULTIPART_OVERHEAD_BYTES: u64 = 64 * 1024; +const MAX_PROTOCOL_BYTES: u64 = 1 << 40; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_mins(2); #[derive(Clone)] pub struct DropPointConfig { pub base_url: Url, - api_token: String, + api_token: Option>, ttl_seconds: Option, max_bytes: Option, } impl DropPointConfig { pub fn from_env() -> Result, String> { - let raw_url = optional_env(ENV_URL); - let api_token = optional_env(ENV_API_TOKEN); + let raw_url = optional_trimmed_env(ENV_URL); + let api_token = optional_secret_env(ENV_API_TOKEN); match (raw_url, api_token) { (None, None) => Ok(None), (Some(_), None) | (None, Some(_)) => Err(format!( "both {ENV_URL} and {ENV_API_TOKEN} must be set to enable DropPoint" )), (Some(raw_url), Some(api_token)) => { + let api_token = Zeroizing::new(api_token); let base_url = parse_base_url(&raw_url)?; + validate_capability(&api_token, "api_") + .map_err(|()| format!("{ENV_API_TOKEN} has an invalid token format"))?; let ttl_seconds = optional_positive_u64_env(ENV_TTL_SECONDS)?; let max_bytes = optional_positive_u64_env(ENV_MAX_BYTES)?; + if max_bytes.is_some_and(|value| value > MAX_PROTOCOL_BYTES) { + return Err(format!( + "{ENV_MAX_BYTES} must not exceed {MAX_PROTOCOL_BYTES}" + )); + } Ok(Some(Self { base_url, - api_token, + api_token: Some(api_token), ttl_seconds, max_bytes, })) } } } + + #[cfg(test)] + pub fn for_test(base_url: Url) -> Self { + Self { + base_url, + api_token: Some(Zeroizing::new("api_test".to_string())), + ttl_seconds: None, + max_bytes: None, + } + } } -fn optional_env(name: &str) -> Option { +fn optional_trimmed_env(name: &str) -> Option { std::env::var(name) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } +fn optional_secret_env(name: &str) -> Option { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + fn optional_positive_u64_env(name: &str) -> Result, String> { - optional_env(name) + optional_trimmed_env(name) .map(|value| { value .parse::() - .map_err(|e| format!("{name} must be a positive integer: {e}")) + .map_err(|error| format!("{name} must be a positive integer: {error}")) .and_then(|parsed| match parsed { 0 => Err(format!("{name} must be positive")), _ => Ok(parsed), @@ -62,17 +98,20 @@ fn optional_positive_u64_env(name: &str) -> Result, String> { .transpose() } -fn parse_base_url(raw: &str) -> Result { - let url = Url::parse(raw).map_err(|e| format!("{ENV_URL} is invalid: {e}"))?; - if url.scheme().is_empty() || url.host_str().is_none() { - return Err(format!("{ENV_URL} must include scheme and host")); - } - if url.query().is_some() || url.fragment().is_some() { - return Err(format!("{ENV_URL} must not include query or fragment")); +pub fn parse_base_url(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|error| format!("{ENV_URL} is invalid: {error}"))?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(format!("{ENV_URL} must be an HTTP(S) origin")); } if !url.username().is_empty() || url.password().is_some() { return Err(format!("{ENV_URL} must not include user info")); } + if !matches!(url.path(), "" | "/") { + return Err(format!("{ENV_URL} must not include a path prefix")); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(format!("{ENV_URL} must not include query or fragment")); + } if url.scheme() != "https" && !is_loopback_http_url(&url) { return Err(format!( "{ENV_URL} must use https (http is only allowed for localhost)" @@ -89,28 +128,124 @@ fn is_loopback_http_url(url: &Url) -> bool { ) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteTerminal { + Closed, + Expired, + Failed, + NotFound, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApiErrorCode { + DropNotReady, + DropPointClosed, + DropPointExpired, + DropPointFailed, + DropPointNotFound, + PayloadUnavailable, + Other, + Missing, +} + +impl fmt::Display for ApiErrorCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DropNotReady => formatter.write_str("drop_not_ready"), + Self::DropPointClosed => formatter.write_str("drop_point_closed"), + Self::DropPointExpired => formatter.write_str("drop_point_expired"), + Self::DropPointFailed => formatter.write_str("drop_point_failed"), + Self::DropPointNotFound => formatter.write_str("drop_point_not_found"), + Self::PayloadUnavailable => formatter.write_str("payload_unavailable"), + Self::Other => formatter.write_str("unknown_error"), + Self::Missing => formatter.write_str("unparsable_error"), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum DropPointClientError { - #[error("invalid DropPoint endpoint URL: {0}")] - InvalidUrl(#[from] url::ParseError), - #[error("DropPoint request failed: {0}")] - Request(#[from] reqwest::Error), - #[error("DropPoint returned HTTP {status}: {body}")] + #[error("DropPoint transport failed: {0}")] + Transport(String), + #[error("DropPoint returned HTTP {status} ({code})")] Http { status: reqwest::StatusCode, - body: String, + code: ApiErrorCode, }, #[error("DropPoint response body exceeded {limit} bytes")] BodyTooLarge { limit: u64 }, + #[error("DropPoint response body could not be allocated within its bound")] + BodyAllocation, + #[error("DropPoint response is invalid: {0}")] + InvalidResponse(String), + #[error("DropPoint client is not configured to create drop points")] + CreateNotConfigured, #[error("DropPoint server returned an invalid drop_point_id")] InvalidDropPointId, - #[error("DropPoint base URL cannot be used as a URL base")] + #[error("DropPoint endpoint could not be constructed")] InvalidBaseUrl, } +impl DropPointClientError { + #[must_use] + pub fn is_retryable(&self) -> bool { + match self { + Self::Transport(_) => true, + Self::Http { status, .. } => { + status.is_server_error() + || matches!( + *status, + reqwest::StatusCode::REQUEST_TIMEOUT + | reqwest::StatusCode::TOO_MANY_REQUESTS + ) + } + Self::BodyTooLarge { .. } + | Self::BodyAllocation + | Self::InvalidResponse(_) + | Self::CreateNotConfigured + | Self::InvalidDropPointId + | Self::InvalidBaseUrl => false, + } + } + + #[must_use] + pub fn is_not_ready(&self) -> bool { + matches!( + self, + Self::Http { + status: reqwest::StatusCode::CONFLICT, + code: ApiErrorCode::DropNotReady, + } + ) + } + + #[must_use] + pub fn terminal(&self) -> Option { + match self { + Self::Http { + status: reqwest::StatusCode::NOT_FOUND, + code: ApiErrorCode::DropPointNotFound | ApiErrorCode::Missing | ApiErrorCode::Other, + } => Some(RemoteTerminal::NotFound), + Self::Http { + status: reqwest::StatusCode::GONE, + code: ApiErrorCode::DropPointClosed, + } => Some(RemoteTerminal::Closed), + Self::Http { + status: reqwest::StatusCode::GONE, + code: ApiErrorCode::DropPointExpired, + } => Some(RemoteTerminal::Expired), + Self::Http { + status: reqwest::StatusCode::GONE, + code: ApiErrorCode::DropPointFailed, + } => Some(RemoteTerminal::Failed), + _ => None, + } + } +} + #[derive(Debug, Serialize)] struct CreateDropPointRequest { - client_name: String, + client_name: &'static str, #[serde(skip_serializing_if = "Option::is_none")] ttl_seconds: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -119,18 +254,55 @@ struct CreateDropPointRequest { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CreateDropPointResponse { pub drop_point_id: String, pub display_name: String, pub drop_link: String, - pub pickup_token: String, + pub pickup_token: Zeroizing, pub expires_at: String, pub max_bytes: u64, } +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum RelayStatus { + Open, + Receiving, + Ready, + Closed, + Expired, + Failed, +} + +impl RelayStatus { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Open => "open", + Self::Receiving => "receiving", + Self::Ready => "ready", + Self::Closed => "closed", + Self::Expired => "expired", + Self::Failed => "failed", + } + } + + #[must_use] + pub const fn terminal(self) -> Option { + match self { + Self::Closed => Some(RemoteTerminal::Closed), + Self::Expired => Some(RemoteTerminal::Expired), + Self::Failed => Some(RemoteTerminal::Failed), + Self::Open | Self::Receiving | Self::Ready => None, + } + } +} + #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DropPointStatusResponse { - pub status: String, + pub status: RelayStatus, pub display_name: String, pub encrypted_size: u64, pub dropped_at: Option, @@ -145,17 +317,40 @@ pub struct DropPointClient { } impl DropPointClient { + pub fn new(config: DropPointConfig) -> Result { + let http = reqwest::Client::builder() + .user_agent(USER_AGENT) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("failed to initialize DropPoint HTTP client: {error}"))?; + Ok(Self { config, http }) + } + + pub fn for_receiver(base_url: &str) -> Result { + let base_url = parse_base_url(base_url)?; + Self::new(DropPointConfig { + base_url, + api_token: None, + ttl_seconds: None, + max_bytes: None, + }) + } + #[must_use] - pub fn new(config: DropPointConfig) -> Self { - Self { - config, - http: reqwest::Client::new(), - } + pub const fn base_url(&self) -> &Url { + &self.config.base_url } pub async fn create_drop_point(&self) -> Result { + let api_token = self + .config + .api_token + .as_deref() + .ok_or(DropPointClientError::CreateNotConfigured)?; let request = CreateDropPointRequest { - client_name: CLIENT_NAME.to_string(), + client_name: CLIENT_NAME, ttl_seconds: self.config.ttl_seconds, max_bytes: self.config.max_bytes, single_use: true, @@ -163,11 +358,18 @@ impl DropPointClient { let response = self .http .post(self.endpoint(&["api", "drop-points"])?) - .bearer_auth(&self.config.api_token) + .bearer_auth(api_token.as_str()) + .header(ACCEPT, "application/json") .json(&request) .send() - .await?; - Ok(ensure_success(response).await?.json().await?) + .await + .map_err(transport_error)?; + let response = ensure_success(response).await?; + require_json_content_type(&response)?; + let bytes = Zeroizing::new(read_body_limited(response, JSON_BODY_LIMIT).await?); + let created: CreateDropPointResponse = parse_json(&bytes, "create response")?; + validate_create_response(&created, &self.config.base_url)?; + Ok(created) } pub async fn status( @@ -179,30 +381,57 @@ impl DropPointClient { .http .get(self.drop_point_endpoint(drop_point_id, Some("status"))?) .bearer_auth(pickup_token) + .header(ACCEPT, "application/json") .send() - .await?; - Ok(ensure_success(response).await?.json().await?) + .await + .map_err(transport_error)?; + let response = ensure_success(response).await?; + require_json_content_type(&response)?; + let bytes = Zeroizing::new(read_body_limited(response, JSON_BODY_LIMIT).await?); + let status: DropPointStatusResponse = parse_json(&bytes, "status response")?; + validate_status_response(&status)?; + Ok(status) } pub async fn pickup( &self, drop_point_id: &str, pickup_token: &str, + max_payload_bytes: u64, ) -> Result<(String, Vec), DropPointClientError> { + if max_payload_bytes == 0 || max_payload_bytes > MAX_PROTOCOL_BYTES { + return Err(DropPointClientError::InvalidResponse( + "persisted max_bytes is outside the protocol range".to_string(), + )); + } let response = self .http .get(self.drop_point_endpoint(drop_point_id, Some("pickup"))?) .bearer_auth(pickup_token) + .header(ACCEPT, "multipart/mixed") .send() - .await?; + .await + .map_err(transport_error)?; let response = ensure_success(response).await?; let content_type = response .headers() - .get(reqwest::header::CONTENT_TYPE) + .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) - .unwrap_or_default() + .ok_or_else(|| { + DropPointClientError::InvalidResponse( + "pickup response is missing a valid Content-Type".to_string(), + ) + })? .to_string(); - let body = read_body_limited(response, self.pickup_body_limit()).await?; + let body_limit = max_payload_bytes + .checked_add(MAX_ENVELOPE_BYTES) + .and_then(|limit| limit.checked_add(MAX_MULTIPART_OVERHEAD_BYTES)) + .ok_or_else(|| { + DropPointClientError::InvalidResponse( + "pickup response limit arithmetic overflowed".to_string(), + ) + })?; + let body = read_body_limited(response, body_limit).await?; Ok((content_type, body)) } @@ -216,23 +445,29 @@ impl DropPointClient { .delete(self.drop_point_endpoint(drop_point_id, None)?) .bearer_auth(pickup_token) .send() - .await?; - ensure_success(response).await.map(|_| ()) - } - - fn pickup_body_limit(&self) -> u64 { - self.config.max_bytes.unwrap_or(DEFAULT_PICKUP_BODY_LIMIT) + .await + .map_err(transport_error)?; + let response = ensure_success(response).await?; + if response.status() != reqwest::StatusCode::NO_CONTENT { + return Err(DropPointClientError::InvalidResponse( + "close response must use HTTP 204".to_string(), + )); + } + Ok(()) } fn endpoint(&self, segments: &[&str]) -> Result { let mut url = self.config.base_url.clone(); + url.set_path("/"); { let mut path = url .path_segments_mut() .map_err(|()| DropPointClientError::InvalidBaseUrl)?; - path.pop_if_empty(); + path.clear(); path.extend(segments); } + url.set_query(None); + url.set_fragment(None); Ok(url) } @@ -241,7 +476,8 @@ impl DropPointClient { drop_point_id: &str, suffix: Option<&str>, ) -> Result { - validate_drop_point_id(drop_point_id)?; + validate_capability(drop_point_id, "dp_") + .map_err(|()| DropPointClientError::InvalidDropPointId)?; let mut segments = vec!["api", "drop-points", drop_point_id]; if let Some(suffix) = suffix { segments.push(suffix); @@ -250,13 +486,113 @@ impl DropPointClient { } } -fn validate_drop_point_id(drop_point_id: &str) -> Result<(), DropPointClientError> { - (!drop_point_id.is_empty() - && drop_point_id - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) - .then_some(()) - .ok_or(DropPointClientError::InvalidDropPointId) +fn validate_create_response( + created: &CreateDropPointResponse, + base_url: &Url, +) -> Result<(), DropPointClientError> { + validate_capability(&created.drop_point_id, "dp_").map_err(|()| { + DropPointClientError::InvalidResponse("create response has an invalid ID".to_string()) + })?; + validate_capability(&created.pickup_token, "pick_").map_err(|()| { + DropPointClientError::InvalidResponse( + "create response has an invalid pickup capability".to_string(), + ) + })?; + validate_display_name(&created.display_name)?; + validate_timestamp(&created.expires_at, "expires_at")?; + if created.max_bytes == 0 || created.max_bytes > MAX_PROTOCOL_BYTES { + return Err(DropPointClientError::InvalidResponse( + "create response max_bytes is outside the protocol range".to_string(), + )); + } + validate_drop_link(&created.drop_link, base_url) +} + +fn validate_status_response(status: &DropPointStatusResponse) -> Result<(), DropPointClientError> { + validate_display_name(&status.display_name)?; + validate_timestamp(&status.expires_at, "expires_at")?; + if let Some(value) = &status.dropped_at { + validate_timestamp(value, "dropped_at")?; + } + if let Some(value) = &status.first_picked_up_at { + validate_timestamp(value, "first_picked_up_at")?; + } + Ok(()) +} + +fn validate_display_name(value: &str) -> Result<(), DropPointClientError> { + if value.trim().is_empty() + || value.len() > 128 + || value.chars().any(|character| { + matches!( + get_general_category(character), + GeneralCategory::Control | GeneralCategory::Format + ) + }) + { + return Err(DropPointClientError::InvalidResponse( + "response has an invalid display_name".to_string(), + )); + } + Ok(()) +} + +fn validate_timestamp(value: &str, field: &str) -> Result<(), DropPointClientError> { + validate_rfc3339_timestamp(value).map_err(|_| { + DropPointClientError::InvalidResponse(format!( + "response field {field} is not an RFC3339 timestamp" + )) + }) +} + +fn validate_drop_link(value: &str, base_url: &Url) -> Result<(), DropPointClientError> { + let link = Url::parse(value).map_err(|_| { + DropPointClientError::InvalidResponse("create response drop_link is invalid".to_string()) + })?; + if !link.username().is_empty() + || link.password().is_some() + || link.query().is_some() + || link.fragment().is_some() + || !same_origin(base_url, &link) + { + return Err(DropPointClientError::InvalidResponse( + "create response drop_link is not a fragment-free link on the relay origin".to_string(), + )); + } + let segments = link + .path_segments() + .map(Iterator::collect::>) + .unwrap_or_default(); + if segments.len() != 2 + || segments.first().copied() != Some("drop") + || segments + .get(1) + .is_none_or(|token| validate_capability(token, "drop_").is_err()) + { + return Err(DropPointClientError::InvalidResponse( + "create response drop_link has an invalid path".to_string(), + )); + } + Ok(()) +} + +pub fn same_origin(expected: &Url, actual: &Url) -> bool { + expected.scheme() == actual.scheme() + && expected.host_str() == actual.host_str() + && expected.port_or_known_default() == actual.port_or_known_default() +} + +fn validate_capability(value: &str, prefix: &str) -> Result<(), ()> { + value + .strip_prefix(prefix) + .filter(|suffix| { + !suffix.is_empty() + && suffix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + }) + .map(|_| ()) + .ok_or(()) } async fn ensure_success( @@ -266,14 +602,72 @@ async fn ensure_success( if status.is_success() { return Ok(response); } - let body = match read_body_limited(response, MAX_ERROR_BODY_BYTES).await { - Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), - Err(DropPointClientError::BodyTooLarge { .. }) => { - format!("") - } - Err(e) => format!(""), + let code = read_body_limited(response, MAX_ERROR_BODY_BYTES) + .await + .map_or(ApiErrorCode::Missing, |bytes| { + parse_api_error_code(&Zeroizing::new(bytes)) + }); + Err(DropPointClientError::Http { status, code }) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ApiErrorEnvelope { + error: ApiErrorBody, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ApiErrorBody { + code: Zeroizing, + message: Zeroizing, +} + +fn parse_api_error_code(bytes: &[u8]) -> ApiErrorCode { + let Ok(envelope) = serde_json::from_slice::(bytes) else { + return ApiErrorCode::Missing; }; - Err(DropPointClientError::Http { status, body }) + let _ = &envelope.error.message; + match envelope.error.code.as_str() { + "drop_not_ready" => ApiErrorCode::DropNotReady, + "drop_point_closed" => ApiErrorCode::DropPointClosed, + "drop_point_expired" => ApiErrorCode::DropPointExpired, + "drop_point_failed" => ApiErrorCode::DropPointFailed, + "drop_point_not_found" => ApiErrorCode::DropPointNotFound, + "payload_unavailable" => ApiErrorCode::PayloadUnavailable, + _ => ApiErrorCode::Other, + } +} + +fn require_json_content_type(response: &reqwest::Response) -> Result<(), DropPointClientError> { + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + DropPointClientError::InvalidResponse( + "JSON response is missing a valid Content-Type".to_string(), + ) + })?; + let media_type = content_type + .split_once(';') + .map_or(content_type, |(media_type, _)| media_type) + .trim(); + if media_type.eq_ignore_ascii_case("application/json") { + Ok(()) + } else { + Err(DropPointClientError::InvalidResponse( + "response Content-Type must be application/json".to_string(), + )) + } +} + +fn parse_json Deserialize<'de>>( + bytes: &[u8], + label: &str, +) -> Result { + serde_json::from_slice(bytes) + .map_err(|_| DropPointClientError::InvalidResponse(format!("{label} is not strict JSON"))) } async fn read_body_limited( @@ -287,17 +681,258 @@ async fn read_body_limited( return Err(DropPointClientError::BodyTooLarge { limit }); } - let capacity = response + let initial_capacity = response .content_length() + .map(|length| length.min(1024 * 1024)) .and_then(|length| usize::try_from(length).ok()) .unwrap_or_default(); - let mut body = Vec::with_capacity(capacity); - while let Some(chunk) = response.chunk().await? { - let next_len = body.len().saturating_add(chunk.len()); + let mut body = Vec::new(); + body.try_reserve(initial_capacity) + .map_err(|_| DropPointClientError::BodyAllocation)?; + while let Some(chunk) = response.chunk().await.map_err(transport_error)? { + let next_len = body + .len() + .checked_add(chunk.len()) + .ok_or(DropPointClientError::BodyTooLarge { limit })?; if u64::try_from(next_len).map_or(true, |next_len| next_len > limit) { return Err(DropPointClientError::BodyTooLarge { limit }); } + body.try_reserve(chunk.len()) + .map_err(|_| DropPointClientError::BodyAllocation)?; body.extend_from_slice(&chunk); } Ok(body) } + +fn transport_error(error: reqwest::Error) -> DropPointClientError { + DropPointClientError::Transport(redact_capabilities(&error.without_url().to_string())) +} + +pub fn redact_capabilities(value: &str) -> String { + let bytes = value.as_bytes(); + let mut output = String::with_capacity(value.len()); + let mut cursor = 0; + while cursor < bytes.len() { + if let Some(prefix_length) = capability_prefix_length(&bytes[cursor..]) { + let mut end = cursor + prefix_length; + let token_start = end; + while end < bytes.len() { + if is_capability_byte(bytes[end]) { + end += 1; + } else if percent_encoded_byte(&bytes[end..]).is_some_and(is_capability_byte) { + end += 3; + } else { + break; + } + } + if end > token_start { + output.push_str(""); + cursor = end; + continue; + } + } + let character = value[cursor..] + .chars() + .next() + .expect("cursor always points to a character boundary"); + output.push(character); + cursor += character.len_utf8(); + } + output +} + +fn capability_prefix_length(value: &[u8]) -> Option { + [b"drop".as_slice(), b"pick".as_slice(), b"api".as_slice()] + .into_iter() + .find_map(|prefix| { + let remainder = value.strip_prefix(prefix)?; + if remainder.starts_with(b"_") { + Some(prefix.len() + 1) + } else if remainder + .get(..3) + .and_then(percent_encoded_byte) + .is_some_and(|byte| byte == b'_') + { + Some(prefix.len() + 3) + } else { + None + } + }) +} + +fn percent_encoded_byte(value: &[u8]) -> Option { + if value.first().copied()? != b'%' { + return None; + } + let high = hex_value(*value.get(1)?)?; + let low = hex_value(*value.get(2)?)?; + Some((high << 4) | low) +} + +const fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +const fn is_capability_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "unwrap is acceptable in tests")] +mod tests { + use super::*; + + #[test] + fn accepts_only_root_relay_origins() { + assert!(parse_base_url("https://drop.example.com").is_ok()); + assert!(parse_base_url("https://drop.example.com/").is_ok()); + assert!(parse_base_url("http://localhost:8080").is_ok()); + for invalid in [ + "ftp://drop.example.com", + "http://drop.example.com", + "https://user@drop.example.com", + "https://drop.example.com/prefix", + "https://drop.example.com/?query=yes", + "https://drop.example.com/#fragment", + ] { + assert!(parse_base_url(invalid).is_err(), "accepted {invalid}"); + } + } + + #[test] + fn receiver_only_client_does_not_require_or_allow_an_api_token() { + let client = DropPointClient::for_receiver("http://127.0.0.1:1").unwrap(); + let result = tauri::async_runtime::block_on(client.create_drop_point()); + assert!(matches!( + result, + Err(DropPointClientError::CreateNotConfigured) + )); + } + + #[test] + fn constructs_endpoints_from_the_origin_root() { + let config = DropPointConfig::for_test(Url::parse("https://drop.example.com/").unwrap()); + let client = DropPointClient::new(config).unwrap(); + assert_eq!( + client + .drop_point_endpoint("dp_example", Some("pickup")) + .unwrap() + .as_str(), + "https://drop.example.com/api/drop-points/dp_example/pickup" + ); + assert!(client.drop_point_endpoint("../secret", None).is_err()); + } + + #[test] + fn create_request_omits_defaults_and_never_emits_null_or_false() { + let request = CreateDropPointRequest { + client_name: CLIENT_NAME, + ttl_seconds: None, + max_bytes: None, + single_use: true, + }; + let value = serde_json::to_value(request).unwrap(); + assert_eq!(value["client_name"], CLIENT_NAME); + assert_eq!(value["single_use"], true); + assert!(value.get("ttl_seconds").is_none()); + assert!(value.get("max_bytes").is_none()); + } + + #[test] + fn parses_all_six_statuses_and_rejects_unknown_status() { + for status in ["open", "receiving", "ready", "closed", "expired", "failed"] { + let json = format!( + r#"{{"status":"{status}","display_name":"calm-otter","encrypted_size":0,"dropped_at":null,"first_picked_up_at":null,"expires_at":"2026-06-30T12:15:00Z"}}"# + ); + let parsed: DropPointStatusResponse = parse_json(json.as_bytes(), "status").unwrap(); + assert_eq!(parsed.status.as_str(), status); + } + let unknown = br#"{"status":"waiting","display_name":"calm-otter","encrypted_size":0,"dropped_at":null,"first_picked_up_at":null,"expires_at":"2026-06-30T12:15:00Z"}"#; + assert!(parse_json::(unknown, "status").is_err()); + } + + #[test] + fn strict_api_response_parsing_rejects_unknown_duplicate_and_wrong_types() { + let cases: &[&[u8]] = &[ + br#"{"status":"open","status":"ready","display_name":"calm-otter","encrypted_size":0,"dropped_at":null,"first_picked_up_at":null,"expires_at":"2026-06-30T12:15:00Z"}"#, + br#"{"status":"open","display_name":"calm-otter","encrypted_size":0,"dropped_at":null,"first_picked_up_at":null,"expires_at":"2026-06-30T12:15:00Z","extra":1}"#, + br#"{"status":"open","display_name":"calm-otter","encrypted_size":true,"dropped_at":null,"first_picked_up_at":null,"expires_at":"2026-06-30T12:15:00Z"}"#, + ]; + for case in cases { + assert!(parse_json::(case, "status").is_err()); + } + } + + #[test] + fn validates_create_response_fields_and_drop_link() { + let base = Url::parse("https://drop.example.com").unwrap(); + let response = CreateDropPointResponse { + drop_point_id: "dp_example".to_string(), + display_name: "calm-otter".to_string(), + drop_link: "https://drop.example.com/drop/drop_example".to_string(), + pickup_token: Zeroizing::new("pick_example".to_string()), + expires_at: "2026-06-30T12:15:00Z".to_string(), + max_bytes: 1024, + }; + assert!(validate_create_response(&response, &base).is_ok()); + } + + #[test] + fn classifies_documented_endpoint_outcomes() { + let not_ready = DropPointClientError::Http { + status: reqwest::StatusCode::CONFLICT, + code: ApiErrorCode::DropNotReady, + }; + assert!(not_ready.is_not_ready()); + let failed = DropPointClientError::Http { + status: reqwest::StatusCode::GONE, + code: ApiErrorCode::DropPointFailed, + }; + assert_eq!(failed.terminal(), Some(RemoteTerminal::Failed)); + let unavailable = DropPointClientError::Http { + status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, + code: ApiErrorCode::PayloadUnavailable, + }; + assert!(unavailable.is_retryable()); + } + + #[test] + fn redacts_capabilities_in_plain_encoded_and_malformed_urls() { + let sensitive = concat!( + "https://api_user@pick_secret.example/drop/drop_abc?api_token=api_xyz#drop_fragment ", + "https://example.invalid/drop%5Fencoded/more pick_tail" + ); + let redacted = redact_capabilities(sensitive); + for secret in [ + "api_user", + "pick_secret", + "drop_abc", + "api_xyz", + "drop%5Fencoded", + "pick_tail", + "drop_fragment", + ] { + assert!(!redacted.contains(secret)); + } + assert!(redacted.contains("")); + } + + #[test] + fn never_echoes_api_error_messages() { + let body = + br#"{"error":{"code":"drop_point_failed","message":"pick_secret and a private key"}}"#; + assert_eq!(parse_api_error_code(body), ApiErrorCode::DropPointFailed); + let error = DropPointClientError::Http { + status: reqwest::StatusCode::GONE, + code: parse_api_error_code(body), + }; + let rendered = error.to_string(); + assert!(!rendered.contains("pick_secret")); + assert!(!rendered.contains("private key")); + } +} diff --git a/src-tauri/src/drop_point/crypto.rs b/src-tauri/src/drop_point/crypto.rs index 7924248..cd6b582 100644 --- a/src-tauri/src/drop_point/crypto.rs +++ b/src-tauri/src/drop_point/crypto.rs @@ -35,18 +35,20 @@ struct Envelope { #[derive(Debug, thiserror::Error)] pub enum DropPointCryptoError { - #[error("envelope JSON is invalid: {0}")] - EnvelopeJson(#[from] serde_json::Error), + #[error("envelope JSON is invalid")] + EnvelopeJson, #[error("unsupported envelope protocol_version {0}")] UnsupportedProtocol(u32), - #[error("unsupported envelope key_agreement {0}")] - UnsupportedKeyAgreement(String), + #[error("unsupported envelope key_agreement")] + UnsupportedKeyAgreement, #[error("envelope field {field} is invalid: {reason}")] InvalidEnvelopeField { field: &'static str, reason: String }, #[error("X25519 shared secret is all zero")] AllZeroSharedSecret, #[error("HKDF expansion failed")] Hkdf, + #[error("encrypted payload is too short to contain an AES-GCM tag")] + EncryptedPayloadTooShort, #[error("AES-GCM decryption failed for {0}")] Decrypt(&'static str), #[error("decrypted manifest is invalid: {0}")] @@ -70,8 +72,12 @@ pub fn decrypt_bundle( envelope_json: &[u8], encrypted_payload: &[u8], ) -> Result, DropPointCryptoError> { - let envelope: Envelope = serde_json::from_slice(envelope_json)?; + let envelope: Envelope = + serde_json::from_slice(envelope_json).map_err(|_| DropPointCryptoError::EnvelopeJson)?; validate_envelope_header(&envelope)?; + if encrypted_payload.len() < AES_GCM_TAG_BYTES { + return Err(DropPointCryptoError::EncryptedPayloadTooShort); + } let sender_public_key = decode_field_32( "sender_ephemeral_public_key", @@ -98,20 +104,20 @@ pub fn decrypt_bundle( &sender_public_key, &recipient_public, )?; - let manifest_json = decrypt_aes_gcm( + let manifest_json = Zeroizing::new(decrypt_aes_gcm( &metadata_key, &metadata_nonce, AAD_METADATA, &encrypted_metadata, "metadata", - )?; - let payload_plaintext = decrypt_aes_gcm( + )?); + let payload_plaintext = Zeroizing::new(decrypt_aes_gcm( &payload_key, &payload_nonce, AAD_PAYLOAD, encrypted_payload, "payload", - )?; + )?); Ok(split_payload(&manifest_json, &payload_plaintext)?) } @@ -123,9 +129,7 @@ fn validate_envelope_header(envelope: &Envelope) -> Result<(), DropPointCryptoEr )); } if envelope.key_agreement != KEY_AGREEMENT { - return Err(DropPointCryptoError::UnsupportedKeyAgreement( - envelope.key_agreement.clone(), - )); + return Err(DropPointCryptoError::UnsupportedKeyAgreement); } Ok(()) } @@ -189,18 +193,27 @@ fn decode_base64url_field( field: &'static str, value: &str, ) -> Result, DropPointCryptoError> { - if value.is_empty() || value.contains('=') { - return Err(DropPointCryptoError::InvalidEnvelopeField { - field, - reason: "base64url value must be non-empty and unpadded".to_string(), - }); + decode_base64url(value) + .map_err(|reason| DropPointCryptoError::InvalidEnvelopeField { field, reason }) +} + +pub fn decode_base64url(value: &str) -> Result, String> { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err( + "base64url value must use the non-empty unpadded URL-safe alphabet".to_string(), + ); } - URL_SAFE_NO_PAD + let decoded = URL_SAFE_NO_PAD .decode(value) - .map_err(|e| DropPointCryptoError::InvalidEnvelopeField { - field, - reason: e.to_string(), - }) + .map_err(|_| "base64url value is malformed".to_string())?; + if URL_SAFE_NO_PAD.encode(&decoded) != value { + return Err("base64url value is not canonical".to_string()); + } + Ok(decoded) } fn derive_keys( @@ -246,8 +259,22 @@ fn decrypt_aes_gcm( #[cfg(test)] #[expect(clippy::unwrap_used, reason = "unwrap is acceptable in tests")] mod tests { + use serde::Deserialize; + use super::*; + #[derive(Deserialize)] + struct ParsingFixture { + base64url: Vec, + } + + #[derive(Deserialize)] + struct Base64Fixture { + value: String, + valid: bool, + decoded_hex: Option, + } + const RECIPIENT_PRIVATE_KEY: &str = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA"; const SINGLE_ENVELOPE_JSON: &str = concat!( r#"{"protocol_version":2,"key_agreement":"x25519-hkdf-sha256-aesgcm-raw32","sender_ephemeral_public_key":"ZLEBsdC-WocEvQePmJUAH8A-jp-VIvGI3RKNmEbUhGY","metadata_nonce":"gYKDhIWGh4iJiouM","payload_nonce":"oaKjpKWmp6ipqqus","encrypted_metadata":"RXCd3ShA60Tza36-2nebwQVpV_NcAFlqtswR1p3V2_CXK9RVNjBXH2SER4pzbkLgtZj8Il4yGrid_PJ1BQatt8XhCygqbzWI5SCXUm-dZwSHv_bZSg6mhLJX6ED"#, @@ -306,4 +333,162 @@ mod tests { Err(DropPointCryptoError::Decrypt("payload")) )); } + + #[test] + fn matches_normative_base64url_fixture() { + let fixture: ParsingFixture = serde_json::from_str(include_str!( + "../../testdata/drop_point/protocol-parsing-policy.json" + )) + .unwrap(); + for case in fixture.base64url { + let decoded = decode_base64url(&case.value); + assert_eq!( + decoded.is_ok(), + case.valid, + "unexpected result for {:?}", + case.value + ); + if let Some(expected_hex) = case.decoded_hex { + let actual_hex = decoded + .unwrap() + .iter() + .fold(String::new(), |mut output, byte| { + use std::fmt::Write as _; + write!(output, "{byte:02x}").unwrap(); + output + }); + assert_eq!(actual_hex, expected_hex); + } + } + } + + #[test] + fn rejects_duplicate_unknown_and_wrongly_typed_envelope_fields() { + let duplicate = SINGLE_ENVELOPE_JSON.replacen( + r#"{"protocol_version":2,"#, + r#"{"protocol_version":2,"protocol_version":2,"#, + 1, + ); + let unknown = SINGLE_ENVELOPE_JSON.replacen( + r#"{"protocol_version":2,"#, + r#"{"extra":0,"protocol_version":2,"#, + 1, + ); + let boolean = SINGLE_ENVELOPE_JSON.replacen( + r#""protocol_version":2"#, + r#""protocol_version":true"#, + 1, + ); + let payload = decode_test_b64(SINGLE_ENCRYPTED_PAYLOAD); + for envelope in [&duplicate, &unknown, &boolean] { + assert!(decrypt_bundle(&private_key(), envelope.as_bytes(), &payload).is_err()); + } + } + + #[test] + fn rejects_tampered_metadata_nonce_key_and_low_order_input() { + let payload = decode_test_b64(SINGLE_ENCRYPTED_PAYLOAD); + + let mut envelope: serde_json::Value = serde_json::from_str(SINGLE_ENVELOPE_JSON).unwrap(); + envelope["metadata_nonce"] = serde_json::Value::String(encode_base64url(&[7; 12])); + assert!(matches!( + decrypt_bundle( + &private_key(), + serde_json::to_vec(&envelope).unwrap().as_slice(), + &payload + ), + Err(DropPointCryptoError::Decrypt("metadata")) + )); + + assert!(decrypt_bundle(&[42; 32], SINGLE_ENVELOPE_JSON.as_bytes(), &payload).is_err()); + + envelope["sender_ephemeral_public_key"] = + serde_json::Value::String(encode_base64url(&[0; 32])); + assert!(matches!( + decrypt_bundle( + &private_key(), + serde_json::to_vec(&envelope).unwrap().as_slice(), + &payload + ), + Err(DropPointCryptoError::AllZeroSharedSecret) + )); + } + + #[test] + fn rejects_tampered_metadata_payload_nonce_and_sender_key() { + let payload = decode_test_b64(SINGLE_ENCRYPTED_PAYLOAD); + let mut envelope: serde_json::Value = serde_json::from_str(SINGLE_ENVELOPE_JSON).unwrap(); + + let mut metadata = decode_test_b64(envelope["encrypted_metadata"].as_str().unwrap()); + let last = metadata.len() - 1; + metadata[last] ^= 1; + envelope["encrypted_metadata"] = serde_json::Value::String(encode_base64url(&metadata)); + assert!(matches!( + decrypt_bundle( + &private_key(), + serde_json::to_vec(&envelope).unwrap().as_slice(), + &payload + ), + Err(DropPointCryptoError::Decrypt("metadata")) + )); + + envelope = serde_json::from_str(SINGLE_ENVELOPE_JSON).unwrap(); + envelope["payload_nonce"] = serde_json::Value::String(encode_base64url(&[9; 12])); + assert!(matches!( + decrypt_bundle( + &private_key(), + serde_json::to_vec(&envelope).unwrap().as_slice(), + &payload + ), + Err(DropPointCryptoError::Decrypt("payload")) + )); + + envelope = serde_json::from_str(SINGLE_ENVELOPE_JSON).unwrap(); + envelope["sender_ephemeral_public_key"] = + serde_json::Value::String(encode_base64url(&[42; 32])); + assert!( + decrypt_bundle( + &private_key(), + serde_json::to_vec(&envelope).unwrap().as_slice(), + &payload + ) + .is_err() + ); + } + + #[test] + fn rejects_another_low_order_x25519_input() { + let payload = decode_test_b64(SINGLE_ENCRYPTED_PAYLOAD); + let mut low_order = [0; 32]; + low_order[0] = 1; + let mut envelope: serde_json::Value = serde_json::from_str(SINGLE_ENVELOPE_JSON).unwrap(); + envelope["sender_ephemeral_public_key"] = + serde_json::Value::String(encode_base64url(&low_order)); + assert!(matches!( + decrypt_bundle( + &private_key(), + serde_json::to_vec(&envelope).unwrap().as_slice(), + &payload + ), + Err(DropPointCryptoError::AllZeroSharedSecret) + )); + } + + #[test] + fn generates_a_fresh_raw_key_pair_per_call() { + let (first_private, first_public) = generate_recipient_key_pair(); + let (second_private, second_public) = generate_recipient_key_pair(); + assert_eq!(first_private.len(), X25519_KEY_BYTES); + assert_eq!(first_public.len(), X25519_KEY_BYTES); + assert_ne!(*first_private, *second_private); + assert_ne!(first_public, second_public); + } + + #[test] + fn rejects_short_encrypted_payload_before_decryption() { + assert!(matches!( + decrypt_bundle(&private_key(), SINGLE_ENVELOPE_JSON.as_bytes(), &[0; 15]), + Err(DropPointCryptoError::EncryptedPayloadTooShort) + )); + } } diff --git a/src-tauri/src/drop_point/manifest.rs b/src-tauri/src/drop_point/manifest.rs index be7c920..f868265 100644 --- a/src-tauri/src/drop_point/manifest.rs +++ b/src-tauri/src/drop_point/manifest.rs @@ -2,11 +2,16 @@ use std::collections::HashSet; use chrono::DateTime; use serde::Deserialize; - -use crate::persistence::attachment_store::sanitize_attachment_filename; +use unicode_general_category::{GeneralCategory, get_general_category}; +use unicode_normalization::{UnicodeNormalization, is_nfc}; +use zeroize::Zeroize; const PROTOCOL_VERSION: u32 = 2; const DEFAULT_MIME_TYPE: &str = "application/octet-stream"; +const MAX_MANIFEST_FILES: usize = 1000; +const MAX_FILENAME_UTF8_BYTES: usize = 240; +const MAX_MIME_TYPE_UTF8_BYTES: usize = 255; +const RESERVED_RECEIPT_NAME: &str = ".droppoint-receipt.json"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RecoveredFile { @@ -15,6 +20,14 @@ pub struct RecoveredFile { pub data: Vec, } +impl Drop for RecoveredFile { + fn drop(&mut self) { + self.filename.zeroize(); + self.content_type.zeroize(); + self.data.zeroize(); + } +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct Manifest { @@ -32,68 +45,100 @@ struct ManifestFile { size: u64, } +impl Drop for ManifestFile { + fn drop(&mut self) { + self.name.zeroize(); + self.mime_type.zeroize(); + } +} + +impl Drop for Manifest { + fn drop(&mut self) { + self.created_at.zeroize(); + } +} + #[derive(Debug, thiserror::Error)] pub enum ManifestError { - #[error("manifest JSON is invalid: {0}")] - Json(#[from] serde_json::Error), + #[error("manifest JSON is invalid")] + Json, #[error("manifest protocol_version = {actual}, want {expected}")] UnsupportedProtocol { actual: u32, expected: u32 }, #[error("manifest created_at is invalid: {0}")] InvalidCreatedAt(String), - #[error("manifest must contain at least one file")] - EmptyFiles, + #[error("manifest must contain between 1 and {MAX_MANIFEST_FILES} files")] + InvalidFileCount, + #[error("manifest filename at index {index} is invalid: {reason}")] + InvalidFilename { index: usize, reason: String }, + #[error("manifest contains colliding filenames at index {index}")] + FilenameCollision { index: usize }, #[error("manifest file {index} MIME type is invalid: {reason}")] InvalidMimeType { index: usize, reason: String }, - #[error("manifest filename {filename} has too many duplicates")] - FilenameSuffixOverflow { filename: String }, - #[error("manifest size sum overflows usize")] - SizeOverflow, - #[error("manifest size sum {expected} does not match payload length {actual}")] - SizeMismatch { expected: usize, actual: usize }, + #[error("manifest file {index} size cannot be represented by this platform")] + SizeNotRepresentable { index: usize }, + #[error("manifest file {index} size exceeds the remaining authenticated payload")] + SizeExceedsPayload { index: usize }, + #[error("manifest sizes leave {remaining} unclaimed authenticated payload bytes")] + UnclaimedPayload { remaining: usize }, + #[error("manifest payload bounds changed while splitting file {index}")] + InvalidSliceBounds { index: usize }, } pub fn split_payload( manifest_json: &[u8], payload_plaintext: &[u8], ) -> Result, ManifestError> { - let manifest: Manifest = serde_json::from_slice(manifest_json)?; + let manifest: Manifest = + serde_json::from_slice(manifest_json).map_err(|_| ManifestError::Json)?; validate_manifest_header(&manifest)?; - let mut total = 0usize; - let mut used_names = HashSet::new(); - let mut sanitized_entries = Vec::with_capacity(manifest.files.len()); + let mut remaining = payload_plaintext.len(); + let mut comparison_keys = HashSet::with_capacity(manifest.files.len()); + let entries = manifest + .files + .iter() + .enumerate() + .map(|(index, file)| { + validate_filename(&file.name) + .map_err(|reason| ManifestError::InvalidFilename { index, reason })?; + if !comparison_keys.insert(filename_collision_key(&file.name)) { + return Err(ManifestError::FilenameCollision { index }); + } + let content_type = sanitize_mime_type(&file.mime_type) + .map_err(|reason| ManifestError::InvalidMimeType { index, reason })?; + let size = usize::try_from(file.size) + .map_err(|_| ManifestError::SizeNotRepresentable { index })?; + if size > remaining { + return Err(ManifestError::SizeExceedsPayload { index }); + } + remaining -= size; + Ok((file.name.clone(), content_type, size)) + }) + .collect::, ManifestError>>()?; - for (index, file) in manifest.files.iter().enumerate() { - let sanitized_filename = sanitize_filename(&file.name); - let filename = unique_filename(&sanitized_filename, &mut used_names)?; - let content_type = sanitize_mime_type(&file.mime_type) - .map_err(|reason| ManifestError::InvalidMimeType { index, reason })?; - let size = usize::try_from(file.size).map_err(|_| ManifestError::SizeOverflow)?; - total = total.checked_add(size).ok_or(ManifestError::SizeOverflow)?; - sanitized_entries.push((filename, content_type, size)); + if remaining != 0 { + return Err(ManifestError::UnclaimedPayload { remaining }); } - if total != payload_plaintext.len() { - return Err(ManifestError::SizeMismatch { - expected: total, - actual: payload_plaintext.len(), - }); - } - - sanitized_entries + entries .into_iter() + .enumerate() .try_fold( - (Vec::new(), 0usize), - |(mut files, offset), (filename, content_type, size)| { - let next = offset + (Vec::with_capacity(manifest.files.len()), 0usize), + |(mut files, offset), (index, (filename, content_type, size))| { + let end = offset .checked_add(size) - .ok_or(ManifestError::SizeOverflow)?; + .ok_or(ManifestError::InvalidSliceBounds { index })?; + let data = payload_plaintext + .get(offset..end) + .ok_or(ManifestError::InvalidSliceBounds { index })? + .to_vec(); files.push(RecoveredFile { filename, content_type, - data: payload_plaintext[offset..next].to_vec(), + data, }); - Ok((files, next)) + Ok((files, end)) }, ) .map(|(files, _)| files) @@ -106,111 +151,401 @@ fn validate_manifest_header(manifest: &Manifest) -> Result<(), ManifestError> { expected: PROTOCOL_VERSION, }); } - if manifest.files.is_empty() { - return Err(ManifestError::EmptyFiles); + if !(1..=MAX_MANIFEST_FILES).contains(&manifest.files.len()) { + return Err(ManifestError::InvalidFileCount); } - DateTime::parse_from_rfc3339(&manifest.created_at) - .map(|_| ()) - .map_err(|e| ManifestError::InvalidCreatedAt(e.to_string())) + validate_rfc3339_timestamp(&manifest.created_at).map_err(ManifestError::InvalidCreatedAt) } -fn unique_filename( - filename: &str, - used_names: &mut HashSet, -) -> Result { - if used_names.insert(fold_filename(filename)) { - return Ok(filename.to_string()); +pub fn validate_rfc3339_timestamp(value: &str) -> Result<(), String> { + let bytes = value.as_bytes(); + let fixed_digits = [0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18]; + if bytes.len() < 20 + || fixed_digits + .into_iter() + .any(|index| bytes.get(index).is_none_or(|byte| !byte.is_ascii_digit())) + || bytes.get(4) != Some(&b'-') + || bytes.get(7) != Some(&b'-') + || bytes.get(10) != Some(&b'T') + || bytes.get(13) != Some(&b':') + || bytes.get(16) != Some(&b':') + { + return Err("timestamp does not have canonical RFC3339 syntax".to_string()); } - let (stem, extension) = split_filename_extension(filename); - std::iter::successors(Some(1usize), |suffix| suffix.checked_add(1)) - .find_map(|suffix| { - let candidate = format!("{stem} ({suffix}){extension}"); - used_names - .insert(fold_filename(&candidate)) - .then_some(candidate) - }) - .ok_or_else(|| ManifestError::FilenameSuffixOverflow { - filename: filename.to_string(), - }) + let mut zone_start = 19; + if bytes.get(zone_start) == Some(&b'.') { + zone_start += 1; + let fraction_start = zone_start; + while bytes.get(zone_start).is_some_and(u8::is_ascii_digit) + && zone_start - fraction_start < 9 + { + zone_start += 1; + } + if zone_start == fraction_start || bytes.get(zone_start).is_some_and(u8::is_ascii_digit) { + return Err("timestamp has an invalid fractional second".to_string()); + } + } + + let zone = bytes.get(zone_start..).unwrap_or_default(); + let valid_zone = zone == b"Z" + || (zone.len() == 6 + && matches!(zone.first(), Some(b'+' | b'-')) + && zone.get(1).is_some_and(u8::is_ascii_digit) + && zone.get(2).is_some_and(u8::is_ascii_digit) + && zone.get(3) == Some(&b':') + && zone.get(4).is_some_and(u8::is_ascii_digit) + && zone.get(5).is_some_and(u8::is_ascii_digit)); + if !valid_zone { + return Err("timestamp must include an RFC3339 timezone".to_string()); + } + + DateTime::parse_from_rfc3339(value) + .map(|_| ()) + .map_err(|_| "timestamp contains an invalid date or time".to_string()) } -fn split_filename_extension(filename: &str) -> (&str, &str) { - match filename.rsplit_once('.') { - Some((stem, _extension)) if !stem.is_empty() => (stem, &filename[stem.len()..]), - Some(_) | None => (filename, ""), +pub fn validate_filename(name: &str) -> Result<(), String> { + let encoded_len = name.len(); + if !(1..=MAX_FILENAME_UTF8_BYTES).contains(&encoded_len) { + return Err(format!( + "filename must contain between 1 and {MAX_FILENAME_UTF8_BYTES} UTF-8 bytes" + )); + } + if !is_nfc(name) { + return Err("filename must use Unicode NFC".to_string()); + } + if name.chars().any(is_forbidden_filename_character) { + return Err("filename contains a forbidden character".to_string()); + } + if name.ends_with([' ', '.']) { + return Err("filename must not end in an ASCII space or dot".to_string()); + } + if matches!(name, "." | "..") || name.chars().all(char::is_whitespace) { + return Err("filename is blank or reserved".to_string()); + } + if name.eq_ignore_ascii_case(RESERVED_RECEIPT_NAME) { + return Err("filename is reserved for the installation receipt".to_string()); + } + if is_windows_reserved_name(name) { + return Err("filename has a Windows-reserved stem".to_string()); } + Ok(()) } -fn fold_filename(filename: &str) -> String { - filename.to_lowercase() +fn is_forbidden_filename_character(character: char) -> bool { + matches!( + character, + '/' | '\\' | '\0' | '<' | '>' | ':' | '"' | '|' | '?' | '*' + ) || matches!( + get_general_category(character), + GeneralCategory::Control | GeneralCategory::Format + ) } -fn sanitize_filename(name: &str) -> String { - sanitize_attachment_filename(name) +fn is_windows_reserved_name(filename: &str) -> bool { + let stem = filename + .split_once('.') + .map_or(filename, |(stem, _)| stem) + .trim_end_matches(' ') + .to_uppercase(); + matches!( + stem.as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "COM¹" + | "COM²" + | "COM³" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + | "LPT¹" + | "LPT²" + | "LPT³" + ) } -fn sanitize_mime_type(value: &str) -> Result { - let trimmed = value.trim(); - if trimmed.is_empty() { +pub fn filename_collision_key(filename: &str) -> String { + filename.nfc().collect::().to_lowercase() +} + +pub fn sanitize_mime_type(value: &str) -> Result { + if value.is_empty() { return Ok(DEFAULT_MIME_TYPE.to_string()); } - if trimmed.chars().any(char::is_whitespace) || trimmed.chars().any(char::is_control) { - return Err("MIME type contains whitespace or control characters".to_string()); + if value.len() > MAX_MIME_TYPE_UTF8_BYTES { + return Err(format!( + "MIME type exceeds {MAX_MIME_TYPE_UTF8_BYTES} UTF-8 bytes" + )); } - let lowered = trimmed.to_ascii_lowercase(); - let Some((top, sub)) = lowered.split_once('/') else { - return Err("MIME type must contain one slash".to_string()); + + let lowered = value.to_ascii_lowercase(); + let Some((top, subtype)) = lowered.split_once('/') else { + return Err("MIME type must contain exactly one slash".to_string()); }; - if sub.contains('/') { - return Err("MIME type must contain one slash".to_string()); - } - if !is_mime_token(top) || !is_mime_token(sub) { + if subtype.contains('/') || !is_mime_token(top) || !is_mime_token(subtype) { return Err("MIME type contains invalid token characters".to_string()); } Ok(lowered) } fn is_mime_token(value: &str) -> bool { - let mut chars = value.chars(); - match chars.next() { - Some(first) if first.is_ascii_alphanumeric() => {} - _ => return false, - } - chars.all(|ch| { - ch.is_ascii_alphanumeric() - || matches!(ch, '!' | '#' | '$' | '&' | '^' | '_' | '.' | '+' | '-') - }) + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(first) if first.is_ascii_alphanumeric()) + && bytes.all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' + ) + }) } #[cfg(test)] +#[expect(clippy::unwrap_used, reason = "unwrap is acceptable in tests")] mod tests { + use proptest::prelude::*; + use serde::Deserialize; + use super::*; + #[derive(Deserialize)] + struct FilenameFixture { + max_filename_utf8_bytes: usize, + max_manifest_files: usize, + canonicalization_bundles: Vec, + validation: Vec, + collisions: Vec, + } + + #[derive(Deserialize)] + struct CanonicalizationBundle { + output: Vec, + } + + #[derive(Deserialize)] + struct FilenameValidation { + name: String, + valid: bool, + } + + #[derive(Deserialize)] + struct FilenameCollision { + names: Vec, + collides: bool, + } + + #[derive(Deserialize)] + struct ParsingFixture { + timestamps: Vec, + mime_types: Vec, + } + + #[derive(Deserialize)] + struct TimestampFixture { + value: String, + valid: bool, + } + + #[derive(Deserialize)] + struct MimeFixture { + value: String, + valid: bool, + canonical: Option, + } + #[test] - #[expect(clippy::unwrap_used, reason = "unwrap is acceptable in tests")] - fn renames_duplicate_filenames_case_insensitively() { - let manifest = br#"{"protocol_version":2,"files":[{"name":"image.jpg","type":"text/plain","size":1},{"name":"image (1).jpg","type":"text/plain","size":1},{"name":"IMAGE.JPG","type":"text/plain","size":1}],"created_at":"2026-06-30T12:00:00Z"}"#; + fn accepts_every_normative_sender_canonicalization_output() { + let fixture: FilenameFixture = serde_json::from_str(include_str!( + "../../testdata/drop_point/filename-policy.json" + )) + .unwrap(); + assert_eq!(fixture.max_filename_utf8_bytes, MAX_FILENAME_UTF8_BYTES); + assert_eq!(fixture.max_manifest_files, MAX_MANIFEST_FILES); + for bundle in fixture.canonicalization_bundles { + let keys = bundle + .output + .iter() + .map(|name| { + validate_filename(name).unwrap(); + filename_collision_key(name) + }) + .collect::>(); + assert_eq!(keys.len(), bundle.output.len()); + } + } - let files = split_payload(manifest, b"abc").unwrap(); + #[test] + fn matches_normative_filename_validation_fixture() { + let fixture: FilenameFixture = serde_json::from_str(include_str!( + "../../testdata/drop_point/filename-policy.json" + )) + .unwrap(); + for case in fixture.validation { + assert_eq!( + validate_filename(&case.name).is_ok(), + case.valid, + "unexpected result for {:?}", + case.name + ); + } + } - assert_eq!( - files + #[test] + fn matches_normative_filename_collision_fixture() { + let fixture: FilenameFixture = serde_json::from_str(include_str!( + "../../testdata/drop_point/filename-policy.json" + )) + .unwrap(); + for case in fixture.collisions { + let keys = case + .names .iter() - .map(|file| file.filename.as_str()) - .collect::>(), - vec!["image.jpg", "image (1).jpg", "IMAGE (2).JPG"] + .map(|name| filename_collision_key(name)) + .collect::>(); + assert_eq!( + keys.len() != case.names.len(), + case.collides, + "unexpected collision result for {:?}", + case.names + ); + } + } + + #[test] + fn matches_normative_timestamp_and_mime_fixtures() { + let fixture: ParsingFixture = serde_json::from_str(include_str!( + "../../testdata/drop_point/protocol-parsing-policy.json" + )) + .unwrap(); + for case in fixture.timestamps { + assert_eq!( + validate_rfc3339_timestamp(&case.value).is_ok(), + case.valid, + "unexpected timestamp result for {:?}", + case.value + ); + } + for case in fixture.mime_types { + let actual = sanitize_mime_type(&case.value); + assert_eq!( + actual.is_ok(), + case.valid, + "unexpected MIME result for {:?}", + case.value + ); + if let Some(canonical) = case.canonical { + assert_eq!(actual.unwrap(), canonical); + } + } + } + + #[test] + fn rejects_noncanonical_rfc3339_spellings() { + for invalid in [ + "2026-06-30 12:00:00Z", + "2026-06-30t12:00:00Z", + "2026-06-30T12:00:00z", + "2026-06-30T12:00:00.1234567890Z", + "2026-06-30T12:00:00+0900", + ] { + assert!( + validate_rfc3339_timestamp(invalid).is_err(), + "accepted {invalid}" + ); + } + } + + #[test] + fn rejects_noncanonical_and_colliding_filenames_instead_of_rewriting() { + let traversal = br#"{"protocol_version":2,"files":[{"name":"../a.txt","type":"text/plain","size":0}],"created_at":"2026-06-30T12:00:00Z"}"#; + assert!(matches!( + split_payload(traversal, b""), + Err(ManifestError::InvalidFilename { .. }) + )); + + let collision = br#"{"protocol_version":2,"files":[{"name":"Report.txt","type":"text/plain","size":1},{"name":"report.TXT","type":"text/plain","size":1}],"created_at":"2026-06-30T12:00:00Z"}"#; + assert!(matches!( + split_payload(collision, b"ab"), + Err(ManifestError::FilenameCollision { index: 1 }) + )); + } + + #[test] + fn rejects_duplicate_unknown_and_wrongly_typed_json_fields() { + let cases: &[&[u8]] = &[ + br#"{"protocol_version":2,"protocol_version":2,"files":[],"created_at":"2026-06-30T12:00:00Z"}"#, + br#"{"protocol_version":2,"files":[],"created_at":"2026-06-30T12:00:00Z","extra":1}"#, + br#"{"protocol_version":true,"files":[],"created_at":"2026-06-30T12:00:00Z"}"#, + br#"{"protocol_version":2,"files":[{"name":"a","type":"","size":true}],"created_at":"2026-06-30T12:00:00Z"}"#, + ]; + for case in cases { + assert!(split_payload(case, b"").is_err()); + } + } + + #[test] + fn enforces_the_manifest_file_count_bound() { + let manifest = |count: usize| { + serde_json::to_vec(&serde_json::json!({ + "protocol_version": 2, + "files": (0..count) + .map(|index| serde_json::json!({ + "name": format!("file-{index}"), + "type": "", + "size": 0, + })) + .collect::>(), + "created_at": "2026-06-30T12:00:00Z", + })) + .unwrap() + }; + assert_eq!( + split_payload(&manifest(MAX_MANIFEST_FILES), b"") + .unwrap() + .len(), + MAX_MANIFEST_FILES ); - assert_eq!(files[0].data, b"a"); - assert_eq!(files[1].data, b"b"); - assert_eq!(files[2].data, b"c"); + assert!(matches!( + split_payload(&manifest(MAX_MANIFEST_FILES + 1), b""), + Err(ManifestError::InvalidFileCount) + )); } #[test] - #[expect(clippy::unwrap_used, reason = "unwrap is acceptable in tests")] - fn sanitizes_path_filenames() { - let manifest = br#"{"protocol_version":2,"files":[{"name":"../a.txt","type":"text/plain","size":0}],"created_at":"2026-06-30T12:00:00Z"}"#; - let files = split_payload(manifest, b"").unwrap(); - assert_eq!(files[0].filename, ".._a.txt"); + fn checks_each_size_against_remaining_payload() { + let manifest = br#"{"protocol_version":2,"files":[{"name":"a","type":"","size":18446744073709551615}],"created_at":"2026-06-30T12:00:00Z"}"#; + assert!(matches!( + split_payload(manifest, b""), + Err(ManifestError::SizeExceedsPayload { .. } + | ManifestError::SizeNotRepresentable { .. }) + )); + } + + proptest! { + #[test] + fn arbitrary_manifests_return_without_panicking( + manifest in proptest::collection::vec(any::(), 0..2048), + payload in proptest::collection::vec(any::(), 0..2048), + ) { + let _ = split_payload(&manifest, &payload); + } } } diff --git a/src-tauri/src/drop_point/mod.rs b/src-tauri/src/drop_point/mod.rs index c7dc2e1..3c6efce 100644 --- a/src-tauri/src/drop_point/mod.rs +++ b/src-tauri/src/drop_point/mod.rs @@ -2,7 +2,9 @@ pub mod client; pub mod crypto; pub mod manifest; pub mod multipart; +pub mod secure_fs; pub mod session; +pub mod storage; pub use client::{DropPointClient, DropPointConfig}; -pub use session::{ActiveDropPointSession, DropPointSessions, cleanup_persisted_sessions}; +pub use session::DropPointSessions; diff --git a/src-tauri/src/drop_point/multipart.rs b/src-tauri/src/drop_point/multipart.rs index 45a9ab1..6d2c1c7 100644 --- a/src-tauri/src/drop_point/multipart.rs +++ b/src-tauri/src/drop_point/multipart.rs @@ -1,4 +1,4 @@ -use mailparse::{DispositionType, parse_mail}; +use mailparse::{DispositionType, parse_content_type, parse_mail}; const ENVELOPE_PART: &str = "envelope"; const PAYLOAD_PART: &str = "payload"; @@ -6,15 +6,25 @@ const MULTIPART_MIXED: &str = "multipart/mixed"; const JSON_CONTENT_TYPE: &str = "application/json"; const OCTET_CONTENT_TYPE: &str = "application/octet-stream"; const MAX_ENVELOPE_BYTES: usize = 1 << 20; +const MAX_CONTENT_TYPE_BYTES: usize = 1024; +const MAX_BOUNDARY_BYTES: usize = 70; #[derive(Debug, thiserror::Error)] pub enum MultipartError { + #[error("pickup response Content-Type is invalid")] + InvalidContentType, #[error("pickup response Content-Type must be multipart/mixed")] NotMultipartMixed, - #[error("pickup response MIME body is invalid: {0}")] - Parse(#[from] mailparse::MailParseError), - #[error("pickup response contains unexpected part {0}")] - UnexpectedPart(String), + #[error("pickup response multipart boundary is invalid")] + InvalidBoundary, + #[error("pickup response is missing its final multipart boundary")] + Truncated, + #[error("pickup response MIME body is invalid")] + Parse, + #[error("pickup response contains an unexpected part")] + UnexpectedPart, + #[error("pickup response part headers are ambiguous or incomplete")] + InvalidPartHeaders, #[error("pickup response contains duplicate part {0}")] DuplicatePart(&'static str), #[error("pickup response is missing part {0}")] @@ -24,16 +34,46 @@ pub enum MultipartError { part: &'static str, expected: &'static str, }, - #[error("pickup response envelope part is too large")] + #[error("pickup response parts must not use Content-Transfer-Encoding")] + TransferEncoding, + #[error("pickup response envelope part exceeds 1 MiB")] EnvelopeTooLarge, + #[error("pickup response encrypted payload exceeds the persisted drop-point limit")] + PayloadTooLarge, } +#[expect( + clippy::too_many_lines, + reason = "the strict multipart shape is validated in one linear pass" +)] pub fn parse_pickup_multipart( content_type: &str, body: &[u8], + max_payload_bytes: u64, ) -> Result<(Vec, Vec), MultipartError> { + if content_type.is_empty() + || content_type.len() > MAX_CONTENT_TYPE_BYTES + || content_type.bytes().any(|byte| byte.is_ascii_control()) + { + return Err(MultipartError::InvalidContentType); + } + + let parsed_content_type = parse_content_type(content_type); + if parsed_content_type.mimetype != MULTIPART_MIXED { + return Err(MultipartError::NotMultipartMixed); + } + if parameter_count(content_type, "boundary") != 1 { + return Err(MultipartError::InvalidBoundary); + } + let boundary = parsed_content_type + .params + .get("boundary") + .filter(|boundary| valid_boundary(boundary)) + .ok_or(MultipartError::InvalidBoundary)?; + validate_complete_body(body, boundary)?; + let synthetic_message = synthetic_message(content_type, body); - let parsed = parse_mail(&synthetic_message)?; + let parsed = parse_mail(&synthetic_message).map_err(|_| MultipartError::Parse)?; if !parsed.ctype.mimetype.eq_ignore_ascii_case(MULTIPART_MIXED) { return Err(MultipartError::NotMultipartMixed); } @@ -42,22 +82,43 @@ pub fn parse_pickup_multipart( let mut payload = None; for part in &parsed.subparts { + let disposition_headers = part + .headers + .iter() + .filter(|header| { + header + .get_key_ref() + .eq_ignore_ascii_case("Content-Disposition") + }) + .collect::>(); + let content_type_count = part + .headers + .iter() + .filter(|header| header.get_key_ref().eq_ignore_ascii_case("Content-Type")) + .count(); + if disposition_headers.len() != 1 + || content_type_count != 1 + || parameter_count(&disposition_headers[0].get_value(), "name") != 1 + { + return Err(MultipartError::InvalidPartHeaders); + } + if part.headers.iter().any(|header| { + header + .get_key_ref() + .eq_ignore_ascii_case("Content-Transfer-Encoding") + }) { + return Err(MultipartError::TransferEncoding); + } let disposition = part.get_content_disposition(); if disposition.disposition != DispositionType::Attachment && disposition.disposition != DispositionType::FormData { - return Err(MultipartError::UnexpectedPart( - disposition - .params - .get("name") - .cloned() - .unwrap_or_else(|| "".to_string()), - )); + return Err(MultipartError::UnexpectedPart); } let name = disposition .params .get("name") - .ok_or_else(|| MultipartError::UnexpectedPart("".to_string()))?; + .ok_or(MultipartError::UnexpectedPart)?; match name.as_str() { ENVELOPE_PART => { if envelope.is_some() { @@ -69,7 +130,7 @@ pub fn parse_pickup_multipart( expected: JSON_CONTENT_TYPE, }); } - let data = part.get_body_raw()?; + let data = part.get_body_raw().map_err(|_| MultipartError::Parse)?; if data.len() > MAX_ENVELOPE_BYTES { return Err(MultipartError::EnvelopeTooLarge); } @@ -85,9 +146,13 @@ pub fn parse_pickup_multipart( expected: OCTET_CONTENT_TYPE, }); } - payload = Some(part.get_body_raw()?); + let data = part.get_body_raw().map_err(|_| MultipartError::Parse)?; + if u64::try_from(data.len()).map_or(true, |length| length > max_payload_bytes) { + return Err(MultipartError::PayloadTooLarge); + } + payload = Some(data); } - other => return Err(MultipartError::UnexpectedPart(other.to_string())), + _ => return Err(MultipartError::UnexpectedPart), } } @@ -97,8 +162,87 @@ pub fn parse_pickup_multipart( )) } +fn parameter_count(header: &str, wanted_name: &str) -> usize { + let mut quoted = false; + let mut escaped = false; + let mut segment_start = 0; + let mut count = 0; + for (index, character) in header.char_indices() { + if escaped { + escaped = false; + continue; + } + match character { + '\\' if quoted => escaped = true, + '"' => quoted = !quoted, + ';' if !quoted => { + count += usize::from( + parameter_name(&header[segment_start..index]) + .is_some_and(|name| name.eq_ignore_ascii_case(wanted_name)), + ); + segment_start = index + 1; + } + _ => {} + } + } + count + + usize::from( + parameter_name(&header[segment_start..]) + .is_some_and(|name| name.eq_ignore_ascii_case(wanted_name)), + ) +} + +fn parameter_name(segment: &str) -> Option<&str> { + segment.trim().split_once('=').map(|(name, _)| name.trim()) +} + +fn valid_boundary(boundary: &str) -> bool { + let bytes = boundary.as_bytes(); + !bytes.is_empty() + && bytes.len() <= MAX_BOUNDARY_BYTES + && !bytes.ends_with(b" ") + && bytes.iter().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'\'' + | b'(' + | b')' + | b'+' + | b'_' + | b',' + | b'-' + | b'.' + | b'/' + | b':' + | b'=' + | b'?' + | b' ' + ) + }) +} + +fn validate_complete_body(body: &[u8], boundary: &str) -> Result<(), MultipartError> { + let opening = [b"--".as_slice(), boundary.as_bytes(), b"\r\n"].concat(); + if !body.starts_with(&opening) { + return Err(MultipartError::Truncated); + } + + let closing = [b"--".as_slice(), boundary.as_bytes(), b"--"].concat(); + let without_optional_crlf = body.strip_suffix(b"\r\n").unwrap_or(body); + if !without_optional_crlf.ends_with(&closing) { + return Err(MultipartError::Truncated); + } + Ok(()) +} + fn synthetic_message(content_type: &str, body: &[u8]) -> Vec { - let mut message = Vec::with_capacity(content_type.len() + body.len() + 40); + let mut message = Vec::with_capacity( + content_type + .len() + .saturating_add(body.len()) + .saturating_add(40), + ); message.extend_from_slice(b"Content-Type: "); message.extend_from_slice(content_type.as_bytes()); message.extend_from_slice(b"\r\nMIME-Version: 1.0\r\n\r\n"); @@ -111,23 +255,104 @@ fn synthetic_message(content_type: &str, body: &[u8]) -> Vec { mod tests { use super::*; + const CONTENT_TYPE: &str = "multipart/mixed; boundary=test-boundary"; + const BODY: &str = concat!( + "--test-boundary\r\n", + "Content-Disposition: attachment; name=\"envelope\"\r\n", + "Content-Type: application/json\r\n\r\n", + "{}\r\n", + "--test-boundary\r\n", + "Content-Disposition: attachment; name=\"payload\"\r\n", + "Content-Type: application/octet-stream\r\n\r\n", + "abc\r\n", + "--test-boundary--\r\n" + ); + #[test] fn parses_drop_point_pickup_shape() { - let content_type = "multipart/mixed; boundary=test-boundary"; - let body = concat!( - "--test-boundary\r\n", - "Content-Disposition: attachment; name=\"envelope\"\r\n", - "Content-Type: application/json\r\n\r\n", - "{}\r\n", - "--test-boundary\r\n", - "Content-Disposition: attachment; name=\"payload\"\r\n", - "Content-Type: application/octet-stream\r\n\r\n", - "abc\r\n", - "--test-boundary--\r\n" - ); - - let (envelope, payload) = parse_pickup_multipart(content_type, body.as_bytes()).unwrap(); + let (envelope, payload) = parse_pickup_multipart(CONTENT_TYPE, BODY.as_bytes(), 3).unwrap(); assert_eq!(envelope, b"{}"); assert_eq!(payload, b"abc"); } + + #[test] + fn rejects_truncated_closing_boundary() { + let truncated = BODY.trim_end_matches("--test-boundary--\r\n"); + assert!(matches!( + parse_pickup_multipart(CONTENT_TYPE, truncated.as_bytes(), 3), + Err(MultipartError::Truncated) + )); + } + + #[test] + fn rejects_duplicate_and_additional_parts() { + let duplicate = BODY.replace( + "--test-boundary--\r\n", + concat!( + "--test-boundary\r\n", + "Content-Disposition: attachment; name=\"payload\"\r\n", + "Content-Type: application/octet-stream\r\n\r\n", + "x\r\n", + "--test-boundary--\r\n" + ), + ); + assert!(matches!( + parse_pickup_multipart(CONTENT_TYPE, duplicate.as_bytes(), 3), + Err(MultipartError::DuplicatePart(PAYLOAD_PART)) + )); + + let additional = BODY.replace("name=\"payload\"", "name=\"other\""); + assert!(matches!( + parse_pickup_multipart(CONTENT_TYPE, additional.as_bytes(), 3), + Err(MultipartError::UnexpectedPart) + )); + } + + #[test] + fn rejects_duplicate_or_missing_required_part_headers() { + let duplicate_name = + BODY.replace("name=\"envelope\"", "name=\"envelope\"; name=\"payload\""); + assert!(matches!( + parse_pickup_multipart(CONTENT_TYPE, duplicate_name.as_bytes(), 3), + Err(MultipartError::InvalidPartHeaders) + )); + + let duplicate_content_type = BODY.replacen( + "Content-Type: application/json\r\n", + "Content-Type: application/json\r\nContent-Type: application/json\r\n", + 1, + ); + assert!(matches!( + parse_pickup_multipart(CONTENT_TYPE, duplicate_content_type.as_bytes(), 3), + Err(MultipartError::InvalidPartHeaders) + )); + } + + #[test] + fn enforces_persisted_payload_bound() { + assert!(matches!( + parse_pickup_multipart(CONTENT_TYPE, BODY.as_bytes(), 2), + Err(MultipartError::PayloadTooLarge) + )); + } + + #[test] + fn rejects_content_type_header_injection_and_missing_boundary() { + assert!(matches!( + parse_pickup_multipart("multipart/mixed\r\nX-Evil: yes", BODY.as_bytes(), 3), + Err(MultipartError::InvalidContentType) + )); + assert!(matches!( + parse_pickup_multipart("multipart/mixed", BODY.as_bytes(), 3), + Err(MultipartError::InvalidBoundary) + )); + assert!(matches!( + parse_pickup_multipart( + "multipart/mixed; boundary=test-boundary; boundary=other", + BODY.as_bytes(), + 3 + ), + Err(MultipartError::InvalidBoundary) + )); + } } diff --git a/src-tauri/src/drop_point/secure_fs.rs b/src-tauri/src/drop_point/secure_fs.rs new file mode 100644 index 0000000..9f8b3ae --- /dev/null +++ b/src-tauri/src/drop_point/secure_fs.rs @@ -0,0 +1,247 @@ +use std::io::Write; +use std::path::Path; + +pub fn ensure_private_directory(path: &Path) -> Result<(), String> { + let mut missing = Vec::new(); + let mut current = path; + loop { + match std::fs::symlink_metadata(current) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("receiver-controlled path is not a directory".to_string()); + } + break; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + missing.push(current.to_path_buf()); + current = current.parent().ok_or_else(|| { + "receiver-controlled directory has no existing parent".to_string() + })?; + } + Err(error) => return Err(error.to_string()), + } + } + + for directory in missing.into_iter().rev() { + create_private_directory(&directory)?; + let parent = directory + .parent() + .ok_or_else(|| "receiver-controlled directory has no parent".to_string())?; + sync_dir(parent).map_err(|error| error.to_string())?; + } + set_private_directory_permissions(path)?; + verify_private_directory(path) +} + +pub fn create_private_directory(path: &Path) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .mode(0o700) + .create(path) + .map_err(|error| error.to_string())?; + } + #[cfg(not(unix))] + std::fs::create_dir(path).map_err(|error| error.to_string())?; + + set_private_directory_permissions(path) +} + +pub fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), String> { + let mut file = open_private_file_new(path)?; + let result = file + .write_all(bytes) + .and_then(|()| file.flush()) + .and_then(|()| file.sync_all()); + drop(file); + result.map_err(|error| error.to_string()) +} + +pub fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "private state path has no parent directory".to_string())?; + ensure_private_directory(parent)?; + let temporary = parent.join(format!( + ".{}.{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("state"), + uuid::Uuid::new_v4() + )); + + let write_result = write_private_file(&temporary, bytes) + .and_then(|()| atomic_replace(&temporary, path)) + .and_then(|()| set_private_file_permissions(path)) + .and_then(|()| sync_dir(parent).map_err(|error| error.to_string())); + if write_result.is_err() { + match std::fs::remove_file(&temporary) { + Ok(()) => { + let _ = sync_dir(parent); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => {} + } + } + write_result +} + +pub fn verify_private_directory(path: &Path) -> Result<(), String> { + let metadata = std::fs::symlink_metadata(path).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("receiver-controlled bundle path is not a directory".to_string()); + } + verify_owner_only(&metadata, "receiver-controlled directory") +} + +pub fn verify_private_regular_file(path: &Path) -> Result { + let metadata = std::fs::symlink_metadata(path).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("receiver-controlled bundle entry is not a regular file".to_string()); + } + verify_owner_only(&metadata, "receiver-controlled file")?; + Ok(metadata) +} + +fn open_private_file_new(path: &Path) -> Result { + let mut options = std::fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(path).map_err(|error| error.to_string())?; + set_private_file_permissions(path)?; + Ok(file) +} + +#[cfg(unix)] +fn set_private_directory_permissions(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| error.to_string()) +} + +#[cfg(not(unix))] +fn set_private_directory_permissions(_path: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(unix)] +fn set_private_file_permissions(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| error.to_string()) +} + +#[cfg(not(unix))] +fn set_private_file_permissions(_path: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(unix)] +#[expect( + clippy::verbose_bit_mask, + reason = "permission masks are clearer in conventional octal notation" +)] +fn verify_owner_only(metadata: &std::fs::Metadata, label: &str) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 == 0 { + Ok(()) + } else { + Err(format!("{label} is not owner-only")) + } +} + +#[cfg(not(unix))] +fn verify_owner_only(_metadata: &std::fs::Metadata, _label: &str) -> Result<(), String> { + Ok(()) +} + +#[cfg(not(windows))] +fn atomic_replace(source: &Path, destination: &Path) -> Result<(), String> { + std::fs::rename(source, destination).map_err(|error| error.to_string()) +} + +#[cfg(windows)] +fn atomic_replace(source: &Path, destination: &Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let source_wide = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination_wide = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: both paths are encoded as valid, NUL-terminated UTF-16 buffers + // and remain alive for the duration of the call. + let result = unsafe { + MoveFileExW( + source_wide.as_ptr(), + destination_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + Err(std::io::Error::last_os_error().to_string()) + } else { + Ok(()) + } +} + +#[cfg(not(windows))] +pub fn sync_dir(path: &Path) -> Result<(), std::io::Error> { + std::fs::File::open(path)?.sync_all() +} + +#[cfg(windows)] +pub fn sync_dir(path: &Path) -> Result<(), std::io::Error> { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + + std::fs::OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path)? + .sync_all() +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "unwrap is acceptable in tests")] +mod tests { + use super::*; + + #[test] + fn atomically_writes_owner_only_state() { + let temporary = tempfile::tempdir().unwrap(); + let directory = temporary.path().join("state"); + let path = directory.join("session.json"); + atomic_write_private(&path, b"first").unwrap(); + atomic_write_private(&path, b"second").unwrap(); + assert_eq!(std::fs::read(path).unwrap(), b"second"); + verify_private_directory(&directory).unwrap(); + } + + #[cfg(unix)] + #[test] + fn rejects_a_symlink_at_the_controlled_directory_boundary() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().unwrap(); + let target = temporary.path().join("target"); + std::fs::create_dir(&target).unwrap(); + let controlled = temporary.path().join("controlled"); + symlink(target, &controlled).unwrap(); + assert!(ensure_private_directory(&controlled).is_err()); + } +} diff --git a/src-tauri/src/drop_point/session.rs b/src-tauri/src/drop_point/session.rs index 9036743..76092e7 100644 --- a/src-tauri/src/drop_point/session.rs +++ b/src-tauri/src/drop_point/session.rs @@ -1,261 +1,759 @@ -use std::collections::HashMap; -use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::Mutex; use chrono::{DateTime, Utc}; use procnote_core::event::types::ExecutionId; use serde::{Deserialize, Serialize}; +use url::{Url, form_urlencoded}; +use x25519_dalek::{PublicKey, StaticSecret}; use zeroize::Zeroizing; -use crate::drop_point::client::DropPointClient; +use crate::drop_point::client::{RemoteTerminal, parse_base_url, same_origin}; +use crate::drop_point::crypto::decode_base64url; +use crate::drop_point::secure_fs::{ + atomic_write_private, ensure_private_directory, verify_private_regular_file, +}; -const SESSION_DIR: &str = ".drop_point_sessions"; +const SESSION_STATE_VERSION: u32 = 1; +const MAX_PROTOCOL_BYTES: u64 = 1 << 40; -#[derive(Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct InstalledBundleState { + pub identity: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CompletionOutcome { + ClosedSuccessfully, + RemoteAlreadyClosed, + Expired, + Failed, + NotFound, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "phase", rename_all = "snake_case", deny_unknown_fields)] +pub enum SessionPhase { + Waiting, + BundleInstalled { + bundle: InstalledBundleState, + }, + ClosePending { + bundle: InstalledBundleState, + }, + Complete { + bundle: Option, + outcome: CompletionOutcome, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ActiveDropPointSession { + version: u32, pub session_id: String, + pub base_url: String, pub drop_point_id: String, - pub pickup_token: String, - pub recipient_private_key: Arc>, + pub display_name: String, + pickup_token: Option>, + recipient_private_key: Option>, + pub recipient_public_key: String, + drop_link: Option>, + drop_link_with_fragment: Option>, pub execution_id: ExecutionId, pub step_id: String, pub input_id: String, pub expires_at: DateTime, + pub max_bytes: u64, pub execution_dir: PathBuf, + pub workspace_root: PathBuf, + pub phase: SessionPhase, } -#[derive(Debug, Serialize, Deserialize)] -struct PersistedDropPointSession { - session_id: String, - drop_point_id: String, - pickup_token: String, - expires_at: DateTime, +pub struct NewDropPointSession { + pub session_id: String, + pub base_url: String, + pub drop_point_id: String, + pub display_name: String, + pub pickup_token: Zeroizing, + pub recipient_private_key: Zeroizing, + pub recipient_public_key: String, + pub drop_link: Zeroizing, + pub drop_link_with_fragment: Zeroizing, + pub execution_id: ExecutionId, + pub step_id: String, + pub input_id: String, + pub expires_at: DateTime, + pub max_bytes: u64, + pub execution_dir: PathBuf, + pub workspace_root: PathBuf, } -#[derive(Default)] -pub struct DropPointSessions { - sessions: Mutex>, -} +impl ActiveDropPointSession { + #[must_use] + pub fn new(value: NewDropPointSession) -> Self { + Self { + version: SESSION_STATE_VERSION, + session_id: value.session_id, + base_url: value.base_url, + drop_point_id: value.drop_point_id, + display_name: value.display_name, + pickup_token: Some(value.pickup_token), + recipient_private_key: Some(value.recipient_private_key), + recipient_public_key: value.recipient_public_key, + drop_link: Some(value.drop_link), + drop_link_with_fragment: Some(value.drop_link_with_fragment), + execution_id: value.execution_id, + step_id: value.step_id, + input_id: value.input_id, + expires_at: value.expires_at, + max_bytes: value.max_bytes, + execution_dir: value.execution_dir, + workspace_root: value.workspace_root, + phase: SessionPhase::Waiting, + } + } -impl DropPointSessions { - pub fn insert(&self, session: ActiveDropPointSession) -> Result<(), String> { - persist_session(&session)?; - self.sessions - .lock() - .map_err(|e| e.to_string())? - .insert(session.session_id.clone(), session); - Ok(()) + pub fn pickup_token(&self) -> Result<&str, String> { + self.pickup_token + .as_deref() + .map(String::as_str) + .ok_or_else(|| "DropPoint session no longer has a pickup capability".to_string()) } - pub fn get(&self, session_id: &str) -> Result { - let (session, expired) = { - let mut sessions = self.sessions.lock().map_err(|e| e.to_string())?; - let session = sessions - .get(session_id) - .cloned() - .ok_or_else(|| format!("DropPoint session not found: {session_id}"))?; - let expired = session.expires_at <= Utc::now(); - if expired { - sessions.remove(session_id); - } - drop(sessions); - (session, expired) - }; + pub fn recipient_private_key(&self) -> Result, String> { + let encoded = self + .recipient_private_key + .as_deref() + .ok_or_else(|| "DropPoint session no longer has a recipient private key".to_string())?; + decode_key(encoded, "recipient private key") + } + + pub fn drop_link_with_fragment(&self) -> Result<&str, String> { + self.drop_link_with_fragment + .as_deref() + .map(String::as_str) + .ok_or_else(|| "DropPoint session no longer has a sender link".to_string()) + } - if expired { - delete_persisted_session(&session)?; - return Err(format!("DropPoint session expired: {session_id}")); + #[must_use] + pub const fn installed_bundle(&self) -> Option<&InstalledBundleState> { + match &self.phase { + SessionPhase::BundleInstalled { bundle } + | SessionPhase::ClosePending { bundle } + | SessionPhase::Complete { + bundle: Some(bundle), + .. + } => Some(bundle), + SessionPhase::Waiting | SessionPhase::Complete { bundle: None, .. } => None, } - Ok(session) } - pub fn take(&self, session_id: &str) -> Result { - let session = { - let mut sessions = self.sessions.lock().map_err(|e| e.to_string())?; - sessions - .remove(session_id) - .ok_or_else(|| format!("DropPoint session not found: {session_id}"))? + #[must_use] + pub fn with_bundle_installed(&self, bundle: InstalledBundleState) -> Self { + let mut updated = self.clone(); + updated.phase = SessionPhase::BundleInstalled { bundle }; + updated + } + + pub fn with_close_pending(&self) -> Result { + let bundle = self.installed_bundle().cloned().ok_or_else(|| { + "cannot close DropPoint before durable bundle installation".to_string() + })?; + let mut updated = self.clone(); + updated.phase = SessionPhase::ClosePending { bundle }; + Ok(updated) + } + + #[must_use] + pub fn with_complete(&self, outcome: CompletionOutcome) -> Self { + let mut updated = self.clone(); + updated.phase = SessionPhase::Complete { + bundle: self.installed_bundle().cloned(), + outcome, }; + updated.pickup_token = None; + updated.recipient_private_key = None; + updated.drop_link = None; + updated.drop_link_with_fragment = None; + updated + } - if session.expires_at <= Utc::now() { - delete_persisted_session(&session)?; - return Err(format!("DropPoint session expired: {session_id}")); + #[must_use] + pub const fn is_resumable(&self) -> bool { + !matches!(self.phase, SessionPhase::Complete { .. }) + } + + fn validate(&self, expected_workspace_root: &Path) -> Result<(), String> { + if self.version != SESSION_STATE_VERSION { + return Err("unsupported DropPoint private-state version".to_string()); } - Ok(session) + validate_session_id(&self.session_id)?; + let base_url = parse_base_url(&self.base_url) + .map_err(|_| "DropPoint private state has an invalid relay origin".to_string())?; + validate_prefixed_value(&self.drop_point_id, "dp_")?; + validate_key(&self.recipient_public_key, "recipient public key")?; + if self.max_bytes == 0 || self.max_bytes > MAX_PROTOCOL_BYTES { + return Err("DropPoint private state has an invalid max_bytes".to_string()); + } + if self.step_id.is_empty() || self.input_id.is_empty() { + return Err("DropPoint private state has an invalid attachment target".to_string()); + } + if self.workspace_root != expected_workspace_root + || !self.execution_dir.starts_with(expected_workspace_root) + { + return Err("DropPoint private state belongs to another workspace".to_string()); + } + + match &self.phase { + SessionPhase::Waiting + | SessionPhase::BundleInstalled { .. } + | SessionPhase::ClosePending { .. } => { + let pickup = self.pickup_token.as_deref().ok_or_else(|| { + "resumable DropPoint private state is missing its pickup capability".to_string() + })?; + validate_prefixed_value(pickup, "pick_")?; + let private = self.recipient_private_key()?; + let expected_public = PublicKey::from(&StaticSecret::from(*private)).to_bytes(); + let public = decode_key(&self.recipient_public_key, "recipient public key")?; + if *public != expected_public { + return Err( + "DropPoint private-state public and private keys do not match".to_string(), + ); + } + if self.drop_link.is_none() || self.drop_link_with_fragment.is_none() { + return Err( + "resumable DropPoint private state is missing its sender link".to_string(), + ); + } + validate_sender_links(self, &base_url)?; + } + SessionPhase::Complete { .. } => { + if self.pickup_token.is_some() + || self.recipient_private_key.is_some() + || self.drop_link.is_some() + || self.drop_link_with_fragment.is_some() + { + return Err( + "completed DropPoint private state still contains capabilities".to_string(), + ); + } + } + } + + if let Some(bundle) = self.installed_bundle() { + validate_identity(&bundle.identity)?; + let expected_path = self + .execution_dir + .join("attachments") + .join(format!("bundle-{}", self.drop_point_id)); + if !bundle.path.is_absolute() || bundle.path != expected_path { + return Err( + "installed DropPoint bundle path is outside its deterministic destination" + .to_string(), + ); + } + } + Ok(()) + } +} + +pub struct DropPointSessions { + state_root: PathBuf, + workspace_root: PathBuf, + io_lock: Mutex<()>, +} + +impl DropPointSessions { + pub fn new(state_root: PathBuf, workspace_root: &Path) -> Result { + let workspace_root = workspace_root + .canonicalize() + .map_err(|error| format!("failed to canonicalize workspace root: {error}"))?; + ensure_private_directory(&state_root)?; + Ok(Self { + state_root, + workspace_root, + io_lock: Mutex::new(()), + }) + } + + pub fn insert(&self, session: &ActiveDropPointSession) -> Result<(), String> { + session.validate(&self.workspace_root)?; + let _guard = self.io_lock.lock().map_err(|error| error.to_string())?; + let path = self.session_path(&session.session_id)?; + match std::fs::symlink_metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => return Err("DropPoint private state already exists".to_string()), + Err(error) => return Err(error.to_string()), + } + persist_session_file(&path, session) + } + + pub fn persist(&self, session: &ActiveDropPointSession) -> Result<(), String> { + session.validate(&self.workspace_root)?; + let _guard = self.io_lock.lock().map_err(|error| error.to_string())?; + let path = self.session_path(&session.session_id)?; + let current = self.read_session(&session.session_id)?; + merge_transition(¤t, session)? + .map_or(Ok(()), |merged| persist_session_file(&path, &merged)) + } + + pub fn get(&self, session_id: &str) -> Result { + let _guard = self.io_lock.lock().map_err(|error| error.to_string())?; + self.read_session(session_id) } - pub fn remove_for_target( + pub fn has_resumable_sessions(&self) -> Result { + let _guard = self.io_lock.lock().map_err(|error| error.to_string())?; + Ok(self + .session_ids()? + .into_iter() + .filter_map(|session_id| self.read_session(&session_id).ok()) + .any(|session| session.is_resumable())) + } + + pub fn find_for_target( &self, execution_id: ExecutionId, step_id: &str, input_id: &str, ) -> Result, String> { - let session = { - let mut sessions = self.sessions.lock().map_err(|e| e.to_string())?; - let session_id = sessions.iter().find_map(|(session_id, session)| { - (session.execution_id == execution_id - && session.step_id == step_id - && session.input_id == input_id) - .then(|| session_id.clone()) - }); - let session = session_id.and_then(|session_id| sessions.remove(&session_id)); - drop(sessions); - session - }; + let _guard = self.io_lock.lock().map_err(|error| error.to_string())?; + let matches = self + .session_ids()? + .into_iter() + .map(|session_id| self.read_session(&session_id)) + .filter_map(|result| match result { + Ok(session) => Some(Ok(session)), + Err(error) => { + log::warn!("ignored invalid DropPoint private-state file: {error}"); + None + } + }) + .filter(|result| { + result.as_ref().is_ok_and(|session| { + session.execution_id == execution_id + && session.step_id == step_id + && session.input_id == input_id + && session.is_resumable() + }) + }) + .collect::, String>>()?; + match matches.as_slice() { + [] => Ok(None), + [session] => Ok(Some(session.clone())), + _ => Err( + "multiple resumable DropPoint sessions target this attachment input".to_string(), + ), + } + } + + fn read_session(&self, session_id: &str) -> Result { + let path = self.session_path(session_id)?; + verify_private_regular_file(&path)?; + let bytes = Zeroizing::new(std::fs::read(path).map_err(|error| error.to_string())?); + let session: ActiveDropPointSession = serde_json::from_slice(&bytes) + .map_err(|_| "DropPoint private-state JSON is invalid".to_string())?; + session.validate(&self.workspace_root)?; Ok(session) } + + fn session_ids(&self) -> Result, String> { + Ok(std::fs::read_dir(&self.state_root) + .map_err(|error| error.to_string())? + .filter_map(|entry| match entry { + Ok(entry) => Some(entry), + Err(error) => { + log::warn!("failed to inspect DropPoint private-state entry: {error}"); + None + } + }) + .filter_map(|entry| { + let path = entry.path(); + (path.extension().and_then(|extension| extension.to_str()) == Some("json")) + .then(|| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(str::to_string) + }) + .flatten() + }) + .collect()) + } + + fn session_path(&self, session_id: &str) -> Result { + validate_session_id(session_id)?; + Ok(self.state_root.join(format!("{session_id}.json"))) + } } -impl ActiveDropPointSession { - pub fn delete_persisted(&self) -> Result<(), String> { - delete_persisted_session(self) +fn merge_transition( + current: &ActiveDropPointSession, + proposed: &ActiveDropPointSession, +) -> Result, String> { + if !same_session_identity(current, proposed) { + return Err("refusing to replace different DropPoint private state".to_string()); + } + let current_bundle = current.installed_bundle().cloned(); + let proposed_bundle = proposed.installed_bundle().cloned(); + if current_bundle.is_some() && proposed_bundle.is_some() && current_bundle != proposed_bundle { + return Err("DropPoint private-state bundle identity conflict".to_string()); + } + + if matches!(current.phase, SessionPhase::Complete { .. }) { + return match (current_bundle, proposed_bundle) { + (None, Some(bundle)) => { + let mut merged = current.clone(); + if let SessionPhase::Complete { + bundle: current_bundle, + .. + } = &mut merged.phase + { + *current_bundle = Some(bundle); + } + Ok(Some(merged)) + } + _ => Ok(None), + }; + } + + let mut merged = proposed.clone(); + if let ( + Some(bundle), + SessionPhase::Complete { + bundle: proposed_bundle, + .. + }, + ) = (current_bundle, &mut merged.phase) + && proposed_bundle.is_none() + { + *proposed_bundle = Some(bundle); + } + if phase_rank(&merged.phase) < phase_rank(¤t.phase) { + Ok(None) + } else { + Ok(Some(merged)) } } -pub async fn cleanup_persisted_sessions(procedures_dir: &Path, client: &DropPointClient) { - let sessions = match persisted_sessions(procedures_dir) { - Ok(sessions) => sessions, - Err(e) => { - log::warn!("failed to scan persisted DropPoint sessions: {e}"); - return; - } - }; +fn same_session_identity(left: &ActiveDropPointSession, right: &ActiveDropPointSession) -> bool { + left.version == right.version + && left.session_id == right.session_id + && left.base_url == right.base_url + && left.drop_point_id == right.drop_point_id + && left.display_name == right.display_name + && left.recipient_public_key == right.recipient_public_key + && left.execution_id == right.execution_id + && left.step_id == right.step_id + && left.input_id == right.input_id + && left.expires_at == right.expires_at + && left.max_bytes == right.max_bytes + && left.execution_dir == right.execution_dir + && left.workspace_root == right.workspace_root +} - for persisted in sessions { - if persisted.expires_at > Utc::now() - && let Err(e) = client - .close(&persisted.drop_point_id, &persisted.pickup_token) - .await - { - log::warn!( - "failed to close persisted DropPoint session {} during startup cleanup: {e}", - persisted.session_id - ); - continue; - } - if let Err(e) = std::fs::remove_file(&persisted.path) { - log::warn!( - "failed to remove persisted DropPoint session {}: {e}", - persisted.path.display() - ); - } +const fn phase_rank(phase: &SessionPhase) -> u8 { + match phase { + SessionPhase::Waiting => 0, + SessionPhase::BundleInstalled { .. } => 1, + SessionPhase::ClosePending { .. } => 2, + SessionPhase::Complete { .. } => 3, } } -struct PersistedSessionFile { - path: PathBuf, - session_id: String, - drop_point_id: String, - pickup_token: String, - expires_at: DateTime, +fn persist_session_file(path: &Path, session: &ActiveDropPointSession) -> Result<(), String> { + let mut bytes = Zeroizing::new( + serde_json::to_vec_pretty(session) + .map_err(|_| "failed to encode DropPoint private state")?, + ); + bytes.push(b'\n'); + atomic_write_private(path, &bytes) } -fn persisted_sessions(procedures_dir: &Path) -> Result, String> { - if !procedures_dir.exists() { - return Ok(Vec::new()); +fn validate_sender_links(session: &ActiveDropPointSession, base_url: &Url) -> Result<(), String> { + let drop_link = Url::parse( + session + .drop_link + .as_deref() + .ok_or_else(|| "DropPoint private state is missing its sender link".to_string())?, + ) + .map_err(|_| "DropPoint private state has an invalid sender link".to_string())?; + let segments = drop_link + .path_segments() + .map(Iterator::collect::>) + .unwrap_or_default(); + if !same_origin(base_url, &drop_link) + || !drop_link.username().is_empty() + || drop_link.password().is_some() + || drop_link.query().is_some() + || drop_link.fragment().is_some() + || segments.len() != 2 + || segments.first().copied() != Some("drop") + || segments + .get(1) + .is_none_or(|token| validate_prefixed_value(token, "drop_").is_err()) + { + return Err("DropPoint private state has an invalid sender link".to_string()); } - let mut sessions = Vec::new(); - for proc_entry in std::fs::read_dir(procedures_dir).map_err(|e| e.to_string())? { - let Ok(proc_entry) = proc_entry else { - continue; - }; - let exec_base = proc_entry.path().join(".executions"); - let Ok(exec_entries) = std::fs::read_dir(exec_base) else { - continue; - }; - for exec_entry in exec_entries.flatten() { - let session_dir = exec_entry.path().join(SESSION_DIR); - let Ok(session_entries) = std::fs::read_dir(session_dir) else { - continue; - }; - for session_entry in session_entries.flatten() { - let path = session_entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("json") { - continue; - } - match read_persisted_session(&path) { - Ok(session) => sessions.push(PersistedSessionFile { - path, - session_id: session.session_id, - drop_point_id: session.drop_point_id, - pickup_token: session.pickup_token, - expires_at: session.expires_at, - }), - Err(e) => log::warn!( - "failed to read persisted DropPoint session {}: {e}", - path.display() - ), - } - } - } + let mut full_link = + Url::parse(session.drop_link_with_fragment.as_deref().ok_or_else(|| { + "DropPoint private state is missing its full sender link".to_string() + })?) + .map_err(|_| "DropPoint private state has an invalid full sender link".to_string())?; + let fragment = full_link + .fragment() + .ok_or_else(|| "DropPoint private state sender link has no key fragment".to_string())?; + let pairs = form_urlencoded::parse(fragment.as_bytes()).collect::>(); + let valid_fragment = matches!( + pairs.as_slice(), + [(version_key, version), (public_key, public), (expiry_key, expiry)] + if version_key == "v" + && version == "2" + && public_key == "pk" + && public == &session.recipient_public_key + && expiry_key == "exp" + && DateTime::parse_from_rfc3339(expiry) + .is_ok_and(|value| value.with_timezone(&Utc) == session.expires_at) + ); + full_link.set_fragment(None); + if !valid_fragment || full_link != drop_link { + return Err("DropPoint private state sender link fragment is invalid".to_string()); } - Ok(sessions) + Ok(()) } -fn persist_session(session: &ActiveDropPointSession) -> Result<(), String> { - let session_dir = session.execution_dir.join(SESSION_DIR); - std::fs::create_dir_all(&session_dir).map_err(|e| e.to_string())?; - let path = session_path(&session.execution_dir, &session.session_id); - let temp_path = path.with_extension(format!("json.tmp-{}", uuid::Uuid::new_v4())); - let persisted = PersistedDropPointSession { - session_id: session.session_id.clone(), - drop_point_id: session.drop_point_id.clone(), - pickup_token: session.pickup_token.clone(), - expires_at: session.expires_at, - }; - let bytes = serde_json::to_vec(&persisted).map_err(|e| e.to_string())?; - let mut file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&temp_path) - .map_err(|e| e.to_string())?; - file.write_all(&bytes).map_err(|e| e.to_string())?; - file.write_all(b"\n").map_err(|e| e.to_string())?; - file.flush().map_err(|e| e.to_string())?; - file.sync_all().map_err(|e| e.to_string())?; - std::fs::rename(&temp_path, &path).map_err(|e| e.to_string())?; - sync_dir(&session_dir).map_err(|e| e.to_string())?; - Ok(()) +fn decode_key(value: &str, label: &str) -> Result, String> { + let bytes = decode_base64url(value).map_err(|_| format!("invalid {label} encoding"))?; + let key = + <[u8; 32]>::try_from(bytes).map_err(|_| format!("{label} must contain 32 raw bytes"))?; + Ok(Zeroizing::new(key)) } -fn read_persisted_session(path: &Path) -> Result { - let source = std::fs::read_to_string(path).map_err(|e| e.to_string())?; - serde_json::from_str(&source).map_err(|e| e.to_string()) +fn validate_key(value: &str, label: &str) -> Result<(), String> { + decode_key(value, label).map(|_| ()) } -fn delete_persisted_session(session: &ActiveDropPointSession) -> Result<(), String> { - let path = session_path(&session.execution_dir, &session.session_id); - match std::fs::remove_file(&path) { - Ok(()) => { - sync_dir(path.parent().unwrap_or(&session.execution_dir)).map_err(|e| e.to_string()) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(e.to_string()), +fn validate_session_id(value: &str) -> Result<(), String> { + let parsed = + uuid::Uuid::parse_str(value).map_err(|_| "DropPoint session ID is invalid".to_string())?; + if parsed.to_string() == value { + Ok(()) + } else { + Err("DropPoint session ID is not canonical".to_string()) + } +} + +fn validate_prefixed_value(value: &str, prefix: &str) -> Result<(), String> { + if value.strip_prefix(prefix).is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + }) { + Ok(()) + } else { + Err("DropPoint private state contains an invalid capability or ID".to_string()) } } -fn session_path(execution_dir: &Path, session_id: &str) -> PathBuf { - execution_dir - .join(SESSION_DIR) - .join(format!("{session_id}.json")) +fn validate_identity(value: &str) -> Result<(), String> { + if value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + Ok(()) + } else { + Err("DropPoint private state contains an invalid bundle identity".to_string()) + } } -#[cfg(not(windows))] -fn sync_dir(path: &Path) -> Result<(), std::io::Error> { - std::fs::File::open(path)?.sync_all() +impl From for CompletionOutcome { + fn from(value: RemoteTerminal) -> Self { + match value { + RemoteTerminal::Closed => Self::RemoteAlreadyClosed, + RemoteTerminal::Expired => Self::Expired, + RemoteTerminal::Failed => Self::Failed, + RemoteTerminal::NotFound => Self::NotFound, + } + } } -#[cfg(windows)] -fn sync_dir(path: &Path) -> Result<(), std::io::Error> { - use std::os::windows::fs::OpenOptionsExt; +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "unwrap is acceptable in tests")] +mod tests { + use crate::drop_point::crypto::{encode_base64url, generate_recipient_key_pair}; + + use super::*; + + fn session(workspace: &Path) -> ActiveDropPointSession { + let workspace = workspace.canonicalize().unwrap(); + let (private, public) = generate_recipient_key_pair(); + let public = encode_base64url(&public); + let expires_at = Utc::now() + chrono::Duration::minutes(10); + let fragment = form_urlencoded::Serializer::new(String::new()) + .append_pair("v", "2") + .append_pair("pk", &public) + .append_pair( + "exp", + &expires_at.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true), + ) + .finish(); + let execution_dir = workspace.join("procedure/.executions/execution"); + std::fs::create_dir_all(&execution_dir).unwrap(); + ActiveDropPointSession::new(NewDropPointSession { + session_id: uuid::Uuid::new_v4().to_string(), + base_url: "https://drop.example.com".to_string(), + drop_point_id: "dp_example".to_string(), + display_name: "calm-otter".to_string(), + pickup_token: Zeroizing::new("pick_example".to_string()), + recipient_private_key: Zeroizing::new(encode_base64url(&*private)), + recipient_public_key: public, + drop_link: Zeroizing::new("https://drop.example.com/drop/drop_example".to_string()), + drop_link_with_fragment: Zeroizing::new(format!( + "https://drop.example.com/drop/drop_example#{fragment}" + )), + execution_id: ExecutionId::new_v4(), + step_id: "step".to_string(), + input_id: "input".to_string(), + expires_at, + max_bytes: 1024, + execution_dir, + workspace_root: workspace, + }) + } + + #[test] + fn private_state_survives_restart_and_is_owner_only() { + let temporary = tempfile::tempdir().unwrap(); + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let state_root = temporary.path().join("private/drop-point-sessions"); + let sessions = DropPointSessions::new(state_root.clone(), &workspace).unwrap(); + let session = session(&workspace); + sessions.insert(&session).unwrap(); + drop(sessions); - const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + let restarted = DropPointSessions::new(state_root, &workspace).unwrap(); + assert!(restarted.has_resumable_sessions().unwrap()); + let loaded = restarted.get(&session.session_id).unwrap(); + assert_eq!(loaded.drop_point_id, session.drop_point_id); + assert_eq!( + *loaded.recipient_private_key().unwrap(), + *session.recipient_private_key().unwrap() + ); + } + + #[test] + fn close_pending_is_resumable_without_reinstalling() { + let temporary = tempfile::tempdir().unwrap(); + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let state_root = temporary.path().join("private/drop-point-sessions"); + let sessions = DropPointSessions::new(state_root.clone(), &workspace).unwrap(); + let original = session(&workspace); + sessions.insert(&original).unwrap(); + let bundle = InstalledBundleState { + identity: "a".repeat(64), + path: original.execution_dir.join("attachments/bundle-dp_example"), + }; + let close_pending = original + .with_bundle_installed(bundle.clone()) + .with_close_pending() + .unwrap(); + sessions.persist(&close_pending).unwrap(); + drop(sessions); + + let restarted = DropPointSessions::new(state_root, &workspace).unwrap(); + let loaded = restarted.get(&original.session_id).unwrap(); + assert!(matches!( + loaded.phase, + SessionPhase::ClosePending { bundle: ref loaded_bundle } if *loaded_bundle == bundle + )); + assert!(loaded.recipient_private_key().is_ok()); + } + + #[test] + fn terminal_transition_is_durable_and_scrubs_capabilities() { + let temporary = tempfile::tempdir().unwrap(); + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let state_root = temporary.path().join("private/drop-point-sessions"); + let sessions = DropPointSessions::new(state_root.clone(), &workspace).unwrap(); + let original = session(&workspace); + let pickup_token = original.pickup_token().unwrap().to_string(); + let private_key = original.recipient_private_key.as_deref().unwrap().clone(); + let sender_link = original.drop_link_with_fragment().unwrap().to_string(); + sessions.insert(&original).unwrap(); + let terminal = original.with_complete(CompletionOutcome::Expired); + sessions.persist(&terminal).unwrap(); + + let loaded = sessions.get(&original.session_id).unwrap(); + assert!(loaded.pickup_token().is_err()); + assert!(loaded.recipient_private_key().is_err()); + assert!(loaded.drop_link_with_fragment().is_err()); + let persisted = + std::fs::read_to_string(state_root.join(format!("{}.json", original.session_id))) + .unwrap(); + assert!(!persisted.contains(&pickup_token)); + assert!(!persisted.contains(&private_key)); + assert!(!persisted.contains(&sender_link)); + assert!(!sessions.has_resumable_sessions().unwrap()); + } - std::fs::OpenOptions::new() - .read(true) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) - .open(path)? - .sync_all() + #[test] + fn stale_transitions_cannot_erase_installed_bundle_recovery_state() { + let temporary = tempfile::tempdir().unwrap(); + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let state_root = temporary.path().join("private/drop-point-sessions"); + let sessions = DropPointSessions::new(state_root, &workspace).unwrap(); + let waiting = session(&workspace); + sessions.insert(&waiting).unwrap(); + let bundle = InstalledBundleState { + identity: "b".repeat(64), + path: waiting.execution_dir.join("attachments/bundle-dp_example"), + }; + sessions + .persist(&waiting.with_bundle_installed(bundle.clone())) + .unwrap(); + + sessions.persist(&waiting).unwrap(); + assert_eq!( + sessions + .get(&waiting.session_id) + .unwrap() + .installed_bundle(), + Some(&bundle) + ); + + sessions + .persist(&waiting.with_complete(CompletionOutcome::Expired)) + .unwrap(); + let terminal = sessions.get(&waiting.session_id).unwrap(); + assert!(matches!(terminal.phase, SessionPhase::Complete { .. })); + assert_eq!(terminal.installed_bundle(), Some(&bundle)); + assert!(terminal.recipient_private_key().is_err()); + } + + #[cfg(unix)] + #[test] + fn state_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::tempdir().unwrap(); + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let state_root = temporary.path().join("private/drop-point-sessions"); + let sessions = DropPointSessions::new(state_root.clone(), &workspace).unwrap(); + let session = session(&workspace); + sessions.insert(&session).unwrap(); + let metadata = + std::fs::metadata(state_root.join(format!("{}.json", session.session_id))).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o077, 0); + } } diff --git a/src-tauri/src/drop_point/storage.rs b/src-tauri/src/drop_point/storage.rs new file mode 100644 index 0000000..d849624 --- /dev/null +++ b/src-tauri/src/drop_point/storage.rs @@ -0,0 +1,760 @@ +use std::collections::HashSet; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::drop_point::manifest::{ + RecoveredFile, filename_collision_key, sanitize_mime_type, validate_filename, +}; +use crate::drop_point::secure_fs::{ + create_private_directory, ensure_private_directory, sync_dir, verify_private_directory, + verify_private_regular_file, write_private_file, +}; + +const RECEIPT_NAME: &str = ".droppoint-receipt.json"; +const RECEIPT_VERSION: u32 = 1; +const IDENTITY_DOMAIN: &[u8] = b"DropPoint installed bundle v1\0"; +const MAX_RECEIPT_FILES: usize = 1000; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledBundle { + pub path: PathBuf, + pub identity: String, + pub files: Vec, + pub already_installed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledFile { + pub filename: String, + pub content_type: String, + pub size: u64, + pub sha256: String, + pub relative_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct BundleReceipt { + receipt_version: u32, + drop_point_id: String, + bundle_sha256: String, + files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct ReceiptFile { + name: String, + mime_type: String, + size: u64, + sha256: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum BundleStorageError { + #[error("invalid drop point ID for bundle installation")] + InvalidDropPointId, + #[error("invalid encrypted-bundle identity")] + InvalidIdentity, + #[error("encrypted bundle is too large to identify on this platform")] + IdentityLength, + #[error("recovered bundle is invalid: {0}")] + InvalidBundle(String), + #[error("bundle receipt is invalid: {0}")] + InvalidReceipt(String), + #[error("a different or damaged bundle already occupies the destination")] + InstallationConflict, + #[error("bundle filesystem operation failed: {0}")] + Filesystem(String), + #[error("bundle receipt JSON is invalid")] + ReceiptJson, +} + +pub fn encrypted_bundle_identity( + envelope_json: &[u8], + encrypted_payload: &[u8], +) -> Result { + let envelope_length = + u64::try_from(envelope_json.len()).map_err(|_| BundleStorageError::IdentityLength)?; + let payload_length = + u64::try_from(encrypted_payload.len()).map_err(|_| BundleStorageError::IdentityLength)?; + let mut digest = Sha256::new(); + digest.update(IDENTITY_DOMAIN); + digest.update(envelope_length.to_be_bytes()); + digest.update(envelope_json); + digest.update(payload_length.to_be_bytes()); + digest.update(encrypted_payload); + Ok(hex_encode(digest.finalize().as_ref())) +} + +pub fn install_bundle( + execution_dir: &Path, + drop_point_id: &str, + identity: &str, + recovered_files: &[RecoveredFile], +) -> Result { + validate_drop_point_id(drop_point_id)?; + validate_identity(identity)?; + let expected_receipt = build_receipt(drop_point_id, identity, recovered_files)?; + + verify_execution_directory(execution_dir)?; + let attachments_dir = execution_dir.join("attachments"); + ensure_private_directory(&attachments_dir).map_err(BundleStorageError::Filesystem)?; + let final_name = format!("bundle-{drop_point_id}"); + let _install_lock = acquire_install_lock(&attachments_dir, &final_name)?; + cleanup_stale_staging_directories(&attachments_dir, &final_name)?; + let final_path = attachments_dir.join(&final_name); + + match std::fs::symlink_metadata(&final_path) { + Ok(_) => { + verify_receipt_and_files(&final_path, &expected_receipt)?; + sync_dir(&attachments_dir) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + return receipt_to_install(&final_path, expected_receipt, true); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(BundleStorageError::Filesystem(error.to_string())), + } + + let staging_path = attachments_dir.join(format!(".{final_name}.{}.tmp", uuid::Uuid::new_v4())); + create_private_directory(&staging_path).map_err(BundleStorageError::Filesystem)?; + sync_dir(&attachments_dir) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + + let install_result = stage_bundle(&staging_path, &expected_receipt, recovered_files) + .and_then(|()| publish_directory_without_replacement(&staging_path, &final_path)) + .and_then(|published| { + if published { + sync_dir(&attachments_dir) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + Ok(false) + } else { + verify_receipt_and_files(&final_path, &expected_receipt)?; + Ok(true) + } + }); + + if install_result.is_err() || staging_path.exists() { + if let Err(error) = std::fs::remove_dir_all(&staging_path) + && error.kind() != std::io::ErrorKind::NotFound + { + log::warn!("failed to remove incomplete DropPoint bundle staging directory"); + } + let _ = sync_dir(&attachments_dir); + } + + let already_installed = install_result?; + receipt_to_install(&final_path, expected_receipt, already_installed) +} + +pub fn verify_installed_bundle( + path: &Path, + drop_point_id: &str, + identity: &str, +) -> Result { + validate_drop_point_id(drop_point_id)?; + validate_identity(identity)?; + let parent = path + .parent() + .ok_or(BundleStorageError::InstallationConflict)?; + verify_private_directory(parent).map_err(|_| BundleStorageError::InstallationConflict)?; + let receipt = load_receipt(path)?; + if receipt.drop_point_id != drop_point_id || receipt.bundle_sha256 != identity { + return Err(BundleStorageError::InstallationConflict); + } + verify_receipt_and_files(path, &receipt)?; + receipt_to_install(path, receipt, true) +} + +fn acquire_install_lock( + attachments_dir: &Path, + final_name: &str, +) -> Result { + let lock_path = attachments_dir.join(format!(".{final_name}.install.lock")); + let file = match std::fs::symlink_metadata(&lock_path) { + Ok(_) => { + verify_private_regular_file(&lock_path) + .map_err(|_| BundleStorageError::InstallationConflict)?; + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&lock_path) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))? + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut options = std::fs::OpenOptions::new(); + options.create_new(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options + .open(&lock_path) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + file.sync_all() + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + sync_dir(attachments_dir) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + file + } + Err(error) => return Err(BundleStorageError::Filesystem(error.to_string())), + }; + file.try_lock_exclusive() + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + Ok(file) +} + +#[expect( + clippy::case_sensitive_file_extension_comparisons, + reason = "only the installer's canonical lowercase .tmp names may be deleted" +)] +fn cleanup_stale_staging_directories( + attachments_dir: &Path, + final_name: &str, +) -> Result<(), BundleStorageError> { + let prefix = format!(".{final_name}."); + let entries = std::fs::read_dir(attachments_dir) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))? + .map(|entry| entry.map_err(|error| BundleStorageError::Filesystem(error.to_string()))) + .collect::, BundleStorageError>>()?; + let candidates = entries + .into_iter() + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(&prefix) && name.ends_with(".tmp")) + }) + .map(|entry| entry.path()) + .collect::>(); + if candidates.is_empty() { + return Ok(()); + } + for candidate in candidates { + let metadata = std::fs::symlink_metadata(&candidate) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(BundleStorageError::InstallationConflict); + } + std::fs::remove_dir_all(candidate) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + } + sync_dir(attachments_dir).map_err(|error| BundleStorageError::Filesystem(error.to_string())) +} + +fn verify_execution_directory(execution_dir: &Path) -> Result<(), BundleStorageError> { + let metadata = std::fs::symlink_metadata(execution_dir) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(BundleStorageError::Filesystem( + "execution destination is not a directory".to_string(), + )); + } + Ok(()) +} + +fn build_receipt( + drop_point_id: &str, + identity: &str, + recovered_files: &[RecoveredFile], +) -> Result { + if !(1..=MAX_RECEIPT_FILES).contains(&recovered_files.len()) { + return Err(BundleStorageError::InvalidBundle( + "bundle must contain between 1 and 1000 files".to_string(), + )); + } + let mut comparison_keys = HashSet::with_capacity(recovered_files.len()); + let files = recovered_files + .iter() + .map(|recovered| { + validate_filename(&recovered.filename).map_err(BundleStorageError::InvalidBundle)?; + if !comparison_keys.insert(filename_collision_key(&recovered.filename)) { + return Err(BundleStorageError::InvalidBundle( + "bundle contains colliding filenames".to_string(), + )); + } + let canonical_mime = sanitize_mime_type(&recovered.content_type) + .map_err(BundleStorageError::InvalidBundle)?; + if canonical_mime != recovered.content_type { + return Err(BundleStorageError::InvalidBundle( + "bundle contains a noncanonical MIME type".to_string(), + )); + } + let size = u64::try_from(recovered.data.len()).map_err(|_| { + BundleStorageError::InvalidBundle( + "recovered file length cannot be represented".to_string(), + ) + })?; + Ok(ReceiptFile { + name: recovered.filename.clone(), + mime_type: recovered.content_type.clone(), + size, + sha256: hex_encode(Sha256::digest(&recovered.data).as_ref()), + }) + }) + .collect::, BundleStorageError>>()?; + + Ok(BundleReceipt { + receipt_version: RECEIPT_VERSION, + drop_point_id: drop_point_id.to_string(), + bundle_sha256: identity.to_string(), + files, + }) +} + +fn stage_bundle( + staging_path: &Path, + receipt: &BundleReceipt, + recovered_files: &[RecoveredFile], +) -> Result<(), BundleStorageError> { + recovered_files.iter().try_for_each(|recovered| { + write_private_file(&staging_path.join(&recovered.filename), &recovered.data) + .map_err(BundleStorageError::Filesystem) + })?; + let mut receipt_bytes = + serde_json::to_vec_pretty(receipt).map_err(|_| BundleStorageError::ReceiptJson)?; + receipt_bytes.push(b'\n'); + write_private_file(&staging_path.join(RECEIPT_NAME), &receipt_bytes) + .map_err(BundleStorageError::Filesystem)?; + sync_dir(staging_path).map_err(|error| BundleStorageError::Filesystem(error.to_string())) +} + +fn load_receipt(path: &Path) -> Result { + verify_private_directory(path).map_err(|_| BundleStorageError::InstallationConflict)?; + let receipt_path = path.join(RECEIPT_NAME); + verify_private_regular_file(&receipt_path) + .map_err(|_| BundleStorageError::InstallationConflict)?; + let bytes = std::fs::read(receipt_path) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + let receipt: BundleReceipt = + serde_json::from_slice(&bytes).map_err(|_| BundleStorageError::ReceiptJson)?; + validate_receipt(&receipt)?; + Ok(receipt) +} + +fn validate_receipt(receipt: &BundleReceipt) -> Result<(), BundleStorageError> { + if receipt.receipt_version != RECEIPT_VERSION { + return Err(BundleStorageError::InvalidReceipt( + "unsupported receipt version".to_string(), + )); + } + validate_drop_point_id(&receipt.drop_point_id)?; + validate_identity(&receipt.bundle_sha256)?; + if !(1..=MAX_RECEIPT_FILES).contains(&receipt.files.len()) { + return Err(BundleStorageError::InvalidReceipt( + "invalid receipt file count".to_string(), + )); + } + + let mut comparison_keys = HashSet::with_capacity(receipt.files.len()); + for entry in &receipt.files { + validate_filename(&entry.name).map_err(BundleStorageError::InvalidReceipt)?; + if !comparison_keys.insert(filename_collision_key(&entry.name)) { + return Err(BundleStorageError::InvalidReceipt( + "receipt contains colliding filenames".to_string(), + )); + } + let canonical_mime = + sanitize_mime_type(&entry.mime_type).map_err(BundleStorageError::InvalidReceipt)?; + if canonical_mime != entry.mime_type || !is_lower_hex_sha256(&entry.sha256) { + return Err(BundleStorageError::InvalidReceipt( + "receipt contains invalid file metadata".to_string(), + )); + } + } + Ok(()) +} + +fn verify_receipt_and_files( + path: &Path, + expected_receipt: &BundleReceipt, +) -> Result<(), BundleStorageError> { + let actual_receipt = load_receipt(path).map_err(|error| match error { + BundleStorageError::Filesystem(_) => error, + _ => BundleStorageError::InstallationConflict, + })?; + if &actual_receipt != expected_receipt { + return Err(BundleStorageError::InstallationConflict); + } + + let expected_names = actual_receipt + .files + .iter() + .map(|entry| entry.name.clone()) + .chain(std::iter::once(RECEIPT_NAME.to_string())) + .collect::>(); + let actual_names = std::fs::read_dir(path) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))? + .map(|entry| { + entry + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))? + .file_name() + .into_string() + .map_err(|_| BundleStorageError::InstallationConflict) + }) + .collect::, BundleStorageError>>()?; + if actual_names != expected_names { + return Err(BundleStorageError::InstallationConflict); + } + + for entry in &actual_receipt.files { + let file_path = path.join(&entry.name); + let metadata = verify_private_regular_file(&file_path) + .map_err(|_| BundleStorageError::InstallationConflict)?; + if metadata.len() != entry.size || hash_file(&file_path)? != entry.sha256 { + return Err(BundleStorageError::InstallationConflict); + } + } + Ok(()) +} + +fn receipt_to_install( + path: &Path, + receipt: BundleReceipt, + already_installed: bool, +) -> Result { + let canonical_path = path + .canonicalize() + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + let bundle_directory = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + BundleStorageError::InvalidReceipt("bundle path has no UTF-8 name".to_string()) + })?; + let files = receipt + .files + .into_iter() + .map(|entry| InstalledFile { + relative_path: format!("attachments/{bundle_directory}/{}", entry.name), + filename: entry.name, + content_type: entry.mime_type, + size: entry.size, + sha256: entry.sha256, + }) + .collect(); + Ok(InstalledBundle { + path: canonical_path, + identity: receipt.bundle_sha256, + files, + already_installed, + }) +} + +fn hash_file(path: &Path) -> Result { + let mut file = std::fs::File::open(path) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + let mut digest = Sha256::new(); + let mut buffer = vec![0u8; 1024 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| BundleStorageError::Filesystem(error.to_string()))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(hex_encode(digest.finalize().as_ref())) +} + +fn publish_directory_without_replacement( + source: &Path, + destination: &Path, +) -> Result { + match rename_without_replacement(source, destination) { + Ok(()) => Ok(true), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty + ) || std::fs::symlink_metadata(destination).is_ok() => + { + Ok(false) + } + Err(error) => Err(BundleStorageError::Filesystem(error.to_string())), + } +} + +#[cfg(any( + target_os = "android", + target_os = "ios", + target_os = "linux", + target_os = "macos" +))] +fn rename_without_replacement(source: &Path, destination: &Path) -> std::io::Result<()> { + rustix::fs::renameat_with( + rustix::fs::CWD, + source, + rustix::fs::CWD, + destination, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from) +} + +#[cfg(not(any( + target_os = "android", + target_os = "ios", + target_os = "linux", + target_os = "macos" +)))] +fn rename_without_replacement(source: &Path, destination: &Path) -> std::io::Result<()> { + if std::fs::symlink_metadata(destination).is_ok() { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "bundle destination already exists", + )); + } + std::fs::rename(source, destination) +} + +fn validate_drop_point_id(value: &str) -> Result<(), BundleStorageError> { + if value + .strip_prefix("dp_") + .is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(is_capability_byte)) + { + Ok(()) + } else { + Err(BundleStorageError::InvalidDropPointId) + } +} + +fn validate_identity(value: &str) -> Result<(), BundleStorageError> { + if is_lower_hex_sha256(value) { + Ok(()) + } else { + Err(BundleStorageError::InvalidIdentity) + } +} + +fn is_lower_hex_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +const fn is_capability_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().fold( + String::with_capacity(bytes.len() * 2), + |mut output, byte| { + use std::fmt::Write as _; + write!(output, "{byte:02x}").expect("writing to a String cannot fail"); + output + }, + ) +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "unwrap is acceptable in tests")] +mod tests { + use super::*; + + fn recovered(data: &[u8]) -> Vec { + vec![RecoveredFile { + filename: "scan.txt".to_string(), + content_type: "text/plain".to_string(), + data: data.to_vec(), + }] + } + + #[test] + fn identity_binds_exact_envelope_and_payload() { + let identity = encrypted_bundle_identity(b"envelope", b"payload").unwrap(); + assert_eq!(identity.len(), 64); + assert_ne!( + identity, + encrypted_bundle_identity(b"envelope2", b"payload").unwrap() + ); + assert_ne!( + identity, + encrypted_bundle_identity(b"envelope", b"payload2").unwrap() + ); + } + + #[test] + fn installs_and_idempotently_verifies_an_owner_only_bundle() { + let temporary = tempfile::tempdir().unwrap(); + let execution_dir = temporary.path().join("execution"); + std::fs::create_dir(&execution_dir).unwrap(); + let identity = encrypted_bundle_identity(b"envelope", b"payload").unwrap(); + + let first = install_bundle( + &execution_dir, + "dp_example", + &identity, + &recovered(b"hello"), + ) + .unwrap(); + assert!(!first.already_installed); + assert_eq!( + first.files[0].relative_path, + "attachments/bundle-dp_example/scan.txt" + ); + + let second = install_bundle( + &execution_dir, + "dp_example", + &identity, + &recovered(b"hello"), + ) + .unwrap(); + assert!(second.already_installed); + assert_eq!(first.path, second.path); + verify_installed_bundle(&first.path, "dp_example", &identity).unwrap(); + } + + #[test] + fn removes_crash_leftover_staging_before_retrying_installation() { + let temporary = tempfile::tempdir().unwrap(); + let execution_dir = temporary.path().join("execution"); + let attachments_dir = execution_dir.join("attachments"); + std::fs::create_dir_all(&attachments_dir).unwrap(); + let stale = attachments_dir.join(".bundle-dp_example.crashed.tmp"); + std::fs::create_dir(&stale).unwrap(); + std::fs::write(stale.join("partial.txt"), b"partial plaintext").unwrap(); + let identity = encrypted_bundle_identity(b"envelope", b"payload").unwrap(); + + install_bundle( + &execution_dir, + "dp_example", + &identity, + &recovered(b"complete"), + ) + .unwrap(); + assert!(!stale.exists()); + } + + #[test] + fn never_overwrites_or_merges_a_conflicting_bundle() { + let temporary = tempfile::tempdir().unwrap(); + let execution_dir = temporary.path().join("execution"); + std::fs::create_dir(&execution_dir).unwrap(); + let first_identity = encrypted_bundle_identity(b"one", b"payload").unwrap(); + let first = install_bundle( + &execution_dir, + "dp_example", + &first_identity, + &recovered(b"first"), + ) + .unwrap(); + let second_identity = encrypted_bundle_identity(b"two", b"payload").unwrap(); + assert!(matches!( + install_bundle( + &execution_dir, + "dp_example", + &second_identity, + &recovered(b"second") + ), + Err(BundleStorageError::InstallationConflict) + )); + assert_eq!( + std::fs::read(first.path.join("scan.txt")).unwrap(), + b"first" + ); + } + + #[test] + fn rejects_extra_and_changed_entries() { + let temporary = tempfile::tempdir().unwrap(); + let execution_dir = temporary.path().join("execution"); + std::fs::create_dir(&execution_dir).unwrap(); + let identity = encrypted_bundle_identity(b"one", b"payload").unwrap(); + let installed = install_bundle( + &execution_dir, + "dp_example", + &identity, + &recovered(b"first"), + ) + .unwrap(); + + std::fs::write(installed.path.join("extra"), b"").unwrap(); + assert!(verify_installed_bundle(&installed.path, "dp_example", &identity).is_err()); + std::fs::remove_file(installed.path.join("extra")).unwrap(); + + std::fs::write(installed.path.join("scan.txt"), b"changed").unwrap(); + assert!(verify_installed_bundle(&installed.path, "dp_example", &identity).is_err()); + } + + #[test] + fn rejects_missing_and_non_regular_installed_files() { + let temporary = tempfile::tempdir().unwrap(); + let execution_dir = temporary.path().join("execution"); + std::fs::create_dir(&execution_dir).unwrap(); + let identity = encrypted_bundle_identity(b"one", b"payload").unwrap(); + let installed = install_bundle( + &execution_dir, + "dp_example", + &identity, + &recovered(b"first"), + ) + .unwrap(); + let file_path = installed.path.join("scan.txt"); + + std::fs::remove_file(&file_path).unwrap(); + assert!(verify_installed_bundle(&installed.path, "dp_example", &identity).is_err()); + std::fs::create_dir(&file_path).unwrap(); + assert!(verify_installed_bundle(&installed.path, "dp_example", &identity).is_err()); + } + + #[cfg(unix)] + #[test] + fn rejects_non_private_installed_files_and_receipts() { + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::tempdir().unwrap(); + let execution_dir = temporary.path().join("execution"); + std::fs::create_dir(&execution_dir).unwrap(); + let identity = encrypted_bundle_identity(b"one", b"payload").unwrap(); + let installed = install_bundle( + &execution_dir, + "dp_example", + &identity, + &recovered(b"first"), + ) + .unwrap(); + let file_path = installed.path.join("scan.txt"); + std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(verify_installed_bundle(&installed.path, "dp_example", &identity).is_err()); + + std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let receipt_path = installed.path.join(RECEIPT_NAME); + std::fs::set_permissions(&receipt_path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(verify_installed_bundle(&installed.path, "dp_example", &identity).is_err()); + + std::fs::set_permissions(&receipt_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::fs::set_permissions(&installed.path, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(verify_installed_bundle(&installed.path, "dp_example", &identity).is_err()); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_installed_files() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().unwrap(); + let execution_dir = temporary.path().join("execution"); + std::fs::create_dir(&execution_dir).unwrap(); + let identity = encrypted_bundle_identity(b"one", b"payload").unwrap(); + let installed = install_bundle( + &execution_dir, + "dp_example", + &identity, + &recovered(b"first"), + ) + .unwrap(); + std::fs::remove_file(installed.path.join("scan.txt")).unwrap(); + symlink("elsewhere", installed.path.join("scan.txt")).unwrap(); + assert!(verify_installed_bundle(&installed.path, "dp_example", &identity).is_err()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 73acb0b..bfd2be1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,7 +22,7 @@ use commands::execution::{ record_action, reveal_execution_dir, start_execution, }; use commands::template::list_templates; -use drop_point::{DropPointClient, DropPointConfig, DropPointSessions, cleanup_persisted_sessions}; +use drop_point::{DropPointClient, DropPointConfig, DropPointSessions}; use state::AppState; /// Command-line arguments shared by both binary crates. @@ -84,13 +84,22 @@ pub fn run(workspace: &Path) { } }; - let drop_point_client = drop_point_config.clone().map(DropPointClient::new); - if let Some(client) = drop_point_client.clone() { - let cleanup_dir = procedures_dir.clone(); - tauri::async_runtime::spawn(async move { - cleanup_persisted_sessions(&cleanup_dir, &client).await; - }); - } + let drop_point_client = match drop_point_config + .clone() + .map(DropPointClient::new) + .transpose() + { + Ok(client) => client, + Err(error) => { + log::warn!("DropPoint disabled: {error}"); + None + } + }; + let drop_point_state_root = + app.path().app_local_data_dir()?.join("drop-point-sessions"); + let drop_point_sessions = + DropPointSessions::new(drop_point_state_root, &procedures_dir) + .map_err(std::io::Error::other)?; app.manage(AppState { procedures_dir, @@ -98,7 +107,7 @@ pub fn run(workspace: &Path) { drop_point_client, attachment_grants: Mutex::new(HashSet::new()), }); - app.manage(DropPointSessions::default()); + app.manage(drop_point_sessions); Ok(()) }) diff --git a/src-tauri/src/persistence/attachment_store.rs b/src-tauri/src/persistence/attachment_store.rs index fefe1f9..79ba59b 100644 --- a/src-tauri/src/persistence/attachment_store.rs +++ b/src-tauri/src/persistence/attachment_store.rs @@ -18,12 +18,7 @@ pub struct StoredAttachment { pub struct PendingStoredAttachment { pub stored: StoredAttachment, - source: PendingAttachmentSource, -} - -enum PendingAttachmentSource { - File(PathBuf), - Bytes(Vec), + source: PathBuf, } impl AttachmentStore { @@ -45,25 +40,7 @@ impl AttachmentStore { let sha256 = compute_sha256(source).map_err(|e| e.to_string())?; Ok(PendingStoredAttachment { stored: stored_attachment(filename, sha256), - source: PendingAttachmentSource::File(source.to_path_buf()), - }) - } - - #[expect( - clippy::unused_self, - clippy::unnecessary_wraps, - reason = "keeps the attachment preparation API uniform with fallible file preparation" - )] - pub fn prepare_bytes( - &self, - filename: &str, - bytes: Vec, - ) -> Result { - let filename = sanitize_attachment_filename(filename); - let sha256 = hex_encode(Sha256::digest(&bytes).as_ref()); - Ok(PendingStoredAttachment { - stored: stored_attachment(filename, sha256), - source: PendingAttachmentSource::Bytes(bytes), + source: source.to_path_buf(), }) } @@ -112,37 +89,30 @@ fn stored_attachment(filename: String, sha256: String) -> StoredAttachment { } fn write_pending_source( - source: &PendingAttachmentSource, + path: &Path, destination: &mut std::fs::File, expected_sha256: &str, ) -> Result<(), String> { - match source { - PendingAttachmentSource::Bytes(bytes) => { - destination.write_all(bytes).map_err(|e| e.to_string()) - } - PendingAttachmentSource::File(path) => { - let mut source = std::fs::File::open(path).map_err(|e| e.to_string())?; - let mut hasher = Sha256::new(); - let mut buffer = vec![0u8; 64 * 1024]; - loop { - let read = source.read(&mut buffer).map_err(|e| e.to_string())?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - destination - .write_all(&buffer[..read]) - .map_err(|e| e.to_string())?; - } - let copied_hash = hex_encode(hasher.finalize().as_ref()); - if copied_hash == expected_sha256 { - Ok(()) - } else { - Err(format!( - "attachment hash mismatch while copying: expected {expected_sha256}, got {copied_hash}" - )) - } + let mut source = std::fs::File::open(path).map_err(|e| e.to_string())?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; 64 * 1024]; + loop { + let read = source.read(&mut buffer).map_err(|e| e.to_string())?; + if read == 0 { + break; } + hasher.update(&buffer[..read]); + destination + .write_all(&buffer[..read]) + .map_err(|e| e.to_string())?; + } + let copied_hash = hex_encode(hasher.finalize().as_ref()); + if copied_hash == expected_sha256 { + Ok(()) + } else { + Err(format!( + "attachment hash mismatch while copying: expected {expected_sha256}, got {copied_hash}" + )) } } diff --git a/src-tauri/src/persistence/execution_store.rs b/src-tauri/src/persistence/execution_store.rs index 05032d0..30ed8eb 100644 --- a/src-tauri/src/persistence/execution_store.rs +++ b/src-tauri/src/persistence/execution_store.rs @@ -20,9 +20,11 @@ pub struct RecordedExecution { pub execution_dir: PathBuf, } -pub struct AttachmentBytesSource { +pub struct InstalledAttachmentSource { pub filename: String, - pub bytes: Vec, + pub relative_path: String, + pub content_type: String, + pub sha256: String, } impl ExecutionStore { @@ -111,24 +113,68 @@ impl ExecutionStore { }) } - pub fn record_attachment_bytes_batch( + /// Durably append the application record for an already-installed atomic + /// bundle. Replaying an identical committed batch is idempotent, which + /// allows `DropPoint` close recovery after a process restart. + pub fn record_installed_attachment_batch( &self, execution_id: ExecutionId, step_id: &str, input_id: &str, - files: Vec, + files: Vec, ) -> Result { - self.with_log_transaction(execution_id, |state, execution_dir| { - let pending_attachments = prepare_attachment_bytes(execution_dir, files)?; - let attachments = pending_attachments.iter().map(attachment_record).collect(); - let event = state - .add_attachments_event(step_id, input_id, attachments) - .map_err(|e| e.to_string())?; - Ok(BuiltAction { - event, - pending_attachments, + let attachments = validate_installed_attachment_sources(files)?; + let execution_dir = find_execution_dir(&self.procedures_dir, execution_id)? + .ok_or_else(|| format!("Execution not found: {execution_id}"))?; + let event_log = EventLog::new(execution_dir.join("events.jsonl")); + let transaction_log = event_log.clone(); + + event_log + .with_exclusive_lock(|| { + let events = transaction_log.read().map_err(|error| error.to_string())?; + let mut state = + ExecutionState::from_events(&events).map_err(|error| error.to_string())?; + + let already_recorded = events.iter().any(|event| { + matches!( + event, + Event::AttachmentsAdded { + execution_id: event_execution_id, + step_id: event_step_id, + input_id: event_input_id, + attachments: event_attachments, + .. + } if *event_execution_id == execution_id + && event_step_id == step_id + && event_input_id == input_id + && event_attachments == &attachments + ) + }); + if already_recorded { + return Ok(RecordedExecution { + state, + execution_dir: execution_dir.clone(), + }); + } + + let event = state + .add_attachments_event(step_id, input_id, attachments) + .map_err(|error| error.to_string())?; + state.apply(&event).map_err(|error| error.to_string())?; + transaction_log + .append_durable(&event) + .map_err(|error| error.to_string())?; + if let Err(error) = write_execution_snapshot_durable(&execution_dir, &state) { + log::warn!( + "failed to update execution snapshot for {execution_id} after appending installed attachment event: {error}" + ); + } + Ok(RecordedExecution { + state, + execution_dir: execution_dir.clone(), + }) }) - }) + .map_err(|error| error.to_string())? } fn with_log_transaction( @@ -338,18 +384,38 @@ fn prepare_attachment_sources( .collect() } -fn prepare_attachment_bytes( - execution_dir: &Path, - sources: Vec, -) -> Result, String> { +fn validate_installed_attachment_sources( + sources: Vec, +) -> Result, String> { if sources.is_empty() { - return Err("at least one attachment file is required".to_string()); + return Err("at least one installed attachment file is required".to_string()); } - - let store = AttachmentStore::new(execution_dir.to_path_buf()); sources .into_iter() - .map(|source| store.prepare_bytes(&source.filename, source.bytes)) + .map(|source| { + let relative = Path::new(&source.relative_path); + if relative.is_absolute() + || !source.relative_path.starts_with("attachments/") + || relative + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + || source.filename.is_empty() + || source.content_type.is_empty() + || source.sha256.len() != 64 + || !source + .sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err("installed attachment metadata is invalid".to_string()); + } + Ok(AttachmentRecord { + filename: source.filename, + path: source.relative_path, + content_type: source.content_type, + sha256: source.sha256, + }) + }) .collect() } @@ -535,6 +601,21 @@ version: 1.0.0 type: measurement unit: V ``` +"; + + const ATTACHMENT_TEMPLATE: &str = r"--- +id: attachment-test +title: Attachment Test +version: 1.0.0 +--- + +## Capture + +```inputs +- id: evidence + label: Evidence + type: attachment +``` "; #[test] @@ -590,4 +671,64 @@ version: 1.0.0 assert!(updated_readme.contains("- [x] Ready")); assert!(updated_readme.contains("Toggled at:")); } + + #[test] + fn installed_attachment_record_is_idempotent_after_restart_boundary() { + let temporary = tempfile::tempdir().unwrap(); + let procedure_dir = temporary.path().join("attachment-test"); + std::fs::create_dir(&procedure_dir).unwrap(); + let template_path = procedure_dir.join("template.md"); + std::fs::write(&template_path, ATTACHMENT_TEMPLATE).unwrap(); + + let template = parse_template(ATTACHMENT_TEMPLATE).unwrap(); + let mut state = ExecutionState::new(); + let events = state.start(&template).unwrap(); + let execution_id = state.execution_id.unwrap(); + let started_at = events + .iter() + .find_map(|event| match event { + Event::ExecutionStarted { at, .. } => Some(*at), + _ => None, + }) + .unwrap(); + let store = ExecutionStore::new(temporary.path().to_path_buf()); + let created = store + .create_execution( + &template_path, + state, + events, + started_at, + execution_id, + "test".to_string(), + ) + .unwrap(); + + let source = || InstalledAttachmentSource { + filename: "scan.txt".to_string(), + relative_path: "attachments/bundle-dp_example/scan.txt".to_string(), + content_type: "text/plain".to_string(), + sha256: "a".repeat(64), + }; + store + .record_installed_attachment_batch(execution_id, "step-0", "evidence", vec![source()]) + .unwrap(); + let replayed = store + .record_installed_attachment_batch(execution_id, "step-0", "evidence", vec![source()]) + .unwrap(); + + assert_eq!( + replayed.state.steps["step-0"].attachments["evidence"].len(), + 1 + ); + let events = EventLog::new(created.execution_dir.join("events.jsonl")) + .read() + .unwrap(); + assert_eq!( + events + .iter() + .filter(|event| matches!(event, Event::AttachmentsAdded { .. })) + .count(), + 1 + ); + } } diff --git a/src-tauri/testdata/drop_point/filename-policy.json b/src-tauri/testdata/drop_point/filename-policy.json new file mode 100644 index 0000000..9bf1330 --- /dev/null +++ b/src-tauri/testdata/drop_point/filename-policy.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "max_filename_utf8_bytes": 240, + "max_manifest_files": 1000, + "canonicalization_bundles": [ + { + "input": [ + "Café.txt", + "report?.txt", + "CON.foo.txt", + "NUL .txt", + "trail. ", + "‍secret.txt", + " ", + ".droppoint-receipt.json", + "photo:stream.jpg", + "a/b.txt" + ], + "output": [ + "Café.txt", + "report_.txt", + "_CON.foo.txt", + "_NUL.txt", + "trail", + "_secret.txt", + "file", + "_.droppoint-receipt.json", + "photo_stream.jpg", + "a_b.txt" + ] + }, + { + "input": ["Report.txt", "report.TXT", "é.txt", "é.txt", "file", "file"], + "output": ["Report.txt", "report (2).TXT", "é.txt", "é (2).txt", "file", "file (2)"] + } + ], + "validation": [ + { "name": "report.txt", "valid": true }, + { "name": "Café.txt", "valid": true }, + { "name": " report.txt", "valid": true }, + { "name": "Café.txt", "valid": false }, + { "name": "CON.foo.txt", "valid": false }, + { "name": "NUL .txt", "valid": false }, + { "name": "report.txt.", "valid": false }, + { "name": "report.txt ", "valid": false }, + { "name": "photo:stream.jpg", "valid": false }, + { "name": "a/b.txt", "valid": false }, + { "name": "‍secret.txt", "valid": false }, + { "name": " ", "valid": false }, + { "name": ".droppoint-receipt.json", "valid": false }, + { + "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "valid": false + } + ], + "collisions": [ + { "names": ["Report.txt", "report.TXT"], "collides": true }, + { "names": ["Ä.txt", "ä.TXT"], "collides": true }, + { "names": ["ß.txt", "ss.txt"], "collides": false }, + { "names": ["file.txt", "file-2.txt"], "collides": false } + ] +} diff --git a/src-tauri/testdata/drop_point/protocol-parsing-policy.json b/src-tauri/testdata/drop_point/protocol-parsing-policy.json new file mode 100644 index 0000000..4da10f7 --- /dev/null +++ b/src-tauri/testdata/drop_point/protocol-parsing-policy.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "base64url": [ + { "value": "AA", "valid": true, "decoded_hex": "00" }, + { "value": "_w", "valid": true, "decoded_hex": "ff" }, + { "value": "SGVsbG8", "valid": true, "decoded_hex": "48656c6c6f" }, + { "value": "", "valid": false }, + { "value": "A", "valid": false }, + { "value": "AB", "valid": false }, + { "value": "AA==", "valid": false }, + { "value": "+w", "valid": false }, + { "value": "/w", "valid": false }, + { "value": "AA\n", "valid": false } + ], + "timestamps": [ + { "value": "2026-06-30T12:00:00Z", "valid": true }, + { "value": "2026-06-30T12:00:00.123456789+09:00", "valid": true }, + { "value": "2026-06-30T12:00:00", "valid": false }, + { "value": "2026-02-30T12:00:00Z", "valid": false }, + { "value": "2026-06-30", "valid": false } + ], + "mime_types": [ + { "value": "", "valid": true, "canonical": "application/octet-stream" }, + { "value": "text/plain", "valid": true, "canonical": "text/plain" }, + { "value": "IMAGE/JPEG", "valid": true, "canonical": "image/jpeg" }, + { "value": "text/plain; charset=utf-8", "valid": false }, + { "value": "text/plain\nX-Evil: 1", "valid": false }, + { "value": "not-a-mime", "valid": false } + ] +} diff --git a/src/lib/components/AttachmentField.svelte b/src/lib/components/AttachmentField.svelte index b68a23c..67a90c6 100644 --- a/src/lib/components/AttachmentField.svelte +++ b/src/lib/components/AttachmentField.svelte @@ -64,6 +64,7 @@ let remoteError = $state(null); let countdownNow = $state(Date.now()); let pollTimer: number | null = null; + let pollBackoffMs = 1000; let countdownTimer: number | null = null; let remoteRunId = 0; let pollInFlightRunId: number | null = null; @@ -232,13 +233,22 @@ function startPolling() { stopPolling(); + pollBackoffMs = 1000; void pollRemoteOnce(); - pollTimer = window.setInterval(() => void pollRemoteOnce(), 1500); + } + + function schedulePoll(delayMs: number) { + if (!remoteSession || pollTimer !== null) return; + const jitteredDelay = Math.max(250, Math.round(delayMs * (0.8 + Math.random() * 0.4))); + pollTimer = window.setTimeout(() => { + pollTimer = null; + void pollRemoteOnce(); + }, jitteredDelay); } function stopPolling() { if (pollTimer !== null) { - window.clearInterval(pollTimer); + window.clearTimeout(pollTimer); pollTimer = null; } } @@ -268,12 +278,16 @@ const status = await onpolldrop(session.session_id); if (runId !== remoteRunId) return; remoteStatus = status; + remoteError = null; + pollBackoffMs = 1000; switch (status.status) { case "open": remotePhase = "open"; + schedulePoll(1500); break; case "receiving": remotePhase = "receiving"; + schedulePoll(1500); break; case "ready": stopPolling(); @@ -287,17 +301,29 @@ stopPolling(); remotePhase = "expired"; break; + case "failed": + stopPolling(); + remotePhase = "failed"; + remoteError = "DropPoint reported a terminal internal failure."; + break; default: stopPolling(); remotePhase = "failed"; - remoteError = `DropPoint session status: ${status.status}`; + remoteError = `Unsupported DropPoint session status: ${status.status}`; break; } } catch (e) { if (runId !== remoteRunId) return; + const pollError = parsePollError(e); + if (pollError.kind === "retryable") { + remoteError = `Temporary polling failure: ${pollError.message}`; + schedulePoll(pollBackoffMs); + pollBackoffMs = Math.min(pollBackoffMs * 2, 15000); + return; + } stopPolling(); remotePhase = "failed"; - remoteError = String(e); + remoteError = pollError.message; } finally { if (pollInFlightRunId === runId) { pollInFlightRunId = null; @@ -321,9 +347,21 @@ } } - async function retryRemoteImport() { + function retryRemoteImport() { if (!remoteSession) return; - await importRemoteUpload(remoteSession.session_id); + remoteError = null; + remotePhase = "open"; + startPolling(); + } + + function parsePollError(error: unknown): { kind: string; message: string } { + if (typeof error === "object" && error !== null) { + const value = error as Record; + if (typeof value.kind === "string" && typeof value.message === "string") { + return { kind: value.kind, message: value.message }; + } + } + return { kind: "fatal", message: String(error) }; } async function cancelRemoteUpload() {