Skip to main content

spec_driven_docs/adapters/
fs.rs

1//! Filesystem primitives the lifecycle services share.
2//!
3//! Hashing, guarded destination checks, and parent-creating writes — the
4//! mechanics only. Which destinations exist and what to do about a refusal
5//! is the installer's and upgrader's business.
6
7use camino::Utf8Path;
8
9use crate::domain::ownership::Sha256;
10
11/// Hash a file's bytes.
12///
13/// # Errors
14///
15/// Any I/O error reading the file.
16pub fn sha256_file(path: &Utf8Path) -> std::io::Result<Sha256> {
17    Ok(Sha256::of(&std::fs::read(path)?))
18}
19
20/// Write a file, creating its parent directories.
21///
22/// # Errors
23///
24/// Any I/O error creating directories or writing.
25pub fn write_file(path: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
26    if let Some(parent) = path.parent() {
27        std::fs::create_dir_all(parent)?;
28    }
29    std::fs::write(path, bytes)
30}
31
32/// Why a destination cannot be touched.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum DestinationRefusal {
35    /// A symlink on the path would resolve the write outside the target.
36    SymlinkEscape,
37    /// A non-directory sits where a directory is needed.
38    FileBlocksDirectory(String),
39    /// The destination exists and is not a regular file.
40    NotARegularFile,
41}
42
43/// Check that touching `destination` under `target` stays inside the target.
44///
45/// No symlink on any component, directories where directories are needed,
46/// and nothing but a regular file (or nothing) at the end.
47///
48/// # Errors
49///
50/// A [`DestinationRefusal`] naming what was found.
51pub fn check_destination(
52    target: &Utf8Path,
53    destination: &Utf8Path,
54) -> Result<(), DestinationRefusal> {
55    let mut prefix = target.to_path_buf();
56    let components: Vec<&str> = destination.as_str().split('/').collect();
57    for part in &components[..components.len().saturating_sub(1)] {
58        prefix.push(part);
59        if prefix.is_symlink() {
60            return Err(DestinationRefusal::SymlinkEscape);
61        }
62        if prefix.exists() && !prefix.is_dir() {
63            let blocked = prefix
64                .as_str()
65                .strip_prefix(target.as_str())
66                .map_or(prefix.as_str(), |rest| rest.trim_start_matches('/'));
67            return Err(DestinationRefusal::FileBlocksDirectory(blocked.to_string()));
68        }
69    }
70    let full = target.join(destination);
71    if full.is_symlink() {
72        return Err(DestinationRefusal::SymlinkEscape);
73    }
74    if full.exists() && !full.is_file() {
75        return Err(DestinationRefusal::NotARegularFile);
76    }
77    Ok(())
78}
79
80#[cfg(test)]
81mod tests {
82    use camino::Utf8PathBuf;
83
84    use super::*;
85
86    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
87        Utf8PathBuf::from(dir.path().to_str().unwrap())
88    }
89
90    #[test]
91    fn accepts_a_fresh_and_an_existing_regular_destination() {
92        let dir = tempfile::tempdir().unwrap();
93        let target = root(&dir);
94        assert_eq!(
95            check_destination(&target, Utf8Path::new("a/b/c.md")),
96            Ok(())
97        );
98        write_file(&target.join("a/b/c.md"), b"x").unwrap();
99        assert_eq!(
100            check_destination(&target, Utf8Path::new("a/b/c.md")),
101            Ok(())
102        );
103    }
104
105    #[test]
106    fn refuses_a_symlinked_component_and_a_symlinked_destination() {
107        let dir = tempfile::tempdir().unwrap();
108        let target = root(&dir);
109        let outside = tempfile::tempdir().unwrap();
110        std::os::unix::fs::symlink(outside.path(), target.join("a").as_std_path()).unwrap();
111        assert_eq!(
112            check_destination(&target, Utf8Path::new("a/c.md")),
113            Err(DestinationRefusal::SymlinkEscape)
114        );
115        std::os::unix::fs::symlink("/etc/hosts", target.join("link.md").as_std_path()).unwrap();
116        assert_eq!(
117            check_destination(&target, Utf8Path::new("link.md")),
118            Err(DestinationRefusal::SymlinkEscape)
119        );
120    }
121
122    #[test]
123    fn refuses_a_file_where_a_directory_is_needed_and_a_directory_destination() {
124        let dir = tempfile::tempdir().unwrap();
125        let target = root(&dir);
126        write_file(&target.join("a"), b"file").unwrap();
127        assert_eq!(
128            check_destination(&target, Utf8Path::new("a/c.md")),
129            Err(DestinationRefusal::FileBlocksDirectory("a".to_string()))
130        );
131        std::fs::create_dir(target.join("d.md")).unwrap();
132        assert_eq!(
133            check_destination(&target, Utf8Path::new("d.md")),
134            Err(DestinationRefusal::NotARegularFile)
135        );
136    }
137
138    #[test]
139    fn hashes_match_the_domain_digest() {
140        let dir = tempfile::tempdir().unwrap();
141        let path = root(&dir).join("x");
142        write_file(&path, b"payload").unwrap();
143        assert_eq!(sha256_file(&path).unwrap(), Sha256::of(b"payload"));
144    }
145}