1use std::path::{Component, Path, PathBuf};
14
15use crate::error::ScriptError;
16
17#[derive(Debug, Clone)]
21pub struct PathSandbox {
22 root: PathBuf,
23}
24
25impl PathSandbox {
26 pub fn new(root: impl AsRef<Path>) -> Result<Self, ScriptError> {
29 let root = root.as_ref();
30 let canonical = std::fs::canonicalize(root)
31 .map_err(|e| ScriptError::internal(format!("script_root {} is not a valid directory: {e}", root.display())))?;
32 if !canonical.is_dir() {
33 return Err(ScriptError::internal(format!(
34 "script_root {} is not a directory",
35 canonical.display()
36 )));
37 }
38 Ok(Self { root: canonical })
39 }
40
41 #[must_use]
43 pub fn root(&self) -> &Path {
44 &self.root
45 }
46
47 pub fn resolve_read(&self, rel: &str) -> Result<PathBuf, ScriptError> {
51 let candidate = Self::syntactic_check(rel)?;
52 let full = self.root.join(candidate);
53 let canonical = std::fs::canonicalize(&full)
54 .map_err(|e| ScriptError::sandbox(format!("fs: cannot resolve {}: {e}", full.display())))?;
55 if !canonical.starts_with(&self.root) {
56 return Err(ScriptError::sandbox(format!(
57 "fs: path escapes script_root: {}",
58 canonical.display()
59 )));
60 }
61 Ok(canonical)
62 }
63
64 pub fn resolve_write(&self, rel: &str) -> Result<PathBuf, ScriptError> {
70 let candidate = Self::syntactic_check(rel)?;
71 let full = self.root.join(&candidate);
72 let parent = full
73 .parent()
74 .ok_or_else(|| ScriptError::sandbox(format!("fs: path has no parent directory: {}", full.display())))?;
75 if !parent.exists() {
76 std::fs::create_dir_all(parent)
77 .map_err(|e| ScriptError::sandbox(format!("fs: cannot create parent directory: {e}")))?;
78 }
79 let canonical_parent = std::fs::canonicalize(parent)
80 .map_err(|e| ScriptError::sandbox(format!("fs: cannot resolve parent directory {}: {e}", parent.display())))?;
81 if !canonical_parent.starts_with(&self.root) {
82 return Err(ScriptError::sandbox(format!(
83 "fs: parent directory escapes script_root: {}",
84 canonical_parent.display()
85 )));
86 }
87 let Some(name) = full.file_name() else {
88 return Err(ScriptError::sandbox("fs: path has no filename"));
89 };
90 let target = canonical_parent.join(name);
91 if let Ok(meta) = std::fs::symlink_metadata(&target)
97 && meta.file_type().is_symlink()
98 {
99 return Err(ScriptError::sandbox(format!(
100 "fs: refusing to write through symlink: {}",
101 target.display()
102 )));
103 }
104 Ok(target)
105 }
106
107 fn syntactic_check(rel: &str) -> Result<PathBuf, ScriptError> {
108 if rel.is_empty() {
109 return Err(ScriptError::sandbox("fs: empty path"));
110 }
111 let path = Path::new(rel);
112 if path.is_absolute() {
113 return Err(ScriptError::sandbox(format!("fs: absolute paths not allowed: {rel}")));
114 }
115 for component in path.components() {
116 match component {
117 Component::ParentDir => {
118 return Err(ScriptError::sandbox(format!(
119 "fs: path traversal (..) not allowed: {rel}"
120 )));
121 },
122 Component::Prefix(_) | Component::RootDir => {
123 return Err(ScriptError::sandbox(format!("fs: path must be relative: {rel}")));
124 },
125 Component::CurDir | Component::Normal(_) => {},
126 }
127 }
128 Ok(path.to_path_buf())
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 fn tmp_sandbox() -> (tempfile::TempDir, PathSandbox) {
137 let tmp = tempfile::tempdir().expect("tempdir");
138 let sb = PathSandbox::new(tmp.path()).expect("sandbox");
139 (tmp, sb)
140 }
141
142 #[test]
143 fn rejects_absolute_path() {
144 let (_tmp, sb) = tmp_sandbox();
145 assert!(sb.resolve_read("/etc/passwd").is_err());
146 assert!(sb.resolve_write("/tmp/out.txt").is_err());
147 }
148
149 #[test]
150 fn rejects_parent_dir() {
151 let (_tmp, sb) = tmp_sandbox();
152 assert!(sb.resolve_read("../escape").is_err());
153 assert!(sb.resolve_read("nested/../../escape").is_err());
154 assert!(sb.resolve_write("../escape").is_err());
155 }
156
157 #[test]
158 fn rejects_empty() {
159 let (_tmp, sb) = tmp_sandbox();
160 assert!(sb.resolve_read("").is_err());
161 assert!(sb.resolve_write("").is_err());
162 }
163
164 #[test]
165 fn resolves_valid_read() {
166 let (tmp, sb) = tmp_sandbox();
167 std::fs::write(tmp.path().join("ok.txt"), b"hello").unwrap();
168 let resolved = sb.resolve_read("ok.txt").expect("resolve");
169 assert!(resolved.starts_with(sb.root()));
170 assert_eq!(resolved.file_name().unwrap(), "ok.txt");
171 }
172
173 #[test]
174 fn resolves_valid_write_creates_parent() {
175 let (tmp, sb) = tmp_sandbox();
176 let resolved = sb.resolve_write("nested/deep/new.txt").expect("resolve");
177 assert!(resolved.starts_with(sb.root()));
178 assert!(tmp.path().join("nested/deep").is_dir());
179 }
180
181 #[cfg(unix)]
182 #[test]
183 fn rejects_symlink_write_final_component() {
184 use std::os::unix::fs::symlink;
185 let (tmp, sb) = tmp_sandbox();
186 let outside = tempfile::tempdir().unwrap();
187 symlink(outside.path().join("escape.txt"), tmp.path().join("out.txt")).unwrap();
191 let err = sb.resolve_write("out.txt").unwrap_err();
192 assert_eq!(err.kind, crate::error::ScriptErrorKind::SandboxViolation);
193 assert!(
194 !outside.path().join("escape.txt").exists(),
195 "must not have created the target"
196 );
197 }
198
199 #[cfg(unix)]
200 #[test]
201 fn rejects_symlink_escape() {
202 use std::os::unix::fs::symlink;
203 let (tmp, sb) = tmp_sandbox();
204 let outside = tempfile::tempdir().unwrap();
205 std::fs::write(outside.path().join("secret"), b"nope").unwrap();
206 symlink(outside.path().join("secret"), tmp.path().join("link")).unwrap();
207 let err = sb.resolve_read("link").unwrap_err();
208 assert_eq!(err.kind, crate::error::ScriptErrorKind::SandboxViolation);
209 }
210}