switchyard/types/
safepath.rs1use std::path::{Component, Path, PathBuf};
2
3use super::errors::{Error, ErrorKind, Result};
4
5#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct SafePath {
10 root: PathBuf,
12 rel: PathBuf,
14}
15
16impl SafePath {
17 #[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 #[must_use]
112 pub fn as_path(&self) -> PathBuf {
113 self.root.join(&self.rel)
114 }
115
116 #[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}