Skip to main content

cageforge_policy/
path.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Validated filesystem selectors and policy globs.
4//!
5//! [`crate::PathSelector`] represents a concrete or symbolic filesystem scope;
6//! [`crate::PathPattern`] represents a validated deny-glob. Shared lexical
7//! identity comes from [`cageforge_path`], while policy-specific glob access
8//! remains in this module.
9
10use crate::PathResolutionContext;
11use crate::PolicyError;
12use cageforge_path::{
13    NativePathKey, case_fold, contains_parent_traversal, is_within, normalize_lexical_path,
14    paths_equal, strings_equal,
15};
16use globset::{GlobBuilder, GlobMatcher};
17use std::cmp::Ordering;
18use std::hash::{Hash, Hasher};
19use std::path::Component;
20use std::path::Path;
21use std::path::PathBuf;
22
23/// A platform-independent description of a filesystem scope.
24#[derive(Debug, Clone)]
25pub struct PathSelector {
26    kind: PathSelectorKind,
27}
28
29#[derive(Debug, Clone)]
30enum PathSelectorKind {
31    Absolute(PathBuf),
32    WorkspaceRoot(PathBuf),
33    Root,
34    Minimal,
35    Tmpdir,
36    SlashTmp,
37}
38
39/// A validated path glob used by a filesystem rule.
40#[derive(Debug, Clone)]
41pub struct PathPattern {
42    raw: String,
43    absolute: bool,
44    prefix: Option<String>,
45    components: Vec<String>,
46    matchers: Vec<GlobMatcher>,
47}
48
49impl PartialEq for PathSelector {
50    fn eq(&self, other: &Self) -> bool {
51        selectors_equal(self, other)
52    }
53}
54
55impl Eq for PathSelector {}
56
57impl Hash for PathSelector {
58    fn hash<H: Hasher>(&self, state: &mut H) {
59        selector_kind_rank(&self.kind).hash(state);
60        if let Some(path) = self.path() {
61            NativePathKey::new(path).hash(state);
62        }
63    }
64}
65
66impl PartialOrd for PathSelector {
67    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
68        Some(self.cmp(other))
69    }
70}
71
72impl Ord for PathSelector {
73    fn cmp(&self, other: &Self) -> Ordering {
74        let kind_order = selector_kind_rank(&self.kind).cmp(&selector_kind_rank(&other.kind));
75        if kind_order != Ordering::Equal {
76            return kind_order;
77        }
78        match (self.path(), other.path()) {
79            (Some(left), Some(right)) => NativePathKey::new(left).cmp(&NativePathKey::new(right)),
80            (None, None) => Ordering::Equal,
81            _ => Ordering::Equal,
82        }
83    }
84}
85
86impl PathSelector {
87    /// Creates an absolute selector without touching the filesystem.
88    pub fn absolute(path: impl Into<PathBuf>) -> Result<Self, PolicyError> {
89        let path = path.into();
90        if path.as_os_str().is_empty() {
91            return Err(PolicyError::EmptyPath);
92        }
93        if contains_nul(&path) {
94            return Err(PolicyError::PathContainsNul { path });
95        }
96        if !path.is_absolute() {
97            return Err(PolicyError::ExpectedAbsolute { path });
98        }
99        if contains_parent_traversal(&path) {
100            return Err(PolicyError::ParentTraversal { path });
101        }
102        Ok(Self {
103            kind: PathSelectorKind::Absolute(path),
104        })
105    }
106
107    /// Creates a selector for the workspace root itself.
108    pub fn workspace_root() -> Self {
109        Self {
110            kind: PathSelectorKind::WorkspaceRoot(PathBuf::from(".")),
111        }
112    }
113
114    /// Creates a selector for every system root supplied by the runtime.
115    ///
116    /// The selector is symbolic. A backend must populate
117    /// [`PathResolutionContext::with_root`] for the target platform.
118    pub const fn root() -> Self {
119        Self {
120            kind: PathSelectorKind::Root,
121        }
122    }
123
124    /// Creates a selector relative to every workspace root.
125    pub fn workspace(relative: impl Into<PathBuf>) -> Result<Self, PolicyError> {
126        let relative = relative.into();
127        let normalized = normalize_relative_path(relative.clone())?;
128        Ok(Self {
129            kind: PathSelectorKind::WorkspaceRoot(normalized),
130        })
131    }
132
133    /// Creates the platform-minimal runtime scope.
134    pub const fn minimal() -> Self {
135        Self {
136            kind: PathSelectorKind::Minimal,
137        }
138    }
139
140    /// Creates the platform temporary-directory scope.
141    pub const fn tmpdir() -> Self {
142        Self {
143            kind: PathSelectorKind::Tmpdir,
144        }
145    }
146
147    /// Creates the conventional `/tmp` scope.
148    pub const fn slash_tmp() -> Self {
149        Self {
150            kind: PathSelectorKind::SlashTmp,
151        }
152    }
153
154    /// Resolves this selector against a caller-provided runtime context.
155    pub fn resolve(&self, context: &PathResolutionContext) -> Vec<PathBuf> {
156        match &self.kind {
157            PathSelectorKind::Absolute(path) => vec![path.clone()],
158            PathSelectorKind::WorkspaceRoot(relative) => context
159                .workspace_roots()
160                .iter()
161                .map(|root| root.join(relative))
162                .collect(),
163            PathSelectorKind::Root => context.root_paths().to_vec(),
164            PathSelectorKind::Minimal => context.minimal_paths().to_vec(),
165            PathSelectorKind::Tmpdir => context
166                .tmpdir()
167                .into_iter()
168                .map(Path::to_path_buf)
169                .collect(),
170            PathSelectorKind::SlashTmp => context
171                .slash_tmp()
172                .into_iter()
173                .map(Path::to_path_buf)
174                .collect(),
175        }
176    }
177
178    /// Returns the stored path for an absolute or workspace-relative selector.
179    pub fn path(&self) -> Option<&Path> {
180        match &self.kind {
181            PathSelectorKind::Absolute(path) | PathSelectorKind::WorkspaceRoot(path) => Some(path),
182            PathSelectorKind::Root
183            | PathSelectorKind::Minimal
184            | PathSelectorKind::Tmpdir
185            | PathSelectorKind::SlashTmp => None,
186        }
187    }
188
189    /// Returns whether this selector is a special platform-defined scope.
190    pub const fn is_special(&self) -> bool {
191        matches!(
192            &self.kind,
193            PathSelectorKind::Root
194                | PathSelectorKind::Minimal
195                | PathSelectorKind::Tmpdir
196                | PathSelectorKind::SlashTmp
197        )
198    }
199
200    /// Returns whether this selector resolves relative to runtime workspace
201    /// roots.
202    pub const fn is_workspace_scope(&self) -> bool {
203        matches!(&self.kind, PathSelectorKind::WorkspaceRoot(_))
204    }
205
206    /// Returns whether this selector targets caller-supplied system roots.
207    pub const fn is_root_scope(&self) -> bool {
208        matches!(&self.kind, PathSelectorKind::Root)
209    }
210
211    /// Returns whether this selector targets the platform-minimal scope.
212    pub const fn is_minimal_scope(&self) -> bool {
213        matches!(&self.kind, PathSelectorKind::Minimal)
214    }
215
216    /// Returns whether this selector targets the platform temporary directory.
217    pub const fn is_tmpdir_scope(&self) -> bool {
218        matches!(&self.kind, PathSelectorKind::Tmpdir)
219    }
220
221    /// Returns whether this selector targets the conventional `/tmp` scope.
222    pub const fn is_slash_tmp_scope(&self) -> bool {
223        matches!(&self.kind, PathSelectorKind::SlashTmp)
224    }
225
226    /// Returns whether this selector stores a native absolute path.
227    pub const fn is_absolute_scope(&self) -> bool {
228        matches!(&self.kind, PathSelectorKind::Absolute(_))
229    }
230
231    /// Returns the number of concrete path components represented by this
232    /// selector after resolution.
233    pub(crate) fn is_definitely_outside(&self, parent: &Self) -> bool {
234        match (&self.kind, &parent.kind) {
235            (PathSelectorKind::Absolute(child), PathSelectorKind::Absolute(parent))
236            | (PathSelectorKind::WorkspaceRoot(child), PathSelectorKind::WorkspaceRoot(parent)) => {
237                !is_within(child, parent)
238            }
239            _ => false,
240        }
241    }
242}
243
244impl PartialEq for PathPattern {
245    fn eq(&self, other: &Self) -> bool {
246        self.semantic_key() == other.semantic_key()
247    }
248}
249
250impl Eq for PathPattern {}
251
252impl Hash for PathPattern {
253    fn hash<H: Hasher>(&self, state: &mut H) {
254        self.semantic_key().hash(state);
255    }
256}
257
258impl PartialOrd for PathPattern {
259    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
260        Some(self.cmp(other))
261    }
262}
263
264impl Ord for PathPattern {
265    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
266        self.semantic_key().cmp(&other.semantic_key())
267    }
268}
269
270impl PathPattern {
271    /// Creates a glob rooted at a native absolute path.
272    pub fn absolute(pattern: impl Into<String>) -> Result<Self, PolicyError> {
273        Self::new(pattern.into(), true)
274    }
275
276    /// Creates a glob relative to every workspace root in the context.
277    pub fn workspace(pattern: impl Into<String>) -> Result<Self, PolicyError> {
278        Self::new(pattern.into(), false)
279    }
280
281    /// Returns the original normalized pattern text.
282    pub fn as_str(&self) -> &str {
283        &self.raw
284    }
285
286    /// Returns whether this pattern is rooted at an absolute path.
287    pub const fn is_absolute(&self) -> bool {
288        self.absolute
289    }
290
291    /// Returns the static path prefix before the first glob component.
292    ///
293    /// Absolute patterns return an absolute prefix. Workspace patterns return
294    /// a relative prefix that must be resolved through the effective runtime
295    /// workspace roots. An empty workspace prefix means the workspace root;
296    /// an absolute root-only prefix must be rejected by backends that cannot
297    /// safely scan the complete filesystem.
298    pub fn literal_prefix(&self) -> PathBuf {
299        let normalized = normalize_lexical_path(Path::new(&self.raw));
300        let mut prefix = PathBuf::new();
301        for component in normalized.components() {
302            match component {
303                Component::Prefix(value) => prefix.push(value.as_os_str()),
304                Component::RootDir => prefix.push(Path::new("/")),
305                Component::CurDir => {}
306                // Parent traversal is rejected during construction. Keep the
307                // prefix calculation defensive if a future constructor adds a
308                // new path source without reusing that validation.
309                Component::ParentDir => break,
310                Component::Normal(value) => {
311                    let value = value.to_string_lossy();
312                    if contains_glob_meta(&value) {
313                        break;
314                    }
315                    prefix.push(value.as_ref());
316                }
317            }
318        }
319        prefix
320    }
321
322    /// Tests this pattern against one path in a validated runtime context.
323    ///
324    /// This is pattern matching only, not a filesystem authorization result.
325    /// Callers enforcing an effective sandbox must still evaluate the complete
326    /// filesystem policy after expansion.
327    pub fn matches_path(&self, path: &Path, context: &PathResolutionContext) -> bool {
328        self.matches(path, context)
329    }
330
331    pub(crate) fn matches(&self, path: &Path, context: &PathResolutionContext) -> bool {
332        if self.absolute {
333            let (prefix, components) = path_components(path);
334            return path.is_absolute()
335                && prefixes_equal(prefix.as_deref(), self.prefix.as_deref())
336                && glob_components_match(&self.components, &self.matchers, &components);
337        }
338
339        context.workspace_roots().iter().any(|root| {
340            relative_path_components(path, root).is_some_and(|components| {
341                glob_components_match(&self.components, &self.matchers, &components)
342            })
343        })
344    }
345
346    pub(crate) fn specificity(&self) -> usize {
347        self.components
348            .iter()
349            .filter(|component| !contains_glob_meta(component))
350            .count()
351    }
352
353    pub(crate) fn semantic_key(&self) -> (bool, Option<String>, Vec<String>) {
354        (
355            self.absolute,
356            self.prefix.as_deref().map(case_fold),
357            self.components
358                .iter()
359                .map(|component| case_fold(component))
360                .collect(),
361        )
362    }
363
364    fn new(raw: String, absolute: bool) -> Result<Self, PolicyError> {
365        if raw.trim().is_empty() {
366            return Err(PolicyError::InvalidGlobPattern {
367                pattern: raw,
368                reason: "pattern cannot be empty".to_string(),
369            });
370        }
371        if raw.contains('\0') {
372            return Err(PolicyError::InvalidGlobPattern {
373                pattern: raw,
374                reason: "pattern cannot contain a NUL character".to_string(),
375            });
376        }
377        let normalized_path = normalize_lexical_path(Path::new(&raw));
378        let path = normalized_path.as_ref();
379        if absolute && !path.is_absolute() {
380            return Err(PolicyError::InvalidGlobPattern {
381                pattern: raw,
382                reason: "absolute glob must use a native absolute path".to_string(),
383            });
384        }
385        if !absolute && path.is_absolute() {
386            return Err(PolicyError::InvalidGlobPattern {
387                pattern: raw,
388                reason: "workspace glob must be relative".to_string(),
389            });
390        }
391
392        let mut components = Vec::new();
393        let mut matchers = Vec::new();
394        let mut prefix = None;
395        for component in path.components() {
396            match component {
397                Component::Prefix(value) => {
398                    prefix = Some(value.as_os_str().to_string_lossy().into_owned());
399                }
400                Component::RootDir => {}
401                Component::CurDir => {}
402                Component::ParentDir => {
403                    return Err(PolicyError::InvalidGlobPattern {
404                        pattern: raw,
405                        reason: "parent traversal is not allowed".to_string(),
406                    });
407                }
408                Component::Normal(value) => {
409                    let component = value.to_string_lossy();
410                    matchers.push(compile_glob_component(&component, &raw)?);
411                    components.push(component.into_owned());
412                }
413            }
414        }
415        if components.is_empty() {
416            return Err(PolicyError::InvalidGlobPattern {
417                pattern: raw,
418                reason: "pattern must contain at least one component".to_string(),
419            });
420        }
421        Ok(Self {
422            raw,
423            absolute,
424            prefix,
425            components,
426            matchers,
427        })
428    }
429}
430
431pub(crate) fn selectors_equal(left: &PathSelector, right: &PathSelector) -> bool {
432    match (&left.kind, &right.kind) {
433        (PathSelectorKind::Absolute(left), PathSelectorKind::Absolute(right))
434        | (PathSelectorKind::WorkspaceRoot(left), PathSelectorKind::WorkspaceRoot(right)) => {
435            paths_equal(left, right)
436        }
437        (PathSelectorKind::Root, PathSelectorKind::Root)
438        | (PathSelectorKind::Minimal, PathSelectorKind::Minimal)
439        | (PathSelectorKind::Tmpdir, PathSelectorKind::Tmpdir)
440        | (PathSelectorKind::SlashTmp, PathSelectorKind::SlashTmp) => true,
441        _ => false,
442    }
443}
444
445fn selector_kind_rank(kind: &PathSelectorKind) -> u8 {
446    match kind {
447        PathSelectorKind::Absolute(_) => 0,
448        PathSelectorKind::WorkspaceRoot(_) => 1,
449        PathSelectorKind::Root => 2,
450        PathSelectorKind::Minimal => 3,
451        PathSelectorKind::Tmpdir => 4,
452        PathSelectorKind::SlashTmp => 5,
453    }
454}
455
456fn normalize_relative_path(relative: PathBuf) -> Result<PathBuf, PolicyError> {
457    if relative.as_os_str().is_empty() {
458        return Err(PolicyError::EmptyPath);
459    }
460    if contains_nul(&relative) {
461        return Err(PolicyError::PathContainsNul { path: relative });
462    }
463    if relative.is_absolute() {
464        return Err(PolicyError::ExpectedRelative { path: relative });
465    }
466    let mut normalized = PathBuf::new();
467    for component in relative.components() {
468        match component {
469            Component::CurDir => {}
470            Component::Normal(part) => normalized.push(part),
471            Component::ParentDir => {
472                return Err(PolicyError::ParentTraversal { path: relative });
473            }
474            Component::RootDir | Component::Prefix(_) => {
475                return Err(PolicyError::ExpectedRelative { path: relative });
476            }
477        }
478    }
479    if normalized.as_os_str().is_empty() {
480        normalized.push(".");
481    }
482    Ok(normalized)
483}
484
485pub(crate) fn contains_nul(path: &Path) -> bool {
486    path.as_os_str().to_string_lossy().contains('\0')
487}
488
489fn path_components(path: &Path) -> (Option<String>, Vec<String>) {
490    let normalized_path = normalize_lexical_path(path);
491    let path = normalized_path.as_ref();
492    let mut prefix = None;
493    let mut components = Vec::new();
494    for component in path.components() {
495        match component {
496            Component::Prefix(value) => {
497                prefix = Some(value.as_os_str().to_string_lossy().into_owned());
498            }
499            Component::RootDir | Component::CurDir => {}
500            Component::ParentDir | Component::Normal(_) => {
501                if let Component::Normal(value) = component {
502                    components.push(value.to_string_lossy().into_owned());
503                }
504            }
505        }
506    }
507    (prefix, components)
508}
509
510pub(crate) fn normal_component_count(path: &Path) -> usize {
511    path.components()
512        .filter(|component| matches!(component, Component::Normal(_)))
513        .count()
514}
515
516fn relative_path_components(path: &Path, root: &Path) -> Option<Vec<String>> {
517    let (path_prefix, path_parts) = path_components(path);
518    let (root_prefix, root_components) = path_components(root);
519    if !prefixes_equal(path_prefix.as_deref(), root_prefix.as_deref())
520        || root_components.len() > path_parts.len()
521        || !root_components
522            .iter()
523            .zip(&path_parts)
524            .all(|(root, path)| strings_equal(path, root))
525    {
526        return None;
527    }
528    Some(path_parts[root_components.len()..].to_vec())
529}
530
531fn glob_components_match(pattern: &[String], matchers: &[GlobMatcher], path: &[String]) -> bool {
532    let mut current = vec![false; path.len() + 1];
533    let mut next = vec![false; path.len() + 1];
534    current[0] = true;
535    for (pattern_index, pattern_component) in pattern.iter().enumerate() {
536        next.fill(false);
537        if pattern_component == "**" {
538            next[0] = current[0];
539            for index in 1..=path.len() {
540                next[index] = current[index] || next[index - 1];
541            }
542        } else {
543            let matcher = &matchers[pattern_index];
544            for index in 1..=path.len() {
545                next[index] =
546                    current[index - 1] && glob_component_matches(matcher, &path[index - 1]);
547            }
548        }
549        std::mem::swap(&mut current, &mut next);
550    }
551    current[path.len()]
552}
553
554fn compile_glob_component(component: &str, pattern: &str) -> Result<GlobMatcher, PolicyError> {
555    // Apply the same native case fold used by PathPattern's Eq/Hash/Ord
556    // contract to both the glob and candidate components. globset's
557    // byte-oriented case-insensitive regex mode is ASCII-only, which would
558    // otherwise make Windows matching disagree with the collection identity
559    // for non-ASCII path names.
560    let component = case_fold(component);
561    let mut builder = GlobBuilder::new(&component);
562    builder
563        .case_insensitive(false)
564        .literal_separator(true)
565        .backslash_escape(false);
566    builder
567        .build()
568        .map(|glob| glob.compile_matcher())
569        .map_err(|error| PolicyError::InvalidGlobPattern {
570            pattern: pattern.to_string(),
571            reason: format!("invalid glob syntax: {error}"),
572        })
573}
574
575fn contains_glob_meta(component: &str) -> bool {
576    component
577        .chars()
578        .any(|character| matches!(character, '*' | '?' | '[' | ']' | '{' | '}'))
579}
580
581fn prefixes_equal(left: Option<&str>, right: Option<&str>) -> bool {
582    match (left, right) {
583        (Some(left), Some(right)) => strings_equal(left, right),
584        (None, None) => true,
585        _ => false,
586    }
587}
588
589fn glob_component_matches(matcher: &GlobMatcher, component: &str) -> bool {
590    #[cfg(windows)]
591    {
592        matcher.is_match(case_fold(component))
593    }
594    #[cfg(not(windows))]
595    {
596        matcher.is_match(component)
597    }
598}