switchyard/types/
safepath.rs

1use std::path::{Component, Path, PathBuf};
2
3use super::errors::{Error, ErrorKind, Result};
4
5/// Data-only type for safe path handling.
6/// Centralized under `crate::types` for cross-layer reuse.
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct SafePath {
10    /// The root path that this safe path is relative to
11    root: PathBuf,
12    /// The relative path component
13    rel: PathBuf,
14}
15
16impl SafePath {
17    /// Creates a new `SafePath` from a root and candidate path.
18    ///
19    /// This function ensures that the candidate path is within the root path
20    /// and does not contain any unsafe components like dotdot (..).
21    ///
22    /// # Arguments
23    ///
24    /// * `root` - The root path that the candidate should be within
25    /// * `candidate` - The path to check and make safe
26    ///
27    /// # Returns
28    ///
29    /// * `Result<Self>` - A `SafePath` if the candidate is valid, or an error otherwise
30    ///
31    /// # Errors
32    ///
33    /// Returns an error if the root path is not absolute, if the candidate path escapes the root,
34    /// or if the candidate path contains unsafe components like dotdot (..).
35    ///
36    /// # Panics
37    ///
38    /// Panics when `root` is not absolute. This mirrors historical semantics and
39    /// preserves SPEC/BDD expectations for construction invariants in tests.
40    ///
41    /// # Example
42    ///
43    /// ```rust
44    /// use switchyard::types::safepath::SafePath;
45    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
46    /// let td = tempfile::tempdir()?;
47    /// let root = td.path();
48    /// std::fs::create_dir_all(root.join("usr/bin"))?;
49    /// let sp = SafePath::from_rooted(root, &root.join("usr/bin/ls"))?;
50    /// assert!(sp.as_path().starts_with(root));
51    /// # Ok(())
52    /// # }
53    /// ```
54    #[allow(
55        clippy::panic,
56        reason = "Root absoluteness is a construction invariant"
57    )]
58    pub fn from_rooted(root: &Path, candidate: &Path) -> Result<Self> {
59        assert!(root.is_absolute(), "root must be absolute");
60        let effective = if candidate.is_absolute() {
61            match candidate.strip_prefix(root) {
62                Ok(p) => p.to_path_buf(),
63                Err(_) => {
64                    return Err(Error {
65                        kind: ErrorKind::Policy,
66                        msg: "path escapes root".into(),
67                    })
68                }
69            }
70        } else {
71            candidate.to_path_buf()
72        };
73
74        let mut rel = PathBuf::new();
75        for seg in effective.components() {
76            match seg {
77                Component::CurDir => {}
78                Component::Normal(p) => rel.push(p),
79                Component::ParentDir => {
80                    return Err(Error {
81                        kind: ErrorKind::Policy,
82                        msg: "dotdot".into(),
83                    });
84                }
85                Component::Prefix(_) | Component::RootDir => {
86                    return Err(Error {
87                        kind: ErrorKind::InvalidPath,
88                        msg: "unsupported component".into(),
89                    });
90                }
91            }
92        }
93        let norm = root.join(&rel);
94        if !norm.starts_with(root) {
95            return Err(Error {
96                kind: ErrorKind::Policy,
97                msg: "path escapes root".into(),
98            });
99        }
100        Ok(SafePath {
101            root: root.to_path_buf(),
102            rel,
103        })
104    }
105
106    /// Returns the full path by joining the root and relative components.
107    ///
108    /// # Returns
109    ///
110    /// * `PathBuf` - The complete path
111    #[must_use]
112    pub fn as_path(&self) -> PathBuf {
113        self.root.join(&self.rel)
114    }
115
116    /// Returns a reference to the relative path component.
117    ///
118    /// # Returns
119    ///
120    /// * `&Path` - Reference to the relative path
121    #[must_use]
122    pub fn rel(&self) -> &Path {
123        &self.rel
124    }
125}
126
127#[cfg(test)]
128#[allow(clippy::panic)]
129mod tests {
130    use super::*;
131    use std::path::Path;
132
133    #[test]
134    fn rejects_dotdot() {
135        let root = Path::new("/tmp");
136        assert!(SafePath::from_rooted(root, Path::new("../etc")).is_err());
137    }
138
139    #[test]
140    fn accepts_absolute_inside_root() {
141        let root = Path::new("/tmp/root");
142        let candidate = Path::new("/tmp/root/usr/bin/ls");
143        let sp = SafePath::from_rooted(root, candidate).unwrap_or_else(|e| {
144            panic!("Failed to create SafePath for absolute path inside root: {e}")
145        });
146        assert!(sp.as_path().starts_with(root));
147        assert_eq!(sp.rel(), Path::new("usr/bin/ls"));
148    }
149
150    #[test]
151    fn rejects_absolute_outside_root() {
152        let root = Path::new("/tmp/root");
153        let candidate = Path::new("/etc/passwd");
154        assert!(SafePath::from_rooted(root, candidate).is_err());
155    }
156
157    #[test]
158    fn normalizes_curdir_components() {
159        let root = Path::new("/tmp/root");
160        let candidate = Path::new("./usr/./bin/./ls");
161        let sp = SafePath::from_rooted(root, candidate).unwrap_or_else(|e| {
162            panic!("Failed to create SafePath with normalized curdir components: {e}")
163        });
164        assert_eq!(sp.rel(), Path::new("usr/bin/ls"));
165        assert_eq!(sp.as_path(), Path::new("/tmp/root/usr/bin/ls"));
166    }
167}