Skip to main content

greentic_deployer/
path_safety.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use thiserror::Error;
5
6/// Structured errors from the typed path-safety helpers
7/// ([`assert_no_symlink_ancestors`]). [`normalize_under_root`] keeps its
8/// anyhow shape for backwards compatibility with the existing
9/// `pack_introspect` consumer.
10#[derive(Debug, Error)]
11pub enum PathSafetyError {
12    #[error("path component `{}` is a symlink (escape risk)", .path.display())]
13    SymlinkAncestor { path: PathBuf },
14    #[error("io error on `{}`: {source}", .path.display())]
15    Io {
16        path: PathBuf,
17        #[source]
18        source: std::io::Error,
19    },
20}
21
22/// Walk every existing ancestor of `target` that is a proper descendant of
23/// `root` (inclusive of `target` itself) and reject if any is a symlink.
24///
25/// Only existing ancestors are checked — non-existent path segments are
26/// fine (callers typically follow this with `create_dir_all`). Same posture
27/// as the P0.4 symlink-TOCTOU defense in the bundle extractors: the check
28/// must run immediately before the write, under whatever flock the caller
29/// holds, so the race window is bounded to that flock's scope.
30///
31/// Returns `Ok(())` (no-op) when `target` is not under `root`; callers that
32/// need that to be an error should validate the prefix themselves before
33/// calling.
34pub fn assert_no_symlink_ancestors(root: &Path, target: &Path) -> Result<(), PathSafetyError> {
35    let suffix = match target.strip_prefix(root) {
36        Ok(s) => s,
37        Err(_) => return Ok(()),
38    };
39    let mut current = root.to_path_buf();
40    for component in suffix.components() {
41        current.push(component);
42        match std::fs::symlink_metadata(&current) {
43            Ok(meta) if meta.is_symlink() => {
44                return Err(PathSafetyError::SymlinkAncestor { path: current });
45            }
46            Ok(_) => {}
47            Err(e) if e.kind() == std::io::ErrorKind::NotFound => break,
48            Err(e) => {
49                return Err(PathSafetyError::Io {
50                    path: current,
51                    source: e,
52                });
53            }
54        }
55    }
56    Ok(())
57}
58
59/// Normalize a user-supplied path and ensure it stays within an allowed root.
60/// Rejects absolute paths and any that escape via `..`.
61pub fn normalize_under_root(root: &Path, candidate: &Path) -> Result<PathBuf> {
62    if candidate.is_absolute() {
63        anyhow::bail!("absolute paths are not allowed: {}", candidate.display());
64    }
65
66    let root_canon = root
67        .canonicalize()
68        .with_context(|| format!("failed to canonicalize {}", root.display()))?;
69    let joined = root_canon.join(candidate);
70    let canon = joined
71        .canonicalize()
72        .with_context(|| format!("failed to canonicalize {}", joined.display()))?;
73
74    if !canon.starts_with(&root_canon) {
75        anyhow::bail!(
76            "path escapes root ({}): {}",
77            root_canon.display(),
78            canon.display()
79        );
80    }
81
82    Ok(canon)
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use tempfile::tempdir;
89
90    #[test]
91    fn assert_no_symlink_ancestors_passes_on_plain_dirs() {
92        let root = tempdir().unwrap();
93        let nested = root.path().join("a").join("b");
94        std::fs::create_dir_all(&nested).unwrap();
95        let target = nested.join("c.txt");
96        assert!(assert_no_symlink_ancestors(root.path(), &target).is_ok());
97    }
98
99    #[test]
100    fn assert_no_symlink_ancestors_passes_on_nonexistent_tail() {
101        let root = tempdir().unwrap();
102        // `a/` doesn't exist; the walk stops at the first NotFound.
103        let target = root.path().join("a").join("b").join("c.txt");
104        assert!(assert_no_symlink_ancestors(root.path(), &target).is_ok());
105    }
106
107    #[cfg(unix)]
108    #[test]
109    fn assert_no_symlink_ancestors_rejects_symlink_component() {
110        use std::os::unix::fs::symlink;
111        let root = tempdir().unwrap();
112        let elsewhere = tempdir().unwrap();
113        // Pre-create `root/rules` as a symlink to a sibling dir.
114        symlink(elsewhere.path(), root.path().join("rules")).unwrap();
115        let target = root.path().join("rules").join("aws-ecs").join("policy.tf");
116        let err = assert_no_symlink_ancestors(root.path(), &target).unwrap_err();
117        assert!(
118            matches!(err, PathSafetyError::SymlinkAncestor { ref path } if path.ends_with("rules")),
119            "expected SymlinkAncestor at the `rules` segment, got {err:?}"
120        );
121    }
122
123    #[test]
124    fn assert_no_symlink_ancestors_noop_when_target_outside_root() {
125        // strip_prefix fails when target is not under root — contract is
126        // explicit no-op (callers validate prefix themselves if needed).
127        let root = tempdir().unwrap();
128        let other = tempdir().unwrap();
129        assert!(assert_no_symlink_ancestors(root.path(), other.path()).is_ok());
130    }
131}