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}