use serde::Serialize;
use crate::error::SailError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EntryType {
File,
Directory,
Symlink,
Other,
}
impl EntryType {
fn from_find_letter(letter: &str) -> EntryType {
match letter {
"d" => EntryType::Directory,
"l" => EntryType::Symlink,
"f" => EntryType::File,
_ => EntryType::Other,
}
}
pub fn as_str(self) -> &'static str {
match self {
EntryType::File => "file",
EntryType::Directory => "directory",
EntryType::Symlink => "symlink",
EntryType::Other => "other",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DirEntry {
pub name: String,
#[serde(rename = "type")]
pub entry_type: EntryType,
pub size: u64,
pub modified_time: f64,
pub mode: u32,
}
pub(crate) fn list_dir_argv(path: &str) -> Vec<String> {
let start = if path.starts_with('/') {
path.to_string()
} else {
format!("./{path}")
};
vec![
"find".to_string(),
"-H".to_string(),
start,
"-maxdepth".to_string(),
"1".to_string(),
"-printf".to_string(),
"%y\\t%s\\t%T@\\t%m\\t%f\\0".to_string(),
]
}
pub(crate) fn parse_dir_entries(stdout: &[u8]) -> Result<Vec<DirEntry>, String> {
let mut entries = Vec::new();
for record in stdout.split(|byte| *byte == b'\0') {
if record.is_empty() {
continue;
}
let malformed = || {
let snippet: String = String::from_utf8_lossy(record).chars().take(80).collect();
format!("malformed record {snippet:?}; GNU find is required in the guest")
};
let mut fields = record.splitn(5, |byte| *byte == b'\t');
let (Some(type_letter), Some(size_text), Some(mtime_text), Some(mode_text), Some(name)) = (
fields.next(),
fields.next(),
fields.next(),
fields.next(),
fields.next(),
) else {
return Err(malformed());
};
let (Ok(type_letter), Ok(size_text), Ok(mtime_text), Ok(mode_text)) = (
std::str::from_utf8(type_letter),
std::str::from_utf8(size_text),
std::str::from_utf8(mtime_text),
std::str::from_utf8(mode_text),
) else {
return Err(malformed());
};
let (Ok(size), Ok(modified_time), Ok(mode)) = (
size_text.parse(),
mtime_text.parse(),
u32::from_str_radix(mode_text, 8),
) else {
return Err(malformed());
};
let Ok(name) = std::str::from_utf8(name) else {
return Err(format!(
"the name {:?} is not valid UTF-8, which the path API cannot \
address",
String::from_utf8_lossy(name)
));
};
entries.push(DirEntry {
name: name.to_string(),
entry_type: EntryType::from_find_letter(type_letter),
size,
modified_time,
mode,
});
}
Ok(entries)
}
pub(crate) fn require_path(path: &str) -> Result<(), SailError> {
if path.is_empty() {
return Err(SailError::InvalidArgument {
message: "path must be non-empty".to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_records_with_all_fields() {
let stdout = b"d\t4096\t1700000000.5\t755\tsub\0f\t12\t1700000001\t644\ta\nb\0";
let entries = parse_dir_entries(stdout).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name, "sub");
assert_eq!(entries[0].entry_type, EntryType::Directory);
assert_eq!(entries[0].size, 4096);
assert_eq!(entries[0].modified_time, 1_700_000_000.5);
assert_eq!(entries[0].mode, 0o755);
assert_eq!(entries[1].name, "a\nb");
assert_eq!(entries[1].entry_type, EntryType::File);
assert_eq!(entries[1].mode, 0o644);
}
#[test]
fn keeps_a_tab_in_the_name() {
let stdout = b"f\t3\t1700000000\t600\tta\tb\0";
let entries = parse_dir_entries(stdout).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "ta\tb");
}
#[test]
fn skips_empty_records() {
let stdout = b"d\t4096\t1\t755\tsub\0\0f\t3\t1\t644\tx\0";
let entries = parse_dir_entries(stdout).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name, "sub");
assert_eq!(entries[1].name, "x");
}
#[test]
fn fails_the_listing_on_a_malformed_record() {
let err = parse_dir_entries(b"d\t4096\t1\t755\tok\0garbage\0").unwrap_err();
assert!(err.contains("garbage"), "{err}");
assert!(parse_dir_entries(b"f\t3\t1\t798\tx\0").is_err());
assert!(parse_dir_entries(b"f\tbig\t1\t644\tx\0").is_err());
}
#[test]
fn fails_the_listing_on_a_non_utf8_name() {
let stdout = b"f\t3\t1700000000\t644\tb\xffad\0";
let err = parse_dir_entries(stdout).unwrap_err();
assert!(err.contains("not valid UTF-8"), "{err}");
}
#[test]
fn symlink_type_is_reported() {
let stdout = b"l\t7\t1700000000\t777\tlink\0";
let entries = parse_dir_entries(stdout).unwrap();
assert_eq!(entries[0].entry_type, EntryType::Symlink);
}
#[test]
fn relative_paths_are_dot_prefixed() {
assert_eq!(list_dir_argv("-weird")[2], "./-weird");
assert_eq!(list_dir_argv("work")[2], "./work");
assert_eq!(list_dir_argv("/abs")[2], "/abs");
}
}