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/// Lexical path syntax used by a configuration value.
27///
28/// This is separate from the compiling host target so a portable
29/// configuration can validate a Windows overlay while it is read on Linux or
30/// macOS.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
32pub enum PathDialect {
33    /// POSIX path syntax used by Linux and macOS.
34    Posix,
35    /// Windows drive, UNC, and separator syntax.
36    Windows,
37}
38
39impl PathDialect {
40    /// Returns the dialect of the compiling host.
41    pub const fn native() -> Self {
42        #[cfg(target_os = "windows")]
43        {
44            Self::Windows
45        }
46        #[cfg(not(target_os = "windows"))]
47        {
48            Self::Posix
49        }
50    }
51}
52
53/// A hashable lexical path identity for an explicitly selected dialect.
54///
55/// Unlike [`NativePathKey`], this key does not use the compiling host target.
56/// It is intended for portable configuration and profile merging. It does
57/// not inspect the filesystem or resolve links.
58#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
59pub struct PlatformPathKey {
60    dialect: PathDialect,
61    components: Vec<PlatformComponentKey>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
65enum PlatformComponentKey {
66    Prefix(String),
67    Root,
68    Parent,
69    Normal(String),
70}
71
72impl PlatformPathKey {
73    /// Creates a lexical identity using the explicitly selected dialect.
74    pub fn new(value: &str, dialect: PathDialect) -> Self {
75        Self {
76            dialect,
77            components: lexical_components(value, dialect),
78        }
79    }
80}
81
82/// Returns whether `value` is absolute according to `dialect`.
83pub fn is_absolute_text(value: &str, dialect: PathDialect) -> bool {
84    match dialect {
85        PathDialect::Posix => value.starts_with('/'),
86        PathDialect::Windows => {
87            let bytes = value.as_bytes();
88            value.starts_with("\\\\")
89                || value.starts_with("//")
90                || (bytes.len() >= 3
91                    && bytes[0].is_ascii_alphabetic()
92                    && bytes[1] == b':'
93                    && matches!(bytes[2], b'/' | b'\\'))
94        }
95    }
96}
97
98/// Returns whether `value` contains a literal parent traversal component.
99pub fn contains_parent_traversal_text(value: &str, dialect: PathDialect) -> bool {
100    let is_separator = |character: char| match dialect {
101        PathDialect::Posix => character == '/',
102        PathDialect::Windows => matches!(character, '/' | '\\'),
103    };
104    let mut component = String::new();
105    for character in value.chars().chain(std::iter::once('/')) {
106        if is_separator(character) {
107            if component == ".." {
108                return true;
109            }
110            component.clear();
111        } else {
112            component.push(character);
113        }
114    }
115    false
116}
117
118fn lexical_components(value: &str, dialect: PathDialect) -> Vec<PlatformComponentKey> {
119    match dialect {
120        PathDialect::Posix => lexical_posix_components(value),
121        PathDialect::Windows => lexical_windows_components(value),
122    }
123}
124
125fn lexical_posix_components(value: &str) -> Vec<PlatformComponentKey> {
126    let mut components = Vec::new();
127    if value.starts_with('/') {
128        components.push(PlatformComponentKey::Root);
129    }
130    for component in value.split('/') {
131        match component {
132            "" | "." => {}
133            ".." => components.push(PlatformComponentKey::Parent),
134            value => components.push(PlatformComponentKey::Normal(value.to_owned())),
135        }
136    }
137    components
138}
139
140fn lexical_windows_components(value: &str) -> Vec<PlatformComponentKey> {
141    let mut value = value.replace('\\', "/");
142    if value
143        .get(..8)
144        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/unc/"))
145        || value
146            .get(..8)
147            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//./unc/"))
148    {
149        value = format!("//{}", &value[8..]);
150    } else if value
151        .get(..4)
152        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/"))
153        || value
154            .get(..4)
155            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//./"))
156    {
157        value = value[4..].to_owned();
158    }
159
160    let mut components = Vec::new();
161    let mut parts = value.split('/');
162    if let Some(first) = parts.next()
163        && first.len() == 2
164        && first.as_bytes()[1] == b':'
165        && first.as_bytes()[0].is_ascii_alphabetic()
166    {
167        components.push(PlatformComponentKey::Prefix(first.to_ascii_lowercase()));
168        if value.as_bytes().get(2) == Some(&b'/') {
169            components.push(PlatformComponentKey::Root);
170        }
171    } else {
172        if value.starts_with("//") {
173            components.push(PlatformComponentKey::Root);
174            components.push(PlatformComponentKey::Prefix("unc".to_owned()));
175        }
176        parts = value.split('/');
177    }
178
179    for component in parts {
180        match component {
181            "" | "." => {}
182            value
183                if value.len() == 2
184                    && value.as_bytes()[1] == b':'
185                    && value.as_bytes()[0].is_ascii_alphabetic()
186                    && components
187                        .iter()
188                        .any(|entry| matches!(entry, PlatformComponentKey::Prefix(_))) => {}
189            ".." => components.push(PlatformComponentKey::Parent),
190            value => components.push(PlatformComponentKey::Normal(value.to_lowercase())),
191        }
192    }
193    components
194}
195
196/// A hashable and orderable lexical path identity using native case rules.
197///
198/// The key is useful when another crate needs a map or set whose identity must
199/// agree with [`paths_equal`]. It does not canonicalize the filesystem or
200/// resolve links.
201#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
202pub struct NativePathKey(Vec<native::NativeComponentKey>);
203
204impl NativePathKey {
205    /// Creates a native lexical key for `path`.
206    pub fn new(path: &Path) -> Self {
207        let path = normalize_lexical_path(path);
208        Self(
209            path.components()
210                .filter(|component| *component != Component::CurDir)
211                .map(native::component_key)
212                .collect(),
213        )
214    }
215}
216
217/// Returns whether a path contains a lexical parent traversal component.
218pub fn contains_parent_traversal(path: &Path) -> bool {
219    path.components()
220        .any(|component| component == Component::ParentDir)
221}
222
223/// The lexical validation failures returned by [`resolve_lexical_path`].
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum PathResolutionError {
226    /// The declaration is empty.
227    Empty,
228    /// The declaration contains a NUL byte.
229    ContainsNul,
230    /// The declaration contains a parent traversal component.
231    ParentTraversal,
232}
233
234impl std::fmt::Display for PathResolutionError {
235    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        let message = match self {
237            Self::Empty => "path declaration is empty",
238            Self::ContainsNul => "path declaration contains NUL",
239            Self::ParentTraversal => "path declaration contains parent traversal",
240        };
241        formatter.write_str(message)
242    }
243}
244
245impl std::error::Error for PathResolutionError {}
246
247/// Resolves a relative-or-absolute declaration against `base` lexically.
248///
249/// The declaration is validated before joining, and the result removes only
250/// current-directory components. It does not access the filesystem, resolve
251/// symlinks, or canonicalize the result.
252pub fn resolve_lexical_path(
253    base: &Path,
254    declaration: &Path,
255) -> Result<PathBuf, PathResolutionError> {
256    if declaration.as_os_str().is_empty() {
257        return Err(PathResolutionError::Empty);
258    }
259    if declaration.as_os_str().to_string_lossy().contains('\0') {
260        return Err(PathResolutionError::ContainsNul);
261    }
262    if contains_parent_traversal(declaration) {
263        return Err(PathResolutionError::ParentTraversal);
264    }
265    let resolved = if declaration.is_absolute() {
266        declaration.to_path_buf()
267    } else {
268        base.join(declaration)
269    };
270    Ok(normalize_lexical_path(&resolved).into_owned())
271}
272
273/// Normalizes lexical aliases that the target platform treats as the same path.
274///
275/// This removes current-directory components without resolving parent
276/// traversal. On Windows it also converts supported verbatim/device drive and
277/// UNC prefixes to their ordinary spelling. Unsupported device namespaces are
278/// otherwise preserved. The function performs no filesystem I/O.
279pub fn normalize_lexical_path(path: &Path) -> Cow<'_, Path> {
280    #[cfg(windows)]
281    let path = native::normalize_windows_device_path(path);
282    #[cfg(not(windows))]
283    let path = Cow::Borrowed(path);
284
285    let normalized = path
286        .components()
287        .filter(|component| *component != Component::CurDir)
288        .collect::<PathBuf>();
289    if normalized.as_os_str() == path.as_os_str() {
290        path
291    } else {
292        Cow::Owned(normalized)
293    }
294}
295
296/// Compares two complete paths using the target platform's path case rules.
297pub fn paths_equal(left: &Path, right: &Path) -> bool {
298    #[cfg(windows)]
299    {
300        NativePathKey::new(left) == NativePathKey::new(right)
301    }
302    #[cfg(not(windows))]
303    let mut left = left
304        .components()
305        .filter(|component| *component != Component::CurDir);
306    #[cfg(not(windows))]
307    let mut right = right
308        .components()
309        .filter(|component| *component != Component::CurDir);
310    #[cfg(not(windows))]
311    loop {
312        match (left.next(), right.next()) {
313            (None, None) => return true,
314            (Some(left), Some(right)) if components_equal(left, right) => {}
315            _ => return false,
316        }
317    }
318}
319
320/// Returns whether `path` is the same as or below `root` by path component.
321///
322/// Parent traversal fails closed. An empty or current-directory relative root
323/// contains relative descendants, but never an absolute or drive-qualified
324/// path.
325pub fn is_within(path: &Path, root: &Path) -> bool {
326    if contains_parent_traversal(path) || contains_parent_traversal(root) {
327        return false;
328    }
329    #[cfg(windows)]
330    {
331        let path = NativePathKey::new(path);
332        let root = NativePathKey::new(root);
333        if root.0.is_empty() {
334            return !matches!(
335                path.0.first(),
336                Some(native::NativeComponentKey::Prefix(_) | native::NativeComponentKey::RootDir)
337            );
338        }
339        path.0.starts_with(&root.0)
340    }
341    #[cfg(not(windows))]
342    let mut path = path
343        .components()
344        .filter(|component| *component != Component::CurDir);
345    #[cfg(not(windows))]
346    let mut root = root
347        .components()
348        .filter(|component| *component != Component::CurDir);
349    #[cfg(not(windows))]
350    if root.clone().next().is_none() {
351        return path.clone().next() != Some(Component::RootDir);
352    }
353    #[cfg(not(windows))]
354    loop {
355        match (root.next(), path.next()) {
356            (None, _) => return true,
357            (Some(root), Some(path)) if components_equal(path, root) => {}
358            (Some(_), _) => return false,
359        }
360    }
361}
362
363/// Returns whether `path` contains `needle` as a contiguous component path.
364///
365/// This is useful for relative metadata protections such as `.git` or
366/// `.cache`. The comparison uses the same native component semantics as
367/// [`paths_equal`] and [`is_within`]. It is lexical only and does not inspect
368/// the filesystem.
369pub fn contains_component_path(path: &Path, needle: &Path) -> bool {
370    #[cfg(windows)]
371    {
372        let path = NativePathKey::new(path);
373        let needle = NativePathKey::new(needle);
374        !needle.0.is_empty()
375            && needle.0.len() <= path.0.len()
376            && path
377                .0
378                .windows(needle.0.len())
379                .any(|window| window == needle.0)
380    }
381    #[cfg(not(windows))]
382    let path_components: Vec<_> = path
383        .components()
384        .filter(|component| *component != Component::CurDir)
385        .collect();
386    #[cfg(not(windows))]
387    let needle_components: Vec<_> = needle
388        .components()
389        .filter(|component| *component != Component::CurDir)
390        .collect();
391    #[cfg(not(windows))]
392    if needle_components.is_empty() || needle_components.len() > path_components.len() {
393        return false;
394    }
395    #[cfg(not(windows))]
396    path_components
397        .windows(needle_components.len())
398        .any(|window| {
399            window
400                .iter()
401                .zip(&needle_components)
402                .all(|(left, right)| components_equal(*left, *right))
403        })
404}
405
406/// Compares two path components with the target platform's path case rules.
407pub fn components_equal(left: Component<'_>, right: Component<'_>) -> bool {
408    native::components_equal(left, right)
409}
410
411/// Compares path-component strings with the target platform's path case rules.
412pub fn strings_equal(left: &str, right: &str) -> bool {
413    #[cfg(windows)]
414    {
415        case_fold(left) == case_fold(right)
416    }
417    #[cfg(not(windows))]
418    {
419        left == right
420    }
421}
422
423/// Folds a string using the target platform's path comparison case rules.
424pub fn case_fold(value: &str) -> String {
425    #[cfg(windows)]
426    {
427        value.to_lowercase()
428    }
429    #[cfg(not(windows))]
430    {
431        value.to_owned()
432    }
433}