Skip to main content

a7n_core/files/
path.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use a7n_proto::limits;
4use unicode_normalization::is_nfc;
5
6#[derive(Debug, thiserror::Error, PartialEq, Eq)]
7pub enum PathError {
8    #[error("empty path")]
9    Empty,
10    #[error("absolute path")]
11    Absolute,
12    #[error("backslash in path")]
13    Backslash,
14    #[error("drive prefix in path")]
15    DrivePrefix,
16    #[error("path exceeds byte limit")]
17    TooLong,
18    #[error("path is not NFC")]
19    NotNfc,
20    #[error("empty segment")]
21    EmptySegment,
22    #[error("dot or parent segment")]
23    DotSegment,
24    #[error("control character in segment")]
25    Control,
26    #[error("reserved device name")]
27    Reserved,
28    #[error("segment ends with a dot or space")]
29    TrailingDotOrSpace,
30    #[error("forbidden character in segment")]
31    ForbiddenCharacter,
32    #[error("segment exceeds byte limit")]
33    SegmentTooLong,
34}
35
36/// A portable relative NFC path. Constructed only through validation.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ManifestPath(String);
39
40impl ManifestPath {
41    #[must_use]
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45
46    pub fn segments(&self) -> impl Iterator<Item = &str> {
47        self.0.split('/')
48    }
49}
50
51impl TryFrom<&str> for ManifestPath {
52    type Error = PathError;
53
54    fn try_from(path: &str) -> Result<Self, Self::Error> {
55        if path.is_empty() {
56            return Err(PathError::Empty);
57        }
58        if path.len() > limits::MANIFEST_PATH_MAX {
59            return Err(PathError::TooLong);
60        }
61        if path.starts_with('/') {
62            return Err(PathError::Absolute);
63        }
64        if path.contains('\\') {
65            return Err(PathError::Backslash);
66        }
67        if path.as_bytes().get(1) == Some(&b':') && path.as_bytes()[0].is_ascii_alphabetic() {
68            return Err(PathError::DrivePrefix);
69        }
70        if !is_nfc(path) {
71            return Err(PathError::NotNfc);
72        }
73        for segment in path.split('/') {
74            validate_segment(segment)?;
75        }
76        Ok(Self(path.to_owned()))
77    }
78}
79
80fn validate_segment(segment: &str) -> Result<(), PathError> {
81    if segment.is_empty() {
82        return Err(PathError::EmptySegment);
83    }
84    if segment.len() > limits::MANIFEST_SEGMENT_MAX {
85        return Err(PathError::SegmentTooLong);
86    }
87    if matches!(segment, "." | "..") {
88        return Err(PathError::DotSegment);
89    }
90    if segment.chars().any(char::is_control) {
91        return Err(PathError::Control);
92    }
93    if segment.ends_with(['.', ' ']) {
94        return Err(PathError::TrailingDotOrSpace);
95    }
96    if segment.contains(['<', '>', ':', '"', '|', '?', '*']) {
97        return Err(PathError::ForbiddenCharacter);
98    }
99    if reserved_name(segment) {
100        return Err(PathError::Reserved);
101    }
102    Ok(())
103}
104
105fn reserved_name(segment: &str) -> bool {
106    let stem = segment.split('.').next().unwrap_or_default().to_uppercase();
107    if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") {
108        return true;
109    }
110    stem.strip_prefix("COM")
111        .or_else(|| stem.strip_prefix("LPT"))
112        .is_some_and(|suffix| {
113            matches!(
114                suffix,
115                "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
116            )
117        })
118}