Skip to main content

boxology_cli_core/
walk.rs

1use boxology_manifest::RelativePath;
2use boxology_workspace::FileEntry;
3use std::{
4    fmt, fs,
5    path::{Path, PathBuf},
6};
7// Current dense block begins at BXW0061; 02-packages discovery and S5-T4 #326 PR1 allocate it.
8type Rule = (&'static str, &'static str, &'static str);
9const RULE_SOURCE: &str =
10    "boxology-details/02-packages.md discovery walk; S5-T4 #326 PR1 task authority";
11const ROOT_TEXT: &str = "workspace root must be a real directory containing a regular Cargo.toml";
12const IO_TEXT: &str = "filesystem refused a directory, symlink, or manifest read";
13const PATH_TEXT: &str = "walked name/path is not a valid RelativePath";
14const ROOT: Rule = ("BXW0061", ROOT_TEXT, RULE_SOURCE);
15const IO: Rule = ("BXW0062", IO_TEXT, RULE_SOURCE);
16const PATH: Rule = ("BXW0063", PATH_TEXT, RULE_SOURCE);
17const CARGO: &str = "Cargo.toml";
18const MANIFEST: &str = "boxology.toml";
19/// A payload-safe failure while materializing raw workspace filesystem inputs.
20#[derive(Debug, Eq, PartialEq)]
21pub struct WalkError(&'static str, PathBuf, &'static str);
22impl WalkError {
23    /// Returns the stable `BXW####` code.
24    pub fn code(&self) -> &'static str {
25        self.0
26    }
27    /// Returns the exact filesystem path at which the walk failed.
28    pub fn path(&self) -> &Path {
29        &self.1
30    }
31    /// Returns stable detail without an operating-system error payload.
32    pub fn detail(&self) -> &'static str {
33        self.2
34    }
35}
36impl fmt::Display for WalkError {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        write!(formatter, "{} {:?}: {}", self.0, self.1, self.2)
39    }
40}
41impl std::error::Error for WalkError {}
42/// Raw filesystem material for `boxology-workspace`.
43#[derive(Debug, Eq, PartialEq)]
44pub struct WalkedWorkspace(Vec<FileEntry>, Vec<(RelativePath, Vec<u8>)>);
45impl WalkedWorkspace {
46    /// Returns regular files and symlinks in bytewise logical-path order.
47    pub fn files(&self) -> &[FileEntry] {
48        &self.0
49    }
50    /// Returns exact-final-name `boxology.toml` files and bytes in path order.
51    pub fn manifests(&self) -> &[(RelativePath, Vec<u8>)] {
52        &self.1
53    }
54}
55/// Walks `root` without following symlink entries. Real `.git` and `target` directories are
56/// pruned at every depth; every other entry must have a valid [`RelativePath`].
57///
58/// # Errors
59///
60/// Returns `BXW0061` unless the root is a real directory with a regular manifest, `BXW0062` for
61/// a refused read, and `BXW0063` for an invalid logical path.
62pub fn walk(root: &Path) -> Result<WalkedWorkspace, WalkError> {
63    if !fs::symlink_metadata(root).is_ok_and(|metadata| metadata.is_dir()) {
64        return Err(failure(ROOT, root.to_owned()));
65    }
66    let cargo = root.join(CARGO);
67    if !fs::symlink_metadata(&cargo).is_ok_and(|metadata| metadata.is_file()) {
68        return Err(failure(ROOT, cargo));
69    }
70    let mut files = Vec::new();
71    let mut manifests = Vec::new();
72    visit(root, root, &mut files, &mut manifests)?;
73    files.sort_unstable_by(|left, right| left.path().cmp(right.path()));
74    manifests.sort_unstable_by(|left, right| left.0.cmp(&right.0));
75    Ok(WalkedWorkspace(files, manifests))
76}
77fn visit(
78    root: &Path,
79    directory: &Path,
80    files: &mut Vec<FileEntry>,
81    manifests: &mut Vec<(RelativePath, Vec<u8>)>,
82) -> Result<(), WalkError> {
83    let entries = fs::read_dir(directory).map_err(|_| failure(IO, directory.to_owned()))?;
84    for entry in entries {
85        let entry = entry.map_err(|_| failure(IO, directory.to_owned()))?;
86        if entry.file_name() == ".git" {
87            continue;
88        }
89        let physical = entry.path();
90        let logical = logical_path(root, &physical)?;
91        let kind = entry
92            .file_type()
93            .map_err(|_| failure(IO, physical.clone()))?;
94        if kind.is_dir() {
95            if entry.file_name() == "target" {
96                continue;
97            }
98            visit(root, &physical, files, manifests)?;
99        } else if kind.is_symlink() {
100            let target = fs::read_link(&physical).map_err(|_| failure(IO, physical.clone()))?;
101            let target = target
102                .to_str()
103                .ok_or_else(|| failure(PATH, physical.clone()))?;
104            files.push(FileEntry::symlink(logical, target.to_owned()));
105        } else if kind.is_file() {
106            if entry.file_name() == MANIFEST {
107                let bytes = read_manifest(&physical, |path| fs::read(path))?;
108                manifests.push((logical.clone(), bytes));
109            }
110            files.push(FileEntry::file(logical));
111        }
112    }
113    Ok(())
114}
115fn read_manifest(
116    path: &Path,
117    reader: impl FnOnce(&Path) -> std::io::Result<Vec<u8>>,
118) -> Result<Vec<u8>, WalkError> {
119    reader(path).map_err(|_| failure(IO, path.to_owned()))
120}
121fn logical_path(root: &Path, physical: &Path) -> Result<RelativePath, WalkError> {
122    let relative = physical
123        .strip_prefix(root)
124        .map_err(|_| failure(PATH, physical.to_owned()))?;
125    let spelling = relative
126        .components()
127        .map(|component| {
128            component
129                .as_os_str()
130                .to_str()
131                .ok_or_else(|| failure(PATH, physical.to_owned()))
132        })
133        .collect::<Result<Vec<_>, _>>()?
134        .join("/");
135    RelativePath::new(spelling).map_err(|_| failure(PATH, physical.to_owned()))
136}
137fn failure(rule: Rule, path: PathBuf) -> WalkError {
138    WalkError(rule.0, path, rule.1)
139}
140#[cfg(test)]
141mod tests {
142    use super::{IO_TEXT, read_manifest};
143    use std::{io, path::Path};
144    #[test]
145    fn refused_manifest_read_is_stable_and_payload_safe() {
146        let path = Path::new("blocked/boxology.toml");
147        let error = read_manifest(path, |_| {
148            Err(io::Error::other("SECRET operating-system payload"))
149        })
150        .expect_err("injected refusal must map through the production helper");
151        assert_eq!(error.code(), "BXW0062");
152        assert_eq!(error.path(), path);
153        assert_eq!(error.detail(), IO_TEXT);
154        assert_eq!(error.to_string(), format!("BXW0062 {path:?}: {IO_TEXT}"));
155        assert!(!error.to_string().contains("SECRET"));
156    }
157}