Skip to main content

mkit_core/
object.rs

1//! mkit object types.
2//!
3//! Spec reference: `docs/specs/SPEC-OBJECTS.md` §1–§9. Briefly:
4//!
5//! * Every stored object begins with the 6-byte v1 prologue
6//!   `[u8 object_type][4B "MKT1"][u8 0x01]`.
7//! * Hashes are 32-byte BLAKE3.
8//! * Integers are little-endian. Timestamps are `u64` (widened from
9//!   `u32` in the mkit-era).
10//! * Tree entry names are 1..=255 bytes, forbid `\0 / \\` and the
11//!   names `.` / `..`, and MUST be lex-sorted with no duplicates.
12//! * Identity is a tagged union `[u8 kind][u16 LE len][payload]`;
13//!   `len` is 1..=[`IDENTITY_MAX_LEN`], ed25519 MUST have `len == 32`.
14
15use crate::hash::{Hash, ZERO};
16use core::fmt;
17
18/// Fixed 4-byte magic at offset 1 of every v1 object.
19pub const MAGIC: [u8; 4] = *b"MKT1";
20/// Current (and only) v1 schema version byte.
21pub const SCHEMA_VERSION: u8 = 0x01;
22/// Upper bound on [`Identity`] payload length. Rejected at decode time
23/// as `IdentityTooLarge` for anything greater.
24pub const IDENTITY_MAX_LEN: u16 = 4096;
25
26/// Object type tag (1 byte, at offset 0 of the v1 prologue).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[repr(u8)]
29pub enum ObjectType {
30    Blob = 0x01,
31    Tree = 0x02,
32    Commit = 0x03,
33    Remix = 0x04,
34    ChunkedBlob = 0x05,
35    Delta = 0x06,
36    /// Annotated / signed tag. New in v1 (issue #230). See
37    /// `SPEC-OBJECTS.md` §6a and [`Tag`].
38    Tag = 0x07,
39}
40
41impl ObjectType {
42    /// `true` for the types content-addressed by a merkle BMT root
43    /// (`Tree`, `ChunkedBlob`) rather than by `BLAKE3` of their bytes — see
44    /// `crate::merkle` and [`Object::id`]. The single source of truth for
45    /// the merkle-addressed set: the store's id dispatch and every write
46    /// path consult this instead of re-spelling `Tree | ChunkedBlob`.
47    #[must_use]
48    pub fn is_merkle(self) -> bool {
49        matches!(self, Self::Tree | Self::ChunkedBlob)
50    }
51
52    /// Spec-defined short name, usable in logs / CLI output.
53    #[must_use]
54    pub fn name(self) -> &'static str {
55        match self {
56            Self::Blob => "blob",
57            Self::Tree => "tree",
58            Self::Commit => "commit",
59            Self::Remix => "remix",
60            Self::ChunkedBlob => "chunked_blob",
61            Self::Delta => "delta",
62            Self::Tag => "tag",
63        }
64    }
65
66    /// Decode the single-byte tag. Rejects reserved/future values.
67    pub(crate) fn from_u8(b: u8) -> Result<Self, MkitError> {
68        Ok(match b {
69            0x01 => Self::Blob,
70            0x02 => Self::Tree,
71            0x03 => Self::Commit,
72            0x04 => Self::Remix,
73            0x05 => Self::ChunkedBlob,
74            0x06 => Self::Delta,
75            0x07 => Self::Tag,
76            other => return Err(MkitError::InvalidObjectType(other)),
77        })
78    }
79}
80
81/// Tree entry mode (1 byte).
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83#[repr(u8)]
84pub enum EntryMode {
85    Blob = 0x01,
86    Tree = 0x02,
87    Symlink = 0x03,
88    /// Regular file with the POSIX executable bit set (0o755). New in
89    /// v1 — the mkit-era silently lost this bit at commit time.
90    Executable = 0x04,
91}
92
93impl EntryMode {
94    pub(crate) fn from_u8(b: u8) -> Result<Self, MkitError> {
95        Ok(match b {
96            0x01 => Self::Blob,
97            0x02 => Self::Tree,
98            0x03 => Self::Symlink,
99            0x04 => Self::Executable,
100            other => return Err(MkitError::InvalidEntryMode(other)),
101        })
102    }
103}
104
105/// Tagged-union author identity. See `SPEC-OBJECTS.md` §9.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107#[repr(u8)]
108pub enum IdentityKind {
109    /// 32-byte raw Ed25519 public key.
110    Ed25519 = 0x01,
111    /// `did:key:` multibase-encoded key material (the `did:key:` scheme
112    /// prefix is stripped — the payload is a multibase string, typically
113    /// base58btc starting with `'z'`). Validated as non-empty printable
114    /// ASCII so binary garbage can't masquerade as a DID (see
115    /// [`Identity::is_valid`]).
116    DidKey = 0x02,
117    /// Arbitrary producer-defined bytes.
118    Opaque = 0x03,
119}
120
121impl IdentityKind {
122    pub(crate) fn from_u8(b: u8) -> Result<Self, MkitError> {
123        Ok(match b {
124            0x01 => Self::Ed25519,
125            0x02 => Self::DidKey,
126            0x03 => Self::Opaque,
127            other => return Err(MkitError::UnknownIdentityKind(other)),
128        })
129    }
130}
131
132/// Tagged-union identity. Owned bytes, cheap to clone — payload is at
133/// most [`IDENTITY_MAX_LEN`] = 4 KiB.
134#[derive(Debug, Clone, PartialEq, Eq, Hash)]
135pub struct Identity {
136    pub kind: IdentityKind,
137    pub bytes: Vec<u8>,
138}
139
140impl Identity {
141    /// Convenience constructor: Ed25519 from a fixed 32-byte pubkey.
142    #[must_use]
143    pub fn ed25519(pubkey: [u8; 32]) -> Self {
144        Self {
145            kind: IdentityKind::Ed25519,
146            bytes: pubkey.to_vec(),
147        }
148    }
149
150    /// Convenience constructor: opaque producer-defined bytes.
151    #[must_use]
152    pub fn opaque(bytes: impl Into<Vec<u8>>) -> Self {
153        Self {
154            kind: IdentityKind::Opaque,
155            bytes: bytes.into(),
156        }
157    }
158
159    /// Structural validity check: payload len in `1..=IDENTITY_MAX_LEN`;
160    /// Ed25519 is exactly 32 bytes; a `DidKey` payload must be a multibase
161    /// string, i.e. all printable ASCII (no NUL/control/whitespace/high
162    /// bytes) — so a binary blob can't be smuggled in under the DID kind.
163    /// `Opaque` is producer-defined and accepts any non-empty bytes.
164    #[must_use]
165    pub fn is_valid(&self) -> bool {
166        if self.bytes.is_empty() || self.bytes.len() > IDENTITY_MAX_LEN as usize {
167            return false;
168        }
169        match self.kind {
170            IdentityKind::Ed25519 => self.bytes.len() == 32,
171            // A multibase string is always printable ASCII; this rejects
172            // garbage without committing to one multibase alphabet (the
173            // payload may be base58btc `z…`, base64 `m…`, etc.).
174            IdentityKind::DidKey => self.bytes.iter().all(u8::is_ascii_graphic),
175            IdentityKind::Opaque => true,
176        }
177    }
178}
179
180/// A single entry in a [`Tree`] object.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct TreeEntry {
183    /// Entry name. 1..=255 bytes, no `\0 / \\`, not `.` / `..`.
184    pub name: Vec<u8>,
185    pub mode: EntryMode,
186    pub object_hash: Hash,
187}
188
189impl TreeEntry {
190    /// Validate an entry name per §4.1.
191    ///
192    /// In addition to the base spec (no `\0 / \\`, not `.` / `..`, 1..=255
193    /// bytes), this rejects names that alias repo metadata or exploit
194    /// platform quirks:
195    ///
196    /// - `.mkit` / `.git` case-insensitively (Git CVE-2021-21300 family).
197    /// - Trailing `.` or space, which Windows strips, causing aliasing.
198    /// - Reserved Windows device names (`CON`, `PRN`, `AUX`, `NUL`,
199    ///   `COM1`-`COM9`, `LPT1`-`LPT9`), with or without an extension,
200    ///   case-insensitively.
201    ///
202    /// ASCII case-folding is sufficient because all other byte-level
203    /// rules above are ASCII-only; names with non-ASCII bytes bypass
204    /// these extra checks but remain constrained by the base rules.
205    #[must_use]
206    pub fn validate_name(name: &[u8]) -> bool {
207        if name.is_empty() || name.len() > 255 {
208            return false;
209        }
210        if name == b"." || name == b".." {
211            return false;
212        }
213        if name.iter().any(|&b| matches!(b, 0 | b'/' | b'\\')) {
214            return false;
215        }
216        // Trailing `.` or space — Windows strips these, causing aliasing
217        // with another entry of the same bare name.
218        if matches!(name.last(), Some(b'.' | b' ')) {
219            return false;
220        }
221        // Case-insensitive `.mkit` / `.git`.
222        if name.eq_ignore_ascii_case(b".mkit") || name.eq_ignore_ascii_case(b".git") {
223            return false;
224        }
225        // Reserved Windows device names — the stem (before the first `.`)
226        // is compared case-insensitively.
227        let stem = match name.iter().position(|&b| b == b'.') {
228            Some(i) => &name[..i],
229            None => name,
230        };
231        if is_windows_reserved_stem(stem) {
232            return false;
233        }
234        true
235    }
236}
237
238/// Returns `true` when `stem` (ASCII bytes, case-insensitive) matches a
239/// reserved Windows device name. The caller has already split on the
240/// first `.` so an extension is ignored.
241fn is_windows_reserved_stem(stem: &[u8]) -> bool {
242    match stem.len() {
243        3 => {
244            stem.eq_ignore_ascii_case(b"CON")
245                || stem.eq_ignore_ascii_case(b"PRN")
246                || stem.eq_ignore_ascii_case(b"AUX")
247                || stem.eq_ignore_ascii_case(b"NUL")
248        }
249        4 => {
250            // COM1-COM9 / LPT1-LPT9 only. 0 is not reserved.
251            let head = &stem[..3];
252            let tail = stem[3];
253            let is_digit_1_9 = matches!(tail, b'1'..=b'9');
254            is_digit_1_9 && (head.eq_ignore_ascii_case(b"COM") || head.eq_ignore_ascii_case(b"LPT"))
255        }
256        _ => false,
257    }
258}
259
260/// Remix source provenance. `upstream_id` is opaque 32-byte caller-
261/// chosen content (e.g. `BLAKE3(repo_url)`); core never interprets it.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
263pub struct RemixSource {
264    pub upstream_id: Hash,
265    pub commit_hash: Hash,
266}
267
268/// Blob: raw bytes, no interpretation. Max 1 GiB at the storage layer.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct Blob {
271    pub data: Vec<u8>,
272}
273
274/// Tree: lex-sorted list of entries.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct Tree {
277    pub entries: Vec<TreeEntry>,
278}
279
280impl Tree {
281    /// Returns `true` when entries are strictly ascending by byte-wise
282    /// name order (no duplicates).
283    #[must_use]
284    pub fn is_sorted(&self) -> bool {
285        self.entries
286            .windows(2)
287            .all(|w| w[0].name.as_slice() < w[1].name.as_slice())
288    }
289}
290
291/// Commit object. See `SPEC-OBJECTS.md` §5.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct Commit {
294    pub tree_hash: Hash,
295    pub parents: Vec<Hash>,
296    pub author: Identity,
297    pub signer: [u8; 32],
298    pub message: Vec<u8>,
299    pub timestamp: u64,
300    /// Optional off-chain annotation. Zero = absent. NOT part of the
301    /// signing bytes — see SPEC-SIGNING §3.
302    pub message_hash: Hash,
303    /// Optional off-chain annotation. Zero = absent. NOT part of the
304    /// signing bytes.
305    pub content_digest: Hash,
306    pub signature: [u8; 64],
307}
308
309impl Commit {
310    /// Commit with both annotation slots zeroed out.
311    #[must_use]
312    pub fn new_unannotated(
313        tree_hash: Hash,
314        parents: Vec<Hash>,
315        author: Identity,
316        signer: [u8; 32],
317        message: Vec<u8>,
318        timestamp: u64,
319        signature: [u8; 64],
320    ) -> Self {
321        Self {
322            tree_hash,
323            parents,
324            author,
325            signer,
326            message,
327            timestamp,
328            message_hash: ZERO,
329            content_digest: ZERO,
330            signature,
331        }
332    }
333}
334
335/// Remix object. See `SPEC-OBJECTS.md` §6.
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct Remix {
338    pub tree_hash: Hash,
339    pub parents: Vec<Hash>,
340    pub sources: Vec<RemixSource>,
341    pub author: Identity,
342    pub signer: [u8; 32],
343    pub message: Vec<u8>,
344    pub timestamp: u64,
345    pub signature: [u8; 64],
346}
347
348impl Remix {
349    /// Returns `true` when sources are sorted by `(upstream_id, commit_hash)`
350    /// with no duplicate `(upstream_id, commit_hash)` pairs.
351    #[must_use]
352    pub fn sources_sorted(&self) -> bool {
353        self.sources.windows(2).all(|w| {
354            let a = &w[0];
355            let b = &w[1];
356            match a.upstream_id.cmp(&b.upstream_id) {
357                core::cmp::Ordering::Less => true,
358                core::cmp::Ordering::Greater => false,
359                core::cmp::Ordering::Equal => a.commit_hash < b.commit_hash,
360            }
361        })
362    }
363}
364
365/// Annotated / signed tag object. See `SPEC-OBJECTS.md` §6a and
366/// `SPEC-SIGNING.md` §4a.
367///
368/// A tag binds a human-readable `name` to a `target` object (commit /
369/// remix / tree / blob), records the `tagger` identity, a free-form
370/// `message`, and a `timestamp`, and carries an Ed25519 `signature`
371/// over the canonical signing bytes (see [`crate::sign::tag_signing_bytes`]).
372///
373/// The `target_type` byte records what kind of object `target` names
374/// so a verifier need not fetch the target to display the tag. It is a
375/// [`ObjectType`] tag and MUST be one of the storable types (not
376/// `Delta`, which is pack-only).
377///
378/// `name` is 1..=[`TAG_NAME_MAX_LEN`] bytes. It is the short ref name
379/// (e.g. `v1.0.0`), not a full `refs/tags/...` path.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct Tag {
382    pub target: Hash,
383    pub target_type: ObjectType,
384    pub name: Vec<u8>,
385    pub tagger: Identity,
386    pub signer: [u8; 32],
387    pub message: Vec<u8>,
388    pub timestamp: u64,
389    pub signature: [u8; 64],
390}
391
392/// Upper bound on a [`Tag`] `name` payload. Rejected at decode time as
393/// [`MkitError::TagNameInvalid`] for anything outside `1..=TAG_NAME_MAX_LEN`.
394pub const TAG_NAME_MAX_LEN: u16 = 4096;
395
396impl Tag {
397    /// Structural validity of the `name`: non-empty, within the length
398    /// bound, and free of the same forbidden bytes a ref name forbids
399    /// (`\0`, `/`, `\\`). The full ref-name grammar is enforced by
400    /// `refs::validate_ref_name` at write time; this is the
401    /// object-layer floor that the serializer guards.
402    #[must_use]
403    pub fn name_is_valid(&self) -> bool {
404        if self.name.is_empty() || self.name.len() > TAG_NAME_MAX_LEN as usize {
405            return false;
406        }
407        !self.name.iter().any(|&b| matches!(b, 0 | b'/' | b'\\'))
408    }
409}
410
411/// Chunked-blob manifest. See `SPEC-OBJECTS.md` §7.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct ChunkedBlob {
414    pub total_size: u64,
415    /// `0` = content-defined chunking (`FastCDC`), otherwise fixed-size.
416    pub chunk_size: u32,
417    pub chunks: Vec<Hash>,
418}
419
420impl ChunkedBlob {
421    /// Enforce the SPEC-OBJECTS §7 reassembly invariant: the concatenated
422    /// chunk contents MUST total exactly `total_size` bytes. Every
423    /// reassembly site calls this after concatenation so a manifest whose
424    /// `total_size` disagrees with its chunks is rejected instead of
425    /// silently yielding wrong-length content.
426    ///
427    /// # Errors
428    /// [`MkitError::ChunkedBlobSizeMismatch`] when `reassembled_len`
429    /// differs from `total_size`.
430    pub fn check_reassembled_size(&self, reassembled_len: usize) -> Result<(), MkitError> {
431        let actual = reassembled_len as u64;
432        if actual != self.total_size {
433            return Err(MkitError::ChunkedBlobSizeMismatch {
434                expected: self.total_size,
435                actual,
436            });
437        }
438        Ok(())
439    }
440}
441
442/// Delta object (pack-only). See `SPEC-OBJECTS.md` §8.
443#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct Delta {
445    pub base_hash: Hash,
446    pub result_size: u32,
447    pub instructions: Vec<u8>,
448}
449
450/// Unified object union.
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub enum Object {
453    Blob(Blob),
454    Tree(Tree),
455    Commit(Commit),
456    Remix(Remix),
457    ChunkedBlob(ChunkedBlob),
458    Delta(Delta),
459    Tag(Tag),
460}
461
462impl Object {
463    /// Return this object's type tag.
464    #[must_use]
465    pub fn object_type(&self) -> ObjectType {
466        match self {
467            Self::Blob(_) => ObjectType::Blob,
468            Self::Tree(_) => ObjectType::Tree,
469            Self::Commit(_) => ObjectType::Commit,
470            Self::Remix(_) => ObjectType::Remix,
471            Self::ChunkedBlob(_) => ObjectType::ChunkedBlob,
472            Self::Delta(_) => ObjectType::Delta,
473            Self::Tag(_) => ObjectType::Tag,
474        }
475    }
476
477    /// This object's content-address (storage id).
478    ///
479    /// Merkelized types are addressed by their Binary Merkle Tree root
480    /// (`crate::merkle`): a `Tree` by [`crate::merkle::compute_tree_id`] and
481    /// a `ChunkedBlob` by [`crate::merkle::compute_chunked_id`]. Every other
482    /// type is addressed by `BLAKE3` of its canonical serialized bytes — the
483    /// historical scheme. This is the single dispatch point the store, pack
484    /// reader, and worktree all route through, so the two addressing schemes
485    /// can never diverge across call sites.
486    ///
487    /// # Errors
488    ///
489    /// Propagates [`MkitError`] from serializing a non-merkelized object
490    /// (merkelized ids are computed directly from the in-memory struct and
491    /// never serialize, so they cannot fail).
492    pub fn id(&self) -> Result<Hash, MkitError> {
493        match merkle_id(self) {
494            Some(h) => Ok(h),
495            None => Ok(crate::hash::hash(&crate::serialize::serialize(self)?)),
496        }
497    }
498}
499
500/// The BMT-root id of a merkelized object ([`Tree`] / [`ChunkedBlob`]), or
501/// `None` for a byte-hashed type. The single source of truth for "is this type
502/// content-addressed by its merkle root?": both [`Object::id`] and
503/// [`id_from_object`] dispatch through it, so the in-memory path and the
504/// bytes path can never disagree on a type (add a merkelized variant here once
505/// and every id path follows).
506#[must_use]
507fn merkle_id(obj: &Object) -> Option<Hash> {
508    match obj {
509        Object::Tree(t) => Some(crate::merkle::compute_tree_id(t)),
510        Object::ChunkedBlob(cb) => Some(crate::merkle::compute_chunked_id(cb)),
511        _ => None,
512    }
513}
514
515/// Content id of an **already-decoded** object whose canonical encoding is
516/// `bytes`. A merkelized type ([`Tree`] / [`ChunkedBlob`]) is addressed by its
517/// BMT root, computed from the decoded struct; every other type is `BLAKE3` of
518/// `bytes`, reusing the caller's buffer (no re-serialize — `bytes` may be up
519/// to ~1 GiB).
520///
521/// This is the bytes-reusing twin of [`Object::id`]; both share the same
522/// `merkle_id` dispatch, so a merkelized type keys identically whether
523/// addressed from the decoded struct (native store) or from bytes the caller
524/// already holds (the pack reader, the `mkit-wasm` encoder).
525///
526/// # Precondition
527/// For a byte-hashed (non-merkle) type, `bytes` MUST be `obj`'s canonical
528/// serialization: the id is `BLAKE3(bytes)`, taken on trust with no re-encode.
529/// Passing a buffer that is not `obj`'s encoding silently mis-addresses it.
530/// Merkle types ignore `bytes` entirely (addressed from the struct).
531#[must_use]
532pub fn id_from_object(obj: &Object, bytes: &[u8]) -> Hash {
533    merkle_id(obj).unwrap_or_else(|| crate::hash::hash(bytes))
534}
535
536/// [`id_from_object`] for an object supplied only as its canonical **bytes**.
537/// Used by the store and batch writer, whose input is bytes they are about to
538/// persist (write) or have already persisted (read-verify).
539///
540/// Only the merkelized types are decoded — to find their BMT root; every
541/// other type (incl. up-to-~1 GiB blobs) is hashed straight from `bytes`
542/// without a decode. A merkle-typed buffer that fails to decode is byte-hashed
543/// like any other: real writers never emit one, and on read-verify the
544/// resulting id mismatch surfaces as `HashMismatch` rather than being masked.
545#[must_use]
546pub(crate) fn object_id_from_bytes(bytes: &[u8]) -> Hash {
547    verified_id_and_object(bytes).0
548}
549
550/// [`object_id_from_bytes`], but also hands back the decoded [`Object`]
551/// when `bytes` turned out to be a merkelized type — sparing a caller
552/// that needs both the verified id AND the decoded object (namely
553/// [`crate::store::ObjectStore::read_object`]) a second `deserialize`
554/// of the same bytes. `None` means either the type is byte-hashed (no
555/// decode ever happens) or the merkle-decode failed, in which case the
556/// id falls back to `hash(bytes)` exactly as [`object_id_from_bytes`]
557/// does, and the caller must decode for itself if it still wants an
558/// `Object` (which will fail the same way `deserialize` always did).
559#[must_use]
560pub(crate) fn verified_id_and_object(bytes: &[u8]) -> (Hash, Option<Object>) {
561    let is_merkle = bytes
562        .first()
563        .and_then(|b| ObjectType::from_u8(*b).ok())
564        .is_some_and(ObjectType::is_merkle);
565    if is_merkle && let Ok(obj) = crate::serialize::deserialize(bytes) {
566        let id = id_from_object(&obj, bytes);
567        return (id, Some(obj));
568    }
569    (crate::hash::hash(bytes), None)
570}
571
572/// [`object_id_from_bytes`] for an object supplied as `parts` whose
573/// concatenation is the canonical bytes — the shape every part-wise sink
574/// (`EphemeralSink`, `WriteBatch`) receives.
575///
576/// A merkelized type needs its contiguous bytes to decode the BMT root, so
577/// it is buffered once and dispatched. A byte-hashed type (a `Blob`/chunk —
578/// the bulk of all bytes, up to ~1 GiB) is hashed **streaming**, never
579/// materialising the concatenation. This keeps the "buffer or stream"
580/// decision in one place instead of in every sink.
581#[must_use]
582pub(crate) fn object_id_from_parts(parts: &[&[u8]]) -> Hash {
583    let merkle = parts
584        .first()
585        .and_then(|p| p.first())
586        .and_then(|b| ObjectType::from_u8(*b).ok())
587        .is_some_and(ObjectType::is_merkle);
588    if merkle {
589        return object_id_from_bytes(&parts.concat());
590    }
591    let mut hasher = crate::hash::Hasher::new();
592    for p in parts {
593        hasher.update(p);
594    }
595    hasher.finalize()
596}
597
598/// All decode / validation errors raised by the serialize module, plus
599/// a small number of construction-time errors.
600#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
601pub enum MkitError {
602    #[error("input is shorter than the 6-byte v1 prologue")]
603    EmptyData,
604    #[error("object_type byte {0:#04x} is not in 0x01..=0x07")]
605    InvalidObjectType(u8),
606    #[error("magic at offset 1 is not \"MKT1\"")]
607    InvalidMagic,
608    #[error("schema_version byte is not 0x01")]
609    UnsupportedObjectVersion,
610    #[error("input ended before a complete field could be read")]
611    UnexpectedEof,
612    #[error("non-empty trailing bytes after a complete object")]
613    TrailingData,
614    #[error("tree.entry_count > 1_000_000")]
615    TooManyEntries,
616    #[error("tree entry name is empty, too long, or contains a forbidden byte")]
617    InvalidEntryName,
618    #[error("tree entry mode byte {0:#04x} is not one of 0x01..=0x04")]
619    InvalidEntryMode(u8),
620    #[error("tree entries are not lexicographically sorted / contain duplicates")]
621    InvalidEntryOrder,
622    #[error("parent_count > 1_000")]
623    TooManyParents,
624    #[error("remix.source_count > 10_000")]
625    TooManySources,
626    #[error("tag name is empty, too long, or contains a forbidden byte (\\0 / \\)")]
627    TagNameInvalid,
628    #[error("tag target_type byte {0:#04x} is not a storable object type")]
629    TagTargetTypeInvalid(u8),
630    #[error("remix sources are not sorted by (upstream_id, commit_hash)")]
631    InvalidSourceOrder,
632    #[error("chunked_blob.chunk_count > 1_000_000")]
633    TooManyChunks,
634    /// A `ChunkedBlob`'s chunks concatenated to a different length than
635    /// its `total_size` claims — the manifest is corrupt (SPEC-OBJECTS
636    /// §7: "The concatenated length MUST equal `total_size`").
637    #[error("chunked blob reassembles to {actual} bytes, manifest total_size is {expected}")]
638    ChunkedBlobSizeMismatch { expected: u64, actual: u64 },
639    #[error("identity kind byte {0:#04x} is not 0x01..=0x03")]
640    UnknownIdentityKind(u8),
641    #[error("identity has zero-length payload, or is Ed25519 with len != 32")]
642    InvalidIdentity,
643    #[error("identity payload len > {}", IDENTITY_MAX_LEN)]
644    IdentityTooLarge,
645    /// A length-prefixed field exceeded the wire-format `u32` cap. Only
646    /// raised by serialise; deserialise can never observe a value larger
647    /// than `u32::MAX` because it reads the prefix first.
648    #[error("oversized payload in field `{field}`: {len} bytes > u32::MAX")]
649    OversizePayload { field: &'static str, len: usize },
650    // ---- sign / key-management errors ----
651    /// Underlying secure-randomness source could not produce bytes.
652    #[error("rng failed to produce key material")]
653    RngFailure,
654    /// Signature verification failed (bad signature, wrong key, tampered
655    /// input, or wrong domain). The Ed25519 layer never tells us *why*.
656    #[error("signature verification failed")]
657    SignatureInvalid,
658    /// Public-key bytes do not decode to a valid Edwards point.
659    #[error("public key is not a valid Ed25519 point")]
660    InvalidPublicKey,
661    /// Key file on disk has a permission bit set that allows non-owner
662    /// access (POSIX `mode & 0o077 != 0`). Refuses to load.
663    #[error("key file mode {actual:#o} is broader than 0600")]
664    InsecureKeyPermissions { actual: u32 },
665    /// Key file is owned by a different uid than the calling process.
666    /// Could mean a planted file from a tar extraction or a malicious
667    /// bind mount. Refuse with the observed uid for diagnostics.
668    #[error("key file owner uid {actual} does not match process euid {euid}")]
669    InsecureKeyOwner { actual: u32, euid: u32 },
670    /// Parent directory of the key file is group/world-accessible.
671    /// `.mkit/keys/` MUST be 0700 to keep `inotify`-style swap attacks
672    /// out of reach.
673    #[error("key directory mode {actual:#o} is broader than 0700")]
674    InsecureKeyDir { actual: u32 },
675    /// Key path resolves through a symlink. We refuse symlinks at the
676    /// open(2) layer (`O_NOFOLLOW`) — this variant fires when the
677    /// kernel returns ELOOP. An attacker who can pre-create the path
678    /// as a symlink could otherwise redirect us to a key they control.
679    #[error("key path {0} is a symlink — refused")]
680    KeyPathIsSymlink(String),
681    /// Key file length is not exactly 32 bytes.
682    #[error("key file size {actual} is not 32 bytes (raw Ed25519 seed)")]
683    InvalidKeyLength { actual: usize },
684    /// Wrapped I/O error from key load/save. Boxed to keep `MkitError`
685    /// variant size small.
686    #[error("key file I/O error: {0}")]
687    KeyIo(String),
688    /// Delta encode input exceeds the v1 wire-format `u32` length cap
689    /// (base or result > 4 GiB - 1). SPEC-PACKFILE holds individual
690    /// payloads under this bound, so this is a caller-programming
691    /// error, not a normal runtime condition — but saturating instead
692    /// of erroring silently produced a stream `decode()` would reject
693    /// with a misleading "length mismatch".
694    #[error("delta length {len} exceeds u32::MAX for field `{field}`")]
695    DeltaLengthOverflow { field: &'static str, len: usize },
696    /// Structural corruption in a v1 delta instruction stream (MKIT-13):
697    /// anything `delta::decode` rejects that is neither a version
698    /// mismatch ([`Self::UnsupportedObjectVersion`]) nor a truncated
699    /// stream ([`Self::UnexpectedEof`]). Distinct from
700    /// [`Self::TrailingData`] — which SPEC-DELTA §10 and
701    /// `serialize.rs` reserve for the genuine "non-empty trailing bytes
702    /// after a complete object" condition — so callers can tell
703    /// "well-formed but truncated/wrong-version" apart from
704    /// "structurally corrupt" without conflating either with an
705    /// unrelated serialization boundary. See [`crate::delta::DeltaCorruption`]
706    /// for the specific corruption kind.
707    #[error("delta stream is structurally corrupt: {0}")]
708    DeltaCorrupt(crate::delta::DeltaCorruption),
709}
710
711impl fmt::Display for Object {
712    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
713        write!(f, "Object::{}", self.object_type().name())
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720
721    #[test]
722    fn object_type_names() {
723        assert_eq!(ObjectType::Blob.name(), "blob");
724        assert_eq!(ObjectType::Tree.name(), "tree");
725        assert_eq!(ObjectType::Commit.name(), "commit");
726        assert_eq!(ObjectType::Remix.name(), "remix");
727        assert_eq!(ObjectType::ChunkedBlob.name(), "chunked_blob");
728        assert_eq!(ObjectType::Delta.name(), "delta");
729        assert_eq!(ObjectType::Tag.name(), "tag");
730    }
731
732    /// The two id paths — `Object::id` (from the in-memory struct) and
733    /// `id_from_object` (reusing the object's canonical bytes) — must agree for
734    /// every variant. Both dispatch through `merkle_id`, so this guards against
735    /// anyone re-introducing a second, divergent dispatch table.
736    #[test]
737    fn object_id_and_id_from_object_agree_for_every_variant() {
738        let samples = [
739            Object::Blob(Blob {
740                data: vec![1, 2, 3, 4],
741            }),
742            Object::Tree(Tree { entries: vec![] }),
743            Object::Tree(Tree {
744                entries: vec![TreeEntry {
745                    name: b"a".to_vec(),
746                    mode: EntryMode::Blob,
747                    object_hash: [9u8; 32],
748                }],
749            }),
750            Object::ChunkedBlob(ChunkedBlob {
751                total_size: 4,
752                chunk_size: 0,
753                chunks: vec![[7u8; 32], [8u8; 32]],
754            }),
755            Object::Commit(Commit::new_unannotated(
756                [1u8; 32],
757                vec![[2u8; 32]],
758                Identity::ed25519([3u8; 32]),
759                [4u8; 32],
760                b"msg".to_vec(),
761                1_700_000_000,
762                [9u8; 64],
763            )),
764            Object::Remix(Remix {
765                tree_hash: [5u8; 32],
766                parents: vec![[6u8; 32]],
767                sources: vec![RemixSource {
768                    upstream_id: [10u8; 32],
769                    commit_hash: [11u8; 32],
770                }],
771                author: Identity::ed25519([12u8; 32]),
772                signer: [13u8; 32],
773                message: b"remix".to_vec(),
774                timestamp: 1_700_000_001,
775                signature: [14u8; 64],
776            }),
777            Object::Delta(Delta {
778                base_hash: [15u8; 32],
779                result_size: 4,
780                instructions: vec![0u8; 4],
781            }),
782            Object::Tag(Tag {
783                target: [16u8; 32],
784                target_type: ObjectType::Commit,
785                name: b"v1".to_vec(),
786                tagger: Identity::ed25519([17u8; 32]),
787                signer: [18u8; 32],
788                message: b"tag".to_vec(),
789                timestamp: 1_700_000_002,
790                signature: [19u8; 64],
791            }),
792        ];
793        for obj in &samples {
794            let bytes = crate::serialize::serialize(obj).unwrap();
795            assert_eq!(
796                obj.id().unwrap(),
797                id_from_object(obj, &bytes),
798                "id paths diverged for {obj}"
799            );
800        }
801    }
802
803    #[test]
804    fn object_type_from_u8_accepts_valid_range() {
805        for b in 0x01u8..=0x07 {
806            assert!(
807                ObjectType::from_u8(b).is_ok(),
808                "byte {b:#04x} should decode"
809            );
810        }
811    }
812
813    #[test]
814    fn object_type_from_u8_rejects_zero_and_high() {
815        assert!(matches!(
816            ObjectType::from_u8(0x00),
817            Err(MkitError::InvalidObjectType(0))
818        ));
819        assert!(matches!(
820            ObjectType::from_u8(0xFF),
821            Err(MkitError::InvalidObjectType(0xFF))
822        ));
823        assert!(matches!(
824            ObjectType::from_u8(0x08),
825            Err(MkitError::InvalidObjectType(0x08))
826        ));
827    }
828
829    #[test]
830    fn tag_name_validity() {
831        let t = |name: &[u8]| Tag {
832            target: ZERO,
833            target_type: ObjectType::Commit,
834            name: name.to_vec(),
835            tagger: Identity::ed25519([0xaa; 32]),
836            signer: [0; 32],
837            message: vec![],
838            timestamp: 0,
839            signature: [0; 64],
840        };
841        assert!(t(b"v1.0.0").name_is_valid());
842        assert!(!t(b"").name_is_valid());
843        assert!(!t(b"a/b").name_is_valid());
844        assert!(!t(b"a\\b").name_is_valid());
845        assert!(!t(b"a\0b").name_is_valid());
846        assert!(!t(&vec![b'a'; TAG_NAME_MAX_LEN as usize + 1]).name_is_valid());
847    }
848
849    #[test]
850    fn tree_entry_name_rejects_empty() {
851        assert!(!TreeEntry::validate_name(b""));
852    }
853
854    #[test]
855    fn tree_entry_name_rejects_separators_and_null() {
856        assert!(!TreeEntry::validate_name(b"foo/bar"));
857        assert!(!TreeEntry::validate_name(b"foo\\bar"));
858        assert!(!TreeEntry::validate_name(b"fo\0o"));
859    }
860
861    #[test]
862    fn tree_entry_name_rejects_dot_and_dotdot() {
863        assert!(!TreeEntry::validate_name(b"."));
864        assert!(!TreeEntry::validate_name(b".."));
865    }
866
867    #[test]
868    fn tree_entry_name_accepts_common() {
869        assert!(TreeEntry::validate_name(b"file.txt"));
870        assert!(TreeEntry::validate_name(b"a"));
871        assert!(TreeEntry::validate_name(b"foo-bar_baz.rs"));
872    }
873
874    #[test]
875    fn tree_entry_name_rejects_over_255() {
876        let long = vec![b'a'; 256];
877        assert!(!TreeEntry::validate_name(&long));
878    }
879
880    #[test]
881    fn tree_entry_name_rejects_dot_mkit_and_dot_git_case_insensitive() {
882        // Exact-case basics
883        assert!(!TreeEntry::validate_name(b".mkit"));
884        assert!(!TreeEntry::validate_name(b".git"));
885        // Mixed/upper case — must also be rejected on case-insensitive FS.
886        assert!(!TreeEntry::validate_name(b".MKIT"));
887        assert!(!TreeEntry::validate_name(b".Mkit"));
888        assert!(!TreeEntry::validate_name(b".GIT"));
889        assert!(!TreeEntry::validate_name(b".Git"));
890        // Unrelated names starting with `.m` or `.g` are fine.
891        assert!(TreeEntry::validate_name(b".mkitignore"));
892        assert!(TreeEntry::validate_name(b".gitignore"));
893    }
894
895    #[test]
896    fn tree_entry_name_rejects_trailing_dot_or_space() {
897        // Windows strips trailing `.` and ` `, causing aliasing with
898        // another entry of the same bare name.
899        assert!(!TreeEntry::validate_name(b"foo."));
900        assert!(!TreeEntry::validate_name(b"foo "));
901        assert!(!TreeEntry::validate_name(b"foo..."));
902        assert!(!TreeEntry::validate_name(b"foo   "));
903        // Trailing dot/space only at end — interior dots and spaces are OK.
904        assert!(TreeEntry::validate_name(b"foo.bar"));
905        assert!(TreeEntry::validate_name(b"foo bar"));
906    }
907
908    #[test]
909    fn tree_entry_name_rejects_windows_reserved_device_names() {
910        for n in [
911            b"CON".as_slice(),
912            b"PRN",
913            b"AUX",
914            b"NUL",
915            b"COM1",
916            b"COM9",
917            b"LPT1",
918            b"LPT9",
919            // case-insensitive
920            b"con",
921            b"Nul",
922            b"lpt3",
923            // with extension
924            b"CON.txt",
925            b"nul.log",
926            b"COM1.dat",
927        ] {
928            assert!(
929                !TreeEntry::validate_name(n),
930                "expected Windows reserved name rejected: {:?}",
931                std::str::from_utf8(n).unwrap_or("?")
932            );
933        }
934        // Non-reserved lookalikes must still be accepted.
935        assert!(TreeEntry::validate_name(b"COM0"));
936        assert!(TreeEntry::validate_name(b"LPT0"));
937        assert!(TreeEntry::validate_name(b"COM10"));
938        assert!(TreeEntry::validate_name(b"CONSOLE"));
939        assert!(TreeEntry::validate_name(b"NULL"));
940    }
941
942    #[test]
943    fn identity_rejects_empty_payload_all_kinds() {
944        for kind in [
945            IdentityKind::Ed25519,
946            IdentityKind::DidKey,
947            IdentityKind::Opaque,
948        ] {
949            assert!(
950                !Identity {
951                    kind,
952                    bytes: Vec::new()
953                }
954                .is_valid()
955            );
956        }
957    }
958
959    #[test]
960    fn identity_rejects_oversize() {
961        let bytes = vec![0xaa; IDENTITY_MAX_LEN as usize + 1];
962        assert!(
963            !Identity {
964                kind: IdentityKind::Opaque,
965                bytes
966            }
967            .is_valid()
968        );
969    }
970
971    #[test]
972    fn identity_requires_32_bytes_for_ed25519() {
973        assert!(
974            !Identity {
975                kind: IdentityKind::Ed25519,
976                bytes: vec![0xaa; 16]
977            }
978            .is_valid()
979        );
980        assert!(Identity::ed25519([0xaa; 32]).is_valid());
981    }
982
983    #[test]
984    fn didkey_requires_printable_ascii_multibase() {
985        let didkey = |b: &[u8]| Identity {
986            kind: IdentityKind::DidKey,
987            bytes: b.to_vec(),
988        };
989        // A real did:key multibase payload (base58btc, scheme stripped).
990        assert!(didkey(b"z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK").is_valid());
991        // Other multibase prefixes are graphic ASCII too — accepted.
992        assert!(didkey(b"mEiB1234").is_valid());
993        // Binary garbage masquerading as a DID is rejected.
994        assert!(!didkey(b"z\0\x01\x02").is_valid());
995        assert!(!didkey(&[0xde, 0xad, 0xbe, 0xef]).is_valid());
996        // Whitespace / control chars are not valid multibase.
997        assert!(!didkey(b"z6Mk has space").is_valid());
998        assert!(!didkey(b"z6Mk\n").is_valid());
999    }
1000
1001    #[test]
1002    fn tree_is_sorted_checks() {
1003        let e = |n: &[u8]| TreeEntry {
1004            name: n.to_vec(),
1005            mode: EntryMode::Blob,
1006            object_hash: ZERO,
1007        };
1008        let sorted = Tree {
1009            entries: vec![e(b"alpha"), e(b"beta"), e(b"gamma")],
1010        };
1011        assert!(sorted.is_sorted());
1012        let unsorted = Tree {
1013            entries: vec![e(b"beta"), e(b"alpha")],
1014        };
1015        assert!(!unsorted.is_sorted());
1016        let dup = Tree {
1017            entries: vec![e(b"alpha"), e(b"alpha")],
1018        };
1019        assert!(!dup.is_sorted());
1020    }
1021
1022    #[test]
1023    fn remix_sources_sorted_checks() {
1024        let src = |u: u8, c: u8| RemixSource {
1025            upstream_id: [u; 32],
1026            commit_hash: [c; 32],
1027        };
1028        let r = |sources| Remix {
1029            tree_hash: ZERO,
1030            parents: vec![],
1031            sources,
1032            author: Identity::ed25519([0xaa; 32]),
1033            signer: [0; 32],
1034            message: vec![],
1035            timestamp: 0,
1036            signature: [0; 64],
1037        };
1038        assert!(r(vec![src(1, 1), src(1, 2), src(2, 1)]).sources_sorted());
1039        assert!(!r(vec![src(2, 1), src(1, 1)]).sources_sorted());
1040        assert!(!r(vec![src(1, 1), src(1, 1)]).sources_sorted());
1041    }
1042}