Skip to main content

gix_validate/
path.rs

1use bstr::{BStr, ByteSlice};
2
3///
4pub mod component {
5    /// The error returned by [`component()`](super::component()).
6    #[derive(Debug)]
7    #[expect(missing_docs)]
8    #[non_exhaustive]
9    pub enum Error {
10        Empty,
11        PathSeparator,
12        WindowsPathPrefix,
13        WindowsReservedName,
14        WindowsIllegalCharacter,
15        DotGitDir,
16        SymlinkedGitModules,
17        Relative,
18    }
19
20    impl std::fmt::Display for Error {
21        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22            match self {
23                Error::Empty => write!(f, "A path component must not be empty"),
24                Error::PathSeparator => write!(f, r"Path separators like / or \ are not allowed"),
25                Error::WindowsPathPrefix => write!(f, "Windows path prefixes are not allowed"),
26                Error::WindowsReservedName => {
27                    write!(f, "Windows device-names may have side-effects and are not allowed")
28                }
29                Error::WindowsIllegalCharacter => write!(
30                    f,
31                    r#"Trailing spaces or dots, and the following characters anywhere, are forbidden in Windows paths, along with non-printable ones: <>:"|?*"#
32                ),
33                Error::DotGitDir => write!(f, "The .git name may never be used"),
34                Error::SymlinkedGitModules => write!(f, "The .gitmodules file must not be a symlink"),
35                Error::Relative => write!(f, "Relative components '.' and '..' are disallowed"),
36            }
37        }
38    }
39
40    impl std::error::Error for Error {
41        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
42            Some(const { &gix_error::ClassificationMarker::VALIDATION })
43        }
44    }
45
46    /// Further specify what to check for in [`component()`](super::component())
47    ///
48    /// Note that the `Default` implementation maximizes safety by enabling all protections.
49    #[derive(Debug, Copy, Clone)]
50    pub struct Options {
51        /// This flag should be turned on when on Windows, but can be turned on when on other platforms
52        /// as well to prevent path components that can cause trouble on Windows.
53        pub protect_windows: bool,
54        /// If `true`, protections for the MacOS HFS+ filesystem will be active, checking for
55        /// special directories that we should never write while ignoring codepoints just like HFS+ would.
56        ///
57        /// This field is equivalent to `core.protectHFS`.
58        pub protect_hfs: bool,
59        /// If `true`, protections for Windows NTFS specific features will be active. This adds special handling
60        /// for `8.3` filenames and alternate data streams, both of which could be used to mask the true name of
61        /// what would be created on disk.
62        ///
63        /// This field is equivalent to `core.protectNTFS`.
64        pub protect_ntfs: bool,
65    }
66
67    impl Default for Options {
68        fn default() -> Self {
69            Options {
70                protect_windows: true,
71                protect_hfs: true,
72                protect_ntfs: true,
73            }
74        }
75    }
76
77    /// The mode of the component, if it's the leaf of a path.
78    #[derive(Debug, Copy, Clone, PartialEq, Eq)]
79    pub enum Mode {
80        /// The item is a symbolic link.
81        Symlink,
82    }
83}
84
85/// Assure the given `input` resembles a valid name for a tree or blob, and in that sense, a path component.
86/// `mode` indicates the kind of `input` and it should be `Some` if `input` is the last component in the underlying
87/// path.
88///
89/// `input` must not make it possible to exit the repository, or to specify absolute paths.
90pub fn component(
91    input: &BStr,
92    mode: Option<component::Mode>,
93    component::Options {
94        protect_windows,
95        protect_hfs,
96        protect_ntfs,
97    }: component::Options,
98) -> Result<&BStr, component::Error> {
99    if input.is_empty() {
100        return Err(component::Error::Empty);
101    }
102    if input == ".." || input == "." {
103        return Err(component::Error::Relative);
104    }
105    if protect_windows {
106        if input.find_byteset(br"/\").is_some() {
107            return Err(component::Error::PathSeparator);
108        }
109        if input.chars().nth(1) == Some(':') {
110            return Err(component::Error::WindowsPathPrefix);
111        }
112    } else if input.find_byte(b'/').is_some() {
113        return Err(component::Error::PathSeparator);
114    }
115    if protect_hfs {
116        if is_dot_hfs(input, "git") {
117            return Err(component::Error::DotGitDir);
118        }
119        if is_symlink(mode) && is_dot_hfs(input, "gitmodules") {
120            return Err(component::Error::SymlinkedGitModules);
121        }
122    }
123
124    if protect_ntfs {
125        if is_dot_git_ntfs(input) {
126            return Err(component::Error::DotGitDir);
127        }
128        if is_symlink(mode) && is_dot_ntfs(input, "gitmodules", "gi7eba") {
129            return Err(component::Error::SymlinkedGitModules);
130        }
131
132        if protect_windows && let Some(err) = check_win_devices_and_illegal_characters(input) {
133            return Err(err);
134        }
135    }
136
137    if !(protect_hfs | protect_ntfs) {
138        if input.eq_ignore_ascii_case(b".git") {
139            return Err(component::Error::DotGitDir);
140        }
141        if is_symlink(mode) && input.eq_ignore_ascii_case(b".gitmodules") {
142            return Err(component::Error::SymlinkedGitModules);
143        }
144    }
145    Ok(input)
146}
147
148/// Return `true` if the path component at `input` looks like a Windows device, like `CON`
149/// or `LPT1` (case-insensitively).
150///
151/// This is relevant only on Windows, where one may be tricked into reading or writing to such devices.
152/// When reading from `CON`, a console-program may block until the user provided input.
153pub fn component_is_windows_device(input: &BStr) -> bool {
154    is_win_device(input)
155}
156
157fn is_win_device(input: &BStr) -> bool {
158    let Some(in3) = input.get(..3) else { return false };
159    if in3.eq_ignore_ascii_case(b"AUX") && is_done_windows(input.get(3..)) {
160        return true;
161    }
162    if in3.eq_ignore_ascii_case(b"NUL") && is_done_windows(input.get(3..)) {
163        return true;
164    }
165    if in3.eq_ignore_ascii_case(b"PRN") && is_done_windows(input.get(3..)) {
166        return true;
167    }
168    // Note that the following allows `COM0`, even though `LPT0` is not allowed.
169    // Even though tests seem to indicate that neither `LPT0` nor `COM0` are valid
170    // device names, it's unclear this truly is the case in all possible versions and editions
171    // of Windows.
172    // Hence, justification for this asymmetry is merely to do exactly the same as Git does,
173    // and to have exactly the same behaviour during validation (for worktree-writes).
174    if in3.eq_ignore_ascii_case(b"COM")
175        && input.get(3).is_some_and(|n| *n >= b'1' && *n <= b'9')
176        && is_done_windows(input.get(4..))
177    {
178        return true;
179    }
180    if in3.eq_ignore_ascii_case(b"LPT")
181        && input.get(3).is_some_and(u8::is_ascii_digit)
182        && is_done_windows(input.get(4..))
183    {
184        return true;
185    }
186    if in3.eq_ignore_ascii_case(b"CON")
187        && (is_done_windows(input.get(3..))
188            || (input.get(3..6).is_some_and(|n| n.eq_ignore_ascii_case(b"IN$")) && is_done_windows(input.get(6..)))
189            || (input.get(3..7).is_some_and(|n| n.eq_ignore_ascii_case(b"OUT$")) && is_done_windows(input.get(7..))))
190    {
191        return true;
192    }
193    false
194}
195
196fn check_win_devices_and_illegal_characters(input: &BStr) -> Option<component::Error> {
197    if is_win_device(input) {
198        return Some(component::Error::WindowsReservedName);
199    }
200    if input.iter().any(|b| *b < 0x20 || b":<>\"|?*".contains(b)) {
201        return Some(component::Error::WindowsIllegalCharacter);
202    }
203    if input.ends_with(b".") || input.ends_with(b" ") {
204        return Some(component::Error::WindowsIllegalCharacter);
205    }
206    None
207}
208
209fn is_symlink(mode: Option<component::Mode>) -> bool {
210    mode == Some(component::Mode::Symlink)
211}
212
213fn is_dot_hfs(input: &BStr, search_case_insensitive: &str) -> bool {
214    let mut input = input.chars().filter(|c| match *c as u32 {
215        // Case-insensitive HFS+ skips these code points as "ignorable" when comparing filenames. See:
216        // https://github.com/git/git/commit/6162a1d323d24fd8cbbb1a6145a91fb849b2568f
217        // https://developer.apple.com/library/archive/technotes/tn/tn1150.html#StringComparisonAlgorithm
218        // https://github.com/apple-oss-distributions/hfs/blob/main/core/UCStringCompareData.h
219            0x200c | // ZERO WIDTH NON-JOINER
220            0x200d | // ZERO WIDTH JOINER
221            0x200e | // LEFT-TO-RIGHT MARK
222            0x200f | // RIGHT-TO-LEFT MARK
223            0x202a | // LEFT-TO-RIGHT EMBEDDING
224            0x202b | // RIGHT-TO-LEFT EMBEDDING
225            0x202c | // POP DIRECTIONAL FORMATTING
226            0x202d | // LEFT-TO-RIGHT OVERRIDE
227            0x202e | // RIGHT-TO-LEFT OVERRIDE
228            0x206a | // INHIBIT SYMMETRIC SWAPPING
229            0x206b | // ACTIVATE SYMMETRIC SWAPPING
230            0x206c | // INHIBIT ARABIC FORM SHAPING
231            0x206d | // ACTIVATE ARABIC FORM SHAPING
232            0x206e | // NATIONAL DIGIT SHAPES
233            0x206f | // NOMINAL DIGIT SHAPES
234            0xfeff => false, // ZERO WIDTH NO-BREAK SPACE
235            _ => true
236        });
237    if input.next() != Some('.') {
238        return false;
239    }
240
241    let mut comp = search_case_insensitive.chars();
242    loop {
243        match (comp.next(), input.next()) {
244            (Some(a), Some(b)) => {
245                if !a.eq_ignore_ascii_case(&b) {
246                    return false;
247                }
248            }
249            (None, None) => return true,
250            _ => return false,
251        }
252    }
253}
254
255fn is_dot_git_ntfs(input: &BStr) -> bool {
256    if input.get(..4).is_some_and(|input| input.eq_ignore_ascii_case(b".git")) {
257        return is_done_ntfs(input.get(4..));
258    }
259    if input.get(..5).is_some_and(|input| input.eq_ignore_ascii_case(b"git~1")) {
260        return is_done_ntfs(input.get(5..));
261    }
262    false
263}
264
265/// The `search_case_insensitive` name is the actual name to look for (in a case-insensitive way).
266/// Opposed to that there is the special `ntfs_shortname_prefix` which is derived from `search_case_insensitive`
267/// but looks more like a hash, one that NTFS uses to disambiguate things, for when there is a lot of files
268/// with the same prefix.
269fn is_dot_ntfs(input: &BStr, search_case_insensitive: &str, ntfs_shortname_prefix: &str) -> bool {
270    if input.first() == Some(&b'.') {
271        let end_pos = 1 + search_case_insensitive.len();
272        if input
273            .get(1..end_pos)
274            .is_some_and(|input| input.eq_ignore_ascii_case(search_case_insensitive.as_bytes()))
275        {
276            is_done_ntfs(input.get(end_pos..))
277        } else {
278            false
279        }
280    } else {
281        let search_case_insensitive: &[u8] = search_case_insensitive.as_bytes();
282        if search_case_insensitive
283            .get(..6)
284            .zip(input.get(..6))
285            .is_some_and(|(ntfs_prefix, first_6_of_input)| {
286                first_6_of_input.eq_ignore_ascii_case(ntfs_prefix)
287                    && input.get(6) == Some(&b'~')
288                    // It's notable that only `~1` to `~4` are possible before the disambiguation algorithm
289                    // switches to using the `ntfs_shortname_prefix`, which is checked hereafter.
290                    && input.get(7).is_some_and(|num| (b'1'..=b'4').contains(num))
291            })
292        {
293            return is_done_ntfs(input.get(8..));
294        }
295
296        let ntfs_shortname_prefix: &[u8] = ntfs_shortname_prefix.as_bytes();
297        let mut saw_tilde = false;
298        let mut pos = 0;
299        while pos < 8 {
300            let Some(b) = input.get(pos).copied() else {
301                return false;
302            };
303            if saw_tilde {
304                if !b.is_ascii_digit() {
305                    return false;
306                }
307            } else if b == b'~' {
308                saw_tilde = true;
309                pos += 1;
310                let Some(b) = input.get(pos).copied() else {
311                    return false;
312                };
313                if !(b'1'..=b'9').contains(&b) {
314                    return false;
315                }
316            } else if pos >= 6
317                || b & 0x80 == 0x80
318                || ntfs_shortname_prefix
319                    .get(pos)
320                    .is_none_or(|ob| !b.eq_ignore_ascii_case(ob))
321            {
322                return false;
323            }
324            pos += 1;
325        }
326        is_done_ntfs(input.get(pos..))
327    }
328}
329
330/// Check if trailing filename bytes leave a match to special files like `.git` unchanged in NTFS.
331fn is_done_ntfs(input: Option<&[u8]>) -> bool {
332    // Skip spaces and dots. Then return true if we are at the end or a colon.
333    let Some(input) = input else { return true };
334    for b in input.bytes() {
335        if b == b':' {
336            return true;
337        }
338        if b != b' ' && b != b'.' {
339            return false;
340        }
341    }
342    true
343}
344
345/// Check if trailing filename bytes leave a match to Windows reserved device names unchanged.
346fn is_done_windows(input: Option<&[u8]>) -> bool {
347    // Skip spaces. Then return true if we are at the end or a dot or colon.
348    let Some(input) = input else { return true };
349    let skip = input.bytes().take_while(|b| *b == b' ').count();
350    let Some(next) = input.get(skip) else { return true };
351    *next == b'.' || *next == b':'
352}