Skip to main content

heddle_object_model/object/
reserved_name.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Tree entry names that alias a repository metadata name (heddle#2028).
3//!
4//! Checking out an entry named `.git` or `.heddle` writes into the metadata
5//! directory, where hooks execute code, and a `.gitmodules` symlink lets a
6//! tree point Git's submodule configuration anywhere. A filesystem can also
7//! resolve other spellings to the same name, so the checks follow Git's own
8//! rules (`verify_path`, `is_ntfs_dotgit`, `is_ntfs_dotgitmodules`,
9//! `is_hfs_dotgit`; CVE-2014-9390, CVE-2019-1353, CVE-2018-11235):
10//!
11//! - any case (`.GIT`), for case-insensitive filesystems;
12//! - trailing dots and spaces (`.git.`, `.git `), which Windows strips;
13//! - an NTFS alternate data stream (`.git::$INDEX_ALLOCATION`);
14//! - the NTFS 8.3 short name: `GIT~1` as Git checks it, `HEDDLE~1` to `~4`
15//!   and `GITMOD~1` to `~4` (Git's rule for names longer than six
16//!   characters), plus the hashed fallback `GI7EBA~N` for `.gitmodules`;
17//! - Unicode code points HFS+ ignores (`.g\u{200c}it`).
18//!
19//! Where each name is reserved:
20//!
21//! - `.git` at every depth, as Git does: a nested `.git` is a repository.
22//! - `.heddle` at the root of a tree only. A nested `.heddle` directory is
23//!   ordinary content (fixtures such as `examples/calculator/.heddle/` are
24//!   tracked and captured), so only the root one is the live metadata
25//!   directory.
26//! - `.gitmodules` at every depth, but only as a symlink.
27//!
28//! These are checked where a tree meets the outside world: Git import
29//! refuses them, and checkout never writes them. Decoding a stored tree does
30//! not check them, because repositories captured before heddle#2028 can hold
31//! a nested `.git` (a vendored clone or a submodule's gitfile) and must stay
32//! readable.
33
34use std::fmt;
35
36/// A name that tree entries must not alias.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38pub enum MetadataName {
39    /// Git's `.git` directory, reserved at every depth.
40    Git,
41    /// Heddle's `.heddle` directory, reserved at the root of a tree.
42    Heddle,
43    /// Git's `.gitmodules` file, reserved as a symlink at every depth.
44    GitModules,
45}
46
47impl MetadataName {
48    /// The reserved name, e.g. `.git`.
49    pub fn as_str(self) -> &'static str {
50        match self {
51            MetadataName::Git => ".git",
52            MetadataName::Heddle => ".heddle",
53            MetadataName::GitModules => ".gitmodules",
54        }
55    }
56
57    fn describe(self) -> &'static str {
58        match self {
59            MetadataName::Git => ".git metadata directory",
60            MetadataName::Heddle => ".heddle metadata directory",
61            MetadataName::GitModules => ".gitmodules file, which must not be a symlink",
62        }
63    }
64
65    /// The name without its leading dot, as ASCII lowercase.
66    fn stem(self) -> &'static [u8] {
67        match self {
68            MetadataName::Git => b"git",
69            MetadataName::Heddle => b"heddle",
70            MetadataName::GitModules => b"gitmodules",
71        }
72    }
73}
74
75/// How a reserved name reaches the metadata name.
76#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
77pub enum MetadataAlias {
78    /// The exact name.
79    Exact,
80    /// The name in another case, on case-insensitive filesystems.
81    Case,
82    /// The name followed by dots or spaces, which Windows strips.
83    TrailingDotsOrSpaces,
84    /// The name followed by `\`, a path separator on Windows.
85    BackslashSeparator,
86    /// An NTFS alternate data stream of the name, e.g. `.git::$DATA`.
87    NtfsStream,
88    /// An NTFS 8.3 short name, e.g. `GIT~1`.
89    NtfsShortName,
90    /// The name with Unicode code points HFS+ ignores.
91    HfsIgnorable,
92}
93
94/// Why a tree entry name is reserved: the name it reaches, and how.
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
96pub struct ReservedMetadataName {
97    pub name: MetadataName,
98    pub alias: MetadataAlias,
99}
100
101impl fmt::Display for ReservedMetadataName {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        let target = self.name.describe();
104        match self.alias {
105            MetadataAlias::Exact => write!(f, "is the {target}"),
106            MetadataAlias::Case => {
107                write!(f, "names the {target} on case-insensitive filesystems")
108            }
109            MetadataAlias::TrailingDotsOrSpaces => write!(
110                f,
111                "names the {target} on Windows, which strips trailing dots and spaces"
112            ),
113            MetadataAlias::BackslashSeparator => write!(
114                f,
115                "is a path into the {target} on Windows, where '\\' separates paths"
116            ),
117            MetadataAlias::NtfsStream => {
118                write!(f, "names an NTFS alternate data stream of the {target}")
119            }
120            MetadataAlias::NtfsShortName => {
121                write!(f, "is an NTFS 8.3 short name of the {target}")
122            }
123            MetadataAlias::HfsIgnorable => write!(
124                f,
125                "names the {target} on HFS+, which ignores some Unicode code points"
126            ),
127        }
128    }
129}
130
131/// A path component that is a reserved metadata name.
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct ReservedPathComponent {
134    /// Zero-based index of the component among the path's non-empty,
135    /// non-`.` components.
136    pub index: usize,
137    /// The component, lossily decoded for display.
138    pub component: String,
139    pub reason: ReservedMetadataName,
140}
141
142impl ReservedPathComponent {
143    /// Whether this is the exact root `.git` or `.heddle`: the repository's
144    /// own metadata, which every walk skips silently. Anything else skipped
145    /// for this reason deserves a warning naming it.
146    pub fn is_own_metadata(&self) -> bool {
147        self.index == 0
148            && self.reason.alias == MetadataAlias::Exact
149            && matches!(self.reason.name, MetadataName::Git | MetadataName::Heddle)
150    }
151}
152
153impl fmt::Display for ReservedPathComponent {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(f, "'{}' {}", self.component, self.reason)
156    }
157}
158
159/// Classify `name` as an alias of `.git` or `.heddle`, regardless of depth.
160///
161/// This is the name-level predicate. Most callers want
162/// [`reserved_tree_entry_name`] or [`reserved_path_component`], which also
163/// apply the depth rule (`.heddle` is reserved only at the root) and the
164/// `.gitmodules` symlink rule.
165///
166/// `name` is raw bytes so Git tree names that are not UTF-8 can be checked.
167pub fn reserved_metadata_name(name: &[u8]) -> Option<ReservedMetadataName> {
168    [MetadataName::Git, MetadataName::Heddle]
169        .into_iter()
170        .find_map(|target| reserved_as(name, target))
171}
172
173/// Whether `name` aliases `.git` or `.heddle`, regardless of depth.
174pub fn is_reserved_metadata_name(name: impl AsRef<[u8]>) -> bool {
175    reserved_metadata_name(name.as_ref()).is_some()
176}
177
178/// Classify the tree entry `name`:
179///
180/// - `.git` aliases at any depth;
181/// - `.heddle` aliases only when `at_root` (the entry is a direct child of a
182///   commit's or State's root tree, or of a checkout's destination);
183/// - `.gitmodules` aliases at any depth when `symlink`.
184pub fn reserved_tree_entry_name(
185    name: &[u8],
186    at_root: bool,
187    symlink: bool,
188) -> Option<ReservedMetadataName> {
189    reserved_as(name, MetadataName::Git)
190        .or_else(|| {
191            at_root
192                .then(|| reserved_as(name, MetadataName::Heddle))
193                .flatten()
194        })
195        .or_else(|| {
196            symlink
197                .then(|| reserved_as(name, MetadataName::GitModules))
198                .flatten()
199        })
200}
201
202/// The first component of the repository-relative `path` that is reserved
203/// by [`reserved_tree_entry_name`], if any. `leaf_symlink` says the last
204/// component is a symlink.
205///
206/// Components are split on both `/` and `\`. Empty and `.` components are
207/// skipped, so `./.heddle/x` is a root `.heddle`.
208pub fn reserved_path_component(path: &[u8], leaf_symlink: bool) -> Option<ReservedPathComponent> {
209    let mut components = path
210        .split(|&b| b == b'/' || b == b'\\')
211        .filter(|component| !component.is_empty() && *component != b".")
212        .enumerate()
213        .peekable();
214    while let Some((index, component)) = components.next() {
215        let symlink = leaf_symlink && components.peek().is_none();
216        if let Some(reason) = reserved_tree_entry_name(component, index == 0, symlink) {
217            return Some(ReservedPathComponent {
218                index,
219                component: String::from_utf8_lossy(component).into_owned(),
220                reason,
221            });
222        }
223    }
224    None
225}
226
227fn reserved_as(name: &[u8], target: MetadataName) -> Option<ReservedMetadataName> {
228    ntfs_alias(name, target)
229        .or_else(|| hfs_alias(name, target))
230        .map(|alias| ReservedMetadataName {
231            name: target,
232            alias,
233        })
234}
235
236/// Git's `is_ntfs_dotgit` and `is_ntfs_dot_generic`, for `target`: the long
237/// name `.<stem>` or a short name in any case, then any run of dots and
238/// spaces, then the end of the name, a path separator, or `:` (an alternate
239/// data stream).
240fn ntfs_alias(name: &[u8], target: MetadataName) -> Option<MetadataAlias> {
241    let stem = target.stem();
242    let (rest, short_name) = match name
243        .strip_prefix(b".")
244        .and_then(|rest| strip_prefix_ignore_ascii_case(rest, stem))
245    {
246        Some(rest) => (rest, false),
247        None => (short_name_rest(name, target)?, true),
248    };
249    let trimmed_len = rest.iter().take_while(|&&b| b == b'.' || b == b' ').count();
250    let trailing = &rest[..trimmed_len];
251    let alias = match rest.get(trimmed_len).copied() {
252        None | Some(b'/' | b'\\' | b':') if short_name => MetadataAlias::NtfsShortName,
253        None | Some(b'/') if !trailing.is_empty() => MetadataAlias::TrailingDotsOrSpaces,
254        // `name` is `.` + the stem in some case + the end or `/`.
255        None | Some(b'/') if name[1..=stem.len()] == *stem => MetadataAlias::Exact,
256        None | Some(b'/') => MetadataAlias::Case,
257        Some(b':') => MetadataAlias::NtfsStream,
258        Some(b'\\') => MetadataAlias::BackslashSeparator,
259        Some(_) => return None,
260    };
261    Some(alias)
262}
263
264/// What follows an NTFS 8.3 short name of `target` at the start of `name`.
265fn short_name_rest(name: &[u8], target: MetadataName) -> Option<&[u8]> {
266    match target {
267        // Git checks only `git~1` for `.git` (`is_ntfs_dotgit`).
268        MetadataName::Git => strip_prefix_ignore_ascii_case(name, b"git~1"),
269        // Longer names: the first six characters, then `~1` to `~4`.
270        MetadataName::Heddle | MetadataName::GitModules => {
271            numbered_short_name_rest(name, &target.stem()[..6]).or_else(|| {
272                (target == MetadataName::GitModules)
273                    .then(|| hashed_short_name_rest(name, b"gi7eba"))
274                    .flatten()
275            })
276        }
277    }
278}
279
280/// `<prefix>~1` to `<prefix>~4`, the regular short names Windows gives the
281/// first four long names sharing a six-character prefix.
282fn numbered_short_name_rest<'a>(name: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
283    let rest = strip_prefix_ignore_ascii_case(name, prefix)?.strip_prefix(b"~")?;
284    match rest.first() {
285        Some(b'1'..=b'4') => Some(&rest[1..]),
286        _ => None,
287    }
288}
289
290/// The fall-back short name Windows derives from a hash once the numbered
291/// ones run out: the first eight characters are a leading part of `prefix`,
292/// `~`, a digit 1-9, then digits (Git's `is_ntfs_dot_generic`).
293fn hashed_short_name_rest<'a>(name: &'a [u8], prefix: &[u8; 6]) -> Option<&'a [u8]> {
294    let mut saw_tilde = false;
295    let mut index = 0;
296    while index < 8 {
297        let byte = *name.get(index)?;
298        if saw_tilde {
299            if !byte.is_ascii_digit() {
300                return None;
301            }
302        } else if byte == b'~' {
303            index += 1;
304            if !matches!(name.get(index), Some(b'1'..=b'9')) {
305                return None;
306            }
307            saw_tilde = true;
308        } else if index >= 6 || !byte.is_ascii() || byte.to_ascii_lowercase() != prefix[index] {
309            return None;
310        }
311        index += 1;
312    }
313    Some(&name[8..])
314}
315
316fn strip_prefix_ignore_ascii_case<'a>(name: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
317    (name.len() >= prefix.len() && name[..prefix.len()].eq_ignore_ascii_case(prefix))
318        .then(|| &name[prefix.len()..])
319}
320
321/// Git's `is_hfs_dotgit`, for `target`: after dropping the code points HFS+
322/// ignores, `.<stem>` in any case, then the end of the name or `/`.
323/// Malformed UTF-8 ends the name, as it does in Git.
324fn hfs_alias(name: &[u8], target: MetadataName) -> Option<MetadataAlias> {
325    let valid = match std::str::from_utf8(name) {
326        Ok(valid) => valid,
327        Err(error) => std::str::from_utf8(&name[..error.valid_up_to()]).ok()?,
328    };
329    let mut chars = valid
330        .chars()
331        .take_while(|&c| c != '/')
332        .filter(|&c| !is_hfs_ignorable(c));
333    if chars.next() != Some('.') {
334        return None;
335    }
336    for &expected in target.stem() {
337        let c = chars.next()?;
338        if !c.is_ascii() || c.to_ascii_lowercase() as u32 != u32::from(expected) {
339            return None;
340        }
341    }
342    chars
343        .next()
344        .is_none()
345        .then_some(MetadataAlias::HfsIgnorable)
346}
347
348/// The code points HFS+ drops when it compares names (Git's
349/// `next_hfs_char`).
350fn is_hfs_ignorable(c: char) -> bool {
351    matches!(
352        c,
353        '\u{200c}'
354            | '\u{200d}'
355            | '\u{200e}'
356            | '\u{200f}'
357            | '\u{202a}'
358            ..='\u{202e}'
359            | '\u{206a}'
360            ..='\u{206f}'
361            | '\u{feff}'
362    )
363}
364
365#[cfg(test)]
366#[path = "reserved_name_tests.rs"]
367mod tests;