Skip to main content

mkit_core/
store.rs

1//! Local content-addressed object store.
2//!
3//! Layout (under the [`crate::layout::RepoLayout`] common dir passed to
4//! [`ObjectStore::open`] / [`ObjectStore::init`]):
5//!
6//! ```text
7//! .mkit/
8//!   objects/
9//!     <2-hex>/<62-hex>   # raw canonical object bytes, BLAKE3-named
10//! ```
11//!
12//! Writes are atomic: bytes are first written to a sibling temp file
13//! (`<name>.tmp.<pid>.<rand>`), made durable, then renamed into place.
14//! A crash mid-write leaves only the temp file behind and never
15//! produces a visible object that fails the read-time hash check.
16//!
17//! Durability comes in three shapes (see [`crate::batch`] for the full
18//! contract): [`ObjectStore::write`] flushes per object
19//! ([`SyncPolicy::PerObject`]); [`ObjectStore::batch`] defers visibility
20//! and amortises durability to **one** full flush per batch — the write
21//! path used by every multi-object command (add, commit, pack unpack);
22//! and [`ObjectStore::bulk_writer`] fsyncs each object's contents before
23//! the rename and batches the dir fsyncs at commit (git import). In all
24//! three an object is never visible before its bytes are durable, so a
25//! ref or index written after the write/commit returns can never
26//! reference a non-durable object.
27//!
28//! Reads always verify integrity by recomputing BLAKE3 over the bytes
29//! and comparing against the requested hash; mismatch returns
30//! [`StoreError::HashMismatch`]. The one opt-in exception is
31//! [`ObjectStore::read_unverified`] (and the [`DisplaySource`] adapter
32//! built on it), reserved for display-only rendering where a corrupt
33//! object should surface as a bad render, never as durable state — see
34//! its doc for the full policy (#625).
35//!
36//! See `docs/specs/SPEC-OBJECTS.md` §10 for the path-layout rule.
37
38use std::fs::{self, File};
39use std::io::{self, Read, Write};
40use std::path::{Path, PathBuf};
41use std::process;
42use std::sync::Arc;
43use std::sync::atomic::{AtomicU64, Ordering};
44
45use tempfile::NamedTempFile;
46
47use crate::batch::{RealSyncer, Syncer};
48pub use crate::batch::{SyncPolicy, WriteBatch};
49use crate::hash::{self, Hash, object_path, to_hex};
50use crate::layout::RepoLayout;
51use crate::object::{MkitError, Object, object_id_from_bytes};
52use crate::serialize;
53
54mod source;
55pub use source::{DisplaySource, EphemeralSink, ObjectSource};
56
57/// Top-level repository directory name.
58pub const MKIT_DIR: &str = ".mkit";
59/// Subdirectory under `.mkit/` that holds raw object files.
60pub const OBJECTS_DIR: &str = "objects";
61/// File under `.mkit/` declaring the object-addressing format. Written at
62/// [`ObjectStore::init`] and required (and matched) at [`ObjectStore::open`]
63/// so an older flat-hash-addressed repository is rejected loudly rather
64/// than silently mis-read once merkle addressing is in effect.
65pub const FORMAT_FILE: &str = "format";
66/// The only supported object-addressing format value (see
67/// `docs/specs/SPEC-MERKLE-OBJECTS.md`): Tree/ChunkedBlob keyed by BMT root.
68pub const FORMAT_VALUE: &str = "bmt-v1";
69/// Hard cap on raw object size, enforced on both [`ObjectStore::write`]
70/// and [`ObjectStore::read`].
71pub const MAX_RAW_OBJECT_SIZE: usize = 1024 * 1024 * 1024; // 1 GiB
72
73/// Hard cap on tree-walk recursion depth.
74///
75/// Every core tree-walker (`index::from_tree`, `ops::diff`, `ops::merge`,
76/// `ops::restore`) recurses one native stack frame per directory level.
77/// Content-addressing prevents true cycles (a child's hash can never equal
78/// its parent's), but a crafted untrusted repo can still nest a few thousand
79/// single-entry trees in a tiny pack and overflow the stack — a denial of
80/// service reachable from `clone`/`checkout`/`merge`/`diff`. Walkers thread a
81/// depth counter and abort with a typed `TreeTooDeep` error once
82/// `depth > MAX_TREE_DEPTH`.
83///
84/// 128 is far beyond any legitimately-deep source tree (Git's own working
85/// trees rarely exceed a couple dozen levels) while remaining comfortably
86/// within a default 8 MiB thread stack. Mirrors the `MAX_REF_DEPTH` cap on
87/// the ref-tree walker in `refs.rs`.
88pub const MAX_TREE_DEPTH: usize = 128;
89
90/// Errors raised by the [`ObjectStore`] surface. Distinct from
91/// [`MkitError`] so callers can pattern-match on filesystem failures
92/// without losing the structured-decode-error variants.
93#[derive(Debug, thiserror::Error)]
94pub enum StoreError {
95    #[error("path is not an mkit repository (missing .mkit/objects)")]
96    NotAMkitRepository,
97    #[error(".mkit already exists in this directory")]
98    AlreadyInitialized,
99    #[error(
100        "repository object-addressing format is {found:?}, expected \"{}\" — this repository predates merkle object addressing and is not readable by this mkit (pre-1.0: no migration). See docs/specs/SPEC-MERKLE-OBJECTS.md.",
101        FORMAT_VALUE
102    )]
103    IncompatibleRepoFormat { found: Option<String> },
104    #[error("object {0} not found")]
105    ObjectNotFound(String),
106    #[error("object exceeds {} byte cap", MAX_RAW_OBJECT_SIZE)]
107    ObjectTooLarge,
108    #[error("tree nesting exceeds {} levels", MAX_TREE_DEPTH)]
109    TreeTooDeep,
110    #[error("on-disk bytes hash to {actual}, expected {expected}")]
111    HashMismatch { expected: String, actual: String },
112    #[error(transparent)]
113    Io(#[from] io::Error),
114    #[error(transparent)]
115    Decode(#[from] MkitError),
116}
117
118/// Deferred-fsync writer returned by [`ObjectStore::bulk_writer`].
119/// See that method for the crash-safety contract.
120#[derive(Debug)]
121pub struct BulkWriter<'a> {
122    store: &'a ObjectStore,
123    dirs: std::collections::HashSet<PathBuf>,
124    files: std::collections::HashSet<PathBuf>,
125}
126
127impl BulkWriter<'_> {
128    /// Write one object durable-before-visible: contents are fsynced
129    /// before the rename publishes the object, and only the shard-dir
130    /// fsync (rename durability) is deferred to commit. This upholds the
131    /// store's global invariant (an object is never visible before its
132    /// bytes are durable), so another process's `contains()` dedup can
133    /// never reference a half-written object. An existing path is
134    /// byte-verified: matched objects are left in place (but still
135    /// fsynced at commit, see below), torn ones are rewritten.
136    ///
137    /// # Panics
138    /// Never in practice: object paths always have a 2-hex shard
139    /// parent by construction.
140    pub fn write(&mut self, bytes: &[u8]) -> StoreResult<Hash> {
141        if bytes.len() > MAX_RAW_OBJECT_SIZE {
142            return Err(StoreError::ObjectTooLarge);
143        }
144        let h = object_id_from_bytes(bytes);
145        let final_path = self.store.path_for(&h);
146        // VERIFY-skip an existing object: content addressing makes
147        // byte-equality the exact durability test — a good file (from
148        // a previously committed session, possibly referenced by
149        // native history) must NOT be replaced with an unsynced inode
150        // a power loss could tear, while a torn file from a crashed
151        // session fails the comparison and is healed by the rewrite.
152        let shard_dir = final_path
153            .parent()
154            .expect("object path always has a 2-hex parent");
155        if let Ok(existing) = fs::read(&final_path)
156            && existing == bytes
157        {
158            // The bytes may have matched out of the PAGE CACHE of a
159            // crashed session's unsynced write — byte equality is not a
160            // durability test, so this pre-existing file still needs a
161            // content fsync at commit.
162            self.dirs.insert(shard_dir.to_path_buf());
163            self.files.insert(final_path);
164            return Ok(h);
165        }
166        fs::create_dir_all(shard_dir)?;
167        // Content fsync happens BEFORE the rename, so the object is
168        // durable the instant it becomes visible. Only the rename's
169        // durability (the shard dir fsync) is deferred to commit.
170        crate::atomic::write_content_synced(&final_path, bytes)?;
171        self.dirs.insert(shard_dir.to_path_buf());
172        Ok(h)
173    }
174
175    /// Make the session durable: fsync the CONTENTS of any pre-existing
176    /// objects this batch re-used (newly written objects were already
177    /// content-fsynced before their rename in [`write`](Self::write)),
178    /// then every touched shard directory (renames become durable).
179    pub fn commit(self) -> StoreResult<()> {
180        for file in &self.files {
181            fs::OpenOptions::new().write(true).open(file)?.sync_all()?;
182        }
183        for dir in &self.dirs {
184            crate::atomic::sync_dir(dir)?;
185        }
186        Ok(())
187    }
188}
189
190/// Result alias used throughout this module.
191pub type StoreResult<T> = Result<T, StoreError>;
192
193// Tiny per-process counter for unique temp-file names. We use this
194// instead of pulling in `rand` because the temp name only needs to be
195// unique within the process; the atomic `rename` enforces global
196// correctness even if two processes collide on a name.
197static TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
198
199/// Local content-addressed object store backed by the filesystem.
200#[derive(Debug, Clone)]
201pub struct ObjectStore {
202    /// Absolute path to `<root>/.mkit/objects`.
203    objects_root: PathBuf,
204    /// Flush/rename primitive seam. Production code always uses
205    /// [`RealSyncer`]; unit tests inject a recording double to assert
206    /// flush ordering and counts (the O(1)-flushes-per-batch contract).
207    syncer: Arc<dyn Syncer>,
208    /// Policy handed out by [`Self::batch`]. Defaults to
209    /// [`SyncPolicy::Batch`]; the CLI maps the repo config key
210    /// `durability.objects = per-object` onto it for deployments that
211    /// want the strict historical schedule.
212    default_policy: SyncPolicy,
213    /// Test-only counter of physical reads (`read_raw` calls) this store
214    /// has performed. There is no read-side equivalent of `syncer` to
215    /// inject a recording double against, so this is a plain counter
216    /// instead — used to prove callers (e.g. the pack reader's delta-base
217    /// resolution, issue #643) hit the store at most once per distinct
218    /// object rather than once per reference to it.
219    #[cfg(test)]
220    read_calls: Arc<AtomicU64>,
221}
222
223impl ObjectStore {
224    /// Open the existing repository described by `layout`. Returns
225    /// [`StoreError::NotAMkitRepository`] if the layout's `objects/`
226    /// directory does not exist. The object store is common-dir
227    /// (shared) state — see [`crate::layout`].
228    pub fn open(layout: &RepoLayout) -> StoreResult<Self> {
229        let objects_root = layout.objects_dir();
230        if !objects_root.is_dir() {
231            return Err(StoreError::NotAMkitRepository);
232        }
233        // Reject a repository that does not declare merkle object
234        // addressing — a pre-merkle repo would mis-read every Tree /
235        // ChunkedBlob (and thus every Commit) under the new id scheme.
236        match fs::read_to_string(layout.format_file()) {
237            Ok(s) if s.trim() == FORMAT_VALUE => {}
238            Ok(s) => {
239                return Err(StoreError::IncompatibleRepoFormat {
240                    found: Some(s.trim().to_owned()),
241                });
242            }
243            Err(e) if e.kind() == io::ErrorKind::NotFound => {
244                return Err(StoreError::IncompatibleRepoFormat { found: None });
245            }
246            Err(e) => return Err(StoreError::Io(e)),
247        }
248        Ok(Self {
249            objects_root,
250            syncer: Arc::new(RealSyncer),
251            default_policy: SyncPolicy::Batch,
252            #[cfg(test)]
253            read_calls: Arc::new(AtomicU64::new(0)),
254        })
255    }
256
257    /// Initialise a fresh common dir for the repository described by
258    /// `layout`. Returns [`StoreError::AlreadyInitialized`] if the
259    /// common dir already exists.
260    pub fn init(layout: &RepoLayout) -> StoreResult<Self> {
261        if layout.common_dir().exists() {
262            return Err(StoreError::AlreadyInitialized);
263        }
264        let objects_root = layout.objects_dir();
265        fs::create_dir_all(&objects_root)?;
266        // Declare the object-addressing format so a future open by an
267        // incompatible (older) mkit fails loudly (SPEC-MERKLE-OBJECTS §7).
268        fs::write(layout.format_file(), format!("{FORMAT_VALUE}\n").as_bytes())?;
269        Ok(Self {
270            objects_root,
271            syncer: Arc::new(RealSyncer),
272            default_policy: SyncPolicy::Batch,
273            #[cfg(test)]
274            read_calls: Arc::new(AtomicU64::new(0)),
275        })
276    }
277
278    /// Select the [`SyncPolicy`] that [`Self::batch`] hands out. The
279    /// CLI wires the repo config key `durability.objects` here so
280    /// deployments on filesystems where they prefer the strict
281    /// per-object schedule can opt into it (SPEC-OBJECTS §10.1).
282    pub fn set_sync_policy(&mut self, policy: SyncPolicy) {
283        self.default_policy = policy;
284    }
285
286    /// Replace the flush/rename primitives. Test-only seam — see the
287    /// `syncer` field. Not exposed publicly so the production sync
288    /// strategy cannot be silently weakened by downstream code.
289    #[cfg(test)]
290    pub(crate) fn set_syncer(&mut self, syncer: Arc<dyn Syncer>) {
291        self.syncer = syncer;
292    }
293
294    /// The active flush/rename primitives, shared with [`WriteBatch`].
295    pub(crate) fn syncer(&self) -> &Arc<dyn Syncer> {
296        &self.syncer
297    }
298
299    /// Test-only: number of physical filesystem reads this store has
300    /// performed (via [`Self::read`] or [`Self::read_unverified`]) since
301    /// creation. See the `read_calls` field doc for why this is a plain
302    /// counter rather than an injectable double like [`Self::set_syncer`].
303    #[cfg(test)]
304    pub(crate) fn read_call_count(&self) -> u64 {
305        self.read_calls.load(Ordering::Relaxed)
306    }
307
308    /// Start a batched write with the store's configured policy
309    /// (default [`SyncPolicy::Batch`]): objects staged by the batch
310    /// become durable and visible together at [`WriteBatch::commit`],
311    /// with O(1) full flushes per batch instead of per object.
312    #[must_use]
313    pub fn batch(&self) -> WriteBatch<'_> {
314        self.batch_with_policy(self.default_policy)
315    }
316
317    /// Start a batched write with an explicit [`SyncPolicy`].
318    #[must_use]
319    pub fn batch_with_policy(&self, policy: SyncPolicy) -> WriteBatch<'_> {
320        WriteBatch::new(self, policy)
321    }
322
323    /// Returns `true` when `root` contains a `.mkit/objects` directory.
324    #[must_use]
325    pub fn is_repo_root(root: &Path) -> bool {
326        root.join(MKIT_DIR).join(OBJECTS_DIR).is_dir()
327    }
328
329    /// Absolute path to the `objects/` directory.
330    #[must_use]
331    pub fn objects_root(&self) -> &Path {
332        &self.objects_root
333    }
334
335    /// Compute the on-disk path for `hash`, joined under `objects/`.
336    /// `pub(crate)` so [`WriteBatch`] shares the single layout rule.
337    pub(crate) fn path_for(&self, h: &Hash) -> PathBuf {
338        let p = object_path(h);
339        // Both halves are ASCII hex by construction in `object_path`.
340        let dir = std::str::from_utf8(&p.dir).expect("ascii hex");
341        let file = std::str::from_utf8(&p.file).expect("ascii hex");
342        self.objects_root.join(dir).join(file)
343    }
344
345    /// Returns `true` when the object `h` is present in the store. Does
346    /// **not** verify integrity — use [`Self::read`] for that.
347    #[must_use]
348    pub fn contains(&self, h: &Hash) -> bool {
349        self.path_for(h).is_file()
350    }
351
352    /// Write `bytes` to the store, returning their BLAKE3 hash. Atomic:
353    /// writes to a sibling temp file, `fsync`s, then renames into place.
354    /// Idempotent — re-writing the same bytes is a no-op (the temp file
355    /// is unlinked on the early-return path).
356    ///
357    /// # Panics
358    ///
359    /// Panics only if the internal hash-to-path mapping produces a path
360    /// without a parent directory, which is impossible by construction.
361    pub fn write(&self, bytes: &[u8]) -> StoreResult<Hash> {
362        if bytes.len() > MAX_RAW_OBJECT_SIZE {
363            return Err(StoreError::ObjectTooLarge);
364        }
365        let h = object_id_from_bytes(bytes);
366        let final_path = self.path_for(&h);
367        if final_path.exists() {
368            // Dedup hit: the object is visible, but if another process
369            // renamed it and has not yet flushed the dirent, it may not
370            // be durable — and our caller is about to reference it.
371            // Flush its shard dir before returning (SPEC-OBJECTS §10.1
372            // dedup rule, mirroring WriteBatch's touched_shards).
373            self.syncer()
374                .dir_sync(final_path.parent().expect("object path has parent"))?;
375            return Ok(h);
376        }
377        let shard_dir = final_path
378            .parent()
379            .expect("object path always has a 2-hex parent");
380        fs::create_dir_all(shard_dir)?;
381        write_atomic(&final_path, bytes, &**self.syncer())?;
382        Ok(h)
383    }
384
385    /// Begin a bulk-write session: each new object is written
386    /// durable-before-visible (contents fsynced, then renamed), and
387    /// [`BulkWriter::commit`] batches the directory fsyncs (rename
388    /// durability) plus a content fsync of any re-used pre-existing
389    /// objects once at the end, instead of fsyncing a dir per write.
390    ///
391    /// Because content is durable before the rename, the session upholds
392    /// the store's global invariant even under concurrent readers: an
393    /// object another process can `contains()`-dedup against is always
394    /// backed by durable bytes.
395    ///
396    /// Crash-safety contract (deliberately weaker than [`Self::write`]
397    /// only for the rename/dirent half, for callers whose whole
398    /// operation is idempotent — e.g. the deterministic git-import,
399    /// which re-runs from a retained source mirror): after a crash
400    /// BEFORE `commit`, a just-renamed object's dirent may be lost (the
401    /// shard dir is not yet fsynced), so objects may be missing — never
402    /// torn, since contents were fsynced first. Existing paths are
403    /// VERIFIED (byte compare)
404    /// rather than blindly rewritten or blindly trusted: a matching
405    /// file is left untouched (it may be durable and referenced by
406    /// native history — replacing it with an unsynced inode would put
407    /// it at risk), a torn one is healed by rewrite, and reads always
408    /// BLAKE3-verify. Callers MUST gate bulk sessions behind their
409    /// own crash marker and re-run on detection.
410    #[must_use]
411    pub fn bulk_writer(&self) -> BulkWriter<'_> {
412        BulkWriter {
413            store: self,
414            dirs: std::collections::HashSet::new(),
415            files: std::collections::HashSet::new(),
416        }
417    }
418
419    /// Open `h`'s on-disk file, cap its size at [`MAX_RAW_OBJECT_SIZE`],
420    /// and read it fully into a pre-sized buffer — the shared body of
421    /// [`Self::read`] and [`Self::read_unverified`]; neither hashes nor
422    /// verifies, that's each caller's job.
423    fn read_raw(&self, h: &Hash) -> StoreResult<Vec<u8>> {
424        #[cfg(test)]
425        self.read_calls.fetch_add(1, Ordering::Relaxed);
426        let path = self.path_for(h);
427        let mut file = File::open(&path).map_err(|e| match e.kind() {
428            io::ErrorKind::NotFound => StoreError::ObjectNotFound(to_hex(h)),
429            _ => StoreError::Io(e),
430        })?;
431        let meta = file.metadata()?;
432        let size = meta.len();
433        if u128::from(size) > MAX_RAW_OBJECT_SIZE as u128 {
434            return Err(StoreError::ObjectTooLarge);
435        }
436        // We've already bounded `size` by `MAX_RAW_OBJECT_SIZE` (1 GiB),
437        // which fits in `usize` on every platform we support (32-bit
438        // included). Pre-size the buffer to avoid the doubling
439        // re-allocations of `read_to_end` for large objects.
440        let cap = usize::try_from(size).map_err(|_| StoreError::ObjectTooLarge)?;
441        let mut bytes = Vec::with_capacity(cap);
442        file.read_to_end(&mut bytes)?;
443        Ok(bytes)
444    }
445
446    /// Read raw bytes for `h`. Verifies that BLAKE3 of the on-disk
447    /// bytes equals `h` and returns [`StoreError::HashMismatch`] on
448    /// failure (the bytes are still discarded so callers cannot
449    /// accidentally use corrupt data).
450    pub fn read(&self, h: &Hash) -> StoreResult<Vec<u8>> {
451        let bytes = self.read_raw(h)?;
452        let actual = object_id_from_bytes(&bytes);
453        if actual != *h {
454            return Err(StoreError::HashMismatch {
455                expected: to_hex(h),
456                actual: to_hex(&actual),
457            });
458        }
459        Ok(bytes)
460    }
461
462    /// Read raw bytes for `h` WITHOUT the BLAKE3 integrity check that
463    /// [`Self::read`] performs — a [`StoreError::HashMismatch`] can
464    /// therefore never come out of this path.
465    ///
466    /// # Policy — display-only paths ONLY (#625)
467    /// This exists for hot read-only rendering (`diff`/`show`/commit
468    /// summaries formatting a diffstat or patch preview). It is the same
469    /// "cheap read" tradeoff [`Self::object_type`] already makes for shape
470    /// checks (see its doc and [`Self::verify_object_type`], which is the
471    /// *verifying* twin used by tree-publication paths) — extended here to
472    /// the full object body.
473    ///
474    /// NEVER use this for publication (commit/merge/rebase tree writes),
475    /// dedup, fetch/apply, or any path whose output is consumed as data
476    /// rather than displayed and discarded. mkit never feeds unverified
477    /// bytes into state that mkit itself writes, or into output that
478    /// mkit's own tooling round-trips (e.g. `format-patch` piped into
479    /// `git am`). On corruption, callers of `read` get a loud, typed error
480    /// before anything downstream can act on bad bytes; callers of
481    /// `read_unverified` get whatever a decoder makes of the corrupt bytes
482    /// — a decode error, or in the worst case garbage in the *rendered
483    /// output* — but never durable state, because display paths write
484    /// nothing back to the store.
485    ///
486    /// Prefer wrapping the source with [`DisplaySource`] at the render
487    /// call site over calling this directly; that keeps the verify/
488    /// no-verify choice at the boundary instead of threading a flag
489    /// through render code.
490    pub fn read_unverified(&self, h: &Hash) -> StoreResult<Vec<u8>> {
491        self.read_raw(h)
492    }
493
494    /// The object's type tag, from its 6-byte prologue — without
495    /// reading or hash-verifying the body. Backs cheap shape checks
496    /// (e.g. "is this staged hash blob-like?") that previously paid a
497    /// full read+BLAKE3 of every staged blob per status/commit; the
498    /// real read path still integrity-verifies at use time.
499    ///
500    /// # Errors
501    /// [`StoreError::ObjectNotFound`] if absent; [`StoreError::Decode`]
502    /// for a short file, bad magic/version, or unknown tag.
503    pub fn object_type(&self, h: &Hash) -> StoreResult<crate::object::ObjectType> {
504        let path = self.path_for(h);
505        let mut file = File::open(&path).map_err(|e| match e.kind() {
506            io::ErrorKind::NotFound => StoreError::ObjectNotFound(to_hex(h)),
507            _ => StoreError::Io(e),
508        })?;
509        let mut prologue = [0u8; 6];
510        file.read_exact(&mut prologue)
511            .map_err(|_| StoreError::Decode(MkitError::EmptyData))?;
512        if prologue[1..5] != crate::object::MAGIC {
513            return Err(StoreError::Decode(MkitError::InvalidMagic));
514        }
515        if prologue[5] != crate::object::SCHEMA_VERSION {
516            return Err(StoreError::Decode(MkitError::UnsupportedObjectVersion));
517        }
518        crate::object::ObjectType::from_u8(prologue[0]).map_err(StoreError::Decode)
519    }
520
521    /// Convenience: read raw bytes and decode into a typed [`Object`].
522    pub fn read_object(&self, h: &Hash) -> StoreResult<Object> {
523        let bytes = self.read(h)?;
524        let obj = serialize::deserialize(&bytes)?;
525        Ok(obj)
526    }
527
528    /// Hash-verifying variant of [`object_type`](Self::object_type): reads
529    /// the whole object and confirms its content hashes to `h` before
530    /// returning the declared type. This is the integrity guard for
531    /// tree-publication paths (commit, merge, rebase, …) — `object_type`
532    /// alone reads only the 6-byte prologue, so a staged object corrupted
533    /// after `add` would otherwise be published into a durable tree and
534    /// only fail at later read time. Use `object_type` on hot read-only
535    /// paths (status/diff snapshots) where nothing durable is published.
536    pub fn verify_object_type(&self, h: &Hash) -> StoreResult<crate::object::ObjectType> {
537        let bytes = self.read(h)?; // read() re-hashes and rejects on mismatch
538        if bytes.len() < 6 {
539            return Err(StoreError::Decode(MkitError::EmptyData));
540        }
541        if bytes[1..5] != crate::object::MAGIC {
542            return Err(StoreError::Decode(MkitError::InvalidMagic));
543        }
544        if bytes[5] != crate::object::SCHEMA_VERSION {
545            return Err(StoreError::Decode(MkitError::UnsupportedObjectVersion));
546        }
547        crate::object::ObjectType::from_u8(bytes[0]).map_err(StoreError::Decode)
548    }
549
550    /// Enumerate every object hash currently in the store by walking
551    /// `objects/<2-hex>/<62-hex>`. Entries whose names are not the
552    /// expected hex shape (stray files, atomic-write temp files, unknown
553    /// dirs) are skipped — they are not objects. Used by `gc` to find
554    /// prune candidates.
555    ///
556    /// # Errors
557    /// [`StoreError::Io`] if a directory cannot be read (gc must then
558    /// fail closed rather than prune against a partial enumeration).
559    pub fn iter_object_hashes(&self) -> StoreResult<Vec<Hash>> {
560        let mut out = Vec::new();
561        let shards = match fs::read_dir(&self.objects_root) {
562            Ok(rd) => rd,
563            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
564            Err(e) => return Err(StoreError::Io(e)),
565        };
566        for shard in shards {
567            let shard = shard?;
568            if !shard.file_type()?.is_dir() {
569                continue;
570            }
571            let dir_name = shard.file_name();
572            let Some(dir) = dir_name.to_str() else {
573                continue;
574            };
575            if dir.len() != 2 || !dir.bytes().all(|b| b.is_ascii_hexdigit()) {
576                continue;
577            }
578            for entry in fs::read_dir(shard.path())? {
579                let entry = entry?;
580                if !entry.file_type()?.is_file() {
581                    continue;
582                }
583                let file_name = entry.file_name();
584                let Some(file) = file_name.to_str() else {
585                    continue;
586                };
587                if file.len() != 62 {
588                    continue;
589                }
590                // Reassemble the 64-hex id and parse it; skips non-objects.
591                if let Ok(h) = hash::from_hex(&format!("{dir}{file}")) {
592                    out.push(h);
593                }
594            }
595        }
596        Ok(out)
597    }
598
599    /// Filesystem metadata for object `h` (size + mtime), for gc's
600    /// grace-window check.
601    ///
602    /// # Errors
603    /// [`StoreError::ObjectNotFound`] if absent, else [`StoreError::Io`].
604    pub fn object_metadata(&self, h: &Hash) -> StoreResult<fs::Metadata> {
605        fs::metadata(self.path_for(h)).map_err(|e| match e.kind() {
606            io::ErrorKind::NotFound => StoreError::ObjectNotFound(to_hex(h)),
607            _ => StoreError::Io(e),
608        })
609    }
610
611    /// Delete object `h` from the store. Idempotent: a missing object is
612    /// not an error (it may have been pruned already).
613    ///
614    /// # Errors
615    /// [`StoreError::Io`] on a filesystem failure other than not-found.
616    pub fn remove_object(&self, h: &Hash) -> StoreResult<()> {
617        match fs::remove_file(self.path_for(h)) {
618            Ok(()) => Ok(()),
619            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
620            Err(e) => Err(StoreError::Io(e)),
621        }
622    }
623}
624
625/// Create a uniquely-named sibling temp file in `parent` for the object
626/// file `file_name`. The `.{name}.tmp.{pid}.{seq}` shape is what
627/// [`ObjectStore::iter_object_hashes`] and the stale-temp tests rely on
628/// to skip non-objects.
629pub(crate) fn temp_file_in(parent: &Path, file_name: &str) -> io::Result<NamedTempFile> {
630    let pid = process::id();
631    let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
632    let tmp_name = format!(".{file_name}.tmp.{pid}.{seq}");
633    NamedTempFile::with_prefix_in(tmp_name, parent)
634}
635
636/// Atomically write `bytes` to `final_path` with per-object durability
637/// ([`SyncPolicy::PerObject`]): temp file, full flush, rename into
638/// place, parent-dir flush. On Unix, `rename(2)` is atomic with respect
639/// to concurrent readers and replaces the destination. On Windows,
640/// [`NamedTempFile::persist`] uses `MOVEFILE_REPLACE_EXISTING`
641/// semantics so the replace-existing path works there too.
642///
643/// After a successful rename we flush the parent directory on Unix to
644/// commit the dirent update — without this, the rename can survive a
645/// power loss only in the page cache and the file appears missing on
646/// reboot.
647///
648/// All flush/rename primitives go through `syncer` so unit tests can
649/// assert ordering; [`RealSyncer`] preserves the historical behaviour
650/// exactly.
651fn write_atomic(final_path: &Path, bytes: &[u8], syncer: &dyn Syncer) -> io::Result<()> {
652    let parent = final_path.parent().expect("write_atomic: path has parent");
653    let file_name = final_path
654        .file_name()
655        .expect("write_atomic: path has file name")
656        .to_string_lossy();
657
658    let mut tmp = temp_file_in(parent, &file_name)?;
659    tmp.as_file_mut().write_all(bytes)?;
660    syncer.full(tmp.as_file(), tmp.path())?;
661
662    // NamedTempFile::persist uses a cross-platform atomic replace:
663    // rename(2) on Unix, MoveFileExW with MOVEFILE_REPLACE_EXISTING on Windows.
664    syncer.rename(tmp.into_temp_path(), final_path)?;
665
666    syncer.dir_sync(parent)?;
667    Ok(())
668}
669
670/// Write target shared by [`ObjectStore`] (per-object durability) and
671/// [`WriteBatch`] (batched durability), so ingest code can be written
672/// once against either sink.
673pub trait ObjectSink {
674    /// Store `bytes` as one object, returning its BLAKE3 hash.
675    fn put(&self, bytes: &[u8]) -> StoreResult<Hash>;
676    /// Store the concatenation of `parts` as one object without the
677    /// caller having to materialise the concatenated buffer.
678    fn put_parts(&self, parts: &[&[u8]]) -> StoreResult<Hash>;
679    /// True when the object is already present (or staged) in this sink.
680    fn has(&self, h: &Hash) -> bool;
681}
682
683impl ObjectSink for ObjectStore {
684    fn put(&self, bytes: &[u8]) -> StoreResult<Hash> {
685        self.write(bytes)
686    }
687
688    fn put_parts(&self, parts: &[&[u8]]) -> StoreResult<Hash> {
689        // Per-object path is the legacy/rare sink; hot ingest paths use
690        // WriteBatch, whose put_parts is copy-free.
691        let total: usize = parts.iter().map(|p| p.len()).sum();
692        let mut bytes = Vec::with_capacity(total);
693        for p in parts {
694            bytes.extend_from_slice(p);
695        }
696        self.write(&bytes)
697    }
698
699    fn has(&self, h: &Hash) -> bool {
700        self.contains(h)
701    }
702}
703
704/// On Unix, fsync the directory holding the just-renamed file so the
705/// dirent update is durable. No-op on non-Unix (Windows does not expose
706/// a stable directory-fsync primitive via `std::fs`).
707#[cfg(unix)]
708pub(crate) fn sync_parent_dir(parent: &Path) -> io::Result<()> {
709    match File::open(parent) {
710        Ok(dir) => dir.sync_all(),
711        // If the dir disappeared under us (race with external cleanup),
712        // the durability invariant is moot — propagate silently.
713        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
714        Err(e) => Err(e),
715    }
716}
717
718#[cfg(not(unix))]
719#[allow(clippy::unnecessary_wraps)]
720pub(crate) fn sync_parent_dir(_parent: &Path) -> io::Result<()> {
721    Ok(())
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use crate::object::Blob;
728    use std::fs::OpenOptions;
729    use std::io::Seek;
730    use tempfile::TempDir;
731
732    fn fresh_store() -> (TempDir, ObjectStore) {
733        let dir = TempDir::new().expect("tempdir");
734        let store = ObjectStore::init(&RepoLayout::single(dir.path())).expect("init");
735        (dir, store)
736    }
737
738    #[test]
739    fn init_creates_layout() {
740        let dir = TempDir::new().unwrap();
741        let _ = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
742        assert!(dir.path().join(MKIT_DIR).is_dir());
743        assert!(dir.path().join(MKIT_DIR).join(OBJECTS_DIR).is_dir());
744    }
745
746    #[test]
747    fn init_rejects_already_initialized() {
748        let dir = TempDir::new().unwrap();
749        let layout = RepoLayout::single(dir.path());
750        ObjectStore::init(&layout).unwrap();
751        let err = ObjectStore::init(&layout).unwrap_err();
752        assert!(matches!(err, StoreError::AlreadyInitialized));
753    }
754
755    #[test]
756    fn open_rejects_non_repo() {
757        let dir = TempDir::new().unwrap();
758        let err = ObjectStore::open(&RepoLayout::single(dir.path())).unwrap_err();
759        assert!(matches!(err, StoreError::NotAMkitRepository));
760    }
761
762    #[test]
763    fn init_writes_format_marker_and_open_accepts_it() {
764        let dir = TempDir::new().unwrap();
765        let layout = RepoLayout::single(dir.path());
766        ObjectStore::init(&layout).unwrap();
767        let marker = fs::read_to_string(dir.path().join(MKIT_DIR).join(FORMAT_FILE)).unwrap();
768        assert_eq!(marker.trim(), FORMAT_VALUE);
769        // A freshly-init'd repo opens cleanly.
770        ObjectStore::open(&layout).unwrap();
771    }
772
773    #[test]
774    fn open_rejects_repo_missing_or_wrong_format_marker() {
775        // A pre-merkle repo (objects dir present, no format marker) must be
776        // rejected loudly rather than silently mis-read.
777        let dir = TempDir::new().unwrap();
778        let layout = RepoLayout::single(dir.path());
779        ObjectStore::init(&layout).unwrap();
780        let marker_path = dir.path().join(MKIT_DIR).join(FORMAT_FILE);
781
782        fs::remove_file(&marker_path).unwrap();
783        assert!(matches!(
784            ObjectStore::open(&layout),
785            Err(StoreError::IncompatibleRepoFormat { found: None })
786        ));
787
788        // A repo declaring some other (e.g. future) format is also rejected.
789        fs::write(&marker_path, b"flat-v0\n").unwrap();
790        assert!(matches!(
791            ObjectStore::open(&layout),
792            Err(StoreError::IncompatibleRepoFormat { found: Some(_) })
793        ));
794    }
795
796    #[test]
797    fn is_repo_root_predicate() {
798        let dir = TempDir::new().unwrap();
799        assert!(!ObjectStore::is_repo_root(dir.path()));
800        ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
801        assert!(ObjectStore::is_repo_root(dir.path()));
802    }
803
804    #[test]
805    fn write_then_read_roundtrip() {
806        let (_dir, store) = fresh_store();
807        let bytes = b"hello world".to_vec();
808        let h = store.write(&bytes).unwrap();
809        assert!(store.contains(&h));
810        let got = store.read(&h).unwrap();
811        assert_eq!(got, bytes);
812    }
813
814    #[test]
815    fn read_object_deserialises() {
816        let (_dir, store) = fresh_store();
817        let obj = Object::Blob(Blob {
818            data: b"object bytes".to_vec(),
819        });
820        let bytes = serialize::serialize(&obj).unwrap();
821        let h = store.write(&bytes).unwrap();
822        let parsed = store.read_object(&h).unwrap();
823        assert_eq!(parsed, obj);
824    }
825
826    #[test]
827    fn write_is_idempotent() {
828        let (_dir, store) = fresh_store();
829        let bytes = b"duplicate".to_vec();
830        let h1 = store.write(&bytes).unwrap();
831        let h2 = store.write(&bytes).unwrap();
832        assert_eq!(h1, h2);
833        // Second write must not have produced any stray temp files.
834        let shard = store.path_for(&h1);
835        let parent = shard.parent().unwrap();
836        let entries: Vec<_> = fs::read_dir(parent).unwrap().collect();
837        assert_eq!(
838            entries.len(),
839            1,
840            "shard dir should contain exactly the final object, no temp leaks"
841        );
842    }
843
844    #[test]
845    fn read_missing_returns_not_found() {
846        let (_dir, store) = fresh_store();
847        let phony = hash::hash(b"never written");
848        let err = store.read(&phony).unwrap_err();
849        assert!(matches!(err, StoreError::ObjectNotFound(_)));
850        assert!(!store.contains(&phony));
851    }
852
853    #[test]
854    fn write_rejects_oversize() {
855        let (_dir, store) = fresh_store();
856        // A REAL cap+1 body: `vec![0u8; n]` is `alloc_zeroed`, so the
857        // 1 GiB+1 buffer costs lazily mapped zero pages, not RSS — and
858        // the guard rejects on `len()` before anything reads the bytes.
859        let oversize = vec![0u8; MAX_RAW_OBJECT_SIZE + 1];
860        let err = store.write(&oversize).unwrap_err();
861        assert!(matches!(err, StoreError::ObjectTooLarge), "got {err:?}");
862        drop(oversize);
863        // A realistic small write still works after the rejection.
864        let h = store.write(&[0u8; 16]).unwrap();
865        assert!(store.contains(&h));
866    }
867
868    #[test]
869    fn read_rejects_oversize_on_disk() {
870        // Construct an oversize on-disk blob by hand and confirm `read`
871        // refuses it. We use a sentinel size = MAX + 1 file padded with
872        // zeros; this allocates ~1 GiB of disk, which is unfriendly in
873        // unit tests, so instead we monkey-patch via a smaller-cap copy
874        // of the read path: we synthesise a too-large file by truncating
875        // a real one and verifying the comparison logic at the boundary.
876        //
877        // We exercise `MAX_RAW_OBJECT_SIZE` indirectly by writing a
878        // small object and then *replacing* the on-disk file with one
879        // whose `metadata().len()` exceeds the cap. We use sparse
880        // truncation so no real disk is consumed.
881        let (_dir, store) = fresh_store();
882        let h = store.write(b"seed").unwrap();
883        let path = store.path_for(&h);
884        let f = OpenOptions::new().write(true).open(&path).unwrap();
885        // Sparse extend to cap+1 bytes; allocates effectively no blocks.
886        f.set_len(MAX_RAW_OBJECT_SIZE as u64 + 1).unwrap();
887        drop(f);
888        let err = store.read(&h).unwrap_err();
889        assert!(matches!(err, StoreError::ObjectTooLarge));
890    }
891
892    #[test]
893    fn read_detects_corruption() {
894        let (_dir, store) = fresh_store();
895        let bytes = b"trustworthy".to_vec();
896        let h = store.write(&bytes).unwrap();
897        // Flip a single byte in the on-disk file and expect HashMismatch.
898        let path = store.path_for(&h);
899        {
900            let mut f = OpenOptions::new()
901                .read(true)
902                .write(true)
903                .open(&path)
904                .unwrap();
905            f.seek(io::SeekFrom::Start(0)).unwrap();
906            f.write_all(&[bytes[0] ^ 0xFF]).unwrap();
907            f.sync_all().unwrap();
908        }
909        let err = store.read(&h).unwrap_err();
910        match err {
911            StoreError::HashMismatch { expected, actual } => {
912                assert_eq!(expected, to_hex(&h));
913                assert_ne!(actual, expected, "actual must differ once corrupted");
914            }
915            other => panic!("expected HashMismatch, got {other:?}"),
916        }
917    }
918
919    /// Flip the first byte of the on-disk object file for `h`, in place —
920    /// the "the bytes on disk no longer match `h`" setup for the
921    /// `read`-vs-`read_unverified` corruption split test below. (The
922    /// `DisplaySource` corruption tests carry their own copy in
923    /// `source.rs`.)
924    fn corrupt_first_byte(store: &ObjectStore, h: &Hash, first_byte: u8) {
925        let path = store.path_for(h);
926        let mut f = OpenOptions::new()
927            .read(true)
928            .write(true)
929            .open(&path)
930            .unwrap();
931        f.seek(io::SeekFrom::Start(0)).unwrap();
932        f.write_all(&[first_byte ^ 0xFF]).unwrap();
933        f.sync_all().unwrap();
934    }
935
936    #[test]
937    fn read_unverified_returns_bytes_read_rejects_corruption() {
938        // The core split this whole feature exists for: `read` must still
939        // fail loudly on a corrupted object, while `read_unverified`
940        // returns the (corrupt) bytes as-is — display paths only ever get
941        // a bad render, never silently-wrong durable state.
942        let (_dir, store) = fresh_store();
943        let bytes = b"trustworthy".to_vec();
944        let h = store.write(&bytes).unwrap();
945        corrupt_first_byte(&store, &h, bytes[0]);
946
947        assert!(matches!(
948            store.read(&h).unwrap_err(),
949            StoreError::HashMismatch { .. }
950        ));
951
952        let mut corrupted = bytes.clone();
953        corrupted[0] ^= 0xFF;
954        assert_eq!(store.read_unverified(&h).unwrap(), corrupted);
955    }
956
957    #[test]
958    fn path_layout_is_2_then_62_hex() {
959        let (_dir, store) = fresh_store();
960        let bytes = b"layout test".to_vec();
961        let h = store.write(&bytes).unwrap();
962        let hex = to_hex(&h);
963        let path = store.path_for(&h);
964        let parent = path.parent().unwrap();
965        let parent_name = parent.file_name().unwrap().to_str().unwrap();
966        let file_name = path.file_name().unwrap().to_str().unwrap();
967        assert_eq!(parent_name.len(), 2);
968        assert_eq!(file_name.len(), 62);
969        assert_eq!(parent_name, &hex[..2]);
970        assert_eq!(file_name, &hex[2..]);
971        assert!(path.is_file(), "object file must exist at expected path");
972    }
973
974    #[test]
975    fn temp_file_left_behind_does_not_satisfy_contains() {
976        // Simulate a crash mid-write: drop a stale `.tmp.*` file in the
977        // shard dir without ever renaming. `contains()` must report the
978        // object as absent, and the shard dir must not contain a real
979        // entry that passes the hash check.
980        let (_dir, store) = fresh_store();
981        let target = hash::hash(b"never finalised");
982        let final_path = store.path_for(&target);
983        let shard = final_path.parent().unwrap();
984        fs::create_dir_all(shard).unwrap();
985        let stale = shard.join(format!(
986            ".{}.tmp.0.0",
987            final_path.file_name().unwrap().to_string_lossy()
988        ));
989        fs::write(&stale, b"partial").unwrap();
990        assert!(stale.is_file());
991        assert!(!store.contains(&target));
992        // No file in the shard dir should hash to `target`.
993        for entry in fs::read_dir(shard).unwrap() {
994            let p = entry.unwrap().path();
995            let bytes = fs::read(&p).unwrap();
996            assert_ne!(
997                hash::hash(&bytes),
998                target,
999                "stale temp file must not satisfy the target hash"
1000            );
1001        }
1002    }
1003}
1004
1005#[cfg(test)]
1006mod bulk_writer_tests {
1007    use super::*;
1008
1009    #[test]
1010    fn bulk_writer_round_trips_and_rewrites() {
1011        let td = tempfile::tempdir().unwrap();
1012        let store = ObjectStore::init(&RepoLayout::single(td.path())).unwrap();
1013        let obj = crate::serialize::serialize(&crate::object::Object::Blob(crate::object::Blob {
1014            data: b"bulk".to_vec(),
1015        }))
1016        .unwrap();
1017        let mut bw = store.bulk_writer();
1018        let h1 = bw.write(&obj).unwrap();
1019        // No existence short-circuit: rewriting is fine and heals
1020        // torn files on idempotent re-runs.
1021        let h2 = bw.write(&obj).unwrap();
1022        assert_eq!(h1, h2);
1023        bw.commit().unwrap();
1024        assert_eq!(store.read(&h1).unwrap(), obj);
1025        // Interoperates with the normal (fsynced) writer.
1026        assert_eq!(store.write(&obj).unwrap(), h1);
1027    }
1028}