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