Skip to main content

leviath_core/
read_paths.rs

1//! The `[read_paths]` allowlist: how an agent is granted read access outside
2//! its workdir, and how each access is checked.
3//!
4//! A blueprint's `[read_paths] allow` array *declares* what the agent wants to
5//! read. Declaring is not granting: the user's config must either name the
6//! same paths (`[security] read_paths` / `[agent_read_paths.<name>]`) or set
7//! `allow_blueprint_read_paths = true`. That keeps the manifest tighten-only -
8//! an `agent.leviath` someone downloaded cannot ship one TOML line that reads
9//! `~/.ssh`. [`ReadPathPolicy::decide`] is that double check, applied per path
10//! at resolve time.
11//!
12//! Three entry forms:
13//! - an exact path: grants the whole subtree under it, checked with the same
14//!   canonicalize-then-prefix containment as the workdir sandbox
15//! - `glob:` - a glob pattern, `*` stays inside one path component, `**`
16//!   crosses them
17//! - `regex:` - a regex, auto-anchored as `^(?:pattern)$` so `regex:runs`
18//!   cannot quietly match `/etc/runs-anything`
19//!
20//! Patterns match the **symlink-resolved real path** of the file, never the
21//! path the agent asked for. That is what makes them safe: a symlink planted
22//! inside an allowlisted directory resolves to its real target, and the real
23//! target must itself match an entry. The matched string uses `/` separators
24//! on every OS and, on Windows, has the `\\?\` verbatim prefix stripped and is
25//! compared case-insensitively (see [`normalize_match_str`]).
26//!
27//! Portability: `~/` expands to the home directory (honoring `LEVIATH_HOME`),
28//! and a bare relative entry resolves against the run's workdir. A relative
29//! `regex:` is refused - there is no way to splice a workdir into a regex
30//! safely, and `glob:` covers that case.
31
32use std::path::{Path, PathBuf};
33
34/// One compiled allowlist entry. Only [`ReadPathSet`] constructs these; the
35/// enum is public so a set's contents are inspectable, not so callers build
36/// entries by hand (compilation is where `~`/relative resolution and
37/// anchoring happen).
38#[derive(Debug, Clone)]
39pub enum ReadPathEntry {
40    /// An exact root: grants the subtree under it. Stored as resolved at
41    /// compile time (tilde/workdir applied) but *uncanonicalized* - the root
42    /// is canonicalized at match time so a root created after spawn still
43    /// works, and a root that cannot be verified never matches.
44    Exact(PathBuf),
45    /// A glob over the normalized real path.
46    Glob {
47        /// The compiled pattern, already `/`-separated and prefixed with the
48        /// escaped home or workdir when the source entry was `~/` or relative.
49        pattern: glob::Pattern,
50        /// Match options: `require_literal_separator` always, case sensitivity
51        /// per platform semantics.
52        options: glob::MatchOptions,
53    },
54    /// A regex over the normalized real path, anchored at compile time.
55    Regex(regex::Regex),
56}
57
58impl ReadPathEntry {
59    /// Whether the already-canonicalized `canonical` path (and its
60    /// pre-normalized string form) lands inside this entry.
61    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                // The root itself cannot be verified (it does not exist, or a
66                // parent is unreadable). `canonical` exists - it was
67                // canonicalized by the caller - so it cannot really live under
68                // an unverifiable root. Refuse.
69                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    /// One concrete path this entry matches, or `None` when none can be
77    /// synthesized from the pattern alone.
78    ///
79    /// This exists for *reporting*, not for enforcement: to say whether a
80    /// config grant covers what a blueprint declares, something has to stand in
81    /// for "a path the declaration would let through", and the honest stand-in
82    /// is a path built from the declaration itself. Every synthesized sample is
83    /// checked back against its own entry, so a sample that cannot be trusted
84    /// comes back as `None` and the caller reports "cannot tell" rather than
85    /// guessing.
86    pub fn sample_path(&self) -> Option<PathBuf> {
87        match self {
88            // The root itself is inside the subtree it grants.
89            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                // A file inside the literal directory prefix first: it is the
99                // shape a real read takes, and it is what a `**` grant covers.
100                // The bare literal second, for a regex that is all literal.
101                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
114/// The component substituted for a wildcard when synthesizing a sample path.
115/// Deliberately unlikely to appear in a real pattern as a literal.
116const SAMPLE_COMPONENT: &str = "_leviath_probe";
117
118/// Replace a glob's wildcards with a literal component so the pattern becomes
119/// a concrete path. `None` for a character class, whose expansion would have to
120/// be guessed at (`[` also carries glob's escape syntax).
121fn 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            // A run of `*` or `**` collapses to one substitution.
128            '*' => {
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
143/// Undo the `^(?:...)$` anchoring [`compile_regex`] applies, so the pattern
144/// text can be read for its literal prefix. Text that is not anchored that way
145/// is returned as-is.
146fn strip_regex_anchors(pattern: &str) -> &str {
147    pattern
148        .strip_prefix("^(?:")
149        .and_then(|rest| rest.strip_suffix(")$"))
150        .unwrap_or(pattern)
151}
152
153/// The leading run of characters a regex matches literally, stopping at the
154/// first metacharacter (an escape included: what follows it is literal, but the
155/// prefix is already long enough to be useful).
156fn literal_prefix(pattern: &str) -> String {
157    pattern
158        .chars()
159        .take_while(|c| {
160            !matches!(
161                c,
162                '.' | '[' | ']' | '(' | ')' | '{' | '}' | '*' | '+' | '?' | '|' | '^' | '$' | '\\'
163            )
164        })
165        .collect()
166}
167
168/// Normalize a canonicalized path string for glob/regex matching.
169///
170/// With `windows` set (production: `cfg!(windows)`, injected so both branches
171/// are testable everywhere):
172/// - `\\?\UNC\server\share\..` becomes `\\server\share\..`
173/// - `\\?\C:\..` (a drive-letter verbatim path, which is what
174///   `fs::canonicalize` returns on Windows) loses the `\\?\` prefix
175/// - any other `\\?\` form (`\\?\Volume{..}`) is left alone; such a path
176///   simply never matches a drive-letter pattern, which fails closed
177/// - every `\` becomes `/`, so patterns are written with `/` on every OS
178///
179/// Without it the string is returned unchanged - a Unix filename may legally
180/// contain `\`.
181pub 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/// A compiled set of allowlist entries, bound to the run they were compiled
201/// for (tilde and relative entries were resolved at compile time).
202#[derive(Debug, Clone, Default)]
203pub struct ReadPathSet {
204    entries: Vec<ReadPathEntry>,
205    /// Windows path semantics for matching (separator normalization and case
206    /// folding). Injected rather than read from `cfg!` inside so every branch
207    /// runs under test on every OS.
208    windows: bool,
209}
210
211impl ReadPathSet {
212    /// Compile raw `[read_paths] allow` strings against a run's workdir and
213    /// home. `windows` selects Windows path semantics: `/`-normalization of
214    /// the matched string and case-insensitive glob/regex matching
215    /// (production passes `cfg!(windows)`).
216    ///
217    /// Any invalid entry is a hard error naming the entry - a skipped entry
218    /// would degrade the agent silently mid-run, and refusing loudly at
219    /// compile (spawn) time is the same posture the sandbox config takes.
220    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    /// Whether the set has no entries at all.
234    pub fn is_empty(&self) -> bool {
235        self.entries.is_empty()
236    }
237
238    /// The compiled entries, for callers that report or display them.
239    pub fn entries(&self) -> &[ReadPathEntry] {
240        &self.entries
241    }
242
243    /// Whether the already-canonicalized `canonical` path matches any entry.
244    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    /// Whether `path` matches any entry, comparing text instead of the
252    /// filesystem: an `Exact` entry is a component-wise prefix test on the path
253    /// as written, with no canonicalization.
254    ///
255    /// [`matches`](Self::matches) is the enforcement path and must resolve
256    /// symlinks; this is the reporting path, which must not. A grant naming a
257    /// directory that does not exist yet, or one behind macOS's `/tmp` ->
258    /// `/private/tmp` link, is still a grant the user wrote, and telling them it
259    /// is missing would be wrong. The trade is the other way too: a report is a
260    /// pattern-level answer, so a run can still be refused at a path this says
261    /// is covered.
262    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            // Glob and regex entries already match on the normalized string
270            // alone; the path argument goes unread.
271            other => other.matches(path, &normalized),
272        })
273    }
274}
275
276/// Whether `path` is `root` or sits under it, on component boundaries so
277/// `/a/bc` is not read as living under `/a/b`. Case-folded under Windows
278/// semantics, matching how glob and regex entries compare there.
279fn 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    // A root of `/` (or `C:/`) trims to `""`/`C:`, and every path under it
291    // starts with the separator the trim removed.
292    path == trimmed || path.starts_with(&format!("{trimmed}/"))
293}
294
295/// The outcome of checking one path against a [`ReadPathPolicy`].
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum ReadPathDecision {
298    /// Declared by the blueprint and granted by the user (or the user opted
299    /// into honoring blueprints wholesale).
300    Allowed,
301    /// The blueprint never asked for this path; the ordinary workdir refusal
302    /// stands.
303    NotDeclared,
304    /// The blueprint asked, but nothing in the user's config grants it.
305    NotGranted,
306}
307
308/// Everything read-path enforcement needs, resolved once at spawn.
309///
310/// `blueprint` is what the manifest declares; `grants` is what the user's
311/// config allows (`[security] read_paths` plus `[agent_read_paths.<name>]`);
312/// `allow_blueprint` is the `[security] allow_blueprint_read_paths` override
313/// that honors declarations without itemized grants.
314#[derive(Debug, Clone, Default)]
315pub struct ReadPathPolicy {
316    /// The agent's name, for error and warning text.
317    pub agent: String,
318    /// Entries the blueprint declares.
319    pub blueprint: ReadPathSet,
320    /// Entries the user's config grants.
321    pub grants: ReadPathSet,
322    /// Whether declarations are honored without itemized grants.
323    pub allow_blueprint: bool,
324}
325
326impl ReadPathPolicy {
327    /// A policy that allows nothing beyond the workdir - the default for
328    /// every agent whose blueprint has no `[read_paths]`.
329    pub fn inactive() -> Self {
330        Self::default()
331    }
332
333    /// Whether the blueprint declares any read paths at all. When false, the
334    /// resolver never consults this policy and the workdir sandbox behaves
335    /// exactly as it always has.
336    pub fn is_active(&self) -> bool {
337        !self.blueprint.is_empty()
338    }
339
340    /// The double check: the blueprint must declare the path AND the user
341    /// must grant it (itemized, or via the blanket override).
342    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
354/// Validate one entry's syntax without binding it to a run: bad glob/regex,
355/// a relative `regex:`, an empty entry. Called from manifest parsing so a
356/// broken entry fails `lev validate`/`lev add`/spawn loudly instead of
357/// degrading the agent at its first out-of-workdir read.
358///
359/// Environment problems (`~` with no resolvable home) are not syntax and are
360/// only caught when the real compile runs at spawn.
361pub fn validate_entry_syntax(raw: &str) -> Result<(), String> {
362    // The dummy workdir is deep enough that a reasonable `../` prefix in a
363    // relative glob validates; how far up a real run can climb is bound to the
364    // real workdir at spawn.
365    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
369/// Compile one raw entry. `windows` is the same injected platform-semantics
370/// flag as [`ReadPathSet::compile`].
371fn 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
389/// Whether pattern text starts like an absolute path: `/..`, `//server/..`,
390/// or a drive letter `C:/..`. Deliberately literal - a pattern opening with a
391/// character class (`[A-Z]:/..`) is refused rather than guessed at.
392fn 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    // Glob has no backslash-escape syntax, so this is lossless and makes
444    // Windows-style patterns portable.
445    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        // The home directory is data, not pattern: escape any glob
456        // metacharacters it happens to contain.
457        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    // A `.` or `..` component anywhere in the final pattern can never match a
465    // canonicalized path, so it is always a mistake - refuse it rather than
466    // let the entry silently match nothing.
467    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        // `*` must not cross a `/`; `**` is the explicit way to.
478        require_literal_separator: true,
479        require_literal_leading_dot: false,
480    };
481    Ok(ReadPathEntry::Glob { pattern, options })
482}
483
484/// Anchor a relative glob at the workdir, folding any *leading* `./` and
485/// `../` components into the workdir prefix so `glob:../shared/**` means the
486/// workdir's sibling.
487fn 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    // "/" splits to ["", ""]; keep the leading "" (it restores the root `/`
496    // on rejoin) and drop trailing empties.
497    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    // The workdir is data, not pattern.
517    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    // -- compile errors ----------------------------------------------------
576
577    #[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    /// There is no safe way to splice a workdir into a regex, so a relative
592    /// regex is a hard error pointing at glob.
593    #[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    /// `~user` expansion is not supported in any entry kind - only `~/`.
601    #[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    /// `~` without a resolvable home is an environment error at compile time,
610    /// for every entry kind.
611    #[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    /// A dot component in the middle of a glob can never match a canonical
620    /// path, so it is refused instead of silently matching nothing.
621    #[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    // -- exact entries -----------------------------------------------------
636
637    #[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    /// The entry itself may be uncanonicalized (macOS `/tmp` vs
653    /// `/private/tmp`); the root is canonicalized at match time.
654    #[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    /// A root that cannot be verified never matches - the canonicalized
663    /// candidate exists, so it cannot really live under a nonexistent root.
664    #[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    // -- glob entries (matching is pure string work, no filesystem) --------
700
701    #[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    /// A relative glob anchored at the filesystem root itself: the root's
721    /// trailing-empty split segment must not double the separator.
722    #[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    /// A pattern that is nothing but dot components (`glob:../`) reduces to
730    /// the folded prefix alone and matches exactly that directory.
731    #[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    /// The workdir is data: glob metacharacters in it must match literally.
739    #[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        // If the workdir were spliced in unescaped, `[ird]` would be a class
744        // and this single-character variant would match.
745        assert!(!s.matches(Path::new("/wei/w/docs/a.md")));
746    }
747
748    /// The home directory is data too.
749    #[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    /// Windows-style pattern text is normalized to `/` so blueprints written
757    /// with backslashes keep working.
758    #[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    // -- regex entries -----------------------------------------------------
773
774    /// The anchor is the point: an unanchored `regex:/etc/runs` must not
775    /// match `/etc/runs-anything` or `/prefix/etc/runs`.
776    #[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    /// The home is spliced in escaped, so a metacharacter in the home path
796    /// matches itself and nothing else.
797    #[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    /// A drive-letter regex is accepted as absolute-shaped.
805    #[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    // -- normalize_match_str ----------------------------------------------
812
813    #[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        // Unrecognized verbatim forms are left alone (they fail to match
829        // drive-letter patterns, which is the safe direction).
830        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    // -- policy ------------------------------------------------------------
838
839    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    /// Declared but ungranted: the blueprint alone grants nothing. This is
869    /// the tighten-only invariant.
870    #[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    /// The grant need not be textually identical - it is a second predicate,
883    /// so a broad user grant covers a narrow blueprint declaration.
884    #[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    /// A grant that does not cover the declared path does nothing: both
894    /// predicates must hold for the same path.
895    #[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        // The override does not widen beyond what is declared.
906        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    // -- validate_entry_syntax --------------------------------------------
918
919    #[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    // -- sample paths ------------------------------------------------------
942
943    /// The one sample an entry stands for, or `None` when none could be built.
944    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    /// The same, as a string, for the pattern entries whose samples are built
949    /// from `/`-separated text on every platform.
950    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    /// An exact entry compiles to a `PathBuf`, so a tilde or relative entry is
955    /// joined with the platform separator. Compared as paths rather than as
956    /// strings for that reason: `/home/me\docs` on Windows is the same answer.
957    #[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        // A relative entry samples as the workdir-resolved path it compiled to.
968        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        // A pattern with no wildcards at all samples as itself.
989        assert_eq!(
990            sample("glob:/data/notes", "/w", None).as_deref(),
991            Some("/data/notes")
992        );
993    }
994
995    /// A character class carries glob's escape syntax too, so its expansion
996    /// would have to be guessed at. Refuse rather than report a wrong answer.
997    #[test]
998    fn a_glob_character_class_has_no_sample() {
999        assert_eq!(sample("glob:/data/[abc]/x", "/w", None), None);
1000    }
1001
1002    /// `*` cannot cross a separator, so a sample that put one there would not
1003    /// match the pattern it came from. The self-check catches it.
1004    #[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        // Force the mismatch: a pattern whose only wildcard is escaped as a
1015        // literal `*` can never match the substituted component.
1016        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        // All-literal: the pattern itself is the only path it matches.
1035        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    /// Nothing literal to build on, and nothing that self-checks: report
1046    /// "cannot tell" instead of a sample the entry does not match.
1047    #[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    /// Anchoring is added at compile time; a regex that arrives without it is
1055    /// read as its own body rather than as a leading `^`, which would leave no
1056    /// literal prefix to build on.
1057    #[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    // -- lexical matching --------------------------------------------------
1064
1065    #[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    /// The whole point of the lexical variant: a grant naming a directory that
1075    /// does not exist yet is still a grant. `matches` refuses it (it cannot
1076    /// canonicalize the root), `matches_lexically` does not.
1077    #[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    /// The entry is written `/`-first so it compiles as an absolute root on
1085    /// every host; only the case folding is under test.
1086    #[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    /// A filesystem root trims to nothing; everything under it still matches.
1095    #[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}