From 3e7c4f2f032af5d4ea38ba253996531db5d4fbd2 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 20:51:45 +0900 Subject: [PATCH] Randomize FTP upload stages --- Cargo.lock | 48 +++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/lib.rs | 26 +++++++++++++++++------- src/session_tests.rs | 16 ++++++++++++++- src/test_host.rs | 12 +++++++++++ 5 files changed, 94 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f5c4452..7a94fab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,12 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + [[package]] name = "equivalent" version = "1.0.2" @@ -26,6 +32,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -71,6 +89,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "log" version = "0.4.34" @@ -98,6 +122,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" name = "memona-plugin-ftp" version = "0.1.3" dependencies = [ + "getrandom", "memona-plugin-sdk", "tokio", ] @@ -106,7 +131,7 @@ dependencies = [ name = "memona-plugin-sdk" version = "0.1.0" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.62.0", ] [[package]] @@ -143,6 +168,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "semver" version = "1.0.28" @@ -229,6 +260,15 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen 0.46.0", +] + [[package]] name = "wasm-encoder" version = "0.259.0" @@ -263,6 +303,12 @@ dependencies = [ "semver", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "wit-bindgen" version = "0.62.0" diff --git a/Cargo.toml b/Cargo.toml index 77a50dc..2d3a3ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ license = "MIT" crate-type = ["cdylib", "rlib"] [dependencies] +getrandom = "0.3" memona-plugin-sdk = { path = "sdk" } [dev-dependencies] diff --git a/src/lib.rs b/src/lib.rs index e02b80c..944ec76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,7 +32,6 @@ const MAX_STAGE_ATTEMPTS: usize = 64; thread_local! { static CONTROL: RefCell> = const { RefCell::new(None) }; static SESSION: RefCell> = const { RefCell::new(None) }; - static NEXT_STAGE: Cell = const { Cell::new(0) }; /// Raw MLSD listings by path, so later pages of a folder are parsed from /// memory instead of downloaded and parsed whole again. static LISTINGS: RefCell>> = RefCell::new(HashMap::new()); @@ -366,18 +365,27 @@ impl Drop for FtpWriter { } } -/// Chooses an unused private sibling for one upload. The sequence is local to -/// the component, and the remote probe prevents reuse after an app restart. -fn stage_path(target: &str) -> Result { +fn random_stage_token() -> Result { + let mut bytes = [0; 16]; + getrandom::fill(&mut bytes).map_err(|_| ErrorCode::Internal)?; + Ok(u128::from_be_bytes(bytes)) +} + +/// Chooses an unused, unguessable private sibling for one upload. The remote +/// probe remains a collision check, while 128 random bits prevent another +/// client from racing a predictable name between that probe and `STOR`. +fn stage_path_with( + target: &str, + mut next_token: impl FnMut() -> Result, +) -> Result { let (parent, name) = target.rsplit_once('/').ok_or(ErrorCode::InvalidInput)?; if name.is_empty() { return Err(ErrorCode::InvalidInput); } for _ in 0..MAX_STAGE_ATTEMPTS { - let sequence = NEXT_STAGE.get(); - NEXT_STAGE.set(sequence.wrapping_add(1)); - let staged = format!("{parent}/.memona-upload-{sequence:016x}"); + let token = next_token()?; + let staged = format!("{parent}/.memona-upload-{token:032x}"); match with_control(|control| probe(control, &staged)) { Err(ErrorCode::NotFound) => return Ok(staged), Ok(_) => {} @@ -387,6 +395,10 @@ fn stage_path(target: &str) -> Result { Err(ErrorCode::Conflict) } +fn stage_path(target: &str) -> Result { + stage_path_with(target, random_stage_token) +} + fn start_writer(target: String) -> Result { ready()?; let staged = stage_path(&target)?; diff --git a/src/session_tests.rs b/src/session_tests.rs index a594aa9..37f78be 100644 --- a/src/session_tests.rs +++ b/src/session_tests.rs @@ -1,7 +1,7 @@ //! FTP command recovery against an in-memory protocol transport. Live QA //! separately exercises the compiled WASM component over real host sockets. -use super::{Ftp, start_writer, test_host}; +use super::{Ftp, stage_path_with, start_writer, test_host}; use memona_plugin_sdk::{ErrorCode, Guest, GuestWriter, Locator, Setting}; type TestResult = Result>; @@ -157,3 +157,17 @@ async fn dropping_an_upload_removes_only_its_private_stage() -> TestResult { assert_eq!(summary.promotions, 0); Ok(()) } + +#[tokio::test] +async fn a_stage_name_collision_draws_a_fresh_random_token() -> TestResult { + let fixture = test_host::install(test_host::Scenario::StageCollision); + guest(Ftp::open("ftp".into(), config()))?; + let mut tokens = [1, 2].into_iter(); + let staged = guest(stage_path_with("/target", || { + tokens.next().ok_or(ErrorCode::Internal) + }))?; + assert_eq!(staged, "/.memona-upload-00000000000000000000000000000002"); + Ftp::close(); + assert_eq!(fixture.summary().stage_probes, 2); + Ok(()) +} diff --git a/src/test_host.rs b/src/test_host.rs index a83444a..1fabc45 100644 --- a/src/test_host.rs +++ b/src/test_host.rs @@ -19,6 +19,7 @@ pub enum Scenario { LostUploadData, LostMutationReply, LostUploadCompletion, + StageCollision, SuccessfulUpload, } @@ -31,6 +32,7 @@ struct State { target_upload_commands: usize, uploaded_bytes: usize, stage_deletes: usize, + stage_probes: usize, promotions: usize, } @@ -48,6 +50,7 @@ pub struct Summary { pub target_upload_commands: usize, pub uploaded_bytes: usize, pub stage_deletes: usize, + pub stage_probes: usize, pub promotions: usize, } @@ -61,6 +64,7 @@ pub fn install(scenario: Scenario) -> Fixture { target_upload_commands: 0, uploaded_bytes: 0, stage_deletes: 0, + stage_probes: 0, promotions: 0, })); ACTIVE.set(Some(state.clone())); @@ -78,6 +82,7 @@ impl Fixture { target_upload_commands: state.target_upload_commands, uploaded_bytes: state.uploaded_bytes, stage_deletes: state.stage_deletes, + stage_probes: state.stage_probes, promotions: state.promotions, } } @@ -196,7 +201,14 @@ impl Tcp { return Err(HostError::Unavailable); } "NOOP" => "200 Still connected\r\n", + "MLST /.memona-upload-00000000000000000000000000000001" + if matches!(state.scenario, Scenario::StageCollision) => + { + state.stage_probes += 1; + "250-Begin\r\n type=file;size=3; /.memona-upload-00000000000000000000000000000001\r\n250 End\r\n" + } command if command.starts_with("MLST /.memona-upload-") => { + state.stage_probes += 1; "550 Missing\r\n" } command