Skip to main content

sley_object/
lib.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2
3//! git-object — Git's object model: commits, trees, tags, and the raw encoded
4//! object framing they share.
5//!
6//! This crate carries the in-memory representations of Git's four object types
7//! ([`Commit`], [`Tree`], [`Tag`], and the blob payload carried inside
8//! [`EncodedObject`]) together with their parse/serialize routines and the
9//! [`parse_framed_object`] helper that decodes the `"<type> <len>\0<body>"`
10//! loose-object frame.
11//!
12//! [`Commit`] and [`Tag`] are parsed, canonical representations of the headers
13//! this crate understands. They are convenient for structured edits, but they
14//! are not byte-lossless round-trippers for signed objects, custom headers, or
15//! other raw object body details. Use [`EncodedObject`] whenever exact object
16//! bytes, object ids, or framed-object bytes must be preserved.
17
18use sley_core::{GitError, ObjectFormat, ObjectId, Result, Signature};
19use std::str::FromStr;
20
21pub use sley_core::BString;
22
23mod commit_create;
24mod identity;
25
26pub use commit_create::*;
27pub use identity::*;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum ObjectType {
31    Blob,
32    Tree,
33    Commit,
34    Tag,
35}
36
37impl ObjectType {
38    pub const fn as_str(self) -> &'static str {
39        match self {
40            Self::Blob => "blob",
41            Self::Tree => "tree",
42            Self::Commit => "commit",
43            Self::Tag => "tag",
44        }
45    }
46}
47
48impl FromStr for ObjectType {
49    type Err = GitError;
50
51    fn from_str(value: &str) -> Result<Self> {
52        match value {
53            "blob" => Ok(Self::Blob),
54            "tree" => Ok(Self::Tree),
55            "commit" => Ok(Self::Commit),
56            "tag" => Ok(Self::Tag),
57            other => Err(GitError::InvalidObject(format!(
58                "unknown object type {other}"
59            ))),
60        }
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct EncodedObject {
66    pub object_type: ObjectType,
67    pub body: Vec<u8>,
68}
69
70impl EncodedObject {
71    /// Create a raw encoded object body.
72    ///
73    /// This is the byte-exact API for preserving Git object contents. For
74    /// commit and tag objects that may contain signatures, continuation
75    /// headers, custom headers, or otherwise unknown data, keep the original
76    /// body here instead of parsing through [`Commit`] or [`Tag`].
77    pub fn new(object_type: ObjectType, body: impl Into<Vec<u8>>) -> Self {
78        Self {
79            object_type,
80            body: body.into(),
81        }
82    }
83
84    /// Return the exact loose-object frame bytes: `"<type> <len>\0<body>"`.
85    pub fn framed_bytes(&self) -> Vec<u8> {
86        let mut out = Vec::with_capacity(self.body.len() + 32);
87        out.extend_from_slice(self.object_type.as_str().as_bytes());
88        out.push(b' ');
89        out.extend_from_slice(self.body.len().to_string().as_bytes());
90        out.push(0);
91        out.extend_from_slice(&self.body);
92        out
93    }
94
95    /// Compute the object id from the raw body bytes.
96    pub fn object_id(&self, format: ObjectFormat) -> Result<ObjectId> {
97        sley_core::object_id_for_bytes(format, self.object_type.as_str(), &self.body)
98    }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Tree {
103    pub entries: Vec<TreeEntry>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct TreeEntry {
108    pub mode: u32,
109    pub name: BString,
110    pub oid: ObjectId,
111}
112
113/// A borrowed parse-view of a single entry in a raw tree object.
114///
115/// The `name` slice points into the original tree body. The object id is a
116/// fixed-size value parsed from the raw bytes, so iterating does not allocate
117/// entry names or build an intermediate entry list.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct TreeEntryRef<'a> {
120    pub mode: u32,
121    pub name: &'a [u8],
122    pub oid: ObjectId,
123}
124
125/// Fallibly iterates raw tree-object bytes without allocating entry names.
126#[derive(Debug, Clone)]
127pub struct TreeEntries<'a> {
128    format: ObjectFormat,
129    bytes: &'a [u8],
130    offset: usize,
131}
132
133impl<'a> TreeEntries<'a> {
134    pub const fn new(format: ObjectFormat, bytes: &'a [u8]) -> Self {
135        Self {
136            format,
137            bytes,
138            offset: 0,
139        }
140    }
141}
142
143impl<'a> Iterator for TreeEntries<'a> {
144    type Item = Result<TreeEntryRef<'a>>;
145
146    fn next(&mut self) -> Option<Self::Item> {
147        if self.offset >= self.bytes.len() {
148            return None;
149        }
150        match parse_tree_entry_ref(self.format, self.bytes, self.offset) {
151            Ok((entry, next_offset)) => {
152                self.offset = next_offset;
153                Some(Ok(entry))
154            }
155            Err(err) => {
156                self.offset = self.bytes.len();
157                Some(Err(err))
158            }
159        }
160    }
161}
162
163impl<'a> From<TreeEntryRef<'a>> for TreeEntry {
164    fn from(entry: TreeEntryRef<'a>) -> Self {
165        Self {
166            mode: entry.mode,
167            name: entry.name.into(),
168            oid: entry.oid,
169        }
170    }
171}
172
173impl Tree {
174    pub fn parse(format: ObjectFormat, bytes: &[u8]) -> Result<Self> {
175        let entries = TreeEntries::new(format, bytes)
176            .map(|entry| entry.map(TreeEntry::from))
177            .collect::<Result<Vec<_>>>()?;
178        Ok(Self { entries })
179    }
180
181    pub fn write(&self) -> Vec<u8> {
182        let mut out = Vec::new();
183        for entry in &self.entries {
184            out.extend_from_slice(format!("{:o}", entry.mode).as_bytes());
185            out.push(b' ');
186            out.extend_from_slice(entry.name.as_bytes());
187            out.push(0);
188            out.extend_from_slice(entry.oid.as_bytes());
189        }
190        out
191    }
192}
193
194fn parse_tree_entry_ref<'a>(
195    format: ObjectFormat,
196    bytes: &'a [u8],
197    offset: usize,
198) -> Result<(TreeEntryRef<'a>, usize)> {
199    let mode_end = bytes[offset..]
200        .iter()
201        .position(|byte| *byte == b' ')
202        .map(|relative| offset + relative)
203        .ok_or_else(|| GitError::InvalidFormat("unterminated tree mode".into()))?;
204    let mode_text = std::str::from_utf8(&bytes[offset..mode_end])
205        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
206    let mode = u32::from_str_radix(mode_text, 8)
207        .map_err(|_| GitError::InvalidFormat("invalid tree mode".into()))?;
208
209    let name_start = mode_end + 1;
210    let name_end = bytes[name_start..]
211        .iter()
212        .position(|byte| *byte == 0)
213        .map(|relative| name_start + relative)
214        .ok_or_else(|| GitError::InvalidFormat("unterminated tree path".into()))?;
215    if name_end == name_start {
216        return Err(GitError::InvalidFormat("empty tree path".into()));
217    }
218
219    let oid_start = name_end + 1;
220    let oid_end = oid_start
221        .checked_add(format.raw_len())
222        .ok_or_else(|| GitError::InvalidFormat("tree oid overflow".into()))?;
223    if oid_end > bytes.len() {
224        return Err(GitError::InvalidFormat("truncated tree object id".into()));
225    }
226
227    Ok((
228        TreeEntryRef {
229            mode,
230            name: &bytes[name_start..name_end],
231            oid: ObjectId::from_raw(format, &bytes[oid_start..oid_end])?,
232        },
233        oid_end,
234    ))
235}
236
237pub fn tree_entry_object_type(mode: u32) -> ObjectType {
238    match mode {
239        0o040000 => ObjectType::Tree,
240        0o160000 => ObjectType::Commit,
241        _ => ObjectType::Blob,
242    }
243}
244
245/// The five entry kinds Git allows inside a tree, each mapping to a fixed mode.
246///
247/// This is a *closed* domain used when *writing* trees; for reading arbitrary
248/// trees, keep the raw [`TreeEntry::mode`] and classify with
249/// [`EntryKind::from_mode`] (which returns `None` for non-canonical modes so
250/// they round-trip rather than being silently coerced).
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
252pub enum EntryKind {
253    /// A subtree (`040000`).
254    Tree,
255    /// A non-executable regular file (`100644`).
256    Blob,
257    /// An executable regular file (`100755`).
258    BlobExecutable,
259    /// A symbolic link (`120000`); the blob bytes are the link target and must
260    /// never be dereferenced.
261    Symlink,
262    /// A gitlink / submodule commit pointer (`160000`).
263    Commit,
264}
265
266impl EntryKind {
267    /// The octal tree-entry mode for this kind.
268    pub const fn mode(self) -> u32 {
269        match self {
270            Self::Tree => 0o040000,
271            Self::Blob => 0o100644,
272            Self::BlobExecutable => 0o100755,
273            Self::Symlink => 0o120000,
274            Self::Commit => 0o160000,
275        }
276    }
277
278    /// Classify a raw tree-entry mode, returning `None` for anything that is
279    /// not one of Git's canonical five.
280    pub const fn from_mode(mode: u32) -> Option<Self> {
281        match mode {
282            0o040000 => Some(Self::Tree),
283            0o100644 => Some(Self::Blob),
284            0o100755 => Some(Self::BlobExecutable),
285            0o120000 => Some(Self::Symlink),
286            0o160000 => Some(Self::Commit),
287            _ => None,
288        }
289    }
290
291    /// The object type an entry of this kind points at (a gitlink points at a
292    /// commit that lives in another repository).
293    pub const fn object_type(self) -> ObjectType {
294        match self {
295            Self::Tree => ObjectType::Tree,
296            Self::Commit => ObjectType::Commit,
297            _ => ObjectType::Blob,
298        }
299    }
300}
301
302impl From<EntryKind> for u32 {
303    fn from(kind: EntryKind) -> Self {
304        kind.mode()
305    }
306}
307
308impl TreeEntry {
309    /// Classify this entry's mode, if it is one of Git's canonical kinds.
310    pub fn kind(&self) -> Option<EntryKind> {
311        EntryKind::from_mode(self.mode)
312    }
313
314    pub fn is_tree(&self) -> bool {
315        self.mode == EntryKind::Tree.mode()
316    }
317
318    pub fn is_symlink(&self) -> bool {
319        self.mode == EntryKind::Symlink.mode()
320    }
321
322    pub fn is_gitlink(&self) -> bool {
323        self.mode == EntryKind::Commit.mode()
324    }
325
326    pub fn is_executable(&self) -> bool {
327        self.mode == EntryKind::BlobExecutable.mode()
328    }
329}
330
331impl TreeEntryRef<'_> {
332    /// Classify this entry's mode, if it is one of Git's canonical kinds.
333    pub fn kind(&self) -> Option<EntryKind> {
334        EntryKind::from_mode(self.mode)
335    }
336
337    pub fn is_tree(&self) -> bool {
338        self.mode == EntryKind::Tree.mode()
339    }
340
341    pub fn is_symlink(&self) -> bool {
342        self.mode == EntryKind::Symlink.mode()
343    }
344
345    pub fn is_gitlink(&self) -> bool {
346        self.mode == EntryKind::Commit.mode()
347    }
348
349    pub fn is_executable(&self) -> bool {
350        self.mode == EntryKind::BlobExecutable.mode()
351    }
352
353    pub fn to_owned(&self) -> TreeEntry {
354        TreeEntry {
355            mode: self.mode,
356            name: self.name.into(),
357            oid: self.oid,
358        }
359    }
360}
361
362/// Order two tree entries the way Git canonically sorts them: by name bytes,
363/// except that a subtree sorts as though its name ended in `/`. Writing a tree
364/// whose entries are in any other order produces a different (wrong) OID.
365pub fn tree_entry_cmp(
366    left_name: &[u8],
367    left_mode: u32,
368    right_name: &[u8],
369    right_mode: u32,
370) -> std::cmp::Ordering {
371    use std::cmp::Ordering;
372    let shared = left_name.len().min(right_name.len());
373    let name_order = left_name[..shared].cmp(&right_name[..shared]);
374    if name_order != Ordering::Equal {
375        return name_order;
376    }
377    let left_end = left_name.len() == shared;
378    let right_end = right_name.len() == shared;
379    match (left_end, right_end) {
380        (true, true) => Ordering::Equal,
381        (true, false) => tree_name_terminator(left_mode).cmp(&right_name[shared]),
382        (false, true) => left_name[shared].cmp(&tree_name_terminator(right_mode)),
383        (false, false) => Ordering::Equal,
384    }
385}
386
387fn tree_name_terminator(mode: u32) -> u8 {
388    if mode == 0o040000 { b'/' } else { 0 }
389}
390
391/// Builds a single tree level: deduplicates entries by name and emits them in
392/// Git's canonical order so the written object is byte-identical to Git's.
393///
394/// Start from [`TreeBuilder::new`] (empty) or [`TreeBuilder::from_tree`] (edit
395/// an existing level), [`upsert`](TreeBuilder::upsert) entries, then
396/// [`build`](TreeBuilder::build) / [`write`](TreeBuilder::write).
397#[derive(Debug, Clone, Default)]
398pub struct TreeBuilder {
399    entries: Vec<TreeEntry>,
400}
401
402impl TreeBuilder {
403    pub fn new() -> Self {
404        Self {
405            entries: Vec::new(),
406        }
407    }
408
409    /// Seed the builder with an existing tree level's entries.
410    pub fn from_tree(tree: Tree) -> Self {
411        Self {
412            entries: tree.entries,
413        }
414    }
415
416    /// Insert or replace the entry named `name` with one of Git's canonical
417    /// kinds.
418    pub fn upsert(&mut self, name: impl Into<BString>, kind: EntryKind, oid: ObjectId) {
419        self.upsert_raw(name, kind.mode(), oid);
420    }
421
422    /// Insert or replace using a raw mode (for round-tripping non-canonical
423    /// modes); prefer [`upsert`](TreeBuilder::upsert) for normal entries.
424    pub fn upsert_raw(&mut self, name: impl Into<BString>, mode: u32, oid: ObjectId) {
425        let name = name.into();
426        if let Some(entry) = self
427            .entries
428            .iter_mut()
429            .find(|entry| entry.name == name.as_bytes())
430        {
431            entry.mode = mode;
432            entry.oid = oid;
433        } else {
434            self.entries.push(TreeEntry { mode, name, oid });
435        }
436    }
437
438    /// Remove the entry named `name`, returning whether one was present.
439    pub fn remove(&mut self, name: &[u8]) -> bool {
440        if let Some(position) = self.entries.iter().position(|entry| entry.name == name) {
441            self.entries.swap_remove(position);
442            true
443        } else {
444            false
445        }
446    }
447
448    pub fn is_empty(&self) -> bool {
449        self.entries.is_empty()
450    }
451
452    pub fn len(&self) -> usize {
453        self.entries.len()
454    }
455
456    /// Collect into a [`Tree`] with entries in Git's canonical order.
457    pub fn build(self) -> Tree {
458        let mut entries = self.entries;
459        entries.sort_by(|left, right| {
460            tree_entry_cmp(
461                left.name.as_bytes(),
462                left.mode,
463                right.name.as_bytes(),
464                right.mode,
465            )
466        });
467        Tree { entries }
468    }
469
470    /// The canonical serialized tree body.
471    pub fn write(self) -> Vec<u8> {
472        self.build().write()
473    }
474
475    /// The OID this tree will have once written.
476    pub fn object_id(self, format: ObjectFormat) -> Result<ObjectId> {
477        EncodedObject::new(ObjectType::Tree, self.write()).object_id(format)
478    }
479}
480
481/// A parsed, canonical representation of the commit headers this crate
482/// understands.
483///
484/// `Commit` preserves `tree`, `parent`, `author`, `committer`, `encoding`, and
485/// message bytes. It intentionally does not retain unknown headers,
486/// continuation blocks such as `gpgsig`, mergetags, or their original ordering.
487/// Use [`EncodedObject`] when commit object bytes or object ids must be
488/// preserved exactly.
489#[derive(Debug, Clone, PartialEq, Eq)]
490pub struct Commit {
491    pub tree: ObjectId,
492    pub parents: Vec<ObjectId>,
493    pub author: Vec<u8>,
494    pub committer: Vec<u8>,
495    pub encoding: Option<Vec<u8>>,
496    pub message: Vec<u8>,
497}
498
499/// A borrowed parse-view of a raw commit object.
500///
501/// The identity, encoding, and message slices point into the original commit
502/// body. Object ids are parsed into fixed-size values while preserving the same
503/// validation behavior as [`Commit::parse`]. Like [`Commit`], this is a parsed
504/// canonical view of known fields rather than a byte-lossless view of every raw
505/// header.
506#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct CommitRef<'a> {
508    pub tree: ObjectId,
509    pub parents: Vec<ObjectId>,
510    pub author: &'a [u8],
511    pub committer: &'a [u8],
512    pub encoding: Option<&'a [u8]>,
513    pub message: &'a [u8],
514}
515
516impl Commit {
517    /// Parse a commit into the canonical typed representation.
518    ///
519    /// Unknown headers and continuation records are accepted but not retained.
520    /// Use [`EncodedObject`] for byte-exact commit preservation.
521    pub fn parse(format: ObjectFormat, bytes: &[u8]) -> Result<Self> {
522        Ok(Self::parse_ref(format, bytes)?.into())
523    }
524
525    pub fn parse_ref<'a>(format: ObjectFormat, bytes: &'a [u8]) -> Result<CommitRef<'a>> {
526        CommitRef::parse(format, bytes)
527    }
528
529    /// Serialize the canonical typed commit representation.
530    ///
531    /// The output contains only the fields represented by [`Commit`]; it is not
532    /// intended to reproduce raw input bytes that contained unknown headers,
533    /// signatures, or mergetags.
534    pub fn write(&self) -> Vec<u8> {
535        let mut out = Vec::new();
536        out.extend_from_slice(format!("tree {}\n", self.tree).as_bytes());
537        for parent in &self.parents {
538            out.extend_from_slice(format!("parent {parent}\n").as_bytes());
539        }
540        out.extend_from_slice(b"author ");
541        out.extend_from_slice(&self.author);
542        out.push(b'\n');
543        out.extend_from_slice(b"committer ");
544        out.extend_from_slice(&self.committer);
545        if let Some(encoding) = &self.encoding {
546            out.extend_from_slice(b"\nencoding ");
547            out.extend_from_slice(encoding);
548        }
549        out.extend_from_slice(b"\n\n");
550        out.extend_from_slice(&self.message);
551        out
552    }
553
554    /// Parse the raw [`author`](Commit::author) line into a typed
555    /// [`Signature`] parse-view, or `None` if the stored bytes are not a
556    /// well-formed git identity.
557    ///
558    /// This is a read-only lens: it does not touch the raw `author` bytes, which
559    /// remain the source of truth for [`Commit::write`]. The returned signature
560    /// re-serializes byte-identically to `author` (see
561    /// [`Signature::to_ident_bytes`]).
562    pub fn author_signature(&self) -> Option<Signature> {
563        Signature::from_ident_line(&self.author)
564    }
565
566    /// Parse the raw [`committer`](Commit::committer) line into a typed
567    /// [`Signature`] parse-view, or `None` if the stored bytes are not a
568    /// well-formed git identity. Read-only over the raw bytes, exactly like
569    /// [`Commit::author_signature`].
570    pub fn committer_signature(&self) -> Option<Signature> {
571        Signature::from_ident_line(&self.committer)
572    }
573}
574
575impl<'a> CommitRef<'a> {
576    pub fn parse(format: ObjectFormat, bytes: &'a [u8]) -> Result<Self> {
577        let split = bytes
578            .windows(2)
579            .position(|window| window == b"\n\n")
580            .ok_or_else(|| GitError::InvalidObject("commit missing message separator".into()))?;
581        let mut tree = None;
582        let mut parents = Vec::new();
583        let mut author = None;
584        let mut committer = None;
585        let mut encoding = None;
586        for line in bytes[..split].split(|byte| *byte == b'\n') {
587            if let Some(value) = line.strip_prefix(b"tree ") {
588                tree = Some(ObjectId::from_hex(format, ascii_header_value(value)?)?);
589            } else if let Some(value) = line.strip_prefix(b"parent ") {
590                parents.push(ObjectId::from_hex(format, ascii_header_value(value)?)?);
591            } else if let Some(value) = line.strip_prefix(b"author ") {
592                author = Some(value);
593            } else if let Some(value) = line.strip_prefix(b"committer ") {
594                committer = Some(value);
595            } else if let Some(value) = line.strip_prefix(b"encoding ") {
596                encoding = Some(value);
597            }
598        }
599        Ok(Self {
600            tree: tree.ok_or_else(|| GitError::InvalidObject("commit missing tree".into()))?,
601            parents,
602            author: author
603                .ok_or_else(|| GitError::InvalidObject("commit missing author".into()))?,
604            committer: committer
605                .ok_or_else(|| GitError::InvalidObject("commit missing committer".into()))?,
606            encoding,
607            message: &bytes[split + 2..],
608        })
609    }
610
611    pub fn to_owned(&self) -> Commit {
612        Commit {
613            tree: self.tree,
614            parents: self.parents.clone(),
615            author: self.author.to_vec(),
616            committer: self.committer.to_vec(),
617            encoding: self.encoding.map(<[u8]>::to_vec),
618            message: self.message.to_vec(),
619        }
620    }
621
622    /// Parse the raw [`author`](Commit::author) line into a typed
623    /// [`Signature`] parse-view, or `None` if the stored bytes are not a
624    /// well-formed git identity.
625    ///
626    /// This is a read-only lens: it does not touch the raw `author` bytes, which
627    /// remain the source of truth for [`Commit::write`]. The returned signature
628    /// re-serializes byte-identically to `author` (see
629    /// [`Signature::to_ident_bytes`]).
630    pub fn author_signature(&self) -> Option<Signature> {
631        Signature::from_ident_line(self.author)
632    }
633
634    /// Parse the raw [`committer`](Commit::committer) line into a typed
635    /// [`Signature`] parse-view, or `None` if the stored bytes are not a
636    /// well-formed git identity. Read-only over the raw bytes, exactly like
637    /// [`Commit::author_signature`].
638    pub fn committer_signature(&self) -> Option<Signature> {
639        Signature::from_ident_line(self.committer)
640    }
641}
642
643impl<'a> From<CommitRef<'a>> for Commit {
644    fn from(commit: CommitRef<'a>) -> Self {
645        Self {
646            tree: commit.tree,
647            parents: commit.parents,
648            author: commit.author.to_vec(),
649            committer: commit.committer.to_vec(),
650            encoding: commit.encoding.map(<[u8]>::to_vec),
651            message: commit.message.to_vec(),
652        }
653    }
654}
655
656/// A parsed, canonical representation of the annotated tag headers this crate
657/// understands.
658///
659/// `Tag` preserves `object`, `type`, `tag`, optional `tagger`, and message
660/// bytes. Parsed tags also retain their original body so parse/write can
661/// preserve annotated tag object ids exactly.
662#[derive(Debug, Clone, Eq)]
663pub struct Tag {
664    pub object: ObjectId,
665    pub object_type: ObjectType,
666    pub name: Vec<u8>,
667    pub tagger: Option<Vec<u8>>,
668    pub message: Vec<u8>,
669    pub raw_body: Option<Vec<u8>>,
670}
671
672/// A borrowed parse-view of a raw annotated tag object.
673///
674/// The tag name, tagger identity, and message slices point into the original
675/// tag body. The object id and object type are parsed into owned values while
676/// preserving the same validation behavior as [`Tag::parse`]. Like [`Tag`],
677/// this is a parsed canonical view of known fields rather than a byte-lossless
678/// view of every raw header.
679#[derive(Debug, Clone, PartialEq, Eq)]
680pub struct TagRef<'a> {
681    pub object: ObjectId,
682    pub object_type: ObjectType,
683    pub name: &'a [u8],
684    pub tagger: Option<&'a [u8]>,
685    pub message: &'a [u8],
686    pub raw_body: Option<&'a [u8]>,
687}
688
689impl PartialEq for Tag {
690    fn eq(&self, other: &Self) -> bool {
691        self.object == other.object
692            && self.object_type == other.object_type
693            && self.name == other.name
694            && self.tagger == other.tagger
695            && self.message == other.message
696    }
697}
698
699impl Tag {
700    /// Parse an annotated tag into the canonical typed representation.
701    ///
702    /// Unknown headers and continuation records are accepted but not retained.
703    /// Use [`EncodedObject`] for byte-exact tag preservation.
704    pub fn parse(format: ObjectFormat, bytes: &[u8]) -> Result<Self> {
705        Ok(Self::parse_ref(format, bytes)?.into())
706    }
707
708    pub fn parse_ref<'a>(format: ObjectFormat, bytes: &'a [u8]) -> Result<TagRef<'a>> {
709        TagRef::parse(format, bytes)
710    }
711
712    /// Serialize the canonical typed tag representation.
713    ///
714    /// The output contains only the fields represented by [`Tag`]; it is not
715    /// intended to reproduce raw input bytes that contained unknown headers or
716    /// signatures.
717    pub fn write(&self) -> Vec<u8> {
718        if let Some(raw) = &self.raw_body {
719            return raw.clone();
720        }
721        let mut out = Vec::new();
722        out.extend_from_slice(format!("object {}\n", self.object).as_bytes());
723        out.extend_from_slice(format!("type {}\n", self.object_type.as_str()).as_bytes());
724        out.extend_from_slice(b"tag ");
725        out.extend_from_slice(&self.name);
726        out.push(b'\n');
727        if let Some(tagger) = &self.tagger {
728            out.extend_from_slice(b"tagger ");
729            out.extend_from_slice(tagger);
730            out.push(b'\n');
731        }
732        out.push(b'\n');
733        out.extend_from_slice(&self.message);
734        out
735    }
736
737    /// Parse the raw [`tagger`](Tag::tagger) line into a typed [`Signature`]
738    /// parse-view.
739    ///
740    /// Returns `None` when the tag has no tagger header *or* when the stored
741    /// bytes are not a well-formed git identity — callers that need to tell
742    /// those apart should inspect [`Tag::tagger`] directly. This is a read-only
743    /// lens over the raw bytes, which stay the source of truth for
744    /// [`Tag::write`]; the returned signature re-serializes byte-identically to
745    /// the stored `tagger` line.
746    pub fn tagger_signature(&self) -> Option<Signature> {
747        Signature::from_ident_line(self.tagger.as_deref()?)
748    }
749}
750
751impl<'a> TagRef<'a> {
752    pub fn parse(format: ObjectFormat, bytes: &'a [u8]) -> Result<Self> {
753        let split = bytes.windows(2).position(|window| window == b"\n\n");
754        let (headers, message) = match split {
755            Some(split) => (&bytes[..split], &bytes[split + 2..]),
756            None => (bytes, &bytes[bytes.len()..]),
757        };
758        let mut object = None;
759        let mut object_type = None;
760        let mut name = None;
761        let mut tagger = None;
762        for line in headers.split(|byte| *byte == b'\n') {
763            if let Some(value) = line.strip_prefix(b"object ") {
764                object = Some(ObjectId::from_hex(format, ascii_header_value(value)?)?);
765            } else if let Some(value) = line.strip_prefix(b"type ") {
766                object_type = Some(ascii_header_value(value)?.parse()?);
767            } else if let Some(value) = line.strip_prefix(b"tag ") {
768                name = Some(value);
769            } else if let Some(value) = line.strip_prefix(b"tagger ") {
770                tagger = Some(value);
771            }
772        }
773        Ok(Self {
774            object: object.ok_or_else(|| GitError::InvalidObject("tag missing object".into()))?,
775            object_type: object_type
776                .ok_or_else(|| GitError::InvalidObject("tag missing type".into()))?,
777            name: name.ok_or_else(|| GitError::InvalidObject("tag missing name".into()))?,
778            tagger,
779            message,
780            raw_body: Some(bytes),
781        })
782    }
783
784    pub fn to_owned(&self) -> Tag {
785        Tag {
786            object: self.object,
787            object_type: self.object_type,
788            name: self.name.to_vec(),
789            tagger: self.tagger.map(<[u8]>::to_vec),
790            message: self.message.to_vec(),
791            raw_body: self.raw_body.map(<[u8]>::to_vec),
792        }
793    }
794
795    /// Parse the raw [`tagger`](Tag::tagger) line into a typed [`Signature`]
796    /// parse-view.
797    ///
798    /// Returns `None` when the tag has no tagger header *or* when the stored
799    /// bytes are not a well-formed git identity — callers that need to tell
800    /// those apart should inspect [`Tag::tagger`] directly. This is a read-only
801    /// lens over the raw bytes, which stay the source of truth for
802    /// [`Tag::write`]; the returned signature re-serializes byte-identically to
803    /// the stored `tagger` line.
804    pub fn tagger_signature(&self) -> Option<Signature> {
805        Signature::from_ident_line(self.tagger?)
806    }
807}
808
809impl<'a> From<TagRef<'a>> for Tag {
810    fn from(tag: TagRef<'a>) -> Self {
811        Self {
812            object: tag.object,
813            object_type: tag.object_type,
814            name: tag.name.to_vec(),
815            tagger: tag.tagger.map(<[u8]>::to_vec),
816            message: tag.message.to_vec(),
817            raw_body: tag.raw_body.map(<[u8]>::to_vec),
818        }
819    }
820}
821
822fn ascii_header_value(value: &[u8]) -> Result<&str> {
823    std::str::from_utf8(value).map_err(|err| GitError::InvalidObject(err.to_string()))
824}
825
826pub fn parse_framed_object(bytes: &[u8]) -> Result<EncodedObject> {
827    let nul = bytes
828        .iter()
829        .position(|byte| *byte == 0)
830        .ok_or_else(|| GitError::InvalidObject("missing object header terminator".into()))?;
831    let header = std::str::from_utf8(&bytes[..nul])
832        .map_err(|err| GitError::InvalidObject(err.to_string()))?;
833    let (kind, size) = header
834        .split_once(' ')
835        .ok_or_else(|| GitError::InvalidObject("missing object size".into()))?;
836    let size: usize = size
837        .parse()
838        .map_err(|_| GitError::InvalidObject("invalid object size".into()))?;
839    let body = &bytes[nul + 1..];
840    if body.len() != size {
841        return Err(GitError::InvalidObject(format!(
842            "object declared {size} bytes, found {}",
843            body.len()
844        )));
845    }
846    Ok(EncodedObject::new(kind.parse()?, body.to_vec()))
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852
853    #[test]
854    fn tree_builder_sorts_canonically_and_dedups() {
855        let format = ObjectFormat::Sha1;
856        let blob = ObjectId::empty_blob(format);
857        let subtree = ObjectId::empty_tree(format);
858        // Validate the infallible well-known constants while we're here.
859        assert_eq!(subtree.to_hex(), "4b825dc642cb6eb9a060e54bf8d69288fbee4904");
860        assert_eq!(blob.to_hex(), "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
861
862        let mut builder = TreeBuilder::new();
863        // Inserted out of order. The directory-suffix rule means "foo.txt"
864        // (blob) sorts before the "foo" subtree, because '.' (0x2e) < '/' (0x2f)
865        // — a plain byte sort of the names would (wrongly) put "foo" first.
866        builder.upsert("foo", EntryKind::Tree, subtree);
867        builder.upsert("a.txt", EntryKind::Blob, blob.clone());
868        builder.upsert("foo.txt", EntryKind::Blob, blob.clone());
869        // Last upsert for a name wins.
870        builder.upsert("a.txt", EntryKind::BlobExecutable, blob);
871
872        let tree = builder.build();
873        let names: Vec<&[u8]> = tree.entries.iter().map(|e| e.name.as_bytes()).collect();
874        assert_eq!(names, vec![&b"a.txt"[..], &b"foo.txt"[..], &b"foo"[..]]);
875        assert_eq!(tree.entries[0].mode, EntryKind::BlobExecutable.mode());
876        assert!(tree.entries[2].is_tree());
877    }
878
879    #[test]
880    fn entry_kind_round_trips_modes() {
881        for kind in [
882            EntryKind::Tree,
883            EntryKind::Blob,
884            EntryKind::BlobExecutable,
885            EntryKind::Symlink,
886            EntryKind::Commit,
887        ] {
888            assert_eq!(EntryKind::from_mode(kind.mode()), Some(kind));
889        }
890        assert_eq!(EntryKind::from_mode(0o100600), None);
891    }
892
893    #[test]
894    fn framed_object_round_trips() {
895        let object = EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec());
896        assert_eq!(
897            parse_framed_object(&object.framed_bytes()).expect("test operation should succeed"),
898            object
899        );
900    }
901
902    #[test]
903    fn encoded_raw_commit_with_multiline_gpgsig_preserves_bytes_and_id() {
904        let format = ObjectFormat::Sha1;
905        let tree = ObjectId::empty_tree(format);
906        let body = format!(
907            concat!(
908                "tree {tree}\n",
909                "author Signer <signer@example.invalid> 1700000000 +0000\n",
910                "committer Signer <signer@example.invalid> 1700000000 +0000\n",
911                "gpgsig -----BEGIN PGP SIGNATURE-----\n",
912                " \n",
913                " iQEzBAABCgAdFiEErawcommitbytescontract\n",
914                " =abcd\n",
915                " -----END PGP SIGNATURE-----\n",
916                "\n",
917                "signed commit\n",
918            ),
919            tree = tree,
920        )
921        .into_bytes();
922
923        assert_encoded_preserves_framed_bytes_and_id(ObjectType::Commit, body, format);
924    }
925
926    #[test]
927    fn encoded_raw_commit_with_mergetag_and_custom_headers_preserves_bytes_and_id() {
928        let format = ObjectFormat::Sha1;
929        let tree = ObjectId::empty_tree(format);
930        let parent = ObjectId::empty_blob(format);
931        let body = format!(
932            concat!(
933                "tree {tree}\n",
934                "parent {parent}\n",
935                "author Merger <merger@example.invalid> 1700000000 +0000\n",
936                "committer Merger <merger@example.invalid> 1700000001 +0000\n",
937                "x-review-id 42\n",
938                "mergetag object {parent}\n",
939                " type commit\n",
940                " tag imported-v1\n",
941                " tagger Tagger <tagger@example.invalid> 1699999999 +0000\n",
942                " \n",
943                " imported tag body\n",
944                " gpgsig -----BEGIN PGP SIGNATURE-----\n",
945                " nested-signature-line\n",
946                " -----END PGP SIGNATURE-----\n",
947                "x-sley-extra raw bytes stay here\n",
948                "\n",
949                "merge commit\n",
950            ),
951            tree = tree,
952            parent = parent,
953        )
954        .into_bytes();
955
956        assert_encoded_preserves_framed_bytes_and_id(ObjectType::Commit, body, format);
957    }
958
959    #[test]
960    fn encoded_raw_annotated_tag_with_signature_and_custom_headers_preserves_bytes_and_id() {
961        let format = ObjectFormat::Sha1;
962        let object = ObjectId::empty_blob(format);
963        let body = format!(
964            concat!(
965                "object {object}\n",
966                "type blob\n",
967                "tag signed-v1\n",
968                "tagger Tagger <tagger@example.invalid> 1700000000 -0000\n",
969                "x-release-channel stable\n",
970                "gpgsig -----BEGIN PGP SIGNATURE-----\n",
971                " tag-signature-line-1\n",
972                " tag-signature-line-2\n",
973                " -----END PGP SIGNATURE-----\n",
974                "\n",
975                "release notes\n",
976            ),
977            object = object,
978        )
979        .into_bytes();
980
981        assert_encoded_preserves_framed_bytes_and_id(ObjectType::Tag, body, format);
982    }
983
984    #[test]
985    fn tree_round_trips_entries() {
986        let blob = ObjectId::from_hex(
987            ObjectFormat::Sha1,
988            "ce013625030ba8dba906f756967f9e9ca394464a",
989        )
990        .expect("test operation should succeed");
991        let tree = Tree {
992            entries: vec![TreeEntry {
993                mode: 0o100644,
994                name: BString::from(b"hello.txt"),
995                oid: blob,
996            }],
997        };
998        assert_eq!(
999            Tree::parse(ObjectFormat::Sha1, &tree.write()).expect("test operation should succeed"),
1000            tree
1001        );
1002    }
1003
1004    #[test]
1005    fn tree_entries_iterates_without_name_allocations() {
1006        let format = ObjectFormat::Sha1;
1007        let blob = ObjectId::from_hex(format, "ce013625030ba8dba906f756967f9e9ca394464a")
1008            .expect("test operation should succeed");
1009        let subtree = ObjectId::empty_tree(format);
1010        let mut bytes = Vec::new();
1011
1012        let first_name_start = b"100644 ".len();
1013        write_tree_entry(&mut bytes, EntryKind::Blob.mode(), b"hello.txt", &blob);
1014        let second_name_start = bytes.len() + b"40000 ".len();
1015        write_tree_entry(&mut bytes, EntryKind::Tree.mode(), b"src", &subtree);
1016
1017        let mut entries = TreeEntries::new(format, &bytes);
1018        let first = entries
1019            .next()
1020            .expect("first entry")
1021            .expect("test operation should succeed");
1022        assert_eq!(first.mode, EntryKind::Blob.mode());
1023        assert_eq!(first.name, b"hello.txt");
1024        assert_eq!(first.oid, blob);
1025        assert_eq!(first.kind(), Some(EntryKind::Blob));
1026        assert!(std::ptr::eq(
1027            first.name.as_ptr(),
1028            bytes[first_name_start..].as_ptr()
1029        ));
1030
1031        let second = entries
1032            .next()
1033            .expect("second entry")
1034            .expect("test operation should succeed");
1035        assert_eq!(second.mode, EntryKind::Tree.mode());
1036        assert_eq!(second.name, b"src");
1037        assert_eq!(second.oid, subtree);
1038        assert!(second.is_tree());
1039        assert!(std::ptr::eq(
1040            second.name.as_ptr(),
1041            bytes[second_name_start..].as_ptr()
1042        ));
1043        assert!(entries.next().is_none());
1044
1045        let owned = Tree::parse(format, &bytes).expect("test operation should succeed");
1046        assert_eq!(owned.entries, vec![first.to_owned(), second.to_owned()]);
1047    }
1048
1049    #[test]
1050    fn tree_entries_reports_invalid_mode_path_and_truncated_oid() {
1051        let format = ObjectFormat::Sha1;
1052        let oid = ObjectId::empty_blob(format);
1053
1054        let mut invalid_mode = b"10088 bad\0".to_vec();
1055        invalid_mode.extend_from_slice(oid.as_bytes());
1056        assert_invalid_tree_entry(
1057            TreeEntries::new(format, &invalid_mode)
1058                .next()
1059                .expect("invalid mode result"),
1060            "invalid tree mode",
1061        );
1062
1063        let mut empty_path = b"100644 \0".to_vec();
1064        empty_path.extend_from_slice(oid.as_bytes());
1065        assert_invalid_tree_entry(
1066            TreeEntries::new(format, &empty_path)
1067                .next()
1068                .expect("empty path result"),
1069            "empty tree path",
1070        );
1071
1072        let mut truncated_oid = b"100644 bad\0".to_vec();
1073        truncated_oid.extend_from_slice(&oid.as_bytes()[..format.raw_len() - 1]);
1074        assert_invalid_tree_entry(
1075            TreeEntries::new(format, &truncated_oid)
1076                .next()
1077                .expect("truncated oid result"),
1078            "truncated tree object id",
1079        );
1080    }
1081
1082    #[test]
1083    fn tree_entry_ref_kind_helpers_match_entry_kinds() {
1084        let oid = ObjectId::null(ObjectFormat::Sha1);
1085
1086        let tree = TreeEntryRef {
1087            mode: EntryKind::Tree.mode(),
1088            name: b"dir",
1089            oid,
1090        };
1091        assert_eq!(tree.kind(), Some(EntryKind::Tree));
1092        assert!(tree.is_tree());
1093        assert!(!tree.is_symlink());
1094        assert!(!tree.is_gitlink());
1095        assert!(!tree.is_executable());
1096
1097        let symlink = TreeEntryRef {
1098            mode: EntryKind::Symlink.mode(),
1099            name: b"link",
1100            oid,
1101        };
1102        assert_eq!(symlink.kind(), Some(EntryKind::Symlink));
1103        assert!(symlink.is_symlink());
1104        assert!(!symlink.is_tree());
1105        assert!(!symlink.is_gitlink());
1106        assert!(!symlink.is_executable());
1107
1108        let executable = TreeEntryRef {
1109            mode: EntryKind::BlobExecutable.mode(),
1110            name: b"run",
1111            oid,
1112        };
1113        assert_eq!(executable.kind(), Some(EntryKind::BlobExecutable));
1114        assert!(executable.is_executable());
1115        assert!(!executable.is_tree());
1116        assert!(!executable.is_symlink());
1117        assert!(!executable.is_gitlink());
1118
1119        let gitlink = TreeEntryRef {
1120            mode: EntryKind::Commit.mode(),
1121            name: b"submodule",
1122            oid,
1123        };
1124        assert_eq!(gitlink.kind(), Some(EntryKind::Commit));
1125        assert!(gitlink.is_gitlink());
1126        assert!(!gitlink.is_tree());
1127        assert!(!gitlink.is_symlink());
1128        assert!(!gitlink.is_executable());
1129    }
1130
1131    #[test]
1132    fn commit_round_trips_headers_and_message() {
1133        let tree = ObjectId::from_hex(
1134            ObjectFormat::Sha1,
1135            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
1136        )
1137        .expect("test operation should succeed");
1138        let commit = Commit {
1139            tree,
1140            parents: Vec::new(),
1141            author: b"A U Thor <a@example.invalid> 0 +0000".to_vec(),
1142            committer: b"C O Mitter <c@example.invalid> 0 +0000".to_vec(),
1143            encoding: Some(b"ISO-8859-1".to_vec()),
1144            message: b"subject\n\nbody\n".to_vec(),
1145        };
1146        assert_eq!(
1147            Commit::parse(ObjectFormat::Sha1, &commit.write())
1148                .expect("test operation should succeed"),
1149            commit
1150        );
1151    }
1152
1153    #[test]
1154    fn commit_ref_borrows_headers_and_message() {
1155        let format = ObjectFormat::Sha1;
1156        let tree_hex = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1157        let parent_hex = "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15";
1158        let body = format!(
1159            "tree {tree_hex}\n\
1160             parent {parent_hex}\n\
1161             author A U Thor <a@example.invalid> 0 +0000\n\
1162             committer C O Mitter <c@example.invalid> 1 -0000\n\
1163             encoding UTF-8\n\
1164             \n\
1165             subject\n\nbody\n"
1166        )
1167        .into_bytes();
1168
1169        let commit = CommitRef::parse(format, &body).expect("test operation should succeed");
1170        assert_eq!(
1171            commit.tree,
1172            ObjectId::from_hex(format, tree_hex).expect("test operation should succeed")
1173        );
1174        assert_eq!(
1175            commit.parents,
1176            vec![ObjectId::from_hex(format, parent_hex).expect("test operation should succeed")]
1177        );
1178        assert_borrows_from(
1179            &body,
1180            commit.author,
1181            b"A U Thor <a@example.invalid> 0 +0000",
1182        );
1183        assert_borrows_from(
1184            &body,
1185            commit.committer,
1186            b"C O Mitter <c@example.invalid> 1 -0000",
1187        );
1188        assert_borrows_from(
1189            &body,
1190            commit.encoding.expect("test operation should succeed"),
1191            b"UTF-8",
1192        );
1193        assert_borrows_from(&body, commit.message, b"subject\n\nbody\n");
1194
1195        assert_eq!(
1196            Commit::parse_ref(format, &body).expect("test operation should succeed"),
1197            commit
1198        );
1199        assert_eq!(
1200            commit.to_owned(),
1201            Commit::parse(format, &body).expect("test operation should succeed")
1202        );
1203    }
1204
1205    #[test]
1206    fn commit_ref_accepts_non_utf8_headers_and_message() {
1207        let format = ObjectFormat::Sha1;
1208        let tree = ObjectId::empty_tree(format);
1209        let mut body = Vec::new();
1210        body.extend_from_slice(format!("tree {tree}\n").as_bytes());
1211        body.extend_from_slice(b"author J\xF6rg <j@example.invalid> 0 +0000\n");
1212        body.extend_from_slice(b"committer M\xFCller <m@example.invalid> 1 +0000\n");
1213        body.extend_from_slice(b"encoding ISO-8859-1\n\n");
1214        body.extend_from_slice(b"caf\xE9\n");
1215
1216        let commit = CommitRef::parse(format, &body).expect("non-utf8 commit parses");
1217        assert_eq!(commit.tree, tree);
1218        assert_borrows_from(&body, commit.author, b"J\xF6rg <j@example.invalid> 0 +0000");
1219        assert_borrows_from(
1220            &body,
1221            commit.committer,
1222            b"M\xFCller <m@example.invalid> 1 +0000",
1223        );
1224        assert_borrows_from(&body, commit.encoding.expect("encoding"), b"ISO-8859-1");
1225        assert_borrows_from(&body, commit.message, b"caf\xE9\n");
1226        assert_eq!(commit.to_owned().write(), body);
1227    }
1228
1229    #[test]
1230    fn commit_ref_rejects_missing_or_malformed_required_headers() {
1231        let format = ObjectFormat::Sha1;
1232        let valid_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1233        let valid_idents =
1234            b"author A U Thor <a@example.invalid> 0 +0000\ncommitter C O Mitter <c@example.invalid> 0 +0000\n\nmessage\n";
1235        let mut missing_tree = Vec::new();
1236        missing_tree.extend_from_slice(valid_idents);
1237        assert_invalid_object(
1238            CommitRef::parse(format, &missing_tree),
1239            "commit missing tree",
1240        );
1241
1242        let malformed_tree = b"tree not-an-object-id\nauthor A U Thor <a@example.invalid> 0 +0000\ncommitter C O Mitter <c@example.invalid> 0 +0000\n\nmessage\n";
1243        assert!(matches!(
1244            CommitRef::parse(format, malformed_tree),
1245            Err(GitError::InvalidObjectId(_))
1246        ));
1247
1248        let missing_committer =
1249            format!("tree {valid_tree}\nauthor A U Thor <a@example.invalid> 0 +0000\n\nmessage\n")
1250                .into_bytes();
1251        assert_invalid_object(
1252            CommitRef::parse(format, &missing_committer),
1253            "commit missing committer",
1254        );
1255    }
1256
1257    #[test]
1258    fn tag_round_trips_headers_and_message() {
1259        let object = ObjectId::from_hex(
1260            ObjectFormat::Sha1,
1261            "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15",
1262        )
1263        .expect("test operation should succeed");
1264        let tag = Tag {
1265            object,
1266            object_type: ObjectType::Commit,
1267            name: b"v1.0".to_vec(),
1268            tagger: Some(b"Example User <example@example.invalid> 0 +0000".to_vec()),
1269            message: b"release\n".to_vec(),
1270            raw_body: None,
1271        };
1272        assert_eq!(
1273            Tag::parse(ObjectFormat::Sha1, &tag.write()).expect("test operation should succeed"),
1274            tag
1275        );
1276    }
1277
1278    #[test]
1279    fn tag_ref_accepts_non_utf8_tagger_and_message() {
1280        let format = ObjectFormat::Sha1;
1281        let object = ObjectId::empty_blob(format);
1282        let mut body = Vec::new();
1283        body.extend_from_slice(format!("object {object}\n").as_bytes());
1284        body.extend_from_slice(b"type blob\n");
1285        body.extend_from_slice(b"tag v1.0\n");
1286        body.extend_from_slice(b"tagger J\xF6rg <j@example.invalid> 0 +0000\n\n");
1287        body.extend_from_slice(b"caf\xE9\n");
1288
1289        let tag = TagRef::parse(format, &body).expect("non-utf8 tag parses");
1290        assert_eq!(tag.object, object);
1291        assert_eq!(tag.object_type, ObjectType::Blob);
1292        assert_borrows_from(&body, tag.name, b"v1.0");
1293        assert_borrows_from(
1294            &body,
1295            tag.tagger.expect("tagger"),
1296            b"J\xF6rg <j@example.invalid> 0 +0000",
1297        );
1298        assert_borrows_from(&body, tag.message, b"caf\xE9\n");
1299        assert_eq!(tag.to_owned().write(), body);
1300    }
1301
1302    #[test]
1303    fn typed_commit_canonicalizes_but_tag_write_preserves_raw_body() {
1304        let format = ObjectFormat::Sha1;
1305        let tree = ObjectId::empty_tree(format);
1306        let raw_commit = format!(
1307            concat!(
1308                "tree {tree}\n",
1309                "author A U Thor <a@example.invalid> 0 +0000\n",
1310                "x-hidden keep only in raw encoded object\n",
1311                "committer C O Mitter <c@example.invalid> 0 +0000\n",
1312                "gpgsig -----BEGIN PGP SIGNATURE-----\n",
1313                " typed-parser-accepts-this\n",
1314                " -----END PGP SIGNATURE-----\n",
1315                "\n",
1316                "subject\n",
1317            ),
1318            tree = tree,
1319        )
1320        .into_bytes();
1321
1322        let commit = Commit::parse(format, &raw_commit).expect("test operation should succeed");
1323        assert_eq!(commit.tree, tree);
1324        assert_eq!(commit.author, b"A U Thor <a@example.invalid> 0 +0000");
1325        assert_eq!(commit.committer, b"C O Mitter <c@example.invalid> 0 +0000");
1326        assert_eq!(commit.message, b"subject\n");
1327
1328        let written_commit = commit.write();
1329        assert_ne!(written_commit, raw_commit);
1330        assert_bytes_not_contains(&written_commit, b"x-hidden");
1331        assert_bytes_not_contains(&written_commit, b"gpgsig");
1332
1333        let object = ObjectId::empty_blob(format);
1334        let raw_tag = format!(
1335            concat!(
1336                "object {object}\n",
1337                "type blob\n",
1338                "tag v1.0\n",
1339                "x-hidden keep only in raw encoded object\n",
1340                "tagger Example User <example@example.invalid> 0 +0000\n",
1341                "gpgsig -----BEGIN PGP SIGNATURE-----\n",
1342                " typed-parser-accepts-this-too\n",
1343                " -----END PGP SIGNATURE-----\n",
1344                "\n",
1345                "release\n",
1346            ),
1347            object = object,
1348        )
1349        .into_bytes();
1350
1351        let tag = Tag::parse(format, &raw_tag).expect("test operation should succeed");
1352        assert_eq!(tag.object, object);
1353        assert_eq!(tag.object_type, ObjectType::Blob);
1354        assert_eq!(tag.name, b"v1.0");
1355        assert_eq!(
1356            tag.tagger.as_deref(),
1357            Some(&b"Example User <example@example.invalid> 0 +0000"[..])
1358        );
1359        assert_eq!(tag.message, b"release\n");
1360
1361        let written_tag = tag.write();
1362        assert_eq!(written_tag, raw_tag);
1363        let original_oid = EncodedObject::new(ObjectType::Tag, raw_tag).object_id(format);
1364        let written_oid = EncodedObject::new(ObjectType::Tag, written_tag).object_id(format);
1365        assert_eq!(
1366            original_oid.expect("original tag oid"),
1367            written_oid.expect("written tag oid")
1368        );
1369    }
1370
1371    #[test]
1372    fn tag_parse_write_preserves_uppercase_object_and_header_only_body() {
1373        let format = ObjectFormat::Sha1;
1374        let object = ObjectId::empty_blob(format);
1375        let mut raw_tag = Vec::new();
1376        raw_tag.extend_from_slice(
1377            format!("object {}\n", object.to_string().to_uppercase()).as_bytes(),
1378        );
1379        raw_tag.extend_from_slice(b"type blob\n");
1380        raw_tag.extend_from_slice(b"tag v1.0\n");
1381        raw_tag.extend_from_slice(b"tagger Example <example@example.invalid> 0 +0000\n");
1382
1383        let tag = Tag::parse(format, &raw_tag).expect("header-only tag parses");
1384        assert_eq!(tag.object, object);
1385        assert_eq!(tag.message, b"");
1386        assert_eq!(tag.write(), raw_tag);
1387    }
1388
1389    #[test]
1390    fn tag_ref_borrows_name_tagger_and_message() {
1391        let format = ObjectFormat::Sha1;
1392        let object_hex = "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15";
1393        let body = format!(
1394            "object {object_hex}\n\
1395             type commit\n\
1396             tag v1.0-borrowed\n\
1397             tagger Example User <example@example.invalid> 0 +0000\n\
1398             \n\
1399             release notes\n"
1400        )
1401        .into_bytes();
1402
1403        let tag = TagRef::parse(format, &body).expect("test operation should succeed");
1404        assert_eq!(
1405            tag.object,
1406            ObjectId::from_hex(format, object_hex).expect("test operation should succeed")
1407        );
1408        assert_eq!(tag.object_type, ObjectType::Commit);
1409        assert_borrows_from(&body, tag.name, b"v1.0-borrowed");
1410        assert_borrows_from(
1411            &body,
1412            tag.tagger.expect("test operation should succeed"),
1413            b"Example User <example@example.invalid> 0 +0000",
1414        );
1415        assert_borrows_from(&body, tag.message, b"release notes\n");
1416
1417        assert_eq!(
1418            Tag::parse_ref(format, &body).expect("test operation should succeed"),
1419            tag
1420        );
1421        assert_eq!(
1422            tag.to_owned(),
1423            Tag::parse(format, &body).expect("test operation should succeed")
1424        );
1425    }
1426
1427    #[test]
1428    fn tag_ref_rejects_missing_or_malformed_required_headers() {
1429        let format = ObjectFormat::Sha1;
1430        let object_hex = "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15";
1431
1432        let missing_name = format!("object {object_hex}\ntype commit\n\nmessage\n").into_bytes();
1433        assert_invalid_object(TagRef::parse(format, &missing_name), "tag missing name");
1434
1435        let malformed_object = b"object not-an-object-id\ntype commit\ntag v1.0\n\nmessage\n";
1436        assert!(matches!(
1437            TagRef::parse(format, malformed_object),
1438            Err(GitError::InvalidObjectId(_))
1439        ));
1440
1441        let malformed_type =
1442            format!("object {object_hex}\ntype mystery\ntag v1.0\n\nmessage\n").into_bytes();
1443        assert_invalid_object(
1444            TagRef::parse(format, &malformed_type),
1445            "unknown object type mystery",
1446        );
1447    }
1448
1449    #[test]
1450    fn commit_signature_accessors_parse_raw_idents_without_changing_storage() {
1451        let tree = ObjectId::from_hex(
1452            ObjectFormat::Sha1,
1453            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
1454        )
1455        .expect("test operation should succeed");
1456        let author_raw = b"A U Thor <a@example.invalid> 1700000000 +0530".to_vec();
1457        let committer_raw = b"C O Mitter <c@example.invalid> 1700000001 -0000".to_vec();
1458        let commit = Commit {
1459            tree,
1460            parents: Vec::new(),
1461            author: author_raw.clone(),
1462            committer: committer_raw.clone(),
1463            encoding: None,
1464            message: b"subject\n".to_vec(),
1465        };
1466
1467        let author = commit.author_signature().expect("author parses");
1468        assert_eq!(author.name.as_bytes(), b"A U Thor");
1469        assert_eq!(author.email.as_bytes(), b"a@example.invalid");
1470        assert_eq!(author.time.seconds, 1_700_000_000);
1471        assert_eq!(author.time.timezone_offset_minutes, 330);
1472        assert!(!author.time.negative_utc);
1473        // The parse-view re-serializes to exactly the stored bytes.
1474        assert_eq!(author.to_ident_bytes(), author_raw);
1475
1476        let committer = commit.committer_signature().expect("committer parses");
1477        assert_eq!(committer.time.seconds, 1_700_000_001);
1478        // The committer used the -0000 sentinel; it must be preserved.
1479        assert!(committer.time.negative_utc);
1480        assert_eq!(committer.to_ident_bytes(), committer_raw);
1481
1482        // The accessors did not mutate the raw fields, and write() still emits
1483        // them verbatim.
1484        assert_eq!(commit.author, author_raw);
1485        assert_eq!(commit.committer, committer_raw);
1486        let written = commit.write();
1487        assert_eq!(
1488            Commit::parse(ObjectFormat::Sha1, &written).expect("test operation should succeed"),
1489            commit
1490        );
1491    }
1492
1493    #[test]
1494    fn commit_signature_accessor_is_none_for_malformed_ident() {
1495        let tree = ObjectId::from_hex(
1496            ObjectFormat::Sha1,
1497            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
1498        )
1499        .expect("test operation should succeed");
1500        let commit = Commit {
1501            tree,
1502            parents: Vec::new(),
1503            author: b"garbage without an email or time".to_vec(),
1504            committer: b"C O Mitter <c@example.invalid> 0 +0000".to_vec(),
1505            encoding: None,
1506            message: b"x\n".to_vec(),
1507        };
1508        assert!(commit.author_signature().is_none());
1509        assert!(commit.committer_signature().is_some());
1510    }
1511
1512    #[test]
1513    fn tag_signature_accessor_parses_tagger_and_handles_absence() {
1514        let object = ObjectId::from_hex(
1515            ObjectFormat::Sha1,
1516            "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15",
1517        )
1518        .expect("test operation should succeed");
1519        let tagger_raw = b"Example User <example@example.invalid> 1700000000 -0000".to_vec();
1520        let tag = Tag {
1521            object: object.clone(),
1522            object_type: ObjectType::Commit,
1523            name: b"v1.0".to_vec(),
1524            tagger: Some(tagger_raw.clone()),
1525            message: b"release\n".to_vec(),
1526            raw_body: None,
1527        };
1528        let tagger = tag.tagger_signature().expect("tagger parses");
1529        assert_eq!(tagger.name.as_bytes(), b"Example User");
1530        assert!(tagger.time.negative_utc);
1531        assert_eq!(tagger.to_ident_bytes(), tagger_raw);
1532        // Raw field and serialization unaffected.
1533        assert_eq!(tag.tagger.as_deref(), Some(tagger_raw.as_slice()));
1534
1535        // A tag with no tagger header yields None.
1536        let lightweight = Tag {
1537            object,
1538            object_type: ObjectType::Commit,
1539            name: b"v1.0".to_vec(),
1540            tagger: None,
1541            message: b"x\n".to_vec(),
1542            raw_body: None,
1543        };
1544        assert!(lightweight.tagger_signature().is_none());
1545    }
1546
1547    fn write_tree_entry(body: &mut Vec<u8>, mode: u32, name: &[u8], oid: &ObjectId) {
1548        body.extend_from_slice(format!("{:o}", mode).as_bytes());
1549        body.push(b' ');
1550        body.extend_from_slice(name);
1551        body.push(0);
1552        body.extend_from_slice(oid.as_bytes());
1553    }
1554
1555    fn assert_invalid_tree_entry(result: Result<TreeEntryRef<'_>>, expected: &str) {
1556        match result {
1557            Err(GitError::InvalidFormat(message)) => assert_eq!(message, expected),
1558            other => panic!("expected invalid format {expected:?}, got {other:?}"),
1559        }
1560    }
1561
1562    fn assert_invalid_object<T: std::fmt::Debug>(result: Result<T>, expected: &str) {
1563        match result {
1564            Err(GitError::InvalidObject(message)) => assert_eq!(message, expected),
1565            other => panic!("expected invalid object {expected:?}, got {other:?}"),
1566        }
1567    }
1568
1569    fn assert_encoded_preserves_framed_bytes_and_id(
1570        object_type: ObjectType,
1571        body: Vec<u8>,
1572        format: ObjectFormat,
1573    ) {
1574        let object = EncodedObject::new(object_type, body.clone());
1575        let expected_id = object
1576            .object_id(format)
1577            .expect("test operation should succeed");
1578        let framed = object.framed_bytes();
1579
1580        let parsed = parse_framed_object(&framed).expect("test operation should succeed");
1581        assert_eq!(parsed.object_type, object_type);
1582        assert_eq!(parsed.body, body);
1583        assert_eq!(
1584            parsed
1585                .object_id(format)
1586                .expect("test operation should succeed"),
1587            expected_id
1588        );
1589        assert_eq!(parsed.framed_bytes(), framed);
1590    }
1591
1592    fn assert_bytes_not_contains(haystack: &[u8], needle: &[u8]) {
1593        assert!(
1594            !haystack
1595                .windows(needle.len())
1596                .any(|window| window == needle),
1597            "expected bytes not to contain {:?}",
1598            String::from_utf8_lossy(needle)
1599        );
1600    }
1601
1602    fn assert_borrows_from(body: &[u8], slice: &[u8], expected: &[u8]) {
1603        assert_eq!(slice, expected);
1604        let offset = body
1605            .windows(expected.len())
1606            .position(|window| window == expected)
1607            .expect("expected slice appears in body");
1608        assert!(std::ptr::eq(slice.as_ptr(), body[offset..].as_ptr()));
1609    }
1610}