Skip to main content

qubit_fs/path/
relative_path.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8
9//! Safe normalized relative logical paths.
10
11use std::fmt::Display;
12use std::fmt::Formatter;
13use std::fmt::Result as FmtResult;
14
15use crate::error::FsError;
16use crate::error::FsOperation;
17use crate::error::FsResult;
18
19/// A non-empty normalized relative path that cannot escape its base.
20///
21/// # Examples
22///
23/// ```rust
24/// use qubit_fs::path::RelativePath;
25///
26/// let relative = RelativePath::parse("reports/2026")?;
27/// assert_eq!("reports/2026", relative.as_str());
28/// # Ok::<(), qubit_fs::FsError>(())
29/// ```
30#[derive(Clone, Debug, Eq, Hash, PartialEq)]
31pub struct RelativePath(
32    /// Normalized descendant path text.
33    String,
34);
35
36impl RelativePath {
37    /// Parses a normalized relative path.
38    ///
39    /// Returns an invalid-path error for empty or absolute input, NUL, or a
40    /// traversal sequence that escapes above the relative root.
41    ///
42    /// # Parameters
43    /// - `text`: Relative path text to normalize and validate.
44    ///
45    /// # Errors
46    /// Returns an invalid-path error when `text` is empty, absolute, contains
47    /// NUL, or escapes above the relative root.
48    pub fn parse(text: &str) -> FsResult<Self> {
49        if text.is_empty() || text.starts_with('/') || text.contains('\0') {
50            return Err(invalid_relative());
51        }
52        let mut components = Vec::new();
53        for component in text.split('/') {
54            match component {
55                "" | "." => {}
56                ".." => {
57                    if components.pop().is_none() {
58                        return Err(invalid_relative());
59                    }
60                }
61                value => components.push(value),
62            }
63        }
64        if components.is_empty() {
65            return Err(invalid_relative());
66        }
67        Ok(Self(components.join("/")))
68    }
69
70    /// Returns the normalized logical path text.
71    #[inline]
72    #[must_use]
73    pub fn as_str(&self) -> &str {
74        &self.0
75    }
76}
77
78impl Display for RelativePath {
79    /// Formats the normalized relative path.
80    #[inline]
81    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
82        formatter.write_str(self.as_str())
83    }
84}
85
86/// Builds the shared relative-path validation failure.
87fn invalid_relative() -> FsError {
88    FsError::invalid_path(
89        FsOperation::ParsePath,
90        "relative path must identify a descendant without escaping its base",
91    )
92}