Open an FTP or FTPS server as a folder in Memona
This commit is contained in:
+175
@@ -0,0 +1,175 @@
|
||||
//! The FTP control channel: one socket, line replies, and passive transfers.
|
||||
|
||||
use memona_plugin_sdk::ErrorCode;
|
||||
use memona_plugin_sdk::memona::filesystem::host;
|
||||
|
||||
const MAX_LINE: usize = 8192;
|
||||
const MAX_REPLY_LINES: usize = 256;
|
||||
|
||||
pub fn transport(error: host::HostError) -> ErrorCode {
|
||||
match error {
|
||||
host::HostError::Denied => ErrorCode::Denied,
|
||||
host::HostError::Cancelled => ErrorCode::Cancelled,
|
||||
host::HostError::InvalidInput => ErrorCode::InvalidInput,
|
||||
host::HostError::Internal => ErrorCode::Internal,
|
||||
host::HostError::Unavailable => ErrorCode::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an FTP reply code means to a filesystem caller.
|
||||
pub fn outcome(code: u16) -> Result<(), ErrorCode> {
|
||||
match code {
|
||||
200..=399 => Ok(()),
|
||||
// 530 is a refused login and 550 covers both a missing file and a denied
|
||||
// one; the server does not distinguish them, so neither does this.
|
||||
530 | 532 => Err(ErrorCode::Denied),
|
||||
550 => Err(ErrorCode::NotFound),
|
||||
450 | 452 | 552 => Err(ErrorCode::Conflict),
|
||||
502 | 504 => Err(ErrorCode::Unsupported),
|
||||
_ => Err(ErrorCode::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Control {
|
||||
socket: host::Tcp,
|
||||
host: String,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Control {
|
||||
pub fn open(
|
||||
host_name: String,
|
||||
port: u16,
|
||||
tls: bool,
|
||||
) -> Result<Self, ErrorCode> {
|
||||
let socket = host::connect(&host_name, port, tls).map_err(transport)?;
|
||||
let mut control = Self {
|
||||
socket,
|
||||
host: host_name,
|
||||
buffer: Vec::new(),
|
||||
};
|
||||
// The greeting is a reply like any other, and a 4xx or 5xx one means the
|
||||
// server is refusing the session before a command was even sent.
|
||||
let (code, _) = control.reply()?;
|
||||
outcome(code)?;
|
||||
Ok(control)
|
||||
}
|
||||
|
||||
fn line(&mut self) -> Result<String, ErrorCode> {
|
||||
loop {
|
||||
if let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') {
|
||||
let line: Vec<u8> = self.buffer.drain(..=index).collect();
|
||||
return Ok(
|
||||
String::from_utf8_lossy(&line)
|
||||
.trim_end_matches(['\r', '\n'])
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
if self.buffer.len() > MAX_LINE {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
let chunk = self.socket.read(4096).map_err(transport)?;
|
||||
if chunk.is_empty() {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
self.buffer.extend_from_slice(&chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one complete reply, following the `NNN-` continuation form.
|
||||
pub fn reply(&mut self) -> Result<(u16, String), ErrorCode> {
|
||||
let first = self.line()?;
|
||||
let code: u16 = first
|
||||
.get(..3)
|
||||
.and_then(|text| text.parse().ok())
|
||||
.ok_or(ErrorCode::Unavailable)?;
|
||||
if first.as_bytes().get(3) != Some(&b'-') {
|
||||
return Ok((code, first));
|
||||
}
|
||||
let terminator = format!("{code} ");
|
||||
for _ in 0..MAX_REPLY_LINES {
|
||||
let line = self.line()?;
|
||||
if line.starts_with(&terminator) {
|
||||
return Ok((code, line));
|
||||
}
|
||||
}
|
||||
Err(ErrorCode::Unavailable)
|
||||
}
|
||||
|
||||
/// Sends one command. A newline in an argument would forge a second command,
|
||||
/// so it is refused rather than escaped.
|
||||
pub fn send(&mut self, command: &str) -> Result<(u16, String), ErrorCode> {
|
||||
if command.contains(['\r', '\n']) || command.len() > MAX_LINE {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
let line = format!("{command}\r\n");
|
||||
let mut sent = 0;
|
||||
while sent < line.len() {
|
||||
let written = self
|
||||
.socket
|
||||
.write(&line.as_bytes()[sent..])
|
||||
.map_err(transport)? as usize;
|
||||
if written == 0 {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
sent += written;
|
||||
}
|
||||
self.reply()
|
||||
}
|
||||
|
||||
pub fn expect(&mut self, command: &str) -> Result<String, ErrorCode> {
|
||||
let (code, text) = self.send(command)?;
|
||||
outcome(code)?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Opens a passive data connection, preferring EPSV so the address the
|
||||
/// server names is never one this plugin could be redirected to.
|
||||
pub fn passive(&mut self) -> Result<host::Tcp, ErrorCode> {
|
||||
let text = self.expect("EPSV")?;
|
||||
let port = extended_port(&text).ok_or(ErrorCode::Unavailable)?;
|
||||
host::connect(&self.host, port, false).map_err(transport)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the port out of `229 Entering Extended Passive Mode (|||1234|)`.
|
||||
fn extended_port(text: &str) -> Option<u16> {
|
||||
let start = text.find('(')?;
|
||||
let end = text[start..].find(')')? + start;
|
||||
let inner = text.get(start + 1..end)?;
|
||||
let separator = inner.chars().next()?;
|
||||
inner
|
||||
.rsplit(separator)
|
||||
.find(|part| !part.is_empty())?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{extended_port, outcome};
|
||||
use memona_plugin_sdk::ErrorCode;
|
||||
|
||||
#[test]
|
||||
fn reads_the_port_from_an_extended_passive_reply() {
|
||||
assert_eq!(
|
||||
extended_port("229 Entering Extended Passive Mode (|||49152|)"),
|
||||
Some(49152)
|
||||
);
|
||||
// The separator is whatever the server chose, not always a pipe.
|
||||
assert_eq!(extended_port("229 ok (!!!1024!)"), Some(1024));
|
||||
assert_eq!(extended_port("229 ok (|||0|)"), Some(0));
|
||||
assert_eq!(extended_port("229 ok"), None);
|
||||
assert_eq!(extended_port("229 ok (|||not-a-port|)"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_codes_keep_refused_and_missing_apart() {
|
||||
assert!(outcome(226).is_ok());
|
||||
assert!(matches!(outcome(530), Err(ErrorCode::Denied)));
|
||||
assert!(matches!(outcome(550), Err(ErrorCode::NotFound)));
|
||||
assert!(matches!(outcome(452), Err(ErrorCode::Conflict)));
|
||||
assert!(matches!(outcome(502), Err(ErrorCode::Unsupported)));
|
||||
assert!(matches!(outcome(421), Err(ErrorCode::Unavailable)));
|
||||
}
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
//! An FTP filesystem provider for Memona, speaking over the host's sockets.
|
||||
|
||||
mod control;
|
||||
mod listing;
|
||||
|
||||
use control::{Control, outcome, transport};
|
||||
use listing::machine_entry;
|
||||
use memona_plugin_sdk::memona::filesystem::host;
|
||||
use memona_plugin_sdk::{
|
||||
Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest,
|
||||
GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition,
|
||||
Writer,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
|
||||
const MAX_CHUNK: u32 = 1024 * 1024;
|
||||
const MAX_ENTRIES: usize = 4096;
|
||||
|
||||
thread_local! {
|
||||
static CONTROL: RefCell<Option<Control>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Ftp;
|
||||
|
||||
fn text(setting: Option<&Setting>) -> Option<String> {
|
||||
match setting {
|
||||
Some(Setting::Text(value)) | Some(Setting::Secret(value)) => {
|
||||
let value = value.trim().to_owned();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
Some(Setting::Toggle(_)) | None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The path a locator names. FTP addresses by path only.
|
||||
fn route(target: &Locator) -> Result<String, ErrorCode> {
|
||||
match target {
|
||||
Locator::Path(path) => Ok(path.clone()),
|
||||
Locator::Id(_) => Err(ErrorCode::InvalidInput),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_control<T>(
|
||||
action: impl FnOnce(&mut Control) -> Result<T, ErrorCode>,
|
||||
) -> Result<T, ErrorCode> {
|
||||
CONTROL.with_borrow_mut(|slot| match slot.as_mut() {
|
||||
Some(control) => action(control),
|
||||
None => Err(ErrorCode::Internal),
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts a passive transfer and leaves the data socket open for the caller.
|
||||
fn transfer(command: &str) -> Result<host::Tcp, ErrorCode> {
|
||||
with_control(|control| {
|
||||
let data = control.passive()?;
|
||||
let (code, _) = control.send(command)?;
|
||||
outcome(code)?;
|
||||
Ok(data)
|
||||
})
|
||||
}
|
||||
|
||||
/// Consumes the reply that follows a completed transfer, so the next command
|
||||
/// does not read someone else's answer.
|
||||
fn settle() -> Result<(), ErrorCode> {
|
||||
with_control(|control| {
|
||||
let (code, _) = control.reply()?;
|
||||
outcome(code)
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads a data socket to the end, bounded so one listing cannot grow forever.
|
||||
fn drain(socket: &host::Tcp, limit: usize) -> Result<Vec<u8>, ErrorCode> {
|
||||
let mut data = Vec::new();
|
||||
loop {
|
||||
let chunk = socket.read(MAX_CHUNK).map_err(transport)?;
|
||||
if chunk.is_empty() {
|
||||
return Ok(data);
|
||||
}
|
||||
data.extend_from_slice(&chunk);
|
||||
if data.len() > limit {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one file from a passive data socket.
|
||||
struct FtpReader {
|
||||
socket: host::Tcp,
|
||||
settled: RefCell<bool>,
|
||||
}
|
||||
|
||||
impl GuestReader for FtpReader {
|
||||
fn pull(&self, limit: u32) -> Result<Vec<u8>, ErrorCode> {
|
||||
if *self.settled.borrow() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let data = self.socket.read(limit.min(MAX_CHUNK)).map_err(transport)?;
|
||||
if data.is_empty() {
|
||||
*self.settled.borrow_mut() = true;
|
||||
settle()?;
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
/// FTP has no version token, so there is nothing honest to return.
|
||||
fn revision(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one file to a passive data socket.
|
||||
struct FtpWriter {
|
||||
socket: host::Tcp,
|
||||
done: RefCell<bool>,
|
||||
}
|
||||
|
||||
impl GuestWriter for FtpWriter {
|
||||
fn push(&self, data: Vec<u8>) -> Result<(), ErrorCode> {
|
||||
if *self.done.borrow() {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
let mut sent = 0;
|
||||
while sent < data.len() {
|
||||
let written =
|
||||
self.socket.write(&data[sent..]).map_err(transport)? as usize;
|
||||
if written == 0 {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
sent += written;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Closing the data socket is what ends an FTP upload, so the handle is
|
||||
/// dropped here and the control reply read once.
|
||||
fn finish(&self) -> Result<Option<FileInfo>, ErrorCode> {
|
||||
if *self.done.borrow() {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
*self.done.borrow_mut() = true;
|
||||
settle()?;
|
||||
Ok(Some(FileInfo {
|
||||
kind: EntryKind::File,
|
||||
size: 0,
|
||||
revision: None,
|
||||
id: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Guest for Ftp {
|
||||
type Reader = FtpReader;
|
||||
type Writer = FtpWriter;
|
||||
|
||||
fn open(
|
||||
_provider_id: String,
|
||||
config: Vec<(String, Setting)>,
|
||||
) -> Result<Capabilities, ErrorCode> {
|
||||
let lookup = |wanted: &str| {
|
||||
config
|
||||
.iter()
|
||||
.find(|(key, _)| key == wanted)
|
||||
.and_then(|(_, value)| text(Some(value)))
|
||||
};
|
||||
let host_name = lookup("host").ok_or(ErrorCode::InvalidInput)?;
|
||||
let port: u16 = lookup("port")
|
||||
.as_deref()
|
||||
.unwrap_or("21")
|
||||
.parse()
|
||||
.map_err(|_| ErrorCode::InvalidInput)?;
|
||||
let tls = matches!(
|
||||
config.iter().find(|(key, _)| key == "tls").map(|(_, v)| v),
|
||||
Some(Setting::Toggle(true))
|
||||
);
|
||||
let mut control = Control::open(host_name, port, tls)?;
|
||||
let user = lookup("username").unwrap_or_else(|| "anonymous".to_owned());
|
||||
let (code, _) = control.send(&format!("USER {user}"))?;
|
||||
if code == 331 {
|
||||
let password = lookup("password").unwrap_or_default();
|
||||
let (code, _) = control.send(&format!("PASS {password}"))?;
|
||||
outcome(code)?;
|
||||
} else {
|
||||
outcome(code)?;
|
||||
}
|
||||
// Binary throughout: text mode would rewrite line endings inside files.
|
||||
control.expect("TYPE I")?;
|
||||
CONTROL.set(Some(control));
|
||||
Ok(Capabilities {
|
||||
write: true,
|
||||
mkdir: true,
|
||||
rename: true,
|
||||
remove: true,
|
||||
// FTP has no precondition on a store, so a conditional write cannot be
|
||||
// promised without simulating one, which the contract forbids.
|
||||
conditional_write: false,
|
||||
watch: false,
|
||||
staged_write: false,
|
||||
publishable: false,
|
||||
virtual_directories: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn close() {
|
||||
CONTROL.with_borrow_mut(|slot| {
|
||||
if let Some(control) = slot.as_mut() {
|
||||
let _ = control.send("QUIT");
|
||||
}
|
||||
*slot = None;
|
||||
});
|
||||
}
|
||||
|
||||
/// FTP reports a size and a directory separately, so a stat is SIZE first
|
||||
/// and a directory change only when that fails.
|
||||
fn stat(target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||
let path = route(&target)?;
|
||||
if path == "/" {
|
||||
return Ok(FileInfo {
|
||||
kind: EntryKind::Directory,
|
||||
size: 0,
|
||||
revision: None,
|
||||
id: None,
|
||||
});
|
||||
}
|
||||
with_control(|control| {
|
||||
let (code, text) = control.send(&format!("SIZE {path}"))?;
|
||||
if (200..300).contains(&code) {
|
||||
let size = text
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(0);
|
||||
return Ok(FileInfo {
|
||||
kind: EntryKind::File,
|
||||
size,
|
||||
revision: None,
|
||||
id: None,
|
||||
});
|
||||
}
|
||||
let (code, _) = control.send(&format!("CWD {path}"))?;
|
||||
outcome(code)?;
|
||||
Ok(FileInfo {
|
||||
kind: EntryKind::Directory,
|
||||
size: 0,
|
||||
revision: None,
|
||||
id: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// MLSD is the machine-readable listing; a server without it cannot be read
|
||||
/// reliably, so this reports unsupported rather than guessing at LIST text.
|
||||
fn list(
|
||||
target: Locator,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<ListPage, ErrorCode> {
|
||||
let path = route(&target)?;
|
||||
let start: usize = match cursor {
|
||||
Some(value) => value.parse().map_err(|_| ErrorCode::InvalidInput)?,
|
||||
None => 0,
|
||||
};
|
||||
let socket = transfer(&format!("MLSD {path}"))?;
|
||||
let data = drain(&socket, 8 * 1024 * 1024)?;
|
||||
drop(socket);
|
||||
settle()?;
|
||||
let children: Vec<Entry> = String::from_utf8_lossy(&data)
|
||||
.lines()
|
||||
.filter_map(machine_entry)
|
||||
.take(MAX_ENTRIES)
|
||||
.collect();
|
||||
let end = start.saturating_add(limit as usize).min(children.len());
|
||||
let entries = children.get(start..end).unwrap_or_default().to_vec();
|
||||
Ok(ListPage {
|
||||
entries,
|
||||
cursor: (end < children.len()).then(|| end.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn open_read(target: Locator, offset: u64) -> Result<Reader, ErrorCode> {
|
||||
let path = route(&target)?;
|
||||
if offset > 0 {
|
||||
with_control(|control| control.expect(&format!("REST {offset}")))?;
|
||||
}
|
||||
let socket = transfer(&format!("RETR {path}"))?;
|
||||
Ok(Reader::new(FtpReader {
|
||||
socket,
|
||||
settled: RefCell::new(false),
|
||||
}))
|
||||
}
|
||||
|
||||
fn open_write(
|
||||
target: Locator,
|
||||
condition: WriteCondition,
|
||||
_size_hint: Option<u64>,
|
||||
) -> Result<Writer, ErrorCode> {
|
||||
let path = route(&target)?;
|
||||
if !matches!(condition, WriteCondition::Overwrite) {
|
||||
return Err(ErrorCode::Unsupported);
|
||||
}
|
||||
let socket = transfer(&format!("STOR {path}"))?;
|
||||
Ok(Writer::new(FtpWriter {
|
||||
socket,
|
||||
done: RefCell::new(false),
|
||||
}))
|
||||
}
|
||||
|
||||
fn mkdir(target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||
let path = route(&target)?;
|
||||
with_control(|control| control.expect(&format!("MKD {path}")))?;
|
||||
Ok(FileInfo {
|
||||
kind: EntryKind::Directory,
|
||||
size: 0,
|
||||
revision: None,
|
||||
id: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn rename(
|
||||
target: Locator,
|
||||
destination: String,
|
||||
) -> Result<FileInfo, ErrorCode> {
|
||||
let path = route(&target)?;
|
||||
with_control(|control| {
|
||||
control.expect(&format!("RNFR {path}"))?;
|
||||
control.expect(&format!("RNTO {destination}"))
|
||||
})?;
|
||||
Self::stat(Locator::Path(destination))
|
||||
}
|
||||
|
||||
/// A file and an empty directory take different commands, and the server
|
||||
/// only says which was wrong after the attempt.
|
||||
fn remove(target: Locator) -> Result<(), ErrorCode> {
|
||||
let path = route(&target)?;
|
||||
with_control(|control| {
|
||||
let (code, _) = control.send(&format!("DELE {path}"))?;
|
||||
if (200..300).contains(&code) {
|
||||
return Ok(());
|
||||
}
|
||||
let (code, _) = control.send(&format!("RMD {path}"))?;
|
||||
outcome(code)
|
||||
})
|
||||
}
|
||||
|
||||
fn poll(_cursor: Option<String>) -> Result<ChangePage, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn publish() -> Result<Option<String>, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
memona_plugin_sdk::export!(Ftp);
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Reading an MLSD listing, which is the one machine-readable form FTP has.
|
||||
|
||||
use memona_plugin_sdk::{Entry, EntryKind, FileInfo};
|
||||
|
||||
/// One line of an MLSD listing: `fact=value;...; name`.
|
||||
pub fn machine_entry(line: &str) -> Option<Entry> {
|
||||
let (facts, name) = line.split_once(' ')?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name == "." || name == ".." {
|
||||
return None;
|
||||
}
|
||||
let mut kind = EntryKind::Other;
|
||||
let mut size = 0u64;
|
||||
for fact in facts.split(';').filter(|fact| !fact.is_empty()) {
|
||||
let (key, value) = fact.split_once('=')?;
|
||||
match key.to_ascii_lowercase().as_str() {
|
||||
"type" => {
|
||||
kind = match value.to_ascii_lowercase().as_str() {
|
||||
"file" => EntryKind::File,
|
||||
"dir" | "cdir" | "pdir" => EntryKind::Directory,
|
||||
_ => EntryKind::Other,
|
||||
};
|
||||
}
|
||||
"size" => size = value.parse().unwrap_or(0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if matches!(kind, EntryKind::Directory) && name.starts_with('.') {
|
||||
// A listing may repeat the directory itself; its own name is never a child.
|
||||
return None;
|
||||
}
|
||||
Some(Entry {
|
||||
name: name.to_owned(),
|
||||
info: FileInfo {
|
||||
kind,
|
||||
size,
|
||||
revision: None,
|
||||
id: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::machine_entry;
|
||||
use memona_plugin_sdk::EntryKind;
|
||||
|
||||
#[test]
|
||||
fn reads_kind_and_size_and_skips_the_directory_itself()
|
||||
-> Result<(), &'static str> {
|
||||
let file =
|
||||
machine_entry("type=file;size=1234;modify=20260101000000; notes.md")
|
||||
.ok_or("a file entry")?;
|
||||
assert_eq!(file.name, "notes.md");
|
||||
assert_eq!(file.info.size, 1234);
|
||||
assert!(matches!(file.info.kind, EntryKind::File));
|
||||
|
||||
let directory =
|
||||
machine_entry("type=dir;sizd=4096; papers").ok_or("a directory entry")?;
|
||||
assert!(matches!(directory.info.kind, EntryKind::Directory));
|
||||
assert_eq!(directory.info.size, 0);
|
||||
|
||||
// A name of its own or its parent is not a child of the listing.
|
||||
assert!(machine_entry("type=cdir; .").is_none());
|
||||
assert!(machine_entry("type=pdir; ..").is_none());
|
||||
assert!(machine_entry("type=file;size=1; ").is_none());
|
||||
assert!(machine_entry("no-space-anywhere").is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_type_is_reported_rather_than_guessed()
|
||||
-> Result<(), &'static str> {
|
||||
let entry = machine_entry("type=OS.unix=slink:/elsewhere;size=0; link")
|
||||
.ok_or("an entry")?;
|
||||
assert!(matches!(entry.info.kind, EntryKind::Other));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user