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