Recover expired FTP control sessions
This commit is contained in:
Generated
+59
@@ -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 = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -71,6 +77,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"
|
||||||
@@ -109,6 +121,17 @@ dependencies = [
|
|||||||
"wit-bindgen",
|
"wit-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -191,6 +214,16 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -208,8 +241,13 @@ version = "1.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -229,6 +267,12 @@ 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 = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-encoder"
|
name = "wasm-encoder"
|
||||||
version = "0.259.0"
|
version = "0.259.0"
|
||||||
@@ -263,6 +307,21 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.62.0"
|
version = "0.62.0"
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ crate-type = ["cdylib", "rlib"]
|
|||||||
memona-plugin-sdk = { path = "sdk" }
|
memona-plugin-sdk = { path = "sdk" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["macros", "rt"] }
|
tokio = { version = "1", features = ["macros", "rt", "net", "io-util", "sync", "time"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ One provider, `ftp`, for the `ftp` and `ftps` schemes. A connection is
|
|||||||
configured with a server name, an optional port, a TLS toggle for FTPS, and an
|
configured with a server name, an optional port, a TLS toggle for FTPS, and an
|
||||||
optional username and password. An empty username signs in anonymously.
|
optional username and password. An empty username signs in anonymously.
|
||||||
|
|
||||||
For localhost or a server on a private network, turn on **Private network** in
|
Public, LAN, VPN, link-local and localhost addresses work directly from the
|
||||||
the connection settings. Save and reconnect after changing these settings.
|
server name; there is no separate network permission setting.
|
||||||
|
|
||||||
The TLS toggle currently starts TLS on the control connection only. Protected
|
The TLS toggle currently starts TLS on the control connection only. Protected
|
||||||
data connections and explicit `AUTH TLS` negotiation are not implemented, so
|
data connections and explicit `AUTH TLS` negotiation are not implemented, so
|
||||||
|
|||||||
+10
-11
@@ -35,11 +35,10 @@ interface host {
|
|||||||
/// `respond`. An empty result means the body is complete.
|
/// `respond`. An empty result means the body is complete.
|
||||||
pull: func(limit: u32) -> result<list<u8>, host-error>;
|
pull: func(limit: u32) -> result<list<u8>, host-error>;
|
||||||
}
|
}
|
||||||
/// Begins a request to any public HTTP or HTTPS host. Each redirect and
|
/// Begins a request to any HTTP or HTTPS host, including LAN and loopback
|
||||||
/// DNS result is checked, and DNS is pinned. Private/LAN addresses require
|
/// addresses. Each redirect and DNS result is checked, and DNS is pinned.
|
||||||
/// the connection's private-network grant. Cross-origin redirects remove
|
/// Cross-origin redirects remove request headers; HTTPS cannot downgrade.
|
||||||
/// request headers; HTTPS cannot
|
/// The host bounds total bytes per exchange, not buffer size.
|
||||||
/// downgrade. The host bounds total bytes per exchange, not buffer size.
|
|
||||||
http: func(request: request) -> result<exchange, host-error>;
|
http: func(request: request) -> result<exchange, host-error>;
|
||||||
|
|
||||||
/// One open TCP connection. Dropping the handle closes the socket, and the
|
/// One open TCP connection. Dropping the handle closes the socket, and the
|
||||||
@@ -51,10 +50,10 @@ interface host {
|
|||||||
/// Writes at most 1,048,576 bytes and returns how many were accepted.
|
/// Writes at most 1,048,576 bytes and returns how many were accepted.
|
||||||
write: func(data: list<u8>) -> result<u32, host-error>;
|
write: func(data: list<u8>) -> result<u32, host-error>;
|
||||||
}
|
}
|
||||||
/// Opens a socket to any public host on a package-declared port.
|
/// Opens a socket to any host, including LAN and loopback addresses, on a
|
||||||
/// The host resolves and pins DNS, applies the same private-address rule
|
/// package-declared port. The host resolves and pins DNS and starts TLS
|
||||||
/// as `http`, and starts TLS when asked. Ports
|
/// when asked. Ports negotiated at runtime, such as an FTP data channel,
|
||||||
/// negotiated at runtime, such as an FTP data channel, must be declared.
|
/// must be declared.
|
||||||
connect: func(host: string, port: u16, tls: bool) -> result<tcp, host-error>;
|
connect: func(host: string, port: u16, tls: bool) -> result<tcp, host-error>;
|
||||||
|
|
||||||
/// One datagram socket connected to a single peer.
|
/// One datagram socket connected to a single peer.
|
||||||
@@ -65,8 +64,8 @@ interface host {
|
|||||||
/// means none arrived inside the host's wait, not that the peer left.
|
/// means none arrived inside the host's wait, not that the peer left.
|
||||||
receive: func(limit: u32) -> result<list<u8>, host-error>;
|
receive: func(limit: u32) -> result<list<u8>, host-error>;
|
||||||
}
|
}
|
||||||
/// Connects a datagram socket under the same port and private-network rules
|
/// Connects a datagram socket under the same port rules as `connect`, for
|
||||||
/// as `connect`, for the few filesystem protocols that still need one.
|
/// the few filesystem protocols that still need one.
|
||||||
bind: func(host: string, port: u16) -> result<udp, host-error>;
|
bind: func(host: string, port: u16) -> result<udp, host-error>;
|
||||||
|
|
||||||
/// A configuration value, typed as the package's manifest declared it.
|
/// A configuration value, typed as the package's manifest declared it.
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
//! The FTP control channel: one socket, line replies, and passive transfers.
|
//! The FTP control channel: one socket, line replies, and passive transfers.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::test_host as host;
|
||||||
use memona_plugin_sdk::ErrorCode;
|
use memona_plugin_sdk::ErrorCode;
|
||||||
|
#[cfg(not(test))]
|
||||||
use memona_plugin_sdk::memona::filesystem::host;
|
use memona_plugin_sdk::memona::filesystem::host;
|
||||||
|
|
||||||
const MAX_LINE: usize = 8192;
|
const MAX_LINE: usize = 8192;
|
||||||
|
|||||||
+78
-16
@@ -4,8 +4,14 @@ mod control;
|
|||||||
mod listing;
|
mod listing;
|
||||||
mod transfer;
|
mod transfer;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod session_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_host;
|
||||||
|
|
||||||
use control::{Control, outcome, preliminary, refusal, transport};
|
use control::{Control, outcome, preliminary, refusal, transport};
|
||||||
use listing::{machine_info, page};
|
use listing::{machine_info, page};
|
||||||
|
#[cfg(not(test))]
|
||||||
use memona_plugin_sdk::memona::filesystem::host;
|
use memona_plugin_sdk::memona::filesystem::host;
|
||||||
use memona_plugin_sdk::{
|
use memona_plugin_sdk::{
|
||||||
Capabilities, ChangePage, EntryKind, ErrorCode, FileInfo, Guest, GuestReader,
|
Capabilities, ChangePage, EntryKind, ErrorCode, FileInfo, Guest, GuestReader,
|
||||||
@@ -14,6 +20,8 @@ use memona_plugin_sdk::{
|
|||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
#[cfg(test)]
|
||||||
|
use test_host as host;
|
||||||
use transfer::Buffered;
|
use transfer::Buffered;
|
||||||
|
|
||||||
const MAX_CHUNK: u32 = 1024 * 1024;
|
const MAX_CHUNK: u32 = 1024 * 1024;
|
||||||
@@ -22,6 +30,7 @@ const MAX_LISTING_BYTES: usize = 8 * 1024 * 1024;
|
|||||||
|
|
||||||
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) };
|
||||||
/// 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());
|
||||||
@@ -29,6 +38,31 @@ thread_local! {
|
|||||||
|
|
||||||
struct Ftp;
|
struct Ftp;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Session {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
tls: bool,
|
||||||
|
user: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
fn connect(&self) -> Result<Control, ErrorCode> {
|
||||||
|
let mut control = Control::open(self.host.clone(), self.port, self.tls)?;
|
||||||
|
let (code, _) = control.send(&format!("USER {}", self.user))?;
|
||||||
|
if code == 331 {
|
||||||
|
let (code, _) = control.send(&format!("PASS {}", self.password))?;
|
||||||
|
outcome(code)?;
|
||||||
|
} else {
|
||||||
|
outcome(code)?;
|
||||||
|
}
|
||||||
|
// Binary throughout: text mode would rewrite line endings inside files.
|
||||||
|
control.expect("TYPE I")?;
|
||||||
|
Ok(control)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn text(setting: Option<&Setting>) -> Option<String> {
|
fn text(setting: Option<&Setting>) -> Option<String> {
|
||||||
match setting {
|
match setting {
|
||||||
Some(Setting::Text(value)) => {
|
Some(Setting::Text(value)) => {
|
||||||
@@ -50,12 +84,32 @@ fn route(target: &Locator) -> Result<String, ErrorCode> {
|
|||||||
fn with_control<T>(
|
fn with_control<T>(
|
||||||
action: impl FnOnce(&mut Control) -> Result<T, ErrorCode>,
|
action: impl FnOnce(&mut Control) -> Result<T, ErrorCode>,
|
||||||
) -> Result<T, ErrorCode> {
|
) -> Result<T, ErrorCode> {
|
||||||
CONTROL.with_borrow_mut(|slot| match slot.as_mut() {
|
CONTROL.with_borrow_mut(|slot| {
|
||||||
Some(control) => action(control),
|
let result = match slot.as_mut() {
|
||||||
None => Err(ErrorCode::Internal),
|
Some(control) => action(control),
|
||||||
|
None => Err(ErrorCode::Internal),
|
||||||
|
};
|
||||||
|
if matches!(result, Err(ErrorCode::Unavailable | ErrorCode::Internal)) {
|
||||||
|
*slot = None;
|
||||||
|
}
|
||||||
|
result
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks a control channel only before a fresh filesystem operation. A lost
|
||||||
|
/// idle session is replaced and logged in again. Nothing inside a transfer or
|
||||||
|
/// mutation retries, because its missing reply may hide a completed change.
|
||||||
|
fn ready() -> Result<(), ErrorCode> {
|
||||||
|
if with_control(|control| control.expect("NOOP")).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let session = SESSION
|
||||||
|
.with_borrow(Clone::clone)
|
||||||
|
.ok_or(ErrorCode::Internal)?;
|
||||||
|
CONTROL.set(Some(session.connect()?));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Starts a passive transfer and leaves the data socket open for the caller,
|
/// Starts a passive transfer and leaves the data socket open for the caller,
|
||||||
/// who owes the server one more reply once that socket is closed: either the
|
/// who owes the server one more reply once that socket is closed: either the
|
||||||
/// completion, read by `settle`, or the answer to an `abort`.
|
/// completion, read by `settle`, or the answer to an `abort`.
|
||||||
@@ -241,19 +295,16 @@ impl Guest for Ftp {
|
|||||||
config.iter().find(|(key, _)| key == "tls").map(|(_, v)| v),
|
config.iter().find(|(key, _)| key == "tls").map(|(_, v)| v),
|
||||||
Some(Setting::Toggle(true))
|
Some(Setting::Toggle(true))
|
||||||
);
|
);
|
||||||
let mut control = Control::open(host_name, port, tls)?;
|
|
||||||
let user = lookup("username").unwrap_or_else(|| "anonymous".to_owned());
|
let user = lookup("username").unwrap_or_else(|| "anonymous".to_owned());
|
||||||
let (code, _) = control.send(&format!("USER {user}"))?;
|
let session = Session {
|
||||||
if code == 331 {
|
host: host_name,
|
||||||
let password = lookup("password").unwrap_or_default();
|
port,
|
||||||
let (code, _) = control.send(&format!("PASS {password}"))?;
|
tls,
|
||||||
outcome(code)?;
|
user,
|
||||||
} else {
|
password: lookup("password").unwrap_or_default(),
|
||||||
outcome(code)?;
|
};
|
||||||
}
|
CONTROL.set(Some(session.connect()?));
|
||||||
// Binary throughout: text mode would rewrite line endings inside files.
|
SESSION.set(Some(session));
|
||||||
control.expect("TYPE I")?;
|
|
||||||
CONTROL.set(Some(control));
|
|
||||||
Ok(Capabilities {
|
Ok(Capabilities {
|
||||||
write: true,
|
write: true,
|
||||||
mkdir: true,
|
mkdir: true,
|
||||||
@@ -274,6 +325,7 @@ impl Guest for Ftp {
|
|||||||
}
|
}
|
||||||
*slot = None;
|
*slot = None;
|
||||||
});
|
});
|
||||||
|
SESSION.set(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stat(target: Locator) -> Result<FileInfo, ErrorCode> {
|
fn stat(target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||||
@@ -285,6 +337,7 @@ impl Guest for Ftp {
|
|||||||
revision: None,
|
revision: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
ready()?;
|
||||||
with_control(|control| probe(control, &path))
|
with_control(|control| probe(control, &path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,13 +359,17 @@ impl Guest for Ftp {
|
|||||||
});
|
});
|
||||||
let listing = match kept {
|
let listing = match kept {
|
||||||
Some(listing) => listing,
|
Some(listing) => listing,
|
||||||
None => fetch_listing(&path)?,
|
None => {
|
||||||
|
ready()?;
|
||||||
|
fetch_listing(&path)?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
page(&listing, start, limit as usize)
|
page(&listing, start, limit as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_read(target: Locator, offset: u64) -> Result<Reader, ErrorCode> {
|
fn open_read(target: Locator, offset: u64) -> Result<Reader, ErrorCode> {
|
||||||
let path = route(&target)?;
|
let path = route(&target)?;
|
||||||
|
ready()?;
|
||||||
if offset > 0 {
|
if offset > 0 {
|
||||||
with_control(|control| control.expect(&format!("REST {offset}")))?;
|
with_control(|control| control.expect(&format!("REST {offset}")))?;
|
||||||
}
|
}
|
||||||
@@ -332,6 +389,7 @@ impl Guest for Ftp {
|
|||||||
if !matches!(condition, WriteCondition::Overwrite) {
|
if !matches!(condition, WriteCondition::Overwrite) {
|
||||||
return Err(ErrorCode::Unsupported);
|
return Err(ErrorCode::Unsupported);
|
||||||
}
|
}
|
||||||
|
ready()?;
|
||||||
let socket = transfer(&format!("STOR {path}"))?;
|
let socket = transfer(&format!("STOR {path}"))?;
|
||||||
Ok(Writer::new(FtpWriter {
|
Ok(Writer::new(FtpWriter {
|
||||||
path,
|
path,
|
||||||
@@ -344,6 +402,7 @@ impl Guest for Ftp {
|
|||||||
/// the one thing the server says in words rather than in its reply code.
|
/// the one thing the server says in words rather than in its reply code.
|
||||||
fn mkdir(target: Locator) -> Result<FileInfo, ErrorCode> {
|
fn mkdir(target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||||
let path = route(&target)?;
|
let path = route(&target)?;
|
||||||
|
ready()?;
|
||||||
with_control(|control| {
|
with_control(|control| {
|
||||||
let (code, text) = control.send(&format!("MKD {path}"))?;
|
let (code, text) = control.send(&format!("MKD {path}"))?;
|
||||||
refusal(code, &text)
|
refusal(code, &text)
|
||||||
@@ -362,6 +421,7 @@ impl Guest for Ftp {
|
|||||||
destination: String,
|
destination: String,
|
||||||
) -> Result<FileInfo, ErrorCode> {
|
) -> Result<FileInfo, ErrorCode> {
|
||||||
let path = route(&target)?;
|
let path = route(&target)?;
|
||||||
|
ready()?;
|
||||||
with_control(|control| {
|
with_control(|control| {
|
||||||
match probe(control, &destination) {
|
match probe(control, &destination) {
|
||||||
Ok(_) => return Err(ErrorCode::Conflict),
|
Ok(_) => return Err(ErrorCode::Conflict),
|
||||||
@@ -379,6 +439,7 @@ impl Guest for Ftp {
|
|||||||
/// holds children is refused as a conflict, not as an absence.
|
/// holds children is refused as a conflict, not as an absence.
|
||||||
fn remove(target: Locator) -> Result<(), ErrorCode> {
|
fn remove(target: Locator) -> Result<(), ErrorCode> {
|
||||||
let path = route(&target)?;
|
let path = route(&target)?;
|
||||||
|
ready()?;
|
||||||
with_control(|control| {
|
with_control(|control| {
|
||||||
let (code, _) = control.send(&format!("DELE {path}"))?;
|
let (code, _) = control.send(&format!("DELE {path}"))?;
|
||||||
if (200..300).contains(&code) {
|
if (200..300).contains(&code) {
|
||||||
@@ -394,6 +455,7 @@ impl Guest for Ftp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
memona_plugin_sdk::export!(Ftp);
|
memona_plugin_sdk::export!(Ftp);
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! Real FTP commands over a native test socket, with an async local server.
|
||||||
|
//! This verifies guest recovery; WASM host integration is exercised separately.
|
||||||
|
|
||||||
|
use super::Ftp;
|
||||||
|
use memona_plugin_sdk::{ErrorCode, Guest, Locator, Setting};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
type Channel = BufReader<TcpStream>;
|
||||||
|
|
||||||
|
fn guest<T>(value: Result<T, ErrorCode>) -> TestResult<T> {
|
||||||
|
value.map_err(|error| format!("guest operation failed: {error:?}").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(port: u16) -> Vec<(String, Setting)> {
|
||||||
|
vec![
|
||||||
|
("host".into(), Setting::Text("127.0.0.1".into())),
|
||||||
|
("port".into(), Setting::Text(port.to_string())),
|
||||||
|
("username".into(), Setting::Text("fixture".into())),
|
||||||
|
(
|
||||||
|
"password".into(),
|
||||||
|
Setting::Secret(" fixture secret ".into()),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reply(channel: &mut Channel, text: &str) -> TestResult {
|
||||||
|
channel.get_mut().write_all(text.as_bytes()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn expect(channel: &mut Channel, expected: &str) -> TestResult {
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
channel.read_line(&mut line).await?;
|
||||||
|
if line == "NOOP\r\n" {
|
||||||
|
reply(channel, "200 Still connected\r\n").await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert_eq!(line, format!("{expected}\r\n"));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(listener: &TcpListener) -> TestResult<Channel> {
|
||||||
|
let (stream, _) = listener.accept().await?;
|
||||||
|
let mut channel = BufReader::new(stream);
|
||||||
|
reply(&mut channel, "220 Fixture\r\n").await?;
|
||||||
|
expect(&mut channel, "USER fixture").await?;
|
||||||
|
reply(&mut channel, "331 Password required\r\n").await?;
|
||||||
|
expect(&mut channel, "PASS fixture secret ").await?;
|
||||||
|
reply(&mut channel, "230 Signed in\r\n").await?;
|
||||||
|
expect(&mut channel, "TYPE I").await?;
|
||||||
|
reply(&mut channel, "200 Binary\r\n").await?;
|
||||||
|
Ok(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn listing(channel: &mut Channel) -> TestResult {
|
||||||
|
expect(channel, "EPSV").await?;
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||||
|
reply(
|
||||||
|
channel,
|
||||||
|
&format!("229 Passive (|||{}|)\r\n", listener.local_addr()?.port()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
expect(channel, "MLSD /").await?;
|
||||||
|
reply(channel, "150 Listing\r\n").await?;
|
||||||
|
let (mut data, _) = listener.accept().await?;
|
||||||
|
data.write_all(b"type=file;size=3; report.md \r\n").await?;
|
||||||
|
data.shutdown().await?;
|
||||||
|
drop(data);
|
||||||
|
reply(channel, "226 Complete\r\n").await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_listing_reconnects_after_the_server_expires_the_control_session()
|
||||||
|
-> TestResult {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||||
|
let port = listener.local_addr()?.port();
|
||||||
|
let (expired, wait_expired) = oneshot::channel();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
let mut channel = login(&listener).await?;
|
||||||
|
listing(&mut channel).await?;
|
||||||
|
reply(&mut channel, "421 Control connection timed out\r\n").await?;
|
||||||
|
channel.get_mut().shutdown().await?;
|
||||||
|
drop(channel);
|
||||||
|
expired.send(()).map_err(|_| "guest disappeared")?;
|
||||||
|
let mut channel = login(&listener).await?;
|
||||||
|
listing(&mut channel).await?;
|
||||||
|
expect(&mut channel, "QUIT").await?;
|
||||||
|
reply(&mut channel, "221 Bye\r\n").await
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
});
|
||||||
|
let client = tokio::task::spawn_blocking(move || -> TestResult {
|
||||||
|
guest(Ftp::open("ftp".into(), config(port)))?;
|
||||||
|
let first = guest(Ftp::list(Locator::Path("/".into()), None, 10))?;
|
||||||
|
assert_eq!(first.entries[0].name, " report.md ");
|
||||||
|
wait_expired.blocking_recv()?;
|
||||||
|
let next = guest(Ftp::list(Locator::Path("/".into()), None, 10))?;
|
||||||
|
assert_eq!(next.entries[0].name, " report.md ");
|
||||||
|
Ftp::close();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if client.is_err() {
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
|
client?;
|
||||||
|
server.await??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recovery_before_a_mutation_never_replays_a_lost_mutation_reply()
|
||||||
|
-> TestResult {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||||
|
let port = listener.local_addr()?.port();
|
||||||
|
let (expired, wait_expired) = oneshot::channel();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
let mut channel = login(&listener).await?;
|
||||||
|
channel.get_mut().shutdown().await?;
|
||||||
|
drop(channel);
|
||||||
|
expired.send(()).map_err(|_| "guest disappeared")?;
|
||||||
|
let mut channel = login(&listener).await?;
|
||||||
|
expect(&mut channel, "MKD /new").await?;
|
||||||
|
// The mutation may have succeeded; lose its reply deliberately.
|
||||||
|
channel.get_mut().shutdown().await?;
|
||||||
|
drop(channel);
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(listener)
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
});
|
||||||
|
let client = tokio::task::spawn_blocking(move || -> TestResult {
|
||||||
|
guest(Ftp::open("ftp".into(), config(port)))?;
|
||||||
|
wait_expired.blocking_recv()?;
|
||||||
|
assert!(matches!(
|
||||||
|
Ftp::mkdir(Locator::Path("/new".into())),
|
||||||
|
Err(ErrorCode::Unavailable)
|
||||||
|
));
|
||||||
|
Ftp::close();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if client.is_err() {
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
|
client?;
|
||||||
|
let listener = server.await??;
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(50), listener.accept())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"a mutation must not be replayed on a third control connection"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
//! Native test transport. Guest calls run only inside `spawn_blocking`;
|
||||||
|
//! production WASM always uses the host's sockets and network policy.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{IpAddr, SocketAddr, TcpStream};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub use memona_plugin_sdk::HostError;
|
||||||
|
|
||||||
|
pub struct Tcp(RefCell<TcpStream>);
|
||||||
|
|
||||||
|
pub fn connect(name: &str, port: u16, tls: bool) -> Result<Tcp, HostError> {
|
||||||
|
if tls {
|
||||||
|
return Err(HostError::InvalidInput);
|
||||||
|
}
|
||||||
|
let ip: IpAddr = name.parse().map_err(|_| HostError::InvalidInput)?;
|
||||||
|
let timeout = Duration::from_secs(2);
|
||||||
|
let stream = TcpStream::connect_timeout(&SocketAddr::new(ip, port), timeout)
|
||||||
|
.map_err(|_| HostError::Unavailable)?;
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(timeout))
|
||||||
|
.map_err(|_| HostError::Unavailable)?;
|
||||||
|
stream
|
||||||
|
.set_write_timeout(Some(timeout))
|
||||||
|
.map_err(|_| HostError::Unavailable)?;
|
||||||
|
Ok(Tcp(RefCell::new(stream)))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tcp {
|
||||||
|
pub fn read(&self, limit: u32) -> Result<Vec<u8>, HostError> {
|
||||||
|
let mut bytes = vec![0; limit as usize];
|
||||||
|
let count = self
|
||||||
|
.0
|
||||||
|
.borrow_mut()
|
||||||
|
.read(&mut bytes)
|
||||||
|
.map_err(|_| HostError::Unavailable)?;
|
||||||
|
bytes.truncate(count);
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write(&self, bytes: &[u8]) -> Result<u32, HostError> {
|
||||||
|
self
|
||||||
|
.0
|
||||||
|
.borrow_mut()
|
||||||
|
.write(bytes)
|
||||||
|
.map(|count| count as u32)
|
||||||
|
.map_err(|_| HostError::Unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user