Skip to main content

mkit_git_bridge/
reconstruct.rs

1//! Verification-grade inverse of [`crate::translate`]
2//! (SPEC-GIT-BRIDGE §9).
3//!
4//! **Not an import path.** Every function here is defined only on git
5//! objects the v1 mapping can emit. Two mechanisms enforce that, and
6//! it matters which catches what: a handful of parse-time rejections
7//! (the reserved `mkit-remix-source` carrier, a malformed
8//! `mkit-object-type`) surface as [`BridgeError::NotBridgeObject`];
9//! EVERYTHING ELSE — header order, duplicate or unknown `mkit-*`
10//! headers, foreign modes (`160000`), any off-spec shape — fails
11//! closed through the *re-translation equality check* (rebuild the
12//! mkit object, translate it forward, require byte equality with the
13//! input), surfacing as [`BridgeError::Integrity`]. There is NO
14//! header whitelist: the equality check IS the load-bearing guard, so
15//! do not weaken it expecting one.
16
17use crate::author;
18use crate::error::BridgeError;
19use crate::gitobj::{GitObject, GitType, Sha1Id, sha1_from_hex};
20use crate::headers;
21use crate::translate;
22use mkit_core::object::{
23    Blob, ChunkedBlob, Commit, EntryMode, Object, ObjectType, Tag, Tree, TreeEntry,
24};
25use mkit_core::worktree::CHUNK_THRESHOLD;
26use mkit_core::{ChunkIterator, FastCdc, Hash};
27use std::collections::HashMap;
28
29/// A reconstructed mkit object: its serialized v1 bytes and BLAKE3
30/// hash. `extras` carries the chunk blobs a large flattened blob
31/// re-chunks into (empty for everything else).
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Reconstructed {
34    pub hash: Hash,
35    pub bytes: Vec<u8>,
36    pub object: Object,
37    pub extras: Vec<(Hash, Vec<u8>)>,
38}
39
40fn finish(object: Object, extras: Vec<(Hash, Vec<u8>)>) -> Result<Reconstructed, BridgeError> {
41    let bytes = mkit_core::serialize(&object)
42        .map_err(|e| BridgeError::Integrity(format!("reserialize: {e}")))?;
43    // §9 validity clause: the rebuilt bytes MUST deserialize under
44    // SPEC-OBJECTS. Re-translation byte-equality proves bridge shape,
45    // but only this proves the result is a legal mkit object (it
46    // catches e.g. forged git trees with `.git`/duplicate entry
47    // names, which no mkit source could have produced).
48    match mkit_core::deserialize(&bytes) {
49        Ok(round) if round == object => {}
50        Ok(_) => {
51            return Err(BridgeError::Integrity(
52                "reconstructed bytes round-trip to a different object".into(),
53            ));
54        }
55        Err(e) => {
56            return Err(BridgeError::NotBridgeObject(format!(
57                "reconstructed object is not legal under SPEC-OBJECTS: {e}"
58            )));
59        }
60    }
61    // Address by the dispatched id: a merkle BMT root for Tree/ChunkedBlob,
62    // BLAKE3 of the bytes for everything else — matching how the store keys
63    // the same object (store::object_id_from_bytes).
64    let hash = object
65        .id()
66        .map_err(|e| BridgeError::Integrity(format!("object id: {e}")))?;
67    Ok(Reconstructed {
68        hash,
69        bytes,
70        object,
71        extras,
72    })
73}
74
75/// §9 blob rule: ≤ 1 MiB → plain blob; larger → pinned-FastCDC
76/// chunk blobs + manifest (mirrors `worktree::store_file_object`).
77pub fn reconstruct_blob(body: &[u8]) -> Result<Reconstructed, BridgeError> {
78    if body.len() as u64 <= CHUNK_THRESHOLD {
79        return finish(
80            Object::Blob(Blob {
81                data: body.to_vec(),
82            }),
83            Vec::new(),
84        );
85    }
86    let mut extras = Vec::new();
87    let chunks: Vec<Hash> = ChunkIterator::new(FastCdc::v1(), body)
88        .map(|b| {
89            let chunk = Object::Blob(Blob {
90                data: body[b.offset..b.offset + b.length].to_vec(),
91            });
92            let bytes = mkit_core::serialize(&chunk)
93                .map_err(|e| BridgeError::Integrity(format!("chunk serialize: {e}")))?;
94            let h = mkit_core::hash::hash(&bytes);
95            extras.push((h, bytes));
96            Ok::<_, BridgeError>(h)
97        })
98        .collect::<Result<_, _>>()?;
99    let manifest = Object::ChunkedBlob(ChunkedBlob {
100        total_size: body.len() as u64,
101        chunk_size: 0,
102        chunks,
103    });
104    finish(manifest, extras)
105}
106
107/// §9 tree rule. `resolve` maps a child git id back to the BLAKE3 of
108/// the already-reconstructed child (bottom-up order is the caller's
109/// job).
110pub fn reconstruct_tree(
111    body: &[u8],
112    resolve: &impl Fn(&Sha1Id) -> Option<Hash>,
113) -> Result<Reconstructed, BridgeError> {
114    let mut entries = Vec::new();
115    let mut local: HashMap<Hash, Sha1Id> = HashMap::new();
116    let mut rest = body;
117    while !rest.is_empty() {
118        let sp = rest
119            .iter()
120            .position(|&b| b == b' ')
121            .ok_or_else(|| not_bridge("tree entry missing mode terminator"))?;
122        let mode = match &rest[..sp] {
123            b"100644" => EntryMode::Blob,
124            b"40000" => EntryMode::Tree,
125            b"120000" => EntryMode::Symlink,
126            b"100755" => EntryMode::Executable,
127            other => {
128                return Err(not_bridge(&format!(
129                    "git tree mode {:?} has no mkit equivalent",
130                    String::from_utf8_lossy(other)
131                )));
132            }
133        };
134        rest = &rest[sp + 1..];
135        let nul = rest
136            .iter()
137            .position(|&b| b == 0)
138            .ok_or_else(|| not_bridge("tree entry missing NUL"))?;
139        let name = rest[..nul].to_vec();
140        rest = &rest[nul + 1..];
141        if rest.len() < 20 {
142            return Err(not_bridge("tree entry truncated id"));
143        }
144        let mut id = [0u8; 20];
145        id.copy_from_slice(&rest[..20]);
146        rest = &rest[20..];
147        let child = resolve(&id).ok_or_else(|| not_bridge("tree child id not reconstructible"))?;
148        local.insert(child, id);
149        entries.push(TreeEntry {
150            name,
151            mode,
152            object_hash: child,
153        });
154    }
155    // mkit canonical order: byte-lex on the raw name.
156    entries.sort_by(|a, b| a.name.cmp(&b.name));
157    let tree = Tree { entries };
158    // Prove exact bridge shape by re-translation.
159    let retrans = translate::translate_tree(&tree, &|h| local.get(h).copied())?;
160    if retrans.body != body {
161        return Err(BridgeError::Integrity(
162            "tree re-translation mismatch (not a bridge-emitted tree)".into(),
163        ));
164    }
165    finish(Object::Tree(tree), Vec::new())
166}
167
168/// §9 commit rule: self-contained (everything rides in headers).
169pub fn reconstruct_commit(body: &[u8]) -> Result<Reconstructed, BridgeError> {
170    let parsed = ParsedBody::parse(body)?;
171    parsed.check_schema()?;
172    let tree_id = parsed.required_git_id("tree")?;
173    let parent_ids = parsed.all_git_ids("parent")?;
174    let author_line = parsed.required(b"author")?;
175    let timestamp = author::parse_timestamp(author_line)
176        .ok_or_else(|| not_bridge("author line is not bridge-synthesized"))?;
177    let identity = headers::parse_identity(parsed.required_str(headers::MKIT_AUTHOR)?)
178        .ok_or_else(|| not_bridge("mkit-author header malformed"))?;
179    let commit = Commit {
180        tree_hash: parsed.required_hash(headers::MKIT_TREE)?,
181        parents: parsed.all_hashes(headers::MKIT_PARENT)?,
182        author: identity,
183        signer: parsed.required_hash(headers::MKIT_SIGNER)?,
184        message: parsed.message.to_vec(),
185        timestamp,
186        message_hash: parsed
187            .optional_hash(headers::MKIT_MESSAGE_HASH)?
188            .unwrap_or(mkit_core::hash::ZERO),
189        content_digest: parsed
190            .optional_hash(headers::MKIT_CONTENT_DIGEST)?
191            .unwrap_or(mkit_core::hash::ZERO),
192        signature: parsed.required_signature(headers::MKIT_SIGNATURE)?,
193    };
194    if commit.parents.len() != parent_ids.len() {
195        return Err(not_bridge("parent / mkit-parent count mismatch"));
196    }
197    let probe = mkit_core::hash::ZERO; // hash input unused by translate_commit checks
198    let retrans = translate::translate_commit(&probe, &commit, &tree_id, &parent_ids)?;
199    if retrans.body != body {
200        return Err(BridgeError::Integrity(
201            "commit re-translation mismatch (not a bridge-emitted commit)".into(),
202        ));
203    }
204    finish(Object::Commit(commit), Vec::new())
205}
206
207/// §9 tag rule: self-contained like commits.
208pub fn reconstruct_tag(body: &[u8]) -> Result<Reconstructed, BridgeError> {
209    let parsed = ParsedBody::parse(body)?;
210    parsed.check_schema()?;
211    let target_id = parsed.required_git_id("object")?;
212    let name = parsed.required(b"tag")?.to_vec();
213    let tagger_line = parsed.required(b"tagger")?;
214    let timestamp = author::parse_timestamp(tagger_line)
215        .ok_or_else(|| not_bridge("tagger line is not bridge-synthesized"))?;
216    let tagger = headers::parse_identity(parsed.required_str(headers::MKIT_TAGGER)?)
217        .ok_or_else(|| not_bridge("mkit-tagger header malformed"))?;
218    let tt_hex = parsed.required_str(headers::MKIT_TARGET_TYPE)?;
219    let tt_byte = crate::gitobj::bytes_from_hex(tt_hex, 1)
220        .ok_or_else(|| not_bridge("mkit-target-type malformed"))?[0];
221    // Only target types the v1 mapping can emit (§7.1): remix/delta
222    // targets are refused at translation time and unknown bytes are
223    // future formats.
224    let target_type = match tt_byte {
225        0x01 => ObjectType::Blob,
226        0x02 => ObjectType::Tree,
227        0x03 => ObjectType::Commit,
228        0x05 => ObjectType::ChunkedBlob,
229        0x07 => ObjectType::Tag,
230        _ => return Err(not_bridge("mkit-target-type not bridge-emittable")),
231    };
232    let tag = Tag {
233        target: parsed.required_hash(headers::MKIT_TARGET)?,
234        target_type,
235        name,
236        tagger,
237        signer: parsed.required_hash(headers::MKIT_SIGNER)?,
238        message: parsed.message.to_vec(),
239        timestamp,
240        signature: parsed.required_signature(headers::MKIT_SIGNATURE)?,
241    };
242    let probe = mkit_core::hash::ZERO;
243    let retrans = translate::translate_tag(&probe, &tag, &target_id)?;
244    if retrans.body != body {
245        return Err(BridgeError::Integrity(
246            "tag re-translation mismatch (not a bridge-emitted tag)".into(),
247        ));
248    }
249    finish(Object::Tag(tag), Vec::new())
250}
251
252/// Dispatch on git type.
253pub fn reconstruct(
254    obj: &GitObject,
255    resolve: &impl Fn(&Sha1Id) -> Option<Hash>,
256) -> Result<Reconstructed, BridgeError> {
257    match obj.gtype {
258        GitType::Blob => reconstruct_blob(&obj.body),
259        GitType::Tree => reconstruct_tree(&obj.body, resolve),
260        GitType::Commit => reconstruct_commit(&obj.body),
261        GitType::Tag => reconstruct_tag(&obj.body),
262    }
263}
264
265fn not_bridge(msg: &str) -> BridgeError {
266    BridgeError::NotBridgeObject(msg.to_owned())
267}
268
269// ─── header-block parsing ───────────────────────────────────────────
270
271struct ParsedBody<'a> {
272    headers: Vec<(&'a [u8], &'a [u8])>,
273    message: &'a [u8],
274}
275
276impl<'a> ParsedBody<'a> {
277    fn parse(body: &'a [u8]) -> Result<Self, BridgeError> {
278        let split = body
279            .windows(2)
280            .position(|w| w == b"\n\n")
281            .ok_or_else(|| not_bridge("no header/message separator"))?;
282        let (head, message) = (&body[..=split], &body[split + 2..]);
283        let mut headers = Vec::new();
284        for line in head.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
285            if line.starts_with(b" ") {
286                // The bridge never emits continuation lines (§6.1).
287                return Err(not_bridge("continuation header line"));
288            }
289            let sp = line
290                .iter()
291                .position(|&b| b == b' ')
292                .ok_or_else(|| not_bridge("header line without value"))?;
293            let key = &line[..sp];
294            if headers::RESERVED.iter().any(|r| r.as_bytes() == key) {
295                return Err(not_bridge("reserved mkit-* header present"));
296            }
297            headers.push((key, &line[sp + 1..]));
298        }
299        Ok(Self { headers, message })
300    }
301
302    /// §1.2: an actionable error for missing/foreign schema versions
303    /// (instead of the generic re-translation mismatch).
304    fn check_schema(&self) -> Result<(), BridgeError> {
305        match self.required_str(headers::MKIT_SCHEMA) {
306            Ok(v) if v == headers::SCHEMA_VALUE => Ok(()),
307            Ok(v) => Err(not_bridge(&format!(
308                "mkit-schema {v} is not covered by bridge mapping v1"
309            ))),
310            Err(_) => Err(not_bridge("missing mkit-schema header")),
311        }
312    }
313
314    fn all(&self, key: &[u8]) -> Vec<&'a [u8]> {
315        self.headers
316            .iter()
317            .filter(|(k, _)| *k == key)
318            .map(|(_, v)| *v)
319            .collect()
320    }
321
322    fn required(&self, key: &[u8]) -> Result<&'a [u8], BridgeError> {
323        match self.all(key).as_slice() {
324            [v] => Ok(v),
325            [] => Err(not_bridge(&format!(
326                "missing {}",
327                String::from_utf8_lossy(key)
328            ))),
329            _ => Err(not_bridge(&format!(
330                "duplicate {}",
331                String::from_utf8_lossy(key)
332            ))),
333        }
334    }
335
336    fn required_str(&self, key: &str) -> Result<&'a str, BridgeError> {
337        std::str::from_utf8(self.required(key.as_bytes())?)
338            .map_err(|_| not_bridge(&format!("{key} not UTF-8")))
339    }
340
341    fn required_git_id(&self, key: &str) -> Result<Sha1Id, BridgeError> {
342        sha1_from_hex(self.required_str(key)?)
343            .ok_or_else(|| not_bridge(&format!("{key} is not a 40-hex id")))
344    }
345
346    fn all_git_ids(&self, key: &str) -> Result<Vec<Sha1Id>, BridgeError> {
347        self.all(key.as_bytes())
348            .into_iter()
349            .map(|v| {
350                std::str::from_utf8(v)
351                    .ok()
352                    .and_then(sha1_from_hex)
353                    .ok_or_else(|| not_bridge(&format!("{key} is not a 40-hex id")))
354            })
355            .collect()
356    }
357
358    fn required_hash(&self, key: &str) -> Result<[u8; 32], BridgeError> {
359        headers::parse_hash(self.required_str(key)?)
360            .ok_or_else(|| not_bridge(&format!("{key} is not a 64-hex hash")))
361    }
362
363    fn optional_hash(&self, key: &str) -> Result<Option<[u8; 32]>, BridgeError> {
364        match self.all(key.as_bytes()).as_slice() {
365            [] => Ok(None),
366            [v] => std::str::from_utf8(v)
367                .ok()
368                .and_then(headers::parse_hash)
369                .map(Some)
370                .ok_or_else(|| not_bridge(&format!("{key} is not a 64-hex hash"))),
371            _ => Err(not_bridge(&format!("duplicate {key}"))),
372        }
373    }
374
375    fn required_signature(&self, key: &str) -> Result<[u8; 64], BridgeError> {
376        headers::parse_signature(self.required_str(key)?)
377            .ok_or_else(|| not_bridge(&format!("{key} is not a 128-hex signature")))
378    }
379
380    fn all_hashes(&self, key: &str) -> Result<Vec<[u8; 32]>, BridgeError> {
381        self.all(key.as_bytes())
382            .into_iter()
383            .map(|v| {
384                std::str::from_utf8(v)
385                    .ok()
386                    .and_then(headers::parse_hash)
387                    .ok_or_else(|| not_bridge(&format!("{key} is not a 64-hex hash")))
388            })
389            .collect()
390    }
391}