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}