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 =
442            self.readable_targets(&deny_targets, &deny_literal_patterns, &writable_targets)?;
443        self.prune_redundant_deny_targets(&mut deny_targets);
444
445        let protected_targets = self.protected_control_targets(&writable, &deny)?;
446
447        let missing_deny_guards = self.missing_deny_guards(&classified_deny, &writable_targets);
448
449        Ok(MaterializedFilesystemPolicy {
450            deny_targets,
451            writable_targets,
452            protected_targets,
453            readable_targets,
454            missing_deny_guards,
455        })
456    }
457
458    fn patterns_with_readable_fragment(&self, patterns: &[String], fragment: &str) -> Vec<String> {
459        let mut result = patterns.to_vec();
460        let fragment_path = self.cwd.join(fragment);
461        if let Ok(contents) = std::fs::read_to_string(fragment_path) {
462            result.extend(contents.lines().map(str::to_string));
463        }
464        result
465    }
466
467    /// Expand `~` in patterns and split into two groups:
468    /// - CWD-relative patterns (including glob patterns like `*.txt`)
469    /// - External absolute paths that exist on disk outside CWD
470    ///
471    /// External absolute paths are removed from the returned patterns and tracked
472    /// separately so they can be added as direct targets without gitignore matching.
473    fn expand_and_split(&self, patterns: &[String]) -> Vec<String> {
474        let mut result = Vec::with_capacity(patterns.len());
475        for pattern in patterns {
476            let expanded = expand_home_pattern(pattern);
477            result.push(self.matcher_pattern(&expanded));
478        }
479        result
480    }
481
482    fn matcher_pattern(&self, pattern: &str) -> String {
483        let Some(body) = pattern.strip_prefix('!') else {
484            return self
485                .cwd_relative_absolute_pattern(pattern)
486                .unwrap_or_else(|| pattern.to_string());
487        };
488        self.cwd_relative_absolute_pattern(body)
489            .map(|relative| format!("!{relative}"))
490            .unwrap_or_else(|| pattern.to_string())
491    }
492
493    fn cwd_relative_absolute_pattern(&self, pattern: &str) -> Option<String> {
494        let path = Path::new(pattern);
495        if !path.is_absolute() || !path.starts_with(self.cwd) {
496            return None;
497        }
498        let relative = path.strip_prefix(self.cwd).ok()?;
499        if relative.as_os_str().is_empty() {
500            return Some(".".to_string());
501        }
502        Some(relative.to_string_lossy().to_string())
503    }
504
505    /// Add concrete literal absolute patterns directly as targets when the host entry exists.
506    /// Missing ordinary host-backed paths are skipped here; missing deny paths that would be
507    /// creatable through a writable directory are recorded later as missing deny guards.
508    fn add_external_targets(
509        &self,
510        classified_paths: &[(PathBuf, ConcretePathState)],
511        targets: &mut BTreeSet<PathBuf>,
512    ) {
513        for (path, state) in classified_paths {
514            if state.is_existing() {
515                targets.insert(path.clone());
516            }
517        }
518    }
519
520    fn missing_deny_guards(
521        &self,
522        classified_deny: &[(PathBuf, ConcretePathState)],
523        writable_targets: &BTreeSet<PathBuf>,
524    ) -> BTreeSet<PathBuf> {
525        let writable_dirs = writable_targets
526            .iter()
527            .filter(|target| target.is_dir())
528            .collect::<Vec<_>>();
529        let mut guards = BTreeSet::new();
530        for (path, state) in classified_deny {
531            if state.is_missing()
532                && writable_dirs
533                    .iter()
534                    .any(|writable| path_has_prefix(path, writable))
535            {
536                guards.insert(path.clone());
537            }
538        }
539        guards
540    }
541
542    fn classified_selected_literal_paths(
543        &self,
544        patterns: &[String],
545    ) -> Result<Vec<(PathBuf, ConcretePathState)>> {
546        let rules = self.literal_path_rules(patterns);
547        let mut paths = Vec::new();
548        let mut seen = BTreeSet::new();
549        for rule in rules.iter().filter(|rule| rule.selected) {
550            if seen.insert(rule.path.clone()) && literal_path_is_selected(&rule.path, &rules) {
551                let state = concrete_path_state(&rule.path)?;
552                paths.push((rule.path.clone(), state));
553            }
554        }
555        Ok(paths)
556    }
557
558    fn literal_path_rules(&self, patterns: &[String]) -> Vec<LiteralPathRule> {
559        patterns
560            .iter()
561            .enumerate()
562            .filter_map(|(order, pattern)| self.literal_path_rule(pattern, order))
563            .collect()
564    }
565
566    fn literal_path_rule(&self, pattern: &str, order: usize) -> Option<LiteralPathRule> {
567        let (selected, body) = match pattern.strip_prefix('!') {
568            Some(body) => (false, body),
569            None => (true, pattern),
570        };
571        if contains_pattern_metacharacter(body) {
572            return None;
573        }
574        let path = PathBuf::from(shellexpand::tilde(body).into_owned());
575        path.is_absolute().then_some(LiteralPathRule {
576            path,
577            selected,
578            order,
579        })
580    }
581
582    fn build_matcher(&self, patterns: &[String], fragment: &str) -> Result<Gitignore> {
583        let mut builder = GitignoreBuilder::new(self.cwd);
584        for pattern in patterns {
585            builder
586                .add_line(None, pattern)
587                .map_err(|source| Error::InvalidPattern {
588                    pattern: pattern.clone(),
589                    source,
590                })?;
591        }
592
593        let fragment_path = self.cwd.join(fragment);
594        if fragment_path
595            .try_exists()
596            .map_err(|source| Error::FragmentStatus {
597                path: fragment_path.clone(),
598                source,
599            })?
600            && let Some(source) = builder.add(&fragment_path)
601        {
602            return Err(Error::InvalidFragment {
603                path: fragment_path,
604                source,
605            });
606        }
607
608        builder.build().map_err(|source| Error::InvalidMatcher {
609            fragment: fragment.to_string(),
610            source,
611        })
612    }
613
614    fn walk_existing(&self) -> Result<BTreeSet<PathBuf>> {
615        let mut paths = BTreeSet::new();
616        for entry in WalkBuilder::new(self.cwd)
617            .hidden(false)
618            .git_ignore(false)
619            .git_global(false)
620            .git_exclude(false)
621            .parents(false)
622            .build()
623        {
624            let entry = entry.map_err(|source| Error::Walk {
625                cwd: self.cwd.to_path_buf(),
626                source,
627            })?;
628            paths.insert(entry.path().to_path_buf());
629        }
630        Ok(paths)
631    }
632
633    fn selected(&self, path: &Path, is_dir: bool, matcher: &Gitignore) -> Result<bool> {
634        let relative = path
635            .strip_prefix(self.cwd)
636            .map_err(|source| Error::Relativize {
637                path: path.to_path_buf(),
638                cwd: self.cwd.to_path_buf(),
639                source,
640            })?;
641        Ok(matcher.matched(relative, is_dir).is_ignore())
642    }
643
644    /// Returns true when any writable pattern resolves to an absolute path that is an
645    /// ancestor of CWD, meaning CWD and its contents are implicitly writable.
646    fn cwd_covered_by_writable_ancestor(&self) -> Result<bool> {
647        for pattern in self.policy.writable() {
648            let expanded = expand_home_pattern(pattern);
649            let path = Path::new(&expanded);
650            if path.is_absolute()
651                && self.cwd.starts_with(path)
652                && concrete_path_state(path)?.is_existing()
653                && path.is_dir()
654            {
655                return Ok(true);
656            }
657        }
658        Ok(false)
659    }
660
661    fn apply_literal_specificity(
662        &self,
663        deny_targets: &mut BTreeSet<PathBuf>,
664        writable_targets: &mut BTreeSet<PathBuf>,
665        deny_patterns: &[String],
666        writable_patterns: &[String],
667    ) {
668        let deny_rules = self.literal_path_rules(deny_patterns);
669        let writable_rules = self.literal_path_rules(writable_patterns);
670        if deny_rules.is_empty() && writable_rules.is_empty() {
671            return;
672        }
673
674        enum LiteralAccess {
675            Deny,
676            Writable,
677            Neither,
678        }
679
680        let paths = deny_targets
681            .union(writable_targets)
682            .cloned()
683            .collect::<Vec<_>>();
684        for path in paths {
685            let deny = literal_path_decision(&path, &deny_rules);
686            let writable = literal_path_decision(&path, &writable_rules);
687            let access = match (deny, writable) {
688                (Some(deny), Some(writable)) => match (deny.selected, writable.selected) {
689                    (true, true) if writable.specificity() > deny.specificity() => {
690                        LiteralAccess::Writable
691                    }
692                    (true, _) => LiteralAccess::Deny,
693                    (false, true) => LiteralAccess::Writable,
694                    (false, false) => LiteralAccess::Neither,
695                },
696                (Some(deny), None) if deny.selected => LiteralAccess::Deny,
697                (Some(_), None) => LiteralAccess::Neither,
698                (None, Some(writable)) if writable.selected => LiteralAccess::Writable,
699                (None, Some(_)) => LiteralAccess::Neither,
700                (None, None) => continue,
701            };
702
703            match access {
704                LiteralAccess::Deny => {
705                    writable_targets.remove(&path);
706                    deny_targets.insert(path);
707                }
708                LiteralAccess::Writable => {
709                    deny_targets.remove(&path);
710                    writable_targets.insert(path);
711                }
712                LiteralAccess::Neither => {
713                    deny_targets.remove(&path);
714                    writable_targets.remove(&path);
715                }
716            }
717        }
718    }
719    /// Deny-negation corridors (`!pattern`) beneath a denied directory, reopened read-only.
720    ///
721    /// Paths already covered by a writable target are excluded: the writable bind grants
722    /// read access too, and a read-only rebind of the same path would stack over the
723    /// writable bind and silently demote it to read-only.
724    fn readable_targets(
725        &self,
726        deny_targets: &BTreeSet<PathBuf>,
727        deny_patterns: &[String],
728        writable_targets: &BTreeSet<PathBuf>,
729    ) -> Result<BTreeSet<PathBuf>> {
730        let mut targets = BTreeSet::new();
731        for pattern in deny_patterns {
732            let Some(restored) = pattern.strip_prefix('!') else {
733                continue;
734            };
735            if contains_pattern_metacharacter(restored) {
736                continue;
737            }
738            let Some(path) = self.literal_path(restored) else {
739                continue;
740            };
741            if concrete_path_state(&path)?.is_existing()
742                && has_denied_directory_ancestor(&path, deny_targets)
743                && !writable_targets
744                    .iter()
745                    .any(|writable| path.starts_with(writable))
746            {
747                targets.insert(path);
748            }
749        }
750        Ok(targets)
751    }
752
753    fn prune_redundant_deny_targets(&self, deny_targets: &mut BTreeSet<PathBuf>) {
754        let original = deny_targets.clone();
755        deny_targets.retain(|target| !has_denied_directory_ancestor(target, &original));
756    }
757
758    fn literal_path(&self, pattern: &str) -> Option<PathBuf> {
759        let path = PathBuf::from(expand_home_pattern(pattern));
760        Some(if path.is_absolute() {
761            path
762        } else {
763            self.cwd.join(path)
764        })
765    }
766
767    fn protected_control_targets(
768        &self,
769        writable: &Gitignore,
770        deny: &Gitignore,
771    ) -> Result<BTreeSet<PathBuf>> {
772        // When a writable ancestor covers CWD, the user trusts the entire tree.
773        // Do not protect any control paths — they are explicitly writable.
774        if self.cwd_covered_by_writable_ancestor()? {
775            return Ok(BTreeSet::new());
776        }
777
778        let mut protected = BTreeSet::new();
779        let cwd_is_writable =
780            self.selected(self.cwd, true, writable)? || broadly_grants_cwd(self.policy.writable());
781        for path in protected_control_candidate_paths(self.cwd)? {
782            let writable_selected = self.selected(&path, path.is_dir(), writable)?;
783            let deny_selected = self.selected(&path, path.is_dir(), deny)?;
784            let existing_control_path_needs_readonly =
785                path.exists() && (cwd_is_writable || !writable_selected || deny_selected);
786            let missing_control_path_needs_readonly = cwd_is_writable;
787            if existing_control_path_needs_readonly || missing_control_path_needs_readonly {
788                protected.insert(path);
789            }
790        }
791        Ok(protected)
792    }
793}
794
795/// Validate filesystem pattern syntax and absolute virtual file targets.
796///
797/// # Errors
798///
799/// Returns a sandbox misconfiguration when any pattern is invalid or a virtual target is relative.
800pub fn validate_filesystem_policy(policy: &FilesystemPolicy) -> Result<()> {
801    validate_patterns(policy.deny())?;
802    validate_patterns(policy.writable())?;
803    for path in policy.virtual_files().keys() {
804        if !path.is_absolute() {
805            return Err(Error::RelativeVirtualTarget {
806                path: path.to_path_buf(),
807            });
808        }
809    }
810    Ok(())
811}
812
813/// Validate gitignore-style filesystem pattern syntax.
814///
815/// # Errors
816///
817/// Returns a sandbox misconfiguration when any pattern is invalid.
818pub fn validate_patterns(patterns: &[String]) -> Result<()> {
819    let temp = std::env::temp_dir();
820    let mut builder = GitignoreBuilder::new(&temp);
821    for pattern in patterns {
822        builder
823            .add_line(None, pattern)
824            .map_err(|source| Error::InvalidPattern {
825                pattern: pattern.clone(),
826                source,
827            })?;
828    }
829    builder.build().map_err(|source| Error::InvalidMatcher {
830        fragment: "inline patterns".to_string(),
831        source,
832    })?;
833    Ok(())
834}
835
836/// Return whether pattern lines broadly grant the policy cwd.
837#[must_use]
838pub fn broadly_grants_cwd(patterns: &[String]) -> bool {
839    patterns
840        .iter()
841        .map(String::as_str)
842        .any(|pattern| matches!(pattern, "." | "./" | "*" | "**" | "**/*"))
843}
844
845/// Return the current user's home directory.
846///
847/// Uses the `dirs` crate for platform-correct resolution.
848#[must_use]
849pub fn home_dir() -> Option<PathBuf> {
850    dirs::home_dir()
851}
852
853fn expand_home_pattern(pattern: &str) -> String {
854    let Some(body) = pattern.strip_prefix('!') else {
855        return shellexpand::tilde(pattern).into_owned();
856    };
857    format!("!{}", shellexpand::tilde(body))
858}
859
860#[derive(Debug, Clone, PartialEq, Eq)]
861struct LiteralPathRule {
862    path: PathBuf,
863    selected: bool,
864    order: usize,
865}
866
867impl LiteralPathRule {
868    fn matches(&self, path: &Path) -> bool {
869        path == self.path || (self.path.is_dir() && path.starts_with(&self.path))
870    }
871
872    fn specificity(&self) -> usize {
873        self.path
874            .components()
875            .filter(|component| !matches!(component, Component::RootDir | Component::Prefix(_)))
876            .count()
877    }
878}
879
880fn literal_path_decision<'a>(
881    path: &Path,
882    rules: &'a [LiteralPathRule],
883) -> Option<&'a LiteralPathRule> {
884    rules
885        .iter()
886        .filter(|rule| rule.matches(path))
887        .max_by_key(|rule| (rule.specificity(), rule.order))
888}
889
890fn literal_path_is_selected(path: &Path, rules: &[LiteralPathRule]) -> bool {
891    literal_path_decision(path, rules).is_some_and(|rule| rule.selected)
892}
893
894fn contains_pattern_metacharacter(pattern: &str) -> bool {
895    pattern
896        .chars()
897        .any(|ch| matches!(ch, '*' | '?' | '[' | ']'))
898}
899
900fn path_has_prefix(path: &Path, prefix: &Path) -> bool {
901    path == prefix || path.starts_with(prefix)
902}
903
904fn has_denied_directory_ancestor(path: &Path, deny_targets: &BTreeSet<PathBuf>) -> bool {
905    path.ancestors()
906        .skip(1)
907        .any(|ancestor| deny_targets.contains(ancestor) && ancestor.is_dir())
908}
909
910fn protected_control_candidate_paths(cwd: &Path) -> Result<BTreeSet<PathBuf>> {
911    let mut paths = [".git", ".agents", ".pi", DENY_FRAGMENT, WRITE_FRAGMENT]
912        .into_iter()
913        .map(|name| cwd.join(name))
914        .collect::<BTreeSet<_>>();
915    if cwd.is_dir() {
916        for entry in std::fs::read_dir(cwd).map_err(|source| Error::ReadDir {
917            cwd: cwd.to_path_buf(),
918            source,
919        })? {
920            let entry = entry.map_err(|source| Error::ReadEntry {
921                cwd: cwd.to_path_buf(),
922                source,
923            })?;
924            let name = entry.file_name();
925            if name.to_string_lossy().starts_with(".heimdall-") {
926                paths.insert(entry.path());
927            }
928        }
929    }
930    Ok(paths)
931}
932
933#[cfg(test)]
934mod tests {
935    use std::path::PathBuf;
936
937    use super::*;
938
939    fn unique_dir(name: &str) -> PathBuf {
940        let dir = std::env::temp_dir().join(format!(
941            "heimdall-{name}-{}",
942            std::time::SystemTime::now()
943                .duration_since(std::time::UNIX_EPOCH)
944                .expect("time moves forward")
945                .as_nanos()
946        ));
947        std::fs::create_dir(&dir).expect("temp dir is created");
948        dir
949    }
950
951    #[test]
952    fn deny_patterns_support_ordered_negation() {
953        let cwd = unique_dir("deny-negation");
954        std::fs::write(cwd.join(".env"), "secret").expect("file written");
955        std::fs::write(cwd.join(".env.example"), "example").expect("file written");
956        let policy = FilesystemPolicy::new(
957            vec![".env*".to_string(), "!.env.example".to_string()],
958            Vec::new(),
959            Default::default(),
960        );
961
962        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
963            .materialize()
964            .expect("policy materializes");
965
966        assert!(materialized.deny_targets().contains(&cwd.join(".env")));
967        assert!(
968            !materialized
969                .deny_targets()
970                .contains(&cwd.join(".env.example"))
971        );
972        std::fs::remove_dir_all(cwd).expect("temp dir removed");
973    }
974
975    #[test]
976    fn fragments_are_appended_after_json_patterns() {
977        let cwd = unique_dir("fragment-order");
978        std::fs::write(cwd.join("secret.txt"), "secret").expect("file written");
979        std::fs::write(cwd.join(DENY_FRAGMENT), "!secret.txt\n").expect("fragment written");
980        let policy = FilesystemPolicy::new(
981            vec!["secret.txt".to_string()],
982            Vec::new(),
983            Default::default(),
984        );
985
986        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
987            .materialize()
988            .expect("policy materializes");
989
990        assert!(
991            !materialized
992                .deny_targets()
993                .contains(&cwd.join("secret.txt"))
994        );
995        std::fs::remove_dir_all(cwd).expect("temp dir removed");
996    }
997
998    #[test]
999    fn invalid_fragments_are_rejected() {
1000        let cwd = unique_dir("invalid-fragment");
1001        std::fs::create_dir(cwd.join(DENY_FRAGMENT)).expect("fragment directory created");
1002        let policy = FilesystemPolicy::default();
1003
1004        let error = FilesystemPolicyMaterializer::new(&cwd, &policy)
1005            .materialize()
1006            .expect_err("invalid fragment is rejected");
1007        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1008
1009        assert!(matches!(error, Error::InvalidFragment { .. }));
1010    }
1011
1012    #[test]
1013    fn broad_writable_grants_protect_control_paths() {
1014        let cwd = unique_dir("protected-existing");
1015        std::fs::create_dir(cwd.join(".git")).expect("control dir created");
1016        std::fs::write(cwd.join(".heimdall-local"), "control").expect("control file written");
1017        let policy = FilesystemPolicy::new(Vec::new(), vec![".".to_string()], Default::default());
1018
1019        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1020            .materialize()
1021            .expect("policy materializes");
1022
1023        assert!(materialized.protected_targets().contains(&cwd.join(".git")));
1024        assert!(
1025            materialized
1026                .protected_targets()
1027                .contains(&cwd.join(".heimdall-local"))
1028        );
1029        assert!(
1030            materialized
1031                .protected_targets()
1032                .contains(&cwd.join(DENY_FRAGMENT))
1033        );
1034        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1035    }
1036
1037    #[test]
1038    fn writable_ancestor_makes_cwd_and_control_paths_writable() {
1039        // Create a structure: parent/cwd/.git where parent is the writable ancestor.
1040        let parent = unique_dir("writable-ancestor");
1041        let cwd = parent.join("sub");
1042        std::fs::create_dir(&cwd).expect("sub dir created");
1043        std::fs::create_dir(cwd.join(".git")).expect("control dir created");
1044        std::fs::write(cwd.join(".heimdall-local"), "control").expect("control file written");
1045
1046        let policy = FilesystemPolicy::new(
1047            Vec::new(),
1048            vec![parent.to_string_lossy().to_string()],
1049            Default::default(),
1050        );
1051
1052        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1053            .materialize()
1054            .expect("policy materializes");
1055
1056        // CWD must be in writable_targets (covered by ancestor).
1057        assert!(materialized.writable_targets().contains(&cwd));
1058        // Control paths must NOT be protected when CWD is covered by a writable ancestor.
1059        assert!(!materialized.protected_targets().contains(&cwd.join(".git")));
1060        assert!(
1061            !materialized
1062                .protected_targets()
1063                .contains(&cwd.join(".heimdall-local"))
1064        );
1065        std::fs::remove_dir_all(parent).expect("temp dir removed");
1066    }
1067
1068    #[test]
1069    fn deny_wins_over_writable() {
1070        let cwd = unique_dir("deny-wins");
1071        std::fs::write(cwd.join("data.txt"), "data").expect("file written");
1072        let policy = FilesystemPolicy::new(
1073            vec!["data.txt".to_string()],
1074            vec!["data.txt".to_string()],
1075            Default::default(),
1076        );
1077
1078        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1079            .materialize()
1080            .expect("policy materializes");
1081
1082        assert!(materialized.deny_targets().contains(&cwd.join("data.txt")));
1083        assert!(
1084            !materialized
1085                .writable_targets()
1086                .contains(&cwd.join("data.txt"))
1087        );
1088        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1089    }
1090
1091    #[test]
1092    fn external_absolute_writable_paths_are_added_directly() {
1093        let cwd = unique_dir("external-writable");
1094        // Create an external dir that exists outside CWD.
1095        let external = std::env::temp_dir().join("heimdall-external-writable-target");
1096        std::fs::create_dir_all(&external).expect("external dir created");
1097
1098        let policy = FilesystemPolicy::new(
1099            Vec::new(),
1100            vec![external.to_string_lossy().to_string()],
1101            Default::default(),
1102        );
1103
1104        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1105            .materialize()
1106            .expect("policy materializes");
1107
1108        assert!(
1109            materialized.writable_targets().contains(&external),
1110            "external absolute writable path should be added as a writable target"
1111        );
1112        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1113        std::fs::remove_dir_all(&external).expect("external dir removed");
1114    }
1115
1116    #[test]
1117    fn external_absolute_deny_paths_are_added_directly() {
1118        let cwd = unique_dir("external-deny");
1119        // Create an external dir that exists outside CWD.
1120        let external = std::env::temp_dir().join("heimdall-external-deny-target");
1121        std::fs::create_dir_all(&external).expect("external dir created");
1122
1123        let policy = FilesystemPolicy::new(
1124            vec![external.to_string_lossy().to_string()],
1125            Vec::new(),
1126            Default::default(),
1127        );
1128
1129        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1130            .materialize()
1131            .expect("policy materializes");
1132
1133        assert!(
1134            materialized.deny_targets().contains(&external),
1135            "external absolute deny path should be added as a deny target"
1136        );
1137        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1138        std::fs::remove_dir_all(&external).expect("external dir removed");
1139    }
1140
1141    #[test]
1142    fn later_absolute_deny_negation_removes_external_deny_target() {
1143        let cwd = unique_dir("external-deny-negation");
1144        let external = unique_dir("external-deny-negated-target");
1145        let policy = FilesystemPolicy::new(
1146            vec![
1147                external.to_string_lossy().to_string(),
1148                format!("!{}", external.display()),
1149            ],
1150            Vec::new(),
1151            Default::default(),
1152        );
1153
1154        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1155            .materialize()
1156            .expect("policy materializes");
1157
1158        assert!(!materialized.deny_targets().contains(&external));
1159        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1160        std::fs::remove_dir_all(external).expect("external dir removed");
1161    }
1162
1163    #[test]
1164    fn later_absolute_deny_negation_suppresses_missing_guard() {
1165        let cwd = unique_dir("missing-deny-negation");
1166        let writable = cwd.join("writable");
1167        std::fs::create_dir(&writable).expect("writable dir created");
1168        let missing = writable.join("missing-deny");
1169        let policy = FilesystemPolicy::new(
1170            vec![
1171                missing.to_string_lossy().to_string(),
1172                format!("!{}", missing.display()),
1173            ],
1174            vec![writable.to_string_lossy().to_string()],
1175            Default::default(),
1176        );
1177
1178        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1179            .materialize()
1180            .expect("policy materializes");
1181
1182        assert!(!materialized.deny_targets().contains(&missing));
1183        assert!(!materialized.missing_deny_guards().contains(&missing));
1184        assert!(!missing.exists());
1185        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1186    }
1187
1188    #[test]
1189    fn later_absolute_deny_negation_does_not_override_writable_target() {
1190        let cwd = unique_dir("deny-negation-writable");
1191        let external = unique_dir("deny-negation-writable-target");
1192        let policy = FilesystemPolicy::new(
1193            vec![
1194                external.to_string_lossy().to_string(),
1195                format!("!{}", external.display()),
1196            ],
1197            vec![external.to_string_lossy().to_string()],
1198            Default::default(),
1199        );
1200
1201        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1202            .materialize()
1203            .expect("policy materializes");
1204
1205        assert!(!materialized.deny_targets().contains(&external));
1206        assert!(materialized.writable_targets().contains(&external));
1207        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1208        std::fs::remove_dir_all(external).expect("external dir removed");
1209    }
1210
1211    #[test]
1212    fn negated_deny_child_covered_by_writable_is_not_readable_target() {
1213        let cwd = unique_dir("negated-writable-readable");
1214        let denied = cwd.join("denied");
1215        let writable = denied.join("child");
1216        std::fs::create_dir_all(&writable).expect("denied parent and writable child created");
1217        let policy = FilesystemPolicy::new(
1218            vec![
1219                denied.to_string_lossy().to_string(),
1220                format!("!{}", writable.display()),
1221            ],
1222            vec![writable.to_string_lossy().to_string()],
1223            Default::default(),
1224        );
1225        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1226            .materialize()
1227            .expect("policy materializes");
1228
1229        assert!(materialized.writable_targets().contains(&writable));
1230        assert!(
1231            !materialized.readable_targets().contains(&writable),
1232            "a writable target must never also become a read-only corridor"
1233        );
1234        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1235    }
1236
1237    #[test]
1238    fn longer_writable_path_wins_over_denied_parent() {
1239        let cwd = unique_dir("writable-wins-external");
1240        let external = std::env::temp_dir().join("heimdall-external-writable-wins-parent");
1241        let writable = external.join("writable");
1242        std::fs::create_dir_all(&writable).expect("external dirs created");
1243
1244        let policy = FilesystemPolicy::new(
1245            vec![external.to_string_lossy().to_string()],
1246            vec![writable.to_string_lossy().to_string()],
1247            Default::default(),
1248        );
1249
1250        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1251            .materialize()
1252            .expect("policy materializes");
1253
1254        assert!(materialized.deny_targets().contains(&external));
1255        assert!(materialized.writable_targets().contains(&writable));
1256        assert!(!materialized.deny_targets().contains(&writable));
1257        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1258        std::fs::remove_dir_all(&external).expect("external dir removed");
1259    }
1260
1261    #[test]
1262    fn longer_deny_path_wins_over_writable_parent() {
1263        let cwd = unique_dir("deny-wins-external");
1264        let external = std::env::temp_dir().join("heimdall-external-deny-wins-parent");
1265        let secret = external.join("secret");
1266        std::fs::create_dir_all(&secret).expect("external dirs created");
1267
1268        let policy = FilesystemPolicy::new(
1269            vec![secret.to_string_lossy().to_string()],
1270            vec![external.to_string_lossy().to_string()],
1271            Default::default(),
1272        );
1273
1274        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1275            .materialize()
1276            .expect("policy materializes");
1277
1278        assert!(materialized.deny_targets().contains(&secret));
1279        assert!(materialized.writable_targets().contains(&external));
1280        assert!(!materialized.writable_targets().contains(&secret));
1281        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1282        std::fs::remove_dir_all(&external).expect("external dir removed");
1283    }
1284
1285    #[test]
1286    fn tilde_patterns_expand_to_home_dir() {
1287        let cwd = unique_dir("tilde-expand");
1288        let home = home_dir().expect("home dir exists");
1289        // Use ~/something as a writable pattern.
1290        // We test against a real directory under home.
1291        let target = home.join(".config");
1292        if !target.is_dir() {
1293            // Skip if ~/.config doesn't exist on this system.
1294            std::fs::remove_dir_all(cwd).expect("temp dir removed");
1295            return;
1296        }
1297
1298        let policy = FilesystemPolicy::new(
1299            Vec::new(),
1300            vec!["~/.config".to_string()],
1301            Default::default(),
1302        );
1303
1304        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1305            .materialize()
1306            .expect("policy materializes");
1307
1308        assert!(
1309            materialized.writable_targets().contains(&target),
1310            "~/.config should expand and be added as a writable target"
1311        );
1312        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1313    }
1314    #[test]
1315    fn concrete_classifier_handles_absolute_missing_existing_and_tilde_inputs() {
1316        let cwd = unique_dir("concrete-classifier");
1317        let existing = cwd.join("exists");
1318        std::fs::write(&existing, "data").expect("file written");
1319        let missing = cwd.join("missing");
1320
1321        assert_eq!(
1322            concrete_path_state(&existing).expect("existing path classifies"),
1323            ConcretePathState::Existing
1324        );
1325        assert_eq!(
1326            concrete_path_state(&missing).expect("missing path classifies"),
1327            ConcretePathState::Missing
1328        );
1329        if let Some(home) = home_dir() {
1330            assert_eq!(
1331                concrete_path_state(&home).expect("home path classifies"),
1332                ConcretePathState::Existing
1333            );
1334        }
1335        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1336    }
1337
1338    #[cfg(unix)]
1339    #[test]
1340    fn final_component_symlinks_are_existing_even_when_dangling() {
1341        use std::os::unix::fs::symlink;
1342
1343        let cwd = unique_dir("concrete-symlink");
1344        let target = cwd.join("target");
1345        let link = cwd.join("link");
1346        let dangling = cwd.join("dangling");
1347        std::fs::write(&target, "data").expect("target written");
1348        symlink(&target, &link).expect("symlink created");
1349        symlink(cwd.join("absent"), &dangling).expect("dangling symlink created");
1350
1351        assert_eq!(
1352            concrete_path_state(&link).expect("link classifies"),
1353            ConcretePathState::Existing
1354        );
1355        assert_eq!(
1356            concrete_path_state(&dangling).expect("dangling link classifies"),
1357            ConcretePathState::Existing
1358        );
1359        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1360    }
1361
1362    #[test]
1363    fn missing_ancestor_classifies_requested_path_as_missing() {
1364        let cwd = unique_dir("missing-ancestor");
1365        let requested = cwd.join("absent").join("child");
1366
1367        assert_eq!(
1368            concrete_path_state(&requested).expect("path classifies"),
1369            ConcretePathState::Missing
1370        );
1371        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1372    }
1373
1374    #[cfg(unix)]
1375    #[test]
1376    fn symlink_ancestor_canonicalization_failure_is_indeterminate() {
1377        use std::os::unix::fs::symlink;
1378
1379        let cwd = unique_dir("indeterminate-ancestor");
1380        let dangling = cwd.join("dangling-parent");
1381        symlink(cwd.join("absent"), &dangling).expect("dangling symlink created");
1382        let requested = dangling.join("child");
1383
1384        assert!(matches!(
1385            concrete_path_state(&requested),
1386            Err(Error::IndeterminatePath { .. })
1387        ));
1388        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1389    }
1390
1391    #[test]
1392    fn missing_literal_writable_and_readable_targets_are_skipped() {
1393        let cwd = unique_dir("missing-writable-readable");
1394        let missing_writable = cwd.join("missing-write");
1395        let missing_readable = cwd.join("missing-read");
1396        let policy = FilesystemPolicy::new(
1397            vec![format!("!{}", missing_readable.display())],
1398            vec![missing_writable.to_string_lossy().to_string()],
1399            Default::default(),
1400        );
1401
1402        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1403            .materialize()
1404            .expect("policy materializes");
1405
1406        assert!(!materialized.writable_targets().contains(&missing_writable));
1407        assert!(!materialized.readable_targets().contains(&missing_readable));
1408        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1409    }
1410
1411    #[cfg(unix)]
1412    #[test]
1413    fn indeterminate_restored_readonly_target_fails_materialization() {
1414        use std::os::unix::fs::symlink;
1415
1416        let cwd = unique_dir("indeterminate-readable");
1417        let dangling = cwd.join("dangling-parent");
1418        symlink(cwd.join("absent"), &dangling).expect("dangling symlink created");
1419        let restored = dangling.join("child");
1420        let policy = FilesystemPolicy::new(
1421            vec![
1422                cwd.to_string_lossy().to_string(),
1423                format!("!{}", restored.display()),
1424            ],
1425            Vec::new(),
1426            Default::default(),
1427        );
1428
1429        let result = FilesystemPolicyMaterializer::new(&cwd, &policy).materialize();
1430
1431        assert!(matches!(result, Err(Error::IndeterminatePath { .. })));
1432        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1433    }
1434
1435    #[test]
1436    fn missing_denies_are_skipped_unless_covered_by_existing_writable_directory() {
1437        let cwd = unique_dir("missing-deny-guards");
1438        let writable = cwd.join("writable");
1439        std::fs::create_dir(&writable).expect("writable dir created");
1440        let guarded = writable.join("missing");
1441        let skipped = cwd.join("outside-missing");
1442        let policy = FilesystemPolicy::new(
1443            vec![
1444                guarded.to_string_lossy().to_string(),
1445                skipped.to_string_lossy().to_string(),
1446            ],
1447            vec![writable.to_string_lossy().to_string()],
1448            Default::default(),
1449        );
1450
1451        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1452            .materialize()
1453            .expect("policy materializes");
1454
1455        assert!(materialized.writable_targets().contains(&writable));
1456        assert!(!materialized.deny_targets().contains(&guarded));
1457        assert!(!materialized.deny_targets().contains(&skipped));
1458        assert!(materialized.missing_deny_guards().contains(&guarded));
1459        assert!(!materialized.missing_deny_guards().contains(&skipped));
1460        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1461    }
1462
1463    #[test]
1464    fn literal_absolute_paths_under_cwd_are_concrete_before_pattern_matching() {
1465        let cwd = unique_dir("absolute-under-cwd");
1466        let missing = cwd.join("missing");
1467        let policy = FilesystemPolicy::new(
1468            vec![missing.to_string_lossy().to_string()],
1469            vec![".".to_string()],
1470            Default::default(),
1471        );
1472
1473        let materialized = FilesystemPolicyMaterializer::new(&cwd, &policy)
1474            .materialize()
1475            .expect("policy materializes");
1476
1477        assert!(materialized.writable_targets().contains(&cwd));
1478        assert!(materialized.missing_deny_guards().contains(&missing));
1479        assert!(!missing.exists());
1480        std::fs::remove_dir_all(cwd).expect("temp dir removed");
1481    }
1482}