Skip to main content

cageforge_path/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared native path comparison primitives for Cageforge.
4//!
5//! These helpers are lexical only. They do not inspect the filesystem or
6//! resolve symlinks; a native backend must perform those operations when its
7//! enforcement model requires them.
8//!
9//! # Reading this crate
10//!
11//! Use [`paths_equal`] and [`is_within`] for direct decisions, and
12//! [`NativePathKey`] when the same identity must be stored in a map or set.
13//! [`contains_parent_traversal`] validates a lexical input boundary, while
14//! [`normalize_lexical_path`] exposes supported Windows aliases. The policy,
15//! command, and configuration crates build their higher-level rules on these
16//! primitives.
17
18#![doc = include_str!("../README.md")]
19#![deny(missing_docs)]
20
21use std::borrow::Cow;
22use std::path::{Component, Path, PathBuf};
23
24mod native;
25
26/// A hashable and orderable lexical path identity using native case rules.
27///
28/// The key is useful when another crate needs a map or set whose identity must
29/// agree with [`paths_equal`]. It does not canonicalize the filesystem or
30/// resolve links.
31#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
32pub struct NativePathKey(Vec<native::NativeComponentKey>);
33
34impl NativePathKey {
35    /// Creates a native lexical key for `path`.
36    pub fn new(path: &Path) -> Self {
37        let path = normalize_lexical_path(path);
38        Self(
39            path.components()
40                .filter(|component| *component != Component::CurDir)
41                .map(native::component_key)
42                .collect(),
43        )
44    }
45}
46
47/// Returns whether a path contains a lexical parent traversal component.
48pub fn contains_parent_traversal(path: &Path) -> bool {
49    path.components()
50        .any(|component| component == Component::ParentDir)
51}
52
53/// Normalizes lexical aliases that the target platform treats as the same path.
54///
55/// This removes current-directory components without resolving parent
56/// traversal. On Windows it also converts supported verbatim/device drive and
57/// UNC prefixes to their ordinary spelling. Unsupported device namespaces are
58/// otherwise preserved. The function performs no filesystem I/O.
59pub fn normalize_lexical_path(path: &Path) -> Cow<'_, Path> {
60    #[cfg(windows)]
61    let path = native::normalize_windows_device_path(path);
62    #[cfg(not(windows))]
63    let path = Cow::Borrowed(path);
64
65    let normalized = path
66        .components()
67        .filter(|component| *component != Component::CurDir)
68        .collect::<PathBuf>();
69    if normalized.as_os_str() == path.as_os_str() {
70        path
71    } else {
72        Cow::Owned(normalized)
73    }
74}
75
76/// Compares two complete paths using the target platform's path case rules.
77pub fn paths_equal(left: &Path, right: &Path) -> bool {
78    #[cfg(windows)]
79    {
80        NativePathKey::new(left) == NativePathKey::new(right)
81    }
82    #[cfg(not(windows))]
83    let mut left = left
84        .components()
85        .filter(|component| *component != Component::CurDir);
86    #[cfg(not(windows))]
87    let mut right = right
88        .components()
89        .filter(|component| *component != Component::CurDir);
90    #[cfg(not(windows))]
91    loop {
92        match (left.next(), right.next()) {
93            (None, None) => return true,
94            (Some(left), Some(right)) if components_equal(left, right) => {}
95            _ => return false,
96        }
97    }
98}
99
100/// Returns whether `path` is the same as or below `root` by path component.
101///
102/// Parent traversal fails closed. An empty or current-directory relative root
103/// contains relative descendants, but never an absolute or drive-qualified
104/// path.
105pub fn is_within(path: &Path, root: &Path) -> bool {
106    if contains_parent_traversal(path) || contains_parent_traversal(root) {
107        return false;
108    }
109    #[cfg(windows)]
110    {
111        let path = NativePathKey::new(path);
112        let root = NativePathKey::new(root);
113        if root.0.is_empty() {
114            return !matches!(
115                path.0.first(),
116                Some(native::NativeComponentKey::Prefix(_) | native::NativeComponentKey::RootDir)
117            );
118        }
119        path.0.starts_with(&root.0)
120    }
121    #[cfg(not(windows))]
122    let mut path = path
123        .components()
124        .filter(|component| *component != Component::CurDir);
125    #[cfg(not(windows))]
126    let mut root = root
127        .components()
128        .filter(|component| *component != Component::CurDir);
129    #[cfg(not(windows))]
130    if root.clone().next().is_none() {
131        return path.clone().next() != Some(Component::RootDir);
132    }
133    #[cfg(not(windows))]
134    loop {
135        match (root.next(), path.next()) {
136            (None, _) => return true,
137            (Some(root), Some(path)) if components_equal(path, root) => {}
138            (Some(_), _) => return false,
139        }
140    }
141}
142
143/// Returns whether `path` contains `needle` as a contiguous component path.
144///
145/// This is useful for relative metadata protections such as `.git` or
146/// `.cache`. The comparison uses the same native component semantics as
147/// [`paths_equal`] and [`is_within`]. It is lexical only and does not inspect
148/// the filesystem.
149pub fn contains_component_path(path: &Path, needle: &Path) -> bool {
150    #[cfg(windows)]
151    {
152        let path = NativePathKey::new(path);
153        let needle = NativePathKey::new(needle);
154        !needle.0.is_empty()
155            && needle.0.len() <= path.0.len()
156            && path
157                .0
158                .windows(needle.0.len())
159                .any(|window| window == needle.0)
160    }
161    #[cfg(not(windows))]
162    let path_components: Vec<_> = path
163        .components()
164        .filter(|component| *component != Component::CurDir)
165        .collect();
166    #[cfg(not(windows))]
167    let needle_components: Vec<_> = needle
168        .components()
169        .filter(|component| *component != Component::CurDir)
170        .collect();
171    #[cfg(not(windows))]
172    if needle_components.is_empty() || needle_components.len() > path_components.len() {
173        return false;
174    }
175    #[cfg(not(windows))]
176    path_components
177        .windows(needle_components.len())
178        .any(|window| {
179            window
180                .iter()
181                .zip(&needle_components)
182                .all(|(left, right)| components_equal(*left, *right))
183        })
184}
185
186/// Compares two path components with the target platform's path case rules.
187pub fn components_equal(left: Component<'_>, right: Component<'_>) -> bool {
188    native::components_equal(left, right)
189}
190
191/// Compares path-component strings with the target platform's path case rules.
192pub fn strings_equal(left: &str, right: &str) -> bool {
193    #[cfg(windows)]
194    {
195        case_fold(left) == case_fold(right)
196    }
197    #[cfg(not(windows))]
198    {
199        left == right
200    }
201}
202
203/// Folds a string using the target platform's path comparison case rules.
204pub fn case_fold(value: &str) -> String {
205    #[cfg(windows)]
206    {
207        value.to_lowercase()
208    }
209    #[cfg(not(windows))]
210    {
211        value.to_owned()
212    }
213}