FTP connections preserve passwords and filenames, with CI checking every change #1
Generated
+47
-1
@@ -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"
|
||||
|
||||
@@ -8,6 +8,7 @@ license = "MIT"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
getrandom = "0.3"
|
||||
memona-plugin-sdk = { path = "sdk" }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+19
-7
@@ -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());
|
||||
@@ -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> {
|
||||
|
|
||||
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
@@ -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>>;
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user
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.