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/// Write a file through a sibling temporary file and a rename, creating its
33/// parent directories.
34///
35/// The scratch file is created exclusively, so a path that already exists
36/// there, a symlink to somewhere else included, refuses the write rather
37/// than being followed or truncated. A failure partway leaves the
38/// destination as it was rather than half-written, and an interruption
39/// leaves either the old bytes or the new.
40///
41/// # Errors
42///
43/// Any I/O error creating directories, creating or writing the scratch
44/// file, or renaming it into place. A scratch path that already exists is
45/// [`std::io::ErrorKind::AlreadyExists`].
46pub fn write_atomic(path: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
47    use std::io::Write as _;
48    if let Some(parent) = path.parent() {
49        std::fs::create_dir_all(parent)?;
50    }
51    let scratch = path.with_extension(format!("{}.sdd-tmp", path.extension().unwrap_or_default()));
52    let mut file = std::fs::OpenOptions::new()
53        .write(true)
54        .create_new(true)
55        .open(&scratch)
56        .map_err(|error| {
57            std::io::Error::new(
58                error.kind(),
59                format!("{scratch}: {error}; remove the scratch file to retry"),
60            )
61        })?;
62    let written = file.write_all(bytes).and_then(|()| file.sync_all());
63    drop(file);
64    if let Err(error) = written {
65        let _ = std::fs::remove_file(&scratch);
66        return Err(error);
67    }
68    std::fs::rename(&scratch, path).inspect_err(|_| {
69        let _ = std::fs::remove_file(&scratch);
70    })
71}
72
73/// Write a repository-relative destination under a target, refusing a path
74/// that leaves it.
75///
76/// [`check_destination`] runs first, so a symlinked parent directory is
77/// refused before a byte lands, and the write itself is [`write_atomic`].
78///
79/// # Errors
80///
81/// A [`std::io::ErrorKind::PermissionDenied`] error naming the refusal when
82/// the destination cannot be touched, and any I/O error of the write.
83pub fn write_within(
84    target: &Utf8Path,
85    destination: &Utf8Path,
86    bytes: &[u8],
87) -> std::io::Result<()> {
88    check_destination(target, destination).map_err(|refusal| refused(destination, &refusal))?;
89    write_atomic(&target.join(destination), bytes)
90}
91
92/// Remove a repository-relative file under a target, refusing a path that
93/// leaves it.
94///
95/// # Errors
96///
97/// A [`std::io::ErrorKind::PermissionDenied`] error naming the refusal when
98/// the destination cannot be touched, and any I/O error of the removal.
99pub fn remove_within(target: &Utf8Path, destination: &Utf8Path) -> std::io::Result<()> {
100    check_destination(target, destination).map_err(|refusal| refused(destination, &refusal))?;
101    std::fs::remove_file(target.join(destination))
102}
103
104fn refused(destination: &Utf8Path, refusal: &DestinationRefusal) -> std::io::Error {
105    std::io::Error::new(
106        std::io::ErrorKind::PermissionDenied,
107        format!("{destination}: {refusal}"),
108    )
109}
110
111/// Why a destination cannot be touched.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum DestinationRefusal {
114    /// A symlink on the path would resolve the write outside the target.
115    SymlinkEscape,
116    /// A non-directory sits where a directory is needed.
117    FileBlocksDirectory(String),
118    /// The destination exists and is not a regular file.
119    NotARegularFile,
120}
121
122impl std::fmt::Display for DestinationRefusal {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        match self {
125            Self::SymlinkEscape => f.write_str("reached through a symlink that leaves the target"),
126            Self::FileBlocksDirectory(blocked) => {
127                write!(f, "a file blocks a directory the write needs: {blocked}")
128            }
129            Self::NotARegularFile => f.write_str("exists and is not a regular file"),
130        }
131    }
132}
133
134/// Check that touching `destination` under `target` stays inside the target.
135///
136/// No symlink on any component, directories where directories are needed,
137/// and nothing but a regular file (or nothing) at the end.
138///
139/// # Errors
140///
141/// A [`DestinationRefusal`] naming what was found.
142pub fn check_destination(
143    target: &Utf8Path,
144    destination: &Utf8Path,
145) -> Result<(), DestinationRefusal> {
146    let mut prefix = target.to_path_buf();
147    let components: Vec<&str> = destination.as_str().split('/').collect();
148    for part in &components[..components.len().saturating_sub(1)] {
149        prefix.push(part);
150        if prefix.is_symlink() {
151            return Err(DestinationRefusal::SymlinkEscape);
152        }
153        if prefix.exists() && !prefix.is_dir() {
154            let blocked = prefix
155                .as_str()
156                .strip_prefix(target.as_str())
157                .map_or(prefix.as_str(), |rest| rest.trim_start_matches('/'));
158            return Err(DestinationRefusal::FileBlocksDirectory(blocked.to_string()));
159        }
160    }
161    let full = target.join(destination);
162    if full.is_symlink() {
163        return Err(DestinationRefusal::SymlinkEscape);
164    }
165    if full.exists() && !full.is_file() {
166        return Err(DestinationRefusal::NotARegularFile);
167    }
168    Ok(())
169}
170
171#[cfg(test)]
172mod tests {
173    use camino::Utf8PathBuf;
174
175    use super::*;
176
177    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
178        Utf8PathBuf::from(dir.path().to_str().unwrap())
179    }
180
181    #[test]
182    fn accepts_a_fresh_and_an_existing_regular_destination() {
183        let dir = tempfile::tempdir().unwrap();
184        let target = root(&dir);
185        assert_eq!(
186            check_destination(&target, Utf8Path::new("a/b/c.md")),
187            Ok(())
188        );
189        write_file(&target.join("a/b/c.md"), b"x").unwrap();
190        assert_eq!(
191            check_destination(&target, Utf8Path::new("a/b/c.md")),
192            Ok(())
193        );
194    }
195
196    #[test]
197    fn refuses_a_symlinked_component_and_a_symlinked_destination() {
198        let dir = tempfile::tempdir().unwrap();
199        let target = root(&dir);
200        let outside = tempfile::tempdir().unwrap();
201        std::os::unix::fs::symlink(outside.path(), target.join("a").as_std_path()).unwrap();
202        assert_eq!(
203            check_destination(&target, Utf8Path::new("a/c.md")),
204            Err(DestinationRefusal::SymlinkEscape)
205        );
206        std::os::unix::fs::symlink("/etc/hosts", target.join("link.md").as_std_path()).unwrap();
207        assert_eq!(
208            check_destination(&target, Utf8Path::new("link.md")),
209            Err(DestinationRefusal::SymlinkEscape)
210        );
211    }
212
213    #[test]
214    fn refuses_a_file_where_a_directory_is_needed_and_a_directory_destination() {
215        let dir = tempfile::tempdir().unwrap();
216        let target = root(&dir);
217        write_file(&target.join("a"), b"file").unwrap();
218        assert_eq!(
219            check_destination(&target, Utf8Path::new("a/c.md")),
220            Err(DestinationRefusal::FileBlocksDirectory("a".to_string()))
221        );
222        std::fs::create_dir(target.join("d.md")).unwrap();
223        assert_eq!(
224            check_destination(&target, Utf8Path::new("d.md")),
225            Err(DestinationRefusal::NotARegularFile)
226        );
227    }
228
229    #[test]
230    fn an_atomic_write_lands_the_bytes_and_leaves_no_scratch_file() {
231        let dir = tempfile::tempdir().unwrap();
232        let path = root(&dir).join("a/debt.yaml");
233        write_atomic(&path, b"first").unwrap();
234        write_atomic(&path, b"second").unwrap();
235        assert_eq!(std::fs::read(&path).unwrap(), b"second");
236        let siblings: Vec<_> = std::fs::read_dir(path.parent().unwrap())
237            .unwrap()
238            .filter_map(Result::ok)
239            .map(|entry| entry.file_name().to_string_lossy().to_string())
240            .collect();
241        assert_eq!(siblings, vec!["debt.yaml".to_string()]);
242    }
243
244    #[test]
245    fn a_pre_existing_scratch_symlink_is_refused_and_nothing_outside_is_touched() {
246        let dir = tempfile::tempdir().unwrap();
247        let target = root(&dir);
248        let outside = tempfile::tempdir().unwrap();
249        let victim = outside.path().join("victim");
250        std::fs::write(&victim, b"keep").unwrap();
251        std::os::unix::fs::symlink(&victim, target.join("debt.yaml.sdd-tmp").as_std_path())
252            .unwrap();
253        let error = write_atomic(&target.join("debt.yaml"), b"new").unwrap_err();
254        assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
255        assert_eq!(
256            std::fs::read(&victim).unwrap(),
257            b"keep",
258            "the scratch symlink was followed"
259        );
260        assert!(!target.join("debt.yaml").exists());
261        // A stale regular scratch file refuses the same way, and stays.
262        std::fs::remove_file(target.join("debt.yaml.sdd-tmp")).unwrap();
263        std::fs::write(target.join("debt.yaml.sdd-tmp"), b"stale").unwrap();
264        assert!(write_atomic(&target.join("debt.yaml"), b"new").is_err());
265    }
266
267    #[test]
268    fn a_write_within_refuses_a_symlinked_parent_before_any_byte_lands() {
269        let dir = tempfile::tempdir().unwrap();
270        let target = root(&dir);
271        let outside = tempfile::tempdir().unwrap();
272        std::os::unix::fs::symlink(outside.path(), target.join("docs").as_std_path()).unwrap();
273        let error = write_within(&target, Utf8Path::new("docs/x.md"), b"x").unwrap_err();
274        assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
275        assert!(!outside.path().join("x.md").exists(), "the write escaped");
276        assert!(
277            remove_within(&target, Utf8Path::new("docs/x.md")).is_err(),
278            "the removal followed the symlink"
279        );
280        write_within(&target, Utf8Path::new("inside/x.md"), b"x").unwrap();
281        assert_eq!(std::fs::read(target.join("inside/x.md")).unwrap(), b"x");
282    }
283
284    #[test]
285    fn hashes_match_the_domain_digest() {
286        let dir = tempfile::tempdir().unwrap();
287        let path = root(&dir).join("x");
288        write_file(&path, b"payload").unwrap();
289        assert_eq!(sha256_file(&path).unwrap(), Sha256::of(b"payload"));
290    }
291}