Skip to main content

grit_lib/objects/
write.rs

1//! Typed inputs for building tree and commit objects without an index or work tree.
2//!
3//! [`Objects::write_tree`](super::Objects::write_tree) and
4//! [`Objects::write_commit`](super::Objects::write_commit) take these types and
5//! produce objects that pass `git fsck --strict`.
6
7use std::collections::HashSet;
8
9use crate::change_id::ChangeId;
10use crate::fsck_standalone::{tree_entry_mode_allowed, MAX_TREE_ENTRY_LEN};
11use crate::ident::Signature;
12use crate::objects::{tree_entry_cmp, CommitData, HashAlgo, ObjectId, TreeEntry};
13use crate::path_validate::{audit_tree_entry_name, PathComponentError, ProtectOptions};
14
15const MODE_TREE: u32 = 0o040000;
16
17/// Why [`Objects::write_tree`](super::Objects::write_tree) refused an entry.
18///
19/// Each variant carries the offending entry name as raw bytes (tree entry names
20/// are not required to be UTF-8).
21#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
22#[non_exhaustive]
23pub enum TreeEntryError {
24    /// The name is empty, `.`, `..`, a `.git` alias, or contains `/` or NUL.
25    #[error("invalid tree entry name '{}': {reason}", String::from_utf8_lossy(.name))]
26    InvalidName {
27        /// Entry name as given.
28        name: Vec<u8>,
29        /// Which path-component rule the name breaks.
30        reason: PathComponentError,
31    },
32    /// The name is longer than the 4096 bytes `git fsck --strict` accepts.
33    #[error(
34        "tree entry name of {} bytes exceeds the {max}-byte limit",
35        .name.len()
36    )]
37    NameTooLong {
38        /// Entry name.
39        name: Vec<u8>,
40        /// Longest accepted name, in bytes.
41        max: usize,
42    },
43    /// The mode is not one of `100644`, `100755`, `120000`, `40000` or `160000`.
44    #[error("tree entry '{}' has unsupported mode {mode:o}", String::from_utf8_lossy(.name))]
45    InvalidMode {
46        /// Entry name.
47        name: Vec<u8>,
48        /// Mode as given.
49        mode: u32,
50    },
51    /// The entry points at the all-zero object id.
52    #[error("tree entry '{}' points at the null object id", String::from_utf8_lossy(.name))]
53    NullObjectId {
54        /// Entry name.
55        name: Vec<u8>,
56    },
57    /// The entry's object id uses a different hash algorithm than the repository.
58    #[error(
59        "tree entry '{}' has a {} object id in a {} repository",
60        String::from_utf8_lossy(.name),
61        .actual.name(),
62        .expected.name()
63    )]
64    HashAlgoMismatch {
65        /// Entry name.
66        name: Vec<u8>,
67        /// The repository's object format.
68        expected: HashAlgo,
69        /// The algorithm of the entry's object id.
70        actual: HashAlgo,
71    },
72    /// Two entries share a name (including a file and a directory of the same name).
73    #[error("tree lists '{}' more than once", String::from_utf8_lossy(.name))]
74    DuplicateName {
75        /// The repeated name.
76        name: Vec<u8>,
77    },
78}
79
80/// Validate `entries` for `algo` and return them in canonical Git tree order.
81///
82/// The checks cover every rule `git fsck --strict` applies to a tree, so the
83/// serialized result needs no second fsck pass.
84///
85/// # Errors
86///
87/// The first [`TreeEntryError`] found, checking each entry in input order and
88/// then duplicate names in sorted order.
89pub(crate) fn canonical_tree_entries(
90    entries: impl IntoIterator<Item = TreeEntry>,
91    algo: HashAlgo,
92) -> Result<Vec<TreeEntry>, TreeEntryError> {
93    let mut entries: Vec<TreeEntry> = entries.into_iter().collect();
94    for entry in &entries {
95        validate_tree_entry(entry, algo)?;
96    }
97    entries
98        .sort_by(|a, b| tree_entry_cmp(&a.name, a.mode == MODE_TREE, &b.name, b.mode == MODE_TREE));
99    let mut seen = HashSet::with_capacity(entries.len());
100    if let Some(dup) = entries.iter().find(|e| !seen.insert(e.name.as_slice())) {
101        return Err(TreeEntryError::DuplicateName {
102            name: dup.name.clone(),
103        });
104    }
105    Ok(entries)
106}
107
108fn validate_tree_entry(entry: &TreeEntry, algo: HashAlgo) -> Result<(), TreeEntryError> {
109    audit_tree_entry_name(&entry.name, ProtectOptions::fsck())
110        .first_error()
111        .map_err(|reason| TreeEntryError::InvalidName {
112            name: entry.name.clone(),
113            reason,
114        })?;
115    if entry.name.len() > MAX_TREE_ENTRY_LEN {
116        return Err(TreeEntryError::NameTooLong {
117            name: entry.name.clone(),
118            max: MAX_TREE_ENTRY_LEN,
119        });
120    }
121    if !tree_entry_mode_allowed(entry.mode) {
122        return Err(TreeEntryError::InvalidMode {
123            name: entry.name.clone(),
124            mode: entry.mode,
125        });
126    }
127    if entry.oid.algo() != algo {
128        return Err(TreeEntryError::HashAlgoMismatch {
129            name: entry.name.clone(),
130            expected: algo,
131            actual: entry.oid.algo(),
132        });
133    }
134    if entry.oid.is_zero() {
135        return Err(TreeEntryError::NullObjectId {
136            name: entry.name.clone(),
137        });
138    }
139    Ok(())
140}
141
142/// A commit to create with [`Objects::write_commit`](super::Objects::write_commit).
143///
144/// Identities are typed [`Signature`]s, so the `author` and `committer` header
145/// lines always have Git's `Name <email> <unix> <±HHMM>` shape.
146///
147/// # Examples
148///
149/// ```
150/// # fn main() -> grit_lib::error::Result<()> {
151/// use grit_lib::ident::Signature;
152/// use grit_lib::objects::{NewCommit, ObjectKind, TreeEntry};
153/// use grit_lib::repo::{InitOptions, Repository};
154/// use time::OffsetDateTime;
155///
156/// let dir = tempfile::tempdir()?;
157/// let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true))?;
158/// let blob = repo.objects().write(ObjectKind::Blob, b"hello\n")?;
159/// let tree = repo.objects().write_tree([TreeEntry {
160///     mode: 0o100644,
161///     name: b"README".to_vec(),
162///     oid: blob,
163/// }])?;
164/// let when = OffsetDateTime::from_unix_timestamp(1_700_000_000).map_err(|e| {
165///     grit_lib::error::Error::Message(e.to_string())
166/// })?;
167/// let ada = Signature::new("Ada Lovelace", "ada@example.com", when)?;
168/// let commit = repo
169///     .objects()
170///     .write_commit(&NewCommit::new(tree, ada.clone(), ada, "Initial commit"))?;
171/// assert_eq!(repo.objects().read_commit(&commit)?.message, "Initial commit\n");
172/// # Ok(())
173/// # }
174/// ```
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct NewCommit {
177    tree: ObjectId,
178    parents: Vec<ObjectId>,
179    author: Signature,
180    committer: Signature,
181    message: String,
182    change_id: Option<ChangeId>,
183}
184
185impl NewCommit {
186    /// A root commit of `tree` with no parents.
187    ///
188    /// # Parameters
189    ///
190    /// - `tree`: the commit's root tree (must exist in the repository when written).
191    /// - `author`, `committer`: identities and timestamps for the two header lines.
192    /// - `message`: commit message; a trailing newline is added when missing.
193    #[must_use]
194    pub fn new(
195        tree: ObjectId,
196        author: Signature,
197        committer: Signature,
198        message: impl Into<String>,
199    ) -> Self {
200        Self {
201            tree,
202            parents: Vec::new(),
203            author,
204            committer,
205            message: message.into(),
206            change_id: None,
207        }
208    }
209
210    /// Replace the parent list (first parent first; each must name a commit when written).
211    #[must_use]
212    pub fn with_parents(mut self, parents: impl IntoIterator<Item = ObjectId>) -> Self {
213        self.parents = parents.into_iter().collect();
214        self
215    }
216
217    /// Record `id` as the commit's `change-id` header instead of assigning a fresh one.
218    ///
219    /// Use this when the new commit rewrites an existing change (amend, rebase) so the
220    /// change keeps its identity. Without it, [`Objects::write_commit`](super::Objects::write_commit)
221    /// assigns a new id when `grit.changeIds` is enabled (the default).
222    #[must_use]
223    pub fn with_change_id(mut self, id: ChangeId) -> Self {
224        self.change_id = Some(id);
225        self
226    }
227
228    /// Explicit change id set with [`Self::with_change_id`].
229    pub(crate) fn change_id(&self) -> Option<&ChangeId> {
230        self.change_id.as_ref()
231    }
232
233    /// The tree and parents, for existence checks before writing.
234    pub(crate) fn tree_and_parents(&self) -> (&ObjectId, &[ObjectId]) {
235        (&self.tree, &self.parents)
236    }
237
238    /// The two identities, for validation before writing.
239    pub(crate) fn signatures(&self) -> [&Signature; 2] {
240        [&self.author, &self.committer]
241    }
242
243    /// Commit fields with header lines rendered from the signatures and no extra headers.
244    pub(crate) fn to_commit_data(&self) -> CommitData {
245        let mut message = self.message.clone();
246        if !message.is_empty() && !message.ends_with('\n') {
247            message.push('\n');
248        }
249        CommitData {
250            tree: self.tree,
251            parents: self.parents.clone(),
252            author: self.author.header_line(),
253            committer: self.committer.header_line(),
254            author_raw: Vec::new(),
255            committer_raw: Vec::new(),
256            encoding: None,
257            message,
258            raw_message: None,
259            extra_headers: Vec::new(),
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::objects::HashAlgo;
268    use time::OffsetDateTime;
269
270    fn oid(byte: u8) -> ObjectId {
271        ObjectId::from_bytes(&[byte; 20]).unwrap()
272    }
273
274    fn entry(mode: u32, name: &[u8], byte: u8) -> TreeEntry {
275        TreeEntry {
276            mode,
277            name: name.to_vec(),
278            oid: oid(byte),
279        }
280    }
281
282    #[test]
283    fn sorts_into_git_tree_order() {
284        let sorted = canonical_tree_entries(
285            [
286                entry(0o100644, b"foo.c", 1),
287                entry(MODE_TREE, b"foo", 2),
288                entry(0o100644, b"foo-bar", 3),
289                entry(0o100644, b"a", 4),
290            ],
291            HashAlgo::Sha1,
292        )
293        .unwrap();
294        let names: Vec<&[u8]> = sorted.iter().map(|e| e.name.as_slice()).collect();
295        assert_eq!(
296            names,
297            [&b"a"[..], &b"foo-bar"[..], &b"foo.c"[..], &b"foo"[..]]
298        );
299    }
300
301    #[test]
302    fn rejects_file_and_directory_with_the_same_name() {
303        let err = canonical_tree_entries(
304            [
305                entry(0o100644, b"foo", 1),
306                entry(0o100644, b"foo.c", 2),
307                entry(MODE_TREE, b"foo", 3),
308            ],
309            HashAlgo::Sha1,
310        )
311        .unwrap_err();
312        assert_eq!(
313            err,
314            TreeEntryError::DuplicateName {
315                name: b"foo".to_vec()
316            }
317        );
318    }
319
320    #[test]
321    fn rejects_bad_names_modes_and_ids() {
322        let cases: [(TreeEntry, TreeEntryError); 7] = [
323            (
324                entry(0o100644, &[b'x'; MAX_TREE_ENTRY_LEN + 1], 1),
325                TreeEntryError::NameTooLong {
326                    name: vec![b'x'; MAX_TREE_ENTRY_LEN + 1],
327                    max: MAX_TREE_ENTRY_LEN,
328                },
329            ),
330            (
331                entry(0o100644, b"", 1),
332                TreeEntryError::InvalidName {
333                    name: Vec::new(),
334                    reason: PathComponentError::Empty,
335                },
336            ),
337            (
338                entry(0o100644, b"a/b", 1),
339                TreeEntryError::InvalidName {
340                    name: b"a/b".to_vec(),
341                    reason: PathComponentError::ContainsSlash,
342                },
343            ),
344            (
345                entry(0o100644, b"..", 1),
346                TreeEntryError::InvalidName {
347                    name: b"..".to_vec(),
348                    reason: PathComponentError::DotDot,
349                },
350            ),
351            (
352                entry(MODE_TREE, b".GIT", 1),
353                TreeEntryError::InvalidName {
354                    name: b".GIT".to_vec(),
355                    reason: PathComponentError::DotGit,
356                },
357            ),
358            (
359                entry(0o100664, b"a", 1),
360                TreeEntryError::InvalidMode {
361                    name: b"a".to_vec(),
362                    mode: 0o100664,
363                },
364            ),
365            (
366                entry(0o100644, b"a", 0),
367                TreeEntryError::NullObjectId {
368                    name: b"a".to_vec(),
369                },
370            ),
371        ];
372        for (input, expected) in cases {
373            assert_eq!(
374                canonical_tree_entries([input], HashAlgo::Sha1).unwrap_err(),
375                expected
376            );
377        }
378    }
379
380    #[test]
381    fn rejects_object_ids_of_the_other_hash_algorithm() {
382        let err = canonical_tree_entries([entry(0o100644, b"a", 1)], HashAlgo::Sha256).unwrap_err();
383        assert!(matches!(
384            err,
385            TreeEntryError::HashAlgoMismatch {
386                expected: HashAlgo::Sha256,
387                actual: HashAlgo::Sha1,
388                ..
389            }
390        ));
391    }
392
393    #[test]
394    fn commit_data_terminates_message_and_renders_signatures() {
395        let when = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
396        let sig = Signature::new("A U Thor", "a@example.com", when).unwrap();
397        let data = NewCommit::new(oid(1), sig.clone(), sig, "subject")
398            .with_parents([oid(2)])
399            .to_commit_data();
400        assert_eq!(data.message, "subject\n");
401        assert_eq!(data.parents, vec![oid(2)]);
402        assert_eq!(data.author, "A U Thor <a@example.com> 1700000000 +0000");
403        assert!(data.extra_headers.is_empty());
404    }
405}