1pub use orcs_auth::{SandboxError, SandboxPolicy};
41
42use std::path::{Path, PathBuf};
43
44#[derive(Debug, Clone)]
61pub struct ProjectSandbox {
62 project_root: PathBuf,
63 permissive_root: PathBuf,
64}
65
66impl ProjectSandbox {
67 pub fn new(project_root: impl AsRef<Path>) -> Result<Self, SandboxError> {
75 let root = project_root.as_ref().canonicalize().map_err(|e| {
76 SandboxError::Init(format!(
77 "cannot canonicalize '{}': {e}",
78 project_root.as_ref().display()
79 ))
80 })?;
81
82 Ok(Self {
83 project_root: root.clone(),
84 permissive_root: root,
85 })
86 }
87
88 pub fn scoped(&self, sub_path: impl AsRef<Path>) -> Result<Self, SandboxError> {
97 let absolute = if sub_path.as_ref().is_absolute() {
98 sub_path.as_ref().to_path_buf()
99 } else {
100 self.permissive_root.join(sub_path.as_ref())
101 };
102
103 let canonical = absolute.canonicalize().map_err(|e| {
104 SandboxError::Init(format!(
105 "cannot canonicalize scoped path '{}': {e}",
106 absolute.display()
107 ))
108 })?;
109
110 if !canonical.starts_with(&self.permissive_root) {
111 return Err(SandboxError::OutsideBoundary {
112 path: absolute.display().to_string(),
113 root: self.permissive_root.display().to_string(),
114 });
115 }
116
117 Ok(Self {
118 project_root: self.project_root.clone(),
119 permissive_root: canonical,
120 })
121 }
122}
123
124impl SandboxPolicy for ProjectSandbox {
125 fn project_root(&self) -> &Path {
126 &self.project_root
127 }
128
129 fn root(&self) -> &Path {
130 &self.permissive_root
131 }
132
133 fn validate_read(&self, path: &str) -> Result<PathBuf, SandboxError> {
134 let absolute = resolve_absolute(path, &self.permissive_root);
135 let canonical = absolute
136 .canonicalize()
137 .map_err(|e| SandboxError::NotFound {
138 path: path.to_string(),
139 source: e,
140 })?;
141
142 if !canonical.starts_with(&self.permissive_root) {
143 return Err(SandboxError::OutsideBoundary {
144 path: path.to_string(),
145 root: self.permissive_root.display().to_string(),
146 });
147 }
148
149 Ok(canonical)
150 }
151
152 fn validate_write(&self, path: &str) -> Result<PathBuf, SandboxError> {
153 let absolute = resolve_absolute(path, &self.permissive_root);
154
155 let mut ancestor = absolute.as_path();
156 loop {
157 if ancestor.exists() {
158 let canonical_ancestor = ancestor.canonicalize().map_err(|e| {
159 SandboxError::Init(format!("path resolution failed: {path} ({e})"))
160 })?;
161 if !canonical_ancestor.starts_with(&self.permissive_root) {
162 return Err(SandboxError::OutsideBoundary {
163 path: path.to_string(),
164 root: self.permissive_root.display().to_string(),
165 });
166 }
167 let suffix = absolute.strip_prefix(ancestor).unwrap_or(Path::new(""));
171 if suffix.as_os_str().is_empty() {
172 return Ok(canonical_ancestor);
173 }
174 return Ok(canonical_ancestor.join(suffix));
175 }
176 match ancestor.parent() {
177 Some(p) if !p.as_os_str().is_empty() => ancestor = p,
178 _ => {
179 return Err(SandboxError::OutsideBoundary {
180 path: path.to_string(),
181 root: self.permissive_root.display().to_string(),
182 });
183 }
184 }
185 }
186 }
187}
188
189fn resolve_absolute(path: &str, root: &Path) -> PathBuf {
193 let requested = Path::new(path);
194 if requested.is_absolute() {
195 requested.to_path_buf()
196 } else {
197 root.join(requested)
198 }
199}
200
201#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::WorkDir;
207 use std::fs;
208
209 fn test_sandbox() -> (WorkDir, ProjectSandbox) {
210 let tmp = WorkDir::temporary().expect("should create temp WorkDir for sandbox test");
211 let sandbox = ProjectSandbox::new(tmp.path()).expect("should create sandbox from temp dir");
212 (tmp, sandbox)
213 }
214
215 #[test]
218 fn new_sandbox_canonicalizes_root() {
219 let (tmp, sandbox) = test_sandbox();
220 let expected = tmp
221 .path()
222 .canonicalize()
223 .expect("should canonicalize temp dir path");
224 assert_eq!(sandbox.root(), expected);
225 assert_eq!(sandbox.project_root(), expected);
226 }
227
228 #[test]
229 fn new_sandbox_nonexistent_path_fails() {
230 let result = ProjectSandbox::new("/nonexistent/path/xyz");
231 assert!(result.is_err());
232 }
233
234 #[test]
237 fn read_accepts_file_under_root() {
238 let (tmp, sandbox) = test_sandbox();
239 fs::write(tmp.path().join("ok.txt"), "data").expect("should write ok.txt to temp dir");
240
241 let result = sandbox.validate_read("ok.txt");
242 assert!(result.is_ok());
243 }
244
245 #[test]
246 fn read_accepts_absolute_under_root() {
247 let (tmp, sandbox) = test_sandbox();
248 let file = tmp.path().join("abs.txt");
249 fs::write(&file, "data").expect("should write abs.txt to temp dir");
250
251 let result =
252 sandbox.validate_read(file.to_str().expect("should convert abs.txt path to str"));
253 assert!(result.is_ok());
254 }
255
256 #[test]
257 fn read_rejects_outside_root() {
258 let (_tmp, sandbox) = test_sandbox();
259 let result = sandbox.validate_read("/etc/hosts");
260 assert!(result.is_err());
261 let err = result
262 .expect_err("read outside root should fail")
263 .to_string();
264 assert!(err.contains("access denied"), "got: {err}");
265 }
266
267 #[test]
268 fn read_rejects_traversal_via_dotdot() {
269 let (tmp, sandbox) = test_sandbox();
270 let sub = tmp.path().join("sub");
271 fs::create_dir_all(&sub).expect("should create sub dir");
272 fs::write(tmp.path().join("secret.txt"), "secret")
273 .expect("should write secret.txt to temp dir");
274
275 let scoped = sandbox
276 .scoped("sub")
277 .expect("should create scoped sandbox for sub");
278 let result = scoped.validate_read("../secret.txt");
279 assert!(result.is_err());
280 let err = result
281 .expect_err("traversal via .. should fail")
282 .to_string();
283 assert!(err.contains("access denied"), "got: {err}");
284 }
285
286 #[test]
287 fn read_rejects_nonexistent() {
288 let (_tmp, sandbox) = test_sandbox();
289 let result = sandbox.validate_read("nonexistent.txt");
290 assert!(result.is_err());
291 let err = result
292 .expect_err("read of nonexistent file should fail")
293 .to_string();
294 assert!(err.contains("path not found"), "got: {err}");
295 }
296
297 #[test]
300 fn write_accepts_new_file_under_root() {
301 let (_tmp, sandbox) = test_sandbox();
302 let result = sandbox.validate_write("new_file.txt");
303 assert!(result.is_ok());
304 }
305
306 #[test]
307 fn write_accepts_nested_new_file() {
308 let (_tmp, sandbox) = test_sandbox();
309 let result = sandbox.validate_write("sub/deep/new.txt");
310 assert!(result.is_ok());
311 }
312
313 #[test]
314 fn write_rejects_outside_root() {
315 let (_tmp, sandbox) = test_sandbox();
316 let result = sandbox.validate_write("/etc/evil.txt");
317 assert!(result.is_err());
318 let err = result
319 .expect_err("write outside root should fail")
320 .to_string();
321 assert!(err.contains("access denied"), "got: {err}");
322 }
323
324 #[test]
325 fn write_rejects_traversal_via_dotdot() {
326 let (_tmp, sandbox) = test_sandbox();
327 let result = sandbox.validate_write("../escape.txt");
328 assert!(result.is_err());
329 let err = result
330 .expect_err("write via .. traversal should fail")
331 .to_string();
332 assert!(err.contains("access denied"), "got: {err}");
333 }
334
335 #[test]
338 fn scoped_narrows_boundary() {
339 let (tmp, sandbox) = test_sandbox();
340 let sub = tmp.path().join("components");
341 fs::create_dir_all(&sub).expect("should create components dir");
342 fs::write(sub.join("comp.lua"), "-- lua").expect("should write comp.lua");
343 fs::write(tmp.path().join("top.txt"), "top").expect("should write top.txt");
344
345 let scoped = sandbox
346 .scoped("components")
347 .expect("should create scoped sandbox for components dir");
348
349 assert!(scoped.validate_read("comp.lua").is_ok());
351
352 assert!(scoped.validate_read("../top.txt").is_err());
354 }
355
356 #[test]
357 fn scoped_preserves_project_root() {
358 let (tmp, sandbox) = test_sandbox();
359 let sub = tmp.path().join("sub");
360 fs::create_dir_all(&sub).expect("should create sub dir for scoped test");
361
362 let scoped = sandbox
363 .scoped("sub")
364 .expect("should create scoped sandbox for sub");
365 assert_eq!(scoped.project_root(), sandbox.project_root());
366 assert_ne!(scoped.root(), sandbox.root());
367 }
368
369 #[test]
370 fn scoped_rejects_outside_parent() {
371 let (_tmp, sandbox) = test_sandbox();
372 let result = sandbox.scoped("/etc");
373 assert!(result.is_err());
374 }
375
376 #[test]
377 fn scoped_nonexistent_subdir_fails() {
378 let (_tmp, sandbox) = test_sandbox();
379 let result = sandbox.scoped("nonexistent_sub");
380 assert!(result.is_err());
381 }
382
383 #[test]
386 fn trait_object_works() {
387 let (tmp, sandbox) = test_sandbox();
388 fs::write(tmp.path().join("trait_test.txt"), "ok").expect("should write trait_test.txt");
389
390 let policy: Box<dyn SandboxPolicy> = Box::new(sandbox);
391 assert!(policy.validate_read("trait_test.txt").is_ok());
392 assert!(policy.validate_read("/etc/hosts").is_err());
393 }
394
395 #[test]
396 fn arc_trait_object_works() {
397 use std::sync::Arc;
398
399 let (tmp, sandbox) = test_sandbox();
400 fs::write(tmp.path().join("arc_test.txt"), "ok").expect("should write arc_test.txt");
401
402 let policy: Arc<dyn SandboxPolicy> = Arc::new(sandbox);
403 let clone = Arc::clone(&policy);
404
405 assert!(policy.validate_read("arc_test.txt").is_ok());
406 assert!(clone.validate_write("new.txt").is_ok());
407 }
408
409 mod proptest_sandbox {
412 use super::*;
413 use proptest::prelude::*;
414
415 fn path_strategy() -> impl Strategy<Value = String> {
417 prop::string::string_regex("[a-zA-Z0-9_./ -]{0,128}")
418 .expect("regex should be valid for path strategy")
419 }
420
421 fn traversal_strategy() -> impl Strategy<Value = String> {
423 prop::string::string_regex("([a-z]{0,8}/)*\\.\\./([a-z]{0,8}/)*[a-z]{1,8}\\.txt")
424 .expect("regex should be valid for traversal strategy")
425 }
426
427 proptest! {
428 #[test]
430 fn read_never_panics(path in path_strategy()) {
431 let (tmp, sandbox) = test_sandbox();
432 let _ = fs::write(tmp.path().join("exists.txt"), "x");
433 let _ = sandbox.validate_read(&path);
434 }
435
436 #[test]
438 fn write_never_panics(path in path_strategy()) {
439 let (_tmp, sandbox) = test_sandbox();
440 let _ = sandbox.validate_write(&path);
441 }
442
443 #[test]
445 fn scoped_rejects_all_traversal(path in traversal_strategy()) {
446 let (tmp, sandbox) = test_sandbox();
447 let sub = tmp.path().join("sub");
448 let _ = fs::create_dir_all(&sub);
449 if let Ok(scoped) = sandbox.scoped("sub") {
450 let result = scoped.validate_read(&path);
451 prop_assert!(
452 result.is_err(),
453 "traversal path '{}' should be rejected in scoped sandbox",
454 path
455 );
456 }
457 }
458
459 #[test]
461 fn internal_files_always_readable(name in "[a-z]{1,16}\\.txt") {
462 let (tmp, sandbox) = test_sandbox();
463 let _ = fs::write(tmp.path().join(&name), "data");
464 let result = sandbox.validate_read(&name);
465 prop_assert!(result.is_ok(), "file '{}' inside sandbox should be readable", name);
466 }
467
468 #[test]
470 fn internal_paths_always_writable(name in "[a-z]{1,16}\\.txt") {
471 let (_tmp, sandbox) = test_sandbox();
472 let result = sandbox.validate_write(&name);
473 prop_assert!(result.is_ok(), "path '{}' inside sandbox should be writable", name);
474 }
475
476 #[test]
478 fn absolute_outside_rejected(name in "[a-z]{1,16}\\.txt") {
479 let (_tmp, sandbox) = test_sandbox();
480 let path = format!("/tmp/orcs-proptest-outside/{}", name);
481 let result = sandbox.validate_write(&path);
482 prop_assert!(result.is_err(), "absolute path '{}' outside root should be rejected", path);
483 }
484 }
485 }
486
487 #[cfg(unix)]
490 mod symlink_tests {
491 use super::*;
492 use std::os::unix::fs::symlink;
493
494 #[test]
495 fn read_rejects_symlink_escape() {
496 let (tmp, sandbox) = test_sandbox();
497 symlink("/etc/hosts", tmp.path().join("evil_link"))
498 .expect("should create symlink to /etc/hosts");
499
500 let result = sandbox.validate_read("evil_link");
501 assert!(result.is_err());
502 assert!(
503 result
504 .expect_err("symlink escape read should fail")
505 .to_string()
506 .contains("access denied"),
507 "symlink to /etc/hosts should be rejected"
508 );
509 }
510
511 #[test]
512 fn write_rejects_symlink_parent_escape() {
513 let (tmp, sandbox) = test_sandbox();
514 let outside =
515 WorkDir::temporary().expect("should create outside temp WorkDir for symlink test");
516 symlink(outside.path(), tmp.path().join("escape_dir"))
517 .expect("should create symlink to outside dir");
518
519 let result = sandbox.validate_write("escape_dir/evil.txt");
520 assert!(result.is_err());
521 assert!(
522 result
523 .expect_err("symlink parent escape write should fail")
524 .to_string()
525 .contains("access denied"),
526 "symlink directory escape should be rejected"
527 );
528 }
529
530 #[test]
531 fn read_allows_symlink_within_sandbox() {
532 let (tmp, sandbox) = test_sandbox();
533 let real = tmp.path().join("real.txt");
534 fs::write(&real, "ok").expect("should write real.txt for internal symlink test");
535 symlink(&real, tmp.path().join("good_link"))
536 .expect("should create symlink within sandbox");
537
538 let result = sandbox.validate_read("good_link");
539 assert!(result.is_ok(), "symlink within sandbox should be allowed");
540 }
541
542 #[test]
543 fn scoped_read_rejects_symlink_to_parent() {
544 let (tmp, sandbox) = test_sandbox();
545 let sub = tmp.path().join("sub");
546 fs::create_dir_all(&sub).expect("should create sub dir for scoped symlink test");
547 fs::write(tmp.path().join("secret.txt"), "secret")
548 .expect("should write secret.txt for scoped symlink test");
549 symlink(tmp.path().join("secret.txt"), sub.join("link_to_parent"))
550 .expect("should create symlink to parent file");
551
552 let scoped = sandbox
553 .scoped("sub")
554 .expect("should create scoped sandbox for sub dir");
555 let result = scoped.validate_read("link_to_parent");
556 assert!(
557 result.is_err(),
558 "symlink escaping scoped sandbox should be rejected"
559 );
560 }
561 }
562}