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