1use camino::Utf8PathBuf;
2use std::cell::RefCell;
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6thread_local! {
8 static THREAD_HOME: RefCell<Option<Utf8PathBuf>> = const { RefCell::new(None) };
9}
10
11#[cfg(unix)]
41pub fn link_count(path: &Path) -> Result<u32, std::io::Error> {
42 use std::os::unix::fs::MetadataExt;
43 let metadata = path.metadata()?;
44 Ok(metadata.nlink() as u32)
46}
47
48#[cfg(windows)]
49pub fn link_count(path: &Path) -> Result<u32, std::io::Error> {
50 use std::fs::File;
51 use std::os::windows::io::AsRawHandle;
52 use windows::Win32::Foundation::HANDLE;
53 use windows::Win32::Storage::FileSystem::{
54 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
55 };
56
57 let file = File::open(path)?;
59
60 let handle = HANDLE(file.as_raw_handle());
61 let mut file_info = BY_HANDLE_FILE_INFORMATION::default();
62
63 let result = unsafe { GetFileInformationByHandle(handle, &mut file_info) };
65
66 match result {
67 Ok(()) => Ok(file_info.nNumberOfLinks),
68 Err(e) => Err(std::io::Error::other(format!(
69 "GetFileInformationByHandle failed: {e}"
70 ))),
71 }
72}
73
74#[derive(Error, Debug, Clone, PartialEq, Eq)]
83pub enum SandboxError {
84 #[error("Sandbox root does not exist: {path}")]
86 RootNotFound { path: String },
87
88 #[error("Sandbox root is not a directory: {path}")]
90 RootNotDirectory { path: String },
91
92 #[error("Failed to canonicalize sandbox root '{path}': {reason}")]
94 RootCanonicalizationFailed { path: String, reason: String },
95
96 #[error("Path contains parent directory traversal: {path}")]
98 ParentTraversal { path: String },
99
100 #[error("Absolute path not allowed: {path}")]
102 AbsolutePath { path: String },
103
104 #[error("Path escapes sandbox root: {path} resolves outside {root}")]
106 EscapeAttempt { path: String, root: String },
107
108 #[error("Symlink not allowed: {path}")]
110 SymlinkNotAllowed { path: String },
111
112 #[error("Hardlink not allowed: {path}")]
114 HardlinkNotAllowed { path: String },
115
116 #[error("Failed to canonicalize path '{path}': {reason}")]
118 PathCanonicalizationFailed { path: String, reason: String },
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub struct SandboxConfig {
128 pub allow_symlinks: bool,
130 pub allow_hardlinks: bool,
132}
133
134impl SandboxConfig {
135 #[must_use]
137 pub fn permissive() -> Self {
138 Self {
139 allow_symlinks: true,
140 allow_hardlinks: true,
141 }
142 }
143}
144
145#[derive(Debug, Clone)]
173pub struct SandboxRoot {
174 root: PathBuf,
176 config: SandboxConfig,
178}
179
180impl SandboxRoot {
181 pub fn new(root: impl AsRef<Path>, config: SandboxConfig) -> Result<Self, SandboxError> {
197 let root_path = root.as_ref();
198
199 if !root_path.exists() {
201 return Err(SandboxError::RootNotFound {
202 path: root_path.display().to_string(),
203 });
204 }
205
206 if !root_path.is_dir() {
208 return Err(SandboxError::RootNotDirectory {
209 path: root_path.display().to_string(),
210 });
211 }
212
213 let canonical =
215 root_path
216 .canonicalize()
217 .map_err(|e| SandboxError::RootCanonicalizationFailed {
218 path: root_path.display().to_string(),
219 reason: e.to_string(),
220 })?;
221
222 Ok(Self {
223 root: canonical,
224 config,
225 })
226 }
227
228 pub fn new_default(root: impl AsRef<Path>) -> Result<Self, SandboxError> {
232 Self::new(root, SandboxConfig::default())
233 }
234
235 pub fn join(&self, rel: impl AsRef<Path>) -> Result<SandboxPath, SandboxError> {
250 let rel_path = rel.as_ref();
251
252 if rel_path.is_absolute() {
254 return Err(SandboxError::AbsolutePath {
255 path: rel_path.display().to_string(),
256 });
257 }
258
259 if rel_path
261 .components()
262 .any(|c| matches!(c, std::path::Component::ParentDir))
263 {
264 return Err(SandboxError::ParentTraversal {
265 path: rel_path.display().to_string(),
266 });
267 }
268
269 let full_path = self.root.join(rel_path);
271
272 if !self.config.allow_symlinks {
274 self.check_symlinks_in_path(&full_path)?;
275 }
276
277 if full_path.exists() {
279 let canonical =
280 full_path
281 .canonicalize()
282 .map_err(|e| SandboxError::PathCanonicalizationFailed {
283 path: full_path.display().to_string(),
284 reason: e.to_string(),
285 })?;
286
287 if !canonical.starts_with(&self.root) {
289 return Err(SandboxError::EscapeAttempt {
290 path: rel_path.display().to_string(),
291 root: self.root.display().to_string(),
292 });
293 }
294
295 if !self.config.allow_hardlinks {
297 self.check_hardlink(&canonical)?;
298 }
299
300 Ok(SandboxPath {
301 full: canonical,
302 rel: rel_path.to_path_buf(),
303 })
304 } else {
305 if self.config.allow_symlinks {
310 self.validate_ancestor_within_sandbox(&full_path, rel_path)?;
311 }
312
313 Ok(SandboxPath {
315 full: full_path,
316 rel: rel_path.to_path_buf(),
317 })
318 }
319 }
320
321 fn check_symlinks_in_path(&self, path: &Path) -> Result<(), SandboxError> {
323 let mut current = PathBuf::new();
324
325 for component in path.components() {
326 current.push(component);
327
328 if current.exists() {
330 if current
332 .symlink_metadata()
333 .map(|m| m.is_symlink())
334 .unwrap_or(false)
335 {
336 return Err(SandboxError::SymlinkNotAllowed {
337 path: current.display().to_string(),
338 });
339 }
340 }
341 }
342
343 Ok(())
344 }
345
346 fn check_hardlink(&self, path: &Path) -> Result<(), SandboxError> {
351 if path.is_file() {
353 match link_count(path) {
354 Ok(count) if count > 1 => {
355 return Err(SandboxError::HardlinkNotAllowed {
356 path: path.display().to_string(),
357 });
358 }
359 Ok(_) => {
360 }
362 Err(_) => {
363 return Err(SandboxError::HardlinkNotAllowed {
365 path: path.display().to_string(),
366 });
367 }
368 }
369 }
370
371 Ok(())
372 }
373
374 fn validate_ancestor_within_sandbox(
388 &self,
389 full_path: &Path,
390 rel_path: &Path,
391 ) -> Result<(), SandboxError> {
392 let mut ancestor = full_path.to_path_buf();
394 while !ancestor.exists() {
395 if !ancestor.pop() {
396 return Ok(());
398 }
399 }
400
401 let canonical_ancestor =
403 ancestor
404 .canonicalize()
405 .map_err(|e| SandboxError::PathCanonicalizationFailed {
406 path: ancestor.display().to_string(),
407 reason: e.to_string(),
408 })?;
409
410 if !canonical_ancestor.starts_with(&self.root) {
412 return Err(SandboxError::EscapeAttempt {
413 path: rel_path.display().to_string(),
414 root: self.root.display().to_string(),
415 });
416 }
417
418 Ok(())
419 }
420
421 #[must_use]
423 pub fn as_path(&self) -> &Path {
424 &self.root
425 }
426
427 #[must_use]
429 pub fn config(&self) -> &SandboxConfig {
430 &self.config
431 }
432}
433
434#[derive(Debug, Clone)]
463pub struct SandboxPath {
464 full: PathBuf,
466 rel: PathBuf,
468}
469
470impl SandboxPath {
471 #[must_use]
476 pub fn as_path(&self) -> &Path {
477 &self.full
478 }
479
480 #[must_use]
485 pub fn relative(&self) -> &Path {
486 &self.rel
487 }
488
489 #[must_use]
491 pub fn to_path_buf(&self) -> PathBuf {
492 self.full.clone()
493 }
494
495 #[must_use]
497 pub fn relative_to_path_buf(&self) -> PathBuf {
498 self.rel.clone()
499 }
500}
501
502impl AsRef<Path> for SandboxPath {
503 fn as_ref(&self) -> &Path {
504 &self.full
505 }
506}
507
508#[must_use]
513pub fn xchecker_home() -> Utf8PathBuf {
514 if let Some(tl) = THREAD_HOME.with(|tl| tl.borrow().clone()) {
515 return tl;
516 }
517 if let Ok(p) = std::env::var("XCHECKER_HOME") {
518 return Utf8PathBuf::from(p);
519 }
520 Utf8PathBuf::from(".xchecker")
521}
522
523#[must_use]
525pub fn spec_root(spec_id: &str) -> Utf8PathBuf {
526 xchecker_home().join("specs").join(spec_id)
527}
528
529#[must_use]
531pub fn cache_dir() -> Utf8PathBuf {
532 xchecker_home().join("cache")
533}
534
535pub fn ensure_dir_all<P: AsRef<std::path::Path>>(p: P) -> std::io::Result<()> {
537 match std::fs::create_dir_all(&p) {
538 Ok(()) => Ok(()),
539 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
540 Err(e) => Err(e),
541 }
542}
543
544#[cfg(any(test, feature = "test-utils"))]
549#[cfg_attr(not(test), allow(dead_code))]
550#[must_use]
551pub fn with_isolated_home() -> tempfile::TempDir {
552 let td = tempfile::TempDir::new().expect("create temp home");
553 let p = Utf8PathBuf::from_path_buf(td.path().to_path_buf()).unwrap();
554 THREAD_HOME.with(|tl| *tl.borrow_mut() = Some(p.clone()));
555 #[cfg(feature = "test-utils")]
556 {
557 xchecker_lock::set_thread_home_for_tests(p);
558 }
559 td
560}
561
562#[cfg(test)]
567mod tests {
568 use super::*;
569 use tempfile::TempDir;
570
571 fn create_test_dir() -> TempDir {
572 TempDir::new().expect("Failed to create temp dir")
573 }
574
575 #[test]
580 fn test_sandbox_root_new_valid_directory() {
581 let temp = create_test_dir();
582 let root = SandboxRoot::new(temp.path(), SandboxConfig::default());
583 assert!(root.is_ok());
584 let root = root.unwrap();
585 assert!(root.as_path().is_absolute());
586 }
587
588 #[test]
589 fn test_sandbox_root_new_nonexistent_path() {
590 let result = SandboxRoot::new(
591 "/nonexistent/path/that/does/not/exist",
592 SandboxConfig::default(),
593 );
594 assert!(result.is_err());
595 assert!(matches!(
596 result.unwrap_err(),
597 SandboxError::RootNotFound { .. }
598 ));
599 }
600
601 #[test]
602 fn test_sandbox_root_new_file_not_directory() {
603 let temp = create_test_dir();
604 let file_path = temp.path().join("file.txt");
605 std::fs::write(&file_path, "content").unwrap();
606
607 let result = SandboxRoot::new(&file_path, SandboxConfig::default());
608 assert!(result.is_err());
609 assert!(matches!(
610 result.unwrap_err(),
611 SandboxError::RootNotDirectory { .. }
612 ));
613 }
614
615 #[test]
616 fn test_sandbox_root_new_default() {
617 let temp = create_test_dir();
618 let root = SandboxRoot::new_default(temp.path());
619 assert!(root.is_ok());
620 }
621
622 #[test]
627 fn test_sandbox_join_simple_relative_path() {
628 let temp = create_test_dir();
629 let subdir = temp.path().join("subdir");
630 std::fs::create_dir(&subdir).unwrap();
631 let file = subdir.join("file.txt");
632 std::fs::write(&file, "content").unwrap();
633
634 let root = SandboxRoot::new_default(temp.path()).unwrap();
635 let result = root.join("subdir/file.txt");
636 assert!(result.is_ok());
637 let sandbox_path = result.unwrap();
638 assert_eq!(sandbox_path.relative(), Path::new("subdir/file.txt"));
639 }
640
641 #[test]
642 fn test_sandbox_join_nonexistent_path_allowed() {
643 let temp = create_test_dir();
644 let root = SandboxRoot::new_default(temp.path()).unwrap();
645
646 let result = root.join("new/path/to/file.txt");
648 assert!(result.is_ok());
649 }
650
651 #[test]
656 fn test_sandbox_join_rejects_parent_traversal() {
657 let temp = create_test_dir();
658 let root = SandboxRoot::new_default(temp.path()).unwrap();
659
660 let result = root.join("../escape");
661 assert!(result.is_err());
662 assert!(matches!(
663 result.unwrap_err(),
664 SandboxError::ParentTraversal { .. }
665 ));
666 }
667
668 #[test]
669 fn test_sandbox_join_rejects_hidden_parent_traversal() {
670 let temp = create_test_dir();
671 let root = SandboxRoot::new_default(temp.path()).unwrap();
672
673 let result = root.join("subdir/../../../escape");
674 assert!(result.is_err());
675 assert!(matches!(
676 result.unwrap_err(),
677 SandboxError::ParentTraversal { .. }
678 ));
679 }
680
681 #[test]
682 fn test_sandbox_join_rejects_parent_at_end() {
683 let temp = create_test_dir();
684 let root = SandboxRoot::new_default(temp.path()).unwrap();
685
686 let result = root.join("subdir/..");
687 assert!(result.is_err());
688 assert!(matches!(
689 result.unwrap_err(),
690 SandboxError::ParentTraversal { .. }
691 ));
692 }
693
694 #[test]
699 fn test_sandbox_join_rejects_absolute_path() {
700 let temp = create_test_dir();
701 let root = SandboxRoot::new_default(temp.path()).unwrap();
702
703 #[cfg(unix)]
704 let result = root.join("/etc/passwd");
705 #[cfg(windows)]
706 let result = root.join("C:\\Windows\\System32");
707
708 assert!(result.is_err());
709 assert!(matches!(
710 result.unwrap_err(),
711 SandboxError::AbsolutePath { .. }
712 ));
713 }
714
715 #[cfg(unix)]
720 #[test]
721 fn test_sandbox_join_rejects_symlink_by_default() {
722 let temp = create_test_dir();
723 let target = temp.path().join("target.txt");
724 std::fs::write(&target, "content").unwrap();
725
726 let link = temp.path().join("link.txt");
727 std::os::unix::fs::symlink(&target, &link).unwrap();
728
729 let root = SandboxRoot::new_default(temp.path()).unwrap();
730 let result = root.join("link.txt");
731 assert!(result.is_err());
732 assert!(matches!(
733 result.unwrap_err(),
734 SandboxError::SymlinkNotAllowed { .. }
735 ));
736 }
737
738 #[cfg(unix)]
739 #[test]
740 fn test_sandbox_join_allows_symlink_when_configured() {
741 let temp = create_test_dir();
742 let target = temp.path().join("target.txt");
743 std::fs::write(&target, "content").unwrap();
744
745 let link = temp.path().join("link.txt");
746 std::os::unix::fs::symlink(&target, &link).unwrap();
747
748 let config = SandboxConfig::permissive();
749 let root = SandboxRoot::new(temp.path(), config).unwrap();
750 let result = root.join("link.txt");
751 assert!(result.is_ok());
752 }
753
754 #[cfg(unix)]
755 #[test]
756 fn test_sandbox_join_rejects_symlink_escape() {
757 let temp = create_test_dir();
758 let outside = TempDir::new().unwrap();
759 let outside_file = outside.path().join("secret.txt");
760 std::fs::write(&outside_file, "secret").unwrap();
761
762 let link = temp.path().join("escape_link");
764 std::os::unix::fs::symlink(&outside_file, &link).unwrap();
765
766 let config = SandboxConfig::permissive();
768 let root = SandboxRoot::new(temp.path(), config).unwrap();
769 let result = root.join("escape_link");
770 assert!(result.is_err());
771 assert!(matches!(
772 result.unwrap_err(),
773 SandboxError::EscapeAttempt { .. }
774 ));
775 }
776
777 #[cfg(unix)]
791 #[test]
792 fn test_sandbox_join_rejects_symlink_dir_escape_via_nonexistent_path() {
793 let temp = create_test_dir();
794 let outside = TempDir::new().unwrap();
795
796 let outside_dir = outside.path().join("attacker_controlled");
798 std::fs::create_dir(&outside_dir).unwrap();
799
800 let escape_link = temp.path().join("escape_dir");
802 std::os::unix::fs::symlink(&outside_dir, &escape_link).unwrap();
803
804 let config = SandboxConfig::permissive();
808 let root = SandboxRoot::new(temp.path(), config).unwrap();
809
810 let result = root.join("escape_dir/nonexistent_malicious_file.txt");
812 assert!(
813 result.is_err(),
814 "Expected escape to be detected for non-existent path through symlinked directory"
815 );
816 assert!(matches!(
817 result.unwrap_err(),
818 SandboxError::EscapeAttempt { .. }
819 ));
820 }
821
822 #[cfg(unix)]
824 #[test]
825 fn test_sandbox_join_allows_safe_symlink_dir_with_nonexistent_path() {
826 let temp = create_test_dir();
827
828 let inside_dir = temp.path().join("real_subdir");
830 std::fs::create_dir(&inside_dir).unwrap();
831
832 let safe_link = temp.path().join("link_to_subdir");
834 std::os::unix::fs::symlink(&inside_dir, &safe_link).unwrap();
835
836 let config = SandboxConfig::permissive();
838 let root = SandboxRoot::new(temp.path(), config).unwrap();
839
840 let result = root.join("link_to_subdir/new_file.txt");
842 assert!(
843 result.is_ok(),
844 "Expected safe symlink with non-existent path to succeed"
845 );
846 }
847
848 #[cfg(unix)]
853 #[test]
854 fn test_sandbox_join_rejects_hardlink_by_default() {
855 let temp = create_test_dir();
856 let original = temp.path().join("original.txt");
857 std::fs::write(&original, "content").unwrap();
858
859 let hardlink = temp.path().join("hardlink.txt");
860 std::fs::hard_link(&original, &hardlink).unwrap();
861
862 let root = SandboxRoot::new_default(temp.path()).unwrap();
863 let result = root.join("hardlink.txt");
864 assert!(result.is_err());
865 assert!(matches!(
866 result.unwrap_err(),
867 SandboxError::HardlinkNotAllowed { .. }
868 ));
869 }
870
871 #[cfg(unix)]
872 #[test]
873 fn test_sandbox_join_allows_hardlink_when_configured() {
874 let temp = create_test_dir();
875 let original = temp.path().join("original.txt");
876 std::fs::write(&original, "content").unwrap();
877
878 let hardlink = temp.path().join("hardlink.txt");
879 std::fs::hard_link(&original, &hardlink).unwrap();
880
881 let config = SandboxConfig::permissive();
882 let root = SandboxRoot::new(temp.path(), config).unwrap();
883 let result = root.join("hardlink.txt");
884 assert!(result.is_ok());
885 }
886
887 #[test]
892 fn test_sandbox_path_as_path() {
893 let temp = create_test_dir();
894 let file = temp.path().join("file.txt");
895 std::fs::write(&file, "content").unwrap();
896
897 let root = SandboxRoot::new_default(temp.path()).unwrap();
898 let sandbox_path = root.join("file.txt").unwrap();
899
900 assert!(sandbox_path.as_path().ends_with("file.txt"));
902 assert!(sandbox_path.as_path().is_absolute());
903 }
904
905 #[test]
906 fn test_sandbox_path_relative() {
907 let temp = create_test_dir();
908 let subdir = temp.path().join("a/b/c");
909 std::fs::create_dir_all(&subdir).unwrap();
910 let file = subdir.join("file.txt");
911 std::fs::write(&file, "content").unwrap();
912
913 let root = SandboxRoot::new_default(temp.path()).unwrap();
914 let sandbox_path = root.join("a/b/c/file.txt").unwrap();
915
916 assert_eq!(sandbox_path.relative(), Path::new("a/b/c/file.txt"));
918 }
919
920 #[test]
921 fn test_sandbox_path_to_path_buf() {
922 let temp = create_test_dir();
923 let file = temp.path().join("file.txt");
924 std::fs::write(&file, "content").unwrap();
925
926 let root = SandboxRoot::new_default(temp.path()).unwrap();
927 let sandbox_path = root.join("file.txt").unwrap();
928
929 let path_buf = sandbox_path.to_path_buf();
930 assert!(path_buf.is_absolute());
931 assert!(path_buf.ends_with("file.txt"));
932 }
933
934 #[test]
935 fn test_sandbox_path_as_ref() {
936 let temp = create_test_dir();
937 let file = temp.path().join("file.txt");
938 std::fs::write(&file, "content").unwrap();
939
940 let root = SandboxRoot::new_default(temp.path()).unwrap();
941 let sandbox_path = root.join("file.txt").unwrap();
942
943 let path_ref: &Path = sandbox_path.as_ref();
945 assert!(path_ref.ends_with("file.txt"));
946 }
947
948 #[test]
953 fn test_sandbox_config_default() {
954 let config = SandboxConfig::default();
955 assert!(!config.allow_symlinks);
956 assert!(!config.allow_hardlinks);
957 }
958
959 #[test]
960 fn test_sandbox_config_permissive() {
961 let config = SandboxConfig::permissive();
962 assert!(config.allow_symlinks);
963 assert!(config.allow_hardlinks);
964 }
965
966 #[test]
971 fn test_sandbox_error_display() {
972 let err = SandboxError::ParentTraversal {
973 path: "../escape".to_string(),
974 };
975 let msg = err.to_string();
976 assert!(msg.contains("parent directory traversal"));
977 assert!(msg.contains("../escape"));
978 }
979
980 #[test]
981 fn test_sandbox_error_equality() {
982 let err1 = SandboxError::AbsolutePath {
983 path: "/etc/passwd".to_string(),
984 };
985 let err2 = SandboxError::AbsolutePath {
986 path: "/etc/passwd".to_string(),
987 };
988 assert_eq!(err1, err2);
989 }
990}