FTP connections preserve passwords and filenames, with CI checking every change #1

Merged
temeddix merged 15 commits from fix-directory-listing into main 2026-09-30 13:10:40 +00:00
5 changed files with 94 additions and 9 deletions
Showing only changes of commit 3e7c4f2f03 - Show all commits
Generated
+47 -1
View File
@@ -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"
+1
View File
@@ -8,6 +8,7 @@ license = "MIT"
crate-type = ["cdylib", "rlib"]
[dependencies]
getrandom = "0.3"
memona-plugin-sdk = { path = "sdk" }
[dev-dependencies]
+19 -7
View File
@@ -32,7 +32,6 @@ const MAX_STAGE_ATTEMPTS: usize = 64;
thread_local! {
static CONTROL: RefCell<Option<Control>> = 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
/// memory instead of downloaded and parsed whole again.
static LISTINGS: RefCell<HashMap<String, Rc<[u8]>>> = RefCell::new(HashMap::new());
4
@@ -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<String, ErrorCode> {
fn random_stage_token() -> Result<u128, ErrorCode> {
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<u128, ErrorCode>,
) -> Result<String, ErrorCode> {
Outdated
Review

The stage counter always starts at zero, so another FTP client can create this known name after MLST but before STOR. STOR can then truncate that client's file and cleanup can delete it. Use an unguessable suffix and add a collision regression.

Profile: codex · gpt-5.6-terra

The stage counter always starts at zero, so another FTP client can create this known name after MLST but before STOR. STOR can then truncate that client's file and cleanup can delete it. Use an unguessable suffix and add a collision regression. > Profile: `codex` · `gpt-5.6-terra`
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<String, ErrorCode> {
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> {
ready()?;
let staged = stage_path(&target)?;
+15 -1
View File
@@ -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<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
3
@@ -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(())
}
+12
View File
@@ -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