gix_validate/
path.rs

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