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 fork_mode = self.options.fork_mode;
424        let (tree, changed) = translate_tree_metadata(id, body, fork_mode, |child_id| {
425            let child = self.object(child_id, 0, depth + 1, new_pairs, normalized)?;
426            Ok((child, self.sink.kind_of(&child)))
427        })?;
428        *normalized |= changed;
429        let bytes = ser(id, &Object::Tree(tree))?;
430        self.sink.write_object(&bytes)
431    }
432
433    /// §3.2: importer-signed commit.
434    fn commit(
435        &mut self,
436        id: &Sha1Id,
437        body: &[u8],
438        new_pairs: &mut Vec<(Sha1Id, Hash)>,
439        normalized: &mut bool,
440    ) -> Result<Hash, BridgeError> {
441        let parsed = gitparse::parse_commit(body).map_err(|e| {
442            BridgeError::from(Refusal::Unparsable {
443                object: hash20(id),
444                detail: format!("commit: {e}"),
445            })
446        })?;
447        if parsed.committer.timestamp < 0 {
448            return Err(Refusal::NegativeTimestamp {
449                object: hash20(id),
450                timestamp: parsed.committer.timestamp,
451            }
452            .into());
453        }
454        if parsed.parents.len() > 1000 {
455            return Err(Refusal::TooManyParents { object: hash20(id) }.into());
456        }
457        if parsed.author.identity.is_empty() || parsed.author.identity.len() > 4096 {
458            return Err(Refusal::AuthorPayload { object: hash20(id) }.into());
459        }
460        let tree = self.object(&parsed.tree, 0, 0, new_pairs, normalized)?;
461        let mut parents = Vec::with_capacity(parsed.parents.len());
462        for p in &parsed.parents {
463            parents.push(self.object(p, 0, 0, new_pairs, normalized)?);
464        }
465        // Raw bytes: the full git object body (commit framing is
466        // recomputable from kind+len).
467        let raw = raw_git_bytes(GitObjKind::Commit, body);
468        (self.retain_raw)(id, &raw)?;
469
470        let mut commit = unsigned_commit(id, body, self.signer.public, tree, parents)?;
471        commit.signature = (self.signer.sign_commit)(&commit)?;
472        let bytes = ser(id, &Object::Commit(commit))?;
473        self.sink.write_object(&bytes)
474    }
475
476    /// §3.4: importer-signed tag.
477    fn tag(
478        &mut self,
479        id: &Sha1Id,
480        body: &[u8],
481        depth: usize,
482        new_pairs: &mut Vec<(Sha1Id, Hash)>,
483        normalized: &mut bool,
484    ) -> Result<Hash, BridgeError> {
485        let parsed = gitparse::parse_tag(body).map_err(|e| {
486            BridgeError::from(Refusal::Unparsable {
487                object: hash20(id),
488                detail: format!("tag: {e}"),
489            })
490        })?;
491        if crate::refname::check_tag_name(&parsed.name).is_err() {
492            return Err(Refusal::TagName { object: hash20(id) }.into());
493        }
494        let target = self.object(&parsed.object, depth + 1, 0, new_pairs, normalized)?;
495        let mut tag = unsigned_tag(
496            id,
497            body,
498            self.signer.public,
499            target,
500            self.sink.kind_of(&target),
501        )?;
502        let raw = raw_git_bytes(GitObjKind::Tag, body);
503        (self.retain_raw)(id, &raw)?;
504        tag.signature = (self.signer.sign_tag)(&tag)?;
505        let bytes = ser(id, &Object::Tag(tag))?;
506        self.sink.write_object(&bytes)
507    }
508}
509
510/// Translate Tree metadata without writing or signing. The resolver supplies
511/// child identities and kinds; callers must authenticate those correspondences.
512/// Returns whether a historic Git mode was normalized.
513///
514/// # Errors
515/// Returns the same typed policy refusals as the importer for invalid trees.
516pub fn translate_tree_metadata(
517    id: &Sha1Id,
518    body: &[u8],
519    fork_mode: bool,
520    mut resolve: impl FnMut(&Sha1Id) -> Result<(Hash, Option<ObjectType>), BridgeError>,
521) -> Result<(Tree, bool), BridgeError> {
522    let parsed = gitparse::parse_tree(body).map_err(|e| {
523        BridgeError::from(Refusal::Unparsable {
524            object: hash20(id),
525            detail: format!("tree: {e}"),
526        })
527    })?;
528    // Mirror the deserializer's entry-count cap (same pattern as
529    // the parents cap on commits): anything larger would store a
530    // signed tree the repo can never read back.
531    if parsed.len() > mkit_core::serialize::MAX_TREE_ENTRIES as usize {
532        return Err(Refusal::TooManyTreeEntries {
533            object: hash20(id),
534            count: parsed.len(),
535        }
536        .into());
537    }
538    let mut normalized = false;
539    let mut entries = Vec::with_capacity(parsed.len());
540    for e in parsed {
541        let mode = match gitparse::map_mode(&e.mode) {
542            ModeMapping::Canonical(m) => m,
543            ModeMapping::Normalized(m) => {
544                if fork_mode {
545                    return Err(Refusal::NormalizedModeInFork {
546                        object: hash20(id),
547                        mode: String::from_utf8_lossy(&e.mode).into_owned(),
548                    }
549                    .into());
550                }
551                normalized = true;
552                m
553            }
554            ModeMapping::Gitlink => {
555                return Err(Refusal::Gitlink {
556                    object: hash20(id),
557                    path: String::from_utf8_lossy(&e.name).into_owned(),
558                }
559                .into());
560            }
561            ModeMapping::Unknown => {
562                return Err(Refusal::UnknownTreeMode {
563                    object: hash20(id),
564                    mode: String::from_utf8_lossy(&e.mode).into_owned(),
565                }
566                .into());
567            }
568        };
569        if !TreeEntry::validate_name(&e.name) {
570            return Err(Refusal::TreeEntryName {
571                object: hash20(id),
572                name: String::from_utf8_lossy(&e.name).into_owned(),
573            }
574            .into());
575        }
576        let (child, actual_kind) = resolve(&e.id)?;
577        // The mode promised one kind; verify the TRANSLATED child
578        // actually is that kind (git tolerates e.g. mode 100644 →
579        // commit; mkit's model cannot). Best effort: a sink that
580        // cannot answer skips the check.
581        if let Some(kind) = actual_kind {
582            let ok = match mode {
583                EntryMode::Tree => kind == ObjectType::Tree,
584                _ => matches!(kind, ObjectType::Blob | ObjectType::ChunkedBlob),
585            };
586            if !ok {
587                return Err(Refusal::TreeEntryKind {
588                    object: hash20(id),
589                    name: String::from_utf8_lossy(&e.name).into_owned(),
590                }
591                .into());
592            }
593        }
594        entries.push(TreeEntry {
595            name: e.name,
596            mode,
597            object_hash: child,
598        });
599    }
600    // git order → mkit byte-lex order. Duplicate names are
601    // git-representable (file `a` + dir `a` sort apart under
602    // git's `name+"/"` key) but undecodable in mkit — the
603    // serializer does NOT check on write, so refuse here or the
604    // store gains a poisoned signed object.
605    entries.sort_by(|a, b| a.name.cmp(&b.name));
606    if entries.windows(2).any(|w| w[0].name == w[1].name) {
607        return Err(Refusal::DuplicateTreeEntry { object: hash20(id) }.into());
608    }
609    Ok((Tree { entries }, normalized))
610}
611
612/// Derive the import-v1 unsigned commit fields from Git bytes and resolved edges.
613/// No private key, object writes or mapping-cache mutation is required.
614///
615/// # Errors
616/// Rejects malformed or unrepresentable source fields and incorrect parent count.
617pub fn unsigned_commit(
618    id: &Sha1Id,
619    body: &[u8],
620    signer: [u8; 32],
621    tree: Hash,
622    parents: Vec<Hash>,
623) -> Result<Commit, BridgeError> {
624    let parsed =
625        gitparse::parse_commit(body).map_err(|e| BridgeError::Integrity(format!("commit: {e}")))?;
626    if parsed.committer.timestamp < 0 {
627        return Err(Refusal::NegativeTimestamp {
628            object: hash20(id),
629            timestamp: parsed.committer.timestamp,
630        }
631        .into());
632    }
633    if parsed.parents.len() > 1000 {
634        return Err(Refusal::TooManyParents { object: hash20(id) }.into());
635    }
636    if parsed.author.identity.is_empty() || parsed.author.identity.len() > 4096 {
637        return Err(Refusal::AuthorPayload { object: hash20(id) }.into());
638    }
639    if parents.len() != parsed.parents.len() {
640        return Err(BridgeError::Integrity(
641            "incorrect imported parent count".into(),
642        ));
643    }
644    let raw = raw_git_bytes(GitObjKind::Commit, body);
645    #[allow(clippy::cast_sign_loss)] // negative refused above
646    let timestamp = parsed.committer.timestamp as u64;
647    let commit = Commit {
648        tree_hash: tree,
649        parents,
650        author: Identity::opaque(parsed.author.identity),
651        signer,
652        message: parsed.message,
653        timestamp,
654        message_hash: mkit_core::hash::ZERO,
655        content_digest: mkit_core::hash::hash(&raw),
656        signature: [0u8; 64],
657    };
658    Ok(commit)
659}
660
661/// Derive import-v1 unsigned tag fields, including chunked target kinds.
662///
663/// # Errors
664/// Rejects malformed fields, illegal tag names and inconsistent target kinds.
665pub fn unsigned_tag(
666    id: &Sha1Id,
667    body: &[u8],
668    signer: [u8; 32],
669    target: Hash,
670    actual: Option<ObjectType>,
671) -> Result<Tag, BridgeError> {
672    let parsed =
673        gitparse::parse_tag(body).map_err(|e| BridgeError::Integrity(format!("tag: {e}")))?;
674    if crate::refname::check_tag_name(&parsed.name).is_err() {
675        return Err(Refusal::TagName { object: hash20(id) }.into());
676    }
677    let target_type = match parsed.target_type.as_slice() {
678        b"commit" => ObjectType::Commit,
679        b"tree" => ObjectType::Tree,
680        b"blob" => ObjectType::Blob,
681        b"tag" => ObjectType::Tag,
682        other => {
683            return Err(Refusal::Unparsable {
684                object: hash20(id),
685                detail: format!(
686                    "tag target type {:?} unknown",
687                    String::from_utf8_lossy(other)
688                ),
689            }
690            .into());
691        }
692    };
693    // The mkit target_type must reflect what the TRANSLATED target
694    // is: a >1MiB git blob became a chunked manifest. And the
695    // DECLARED type must match the actual target — a tag claiming
696    // `type commit` over a blob would sign an inconsistent mkit
697    // tag (git tolerates the lie; mkit's model must not).
698    let target_type = match (target_type, actual) {
699        (ObjectType::Blob, Some(ObjectType::ChunkedBlob)) => ObjectType::ChunkedBlob,
700        (declared, Some(actual)) if actual != declared => {
701            return Err(Refusal::Unparsable {
702                object: hash20(id),
703                detail: format!(
704                    "tag declares target type {declared:?} but the target is {actual:?}"
705                ),
706            }
707            .into());
708        }
709        (declared, _) => declared,
710    };
711    let (tagger_identity, timestamp) = match parsed.tagger {
712        Some(p) => {
713            if p.timestamp < 0 {
714                return Err(Refusal::NegativeTimestamp {
715                    object: hash20(id),
716                    timestamp: p.timestamp,
717                }
718                .into());
719            }
720            if p.identity.is_empty() || p.identity.len() > 4096 {
721                return Err(Refusal::AuthorPayload { object: hash20(id) }.into());
722            }
723            #[allow(clippy::cast_sign_loss)]
724            let ts = p.timestamp as u64;
725            (Identity::opaque(p.identity), ts)
726        }
727        // Historic tagger-less tags: a pinned sentinel identity
728        // and epoch 0 (deterministic; provenance retains truth).
729        None => (Identity::opaque(b"(no tagger)".to_vec()), 0),
730    };
731    let tag = Tag {
732        target,
733        target_type,
734        name: parsed.name,
735        tagger: tagger_identity,
736        signer,
737        message: parsed.message,
738        timestamp,
739        signature: [0u8; 64],
740    };
741    Ok(tag)
742}
743
744/// Serialize, mapping failure to a per-ref refusal: a serialize error
745/// here is always content-derived (a SPEC-OBJECTS cap the upstream
746/// object exceeds), never an environment fault — one hostile object
747/// must not abort the import of every other ref.
748fn ser(id: &Sha1Id, obj: &Object) -> Result<Vec<u8>, BridgeError> {
749    mkit_core::serialize(obj).map_err(|e| {
750        Refusal::Unrepresentable {
751            object: hash20(id),
752            detail: e.to_string(),
753        }
754        .into()
755    })
756}
757
758/// Rebuild the full `"<type> <len>\0" + body` git object bytes for
759/// retention + `content_digest` (SPEC-GIT-IMPORT §5: "raw git commit
760/// bytes" are the framed object bytes, matching `git cat-file`'s
761/// hashed form). Frames via the canonical [`GitObject::raw`] so the
762/// layout-critical header exists in exactly one place.
763fn raw_git_bytes(kind: GitObjKind, body: &[u8]) -> Vec<u8> {
764    crate::gitobj::GitObject {
765        gtype: kind.into(),
766        body: body.to_vec(),
767    }
768    .raw()
769}
770
771/// A `Sha1Id` widened into the 32-byte `Hash` slot Refusal uses for
772/// display (zero-padded; Display prints the meaningful prefix).
773fn hash20(id: &Sha1Id) -> Hash {
774    let mut h = [0u8; 32];
775    h[..20].copy_from_slice(id);
776    h
777}