1use 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
10pub const DENY_FRAGMENT: &str = ".heimdall-deny";
12pub const WRITE_FRAGMENT: &str = ".heimdall-write";
14
15pub type Result<T> = std::result::Result<T, Error>;
17
18#[derive(Debug, ThisError)]
20pub enum Error {
21 #[error("invalid filesystem pattern {pattern:?}: {source}")]
23 InvalidPattern {
24 pattern: String,
26 #[source]
28 source: ignore::Error,
29 },
30 #[error("failed to inspect filesystem fragment {}: {source}", path.display())]
32 FragmentStatus {
33 path: PathBuf,
35 #[source]
37 source: std::io::Error,
38 },
39 #[error("invalid filesystem fragment {}: {source}", path.display())]
41 InvalidFragment {
42 path: PathBuf,
44 #[source]
46 source: ignore::Error,
47 },
48 #[error("invalid filesystem matcher for {fragment}: {source}")]
50 InvalidMatcher {
51 fragment: String,
53 #[source]
55 source: ignore::Error,
56 },
57 #[error("failed to walk {}: {source}", cwd.display())]
59 Walk {
60 cwd: PathBuf,
62 #[source]
64 source: ignore::Error,
65 },
66 #[error("failed to relativize {} against {}: {source}", path.display(), cwd.display())]
68 Relativize {
69 path: PathBuf,
71 cwd: PathBuf,
73 #[source]
75 source: std::path::StripPrefixError,
76 },
77 #[error("filesystem.virtual target {} must be absolute", path.display())]
79 RelativeVirtualTarget {
80 path: PathBuf,
82 },
83 #[error("failed to read {}: {source}", cwd.display())]
85 ReadDir {
86 cwd: PathBuf,
88 #[source]
90 source: std::io::Error,
91 },
92 #[error("failed to read entry in {}: {source}", cwd.display())]
94 ReadEntry {
95 cwd: PathBuf,
97 #[source]
99 source: std::io::Error,
100 },
101 #[error("failed to determine whether {} exists: {source}", path.display())]
103 IndeterminatePath {
104 path: PathBuf,
106 #[source]
108 source: std::io::Error,
109 },
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum NetworkMode {
115 Host,
117 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum ProcMode {
133 Default,
135 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#[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 #[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 #[must_use]
169 pub const fn is_empty(&self) -> bool {
170 !self.ssh_agent && !self.gpg_agent && !self.age_agent
171 }
172
173 #[must_use]
175 pub const fn ssh_agent(&self) -> bool {
176 self.ssh_agent
177 }
178
179 #[must_use]
181 pub const fn gpg_agent(&self) -> bool {
182 self.gpg_agent
183 }
184
185 #[must_use]
187 pub const fn age_agent(&self) -> bool {
188 self.age_agent
189 }
190}
191
192#[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 #[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 #[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 #[must_use]
223 pub fn deny(&self) -> &[String] {
224 &self.deny
225 }
226
227 #[must_use]
229 pub fn writable(&self) -> &[String] {
230 &self.writable
231 }
232
233 #[must_use]
235 pub fn virtual_files(&self) -> &BTreeMap<PathBuf, String> {
236 &self.virtual_files
237 }
238}
239
240#[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 #[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 #[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 #[must_use]
283 pub fn deny_targets(&self) -> &BTreeSet<PathBuf> {
284 &self.deny_targets
285 }
286
287 #[must_use]
289 pub fn writable_targets(&self) -> &BTreeSet<PathBuf> {
290 &self.writable_targets
291 }
292
293 #[must_use]
295 pub fn protected_targets(&self) -> &BTreeSet<PathBuf> {
296 &self.protected_targets
297 }
298
299 #[must_use]
301 pub fn readable_targets(&self) -> &BTreeSet<PathBuf> {
302 &self.readable_targets
303 }
304
305 #[must_use]
307 pub fn missing_deny_guards(&self) -> &BTreeSet<PathBuf> {
308 &self.missing_deny_guards
309 }
310
311 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum ConcretePathState {
325 Existing,
327 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
341pub 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(¤t) {
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
380pub struct FilesystemPolicyMaterializer<'a> {
382 cwd: &'a Path,
383 policy: &'a FilesystemPolicy,
384}
385
386impl<'a> FilesystemPolicyMaterializer<'a> {
387 #[must_use]
389 pub const fn new(cwd: &'a Path, policy: &'a FilesystemPolicy) -> Self {
390 Self { cwd, policy }
391 }
392
393 pub fn materialize(self) -> Result<MaterializedFilesystemPolicy> {
399 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 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 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 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 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 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 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
795pub 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
813pub 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#[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#[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 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 assert!(materialized.writable_targets().contains(&cwd));
1058 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 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 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 let target = home.join(".config");
1292 if !target.is_dir() {
1293 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}