Skip to main content

objects/util/
git_tree_name.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Git tree-entry name classification shared by import engines.
3
4use crate::object::{ReservedMetadataName, reserved_tree_entry_name, validate_tree_entry_name};
5
6#[derive(Clone, Debug, Eq, PartialEq)]
7pub enum GitTreeNameClassification {
8    Representable(String),
9    NeedsLossy(GitTreeNameLossy),
10}
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct GitTreeNameLossy {
14    pub name: String,
15    pub action: GitTreeNameLossyAction,
16    pub reason: &'static str,
17}
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum GitTreeNameLossyAction {
21    Dropped,
22    Converted,
23}
24
25/// Classify one Git tree entry name for import. `at_root` says the entry is
26/// a direct child of a commit's root tree, where `.heddle` is reserved too;
27/// `symlink` says it is a symlink, which `.gitmodules` may not be (see
28/// [`reserved_tree_entry_name`]).
29///
30/// A reserved name is an `Err`: import must fail, and not even `--lossy`
31/// may drop it, because the tree is hostile rather than unrepresentable.
32pub fn classify_git_tree_name(
33    raw_name: &[u8],
34    at_root: bool,
35    symlink: bool,
36) -> Result<GitTreeNameClassification, ReservedMetadataName> {
37    // Checked on the raw bytes, before any lossy conversion could hide or
38    // create an alias.
39    match reserved_tree_entry_name(raw_name, at_root, symlink) {
40        Some(reason) => Err(reason),
41        None => Ok(classify_git_tree_name_representable(raw_name)),
42    }
43}
44
45/// Classify only whether a Git tree entry name is representable, without
46/// the reserved-name check. For read-only projections of Git history, which
47/// never write a worktree (checkout still refuses reserved names).
48pub fn classify_git_tree_name_representable(raw_name: &[u8]) -> GitTreeNameClassification {
49    let (name, utf8_lossy) = match std::str::from_utf8(raw_name) {
50        Ok(name) => (name.to_string(), false),
51        Err(_) => (String::from_utf8_lossy(raw_name).into_owned(), true),
52    };
53
54    // Validate the FINAL name (after any UTF-8 replacement) against the
55    // canonical tree-name validator, so this classifier's representable set
56    // can never drift from what Heddle will actually store (path separators
57    // '/' and '\', '.'/'..', control bytes, empty). Critically, a name that
58    // is invalid UTF-8 AND otherwise unrepresentable (e.g. `bad\<0xff>` ->
59    // lossy `bad\<U+FFFD>` still containing a backslash) must be Dropped, not
60    // silently persisted as Converted.
61    match (validate_tree_entry_name(&name), utf8_lossy) {
62        (Ok(()), false) => GitTreeNameClassification::Representable(name),
63        (Ok(()), true) => GitTreeNameClassification::NeedsLossy(GitTreeNameLossy {
64            name,
65            action: GitTreeNameLossyAction::Converted,
66            reason: "tree entry name is not valid UTF-8 and was converted with replacement characters",
67        }),
68        (Err(_), _) => GitTreeNameClassification::NeedsLossy(GitTreeNameLossy {
69            name,
70            action: GitTreeNameLossyAction::Dropped,
71            reason: "tree entry name is not representable in Heddle",
72        }),
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    /// Close-the-class guard: the classifier's `Representable` verdict must be
81    /// EXACTLY the set `validate_tree_entry_name` accepts. If the two ever
82    /// diverge (as they did for `\\` before this fix), this fails.
83    #[test]
84    fn representable_iff_validator_accepts() {
85        let cases = [
86            "ok.txt",
87            "with space",
88            "ünïcödé",
89            "",
90            ".",
91            "..",
92            "a/b",
93            "a\\b",
94            "ctrl\u{0001}",
95            "del\u{7f}",
96            ".git",
97            ".heddle",
98        ];
99        for c in cases {
100            let classified_representable = matches!(
101                classify_git_tree_name_representable(c.as_bytes()),
102                GitTreeNameClassification::Representable(_)
103            );
104            let validator_accepts = validate_tree_entry_name(c).is_ok();
105            assert_eq!(
106                classified_representable, validator_accepts,
107                "classifier/validator disagree on {c:?}"
108            );
109        }
110    }
111
112    #[test]
113    fn backslash_name_is_not_representable() {
114        assert!(matches!(
115            classify_git_tree_name_representable(b"foo\\bar"),
116            GitTreeNameClassification::NeedsLossy(_)
117        ));
118    }
119
120    #[test]
121    fn invalid_utf8_is_converted_not_dropped() {
122        match classify_git_tree_name_representable(&[b'a', 0xff, b'b']) {
123            GitTreeNameClassification::NeedsLossy(lossy) => {
124                assert_eq!(lossy.action, GitTreeNameLossyAction::Converted);
125            }
126            other => panic!("expected NeedsLossy/Converted, got {other:?}"),
127        }
128    }
129
130    #[test]
131    fn invalid_utf8_that_stays_unrepresentable_after_conversion_is_dropped() {
132        // `bad\<0xff>`: invalid UTF-8 AND contains a backslash. Lossy UTF-8
133        // conversion replaces the 0xff but the backslash survives, so the
134        // converted name is still rejected by validate_tree_entry_name and
135        // must be Dropped — never silently persisted as Converted.
136        match classify_git_tree_name_representable(b"bad\\\xff") {
137            GitTreeNameClassification::NeedsLossy(lossy) => {
138                assert_eq!(lossy.action, GitTreeNameLossyAction::Dropped);
139            }
140            other => panic!("expected NeedsLossy/Dropped, got {other:?}"),
141        }
142    }
143
144    #[test]
145    fn reserved_names_fail_rather_than_go_lossy() {
146        let cases: [(&[u8], bool, bool); 10] = [
147            (b".git", false, false),
148            (b".GIT", false, false),
149            (b".git::$INDEX_ALLOCATION", false, false),
150            (b"git~1", false, false),
151            (b".git\\hooks", false, false),
152            (b".git\xff", false, false),
153            (b".heddle", true, false),
154            (b".HEDDLE", true, false),
155            (b".gitmodules", false, true),
156            (b"GITMOD~1", false, true),
157        ];
158        for (raw, at_root, symlink) in cases {
159            assert!(
160                classify_git_tree_name(raw, at_root, symlink).is_err(),
161                "{raw:?}"
162            );
163        }
164        // A nested `.heddle` is ordinary content.
165        assert_eq!(
166            classify_git_tree_name(b".heddle", false, false),
167            Ok(GitTreeNameClassification::Representable(
168                ".heddle".to_string()
169            ))
170        );
171        // A regular `.gitmodules` file is ordinary.
172        assert_eq!(
173            classify_git_tree_name(b".gitmodules", true, false),
174            Ok(GitTreeNameClassification::Representable(
175                ".gitmodules".to_string()
176            ))
177        );
178    }
179}