Skip to main content

puregit/object/
tree.rs

1//! Tree objects — git's directory representation.
2//!
3//! A tree is a sorted list of entries, each pairing a filename with a mode and
4//! the object id of its contents. The on-disk encoding of one entry is:
5//!
6//! ```text
7//! <mode-octal-ascii> SP <name> NUL <raw-oid>
8//! ```
9//!
10//! where the mode has no leading zero (`40000` for a subtree, `100644` for a
11//! regular file) and the id is in fixed-width binary form (20 bytes for SHA-1,
12//! 32 for SHA-256). Entries are sorted by name, with subtree names compared as
13//! though they ended in `/` — [`Tree::serialize`] re-applies that ordering.
14
15use alloc::vec::Vec;
16
17use crate::error::{Error, Result};
18use crate::oid::{HashAlgo, ObjectId};
19
20/// A file mode in a tree entry. Git uses a small fixed set of values rather
21/// than full POSIX modes.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum FileMode {
24    /// `40000` — a subdirectory (the entry's id names another tree).
25    Tree,
26    /// `100644` — a regular, non-executable file.
27    Regular,
28    /// `100755` — a regular, executable file.
29    Executable,
30    /// `120000` — a symbolic link (the blob holds the link target).
31    Symlink,
32    /// `160000` — a gitlink / submodule (the id is a commit in another repo).
33    Gitlink,
34}
35
36impl FileMode {
37    /// The octal-ASCII form written on the wire (no leading zero).
38    pub const fn as_octal(self) -> &'static str {
39        match self {
40            FileMode::Tree => "40000",
41            FileMode::Regular => "100644",
42            FileMode::Executable => "100755",
43            FileMode::Symlink => "120000",
44            FileMode::Gitlink => "160000",
45        }
46    }
47
48    /// Parses an octal-ASCII mode from a tree entry.
49    pub fn from_octal(s: &[u8]) -> Result<Self> {
50        Ok(match s {
51            b"40000" | b"040000" => FileMode::Tree,
52            b"100644" => FileMode::Regular,
53            b"100755" => FileMode::Executable,
54            b"120000" => FileMode::Symlink,
55            b"160000" => FileMode::Gitlink,
56            other => {
57                use alloc::format;
58                return Err(Error::Parse(format!(
59                    "tree: unknown file mode {:?}",
60                    core::str::from_utf8(other).unwrap_or("<non-utf8>")
61                )));
62            }
63        })
64    }
65
66    /// Whether this entry names a subtree (affects sort ordering).
67    pub fn is_tree(self) -> bool {
68        matches!(self, FileMode::Tree)
69    }
70
71    /// Maps a raw index/stat mode (e.g. `0o100644`) to a tree [`FileMode`].
72    /// Any regular-file mode without the executable bit is treated as
73    /// [`FileMode::Regular`], matching git's normalization.
74    pub fn from_mode_bits(mode: u32) -> Result<Self> {
75        Ok(match mode & 0o170000 {
76            0o040000 => FileMode::Tree,
77            0o120000 => FileMode::Symlink,
78            0o160000 => FileMode::Gitlink,
79            0o100000 => {
80                if mode & 0o111 != 0 {
81                    FileMode::Executable
82                } else {
83                    FileMode::Regular
84                }
85            }
86            _ => {
87                use alloc::format;
88                return Err(Error::Parse(format!("tree: unrepresentable mode {mode:o}")));
89            }
90        })
91    }
92}
93
94/// One entry in a [`Tree`]: a mode, a name, and the id of the named object.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct TreeEntry {
97    /// The file mode.
98    pub mode: FileMode,
99    /// The entry name (a single path component; never contains `/` or NUL).
100    /// Stored as raw bytes because git filenames are not required to be UTF-8.
101    pub name: Vec<u8>,
102    /// The id of the blob, subtree, or (for gitlinks) commit.
103    pub id: ObjectId,
104}
105
106/// A parsed tree object: its entries in git's canonical order.
107#[derive(Debug, Clone, Default, PartialEq, Eq)]
108pub struct Tree {
109    /// The entries. Kept sorted by [`Tree::serialize`]; callers may push in any
110    /// order and rely on serialization to canonicalize.
111    pub entries: Vec<TreeEntry>,
112}
113
114impl Tree {
115    /// Parses a tree payload under the repository's hash algorithm.
116    pub fn parse(algo: HashAlgo, mut payload: &[u8]) -> Result<Self> {
117        let id_len = algo.raw_len();
118        let mut entries = Vec::new();
119
120        while !payload.is_empty() {
121            let space = payload
122                .iter()
123                .position(|&b| b == b' ')
124                .ok_or_else(|| Error::Parse("tree: entry missing mode separator".into()))?;
125            let mode = FileMode::from_octal(&payload[..space])?;
126
127            let rest = &payload[space + 1..];
128            let nul = rest
129                .iter()
130                .position(|&b| b == 0)
131                .ok_or_else(|| Error::Parse("tree: entry missing name terminator".into()))?;
132            let name = rest[..nul].to_vec();
133
134            // The doc comment on `TreeEntry::name` promises a single path
135            // component with no `/` or NUL; enforce that invariant here so the
136            // guarantee holds at the parse boundary rather than only being
137            // re-checked at the checkout sink (D1). The NUL cannot appear (it
138            // terminates the name), but an empty name or an embedded `/` can,
139            // and both are illegal in a git tree.
140            if name.is_empty() || name.contains(&b'/') || name.contains(&0) {
141                return Err(Error::Parse(
142                    "tree: entry name is not a single path component".into(),
143                ));
144            }
145
146            let after_name = &rest[nul + 1..];
147            if after_name.len() < id_len {
148                return Err(Error::Parse("tree: truncated entry id".into()));
149            }
150            let id = ObjectId::from_bytes(algo, &after_name[..id_len])?;
151
152            entries.push(TreeEntry { mode, name, id });
153            payload = &after_name[id_len..];
154        }
155        Ok(Tree { entries })
156    }
157
158    /// Serializes the tree to its canonical bytes, sorting entries the way git
159    /// does (by name, treating subtree names as if suffixed with `/`).
160    pub fn serialize(&self) -> Vec<u8> {
161        let mut sorted: Vec<&TreeEntry> = self.entries.iter().collect();
162        sorted.sort_by(|a, b| cmp_entry_names(a, b));
163
164        let mut out = Vec::new();
165        for e in sorted {
166            out.extend_from_slice(e.mode.as_octal().as_bytes());
167            out.push(b' ');
168            out.extend_from_slice(&e.name);
169            out.push(0);
170            out.extend_from_slice(e.id.as_bytes());
171        }
172        out
173    }
174
175    /// Finds an entry by exact name.
176    pub fn get(&self, name: &[u8]) -> Option<&TreeEntry> {
177        self.entries.iter().find(|e| e.name == name)
178    }
179}
180
181/// Validates that `name` is a single, safe path component fit to be joined
182/// onto a working-tree (or `.git`-relative) path derived from **untrusted**
183/// input — a malicious tree, index, or ref can carry names crafted to escape
184/// the directory or clobber the git metadata directory.
185///
186/// A name is rejected when it is empty, `.` or `..`, contains a path separator
187/// (`/` or `\`), a NUL byte, or a `:` (an absolute or drive-relative path
188/// always contains one of these), or names the `.git` directory in any of the
189/// forms git itself special-cases:
190///
191/// - a plain `.git` (case-insensitively, since checkouts target
192///   case-insensitive filesystems too),
193/// - `.git` with trailing dots or spaces (`.git.`, `.git `), which some
194///   filesystems silently strip so the result still lands on `.git`, and
195/// - the DOS 8.3 short name `GIT~1` that resolves to `.git` on Windows.
196///
197/// This is the shared guard behind checkout (D1/D2), the [`Tree::parse`]
198/// invariant, and index-path validation (D4, see [`crate::index`]).
199pub fn is_safe_path_component(name: &[u8]) -> bool {
200    if name.is_empty() || name == b"." || name == b".." {
201        return false;
202    }
203    // No embedded separators, NUL, or NT drive / alternate-data-stream markers.
204    if name
205        .iter()
206        .any(|&b| b == b'/' || b == b'\\' || b == 0 || b == b':')
207    {
208        return false;
209    }
210    !is_dot_git_component(name)
211}
212
213/// Whether `name` resolves to the `.git` metadata directory under the naming
214/// quirks git guards against (trailing dot/space stripping, the `GIT~1` 8.3
215/// short name), compared case-insensitively for ASCII.
216fn is_dot_git_component(name: &[u8]) -> bool {
217    // Strip the trailing dots and spaces that Windows filesystems drop, so
218    // `.git.`, `.git `, `.git. .` all normalize to `.git`.
219    let mut end = name.len();
220    while end > 0 && (name[end - 1] == b'.' || name[end - 1] == b' ') {
221        end -= 1;
222    }
223    let trimmed = &name[..end];
224    ascii_eq_ignore_case(trimmed, b".git") || ascii_eq_ignore_case(name, b"git~1")
225}
226
227/// ASCII-case-insensitive byte-slice equality.
228fn ascii_eq_ignore_case(a: &[u8], b: &[u8]) -> bool {
229    a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.eq_ignore_ascii_case(y))
230}
231
232/// Compares two entries' names using git's tree-sort rule: a subtree sorts as
233/// though its name had a trailing `/`, so `foo` (file) sorts before `foo/`
234/// (subtree) when another entry named `foo.txt` exists between them.
235fn cmp_entry_names(a: &TreeEntry, b: &TreeEntry) -> core::cmp::Ordering {
236    let a_name = effective_name(a);
237    let b_name = effective_name(b);
238    a_name.cmp(&b_name)
239}
240
241fn effective_name(e: &TreeEntry) -> Vec<u8> {
242    let mut n = e.name.clone();
243    if e.mode.is_tree() {
244        n.push(b'/');
245    }
246    n
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn oid(byte: u8) -> ObjectId {
254        ObjectId::from_bytes(HashAlgo::Sha1, &[byte; 20]).unwrap()
255    }
256
257    #[test]
258    fn roundtrip() {
259        let tree = Tree {
260            entries: alloc::vec![
261                TreeEntry {
262                    mode: FileMode::Regular,
263                    name: b"a.txt".to_vec(),
264                    id: oid(1)
265                },
266                TreeEntry {
267                    mode: FileMode::Tree,
268                    name: b"sub".to_vec(),
269                    id: oid(2)
270                },
271            ],
272        };
273        let bytes = tree.serialize();
274        let back = Tree::parse(HashAlgo::Sha1, &bytes).unwrap();
275        assert_eq!(back.entries.len(), 2);
276        assert_eq!(back.get(b"a.txt").unwrap().mode, FileMode::Regular);
277        assert_eq!(back.get(b"sub").unwrap().mode, FileMode::Tree);
278    }
279
280    #[test]
281    fn sort_treats_subtree_with_slash() {
282        // `foo` (file), `foo.txt`, `foo` (tree) must serialize as foo, foo.txt? No:
283        // file "foo" < "foo.txt" < tree "foo/" — verify the tree sorts last here.
284        let tree = Tree {
285            entries: alloc::vec![
286                TreeEntry {
287                    mode: FileMode::Tree,
288                    name: b"foo".to_vec(),
289                    id: oid(3)
290                },
291                TreeEntry {
292                    mode: FileMode::Regular,
293                    name: b"foo.txt".to_vec(),
294                    id: oid(1)
295                },
296                TreeEntry {
297                    mode: FileMode::Regular,
298                    name: b"foo".to_vec(),
299                    id: oid(2)
300                },
301            ],
302        };
303        let back = Tree::parse(HashAlgo::Sha1, &tree.serialize()).unwrap();
304        let names: Vec<&[u8]> = back.entries.iter().map(|e| e.name.as_slice()).collect();
305        assert_eq!(
306            names,
307            alloc::vec![&b"foo"[..], &b"foo.txt"[..], &b"foo"[..]]
308        );
309    }
310}