Skip to main content

heimdall_sandbox_policy/
lib.rs

1//! Shared sandbox policy types and filesystem policy materialization.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Component, Path, PathBuf};
5
6use ignore::WalkBuilder;
7use ignore::gitignore::{Gitignore, GitignoreBuilder};
8use thiserror::Error as ThisError;
9
10/// Cwd-local deny fragment filename.
11pub const DENY_FRAGMENT: &str = ".heimdall-deny";
12/// Cwd-local writable fragment filename.
13pub const WRITE_FRAGMENT: &str = ".heimdall-write";
14
15/// Result type for sandbox policy operations.
16pub type Result<T> = std::result::Result<T, Error>;
17
18/// Errors returned by shared sandbox policy operations.
19#[derive(Debug, ThisError)]
20pub enum Error {
21    /// Filesystem pattern syntax is invalid.
22    #[error("invalid filesystem pattern {pattern:?}: {source}")]
23    InvalidPattern {
24        /// Invalid pattern line.
25        pattern: String,
26        /// Underlying gitignore parser error.
27        #[source]
28        source: ignore::Error,
29    },
30    /// Filesystem fragment existence could not be checked.
31    #[error("failed to inspect filesystem fragment {}: {source}", path.display())]
32    FragmentStatus {
33        /// Fragment path.
34        path: PathBuf,
35        /// Underlying I/O error.
36        #[source]
37        source: std::io::Error,
38    },
39    /// Filesystem fragment could not be read or parsed.
40    #[error("invalid filesystem fragment {}: {source}", path.display())]
41    InvalidFragment {
42        /// Fragment path.
43        path: PathBuf,
44        /// Underlying gitignore parser or I/O error.
45        #[source]
46        source: ignore::Error,
47    },
48    /// Filesystem matcher could not be built.
49    #[error("invalid filesystem matcher for {fragment}: {source}")]
50    InvalidMatcher {
51        /// Fragment kind used for the matcher.
52        fragment: String,
53        /// Underlying gitignore matcher error.
54        #[source]
55        source: ignore::Error,
56    },
57    /// Policy cwd walk failed.
58    #[error("failed to walk {}: {source}", cwd.display())]
59    Walk {
60        /// Policy cwd.
61        cwd: PathBuf,
62        /// Underlying walk error.
63        #[source]
64        source: ignore::Error,
65    },
66    /// Policy path could not be relativized against cwd.
67    #[error("failed to relativize {} against {}: {source}", path.display(), cwd.display())]
68    Relativize {
69        /// Path being matched.
70        path: PathBuf,
71        /// Policy cwd.
72        cwd: PathBuf,
73        /// Underlying strip-prefix error.
74        #[source]
75        source: std::path::StripPrefixError,
76    },
77    /// Virtual file target is not absolute.
78    #[error("filesystem.virtual target {} must be absolute", path.display())]
79    RelativeVirtualTarget {
80        /// Invalid virtual target.
81        path: PathBuf,
82    },
83    /// Cwd directory could not be read while discovering protected paths.
84    #[error("failed to read {}: {source}", cwd.display())]
85    ReadDir {
86        /// Policy cwd.
87        cwd: PathBuf,
88        /// Underlying I/O error.
89        #[source]
90        source: std::io::Error,
91    },
92    /// Cwd directory entry could not be read while discovering protected paths.
93    #[error("failed to read entry in {}: {source}", cwd.display())]
94    ReadEntry {
95        /// Policy cwd.
96        cwd: PathBuf,
97        /// Underlying I/O error.
98        #[source]
99        source: std::io::Error,
100    },
101    /// Concrete path existence could not be determined safely.
102    #[error("failed to determine whether {} exists: {source}", path.display())]
103    IndeterminatePath {
104        /// Path being classified.
105        path: PathBuf,
106        /// Underlying I/O error.
107        #[source]
108        source: std::io::Error,
109    },
110}
111
112/// Child network isolation policy.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum NetworkMode {
115    /// Preserve host networking.
116    Host,
117    /// Isolate host networking.
118    None,
119}
120
121impl std::fmt::Display for NetworkMode {
122    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        match self {
124            Self::Host => formatter.write_str("host"),
125            Self::None => formatter.write_str("none"),
126        }
127    }
128}
129
130/// Child proc filesystem mount policy.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum ProcMode {
133    /// Mount `/proc` when host preflight allows it.
134    Default,
135    /// Do not mount `/proc` inside the sandbox.
136    Disabled,
137}
138
139impl std::fmt::Display for ProcMode {
140    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            Self::Default => formatter.write_str("default"),
143            Self::Disabled => formatter.write_str("disabled"),
144        }
145    }
146}
147
148/// Host agent socket mount policy.
149#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
150pub struct AgentPolicy {
151    ssh_agent: bool,
152    gpg_agent: bool,
153    age_agent: bool,
154}
155
156impl AgentPolicy {
157    /// Create an agent policy from boolean feature toggles.
158    #[must_use]
159    pub const fn new(ssh_agent: bool, gpg_agent: bool, age_agent: bool) -> Self {
160        Self {
161            ssh_agent,
162            gpg_agent,
163            age_agent,
164        }
165    }
166
167    /// Return true when no host agent sockets are enabled.
168    #[must_use]
169    pub const fn is_empty(&self) -> bool {
170        !self.ssh_agent && !self.gpg_agent && !self.age_agent
171    }
172
173    /// Whether `SSH_AUTH_SOCK` should be mounted.
174    #[must_use]
175    pub const fn ssh_agent(&self) -> bool {
176        self.ssh_agent
177    }
178
179    /// Whether GnuPG agent, keyboxd, and dirmngr sockets should be mounted.
180    #[must_use]
181    pub const fn gpg_agent(&self) -> bool {
182        self.gpg_agent
183    }
184
185    /// Whether age-compatible agent sockets should be mounted.
186    #[must_use]
187    pub const fn age_agent(&self) -> bool {
188        self.age_agent
189    }
190}
191
192/// Filesystem sandbox policy expressed as cwd-relative gitignore-style pattern lists.
193#[derive(Debug, Clone, Default, PartialEq, Eq)]
194pub struct FilesystemPolicy {
195    deny: Vec<String>,
196    writable: Vec<String>,
197    virtual_files: BTreeMap<PathBuf, String>,
198}
199
200impl FilesystemPolicy {
201    /// Create a filesystem policy from deny patterns, writable patterns, and virtual files.
202    #[must_use]
203    pub fn new(
204        deny: Vec<String>,
205        writable: Vec<String>,
206        virtual_files: BTreeMap<PathBuf, String>,
207    ) -> Self {
208        Self {
209            deny,
210            writable,
211            virtual_files,
212        }
213    }
214
215    /// Return true when no filesystem controls are configured.
216    #[must_use]
217    pub fn is_empty(&self) -> bool {
218        self.deny.is_empty() && self.writable.is_empty() && self.virtual_files.is_empty()
219    }
220
221    /// Deny matcher pattern lines.
222    #[must_use]
223    pub fn deny(&self) -> &[String] {
224        &self.deny
225    }
226
227    /// Writable matcher pattern lines.
228    #[must_use]
229    pub fn writable(&self) -> &[String] {
230        &self.writable
231    }
232
233    /// Readonly virtual file contents keyed by absolute sandbox path.
234    #[must_use]
235    pub fn virtual_files(&self) -> &BTreeMap<PathBuf, String> {
236        &self.virtual_files
237    }
238}
239
240/// Concrete filesystem decisions materialized from cwd-relative policy patterns.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct MaterializedFilesystemPolicy {
243    deny_targets: BTreeSet<PathBuf>,
244    writable_targets: BTreeSet<PathBuf>,
245    protected_targets: BTreeSet<PathBuf>,
246    readable_targets: BTreeSet<PathBuf>,
247    missing_deny_guards: BTreeSet<PathBuf>,
248}
249
250impl MaterializedFilesystemPolicy {
251    /// Create a materialized policy from the given target sets.
252    ///
253    /// Backend planners are responsible for ordering targets so the most specific path rule wins.
254    #[must_use]
255    pub fn new(
256        deny_targets: BTreeSet<PathBuf>,
257        writable_targets: BTreeSet<PathBuf>,
258        protected_targets: BTreeSet<PathBuf>,
259    ) -> Self {
260        Self {
261            deny_targets,
262            writable_targets,
263            protected_targets,
264            readable_targets: BTreeSet::new(),
265            missing_deny_guards: BTreeSet::new(),
266        }
267    }
268
269    /// Create an empty materialized policy with no targets.
270    #[must_use]
271    pub fn empty() -> Self {
272        Self {
273            deny_targets: BTreeSet::new(),
274            writable_targets: BTreeSet::new(),
275            protected_targets: BTreeSet::new(),
276            readable_targets: BTreeSet::new(),
277            missing_deny_guards: BTreeSet::new(),
278        }
279    }
280
281    /// Existing paths selected by deny policy.
282    #[must_use]
283    pub fn deny_targets(&self) -> &BTreeSet<PathBuf> {
284        &self.deny_targets
285    }
286
287    /// Existing paths selected by writable policy after deny precedence.
288    #[must_use]
289    pub fn writable_targets(&self) -> &BTreeSet<PathBuf> {
290        &self.writable_targets
291    }
292
293    /// Protected control paths that must not become writable.
294    #[must_use]
295    pub fn protected_targets(&self) -> &BTreeSet<PathBuf> {
296        &self.protected_targets
297    }
298
299    /// Existing paths explicitly restored by deny-policy negation rules.
300    #[must_use]
301    pub fn readable_targets(&self) -> &BTreeSet<PathBuf> {
302        &self.readable_targets
303    }
304
305    /// Confirmed-missing denied paths that remain creatable through writable directory targets.
306    #[must_use]
307    pub fn missing_deny_guards(&self) -> &BTreeSet<PathBuf> {
308        &self.missing_deny_guards
309    }
310
311    /// Decompose into owned target sets.
312    #[must_use]
313    pub fn into_parts(self) -> (BTreeSet<PathBuf>, BTreeSet<PathBuf>, BTreeSet<PathBuf>) {
314        (
315            self.deny_targets,
316            self.writable_targets,
317            self.protected_targets,
318        )
319    }
320}
321
322/// Existence state for a concrete host path after literal expansion.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum ConcretePathState {
325    /// The final directory entry exists, including dangling final symlinks.
326    Existing,
327    /// The final entry or an ancestor is confirmed absent.
328    Missing,
329}
330
331impl ConcretePathState {
332    const fn is_existing(self) -> bool {
333        matches!(self, Self::Existing)
334    }
335
336    const fn is_missing(self) -> bool {
337        matches!(self, Self::Missing)
338    }
339}
340
341/// Classify a concrete absolute host path without following the final component.
342///
343/// # Errors
344///
345/// Returns an indeterminate-path error for permission, traversal, or other non-not-found failures.
346pub fn concrete_path_state(path: &Path) -> Result<ConcretePathState> {
347    let mut current = PathBuf::new();
348    let components = path.components().collect::<Vec<_>>();
349    for (index, component) in components.iter().enumerate() {
350        current.push(component.as_os_str());
351        if matches!(component, Component::Prefix(_) | Component::RootDir) {
352            continue;
353        }
354        match std::fs::symlink_metadata(&current) {
355            Ok(_) if index + 1 == components.len() => return Ok(ConcretePathState::Existing),
356            Ok(metadata) if metadata.file_type().is_symlink() => match current.canonicalize() {
357                Ok(canonical) => current = canonical,
358                Err(source) => {
359                    return Err(Error::IndeterminatePath {
360                        path: current,
361                        source,
362                    });
363                }
364            },
365            Ok(_) => {}
366            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
367                return Ok(ConcretePathState::Missing);
368            }
369            Err(source) => {
370                return Err(Error::IndeterminatePath {
371                    path: current,
372                    source,
373                });
374            }
375        }
376    }
377    Ok(ConcretePathState::Existing)
378}
379
380/// Materializes cwd-relative gitignore-style filesystem policy into concrete paths.
381pub struct FilesystemPolicyMaterializer<'a> {
382    cwd: &'a Path,
383    policy: &'a FilesystemPolicy,
384}
385
386impl<'a> FilesystemPolicyMaterializer<'a> {
387    /// Create a filesystem policy materializer.
388    #[must_use]
389    pub const fn new(cwd: &'a Path, policy: &'a FilesystemPolicy) -> Self {
390        Self { cwd, policy }
391    }
392
393    /// Materialize policy into concrete deny, writable, and protected targets.
394    ///
395    /// # Errors
396    ///
397    /// Returns a sandbox misconfiguration when policy patterns are invalid or cwd cannot be walked.
398    pub fn materialize(self) -> Result<MaterializedFilesystemPolicy> {
399        // Expand ~ in all patterns and split into CWD-relative (gitignore) vs
400        // external-absolute (direct target) groups.
401        let cwd_relative_deny = self.expand_and_split(self.policy.deny());
402        let cwd_relative_writable = self.expand_and_split(self.policy.writable());
403
404        let deny = self.build_matcher(&cwd_relative_deny, DENY_FRAGMENT)?;
405        let writable = self.build_matcher(&cwd_relative_writable, WRITE_FRAGMENT)?;
406        let paths = self.walk_existing()?;
407
408        let mut deny_targets = BTreeSet::new();
409        let mut writable_targets = BTreeSet::new();
410        let cwd_is_covered = broadly_grants_cwd(self.policy.writable())
411            || self.cwd_covered_by_writable_ancestor()?;
412        for path in &paths {
413            let is_dir = path.is_dir();
414            if self.selected(path, is_dir, &deny)? {
415                deny_targets.insert(path.clone());
416            } else if self.selected(path, is_dir, &writable)?
417                || (path == self.cwd && cwd_is_covered)
418            {
419                writable_targets.insert(path.clone());
420            }
421        }
422
423        let deny_literal_patterns =
424            self.patterns_with_readable_fragment(self.policy.deny(), DENY_FRAGMENT);
425        let writable_literal_patterns =
426            self.patterns_with_readable_fragment(self.policy.writable(), WRITE_FRAGMENT);
427        let classified_deny = self.classified_selected_literal_paths(&deny_literal_patterns)?;
428        let classified_writable =
429            self.classified_selected_literal_paths(&writable_literal_patterns)?;
430
431        // Add external absolute paths directly as targets.
432        self.add_external_targets(&classified_deny, &mut deny_targets);
433        self.add_external_targets(&classified_writable, &mut writable_targets);
434
435        self.apply_literal_specificity(
436            &mut deny_targets,
437            &mut writable_targets,
438            &deny_literal_patterns,
439            &writable_literal_patterns,
440        );
441        let readable_targets = self.readable_targets(&deny_targets, &deny_literal_patterns)?;
442        self.prune_redundant_deny_targets(&mut deny_targets);
443
444        let protected_targets = self.protected_control_targets(&writable, &deny)?;
445
446        let missing_deny_guards = self.missing_deny_guards(&classified_deny, &writable_targets);
447
448        Ok(MaterializedFilesystemPolicy {
449            deny_targets,
450            writable_targets,
451            protected_targets,
452            readable_targets,
453            missing_deny_guards,
454        })
455    }
456
457    fn patterns_with_readable_fragment(&self, patterns: &[String], fragment: &str) -> Vec<String> {
458        let mut result = patterns.to_vec();
459        let fragment_path = self.cwd.join(fragment);
460        if let Ok(contents) = std::fs::read_to_string(fragment_path) {
461            result.extend(contents.lines().map(str::to_string));
462        }
463        result
464    }
465
466    /// Expand `~` in patterns and split into two groups:
467    /// - CWD-relative patterns (including glob patterns like `*.txt`)
468    /// - External absolute paths that exist on disk outside CWD
469    ///
470    /// External absolute paths are removed from the returned patterns and tracked
471    /// separately so they can be added as direct targets without gitignore matching.
472    fn expand_and_split(&self, patterns: &[String]) -> Vec<String> {
473        let mut result = Vec::with_capacity(patterns.len());
474        for pattern in patterns {
475            let expanded = expand_home_pattern(pattern);
476            result.push(self.matcher_pattern(&expanded));
477        }
478        result
479    }
480
481    fn matcher_pattern(&self, pattern: &str) -> String {
482        let Some(body) = pattern.strip_prefix('!') else {
483            return self
484                .cwd_relative_absolute_pattern(pattern)
485                .unwrap_or_else(|| pattern.to_string());
486        };
487        self.cwd_relative_absolute_pattern(body)
488            .map(|relative| format!("!{relative}"))
489            .unwrap_or_else(|| pattern.to_string())
490    }
491
492    fn cwd_relative_absolute_pattern(&self, pattern: &str) -> Option<String> {
493        let path = Path::new(pattern);
494        if !path.is_absolute() || !path.starts_with(self.cwd) {
495            return None;
496        }
497        let relative = path.strip_prefix(self.cwd).ok()?;
498        if relative.as_os_str().is_empty() {
499            return Some(".".to_string());
500        }
501        Some(relative.to_string_lossy().to_string())
502    }
503
504    /// Add concrete literal absolute patterns directly as targets when the host entry exists.
505    /// Missing ordinary host-backed paths are skipped here; missing deny paths that would be
506    /// creatable through a writable directory are recorded later as missing deny guards.
507    fn add_external_targets(
508        &self,
509        classified_paths: &[(PathBuf, ConcretePathState)],
510        targets: &mut BTreeSet<PathBuf>,
511    ) {
512        for (path, state) in classified_paths {
513            if state.is_existing() {
514                targets.insert(path.clone());
515            }
516        }
517    }
518
519    fn missing_deny_guards(
520        &self,
521        classified_deny: &[(PathBuf, ConcretePathState)],
522        writable_targets: &BTreeSet<PathBuf>,
523    ) -> BTreeSet<PathBuf> {
524        let writable_dirs = writable_targets
525            .iter()
526            .filter(|target| target.is_dir())
527            .collect::<Vec<_>>();
528        let mut guards = BTreeSet::new();
529        for (path, state) in classified_deny {
530            if state.is_missing()
531                && writable_dirs
532                    .iter()
533                    .any(|writable| path_has_prefix(path, writable))
534            {
535                guards.insert(path.clone());
536            }
537        }
538        guards
539    }
540
541    fn classified_selected_literal_paths(
542        &self,
543        patterns: &[String],
544    ) -> Result<Vec<(PathBuf, ConcretePathState)>> {
545        let rules = self.literal_path_rules(patterns);
546        let mut paths = Vec::new();
547        let mut seen = BTreeSet::new();
548        for rule in rules.iter().filter(|rule| rule.selected) {
549            if seen.insert(rule.path.clone()) && literal_path_is_selected(&rule.path, &rules) {
550                let state = concrete_path_state(&rule.path)?;
551                paths.push((rule.path.clone(), state));
552            }
553        }
554        Ok(paths)
555    }
556
557    fn literal_path_rules(&self, patterns: &[String]) -> Vec<LiteralPathRule> {
558        patterns
559            .iter()
560            .enumerate()
561            .filter_map(|(order, pattern)| self.literal_path_rule(pattern, order))
562            .collect()
563    }
564
565    fn literal_path_rule(&self, pattern: &str, order: usize) -> Option<LiteralPathRule> {
566        let (selected, body) = match pattern.strip_prefix('!') {
567            Some(body) => (false, body),
568            None => (true, pattern),
569        };
570        if contains_pattern_metacharacter(body) {
571            return None;
572        }
573        let path = PathBuf::from(shellexpand::tilde(body).into_owned());
574        path.is_absolute().then_some(LiteralPathRule {
575            path,
576            selected,
577            order,
578        })
579    }
580
581    fn build_matcher(&self, patterns: &[String], fragment: &str) -> Result<Gitignore> {
582        let mut builder = GitignoreBuilder::new(self.cwd);
583        for pattern in patterns {
584            builder
585                .add_line(None, pattern)
586                .map_err(|source| Error::InvalidPattern {
587                    pattern: pattern.clone(),
588                    source,
589                })?;
590        }
591
592        let fragment_path = self.cwd.join(fragment);
593        if fragment_path
594            .try_exists()
595            .map_err(|source| Error::FragmentStatus {
596                path: fragment_path.clone(),
597                source,
598            })?
599            && let Some(source) = builder.add(&fragment_path)
600        {
601            return Err(Error::InvalidFragment {
602                path: fragment_path,
603                source,
604            });
605        }
606
607        builder.build().map_err(|source| Error::InvalidMatcher {
608            fragment: fragment.to_string(),
609            source,
610        })
611    }
612
613    fn walk_existing(&self) -> Result<BTreeSet<PathBuf>> {
614        let mut paths = BTreeSet::new();
615        for entry in WalkBuilder::new(self.cwd)
616            .hidden(false)
617            .git_ignore(false)
618            .git_global(false)
619            .git_exclude(false)
620            .parents(false)
621            .build()
622        {
623            let entry = entry.map_err(|source| Error::Walk {
624                cwd: self.cwd.to_path_buf(),
625                source,
626            })?;
627            paths.insert(entry.path().to_path_buf());
628        }
629        Ok(paths)
630    }
631
632    fn selected(&self, path: &Path, is_dir: bool, matcher: &Gitignore) -> Result<bool> {
633        let relative = path
634            .strip_prefix(self.cwd)
635            .map_err(|source| Error::Relativize {
636                path: path.to_path_buf(),
637                cwd: self.cwd.to_path_buf(),
638                source,
639            })?;
640        Ok(matcher.matched(relative, is_dir).is_ignore())
641    }
642
643    /// Returns true when any writable pattern resolves to an absolute path that is an
644    /// ancestor of CWD, meaning CWD and its contents are implicitly writable.
645    fn cwd_covered_by_writable_ancestor(&self) -> Result<bool> {
646        for pattern in self.policy.writable() {
647            let expanded = expand_home_pattern(pattern);
648            let path = Path::new(&expanded);
649            if path.is_absolute()
650                && self.cwd.starts_with(path)
651                && concrete_path_state(path)?.is_existing()
652                && path.is_dir()
653            {
654                return Ok(true);
655            }
656        }
657        Ok(false)
658    }
659
660    fn apply_literal_specificity(
661        &self,
662        deny_targets: &mut BTreeSet<PathBuf>,
663        writable_targets: &mut BTreeSet<PathBuf>,
664        deny_patterns: &[String],
665        writable_patterns: &[String],
666    ) {
667        let deny_rules = self.literal_path_rules(deny_patterns);
668        let writable_rules = self.literal_path_rules(writable_patterns);
669        if deny_rules.is_empty() && writable_rules.is_empty() {
670            return;
671        }
672
673        enum LiteralAccess {
674            Deny,
675            Writable,
676            Neither,
677        }
678
679        let paths = deny_targets
680            .union(writable_targets)
681            .cloned()
682            .collect::<Vec<_>>();
683        for path in paths {
684            let deny = literal_path_decision(&path, &deny_rules);
685            let writable = literal_path_decision(&path, &writable_rules);
686            let access = match (deny, writable) {
687                (Some(deny), Some(writable)) => match (deny.selected, writable.selected) {
688                    (true, true) if writable.specificity() > deny.specificity() => {
689                        LiteralAccess::Writable
690                    }
691                    (true, _) => LiteralAccess::Deny,
692                    (false, true) => LiteralAccess::Writable,
693                    (false, false) => LiteralAccess::Neither,
694                },
695                (Some(deny), None) if deny.selected => LiteralAccess::Deny,
696                (Some(_), None) => LiteralAccess::Neither,
697                (None, Some(writable)) if writable.selected => LiteralAccess::Writable,
698                (None, Some(_)) => LiteralAccess::Neither,
699                (None, None) => continue,
700            };
701
702            match access {
703                LiteralAccess::Deny => {
704                    writable_targets.remove(&path);
705                    deny_targets.insert(path);
706                }
707                LiteralAccess::Writable => {
708                    deny_targets.remove(&path);
709                    writable_targets.insert(path);
710                }
711                LiteralAccess::Neither => {
712                    deny_targets.remove(&path);
713                    writable_targets.remove(&path);
714                }
715            }
716        }
717    }
718
719    fn readable_targets(
720        &self,
721        deny_targets: &BTreeSet<PathBuf>,
722        deny_patterns: &[String],
723    ) -> Result<BTreeSet<PathBuf>> {
724        let mut targets = BTreeSet::new();
725        for pattern in deny_patterns {
726            let Some(restored) = pattern.strip_prefix('!') else {
727                continue;
728            };
729            if contains_pattern_metacharacter(restored) {
730                continue;
731            }
732            let Some(path) = self.literal_path(restored) else {
733                continue;
734            };
735            if concrete_path_state(&path)?.is_existing()
736                && has_denied_directory_ancestor(&path, deny_targets)
737            {
738                targets.insert(path);
739            }
740        }
741        Ok(targets)
742    }
743
744    fn prune_redundant_deny_targets(&self, deny_targets: &mut BTreeSet<PathBuf>) {
745        let original = deny_targets.clone();
746        deny_targets.retain(|target| !has_denied_directory_ancestor(target, &original));
747    }
748
749    fn literal_path(&self, pattern: &str) -> Option<PathBuf> {
750        let path = PathBuf::from(expand_home_pattern(pattern));
751        Some(if path.is_absolute() {
752            path
753        } else {
754            self.cwd.join(path)
755        })
756    }
757
758    fn protected_control_targets(
759        &self,
760        writable: &Gitignore,
761        deny: &Gitignore,
762    ) -> Result<BTreeSet<PathBuf>> {
763        // When a writable ancestor covers CWD, the user trusts the entire tree.
764        // Do not protect any control paths — they are explicitly writable.
765        if self.cwd_covered_by_writable_ancestor()? {
766            return Ok(BTreeSet::new());
767        }
768
769        let mut protected = BTreeSet::new();
770        let cwd_is_writable =
771            self.selected(self.cwd, true, writable)? || broadly_grants_cwd(self.policy.writable());
772        for path in protected_control_candidate_paths(self.cwd)? {
773            let writable_selected = self.selected(&path, path.is_dir(), writable)?;
774            let deny_selected = self.selected(&path, path.is_dir(), deny)?;
775            let existing_control_path_needs_readonly =
776                path.exists() && (cwd_is_writable || !writable_selected || deny_selected);
777            let missing_control_path_needs_readonly = cwd_is_writable;
778            if existing_control_path_needs_readonly || missing_control_path_needs_readonly {
779                protected.insert(path);
780            }
781        }
782        Ok(protected)
783    }
784}
785
786/// Validate filesystem pattern syntax and absolute virtual file targets.
787///
788/// # Errors
789///
790/// Returns a sandbox misconfiguration when any pattern is invalid or a virtual target is relative.
791pub fn validate_filesystem_policy(policy: &FilesystemPolicy) -> Result<()> {
792    validate_patterns(policy.deny())?;
793    validate_patterns(policy.writable())?;
794    for path in policy.virtual_files().keys() {
795        if !path.is_absolute() {
796            return Err(Error::RelativeVirtualTarget {
797                path: path.to_path_buf(),
798            });
799        }
800    }
801    Ok(())
802}
803
804/// Validate gitignore-style filesystem pattern syntax.
805///
806/// # Errors
807///
808/// Returns a sandbox misconfiguration when any pattern is invalid.
809pub fn validate_patterns(patterns: &[String]) -> Result<()> {
810    let temp = std::env::temp_dir();
811    let mut builder = GitignoreBuilder::new(&temp);
812    for pattern in patterns {
813        builder
814            .add_line(None, pattern)
815            .map_err(|source| Error::InvalidPattern {
816                pattern: pattern.clone(),
817                source,
818            })?;
819    }
820    builder.build().map_err(|source| Error::InvalidMatcher {
821        fragment: "inline patterns".to_string(),
822        source,
823    })?;
824    Ok(())
825}
826
827/// Return whether pattern lines broadly grant the policy cwd.
828#[must_use]
829pub fn broadly_grants_cwd(patterns: &[String]) -> bool {
830    patterns
831        .iter()
832        .map(String::as_str)
833        .any(|pattern| matches!(pattern, "." | "./" | "*" | "**" | "**/*"))
834}
835
836/// Return the current user's home directory.
837///
838/// Uses the `dirs` crate for platform-correct resolution.
839#[must_use]
840pub fn home_dir() -> Option<PathBuf> {
841    dirs::home_dir()
842}
843
844fn expand_home_pattern(pattern: &str) -> String {
845    let Some(body) = pattern.strip_prefix('!') else {
846        return shellexpand::tilde(pattern).into_owned();
847    };
848    format!("!{}", shellexpand::tilde(body))
849}
850
851#[derive(Debug, Clone, PartialEq, Eq)]
852struct LiteralPathRule {
853    path: PathBuf,
854    selected: bool,
855    order: usize,
856}
857
858impl LiteralPathRule {
859    fn matches(&self, path: &Path) -> bool {
860        path == self.path || (self.path.is_dir() && path.starts_with(&self.path))
861    }
862
863    fn specificity(&self) -> usize {
864        self.path
865            .components()
866            .filter(|component| !matches!(component, Component::RootDir | Component::Prefix(_)))
867            .count()
868    }
869}
870
871fn literal_path_decision<'a>(
872    path: &Path,
873    rules: &'a [LiteralPathRule],
874) -> Option<&'a LiteralPathRule> {
875    rules
876        .iter()
877        .filter(|rule| rule.matches(path))
878        .max_by_key(|rule| (rule.specificity(), rule.order))
879}
880
881fn literal_path_is_selected(path: &Path, rules: &[LiteralPathRule]) -> bool {
882    literal_path_decision(path, rules).is_some_and(|rule| rule.selected)
883}
884
885fn contains_pattern_metacharacter(pattern: &str) -> bool {
886    pattern
887        .chars()
888        .any(|ch| matches!(ch, '*' | '?' | '[' | ']'))
889}
890
891fn path_has_prefix(path: &Path, prefix: &Path) -> bool {
892    path == prefix || path.starts_with(prefix)
893}
894
895fn has_denied_directory_ancestor(path: &Path, deny_targets: &BTreeSet<PathBuf>) -> bool {
896    path.ancestors()
897        .skip(1)
898        .any(|ancestor| deny_targets.contains(ancestor) && ancestor.is_dir())
899}
900
901fn protected_control_candidate_paths(cwd: &Path) -> Result<BTreeSet<PathBuf>> {
902    let mut paths = [".git", ".agents", ".pi", DENY_FRAGMENT, WRITE_FRAGMENT]
903        .into_iter()
904        .map(|name| cwd.join(name))
905        .collect::<BTreeSet<_>>();
906    if cwd.is_dir() {
907        for entry in std::fs::read_dir(cwd).map_err(|source| Error::ReadDir {
908            cwd: cwd.to_path_buf(),
909            source,
910        })? {
911            let entry = entry.map_err(|source| Error::ReadEntry {
912                cwd: cwd.to_path_buf(),
913                source,
914            })?;
915            let name = entry.file_name();
916            if name.to_string_lossy().starts_with(".heimdall-") {
917                paths.insert(entry.path());
918            }
919        }
920    }
921    Ok(paths)
922}
923
924#[cfg(test)]
925mod tests {
926    use std::path::PathBuf;
927
928    use super::*;
929
930    fn unique_dir(name: &str) -> PathBuf {
931        let dir = std::env::temp_dir().join(format!(
932            "heimdall-{name}-{}",
933            std::time::SystemTime::now()
934                .duration_since(std::time::UNIX_EPOCH)
935                .expect("time moves forward")
936                .as_nanos()
937        ));
938        std::fs::create_dir(&dir).expect("temp dir is created");
939        dir
940    }
941
942    #[test]
943    fn deny_patterns_support_ordered_negation() {
944        let cwd = unique_dir("deny-negation");
945        std::fs::write(cwd.join(".env"), "secret").expect("file written");
946        std::fs::write(cwd.join(".env.example"), "example").expect("file written");
947        let policy = FilesystemPolicy::new(
948            vec![".env*".to_string(), "!.env.example".to_string()],
949            Vec::new(),
950            Default::default(),
951        );
952
953        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
954            .materialize()
955            .expect("policy materializes");
956
957        assert!(materialized.deny_targets().contains(&cwd.join(".env")));
958        assert!(
959            !materialized
960                .deny_targets()
961                .contains(&cwd.join(".env.example"))
962        );
963        std::fs::remove_dir_all(cwd).expect("temp dir removed");
964    }
965
966    #[test]
967    fn fragments_are_appended_after_json_patterns() {
968        let cwd = unique_dir("fragment-order");
969        std::fs::write(cwd.join("secret.txt"), "secret").expect("file written");
970        std::fs::write(cwd.join(DENY_FRAGMENT), "!secret.txt\n").expect("fragment written");
971        let policy = FilesystemPolicy::new(
972            vec!["secret.txt".to_string()],
973            Vec::new(),
974            Default::default(),
975        );
976
977        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
978            .materialize()
979            .expect("policy materializes");
980
981        assert!(
982            !materialized
983                .deny_targets()
984                .contains(&cwd.join("secret.txt"))
985        );
986        std::fs::remove_dir_all(cwd).expect("temp dir removed");
987    }
988
989    #[test]
990    fn invalid_fragments_are_rejected() {
991        let cwd = unique_dir("invalid-fragment");
992        std::fs::create_dir(cwd.join(DENY_FRAGMENT)).expect("fragment directory created");
993        let policy = FilesystemPolicy::default();
994
995        let error = FilesystemPolicyMaterializer::new(&cwd, &policy)
996            .materialize()
997            .expect_err("invalid fragment is rejected");
998        std::fs::remove_dir_all(cwd).expect("temp dir removed");
999
1000        assert!(matches!(error, Error::InvalidFragment { .. }));
1001    }
1002
1003    #[test]
1004    fn broad_writable_grants_protect_control_paths() {
1005        let cwd = unique_dir("protected-existing");
1006        std::fs::create_dir(cwd.join(".git")).expect("control dir created");
1007        std::fs::write(cwd.join(".heimdall-local"), "control").expect("control file written");
1008        let policy = FilesystemPolicy::new(Vec::new(), vec![".".to_string()], Default::default());
1009
1010        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1011            .materialize()
1012            .expect("policy materializes");
1013
1014        assert!(materialized.protected_targets().contains(&cwd.join(".git")));
1015        assert!(
1016            materialized
1017                .protected_targets()
1018                .contains(&cwd.join(".heimdall-local"))
1019        );
1020        assert!(
1021            materialized
1022                .protected_targets()
1023                .contains(&cwd.join(DENY_FRAGMENT))
1024        );
1025        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1026    }
1027
1028    #[test]
1029    fn writable_ancestor_makes_cwd_and_control_paths_writable() {
1030        // Create a structure: parent/cwd/.git where parent is the writable ancestor.
1031        let parent = unique_dir("writable-ancestor");
1032        let cwd = parent.join("sub");
1033        std::fs::create_dir(&cwd).expect("sub dir created");
1034        std::fs::create_dir(cwd.join(".git")).expect("control dir created");
1035        std::fs::write(cwd.join(".heimdall-local"), "control").expect("control file written");
1036
1037        let policy = FilesystemPolicy::new(
1038            Vec::new(),
1039            vec![parent.to_string_lossy().to_string()],
1040            Default::default(),
1041        );
1042
1043        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1044            .materialize()
1045            .expect("policy materializes");
1046
1047        // CWD must be in writable_targets (covered by ancestor).
1048        assert!(materialized.writable_targets().contains(&cwd));
1049        // Control paths must NOT be protected when CWD is covered by a writable ancestor.
1050        assert!(!materialized.protected_targets().contains(&cwd.join(".git")));
1051        assert!(
1052            !materialized
1053                .protected_targets()
1054                .contains(&cwd.join(".heimdall-local"))
1055        );
1056        std::fs::remove_dir_all(parent).expect("temp dir removed");
1057    }
1058
1059    #[test]
1060    fn deny_wins_over_writable() {
1061        let cwd = unique_dir("deny-wins");
1062        std::fs::write(cwd.join("data.txt"), "data").expect("file written");
1063        let policy = FilesystemPolicy::new(
1064            vec!["data.txt".to_string()],
1065            vec!["data.txt".to_string()],
1066            Default::default(),
1067        );
1068
1069        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1070            .materialize()
1071            .expect("policy materializes");
1072
1073        assert!(materialized.deny_targets().contains(&cwd.join("data.txt")));
1074        assert!(
1075            !materialized
1076                .writable_targets()
1077                .contains(&cwd.join("data.txt"))
1078        );
1079        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1080    }
1081
1082    #[test]
1083    fn external_absolute_writable_paths_are_added_directly() {
1084        let cwd = unique_dir("external-writable");
1085        // Create an external dir that exists outside CWD.
1086        let external = std::env::temp_dir().join("heimdall-external-writable-target");
1087        std::fs::create_dir_all(&external).expect("external dir created");
1088
1089        let policy = FilesystemPolicy::new(
1090            Vec::new(),
1091            vec![external.to_string_lossy().to_string()],
1092            Default::default(),
1093        );
1094
1095        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1096            .materialize()
1097            .expect("policy materializes");
1098
1099        assert!(
1100            materialized.writable_targets().contains(&external),
1101            "external absolute writable path should be added as a writable target"
1102        );
1103        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1104        std::fs::remove_dir_all(&external).expect("external dir removed");
1105    }
1106
1107    #[test]
1108    fn external_absolute_deny_paths_are_added_directly() {
1109        let cwd = unique_dir("external-deny");
1110        // Create an external dir that exists outside CWD.
1111        let external = std::env::temp_dir().join("heimdall-external-deny-target");
1112        std::fs::create_dir_all(&external).expect("external dir created");
1113
1114        let policy = FilesystemPolicy::new(
1115            vec![external.to_string_lossy().to_string()],
1116            Vec::new(),
1117            Default::default(),
1118        );
1119
1120        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1121            .materialize()
1122            .expect("policy materializes");
1123
1124        assert!(
1125            materialized.deny_targets().contains(&external),
1126            "external absolute deny path should be added as a deny target"
1127        );
1128        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1129        std::fs::remove_dir_all(&external).expect("external dir removed");
1130    }
1131
1132    #[test]
1133    fn later_absolute_deny_negation_removes_external_deny_target() {
1134        let cwd = unique_dir("external-deny-negation");
1135        let external = unique_dir("external-deny-negated-target");
1136        let policy = FilesystemPolicy::new(
1137            vec![
1138                external.to_string_lossy().to_string(),
1139                format!("!{}", external.display()),
1140            ],
1141            Vec::new(),
1142            Default::default(),
1143        );
1144
1145        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1146            .materialize()
1147            .expect("policy materializes");
1148
1149        assert!(!materialized.deny_targets().contains(&external));
1150        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1151        std::fs::remove_dir_all(external).expect("external dir removed");
1152    }
1153
1154    #[test]
1155    fn later_absolute_deny_negation_suppresses_missing_guard() {
1156        let cwd = unique_dir("missing-deny-negation");
1157        let writable = cwd.join("writable");
1158        std::fs::create_dir(&writable).expect("writable dir created");
1159        let missing = writable.join("missing-deny");
1160        let policy = FilesystemPolicy::new(
1161            vec![
1162                missing.to_string_lossy().to_string(),
1163                format!("!{}", missing.display()),
1164            ],
1165            vec![writable.to_string_lossy().to_string()],
1166            Default::default(),
1167        );
1168
1169        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1170            .materialize()
1171            .expect("policy materializes");
1172
1173        assert!(!materialized.deny_targets().contains(&missing));
1174        assert!(!materialized.missing_deny_guards().contains(&missing));
1175        assert!(!missing.exists());
1176        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1177    }
1178
1179    #[test]
1180    fn later_absolute_deny_negation_does_not_override_writable_target() {
1181        let cwd = unique_dir("deny-negation-writable");
1182        let external = unique_dir("deny-negation-writable-target");
1183        let policy = FilesystemPolicy::new(
1184            vec![
1185                external.to_string_lossy().to_string(),
1186                format!("!{}", external.display()),
1187            ],
1188            vec![external.to_string_lossy().to_string()],
1189            Default::default(),
1190        );
1191
1192        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1193            .materialize()
1194            .expect("policy materializes");
1195
1196        assert!(!materialized.deny_targets().contains(&external));
1197        assert!(materialized.writable_targets().contains(&external));
1198        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1199        std::fs::remove_dir_all(external).expect("external dir removed");
1200    }
1201
1202    #[test]
1203    fn longer_writable_path_wins_over_denied_parent() {
1204        let cwd = unique_dir("writable-wins-external");
1205        let external = std::env::temp_dir().join("heimdall-external-writable-wins-parent");
1206        let writable = external.join("writable");
1207        std::fs::create_dir_all(&writable).expect("external dirs created");
1208
1209        let policy = FilesystemPolicy::new(
1210            vec![external.to_string_lossy().to_string()],
1211            vec![writable.to_string_lossy().to_string()],
1212            Default::default(),
1213        );
1214
1215        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1216            .materialize()
1217            .expect("policy materializes");
1218
1219        assert!(materialized.deny_targets().contains(&external));
1220        assert!(materialized.writable_targets().contains(&writable));
1221        assert!(!materialized.deny_targets().contains(&writable));
1222        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1223        std::fs::remove_dir_all(&external).expect("external dir removed");
1224    }
1225
1226    #[test]
1227    fn longer_deny_path_wins_over_writable_parent() {
1228        let cwd = unique_dir("deny-wins-external");
1229        let external = std::env::temp_dir().join("heimdall-external-deny-wins-parent");
1230        let secret = external.join("secret");
1231        std::fs::create_dir_all(&secret).expect("external dirs created");
1232
1233        let policy = FilesystemPolicy::new(
1234            vec![secret.to_string_lossy().to_string()],
1235            vec![external.to_string_lossy().to_string()],
1236            Default::default(),
1237        );
1238
1239        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1240            .materialize()
1241            .expect("policy materializes");
1242
1243        assert!(materialized.deny_targets().contains(&secret));
1244        assert!(materialized.writable_targets().contains(&external));
1245        assert!(!materialized.writable_targets().contains(&secret));
1246        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1247        std::fs::remove_dir_all(&external).expect("external dir removed");
1248    }
1249
1250    #[test]
1251    fn tilde_patterns_expand_to_home_dir() {
1252        let cwd = unique_dir("tilde-expand");
1253        let home = home_dir().expect("home dir exists");
1254        // Use ~/something as a writable pattern.
1255        // We test against a real directory under home.
1256        let target = home.join(".config");
1257        if !target.is_dir() {
1258            // Skip if ~/.config doesn't exist on this system.
1259            std::fs::remove_dir_all(cwd).expect("temp dir removed");
1260            return;
1261        }
1262
1263        let policy = FilesystemPolicy::new(
1264            Vec::new(),
1265            vec!["~/.config".to_string()],
1266            Default::default(),
1267        );
1268
1269        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1270            .materialize()
1271            .expect("policy materializes");
1272
1273        assert!(
1274            materialized.writable_targets().contains(&target),
1275            "~/.config should expand and be added as a writable target"
1276        );
1277        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1278    }
1279    #[test]
1280    fn concrete_classifier_handles_absolute_missing_existing_and_tilde_inputs() {
1281        let cwd = unique_dir("concrete-classifier");
1282        let existing = cwd.join("exists");
1283        std::fs::write(&existing, "data").expect("file written");
1284        let missing = cwd.join("missing");
1285
1286        assert_eq!(
1287            concrete_path_state(&existing).expect("existing path classifies"),
1288            ConcretePathState::Existing
1289        );
1290        assert_eq!(
1291            concrete_path_state(&missing).expect("missing path classifies"),
1292            ConcretePathState::Missing
1293        );
1294        if let Some(home) = home_dir() {
1295            assert_eq!(
1296                concrete_path_state(&home).expect("home path classifies"),
1297                ConcretePathState::Existing
1298            );
1299        }
1300        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1301    }
1302
1303    #[cfg(unix)]
1304    #[test]
1305    fn final_component_symlinks_are_existing_even_when_dangling() {
1306        use std::os::unix::fs::symlink;
1307
1308        let cwd = unique_dir("concrete-symlink");
1309        let target = cwd.join("target");
1310        let link = cwd.join("link");
1311        let dangling = cwd.join("dangling");
1312        std::fs::write(&target, "data").expect("target written");
1313        symlink(&target, &link).expect("symlink created");
1314        symlink(cwd.join("absent"), &dangling).expect("dangling symlink created");
1315
1316        assert_eq!(
1317            concrete_path_state(&link).expect("link classifies"),
1318            ConcretePathState::Existing
1319        );
1320        assert_eq!(
1321            concrete_path_state(&dangling).expect("dangling link classifies"),
1322            ConcretePathState::Existing
1323        );
1324        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1325    }
1326
1327    #[test]
1328    fn missing_ancestor_classifies_requested_path_as_missing() {
1329        let cwd = unique_dir("missing-ancestor");
1330        let requested = cwd.join("absent").join("child");
1331
1332        assert_eq!(
1333            concrete_path_state(&requested).expect("path classifies"),
1334            ConcretePathState::Missing
1335        );
1336        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1337    }
1338
1339    #[cfg(unix)]
1340    #[test]
1341    fn symlink_ancestor_canonicalization_failure_is_indeterminate() {
1342        use std::os::unix::fs::symlink;
1343
1344        let cwd = unique_dir("indeterminate-ancestor");
1345        let dangling = cwd.join("dangling-parent");
1346        symlink(cwd.join("absent"), &dangling).expect("dangling symlink created");
1347        let requested = dangling.join("child");
1348
1349        assert!(matches!(
1350            concrete_path_state(&requested),
1351            Err(Error::IndeterminatePath { .. })
1352        ));
1353        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1354    }
1355
1356    #[test]
1357    fn missing_literal_writable_and_readable_targets_are_skipped() {
1358        let cwd = unique_dir("missing-writable-readable");
1359        let missing_writable = cwd.join("missing-write");
1360        let missing_readable = cwd.join("missing-read");
1361        let policy = FilesystemPolicy::new(
1362            vec![format!("!{}", missing_readable.display())],
1363            vec![missing_writable.to_string_lossy().to_string()],
1364            Default::default(),
1365        );
1366
1367        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1368            .materialize()
1369            .expect("policy materializes");
1370
1371        assert!(!materialized.writable_targets().contains(&missing_writable));
1372        assert!(!materialized.readable_targets().contains(&missing_readable));
1373        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1374    }
1375
1376    #[cfg(unix)]
1377    #[test]
1378    fn indeterminate_restored_readonly_target_fails_materialization() {
1379        use std::os::unix::fs::symlink;
1380
1381        let cwd = unique_dir("indeterminate-readable");
1382        let dangling = cwd.join("dangling-parent");
1383        symlink(cwd.join("absent"), &dangling).expect("dangling symlink created");
1384        let restored = dangling.join("child");
1385        let policy = FilesystemPolicy::new(
1386            vec![
1387                cwd.to_string_lossy().to_string(),
1388                format!("!{}", restored.display()),
1389            ],
1390            Vec::new(),
1391            Default::default(),
1392        );
1393
1394        let result = FilesystemPolicyMaterializer::new(&cwd, &policy).materialize();
1395
1396        assert!(matches!(result, Err(Error::IndeterminatePath { .. })));
1397        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1398    }
1399
1400    #[test]
1401    fn missing_denies_are_skipped_unless_covered_by_existing_writable_directory() {
1402        let cwd = unique_dir("missing-deny-guards");
1403        let writable = cwd.join("writable");
1404        std::fs::create_dir(&writable).expect("writable dir created");
1405        let guarded = writable.join("missing");
1406        let skipped = cwd.join("outside-missing");
1407        let policy = FilesystemPolicy::new(
1408            vec![
1409                guarded.to_string_lossy().to_string(),
1410                skipped.to_string_lossy().to_string(),
1411            ],
1412            vec![writable.to_string_lossy().to_string()],
1413            Default::default(),
1414        );
1415
1416        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1417            .materialize()
1418            .expect("policy materializes");
1419
1420        assert!(materialized.writable_targets().contains(&writable));
1421        assert!(!materialized.deny_targets().contains(&guarded));
1422        assert!(!materialized.deny_targets().contains(&skipped));
1423        assert!(materialized.missing_deny_guards().contains(&guarded));
1424        assert!(!materialized.missing_deny_guards().contains(&skipped));
1425        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1426    }
1427
1428    #[test]
1429    fn literal_absolute_paths_under_cwd_are_concrete_before_pattern_matching() {
1430        let cwd = unique_dir("absolute-under-cwd");
1431        let missing = cwd.join("missing");
1432        let policy = FilesystemPolicy::new(
1433            vec![missing.to_string_lossy().to_string()],
1434            vec![".".to_string()],
1435            Default::default(),
1436        );
1437
1438        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1439            .materialize()
1440            .expect("policy materializes");
1441
1442        assert!(materialized.writable_targets().contains(&cwd));
1443        assert!(materialized.missing_deny_guards().contains(&missing));
1444        assert!(!missing.exists());
1445        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1446    }
1447}