Skip to main content

mkit_git_bridge/
import.rs

1//! The git→mkit import driver (SPEC-GIT-IMPORT §3).
2//!
3//! Pure translation engine: reads git objects through a [`GitSource`],
4//! writes serialized mkit objects through an [`ObjectSink`], signs
5//! through caller-supplied callbacks (the crate stays crypto-free —
6//! the CLI passes its `CommitSigner`), and records sha1→blake3 pairs
7//! plus retained raw commit/tag bytes through caller-supplied hooks.
8//!
9//! Everything here is deterministic given *(git bytes, signing key,
10//! import-spec version)*: deterministic Ed25519 means the engine can
11//! be re-run idempotently and the map is a rebuildable cache under
12//! the same key (SPEC-GIT-IMPORT §1.2).
13
14use crate::error::{BridgeError, Refusal};
15use crate::gitobj::Sha1Id;
16use crate::gitparse::{self, ModeMapping};
17use crate::gitsrc::{CatFileBatch, GitObjKind};
18use mkit_core::object::{
19    Blob, ChunkedBlob, Commit, EntryMode, Identity, Object, ObjectType, Tag, Tree, TreeEntry,
20};
21use mkit_core::{ChunkIterator, FastCdc, Hash};
22use std::collections::HashMap;
23
24/// SPEC-GIT-IMPORT §3.1: the normative chunking threshold.
25pub const CHUNK_THRESHOLD: u64 = mkit_core::worktree::CHUNK_THRESHOLD;
26
27/// SPEC-GIT-IMPORT §3.4: tag→tag chains beyond this depth refuse.
28pub const MAX_TAG_CHAIN: usize = 16;
29
30/// Tree nesting cap, matching mkit-core's `MAX_TREE_DEPTH` defense
31/// (the importer is the most untrusted boundary in the system).
32pub const MAX_TREE_DEPTH: usize = 128;
33
34/// This implementation's import-spec version (SPEC-GIT-IMPORT §1.2).
35pub const IMPORT_SPEC_VERSION: u32 = 1;
36
37/// Where the driver reads git objects from. [`CatFileBatch`] for real
38/// repositories; an in-memory map for hermetic tests/vectors.
39pub trait GitSource {
40    fn read_git(&mut self, id: &Sha1Id) -> Result<(GitObjKind, Vec<u8>), BridgeError>;
41}
42
43impl GitSource for CatFileBatch {
44    fn read_git(&mut self, id: &Sha1Id) -> Result<(GitObjKind, Vec<u8>), BridgeError> {
45        self.read(id)
46    }
47}
48
49/// In-memory source for tests and golden vectors.
50#[derive(Debug, Default)]
51pub struct MemGitSource(pub HashMap<Sha1Id, (GitObjKind, Vec<u8>)>);
52
53impl MemGitSource {
54    /// Insert a git object body, computing its real sha1 id.
55    pub fn put(&mut self, kind: GitObjKind, body: Vec<u8>) -> Sha1Id {
56        let id = crate::gitobj::GitObject {
57            gtype: kind.into(),
58            body: body.clone(),
59        }
60        .id();
61        self.0.insert(id, (kind, body));
62        id
63    }
64}
65
66impl GitSource for MemGitSource {
67    fn read_git(&mut self, id: &Sha1Id) -> Result<(GitObjKind, Vec<u8>), BridgeError> {
68        self.0
69            .get(id)
70            .cloned()
71            .ok_or_else(|| BridgeError::Source("object missing from memory source".into()))
72    }
73}
74
75/// Where serialized mkit objects land. Implemented for
76/// [`mkit_core::ObjectStore`] (per-object fsync) and the CLI's bulk
77/// writer; tests use an in-memory map.
78pub trait ObjectSink {
79    fn write_object(&mut self, bytes: &[u8]) -> Result<Hash, BridgeError>;
80
81    /// The stored object's type byte, when the sink can answer (used
82    /// only to disambiguate blob vs chunked-manifest tag targets).
83    fn kind_of(&self, _h: &Hash) -> Option<ObjectType> {
84        None
85    }
86}
87
88impl ObjectSink for mkit_core::ObjectStore {
89    fn write_object(&mut self, bytes: &[u8]) -> Result<Hash, BridgeError> {
90        self.write(bytes)
91            .map_err(|e| BridgeError::Source(format!("store write: {e}")))
92    }
93
94    fn kind_of(&self, h: &Hash) -> Option<ObjectType> {
95        self.read_object(h).ok().map(|o| o.object_type())
96    }
97}
98
99/// In-memory sink for tests.
100#[derive(Debug, Default)]
101pub struct MemSink(pub HashMap<Hash, Vec<u8>>);
102
103impl ObjectSink for MemSink {
104    fn write_object(&mut self, bytes: &[u8]) -> Result<Hash, BridgeError> {
105        let h = mkit_core::hash::hash(bytes);
106        self.0.insert(h, bytes.to_vec());
107        Ok(h)
108    }
109
110    fn kind_of(&self, h: &Hash) -> Option<ObjectType> {
111        self.0
112            .get(h)
113            .and_then(|b| mkit_core::deserialize(b).ok())
114            .map(|o| o.object_type())
115    }
116}
117
118/// Hook receiving (upstream sha1, framed raw git bytes) for retention.
119/// The explicit lifetime keeps the trait object bound to the
120/// borrower's scope (a bare `dyn` alias would default to `'static`).
121pub type RetainRawFn<'f> = dyn FnMut(&Sha1Id, &[u8]) -> Result<(), BridgeError> + 'f;
122
123/// Signing callbacks: given the unsigned object (zeroed signature),
124/// return the 64-byte signature. The signer pubkey is supplied
125/// separately so the engine can fill the `signer` field first.
126pub struct ImportSigner<'a> {
127    pub public: [u8; 32],
128    pub sign_commit: &'a mut dyn FnMut(&Commit) -> Result<[u8; 64], BridgeError>,
129    pub sign_tag: &'a mut dyn FnMut(&Tag) -> Result<[u8; 64], BridgeError>,
130}
131
132impl std::fmt::Debug for ImportSigner<'_> {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("ImportSigner")
135            .field("public", &crate::gitobj::bytes_hex(&self.public))
136            .finish_non_exhaustive()
137    }
138}
139
140/// Per-run options.
141#[derive(Debug, Clone, Copy, Default)]
142pub struct ImportOptions {
143    /// Recorded state-dir direction is `fork`: historic-mode
144    /// normalization must refuse instead (SPEC-GIT-IMPORT §3.3).
145    pub fork_mode: bool,
146}
147
148/// Outcome of importing one ref tip.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct ImportedRef {
151    /// The mkit hash of the translated tip object (commit or tag).
152    pub head: Hash,
153    /// New (sha1, blake3) pairs discovered by this call, in
154    /// dependency order — append these to the map cache.
155    pub new_pairs: Vec<(Sha1Id, Hash)>,
156    /// Whether any historic mode was normalized (declared-lossy warn).
157    pub normalized_modes: bool,
158}
159
160/// The import engine. `map` is the sha1→blake3 cache (load it from
161/// the state dir; pairs accumulate across calls).
162///
163/// Long unmapped parent chains should go through
164/// [`Importer::import_commits`] (parents-first order, recursion depth
165/// 1); [`Importer::import_ref`] recurses through unmapped parents.
166pub struct Importer<'a, S: GitSource, K: ObjectSink> {
167    pub source: &'a mut S,
168    pub sink: &'a mut K,
169    pub signer: ImportSigner<'a>,
170    pub map: &'a mut HashMap<Sha1Id, Hash>,
171    /// Retained-raw-bytes hook (commits + tags only); the CLI writes
172    /// these sha1-addressed under the state dir (SPEC-GIT-IMPORT §5).
173    pub retain_raw: &'a mut RetainRawFn<'a>,
174    pub options: ImportOptions,
175    /// Per-run scratch for the §3.3/§3.4 composition checks (tree
176    /// heights / tag chain lengths measured on map-cache hits).
177    pub depth_memo: DepthMemo,
178}
179
180/// Memoized tree heights and tag-chain lengths, keyed by git id.
181/// Needed because a map hit skips recursion: a previously-imported
182/// LEGAL subtree (or tag chain) re-referenced deeper in a new parent
183/// could compose past the normative caps without it.
184#[derive(Debug, Default)]
185pub struct DepthMemo {
186    heights: HashMap<Sha1Id, usize>,
187    chains: HashMap<Sha1Id, usize>,
188}
189
190impl<S: GitSource, K: ObjectSink> std::fmt::Debug for Importer<'_, S, K> {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        f.debug_struct("Importer").finish_non_exhaustive()
193    }
194}
195
196impl<S: GitSource, K: ObjectSink> Importer<'_, S, K> {
197    /// Import the closure of one upstream ref tip (commit or tag
198    /// object id, parents-first commit order is derived internally
199    /// for the in-memory path; CLI callers pass `rev_list` order via
200    /// [`Self::import_commits`] for incremental efficiency).
201    pub fn import_ref(&mut self, tip: &Sha1Id) -> Result<ImportedRef, BridgeError> {
202        let mut new_pairs = Vec::new();
203        let mut normalized = false;
204        let head = self.object(tip, 0, 0, &mut new_pairs, &mut normalized)?;
205        Ok(ImportedRef {
206            head,
207            new_pairs,
208            normalized_modes: normalized,
209        })
210    }
211
212    /// Import commits in caller-supplied parents-first order, then
213    /// return the map entry for `tip`. More efficient than
214    /// [`Self::import_ref`] for long histories (no deep recursion
215    /// through parent links).
216    ///
217    /// `new_pairs` and `normalized` are CALLER-owned and keep every
218    /// pair discovered before an error: the sink writes happen
219    /// regardless, so on a per-ref refusal the caller must still
220    /// persist those pairs — a later ref sharing that history
221    /// memo-hits without re-emitting them, and dropping them here
222    /// would leave the durable map missing objects that recorded
223    /// refs reference.
224    pub fn import_commits(
225        &mut self,
226        order: &[Sha1Id],
227        tip: &Sha1Id,
228        new_pairs: &mut Vec<(Sha1Id, Hash)>,
229        normalized: &mut bool,
230    ) -> Result<Hash, BridgeError> {
231        for id in order {
232            self.object(id, 0, 0, new_pairs, normalized)?;
233        }
234        self.object(tip, 0, 0, new_pairs, normalized)
235    }
236
237    /// Translate one git object (memoized through the map).
238    fn object(
239        &mut self,
240        id: &Sha1Id,
241        tag_depth: usize,
242        tree_depth: usize,
243        new_pairs: &mut Vec<(Sha1Id, Hash)>,
244        normalized: &mut bool,
245    ) -> Result<Hash, BridgeError> {
246        if let Some(h) = self.map.get(id).copied() {
247            // A hit skips recursion, so the depth caps must be
248            // enforced against the MEASURED shape of what the hit
249            // stands for — a legal 120-deep subtree wrapped 50 levels
250            // down by a later ref would otherwise compose past the
251            // §3.3 cap and store trees mkit's read paths refuse.
252            self.check_hit_budget(id, &h, tag_depth, tree_depth)?;
253            return Ok(h);
254        }
255        let (kind, body) = self.source.read_git(id)?;
256        let h = match kind {
257            GitObjKind::Blob => self.blob(id, &body, new_pairs)?,
258            GitObjKind::Tree => {
259                if tree_depth >= MAX_TREE_DEPTH {
260                    return Err(Refusal::TreeTooDeep { object: hash20(id) }.into());
261                }
262                self.tree(id, &body, tree_depth, new_pairs, normalized)?
263            }
264            GitObjKind::Commit => self.commit(id, &body, new_pairs, normalized)?,
265            GitObjKind::Tag => {
266                if tag_depth >= MAX_TAG_CHAIN {
267                    return Err(Refusal::TagChain { object: hash20(id) }.into());
268                }
269                self.tag(id, &body, tag_depth, new_pairs, normalized)?
270            }
271        };
272        self.map.insert(*id, h);
273        new_pairs.push((*id, h));
274        Ok(h)
275    }
276
277    /// Composition caps on a map-cache hit (no recursion happens, so
278    /// measure instead). Best effort by kind: a sink that cannot
279    /// answer skips (in-memory test sinks always can).
280    fn check_hit_budget(
281        &mut self,
282        id: &Sha1Id,
283        twin: &Hash,
284        tag_depth: usize,
285        tree_depth: usize,
286    ) -> Result<(), BridgeError> {
287        if tree_depth == 0 && tag_depth == 0 {
288            return Ok(());
289        }
290        match self.sink.kind_of(twin) {
291            Some(ObjectType::Tree) if tree_depth > 0 => {
292                let height = self.tree_height(id, MAX_TREE_DEPTH - tree_depth + 1)?;
293                if tree_depth + height > MAX_TREE_DEPTH {
294                    return Err(Refusal::TreeTooDeep { object: hash20(id) }.into());
295                }
296            }
297            Some(ObjectType::Tag) if tag_depth > 0 => {
298                let len = self.tag_chain_len(id, MAX_TAG_CHAIN - tag_depth + 1)?;
299                if tag_depth + len > MAX_TAG_CHAIN {
300                    return Err(Refusal::TagChain { object: hash20(id) }.into());
301                }
302            }
303            _ => {}
304        }
305        Ok(())
306    }
307
308    /// Height of a git tree (1 = leaf tree), measured via the source,
309    /// memoized, and capped: returns early once `budget` is exceeded
310    /// (the caller refuses anyway, so exactness past the cap is
311    /// pointless and the walk stays bounded).
312    fn tree_height(&mut self, id: &Sha1Id, budget: usize) -> Result<usize, BridgeError> {
313        if let Some(h) = self.depth_memo.heights.get(id) {
314            return Ok(*h);
315        }
316        if budget == 0 {
317            return Ok(MAX_TREE_DEPTH + 1);
318        }
319        let (kind, body) = self.source.read_git(id)?;
320        if kind != GitObjKind::Tree {
321            return Ok(0);
322        }
323        let parsed =
324            gitparse::parse_tree(&body).map_err(|e| BridgeError::Source(format!("tree: {e}")))?;
325        let mut max_child = 0usize;
326        for e in parsed {
327            if gitparse::map_mode(&e.mode) == ModeMapping::Canonical(EntryMode::Tree)
328                || gitparse::map_mode(&e.mode) == ModeMapping::Normalized(EntryMode::Tree)
329            {
330                max_child = max_child.max(self.tree_height(&e.id, budget - 1)?);
331                if max_child > MAX_TREE_DEPTH {
332                    break;
333                }
334            }
335        }
336        let h = 1 + max_child;
337        if h <= MAX_TREE_DEPTH {
338            // Over-cap values are budget-truncated, not exact — memoizing
339            // them would falsely refuse a later ref that reuses this
340            // subtree at a legal shallower depth.
341            self.depth_memo.heights.insert(*id, h);
342        }
343        Ok(h)
344    }
345
346    /// Length of a git tag chain starting at `id` (1 = tag pointing
347    /// at a non-tag), measured via the source, memoized, capped.
348    fn tag_chain_len(&mut self, id: &Sha1Id, budget: usize) -> Result<usize, BridgeError> {
349        if let Some(l) = self.depth_memo.chains.get(id) {
350            return Ok(*l);
351        }
352        if budget == 0 {
353            return Ok(MAX_TAG_CHAIN + 1);
354        }
355        let (kind, body) = self.source.read_git(id)?;
356        if kind != GitObjKind::Tag {
357            return Ok(0);
358        }
359        let parsed =
360            gitparse::parse_tag(&body).map_err(|e| BridgeError::Source(format!("tag: {e}")))?;
361        let len = 1 + self.tag_chain_len(&parsed.object, budget - 1)?;
362        if len <= MAX_TAG_CHAIN {
363            // Same rule as tree heights: only exact values are memoizable.
364            self.depth_memo.chains.insert(*id, len);
365        }
366        Ok(len)
367    }
368
369    /// §3.1: verbatim ≤ threshold, pinned `FastCDC` above it.
370    fn blob(
371        &mut self,
372        id: &Sha1Id,
373        body: &[u8],
374        new_pairs: &mut Vec<(Sha1Id, Hash)>,
375    ) -> Result<Hash, BridgeError> {
376        let _ = new_pairs; // chunk blobs are content-addressed extras, not mapped
377        if body.len() as u64 > mkit_core::worktree::MAX_FILE_BYTES {
378            return Err(Refusal::BlobTooLarge {
379                object: hash20(id),
380                size: body.len() as u64,
381            }
382            .into());
383        }
384        if body.len() as u64 <= CHUNK_THRESHOLD {
385            let bytes = ser(
386                id,
387                &Object::Blob(Blob {
388                    data: body.to_vec(),
389                }),
390            )?;
391            return self.sink.write_object(&bytes);
392        }
393        let mut chunks = Vec::new();
394        for b in ChunkIterator::new(FastCdc::v1(), body) {
395            let chunk = ser(
396                id,
397                &Object::Blob(Blob {
398                    data: body[b.offset..b.offset + b.length].to_vec(),
399                }),
400            )?;
401            chunks.push(self.sink.write_object(&chunk)?);
402        }
403        let manifest = ser(
404            id,
405            &Object::ChunkedBlob(ChunkedBlob {
406                total_size: body.len() as u64,
407                chunk_size: 0,
408                chunks,
409            }),
410        )?;
411        self.sink.write_object(&manifest)
412    }
413
414    /// §3.3: re-sort, validate names, map modes.
415    fn tree(
416        &mut self,
417        id: &Sha1Id,
418        body: &[u8],
419        depth: usize,
420        new_pairs: &mut Vec<(Sha1Id, Hash)>,
421        normalized: &mut bool,
422    ) -> Result<Hash, BridgeError> {
423        let parsed = gitparse::parse_tree(body).map_err(|e| {
424            BridgeError::from(Refusal::Unparsable {
425                object: hash20(id),
426                detail: format!("tree: {e}"),
427            })
428        })?;
429        // Mirror the deserializer's entry-count cap (same pattern as
430        // the parents cap on commits): anything larger would store a
431        // signed tree the repo can never read back.
432        if parsed.len() > mkit_core::serialize::MAX_TREE_ENTRIES as usize {
433            return Err(Refusal::TooManyTreeEntries {
434                object: hash20(id),
435                count: parsed.len(),
436            }
437            .into());
438        }
439        let mut entries = Vec::with_capacity(parsed.len());
440        for e in parsed {
441            let mode = match gitparse::map_mode(&e.mode) {
442                ModeMapping::Canonical(m) => m,
443                ModeMapping::Normalized(m) => {
444                    if self.options.fork_mode {
445                        return Err(Refusal::NormalizedModeInFork {
446                            object: hash20(id),
447                            mode: String::from_utf8_lossy(&e.mode).into_owned(),
448                        }
449                        .into());
450                    }
451                    *normalized = true;
452                    m
453                }
454                ModeMapping::Gitlink => {
455                    return Err(Refusal::Gitlink {
456                        object: hash20(id),
457                        path: String::from_utf8_lossy(&e.name).into_owned(),
458                    }
459                    .into());
460                }
461                ModeMapping::Unknown => {
462                    return Err(Refusal::UnknownTreeMode {
463                        object: hash20(id),
464                        mode: String::from_utf8_lossy(&e.mode).into_owned(),
465                    }
466                    .into());
467                }
468            };
469            if !TreeEntry::validate_name(&e.name) {
470                return Err(Refusal::TreeEntryName {
471                    object: hash20(id),
472                    name: String::from_utf8_lossy(&e.name).into_owned(),
473                }
474                .into());
475            }
476            let child = self.object(&e.id, 0, depth + 1, new_pairs, normalized)?;
477            // The mode promised one kind; verify the TRANSLATED child
478            // actually is that kind (git tolerates e.g. mode 100644 →
479            // commit; mkit's model cannot). Best effort: a sink that
480            // cannot answer skips the check.
481            if let Some(kind) = self.sink.kind_of(&child) {
482                let ok = match mode {
483                    EntryMode::Tree => kind == ObjectType::Tree,
484                    _ => matches!(kind, ObjectType::Blob | ObjectType::ChunkedBlob),
485                };
486                if !ok {
487                    return Err(Refusal::TreeEntryKind {
488                        object: hash20(id),
489                        name: String::from_utf8_lossy(&e.name).into_owned(),
490                    }
491                    .into());
492                }
493            }
494            entries.push(TreeEntry {
495                name: e.name,
496                mode,
497                object_hash: child,
498            });
499        }
500        // git order → mkit byte-lex order. Duplicate names are
501        // git-representable (file `a` + dir `a` sort apart under
502        // git's `name+"/"` key) but undecodable in mkit — the
503        // serializer does NOT check on write, so refuse here or the
504        // store gains a poisoned signed object.
505        entries.sort_by(|a, b| a.name.cmp(&b.name));
506        if entries.windows(2).any(|w| w[0].name == w[1].name) {
507            return Err(Refusal::DuplicateTreeEntry { object: hash20(id) }.into());
508        }
509        let bytes = ser(id, &Object::Tree(Tree { entries }))?;
510        self.sink.write_object(&bytes)
511    }
512
513    /// §3.2: importer-signed commit.
514    fn commit(
515        &mut self,
516        id: &Sha1Id,
517        body: &[u8],
518        new_pairs: &mut Vec<(Sha1Id, Hash)>,
519        normalized: &mut bool,
520    ) -> Result<Hash, BridgeError> {
521        let parsed = gitparse::parse_commit(body).map_err(|e| {
522            BridgeError::from(Refusal::Unparsable {
523                object: hash20(id),
524                detail: format!("commit: {e}"),
525            })
526        })?;
527        if parsed.committer.timestamp < 0 {
528            return Err(Refusal::NegativeTimestamp {
529                object: hash20(id),
530                timestamp: parsed.committer.timestamp,
531            }
532            .into());
533        }
534        if parsed.parents.len() > 1000 {
535            return Err(Refusal::TooManyParents { object: hash20(id) }.into());
536        }
537        if parsed.author.identity.is_empty() || parsed.author.identity.len() > 4096 {
538            return Err(Refusal::AuthorPayload { object: hash20(id) }.into());
539        }
540        let tree = self.object(&parsed.tree, 0, 0, new_pairs, normalized)?;
541        let mut parents = Vec::with_capacity(parsed.parents.len());
542        for p in &parsed.parents {
543            parents.push(self.object(p, 0, 0, new_pairs, normalized)?);
544        }
545        // Raw bytes: the full git object body (commit framing is
546        // recomputable from kind+len).
547        let raw = raw_git_bytes(GitObjKind::Commit, body);
548        (self.retain_raw)(id, &raw)?;
549
550        #[allow(clippy::cast_sign_loss)] // negative refused above
551        let timestamp = parsed.committer.timestamp as u64;
552        let mut commit = Commit {
553            tree_hash: tree,
554            parents,
555            author: Identity::opaque(parsed.author.identity),
556            signer: self.signer.public,
557            message: parsed.message,
558            timestamp,
559            message_hash: mkit_core::hash::ZERO,
560            content_digest: mkit_core::hash::hash(&raw),
561            signature: [0u8; 64],
562        };
563        commit.signature = (self.signer.sign_commit)(&commit)?;
564        let bytes = ser(id, &Object::Commit(commit))?;
565        self.sink.write_object(&bytes)
566    }
567
568    /// §3.4: importer-signed tag.
569    fn tag(
570        &mut self,
571        id: &Sha1Id,
572        body: &[u8],
573        depth: usize,
574        new_pairs: &mut Vec<(Sha1Id, Hash)>,
575        normalized: &mut bool,
576    ) -> Result<Hash, BridgeError> {
577        let parsed = gitparse::parse_tag(body).map_err(|e| {
578            BridgeError::from(Refusal::Unparsable {
579                object: hash20(id),
580                detail: format!("tag: {e}"),
581            })
582        })?;
583        if crate::refname::check_tag_name(&parsed.name).is_err() {
584            return Err(Refusal::TagName { object: hash20(id) }.into());
585        }
586        let target_type = match parsed.target_type.as_slice() {
587            b"commit" => ObjectType::Commit,
588            b"tree" => ObjectType::Tree,
589            b"blob" => ObjectType::Blob,
590            b"tag" => ObjectType::Tag,
591            other => {
592                return Err(Refusal::Unparsable {
593                    object: hash20(id),
594                    detail: format!(
595                        "tag target type {:?} unknown",
596                        String::from_utf8_lossy(other)
597                    ),
598                }
599                .into());
600            }
601        };
602        let target = self.object(&parsed.object, depth + 1, 0, new_pairs, normalized)?;
603        // The mkit target_type must reflect what the TRANSLATED target
604        // is: a >1MiB git blob became a chunked manifest. And the
605        // DECLARED type must match the actual target — a tag claiming
606        // `type commit` over a blob would sign an inconsistent mkit
607        // tag (git tolerates the lie; mkit's model must not).
608        let actual = self.sink.kind_of(&target);
609        let target_type = match (target_type, actual) {
610            (ObjectType::Blob, Some(ObjectType::ChunkedBlob)) => ObjectType::ChunkedBlob,
611            (declared, Some(actual)) if actual != declared => {
612                return Err(Refusal::Unparsable {
613                    object: hash20(id),
614                    detail: format!(
615                        "tag declares target type {declared:?} but the target is {actual:?}"
616                    ),
617                }
618                .into());
619            }
620            (declared, _) => declared,
621        };
622        let (tagger_identity, timestamp) = match parsed.tagger {
623            Some(p) => {
624                if p.timestamp < 0 {
625                    return Err(Refusal::NegativeTimestamp {
626                        object: hash20(id),
627                        timestamp: p.timestamp,
628                    }
629                    .into());
630                }
631                if p.identity.is_empty() || p.identity.len() > 4096 {
632                    return Err(Refusal::AuthorPayload { object: hash20(id) }.into());
633                }
634                #[allow(clippy::cast_sign_loss)]
635                let ts = p.timestamp as u64;
636                (Identity::opaque(p.identity), ts)
637            }
638            // Historic tagger-less tags: a pinned sentinel identity
639            // and epoch 0 (deterministic; provenance retains truth).
640            None => (Identity::opaque(b"(no tagger)".to_vec()), 0),
641        };
642        let raw = raw_git_bytes(GitObjKind::Tag, body);
643        (self.retain_raw)(id, &raw)?;
644        let mut tag = Tag {
645            target,
646            target_type,
647            name: parsed.name,
648            tagger: tagger_identity,
649            signer: self.signer.public,
650            message: parsed.message,
651            timestamp,
652            signature: [0u8; 64],
653        };
654        tag.signature = (self.signer.sign_tag)(&tag)?;
655        let bytes = ser(id, &Object::Tag(tag))?;
656        self.sink.write_object(&bytes)
657    }
658}
659
660/// Serialize, mapping failure to a per-ref refusal: a serialize error
661/// here is always content-derived (a SPEC-OBJECTS cap the upstream
662/// object exceeds), never an environment fault — one hostile object
663/// must not abort the import of every other ref.
664fn ser(id: &Sha1Id, obj: &Object) -> Result<Vec<u8>, BridgeError> {
665    mkit_core::serialize(obj).map_err(|e| {
666        Refusal::Unrepresentable {
667            object: hash20(id),
668            detail: e.to_string(),
669        }
670        .into()
671    })
672}
673
674/// Rebuild the full `"<type> <len>\0" + body` git object bytes for
675/// retention + `content_digest` (SPEC-GIT-IMPORT §5: "raw git commit
676/// bytes" are the framed object bytes, matching `git cat-file`'s
677/// hashed form). Frames via the canonical [`GitObject::raw`] so the
678/// layout-critical header exists in exactly one place.
679fn raw_git_bytes(kind: GitObjKind, body: &[u8]) -> Vec<u8> {
680    crate::gitobj::GitObject {
681        gtype: kind.into(),
682        body: body.to_vec(),
683    }
684    .raw()
685}
686
687/// A `Sha1Id` widened into the 32-byte `Hash` slot Refusal uses for
688/// display (zero-padded; Display prints the meaningful prefix).
689fn hash20(id: &Sha1Id) -> Hash {
690    let mut h = [0u8; 32];
691    h[..20].copy_from_slice(id);
692    h
693}