1use std::path::{Path, PathBuf};
33
34#[derive(Debug, Clone)]
39pub enum ReadPathEntry {
40 Exact(PathBuf),
45 Glob {
47 pattern: glob::Pattern,
50 options: glob::MatchOptions,
53 },
54 Regex(regex::Regex),
56}
57
58impl ReadPathEntry {
59 fn matches(&self, canonical: &Path, normalized: &str) -> bool {
62 match self {
63 ReadPathEntry::Exact(root) => match std::fs::canonicalize(root) {
64 Ok(real_root) => canonical.starts_with(&real_root),
65 Err(_) => false,
70 },
71 ReadPathEntry::Glob { pattern, options } => pattern.matches_with(normalized, *options),
72 ReadPathEntry::Regex(re) => re.is_match(normalized),
73 }
74 }
75
76 pub fn sample_path(&self) -> Option<PathBuf> {
87 match self {
88 ReadPathEntry::Exact(root) => Some(root.clone()),
90 ReadPathEntry::Glob { pattern, options } => {
91 let sample = fill_glob_wildcards(pattern.as_str())?;
92 pattern
93 .matches_with(&sample, *options)
94 .then(|| PathBuf::from(sample))
95 }
96 ReadPathEntry::Regex(re) => {
97 let literal = literal_prefix(strip_regex_anchors(re.as_str()));
98 let in_dir = literal
102 .rsplit_once('/')
103 .map(|(dir, _)| format!("{dir}/{SAMPLE_COMPONENT}"));
104 [in_dir, (!literal.is_empty()).then_some(literal)]
105 .into_iter()
106 .flatten()
107 .find(|candidate| re.is_match(candidate))
108 .map(PathBuf::from)
109 }
110 }
111 }
112}
113
114const SAMPLE_COMPONENT: &str = "_leviath_probe";
117
118fn fill_glob_wildcards(pattern: &str) -> Option<String> {
122 let mut out = String::with_capacity(pattern.len());
123 let mut previous_was_star = false;
124 for ch in pattern.chars() {
125 match ch {
126 '[' => return None,
127 '*' => {
129 if !previous_was_star {
130 out.push_str(SAMPLE_COMPONENT);
131 }
132 previous_was_star = true;
133 continue;
134 }
135 '?' => out.push('x'),
136 other => out.push(other),
137 }
138 previous_was_star = false;
139 }
140 Some(out)
141}
142
143fn strip_regex_anchors(pattern: &str) -> &str {
147 pattern
148 .strip_prefix("^(?:")
149 .and_then(|rest| rest.strip_suffix(")$"))
150 .unwrap_or(pattern)
151}
152
153fn literal_prefix(pattern: &str) -> String {
157 pattern
158 .chars()
159 .take_while(|c| {
160 !matches!(
161 c,
162 '.' | '[' | ']' | '(' | ')' | '{' | '}' | '*' | '+' | '?' | '|' | '^' | '$' | '\\'
163 )
164 })
165 .collect()
166}
167
168pub fn normalize_match_str(s: &str, windows: bool) -> String {
182 if !windows {
183 return s.to_string();
184 }
185 let stripped = if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
186 format!(r"\\{rest}")
187 } else if let Some(rest) = s.strip_prefix(r"\\?\") {
188 let bytes = rest.as_bytes();
189 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
190 rest.to_string()
191 } else {
192 s.to_string()
193 }
194 } else {
195 s.to_string()
196 };
197 stripped.replace('\\', "/")
198}
199
200#[derive(Debug, Clone, Default)]
203pub struct ReadPathSet {
204 entries: Vec<ReadPathEntry>,
205 windows: bool,
209}
210
211impl ReadPathSet {
212 pub fn compile(
221 raw: &[String],
222 workdir: &Path,
223 home: Option<&Path>,
224 windows: bool,
225 ) -> Result<Self, String> {
226 let entries = raw
227 .iter()
228 .map(|entry| compile_entry(entry, workdir, home, windows))
229 .collect::<Result<Vec<_>, String>>()?;
230 Ok(Self { entries, windows })
231 }
232
233 pub fn is_empty(&self) -> bool {
235 self.entries.is_empty()
236 }
237
238 pub fn entries(&self) -> &[ReadPathEntry] {
240 &self.entries
241 }
242
243 pub fn matches(&self, canonical: &Path) -> bool {
245 let normalized = normalize_match_str(&canonical.to_string_lossy(), self.windows);
246 self.entries
247 .iter()
248 .any(|e| e.matches(canonical, &normalized))
249 }
250
251 pub fn matches_lexically(&self, path: &Path) -> bool {
263 let normalized = normalize_match_str(&path.to_string_lossy(), self.windows);
264 self.entries.iter().any(|entry| match entry {
265 ReadPathEntry::Exact(root) => {
266 let root = normalize_match_str(&root.to_string_lossy(), self.windows);
267 covers_lexically(&normalized, &root, self.windows)
268 }
269 other => other.matches(path, &normalized),
272 })
273 }
274}
275
276fn covers_lexically(path: &str, root: &str, windows: bool) -> bool {
280 let fold = |s: &str| {
281 if windows {
282 s.to_lowercase()
283 } else {
284 s.to_string()
285 }
286 };
287 let path = fold(path);
288 let root = fold(root);
289 let trimmed = root.trim_end_matches('/');
290 path == trimmed || path.starts_with(&format!("{trimmed}/"))
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum ReadPathDecision {
298 Allowed,
301 NotDeclared,
304 NotGranted,
306}
307
308#[derive(Debug, Clone, Default)]
315pub struct ReadPathPolicy {
316 pub agent: String,
318 pub blueprint: ReadPathSet,
320 pub grants: ReadPathSet,
322 pub allow_blueprint: bool,
324}
325
326impl ReadPathPolicy {
327 pub fn inactive() -> Self {
330 Self::default()
331 }
332
333 pub fn is_active(&self) -> bool {
337 !self.blueprint.is_empty()
338 }
339
340 pub fn decide(&self, canonical: &Path) -> ReadPathDecision {
343 if !self.blueprint.matches(canonical) {
344 return ReadPathDecision::NotDeclared;
345 }
346 if self.allow_blueprint || self.grants.matches(canonical) {
347 ReadPathDecision::Allowed
348 } else {
349 ReadPathDecision::NotGranted
350 }
351 }
352}
353
354pub fn validate_entry_syntax(raw: &str) -> Result<(), String> {
362 let workdir = Path::new("/validate/a/b/c/d/e/f/g/h");
366 compile_entry(raw, workdir, Some(Path::new("/validate-home")), false).map(|_| ())
367}
368
369fn compile_entry(
372 raw: &str,
373 workdir: &Path,
374 home: Option<&Path>,
375 windows: bool,
376) -> Result<ReadPathEntry, String> {
377 if raw.trim().is_empty() {
378 return Err("read_paths entry is empty".to_string());
379 }
380 if let Some(rest) = raw.strip_prefix("regex:") {
381 compile_regex(raw, rest, home, windows)
382 } else if let Some(rest) = raw.strip_prefix("glob:") {
383 compile_glob(raw, rest, workdir, home, windows)
384 } else {
385 compile_exact(raw, workdir, home)
386 }
387}
388
389fn absolute_shaped(text: &str) -> bool {
393 let bytes = text.as_bytes();
394 text.starts_with('/')
395 || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
396}
397
398fn compile_regex(
399 raw: &str,
400 rest: &str,
401 home: Option<&Path>,
402 windows: bool,
403) -> Result<ReadPathEntry, String> {
404 if rest.is_empty() {
405 return Err(format!("read_paths entry '{raw}': regex pattern is empty"));
406 }
407 let body = if let Some(after_tilde) = rest.strip_prefix('~') {
408 if !(after_tilde.is_empty() || after_tilde.starts_with('/')) {
409 return Err(format!(
410 "read_paths entry '{raw}': only '~/' home expansion is supported"
411 ));
412 }
413 let home = home.ok_or_else(|| {
414 format!("read_paths entry '{raw}': no home directory resolved for '~' expansion")
415 })?;
416 let prefix = regex::escape(&normalize_match_str(&home.to_string_lossy(), windows));
417 format!("{prefix}{after_tilde}")
418 } else if absolute_shaped(rest) {
419 rest.to_string()
420 } else {
421 return Err(format!(
422 "read_paths entry '{raw}': regex entries must start with '/', a drive letter, or '~/'; \
423 use 'glob:' for workdir-relative patterns"
424 ));
425 };
426 regex::RegexBuilder::new(&format!("^(?:{body})$"))
427 .case_insensitive(windows)
428 .build()
429 .map(ReadPathEntry::Regex)
430 .map_err(|e| format!("read_paths entry '{raw}': invalid regex: {e}"))
431}
432
433fn compile_glob(
434 raw: &str,
435 rest: &str,
436 workdir: &Path,
437 home: Option<&Path>,
438 windows: bool,
439) -> Result<ReadPathEntry, String> {
440 if rest.is_empty() {
441 return Err(format!("read_paths entry '{raw}': glob pattern is empty"));
442 }
443 let text = rest.replace('\\', "/");
446 let text = if let Some(after_tilde) = text.strip_prefix('~') {
447 if !(after_tilde.is_empty() || after_tilde.starts_with('/')) {
448 return Err(format!(
449 "read_paths entry '{raw}': only '~/' home expansion is supported"
450 ));
451 }
452 let home = home.ok_or_else(|| {
453 format!("read_paths entry '{raw}': no home directory resolved for '~' expansion")
454 })?;
455 let prefix = glob::Pattern::escape(&normalize_match_str(&home.to_string_lossy(), windows));
458 format!("{prefix}{after_tilde}")
459 } else if absolute_shaped(&text) {
460 text
461 } else {
462 resolve_relative_glob(raw, &text, workdir, windows)?
463 };
464 if text.split('/').any(|c| c == "." || c == "..") {
468 return Err(format!(
469 "read_paths entry '{raw}': glob patterns cannot contain '.' or '..' components \
470 (relative entries fold them against the workdir at the start only)"
471 ));
472 }
473 let pattern = glob::Pattern::new(&text)
474 .map_err(|e| format!("read_paths entry '{raw}': invalid glob: {e}"))?;
475 let options = glob::MatchOptions {
476 case_sensitive: !windows,
477 require_literal_separator: true,
479 require_literal_leading_dot: false,
480 };
481 Ok(ReadPathEntry::Glob { pattern, options })
482}
483
484fn resolve_relative_glob(
488 raw: &str,
489 text: &str,
490 workdir: &Path,
491 windows: bool,
492) -> Result<String, String> {
493 let base_str = normalize_match_str(&workdir.to_string_lossy(), windows);
494 let mut base: Vec<&str> = base_str.split('/').collect();
495 while base.len() > 1 && base.last().is_some_and(|s| s.is_empty()) {
498 base.pop();
499 }
500 let mut rest = text;
501 loop {
502 if let Some(r) = rest.strip_prefix("./") {
503 rest = r;
504 } else if let Some(r) = rest.strip_prefix("../") {
505 if base.len() <= 1 {
506 return Err(format!(
507 "read_paths entry '{raw}': relative pattern escapes the filesystem root"
508 ));
509 }
510 base.pop();
511 rest = r;
512 } else {
513 break;
514 }
515 }
516 let prefix = glob::Pattern::escape(&base.join("/"));
518 Ok(if rest.is_empty() {
519 prefix
520 } else {
521 format!("{prefix}/{rest}")
522 })
523}
524
525fn compile_exact(raw: &str, workdir: &Path, home: Option<&Path>) -> Result<ReadPathEntry, String> {
526 let path = if let Some(after_tilde) = raw.strip_prefix('~') {
527 let sub = after_tilde
528 .strip_prefix('/')
529 .or_else(|| after_tilde.strip_prefix('\\'));
530 let sub = match (sub, after_tilde.is_empty()) {
531 (_, true) => "",
532 (Some(sub), _) => sub,
533 (None, false) => {
534 return Err(format!(
535 "read_paths entry '{raw}': only '~/' home expansion is supported"
536 ));
537 }
538 };
539 let home = home.ok_or_else(|| {
540 format!("read_paths entry '{raw}': no home directory resolved for '~' expansion")
541 })?;
542 if sub.is_empty() {
543 home.to_path_buf()
544 } else {
545 home.join(sub)
546 }
547 } else if Path::new(raw).is_absolute() {
548 PathBuf::from(raw)
549 } else {
550 workdir.join(raw)
551 };
552 Ok(ReadPathEntry::Exact(path))
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 fn set(entries: &[&str], workdir: &str, home: Option<&str>, windows: bool) -> ReadPathSet {
560 let raw: Vec<String> = entries.iter().map(|s| s.to_string()).collect();
561 ReadPathSet::compile(&raw, Path::new(workdir), home.map(Path::new), windows)
562 .expect("entries compile")
563 }
564
565 fn compile_err(entry: &str, workdir: &str, home: Option<&str>) -> String {
566 ReadPathSet::compile(
567 &[entry.to_string()],
568 Path::new(workdir),
569 home.map(Path::new),
570 false,
571 )
572 .expect_err("entry must be refused")
573 }
574
575 #[test]
578 fn empty_and_whitespace_entries_are_refused() {
579 assert!(compile_err("", "/w", None).contains("empty"));
580 assert!(compile_err(" ", "/w", None).contains("empty"));
581 assert!(compile_err("glob:", "/w", None).contains("glob pattern is empty"));
582 assert!(compile_err("regex:", "/w", None).contains("regex pattern is empty"));
583 }
584
585 #[test]
586 fn invalid_patterns_are_refused() {
587 assert!(compile_err("glob:/a/[", "/w", None).contains("invalid glob"));
588 assert!(compile_err("regex:/a/(", "/w", None).contains("invalid regex"));
589 }
590
591 #[test]
594 fn a_relative_regex_is_refused() {
595 let err = compile_err("regex:etc/passwd", "/w", None);
596 assert!(err.contains("must start with"), "got: {err}");
597 assert!(err.contains("glob:"), "got: {err}");
598 }
599
600 #[test]
602 fn tilde_user_forms_are_refused() {
603 for entry in ["~other/x", "glob:~other/**", "regex:~other/.*"] {
604 let err = compile_err(entry, "/w", Some("/home/me"));
605 assert!(err.contains("only '~/'"), "{entry}: {err}");
606 }
607 }
608
609 #[test]
612 fn tilde_without_a_home_is_refused() {
613 for entry in ["~/docs", "glob:~/docs/**", "regex:~/docs/.*"] {
614 let err = compile_err(entry, "/w", None);
615 assert!(err.contains("no home directory"), "{entry}: {err}");
616 }
617 }
618
619 #[test]
622 fn interior_dot_components_in_globs_are_refused() {
623 for entry in ["glob:/a/../b/**", "glob:/a/./b", "glob:a/../../b"] {
624 let err = compile_err(entry, "/w/x", None);
625 assert!(err.contains("cannot contain"), "{entry}: {err}");
626 }
627 }
628
629 #[test]
630 fn a_relative_glob_cannot_climb_past_the_root() {
631 let err = compile_err("glob:../../../x/**", "/w", None);
632 assert!(err.contains("escapes the filesystem root"), "got: {err}");
633 }
634
635 #[test]
638 fn an_exact_root_grants_its_subtree_and_nothing_else() {
639 let dir = tempfile::tempdir().unwrap();
640 let root = std::fs::canonicalize(dir.path()).unwrap();
641 std::fs::create_dir(root.join("sub")).unwrap();
642 std::fs::write(root.join("sub/f.txt"), b"x").unwrap();
643 let outside = tempfile::tempdir().unwrap();
644 let outside_file = outside.path().join("f.txt");
645 std::fs::write(&outside_file, b"x").unwrap();
646
647 let s = set(&[root.to_str().unwrap()], "/w", None, false);
648 assert!(s.matches(&root.join("sub/f.txt")));
649 assert!(!s.matches(&std::fs::canonicalize(&outside_file).unwrap()));
650 }
651
652 #[test]
655 fn an_uncanonicalized_exact_root_still_matches() {
656 let dir = tempfile::tempdir().unwrap();
657 std::fs::write(dir.path().join("f.txt"), b"x").unwrap();
658 let s = set(&[dir.path().to_str().unwrap()], "/w", None, false);
659 assert!(s.matches(&std::fs::canonicalize(dir.path().join("f.txt")).unwrap()));
660 }
661
662 #[test]
665 fn a_nonexistent_exact_root_never_matches() {
666 let dir = tempfile::tempdir().unwrap();
667 let real = std::fs::canonicalize(dir.path()).unwrap();
668 let s = set(&["/definitely/not/a/real/root"], "/w", None, false);
669 assert!(!s.matches(&real));
670 }
671
672 #[test]
673 fn a_relative_exact_entry_resolves_against_the_workdir() {
674 let parent = tempfile::tempdir().unwrap();
675 let workdir = parent.path().join("work");
676 let sibling = parent.path().join("shared");
677 std::fs::create_dir_all(&workdir).unwrap();
678 std::fs::create_dir_all(&sibling).unwrap();
679 std::fs::write(sibling.join("doc.md"), b"x").unwrap();
680
681 let s = set(&["../shared"], workdir.to_str().unwrap(), None, false);
682 assert!(s.matches(&std::fs::canonicalize(sibling.join("doc.md")).unwrap()));
683 }
684
685 #[test]
686 fn tilde_exact_entries_expand_to_the_home_argument() {
687 let home = tempfile::tempdir().unwrap();
688 std::fs::create_dir(home.path().join("docs")).unwrap();
689 std::fs::write(home.path().join("docs/a.md"), b"x").unwrap();
690 let home_str = home.path().to_str().unwrap();
691
692 let bare = set(&["~"], "/w", Some(home_str), false);
693 let scoped = set(&["~/docs"], "/w", Some(home_str), false);
694 let canonical = std::fs::canonicalize(home.path().join("docs/a.md")).unwrap();
695 assert!(bare.matches(&canonical));
696 assert!(scoped.matches(&canonical));
697 }
698
699 #[test]
702 fn star_stays_within_one_component_and_doublestar_crosses() {
703 let s = set(&["glob:/data/runs/*"], "/w", None, false);
704 assert!(s.matches(Path::new("/data/runs/r1")));
705 assert!(!s.matches(Path::new("/data/runs/r1/log.txt")));
706
707 let deep = set(&["glob:/data/runs/**"], "/w", None, false);
708 assert!(deep.matches(Path::new("/data/runs/r1/log.txt")));
709 assert!(!deep.matches(Path::new("/data/other/x")));
710 }
711
712 #[test]
713 fn a_relative_glob_is_anchored_at_the_workdir() {
714 let s = set(&["glob:../shared/**"], "/w/agent", None, false);
715 assert!(s.matches(Path::new("/w/shared/notes/a.md")));
716 assert!(!s.matches(Path::new("/w/agent/own.md")));
717 assert!(!s.matches(Path::new("/elsewhere/shared/a.md")));
718 }
719
720 #[test]
723 fn a_relative_glob_works_from_a_root_workdir() {
724 let s = set(&["glob:docs/**"], "/", None, false);
725 assert!(s.matches(Path::new("/docs/a.md")));
726 assert!(!s.matches(Path::new("/other/a.md")));
727 }
728
729 #[test]
732 fn a_dots_only_glob_matches_the_folded_directory_itself() {
733 let s = set(&["glob:../"], "/a/b", None, false);
734 assert!(s.matches(Path::new("/a")));
735 assert!(!s.matches(Path::new("/a/b")));
736 }
737
738 #[test]
740 fn a_metachar_workdir_is_escaped_in_relative_globs() {
741 let s = set(&["glob:./docs/**"], "/we[ird]/w", None, false);
742 assert!(s.matches(Path::new("/we[ird]/w/docs/a.md")));
743 assert!(!s.matches(Path::new("/wei/w/docs/a.md")));
746 }
747
748 #[test]
750 fn a_metachar_home_is_escaped_in_tilde_globs() {
751 let s = set(&["glob:~/docs/**"], "/w", Some("/ho[me]"), false);
752 assert!(s.matches(Path::new("/ho[me]/docs/a.md")));
753 assert!(!s.matches(Path::new("/hom/docs/a.md")));
754 }
755
756 #[test]
759 fn backslash_glob_patterns_are_normalized() {
760 let s = set(&[r"glob:C:\data\runs\**"], "/w", None, true);
761 assert!(s.matches(Path::new(r"C:\data\runs\r1\log.txt")));
762 }
763
764 #[test]
765 fn glob_case_sensitivity_follows_the_platform_flag() {
766 let insensitive = set(&["glob:/Data/**"], "/w", None, true);
767 assert!(insensitive.matches(Path::new("/data/x")));
768 let sensitive = set(&["glob:/Data/**"], "/w", None, false);
769 assert!(!sensitive.matches(Path::new("/data/x")));
770 }
771
772 #[test]
777 fn regexes_are_anchored_to_the_whole_path() {
778 let s = set(&["regex:/etc/runs"], "/w", None, false);
779 assert!(s.matches(Path::new("/etc/runs")));
780 assert!(!s.matches(Path::new("/etc/runs-anything")));
781 assert!(!s.matches(Path::new("/prefix/etc/runs")));
782
783 let subtree = set(&["regex:/etc/runs/.*"], "/w", None, false);
784 assert!(subtree.matches(Path::new("/etc/runs/deep/file")));
785 }
786
787 #[test]
788 fn regex_case_sensitivity_follows_the_platform_flag() {
789 let insensitive = set(&["regex:/Data/.*"], "/w", None, true);
790 assert!(insensitive.matches(Path::new("/data/x")));
791 let sensitive = set(&["regex:/Data/.*"], "/w", None, false);
792 assert!(!sensitive.matches(Path::new("/data/x")));
793 }
794
795 #[test]
798 fn a_metachar_home_is_escaped_in_tilde_regexes() {
799 let s = set(&["regex:~/docs/.*"], "/w", Some("/ho.me"), false);
800 assert!(s.matches(Path::new("/ho.me/docs/a")));
801 assert!(!s.matches(Path::new("/hoXme/docs/a")));
802 }
803
804 #[test]
806 fn a_drive_letter_regex_is_accepted() {
807 let s = set(&["regex:C:/data/.*"], "/w", None, true);
808 assert!(s.matches(Path::new(r"C:\data\x")));
809 }
810
811 #[test]
814 fn unix_strings_pass_through_untouched() {
815 assert_eq!(
816 normalize_match_str(r"/a/weird\name", false),
817 r"/a/weird\name"
818 );
819 }
820
821 #[test]
822 fn windows_verbatim_prefixes_are_stripped_for_matching() {
823 assert_eq!(normalize_match_str(r"\\?\C:\Users\x", true), "C:/Users/x");
824 assert_eq!(
825 normalize_match_str(r"\\?\UNC\srv\share\x", true),
826 "//srv/share/x"
827 );
828 assert_eq!(
831 normalize_match_str(r"\\?\Volume{abc}\x", true),
832 "//?/Volume{abc}/x"
833 );
834 assert_eq!(normalize_match_str(r"C:\plain\x", true), "C:/plain/x");
835 }
836
837 fn policy(blueprint: &[&str], grants: &[&str], allow_blueprint: bool) -> ReadPathPolicy {
840 ReadPathPolicy {
841 agent: "tester".into(),
842 blueprint: set(blueprint, "/w", None, false),
843 grants: set(grants, "/w", None, false),
844 allow_blueprint,
845 }
846 }
847
848 #[test]
849 fn an_inactive_policy_declares_nothing() {
850 let p = ReadPathPolicy::inactive();
851 assert!(!p.is_active());
852 assert_eq!(
853 p.decide(Path::new("/anything")),
854 ReadPathDecision::NotDeclared
855 );
856 }
857
858 #[test]
859 fn a_path_the_blueprint_never_declared_is_not_declared() {
860 let p = policy(&["glob:/data/**"], &["glob:/data/**"], false);
861 assert!(p.is_active());
862 assert_eq!(
863 p.decide(Path::new("/etc/passwd")),
864 ReadPathDecision::NotDeclared
865 );
866 }
867
868 #[test]
871 fn a_declared_but_ungranted_path_is_not_granted() {
872 let p = policy(&["glob:/data/**"], &[], false);
873 assert_eq!(p.decide(Path::new("/data/x")), ReadPathDecision::NotGranted);
874 }
875
876 #[test]
877 fn a_granted_path_is_allowed() {
878 let p = policy(&["glob:/data/**"], &["glob:/data/**"], false);
879 assert_eq!(p.decide(Path::new("/data/x")), ReadPathDecision::Allowed);
880 }
881
882 #[test]
885 fn a_broader_grant_covers_a_narrow_declaration() {
886 let p = policy(&["glob:/data/runs/**"], &["glob:/data/**"], false);
887 assert_eq!(
888 p.decide(Path::new("/data/runs/r1")),
889 ReadPathDecision::Allowed
890 );
891 }
892
893 #[test]
896 fn a_nonoverlapping_grant_does_not_help() {
897 let p = policy(&["glob:/data/**"], &["glob:/other/**"], false);
898 assert_eq!(p.decide(Path::new("/data/x")), ReadPathDecision::NotGranted);
899 }
900
901 #[test]
902 fn the_blanket_override_honors_declarations_without_grants() {
903 let p = policy(&["glob:/data/**"], &[], true);
904 assert_eq!(p.decide(Path::new("/data/x")), ReadPathDecision::Allowed);
905 assert_eq!(p.decide(Path::new("/etc/x")), ReadPathDecision::NotDeclared);
907 }
908
909 #[test]
910 fn an_empty_set_matches_nothing() {
911 let s = set(&[], "/w", None, false);
912 assert!(s.is_empty());
913 assert!(s.entries().is_empty());
914 assert!(!s.matches(Path::new("/anything")));
915 }
916
917 #[test]
920 fn syntax_validation_accepts_well_formed_entries() {
921 for entry in [
922 "/abs/dir",
923 "relative/dir",
924 "~/docs",
925 "glob:~/runs/**",
926 "glob:../shared/**",
927 "regex:/data/.*",
928 r"C:\Users\me\docs",
929 ] {
930 assert!(validate_entry_syntax(entry).is_ok(), "{entry}");
931 }
932 }
933
934 #[test]
935 fn syntax_validation_refuses_malformed_entries() {
936 for entry in ["", "glob:[", "regex:(", "regex:relative/.*", "~oops"] {
937 assert!(validate_entry_syntax(entry).is_err(), "{entry}");
938 }
939 }
940
941 fn sample_path_of(entry: &str, workdir: &str, home: Option<&str>) -> Option<PathBuf> {
945 set(&[entry], workdir, home, false).entries()[0].sample_path()
946 }
947
948 fn sample(entry: &str, workdir: &str, home: Option<&str>) -> Option<String> {
951 sample_path_of(entry, workdir, home).map(|p| p.to_string_lossy().into_owned())
952 }
953
954 #[test]
958 fn an_exact_entry_samples_as_its_own_root() {
959 assert_eq!(
960 sample("/data/runs", "/w", None).as_deref(),
961 Some("/data/runs")
962 );
963 assert_eq!(
964 sample_path_of("~/docs", "/w", Some("/home/me")),
965 Some(Path::new("/home/me").join("docs"))
966 );
967 assert_eq!(
969 sample_path_of("../shared", "/w/run", None),
970 Some(Path::new("/w/run").join("../shared"))
971 );
972 }
973
974 #[test]
975 fn glob_wildcards_are_filled_with_a_literal_component() {
976 assert_eq!(
977 sample("glob:/data/**", "/w", None).as_deref(),
978 Some("/data/_leviath_probe")
979 );
980 assert_eq!(
981 sample("glob:/data/*/notes", "/w", None).as_deref(),
982 Some("/data/_leviath_probe/notes")
983 );
984 assert_eq!(
985 sample("glob:/data/log?", "/w", None).as_deref(),
986 Some("/data/logx")
987 );
988 assert_eq!(
990 sample("glob:/data/notes", "/w", None).as_deref(),
991 Some("/data/notes")
992 );
993 }
994
995 #[test]
998 fn a_glob_character_class_has_no_sample() {
999 assert_eq!(sample("glob:/data/[abc]/x", "/w", None), None);
1000 }
1001
1002 #[test]
1005 fn a_sample_that_fails_its_own_pattern_is_refused() {
1006 let entry = ReadPathEntry::Glob {
1007 pattern: glob::Pattern::new("/data/*").expect("pattern compiles"),
1008 options: glob::MatchOptions {
1009 case_sensitive: true,
1010 require_literal_separator: true,
1011 require_literal_leading_dot: false,
1012 },
1013 };
1014 let literal_star = ReadPathEntry::Glob {
1017 pattern: glob::Pattern::new("/data/[*]").expect("pattern compiles"),
1018 options: glob::MatchOptions {
1019 case_sensitive: true,
1020 require_literal_separator: true,
1021 require_literal_leading_dot: false,
1022 },
1023 };
1024 assert!(entry.sample_path().is_some());
1025 assert_eq!(literal_star.sample_path(), None);
1026 }
1027
1028 #[test]
1029 fn a_regex_samples_a_file_inside_its_literal_prefix() {
1030 assert_eq!(
1031 sample("regex:/data/archives/.*", "/w", None).as_deref(),
1032 Some("/data/archives/_leviath_probe")
1033 );
1034 assert_eq!(
1036 sample("regex:/data/archives", "/w", None).as_deref(),
1037 Some("/data/archives")
1038 );
1039 assert_eq!(
1040 sample("regex:~/runs/.*", "/w", Some("/home/me")).as_deref(),
1041 Some("/home/me/runs/_leviath_probe")
1042 );
1043 }
1044
1045 #[test]
1048 fn a_regex_with_no_usable_literal_prefix_has_no_sample() {
1049 let entry =
1050 ReadPathEntry::Regex(regex::Regex::new("^(?:[/a-z]+)$").expect("regex compiles"));
1051 assert_eq!(entry.sample_path(), None);
1052 }
1053
1054 #[test]
1058 fn an_unanchored_regex_is_read_as_written() {
1059 let entry = ReadPathEntry::Regex(regex::Regex::new("/data/x.*").expect("regex compiles"));
1060 assert_eq!(entry.sample_path(), Some(PathBuf::from("/data/x")));
1061 }
1062
1063 #[test]
1066 fn lexical_matching_covers_a_root_and_its_subtree() {
1067 let s = set(&["/data/runs"], "/w", None, false);
1068 assert!(s.matches_lexically(Path::new("/data/runs")));
1069 assert!(s.matches_lexically(Path::new("/data/runs/june/1")));
1070 assert!(!s.matches_lexically(Path::new("/data/runs-old/1")));
1071 assert!(!s.matches_lexically(Path::new("/data")));
1072 }
1073
1074 #[test]
1078 fn lexical_matching_does_not_need_the_root_to_exist() {
1079 let s = set(&["/definitely/not/here"], "/w", None, false);
1080 assert!(s.matches_lexically(Path::new("/definitely/not/here/x")));
1081 assert!(!s.matches(Path::new("/definitely/not/here/x")));
1082 }
1083
1084 #[test]
1087 fn lexical_matching_folds_case_under_windows_semantics() {
1088 let windows = set(&["/Users/Me/docs"], "/w", None, true);
1089 assert!(windows.matches_lexically(Path::new("/users/me/docs/notes.md")));
1090 let unix = set(&["/Users/Me/docs"], "/w", None, false);
1091 assert!(!unix.matches_lexically(Path::new("/users/me/docs/notes.md")));
1092 }
1093
1094 #[test]
1096 fn lexical_matching_handles_a_root_entry() {
1097 let s = set(&["/"], "/w", None, false);
1098 assert!(s.matches_lexically(Path::new("/etc/passwd")));
1099 assert!(s.matches_lexically(Path::new("/")));
1100 }
1101
1102 #[test]
1103 fn lexical_matching_uses_the_pattern_entries_unchanged() {
1104 let s = set(&["glob:/data/**", "regex:/logs/.*"], "/w", None, false);
1105 assert!(s.matches_lexically(Path::new("/data/x/y")));
1106 assert!(s.matches_lexically(Path::new("/logs/today")));
1107 assert!(!s.matches_lexically(Path::new("/elsewhere/x")));
1108 }
1109}