Report uncertain FTP mutation outcomes
This commit is contained in:
+46
-7
@@ -91,6 +91,11 @@ pub struct Control {
|
|||||||
buffer: Vec<u8>,
|
buffer: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DispatchError {
|
||||||
|
code: ErrorCode,
|
||||||
|
dispatched: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl Control {
|
impl Control {
|
||||||
pub fn open(
|
pub fn open(
|
||||||
host_name: String,
|
host_name: String,
|
||||||
@@ -155,27 +160,61 @@ impl Control {
|
|||||||
Err(ErrorCode::Unavailable)
|
Err(ErrorCode::Unavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends one command. A newline in an argument would forge a second command,
|
/// Writes one complete command and records whether any of it reached the
|
||||||
/// so it is refused rather than escaped.
|
/// socket. A newline in an argument would forge a second command, so it is
|
||||||
pub fn send(&mut self, command: &str) -> Result<(u16, String), ErrorCode> {
|
/// refused before dispatch rather than escaped.
|
||||||
|
fn dispatch(&mut self, command: &str) -> Result<(), DispatchError> {
|
||||||
if command.contains(['\r', '\n']) || command.len() > MAX_LINE {
|
if command.contains(['\r', '\n']) || command.len() > MAX_LINE {
|
||||||
return Err(ErrorCode::InvalidInput);
|
return Err(DispatchError {
|
||||||
|
code: ErrorCode::InvalidInput,
|
||||||
|
dispatched: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let line = format!("{command}\r\n");
|
let line = format!("{command}\r\n");
|
||||||
let mut sent = 0;
|
let mut sent = 0;
|
||||||
while sent < line.len() {
|
while sent < line.len() {
|
||||||
let written = self
|
let written =
|
||||||
|
self
|
||||||
.socket
|
.socket
|
||||||
.write(&line.as_bytes()[sent..])
|
.write(&line.as_bytes()[sent..])
|
||||||
.map_err(transport)? as usize;
|
.map_err(|error| DispatchError {
|
||||||
|
code: transport(error),
|
||||||
|
dispatched: sent > 0,
|
||||||
|
})? as usize;
|
||||||
if written == 0 {
|
if written == 0 {
|
||||||
return Err(ErrorCode::Unavailable);
|
return Err(DispatchError {
|
||||||
|
code: ErrorCode::Unavailable,
|
||||||
|
dispatched: sent > 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
sent += written;
|
sent += written;
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends one command and reads its reply.
|
||||||
|
pub fn send(&mut self, command: &str) -> Result<(u16, String), ErrorCode> {
|
||||||
|
self.dispatch(command).map_err(|error| error.code)?;
|
||||||
self.reply()
|
self.reply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends a command that changes remote state. Once any command byte was
|
||||||
|
/// dispatched, a write interruption or missing reply makes its outcome
|
||||||
|
/// unknowable even when the underlying transport reports cancellation.
|
||||||
|
pub fn send_mutation(
|
||||||
|
&mut self,
|
||||||
|
command: &str,
|
||||||
|
) -> Result<(u16, String), ErrorCode> {
|
||||||
|
self.dispatch(command).map_err(|error| {
|
||||||
|
if error.dispatched {
|
||||||
|
ErrorCode::OutcomeUnknown
|
||||||
|
} else {
|
||||||
|
error.code
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
self.reply().map_err(|_| ErrorCode::OutcomeUnknown)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn expect(&mut self, command: &str) -> Result<String, ErrorCode> {
|
pub fn expect(&mut self, command: &str) -> Result<String, ErrorCode> {
|
||||||
let (code, text) = self.send(command)?;
|
let (code, text) = self.send(command)?;
|
||||||
outcome(code)?;
|
outcome(code)?;
|
||||||
|
|||||||
+51
-14
@@ -89,7 +89,14 @@ fn with_control<T>(
|
|||||||
Some(control) => action(control),
|
Some(control) => action(control),
|
||||||
None => Err(ErrorCode::Internal),
|
None => Err(ErrorCode::Internal),
|
||||||
};
|
};
|
||||||
if matches!(result, Err(ErrorCode::Unavailable | ErrorCode::Internal)) {
|
if matches!(
|
||||||
|
result,
|
||||||
|
Err(
|
||||||
|
ErrorCode::Unavailable
|
||||||
|
| ErrorCode::OutcomeUnknown
|
||||||
|
| ErrorCode::Internal
|
||||||
|
)
|
||||||
|
) {
|
||||||
*slot = None;
|
*slot = None;
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
@@ -126,6 +133,17 @@ fn transfer(command: &str) -> Result<host::Tcp, ErrorCode> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts a passive upload. Losing the command reply is ambiguous because an
|
||||||
|
/// FTP server may have created or truncated the destination on receipt.
|
||||||
|
fn mutation_transfer(command: &str) -> Result<host::Tcp, ErrorCode> {
|
||||||
|
with_control(|control| {
|
||||||
|
let data = control.passive()?;
|
||||||
|
let (code, _) = control.send_mutation(command)?;
|
||||||
|
preliminary(code)?;
|
||||||
|
Ok(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Consumes the completion reply of a transfer whose data socket is closed.
|
/// Consumes the completion reply of a transfer whose data socket is closed.
|
||||||
fn settle() -> Result<(), ErrorCode> {
|
fn settle() -> Result<(), ErrorCode> {
|
||||||
with_control(|control| {
|
with_control(|control| {
|
||||||
@@ -134,6 +152,16 @@ fn settle() -> Result<(), ErrorCode> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Consumes an upload's completion reply. The data and command are already
|
||||||
|
/// dispatched, so an interrupted reply cannot say whether the file committed.
|
||||||
|
fn settle_mutation() -> Result<(), ErrorCode> {
|
||||||
|
let (code, _) = with_control(|control| {
|
||||||
|
control.reply().map_err(|_| ErrorCode::OutcomeUnknown)
|
||||||
|
})
|
||||||
|
.map_err(|_| ErrorCode::OutcomeUnknown)?;
|
||||||
|
outcome(code)
|
||||||
|
}
|
||||||
|
|
||||||
/// Ends a transfer early, after its data socket is closed.
|
/// Ends a transfer early, after its data socket is closed.
|
||||||
fn abort() -> Result<(), ErrorCode> {
|
fn abort() -> Result<(), ErrorCode> {
|
||||||
with_control(Control::abort)
|
with_control(Control::abort)
|
||||||
@@ -237,9 +265,11 @@ impl GuestWriter for FtpWriter {
|
|||||||
let socket = socket.as_ref().ok_or(ErrorCode::InvalidInput)?;
|
let socket = socket.as_ref().ok_or(ErrorCode::InvalidInput)?;
|
||||||
let mut sent = 0;
|
let mut sent = 0;
|
||||||
while sent < data.len() {
|
while sent < data.len() {
|
||||||
let written = socket.write(&data[sent..]).map_err(transport)? as usize;
|
let written = socket
|
||||||
|
.write(&data[sent..])
|
||||||
|
.map_err(|_| ErrorCode::OutcomeUnknown)? as usize;
|
||||||
if written == 0 {
|
if written == 0 {
|
||||||
return Err(ErrorCode::Unavailable);
|
return Err(ErrorCode::OutcomeUnknown);
|
||||||
}
|
}
|
||||||
sent += written;
|
sent += written;
|
||||||
}
|
}
|
||||||
@@ -250,7 +280,7 @@ impl GuestWriter for FtpWriter {
|
|||||||
/// server answer, so the socket is given up before the reply is read.
|
/// server answer, so the socket is given up before the reply is read.
|
||||||
fn finish(&self) -> Result<Option<FileInfo>, ErrorCode> {
|
fn finish(&self) -> Result<Option<FileInfo>, ErrorCode> {
|
||||||
drop(self.socket.take().ok_or(ErrorCode::InvalidInput)?);
|
drop(self.socket.take().ok_or(ErrorCode::InvalidInput)?);
|
||||||
settle()?;
|
settle_mutation()?;
|
||||||
Ok(Some(FileInfo {
|
Ok(Some(FileInfo {
|
||||||
kind: EntryKind::File,
|
kind: EntryKind::File,
|
||||||
size: self.written.get(),
|
size: self.written.get(),
|
||||||
@@ -269,8 +299,11 @@ impl Drop for FtpWriter {
|
|||||||
};
|
};
|
||||||
drop(socket);
|
drop(socket);
|
||||||
if abort().is_ok() {
|
if abort().is_ok() {
|
||||||
let _ =
|
let _ = with_control(|control| {
|
||||||
with_control(|control| control.expect(&format!("DELE {}", self.path)));
|
let (code, _) =
|
||||||
|
control.send_mutation(&format!("DELE {}", self.path))?;
|
||||||
|
outcome(code)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +427,7 @@ impl Guest for Ftp {
|
|||||||
return Err(ErrorCode::Unsupported);
|
return Err(ErrorCode::Unsupported);
|
||||||
}
|
}
|
||||||
ready()?;
|
ready()?;
|
||||||
let socket = transfer(&format!("STOR {path}"))?;
|
let socket = mutation_transfer(&format!("STOR {path}"))?;
|
||||||
Ok(Writer::new(FtpWriter {
|
Ok(Writer::new(FtpWriter {
|
||||||
path,
|
path,
|
||||||
socket: RefCell::new(Some(socket)),
|
socket: RefCell::new(Some(socket)),
|
||||||
@@ -408,7 +441,7 @@ impl Guest for Ftp {
|
|||||||
let path = route(&target)?;
|
let path = route(&target)?;
|
||||||
ready()?;
|
ready()?;
|
||||||
with_control(|control| {
|
with_control(|control| {
|
||||||
let (code, text) = control.send(&format!("MKD {path}"))?;
|
let (code, text) = control.send_mutation(&format!("MKD {path}"))?;
|
||||||
refusal(code, &text)
|
refusal(code, &text)
|
||||||
})?;
|
})?;
|
||||||
Ok(FileInfo {
|
Ok(FileInfo {
|
||||||
@@ -427,14 +460,16 @@ impl Guest for Ftp {
|
|||||||
let path = route(&target)?;
|
let path = route(&target)?;
|
||||||
ready()?;
|
ready()?;
|
||||||
with_control(|control| {
|
with_control(|control| {
|
||||||
|
let info = probe(control, &path)?;
|
||||||
match probe(control, &destination) {
|
match probe(control, &destination) {
|
||||||
Ok(_) => return Err(ErrorCode::Conflict),
|
Ok(_) => return Err(ErrorCode::Conflict),
|
||||||
Err(ErrorCode::NotFound) => {}
|
Err(ErrorCode::NotFound) => {}
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
}
|
}
|
||||||
control.expect(&format!("RNFR {path}"))?;
|
control.expect(&format!("RNFR {path}"))?;
|
||||||
control.expect(&format!("RNTO {destination}"))?;
|
let (code, _) = control.send_mutation(&format!("RNTO {destination}"))?;
|
||||||
probe(control, &destination)
|
outcome(code)?;
|
||||||
|
Ok(info)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,11 +480,13 @@ impl Guest for Ftp {
|
|||||||
let path = route(&target)?;
|
let path = route(&target)?;
|
||||||
ready()?;
|
ready()?;
|
||||||
with_control(|control| {
|
with_control(|control| {
|
||||||
let (code, _) = control.send(&format!("DELE {path}"))?;
|
let (code, _) = control.send_mutation(&format!("DELE {path}"))?;
|
||||||
if (200..300).contains(&code) {
|
match outcome(code) {
|
||||||
return Ok(());
|
Ok(()) => return Ok(()),
|
||||||
|
Err(ErrorCode::NotFound) => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
}
|
}
|
||||||
let (code, text) = control.send(&format!("RMD {path}"))?;
|
let (code, text) = control.send_mutation(&format!("RMD {path}"))?;
|
||||||
refusal(code, &text)
|
refusal(code, &text)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -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, test_host};
|
use super::{Ftp, mutation_transfer, settle_mutation, test_host};
|
||||||
use memona_plugin_sdk::{ErrorCode, Guest, Locator, Setting};
|
use memona_plugin_sdk::{ErrorCode, Guest, 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>>;
|
||||||
@@ -49,7 +49,7 @@ async fn recovery_before_a_mutation_never_replays_a_lost_mutation_reply()
|
|||||||
guest(Ftp::open("ftp".into(), config()))?;
|
guest(Ftp::open("ftp".into(), config()))?;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
Ftp::mkdir(Locator::Path("/new".into())),
|
Ftp::mkdir(Locator::Path("/new".into())),
|
||||||
Err(ErrorCode::Unavailable)
|
Err(ErrorCode::OutcomeUnknown)
|
||||||
));
|
));
|
||||||
Ftp::close();
|
Ftp::close();
|
||||||
let summary = fixture.summary();
|
let summary = fixture.summary();
|
||||||
@@ -75,3 +75,19 @@ async fn cancelled_preflight_never_reconnects_or_executes_the_mutation()
|
|||||||
assert_eq!(summary.mkdir_commands, 0);
|
assert_eq!(summary.mkdir_commands, 0);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_lost_upload_completion_reports_an_unknown_outcome() -> TestResult {
|
||||||
|
let fixture = test_host::install(test_host::Scenario::LostUploadCompletion);
|
||||||
|
guest(Ftp::open("ftp".into(), config()))?;
|
||||||
|
let socket = guest(mutation_transfer("STOR /upload"))?;
|
||||||
|
assert!(matches!(socket.write(b"body"), Ok(4)));
|
||||||
|
drop(socket);
|
||||||
|
assert!(matches!(settle_mutation(), Err(ErrorCode::OutcomeUnknown)));
|
||||||
|
Ftp::close();
|
||||||
|
let summary = fixture.summary();
|
||||||
|
assert_eq!(summary.control_connections, 1);
|
||||||
|
assert_eq!(summary.upload_commands, 1);
|
||||||
|
assert_eq!(summary.uploaded_bytes, 4);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
+24
-1
@@ -16,6 +16,7 @@ pub enum Scenario {
|
|||||||
CancelledPreflight,
|
CancelledPreflight,
|
||||||
ExpiredListing,
|
ExpiredListing,
|
||||||
LostMutationReply,
|
LostMutationReply,
|
||||||
|
LostUploadCompletion,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct State {
|
struct State {
|
||||||
@@ -23,6 +24,8 @@ struct State {
|
|||||||
control_connections: usize,
|
control_connections: usize,
|
||||||
listings: usize,
|
listings: usize,
|
||||||
mkdir_commands: usize,
|
mkdir_commands: usize,
|
||||||
|
upload_commands: usize,
|
||||||
|
uploaded_bytes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
@@ -35,6 +38,8 @@ pub struct Summary {
|
|||||||
pub control_connections: usize,
|
pub control_connections: usize,
|
||||||
pub listings: usize,
|
pub listings: usize,
|
||||||
pub mkdir_commands: usize,
|
pub mkdir_commands: usize,
|
||||||
|
pub upload_commands: usize,
|
||||||
|
pub uploaded_bytes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn install(scenario: Scenario) -> Fixture {
|
pub fn install(scenario: Scenario) -> Fixture {
|
||||||
@@ -43,6 +48,8 @@ pub fn install(scenario: Scenario) -> Fixture {
|
|||||||
control_connections: 0,
|
control_connections: 0,
|
||||||
listings: 0,
|
listings: 0,
|
||||||
mkdir_commands: 0,
|
mkdir_commands: 0,
|
||||||
|
upload_commands: 0,
|
||||||
|
uploaded_bytes: 0,
|
||||||
}));
|
}));
|
||||||
ACTIVE.set(Some(state.clone()));
|
ACTIVE.set(Some(state.clone()));
|
||||||
Fixture(state)
|
Fixture(state)
|
||||||
@@ -55,6 +62,8 @@ impl Fixture {
|
|||||||
control_connections: state.control_connections,
|
control_connections: state.control_connections,
|
||||||
listings: state.listings,
|
listings: state.listings,
|
||||||
mkdir_commands: state.mkdir_commands,
|
mkdir_commands: state.mkdir_commands,
|
||||||
|
upload_commands: state.upload_commands,
|
||||||
|
uploaded_bytes: state.uploaded_bytes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,8 +133,16 @@ impl Tcp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn write(&self, bytes: &[u8]) -> Result<u32, HostError> {
|
pub fn write(&self, bytes: &[u8]) -> Result<u32, HostError> {
|
||||||
let Kind::Control(index) = self.kind else {
|
if matches!(self.kind, Kind::Data) {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
if matches!(state.scenario, Scenario::LostUploadCompletion) {
|
||||||
|
state.uploaded_bytes += bytes.len();
|
||||||
|
return Ok(bytes.len() as u32);
|
||||||
|
}
|
||||||
return Err(HostError::InvalidInput);
|
return Err(HostError::InvalidInput);
|
||||||
|
}
|
||||||
|
let Kind::Control(index) = self.kind else {
|
||||||
|
unreachable!();
|
||||||
};
|
};
|
||||||
let command = std::str::from_utf8(bytes)
|
let command = std::str::from_utf8(bytes)
|
||||||
.map_err(|_| HostError::InvalidInput)?
|
.map_err(|_| HostError::InvalidInput)?
|
||||||
@@ -154,6 +171,12 @@ impl Tcp {
|
|||||||
return Err(HostError::Unavailable);
|
return Err(HostError::Unavailable);
|
||||||
}
|
}
|
||||||
"NOOP" => "200 Still connected\r\n",
|
"NOOP" => "200 Still connected\r\n",
|
||||||
|
"STOR /upload"
|
||||||
|
if matches!(state.scenario, Scenario::LostUploadCompletion) =>
|
||||||
|
{
|
||||||
|
state.upload_commands += 1;
|
||||||
|
"150 Upload started\r\n"
|
||||||
|
}
|
||||||
"MKD /new"
|
"MKD /new"
|
||||||
if index == 1
|
if index == 1
|
||||||
&& matches!(state.scenario, Scenario::LostMutationReply) =>
|
&& matches!(state.scenario, Scenario::LostMutationReply) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user