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}