1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "kebab-case")]
13pub enum FsAccessMode {
14 ReadOnly,
15 #[default]
16 WorkspaceWrite,
17 DangerFullAccess,
18}
19
20impl FsAccessMode {
21 pub fn as_str(self) -> &'static str {
22 match self {
23 Self::ReadOnly => "read-only",
24 Self::WorkspaceWrite => "workspace-write",
25 Self::DangerFullAccess => "danger-full-access",
26 }
27 }
28}
29
30impl std::str::FromStr for FsAccessMode {
31 type Err = String;
32
33 fn from_str(s: &str) -> Result<Self, Self::Err> {
34 match s {
35 "read-only" | "readonly" | "ro" => Ok(Self::ReadOnly),
36 "workspace-write" | "workspace" | "ws" => Ok(Self::WorkspaceWrite),
37 "danger-full-access" | "full-access" | "danger" => Ok(Self::DangerFullAccess),
38 other => Err(format!(
39 "unknown fs access mode: {other}. Expected one of: read-only, workspace-write, danger-full-access"
40 )),
41 }
42 }
43}
44
45#[derive(Debug, Clone, Default)]
49pub struct FsAccessPolicy {
50 pub mode: FsAccessMode,
51 pub workspace: Option<PathBuf>,
52}
53
54impl FsAccessPolicy {
55 pub fn danger_full_access() -> Self {
56 Self {
57 mode: FsAccessMode::DangerFullAccess,
58 workspace: None,
59 }
60 }
61
62 pub fn workspace_write(workspace: PathBuf) -> Self {
63 Self {
64 mode: FsAccessMode::WorkspaceWrite,
65 workspace: Some(workspace),
66 }
67 }
68
69 pub fn check_write(&self, target: &Path) -> Result<(), FsAccessError> {
70 check_write(target, self.workspace.as_deref(), self.mode)
71 }
72}
73
74#[derive(Debug, Error)]
75pub enum FsAccessError {
76 #[error("read-only fs access: refusing to write {}", path.display())]
77 ReadOnlyBlocked { path: PathBuf },
78 #[error(
79 "workspace-write fs access: refusing to write {} outside workspace root {}",
80 path.display(),
81 workspace.display()
82 )]
83 OutsideWorkspace { path: PathBuf, workspace: PathBuf },
84}
85
86pub fn check_write(
91 target: &Path,
92 workspace: Option<&Path>,
93 mode: FsAccessMode,
94) -> Result<(), FsAccessError> {
95 match mode {
96 FsAccessMode::DangerFullAccess => Ok(()),
97 FsAccessMode::ReadOnly => Err(FsAccessError::ReadOnlyBlocked {
98 path: target.to_path_buf(),
99 }),
100 FsAccessMode::WorkspaceWrite => {
101 let canonical = canonicalize_stable(target);
102 let temp = canonicalize_stable(&std::env::temp_dir());
103 if canonical.starts_with(&temp) {
104 return Ok(());
105 }
106 let ws = match workspace {
107 Some(ws) => canonicalize_stable(ws),
108 None => {
109 return Err(FsAccessError::OutsideWorkspace {
110 path: canonical,
111 workspace: PathBuf::from("<none>"),
112 });
113 }
114 };
115 if canonical.starts_with(&ws) {
116 Ok(())
117 } else {
118 Err(FsAccessError::OutsideWorkspace {
119 path: canonical,
120 workspace: ws,
121 })
122 }
123 }
124 }
125}
126
127fn canonicalize_stable(p: &Path) -> PathBuf {
133 let absolute = if p.is_absolute() {
134 p.to_path_buf()
135 } else {
136 match std::env::current_dir() {
137 Ok(cwd) => cwd.join(p),
138 Err(_) => return p.to_path_buf(),
139 }
140 };
141 let mut ancestor = absolute.as_path();
142 let mut suffix: Vec<&std::ffi::OsStr> = Vec::new();
143 let root = loop {
144 if let Ok(real) = ancestor.canonicalize() {
145 break real;
146 }
147 match (ancestor.parent(), ancestor.file_name()) {
148 (Some(parent), Some(name)) => {
149 suffix.push(name);
150 ancestor = parent;
151 }
152 _ => return absolute,
153 }
154 };
155 let mut out = root;
156 for name in suffix.into_iter().rev() {
157 out.push(name);
158 }
159 out
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use tempfile::TempDir;
166
167 #[test]
168 fn parse_canonical_forms() {
169 use std::str::FromStr;
170 assert_eq!(
171 FsAccessMode::from_str("read-only").unwrap(),
172 FsAccessMode::ReadOnly
173 );
174 assert_eq!(
175 FsAccessMode::from_str("workspace-write").unwrap(),
176 FsAccessMode::WorkspaceWrite
177 );
178 assert_eq!(
179 FsAccessMode::from_str("danger-full-access").unwrap(),
180 FsAccessMode::DangerFullAccess
181 );
182 }
183
184 #[test]
185 fn parse_aliases() {
186 use std::str::FromStr;
187 assert_eq!(
188 FsAccessMode::from_str("ro").unwrap(),
189 FsAccessMode::ReadOnly
190 );
191 assert_eq!(
192 FsAccessMode::from_str("ws").unwrap(),
193 FsAccessMode::WorkspaceWrite
194 );
195 assert_eq!(
196 FsAccessMode::from_str("danger").unwrap(),
197 FsAccessMode::DangerFullAccess
198 );
199 }
200
201 #[test]
202 fn parse_rejects_unknown() {
203 use std::str::FromStr;
204 let err = FsAccessMode::from_str("chaos").unwrap_err();
205 assert!(err.contains("chaos"));
206 }
207
208 #[test]
209 fn default_is_workspace_write() {
210 assert_eq!(FsAccessMode::default(), FsAccessMode::WorkspaceWrite);
211 }
212
213 #[test]
214 fn round_trip_as_str_and_from_str() {
215 use std::str::FromStr;
216 for mode in [
217 FsAccessMode::ReadOnly,
218 FsAccessMode::WorkspaceWrite,
219 FsAccessMode::DangerFullAccess,
220 ] {
221 assert_eq!(FsAccessMode::from_str(mode.as_str()).unwrap(), mode);
222 }
223 }
224
225 #[test]
226 fn read_only_blocks_every_write() {
227 let ws = TempDir::new().unwrap();
228 let target = ws.path().join("inside.txt");
229 let err = check_write(&target, Some(ws.path()), FsAccessMode::ReadOnly).unwrap_err();
230 assert!(matches!(err, FsAccessError::ReadOnlyBlocked { .. }));
231 }
232
233 #[test]
234 fn danger_full_access_permits_arbitrary_paths() {
235 let target = std::env::temp_dir().join("some/absurd/deep/path.txt");
236 assert!(check_write(&target, None, FsAccessMode::DangerFullAccess).is_ok());
237 assert!(
238 check_write(
239 Path::new("/etc/passwd"),
240 None,
241 FsAccessMode::DangerFullAccess
242 )
243 .is_ok()
244 );
245 }
246
247 #[test]
248 fn workspace_write_allows_paths_inside_workspace() {
249 let ws = TempDir::new().unwrap();
250 let nested = ws.path().join("sub/dir");
251 std::fs::create_dir_all(&nested).unwrap();
252 let target = nested.join("a.txt");
253 assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
254 }
255
256 #[test]
257 fn workspace_write_allows_paths_inside_tempdir() {
258 let ws = TempDir::new().unwrap();
259 let scratch = TempDir::new().unwrap();
260 let target = scratch.path().join("scratch.txt");
261 assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
262 }
263
264 #[test]
265 fn workspace_write_blocks_paths_outside_workspace_and_tempdir() {
266 let ws = TempDir::new().unwrap();
267 let outside = TempDir::new().unwrap();
268 let outside_target = outside.path().join("../../etc/passwd");
269 let bad = PathBuf::from("/etc/passwd");
272 let err = check_write(&bad, Some(ws.path()), FsAccessMode::WorkspaceWrite).unwrap_err();
273 assert!(matches!(err, FsAccessError::OutsideWorkspace { .. }));
274 drop(outside_target);
275 }
276
277 #[test]
278 fn workspace_write_without_workspace_only_allows_tempdir() {
279 let scratch = TempDir::new().unwrap();
280 assert!(
281 check_write(
282 &scratch.path().join("f.txt"),
283 None,
284 FsAccessMode::WorkspaceWrite,
285 )
286 .is_ok()
287 );
288 let err =
289 check_write(Path::new("/etc/passwd"), None, FsAccessMode::WorkspaceWrite).unwrap_err();
290 assert!(matches!(err, FsAccessError::OutsideWorkspace { .. }));
291 }
292
293 #[test]
294 fn nonexistent_target_still_checked_against_workspace() {
295 let ws = TempDir::new().unwrap();
296 let target = ws.path().join("does/not/exist/yet.txt");
299 assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
300 }
301
302 #[test]
303 fn error_messages_reference_the_offending_paths() {
304 let ws = TempDir::new().unwrap();
305 let err = check_write(
306 Path::new("/etc/passwd"),
307 Some(ws.path()),
308 FsAccessMode::WorkspaceWrite,
309 )
310 .unwrap_err();
311 let msg = err.to_string();
312 assert!(msg.contains("/etc/passwd"));
313 assert!(msg.contains(&ws.path().display().to_string()));
314 }
315}