Randomize FTP upload stages
CI / checks (pull_request) Successful in 47s
Agents / bot (pull_request) Successful in 4m48s

This commit is contained in:
Danny Kim
2026-09-30 20:51:45 +09:00
parent bc6a495361
commit 3e7c4f2f03
5 changed files with 94 additions and 9 deletions
Generated
+47 -1
View File
@@ -14,6 +14,12 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -26,6 +32,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" 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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -71,6 +89,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.34" version = "0.4.34"
@@ -98,6 +122,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
name = "memona-plugin-ftp" name = "memona-plugin-ftp"
version = "0.1.3" version = "0.1.3"
dependencies = [ dependencies = [
"getrandom",
"memona-plugin-sdk", "memona-plugin-sdk",
"tokio", "tokio",
] ]
@@ -106,7 +131,7 @@ dependencies = [
name = "memona-plugin-sdk" name = "memona-plugin-sdk"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"wit-bindgen", "wit-bindgen 0.62.0",
] ]
[[package]] [[package]]
@@ -143,6 +168,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@@ -229,6 +260,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" 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]] [[package]]
name = "wasm-encoder" name = "wasm-encoder"
version = "0.259.0" version = "0.259.0"
@@ -263,6 +303,12 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.62.0" version = "0.62.0"
+1
View File
@@ -8,6 +8,7 @@ license = "MIT"
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
getrandom = "0.3"
memona-plugin-sdk = { path = "sdk" } memona-plugin-sdk = { path = "sdk" }
[dev-dependencies] [dev-dependencies]
+19 -7
View File
@@ -32,7 +32,6 @@ const MAX_STAGE_ATTEMPTS: usize = 64;
thread_local! { thread_local! {
static CONTROL: RefCell<Option<Control>> = const { RefCell::new(None) }; static CONTROL: RefCell<Option<Control>> = const { RefCell::new(None) };
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) }; static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
static NEXT_STAGE: Cell<u64> = const { Cell::new(0) };
/// Raw MLSD listings by path, so later pages of a folder are parsed from /// Raw MLSD listings by path, so later pages of a folder are parsed from
/// memory instead of downloaded and parsed whole again. /// memory instead of downloaded and parsed whole again.
static LISTINGS: RefCell<HashMap<String, Rc<[u8]>>> = RefCell::new(HashMap::new()); static LISTINGS: RefCell<HashMap<String, Rc<[u8]>>> = 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 fn random_stage_token() -> Result<u128, ErrorCode> {
/// the component, and the remote probe prevents reuse after an app restart. let mut bytes = [0; 16];
fn stage_path(target: &str) -> Result<String, ErrorCode> { 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<u128, ErrorCode>,
) -> Result<String, ErrorCode> {
let (parent, name) = let (parent, name) =
target.rsplit_once('/').ok_or(ErrorCode::InvalidInput)?; target.rsplit_once('/').ok_or(ErrorCode::InvalidInput)?;
if name.is_empty() { if name.is_empty() {
return Err(ErrorCode::InvalidInput); return Err(ErrorCode::InvalidInput);
} }
for _ in 0..MAX_STAGE_ATTEMPTS { for _ in 0..MAX_STAGE_ATTEMPTS {
let sequence = NEXT_STAGE.get(); let token = next_token()?;
NEXT_STAGE.set(sequence.wrapping_add(1)); let staged = format!("{parent}/.memona-upload-{token:032x}");
let staged = format!("{parent}/.memona-upload-{sequence:016x}");
match with_control(|control| probe(control, &staged)) { match with_control(|control| probe(control, &staged)) {
Err(ErrorCode::NotFound) => return Ok(staged), Err(ErrorCode::NotFound) => return Ok(staged),
Ok(_) => {} Ok(_) => {}
@@ -387,6 +395,10 @@ fn stage_path(target: &str) -> Result<String, ErrorCode> {
Err(ErrorCode::Conflict) Err(ErrorCode::Conflict)
} }
fn stage_path(target: &str) -> Result<String, ErrorCode> {
stage_path_with(target, random_stage_token)
}
fn start_writer(target: String) -> Result<FtpWriter, ErrorCode> { fn start_writer(target: String) -> Result<FtpWriter, ErrorCode> {
ready()?; ready()?;
let staged = stage_path(&target)?; let staged = stage_path(&target)?;
+15 -1
View File
@@ -1,7 +1,7 @@
//! FTP command recovery against an in-memory protocol transport. Live QA //! FTP command recovery against an in-memory protocol transport. Live QA
//! separately exercises the compiled WASM component over real host sockets. //! 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}; use memona_plugin_sdk::{ErrorCode, Guest, GuestWriter, Locator, Setting};
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>; type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
@@ -157,3 +157,17 @@ async fn dropping_an_upload_removes_only_its_private_stage() -> TestResult {
assert_eq!(summary.promotions, 0); assert_eq!(summary.promotions, 0);
Ok(()) 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(())
}
+12
View File
@@ -19,6 +19,7 @@ pub enum Scenario {
LostUploadData, LostUploadData,
LostMutationReply, LostMutationReply,
LostUploadCompletion, LostUploadCompletion,
StageCollision,
SuccessfulUpload, SuccessfulUpload,
} }
@@ -31,6 +32,7 @@ struct State {
target_upload_commands: usize, target_upload_commands: usize,
uploaded_bytes: usize, uploaded_bytes: usize,
stage_deletes: usize, stage_deletes: usize,
stage_probes: usize,
promotions: usize, promotions: usize,
} }
@@ -48,6 +50,7 @@ pub struct Summary {
pub target_upload_commands: usize, pub target_upload_commands: usize,
pub uploaded_bytes: usize, pub uploaded_bytes: usize,
pub stage_deletes: usize, pub stage_deletes: usize,
pub stage_probes: usize,
pub promotions: usize, pub promotions: usize,
} }
@@ -61,6 +64,7 @@ pub fn install(scenario: Scenario) -> Fixture {
target_upload_commands: 0, target_upload_commands: 0,
uploaded_bytes: 0, uploaded_bytes: 0,
stage_deletes: 0, stage_deletes: 0,
stage_probes: 0,
promotions: 0, promotions: 0,
})); }));
ACTIVE.set(Some(state.clone())); ACTIVE.set(Some(state.clone()));
@@ -78,6 +82,7 @@ impl Fixture {
target_upload_commands: state.target_upload_commands, target_upload_commands: state.target_upload_commands,
uploaded_bytes: state.uploaded_bytes, uploaded_bytes: state.uploaded_bytes,
stage_deletes: state.stage_deletes, stage_deletes: state.stage_deletes,
stage_probes: state.stage_probes,
promotions: state.promotions, promotions: state.promotions,
} }
} }
@@ -196,7 +201,14 @@ impl Tcp {
return Err(HostError::Unavailable); return Err(HostError::Unavailable);
} }
"NOOP" => "200 Still connected\r\n", "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-") => { command if command.starts_with("MLST /.memona-upload-") => {
state.stage_probes += 1;
"550 Missing\r\n" "550 Missing\r\n"
} }
command command