mod exec;
mod plan;
pub use plan::{plan, Conflict, Kind, Node, NodeKind, Op, Plan, PlanCtx, Refusal, Source, Step};
#[cfg_attr(not(test), allow(unused_imports))]
pub use exec::{start, start_undo, Direction, Failure, Journal, Msg, Report, Run, Undoable};
use std::ffi::OsString;
use std::fs::{self, Metadata};
use std::io;
use std::path::{Path, PathBuf};
pub const MAX_TREE_ITEMS: usize = 50_000;
pub const MAX_TREE_DEPTH: usize = 64;
#[derive(Debug)]
pub struct Collected {
pub sources: Vec<Source>,
pub missing: Vec<PathBuf>,
}
pub fn collect(paths: &[PathBuf]) -> Result<Collected, Refusal> {
collect_bounded(paths, MAX_TREE_ITEMS, MAX_TREE_DEPTH)
}
fn collect_bounded(
paths: &[PathBuf],
max_items: usize,
max_depth: usize,
) -> Result<Collected, Refusal> {
let mut walk = Walk {
budget: max_items,
max_items,
max_depth,
};
let mut sources = Vec::with_capacity(paths.len());
let mut missing = Vec::new();
for path in paths {
let meta = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
missing.push(path.clone());
continue;
}
Err(e) => return Err(io_refusal(path, &e)),
};
let kind = kind_of(&meta);
walk.charge()?;
let (nodes, bytes) = if kind == NodeKind::Dir {
walk.tree(path)?
} else {
(Vec::new(), size_of(&meta, kind))
};
sources.push(Source {
path: path.clone(),
kind,
items: 1 + nodes.len(),
nodes,
bytes,
});
}
Ok(Collected { sources, missing })
}
struct Walk {
budget: usize,
max_items: usize,
max_depth: usize,
}
impl Walk {
fn charge(&mut self) -> Result<(), Refusal> {
if self.budget == 0 {
return Err(Refusal::TooLarge {
limit: self.max_items,
});
}
self.budget -= 1;
Ok(())
}
fn tree(&mut self, root: &Path) -> Result<(Vec<Node>, u64), Refusal> {
let mut nodes = Vec::new();
let mut bytes = 0;
self.descend(root, Path::new(""), 1, &mut nodes, &mut bytes)?;
Ok((nodes, bytes))
}
fn descend(
&mut self,
dir: &Path,
rel: &Path,
depth: usize,
nodes: &mut Vec<Node>,
bytes: &mut u64,
) -> Result<(), Refusal> {
if depth > self.max_depth {
return Err(Refusal::TooDeep {
path: dir.to_path_buf(),
limit: self.max_depth,
});
}
let entries = fs::read_dir(dir).map_err(|e| io_refusal(dir, &e))?;
let mut children: Vec<(OsString, PathBuf)> = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| io_refusal(dir, &e))?;
children.push((entry.file_name(), entry.path()));
}
children.sort_by(|a, b| a.0.cmp(&b.0));
for (name, path) in children {
let meta = fs::symlink_metadata(&path).map_err(|e| io_refusal(&path, &e))?;
let kind = kind_of(&meta);
self.charge()?;
let child_rel = rel.join(&name);
let size = size_of(&meta, kind);
*bytes += size;
nodes.push(Node {
rel: child_rel.clone(),
kind,
});
if kind == NodeKind::Dir {
self.descend(&path, &child_rel, depth + 1, nodes, bytes)?;
}
}
Ok(())
}
}
fn kind_of(meta: &Metadata) -> NodeKind {
let ft = meta.file_type();
if ft.is_symlink() {
NodeKind::Symlink
} else if ft.is_dir() {
NodeKind::Dir
} else {
NodeKind::File
}
}
fn size_of(meta: &Metadata, kind: NodeKind) -> u64 {
match kind {
NodeKind::Dir => 0,
_ => meta.len(),
}
}
fn io_refusal(path: &Path, e: &io::Error) -> Refusal {
Refusal::Io {
path: path.to_path_buf(),
msg: e.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
fn touch(root: &Path, rel: &str, bytes: usize) {
let path = root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
let mut f = File::create(&path).expect("create file");
f.write_all(&vec![b'x'; bytes]).expect("write file");
}
fn mkdir(root: &Path, rel: &str) -> PathBuf {
let path = root.join(rel);
fs::create_dir_all(&path).expect("create dir");
path
}
fn rels(source: &Source) -> Vec<String> {
source
.nodes
.iter()
.map(|n| n.rel.to_string_lossy().into_owned())
.collect()
}
#[test]
fn a_missing_path_is_reported_rather_than_failing_the_call() {
let tmp = TempDir::new().expect("tempdir");
touch(tmp.path(), "here.txt", 3);
let gone = tmp.path().join("gone.txt");
let found = collect(&[tmp.path().join("here.txt"), gone.clone()]).expect("collect");
assert_eq!(found.sources.len(), 1);
assert_eq!(found.sources[0].path, tmp.path().join("here.txt"));
assert_eq!(found.missing, vec![gone]);
}
#[test]
fn a_file_source_is_one_item_with_no_nodes() {
let tmp = TempDir::new().expect("tempdir");
touch(tmp.path(), "a.bin", 128);
let found = collect(&[tmp.path().join("a.bin")]).expect("collect");
let source = &found.sources[0];
assert_eq!(source.kind, NodeKind::File);
assert!(source.nodes.is_empty());
assert_eq!(source.items, 1);
assert_eq!(source.bytes, 128);
}
#[test]
fn nested_directories_list_parents_before_children_deterministically() {
let tmp = TempDir::new().expect("tempdir");
let root = mkdir(tmp.path(), "root");
touch(&root, "c.txt", 1);
touch(&root, "a.txt", 2);
touch(&root, "b/b1.txt", 4);
touch(&root, "b/b2/deep.txt", 8);
let expected = vec!["a.txt", "b", "b/b1.txt", "b/b2", "b/b2/deep.txt", "c.txt"];
let first = collect(std::slice::from_ref(&root)).expect("collect");
assert_eq!(rels(&first.sources[0]), expected);
let second = collect(std::slice::from_ref(&root)).expect("collect");
assert_eq!(rels(&second.sources[0]), expected);
let source = &first.sources[0];
assert_eq!(source.kind, NodeKind::Dir);
assert_eq!(source.items, 7);
assert_eq!(source.bytes, 15);
let b = source
.nodes
.iter()
.find(|n| n.rel == Path::new("b"))
.expect("b is enumerated");
assert_eq!(b.kind, NodeKind::Dir);
}
#[test]
#[cfg(unix)]
fn a_symlink_is_recorded_as_a_link_and_never_followed() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().expect("tempdir");
let root = mkdir(tmp.path(), "root");
let target = mkdir(tmp.path(), "target");
touch(&target, "hidden.txt", 5);
symlink(&target, root.join("link")).expect("symlink");
let found = collect(std::slice::from_ref(&root)).expect("collect");
let source = &found.sources[0];
assert_eq!(rels(source), vec!["link"]);
assert_eq!(source.nodes[0].kind, NodeKind::Symlink);
assert!(!rels(source).iter().any(|r| r.contains("hidden")));
assert_eq!(source.items, 2);
let direct = collect(&[root.join("link")]).expect("collect");
assert_eq!(direct.sources[0].kind, NodeKind::Symlink);
assert!(direct.sources[0].nodes.is_empty());
assert_eq!(direct.sources[0].items, 1);
}
#[test]
#[cfg(unix)]
fn a_symlink_loop_terminates_instead_of_recursing() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().expect("tempdir");
let root = mkdir(tmp.path(), "root");
symlink(&root, root.join("loop")).expect("symlink");
let found = collect(&[root]).expect("collect terminates");
assert_eq!(rels(&found.sources[0]), vec!["loop"]);
}
#[test]
fn the_item_cap_refuses_the_whole_operation() {
let tmp = TempDir::new().expect("tempdir");
let root = mkdir(tmp.path(), "root");
touch(&root, "a", 1);
touch(&root, "b", 1);
touch(&root, "c", 1);
let err = collect_bounded(std::slice::from_ref(&root), 3, MAX_TREE_DEPTH)
.expect_err("an explicit refusal, never a silent partial");
assert_eq!(err, Refusal::TooLarge { limit: 3 });
assert!(collect_bounded(&[root], 4, MAX_TREE_DEPTH).is_ok());
}
#[test]
fn the_item_cap_spans_the_whole_call_not_each_source() {
let tmp = TempDir::new().expect("tempdir");
touch(tmp.path(), "a", 1);
touch(tmp.path(), "b", 1);
let paths = vec![tmp.path().join("a"), tmp.path().join("b")];
assert_eq!(
collect_bounded(&paths, 1, MAX_TREE_DEPTH).expect_err("two files, one slot"),
Refusal::TooLarge { limit: 1 }
);
assert!(collect_bounded(&paths, 2, MAX_TREE_DEPTH).is_ok());
}
#[test]
fn the_depth_cap_refuses_the_whole_operation() {
let tmp = TempDir::new().expect("tempdir");
let root = mkdir(tmp.path(), "root");
touch(&root, "a/b/deep.txt", 1);
let err = collect_bounded(std::slice::from_ref(&root), MAX_TREE_ITEMS, 2)
.expect_err("deeper than we agreed to walk");
assert!(
matches!(err, Refusal::TooDeep { limit: 2, .. }),
"expected TooDeep, got {err:?}"
);
assert!(collect_bounded(&[root], MAX_TREE_ITEMS, 3).is_ok());
}
#[test]
#[cfg(unix)]
fn an_unreadable_directory_is_surfaced_not_swallowed() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().expect("tempdir");
let root = mkdir(tmp.path(), "root");
let locked = mkdir(&root, "locked");
touch(&locked, "inside.txt", 1);
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("chmod");
let result = collect(std::slice::from_ref(&root));
let readable = fs::read_dir(&locked).is_ok();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).expect("chmod back");
if readable {
return;
}
let err = result.expect_err("an unreadable directory is a real error");
assert!(
matches!(err, Refusal::Io { .. }),
"expected Io, got {err:?}"
);
}
}