mkit_core/history.rs
1//! Append-only commit-history Merkle Mountain Range (MMR).
2//!
3//! Issue #157. Light-client inclusion proofs for the commit chain: a
4//! verifier with the MMR root for a branch tip can check "commit X was
5//! leaf N on this branch" with `O(log n)` hash work, without
6//! downloading the parent chain or any pack.
7//!
8//! # Status
9//!
10//! - **In-memory** (shipped, [`CommitHistory::open`]): `mem`-backed
11//! MMR. Lost on process exit. Useful for tests and
12//! short-lived computations where the proof is the only output.
13//! - **On-disk journaled** (this build, [`CommitHistory::open_at`]):
14//! journaled MMR backed by
15//! [`commonware_storage::merkle::mmr::full::Mmr`] pinned to
16//! `=2026.5.0`. The on-disk layout is commonware's native two-store
17//! shape — a fixed-item journal of node digests plus a metadata
18//! sidecar for pruned pinned nodes — laid out under
19//! `<mkit_dir>/history/<sanitized_branch>/`. See
20//! `docs/specs/SPEC-HISTORY-PROOF.md` §4.
21//! - **Commit-field integration** (planned, v0.2): `Commit.history_root`
22//! proto field, new signing-bytes layout. Out of scope here.
23//!
24//! # Hashing
25//!
26//! The underlying primitive is `commonware-storage`'s journaled MMR
27//! parameterised over BLAKE3 (from `commonware-cryptography`). Node
28//! digests are 32-byte BLAKE3 values — same primitive mkit already
29//! uses elsewhere ([`crate::hash::Hash`]). The schedule is **not** the
30//! same as [`crate::hash::hash`]: commonware injects each node's
31//! position into the parent/leaf digest. Treat the digests as opaque
32//! beyond comparison.
33//!
34//! # Sync / async bridge
35//!
36//! commonware's journaled MMR is async over a `commonware-runtime`
37//! `Context`. The mkit-core public surface ([`CommitHistory::append`],
38//! [`CommitHistory::root`], [`CommitHistory::prove`]) is synchronous
39//! by design — it is called from the synchronous `refs::update_ref`
40//! path and from CLI helpers that have no async-runtime context.
41//!
42//! The bridge is the [`crate::protocol::async_shim::Executor`] trait
43//! hoisted in PR #167. [`CommitHistory`] is generic over an executor
44//! `X: Executor`; every sync method drives its async counterpart via
45//! `executor.block_on(...)`. The executor is held by [`Arc`] for the
46//! lifetime of the [`CommitHistory`] value so multiple `append` /
47//! `prove` calls share a single runtime.
48//!
49//! # Executor / Context ownership
50//!
51//! [`CommitHistory::open_at`] takes an [`Arc`]-shared executor from
52//! the caller. The commonware `Context` needed to drive the
53//! journaled MMR is bootstrapped *internally* via a one-shot
54//! [`commonware_runtime::tokio::Runner::start`] on a fresh OS thread
55//! (the standard workaround documented in transport-enc):
56//! the runner returns a Context clone, the outer `Arc<Executor>` inside
57//! the Context keeps tokio's runtime alive, and the bootstrap thread
58//! joins immediately. Subsequent async ops are driven through the
59//! caller-supplied executor.
60//!
61//! This means production callers only need to construct an executor
62//! ([`crate::history::tokio_executor::TokioExecutor`] is provided when
63//! the `history-mmr` feature is on); mkit-core handles the
64//! commonware-side wiring. The trade-off: every [`CommitHistory`]
65//! owns one tokio runtime (via its executor) AND one commonware
66//! Context with its own inner tokio runtime. The two never need to
67//! interact — `tokio::fs` and `tokio::sync::Mutex` are runtime-agnostic
68//! and work whichever runtime is driving the poll.
69
70use std::path::{Path, PathBuf};
71use std::sync::Arc;
72
73use commonware_cryptography::{Blake3, Hasher as CHasher};
74use commonware_parallel::Sequential;
75use commonware_runtime::{Runner as _, Supervisor as _, buffer::paged::CacheRef};
76use commonware_storage::merkle::Bagging;
77use commonware_storage::merkle::mmr::{
78 Location as MmrLocation, Proof as MmrProof, StandardHasher,
79 full::{Config as JConfig, Mmr as JournaledMmr},
80 mem::Mmr as MemMmr,
81};
82use commonware_utils::{NZU16, NZU64, NZUsize};
83
84use crate::hash::{HASH_LEN, Hash};
85use crate::protocol::async_shim::Executor;
86use crate::refs::validate_ref_name;
87
88pub mod tokio_executor;
89
90/// Re-export of the bundled tokio-runtime executor.
91pub use tokio_executor::TokioExecutor;
92
93/// Peak-bagging policy for the commit-history MMR.
94///
95/// This is a **load-bearing cryptographic parameter**: the producer
96/// ([`CommitHistory::root`] / [`CommitHistory::prove`]) and the verifier
97/// ([`verify_inclusion`]) must fold MMR peaks into a root identically, or
98/// every inclusion proof silently fails to verify. Pinning it here — and
99/// consuming it only via [`history_hasher`] — makes that agreement
100/// un-desyncable across all call sites and both code paths.
101///
102/// `ForwardFold` is also a **back-compat** choice: it reproduces the root
103/// that the pre-2026.5 commonware default produced byte-for-byte, so MMR
104/// journals written by an older mkit build and roots already stored in the
105/// reflog stay valid across the commonware bump. Do **not** change this
106/// without an on-disk format-version bump and a migration.
107const HISTORY_BAGGING: Bagging = Bagging::ForwardFold;
108
109// Test-only instrumentation: counts calls to the journaled flavour's
110// `journal.sync()` (fsync), thread-local so parallel `cargo test`
111// threads never interfere with each other's count. Exists purely to
112// let tests assert "backfilling N commits does one fsync, not N"
113// without depending on wall-clock timing. No production overhead —
114// compiled out entirely on non-test builds.
115#[cfg(test)]
116thread_local! {
117 static SYNC_CALL_COUNT: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
118}
119
120#[cfg(test)]
121fn record_sync_call() {
122 SYNC_CALL_COUNT.with(|c| c.set(c.get() + 1));
123}
124
125/// Reset this thread's fsync counter to 0. Call before the operation
126/// under test.
127#[cfg(test)]
128pub(crate) fn reset_sync_call_count() {
129 SYNC_CALL_COUNT.with(|c| c.set(0));
130}
131
132/// Number of `journal.sync()` (fsync) calls on this thread since the
133/// last [`reset_sync_call_count`].
134#[cfg(test)]
135pub(crate) fn sync_call_count() -> u64 {
136 SYNC_CALL_COUNT.with(std::cell::Cell::get)
137}
138
139/// The canonical hasher for the commit-history MMR — the single source of
140/// truth for [`HISTORY_BAGGING`], so the producer and verifier sides can
141/// never drift on the bagging policy.
142fn history_hasher() -> StandardHasher<Blake3> {
143 StandardHasher::new(HISTORY_BAGGING)
144}
145
146// ---------------------------------------------------------------------
147// Public types
148// ---------------------------------------------------------------------
149
150/// 0-based index of a commit within its branch's MMR.
151///
152/// In commonware's vocabulary this is a `Location` — the leaf index in
153/// insertion order, not the MMR's internal node position. The first
154/// commit appended is `Position(0)`, the second is `Position(1)`, etc.
155/// Stable for the lifetime of the branch: positions never shift
156/// because the MMR is append-only.
157#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
158pub struct Position(pub u64);
159
160impl Position {
161 /// Raw `u64`.
162 #[must_use]
163 pub const fn as_u64(self) -> u64 {
164 self.0
165 }
166}
167
168/// Inclusion proof — re-export of commonware's MMR proof type bound to
169/// our BLAKE3 digest. Wire shape is normatively defined in
170/// `SPEC-HISTORY-PROOF.md` §2.
171pub type InclusionProof = MmrProof<<Blake3 as CHasher>::Digest>;
172
173/// Errors returned by [`CommitHistory`] and [`verify_inclusion`].
174#[derive(Debug, thiserror::Error)]
175pub enum HistoryError {
176 /// The underlying MMR rejected the operation (out-of-bounds proof,
177 /// invalid size, etc.). The wrapped string is commonware's own
178 /// `Display` impl — stable enough for logs, not parsed.
179 #[error("mmr error: {0}")]
180 Mmr(String),
181 /// The branch name failed [`crate::refs::validate_ref_name`].
182 #[error("invalid branch name for history journal: {0:?}")]
183 InvalidBranch(String),
184 /// On-disk journal could not be opened or recovered. commonware's
185 /// own `init` performs roll-forward recovery on a half-written
186 /// trailing leaf (see `commonware_storage::merkle::mmr::full`
187 /// docs); this variant surfaces failures it cannot recover from.
188 #[error("history journal is corrupt: {0}")]
189 Corrupted(String),
190 /// Failed to bootstrap the commonware tokio Context.
191 #[error("failed to bootstrap commonware runtime: {0}")]
192 RuntimeBootstrap(String),
193 /// Failed to set up the on-disk history directory.
194 #[error("history directory I/O: {0}")]
195 Io(#[from] std::io::Error),
196}
197
198// ---------------------------------------------------------------------
199// On-disk layout
200// ---------------------------------------------------------------------
201
202/// Subdirectory of `<mkit_dir>` that holds per-branch MMR journals.
203///
204/// commonware lays its journaled-MMR partitions out as a pair of
205/// suffix-discriminated directories below this root:
206///
207/// ```text
208/// <mkit_dir>/history/<sanitized_branch>__journal-blobs/ # node-digest journal blobs
209/// <mkit_dir>/history/<sanitized_branch>__journal-metadata/ # journal segment table
210/// <mkit_dir>/history/<sanitized_branch>__metadata/ # pruned-pinned-node sidecar
211/// ```
212///
213/// The `-blobs` / `-metadata` segment suffixes are appended by
214/// commonware-storage; mkit names the leading partition tokens
215/// (`<sanitized_branch>__journal` and `<sanitized_branch>__metadata`)
216/// and hands them to `journaled::Mmr::init` via [`JConfig`].
217///
218/// commonware partition names are restricted to `[A-Za-z0-9_-]+`, so
219/// branch names go through `sanitize_branch` — a `_xx`-hex encoding
220/// of every byte outside `[A-Za-z0-9-]`. Empty branches and invalid
221/// ref names are rejected up front by [`CommitHistory::open_at`].
222pub const HISTORY_DIR: &str = crate::layout::HISTORY_DIR_NAME;
223
224/// Suffix appended to the branch's partition name for the node-digest
225/// journal.
226pub const JOURNAL_PARTITION_SUFFIX: &str = "__journal";
227
228/// Suffix appended to the branch's partition name for the
229/// pruned-pinned-node metadata sidecar.
230pub const METADATA_PARTITION_SUFFIX: &str = "__metadata";
231
232// ---------------------------------------------------------------------
233// CommitHistory
234// ---------------------------------------------------------------------
235
236/// Append-only Merkle history of commit hashes for one branch.
237///
238/// Two construction modes:
239///
240/// 1. [`CommitHistory::open`] — purely in-memory. Useful for tests
241/// and for callers that don't want any disk side-effects. Lost on
242/// drop.
243/// 2. [`CommitHistory::open_at`] — on-disk journaled MMR under
244/// `<mkit_dir>/history/<branch>/`. Survives process exit.
245///
246/// Each call to a sync method (`append`, `root`, `prove`) on the
247/// journaled flavour drives an async operation on the underlying
248/// `commonware-storage::journaled::Mmr` via the caller-supplied
249/// [`Executor`].
250pub struct CommitHistory<X: Executor = TokioExecutor> {
251 backend: Backend<X>,
252 hasher: StandardHasher<Blake3>,
253}
254
255/// Internal backend selector. The mem variant is the in-memory shape;
256/// the journaled variant is the on-disk one.
257///
258/// `Journaled` is boxed because its inner state (commonware's
259/// `Journaled` plus a `Context` clone) is ~2.2 KiB and would otherwise
260/// pad every mem-flavour `CommitHistory` by the same amount.
261enum Backend<X: Executor> {
262 Mem {
263 mmr: MemMmr<<Blake3 as CHasher>::Digest>,
264 },
265 Journaled(Box<JournaledBackend<X>>),
266}
267
268struct JournaledBackend<X: Executor> {
269 // Order matters for Drop: `mmr` must drop before `ctx` so
270 // any pending async-resource shutdowns can still poll on the
271 // surviving tokio runtime. In practice commonware's
272 // `Journaled` only flushes synchronously in `sync`, but the
273 // ordering is cheap insurance.
274 mmr: JournaledMmr<commonware_runtime::tokio::Context, <Blake3 as CHasher>::Digest, Sequential>,
275 executor: Arc<X>,
276 // Held to keep the bootstrap tokio runtime (inside the Context's
277 // executor `Arc`) alive for the whole CommitHistory lifetime, AND
278 // read by `reopen` (issue #640) to derive a labelled child Context
279 // instead of paying for a second `bootstrap_commonware_context`.
280 ctx: commonware_runtime::tokio::Context,
281 // Held so update_ref_with_history can take a fresh RepoLock for
282 // every append. None for the mem-only flavour. This is the COMMON
283 // dir: history is shared state across worktrees (#493).
284 common_dir: PathBuf,
285 branch: String,
286}
287
288impl<X: Executor> core::fmt::Debug for CommitHistory<X> {
289 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
290 match &self.backend {
291 Backend::Mem { mmr } => f
292 .debug_struct("CommitHistory::Mem")
293 .field("leaves", &u64::from(mmr.leaves()))
294 .field("size", &u64::from(mmr.size()))
295 .finish_non_exhaustive(),
296 Backend::Journaled(b) => f
297 .debug_struct("CommitHistory::Journaled")
298 .field("branch", &b.branch)
299 .field("leaves", &u64::from(b.mmr.leaves()))
300 .field("size", &u64::from(b.mmr.size()))
301 .finish_non_exhaustive(),
302 }
303 }
304}
305
306impl CommitHistory<TokioExecutor> {
307 /// Open a fresh empty in-memory history (mem-backed shape).
308 ///
309 /// Lost on drop. Useful for unit tests and for callers that just
310 /// need a proof bundle without committing any state to disk.
311 #[must_use]
312 pub fn open() -> Self {
313 let hasher = history_hasher();
314 let mmr = MemMmr::new();
315 Self {
316 backend: Backend::Mem { mmr },
317 hasher,
318 }
319 }
320}
321
322impl<X: Executor + 'static> CommitHistory<X> {
323 /// Open a persisted history under `<common dir>/history/<branch>/`.
324 ///
325 /// The on-disk layout is commonware-storage's native journaled MMR
326 /// shape — see [`HISTORY_DIR`] and SPEC-HISTORY-PROOF §4. If the
327 /// underlying journal does not yet exist, it is created empty
328 /// (subsequent [`CommitHistory::append`] calls populate it).
329 ///
330 /// Crash recovery is delegated to commonware: its `Journaled::init`
331 /// detects a half-written trailing leaf, rewinds the journal to
332 /// the last valid size, and re-derives the in-memory state. See
333 /// SPEC-HISTORY-PROOF §4.4 for the contract surfaced to mkit
334 /// callers.
335 ///
336 /// # Errors
337 ///
338 /// - [`HistoryError::InvalidBranch`] — `branch` failed
339 /// [`validate_ref_name`].
340 /// - [`HistoryError::RuntimeBootstrap`] — could not start the
341 /// commonware tokio Runner used to construct the underlying
342 /// storage Context.
343 /// - [`HistoryError::Corrupted`] — commonware refused to recover
344 /// the journal (a deeper-than-trailing-leaf corruption).
345 /// - [`HistoryError::Io`] — failed to create the history dir.
346 pub fn open_at(
347 executor: Arc<X>,
348 layout: &crate::layout::RepoLayout,
349 branch: &str,
350 ) -> Result<Self, HistoryError> {
351 Self::open_at_common_dir(executor, layout.common_dir(), branch)
352 }
353
354 /// [`Self::open_at`] on a bare common dir — the internal seam
355 /// [`Self::reopen`] uses to reconstruct itself from the stored
356 /// `common_dir` without re-materializing a full layout.
357 fn open_at_common_dir(
358 executor: Arc<X>,
359 common_dir: &Path,
360 branch: &str,
361 ) -> Result<Self, HistoryError> {
362 if !validate_ref_name(branch) {
363 return Err(HistoryError::InvalidBranch(branch.to_string()));
364 }
365
366 let history_dir = common_dir.join(HISTORY_DIR);
367 std::fs::create_dir_all(&history_dir)?;
368
369 // Bootstrap a commonware tokio Context rooted at
370 // `<mkit_dir>/history`. The Context survives the bootstrap
371 // runner's drop because its inner `Arc<Executor>` (the
372 // commonware Executor, not ours) holds the tokio runtime alive.
373 //
374 // This is the ONLY place a fresh Context gets bootstrapped.
375 // [`Self::reopen`] re-derives state via [`Self::init_journaled`]
376 // against its already-live Context instead of calling this
377 // again — see issue #640.
378 let ctx = bootstrap_commonware_context(&history_dir)?;
379
380 Self::init_journaled(executor, ctx, common_dir, branch)
381 }
382
383 /// Open (or re-derive) the journaled backend against an
384 /// already-live commonware `Context`.
385 ///
386 /// This is the shared tail of [`Self::open_at_common_dir`] (which
387 /// bootstraps a fresh `Context` and hands it here) and
388 /// [`Self::reopen`] (which passes in the CURRENT handle's already
389 /// -bootstrapped `Context` instead of building a new one). Every
390 /// call re-runs `JournaledMmr::init`, so the returned handle always
391 /// reflects what's actually on disk right now — the expensive part
392 /// this function does NOT repeat is the OS-thread `Context`
393 /// bootstrap itself.
394 fn init_journaled(
395 executor: Arc<X>,
396 ctx: commonware_runtime::tokio::Context,
397 common_dir: &Path,
398 branch: &str,
399 ) -> Result<Self, HistoryError> {
400 let sanitized = sanitize_branch(branch);
401 let journal_partition = format!("{sanitized}{JOURNAL_PARTITION_SUFFIX}");
402 let metadata_partition = format!("{sanitized}{METADATA_PARTITION_SUFFIX}");
403 let cfg = JConfig {
404 journal_partition,
405 metadata_partition,
406 // 4096 items/blob → 128 KiB blobs (32 B digest × 4096). Big
407 // enough that small branches stay in one blob; small enough
408 // that pruning later doesn't carry stale data around.
409 items_per_blob: NZU64!(4096),
410 write_buffer: NZUsize!(4096),
411 strategy: Sequential,
412 page_cache: CacheRef::from_pooler(&ctx, NZU16!(4096), NZUsize!(8)),
413 };
414
415 let hasher = history_hasher();
416 let mmr = {
417 let hasher_inner = history_hasher();
418 // `child` returns an owned child Context, leaving `ctx` usable for
419 // the surviving CommitHistory (which keeps the bootstrap runtime
420 // alive via its inner executor Arc).
421 let ctx_for_init = ctx.child("mmr_init");
422 executor
423 .block_on(async move {
424 JournaledMmr::<_, <Blake3 as CHasher>::Digest, Sequential>::init(
425 ctx_for_init,
426 &hasher_inner,
427 cfg,
428 )
429 .await
430 })
431 .map_err(|e| HistoryError::Corrupted(e.to_string()))?
432 };
433
434 Ok(Self {
435 backend: Backend::Journaled(Box::new(JournaledBackend {
436 mmr,
437 executor,
438 ctx,
439 common_dir: common_dir.to_path_buf(),
440 branch: branch.to_string(),
441 })),
442 hasher,
443 })
444 }
445
446 /// Borrow the common dir this history was opened against. `None`
447 /// for the mem-only flavour. Used by
448 /// [`crate::refs::update_ref_with_history`] to take a `RepoLock`
449 /// around the ref-write + MMR-append critical section.
450 #[must_use]
451 pub fn common_dir(&self) -> Option<&Path> {
452 match &self.backend {
453 Backend::Mem { .. } => None,
454 Backend::Journaled(b) => Some(b.common_dir.as_path()),
455 }
456 }
457
458 /// Borrow the branch name this history was opened against. `None`
459 /// for the mem-only flavour.
460 #[must_use]
461 pub fn branch(&self) -> Option<&str> {
462 match &self.backend {
463 Backend::Mem { .. } => None,
464 Backend::Journaled(b) => Some(b.branch.as_str()),
465 }
466 }
467
468 /// Re-derive this handle's in-memory state from the current
469 /// on-disk journal. A no-op for the mem-only flavour.
470 ///
471 /// [`CommitHistory::open_at`] may run before the caller has taken
472 /// any cross-process lock (opening the journal is cheap and the
473 /// lock is only needed around the ref-write + append critical
474 /// section — see [`crate::refs::update_ref_with_history`]). If
475 /// another process appended to the same on-disk journal in the
476 /// window between that `open_at` and this handle's own locked
477 /// critical section, this handle's leaf count / root would be
478 /// stale, and appending against stale state risks writing a leaf
479 /// at a position the on-disk journal has already used. Callers
480 /// that hold a cross-process lock around their critical section
481 /// MUST call this once after acquiring it and before appending, so
482 /// the append is always against what is truly on disk.
483 ///
484 /// # Errors
485 ///
486 /// Same as [`CommitHistory::open_at`]: [`HistoryError::Corrupted`]
487 /// if commonware cannot recover the journal,
488 /// [`HistoryError::RuntimeBootstrap`] if the runtime bootstrap
489 /// fails, [`HistoryError::Io`] for filesystem failures.
490 ///
491 /// # Bootstrap cost (issue #640)
492 ///
493 /// This does NOT spawn a second commonware `Context` bootstrap.
494 /// [`CommitHistory::open_at`]'s bootstrap thread already built a
495 /// full tokio runtime, metrics task, and buffer pools for this
496 /// handle; re-running that per call would double the cost of
497 /// every history-tracked ref write for no benefit, since the
498 /// bootstrapped Context is reusable — only the MMR's in-memory
499 /// view of the on-disk journal needs re-deriving. `reopen` takes a
500 /// labelled child of the handle's own already-live Context and
501 /// re-runs `JournaledMmr::init` against it, which is what actually
502 /// picks up a concurrent writer's append.
503 pub fn reopen(&mut self) -> Result<(), HistoryError> {
504 let Backend::Journaled(b) = &self.backend else {
505 return Ok(());
506 };
507 // Reuse the already-bootstrapped Context — see the doc comment
508 // above and `bootstrap_commonware_context`'s doc comment for
509 // why a second bootstrap must not happen here.
510 let ctx = b.ctx.child("mmr_reopen");
511 let fresh = Self::init_journaled(b.executor.clone(), ctx, &b.common_dir, &b.branch)?;
512 *self = fresh;
513 Ok(())
514 }
515
516 /// Append a commit hash. Returns its leaf [`Position`].
517 ///
518 /// Positions are dense: the *n*-th append returns `Position(n)`.
519 /// For the journaled flavour, the underlying MMR is `sync`'d to
520 /// disk before returning — survives a `SIGKILL` immediately after.
521 ///
522 /// A thin wrapper over `Self::append_no_sync` + `Self::sync` —
523 /// see those for the split. Callers appending many leaves in one
524 /// critical section (e.g. [`rebuild_from_chain`]'s backfill) should
525 /// call the two halves directly instead, batching the fsync.
526 pub fn append(&mut self, commit_hash: &Hash) -> Result<Position, HistoryError> {
527 let pos = self.append_no_sync(commit_hash)?;
528 self.sync()?;
529 Ok(pos)
530 }
531
532 /// Append a commit hash WITHOUT flushing to disk.
533 ///
534 /// Identical to [`Self::append`] except the journaled flavour skips
535 /// the trailing `journal.sync()` (fsync). Unlike `append`, a leaf
536 /// written this way is only guaranteed durable once a subsequent
537 /// [`Self::sync`] call returns — a `SIGKILL` before that sync loses
538 /// it. Exists so a caller appending many leaves in one critical
539 /// section (e.g. [`rebuild_from_chain`]'s backfill) can defer the
540 /// fsync to once for the whole batch instead of once per leaf: at
541 /// roughly 1-10ms per fsync, one-per-commit made backfilling a
542 /// branch with tens of thousands of commits take minutes instead of
543 /// the single-digit milliseconds SPEC-HISTORY-PROOF promises.
544 ///
545 /// # Errors
546 ///
547 /// Same as [`Self::append`].
548 fn append_no_sync(&mut self, commit_hash: &Hash) -> Result<Position, HistoryError> {
549 let leaf = digest_from_hash(commit_hash);
550 match &mut self.backend {
551 Backend::Mem { mmr } => {
552 let leaf_loc = mmr.leaves();
553 let batch = mmr
554 .new_batch()
555 .add(&self.hasher, &leaf)
556 .merkleize(mmr, &self.hasher);
557 mmr.apply_batch(&batch)
558 .map_err(|e| HistoryError::Mmr(e.to_string()))?;
559 Ok(Position(u64::from(leaf_loc)))
560 }
561 Backend::Journaled(b) => {
562 let leaf_loc = b.mmr.leaves();
563 let batch = b.mmr.new_batch().add(&self.hasher, &leaf);
564 let batch = b.mmr.with_mem(|mem| batch.merkleize(mem, &self.hasher));
565 b.mmr
566 .apply_batch(&batch)
567 .map_err(|e| HistoryError::Mmr(e.to_string()))?;
568 Ok(Position(u64::from(leaf_loc)))
569 }
570 }
571 }
572
573 /// Flush any leaves appended via [`Self::append_no_sync`] to disk.
574 /// No-op for the mem-only flavour (nothing to flush).
575 ///
576 /// # Errors
577 ///
578 /// [`HistoryError::Mmr`] if the underlying journal sync fails.
579 fn sync(&mut self) -> Result<(), HistoryError> {
580 if let Backend::Journaled(b) = &mut self.backend {
581 #[cfg(test)]
582 record_sync_call();
583 // Flush to disk synchronously so a SIGKILL between this
584 // call and the caller's next ref-write does not lose any
585 // leaf appended (via `append_no_sync`) since the last sync.
586 let sync_fut = b.mmr.sync();
587 b.executor
588 .block_on(sync_fut)
589 .map_err(|e| HistoryError::Mmr(e.to_string()))?;
590 }
591 Ok(())
592 }
593
594 /// Current MMR root digest. 32-byte BLAKE3 (mkit `Hash` shape).
595 ///
596 /// Defined for an empty history — commonware returns a
597 /// deterministic empty-MMR root (see SPEC-HISTORY-PROOF §2.3).
598 ///
599 /// # Panics
600 ///
601 /// Never panics in practice. Internally calls commonware's
602 /// `root(&hasher, 0)`, which only returns an error for a non-zero
603 /// inactive-peak count; mkit always requests `0` inactive peaks
604 /// (its proofs are self-contained over the full leaf set), so the
605 /// `Result` is always `Ok`.
606 #[must_use]
607 pub fn root(&self) -> Hash {
608 // `root` takes the hasher + an `inactive_peaks` count (0 for a
609 // self-contained MMR over the full leaf set) and returns an owned
610 // `Digest`. Both backends are sync here (Journaled reads its
611 // in-memory cache); 0 inactive peaks is always a valid request,
612 // so the `Result` is always `Ok` — see the `# Panics` note above.
613 let digest = match &self.backend {
614 Backend::Mem { mmr } => mmr.root(&self.hasher, 0),
615 Backend::Journaled(b) => b.mmr.root(&self.hasher, 0),
616 }
617 .expect("0 inactive peaks is always a valid root request");
618 let mut out = [0u8; HASH_LEN];
619 out.copy_from_slice(digest.as_ref());
620 out
621 }
622
623 /// Number of leaves (commits) appended so far.
624 #[must_use]
625 pub fn len(&self) -> u64 {
626 match &self.backend {
627 Backend::Mem { mmr } => u64::from(mmr.leaves()),
628 Backend::Journaled(b) => u64::from(b.mmr.leaves()),
629 }
630 }
631
632 /// `true` if no commits have been appended.
633 #[must_use]
634 pub fn is_empty(&self) -> bool {
635 self.len() == 0
636 }
637
638 /// Build an inclusion proof for the commit at `position`.
639 pub fn prove(&self, position: Position) -> Result<InclusionProof, HistoryError> {
640 let loc = MmrLocation::new(position.0);
641 match &self.backend {
642 Backend::Mem { mmr } => mmr
643 .proof(&self.hasher, loc, 0)
644 .map_err(|e| HistoryError::Mmr(e.to_string())),
645 Backend::Journaled(b) => {
646 let hasher = self.hasher.clone();
647 let proof_fut = b.mmr.proof(&hasher, loc, 0);
648 // Drive the async proof builder via the executor. The
649 // future borrows `mmr` immutably for the duration of
650 // `block_on`; the borrow ends when `block_on` returns.
651 b.executor
652 .block_on(proof_fut)
653 .map_err(|e| HistoryError::Mmr(e.to_string()))
654 }
655 }
656 }
657
658 /// Permanently delete this history's on-disk journal + metadata
659 /// partitions (issue #648).
660 ///
661 /// Consumes `self`: there is no valid handle to keep using once the
662 /// backing storage is gone. A no-op for the mem-only flavour (there
663 /// is nothing on disk to remove).
664 ///
665 /// This is the primitive [`crate::refs::delete_ref_with_history`]
666 /// uses to fence a branch's journal on `branch -d` / `branch -m`:
667 /// without it, re-creating a branch with a previously-used name
668 /// would reopen the dead incarnation's non-empty journal (same
669 /// sanitized partition name) and resume appending on top of its old
670 /// leaves — see SPEC-HISTORY-PROOF and issue #648 for the full
671 /// write-up of the resulting stale-inclusion-proof bug.
672 ///
673 /// # Errors
674 ///
675 /// [`HistoryError::Mmr`] if commonware's underlying journal/metadata
676 /// `destroy()` fails (e.g. an I/O error removing the partition
677 /// files).
678 pub fn destroy(self) -> Result<(), HistoryError> {
679 match self.backend {
680 Backend::Mem { .. } => Ok(()),
681 Backend::Journaled(b) => {
682 // Destructure so `executor` and `ctx` (which keeps the
683 // bootstrap commonware runtime alive — see the
684 // `JournaledBackend` doc comment) both stay in scope for
685 // the duration of `block_on`, exactly like every other
686 // method on this type. `ctx` itself is unused here beyond
687 // that lifetime-extension role (see #640, which made the
688 // field readable elsewhere but `destroy` has no need to
689 // read it).
690 let JournaledBackend {
691 mmr,
692 executor,
693 ctx: _ctx,
694 ..
695 } = *b;
696 executor
697 .block_on(mmr.destroy())
698 .map_err(|e| HistoryError::Mmr(e.to_string()))
699 }
700 }
701 }
702}
703
704impl Default for CommitHistory<TokioExecutor> {
705 fn default() -> Self {
706 Self::open()
707 }
708}
709
710/// Verify that `commit_hash` was appended at `position` to a history
711/// whose current root is `root`. Returns `true` on a passing proof,
712/// `false` on any tamper / wrong-position / wrong-root case.
713///
714/// Pure function: no allocation beyond what commonware's verifier does
715/// internally; safe to call from a light-client without any of the
716/// preceding chain bytes.
717#[must_use]
718pub fn verify_inclusion(
719 commit_hash: &Hash,
720 position: Position,
721 proof: &InclusionProof,
722 root: &Hash,
723) -> bool {
724 let leaf = digest_from_hash(commit_hash);
725 let root_digest = digest_from_hash(root);
726 let loc = MmrLocation::new(position.0);
727
728 // Same bagging policy as the producer — see [`HISTORY_BAGGING`].
729 let hasher = history_hasher();
730 proof.verify_element_inclusion(&hasher, leaf.as_ref(), loc, &root_digest)
731}
732
733// ---------------------------------------------------------------------
734// v0.1.x → v0.2.x rebuild shim
735// ---------------------------------------------------------------------
736
737/// Rebuild a branch's history MMR from its on-disk parent chain.
738///
739/// For repos created against an older mkit (v0.1.x, before this
740/// module persisted anything to disk) the `<mkit_dir>/history/<branch>/`
741/// directory is empty on first open, but the branch's commit chain is
742/// already on disk in the object store. This helper walks the
743/// first-parent chain from `tip` to root, then re-appends each commit
744/// (in oldest-first order) into the supplied empty [`CommitHistory`].
745///
746/// The walker is supplied by the caller as `parent_of`: given a
747/// commit hash, it returns `Ok(Some(parent_hash))` if there is a
748/// parent, `Ok(None)` for the root commit, and `Err(_)` for any
749/// lookup failure. This keeps `mkit-core::history` free of an
750/// `ObjectStore` dependency — the rebuild shim is a tool the caller
751/// (typically `mkit-cli`) drives.
752///
753/// Batches all backfilled leaves into a single `journal.sync()` (fsync)
754/// for the whole chain, rather than one per commit — see
755/// `CommitHistory::append_no_sync`. Git adopted the same pattern
756/// (`core.fsyncMethod=batch`) for the same reason: fsync latency, not
757/// the MMR math, is what dominates a large backfill.
758///
759/// # Errors
760///
761/// - Propagates any error from `parent_of`.
762/// - Propagates any [`HistoryError`] from the underlying append/sync.
763pub fn rebuild_from_chain<X, F, E>(
764 history: &mut CommitHistory<X>,
765 tip: Hash,
766 mut parent_of: F,
767) -> Result<u64, RebuildError<E>>
768where
769 X: Executor + 'static,
770 F: FnMut(&Hash) -> Result<Option<Hash>, E>,
771 E: core::fmt::Display,
772{
773 let mut chain = Vec::new();
774 let mut cursor = Some(tip);
775 while let Some(h) = cursor {
776 chain.push(h);
777 cursor = parent_of(&h).map_err(RebuildError::Walker)?;
778 }
779 // Walker yielded tip..root; we need root..tip for the append order.
780 chain.reverse();
781 let count = chain.len() as u64;
782 for h in &chain {
783 history.append_no_sync(h).map_err(RebuildError::History)?;
784 }
785 // One fsync for the entire batch instead of one per commit.
786 if count > 0 {
787 history.sync().map_err(RebuildError::History)?;
788 }
789 Ok(count)
790}
791
792/// Errors returned by [`rebuild_from_chain`].
793#[derive(Debug, thiserror::Error)]
794pub enum RebuildError<E: core::fmt::Display> {
795 /// The caller-supplied parent-walker failed. The wrapped `E`
796 /// carries the walker's error verbatim, so callers that pass a
797 /// structured error type get it back unchanged.
798 #[error("parent-chain walker failed: {0}")]
799 Walker(E),
800 /// The underlying [`CommitHistory::append`] failed.
801 #[error(transparent)]
802 History(HistoryError),
803}
804
805// ---------------------------------------------------------------------
806// Internals
807// ---------------------------------------------------------------------
808
809/// Convert an mkit `Hash` (`[u8; 32]`) into commonware's `Blake3::Digest`.
810fn digest_from_hash(h: &Hash) -> <Blake3 as CHasher>::Digest {
811 <<Blake3 as CHasher>::Digest as From<[u8; HASH_LEN]>>::from(*h)
812}
813
814/// Hex-escape a mkit branch name into a commonware-partition-safe
815/// token — see [`crate::refs::sanitize_ref_name`] for the encoding
816/// (`main` → `main`, `feat/v1.0` → `feat_2fv1_2e0`, injective).
817///
818/// The canonical implementation lives in `refs.rs`, NOT here: this
819/// module is entirely `#[cfg(feature = "history-mmr")]`-gated (see
820/// `lib.rs`), but `cas_write`'s per-ref lock naming needs the same
821/// sanitizer in every build, including when this module doesn't
822/// exist. `refs.rs` already has the right dependency direction —
823/// `history.rs` already depends on it for [`crate::refs::validate_ref_name`]
824/// — so this is a thin re-export, not a second implementation, to
825/// avoid the two drifting (found during the epic-#634 code review).
826use crate::refs::sanitize_ref_name as sanitize_branch;
827
828/// Test-only instrumentation for observing how many times
829/// [`bootstrap_commonware_context`] actually spawns a bootstrap OS
830/// thread. Production builds pay zero cost for this (compiled out
831/// entirely under `#[cfg(test)]`).
832///
833/// The counter is installed per-thread rather than as one global
834/// static so that tests exercising this (like
835/// `reopen_reuses_the_bootstrapped_commonware_context_instead_of_rebootstrapping`)
836/// are not flaky under `cargo test`'s default multi-threaded runner —
837/// each test only observes bootstraps triggered by its own call
838/// chain. [`bootstrap_commonware_context`] reads the hook
839/// synchronously on the *calling* thread (before it spawns the
840/// bootstrap thread) and moves the resulting `Arc` into the spawned
841/// closure, so the count still lands correctly even though the
842/// bootstrap itself happens on a different OS thread.
843#[cfg(test)]
844mod bootstrap_probe {
845 use std::cell::RefCell;
846 use std::sync::Arc;
847 use std::sync::atomic::AtomicUsize;
848
849 thread_local! {
850 static COUNTER: RefCell<Option<Arc<AtomicUsize>>> = const { RefCell::new(None) };
851 }
852
853 struct Clear;
854 impl Drop for Clear {
855 fn drop(&mut self) {
856 COUNTER.with(|c| *c.borrow_mut() = None);
857 }
858 }
859
860 /// Install `counter` as the active probe for the current thread.
861 /// Returns a guard that clears the hook on drop.
862 #[must_use]
863 pub(super) fn track(counter: Arc<AtomicUsize>) -> impl Drop {
864 COUNTER.with(|c| *c.borrow_mut() = Some(counter));
865 Clear
866 }
867
868 pub(super) fn current() -> Option<Arc<AtomicUsize>> {
869 COUNTER.with(|c| c.borrow().clone())
870 }
871}
872
873/// Bootstrap a commonware tokio `Context` whose `storage_directory`
874/// is the supplied path.
875///
876/// The bootstrap is done on a fresh OS thread because tokio refuses to
877/// nest `runtime::Builder::build()` inside an already-active runtime
878/// (the `TokioExecutor`'s runtime is already alive in the caller's
879/// thread). The returned Context's inner `Arc<Executor>` keeps the
880/// bootstrap runtime alive after the bootstrap thread joins.
881///
882/// See `docs/specs/SPEC-HISTORY-PROOF.md` §4.1 for the design rationale and
883/// the trade-off with the alternative "share the caller's tokio
884/// runtime" approach.
885///
886/// Callers that already hold a live [`commonware_runtime::tokio::Context`]
887/// (e.g. [`CommitHistory::reopen`]) MUST NOT call this a second time —
888/// see [`CommitHistory::init_journaled`], which re-derives on-disk
889/// state against an existing Context instead.
890fn bootstrap_commonware_context(
891 storage_directory: &Path,
892) -> Result<commonware_runtime::tokio::Context, HistoryError> {
893 let dir = storage_directory.to_path_buf();
894 #[cfg(test)]
895 let probe = bootstrap_probe::current();
896 std::thread::spawn(move || {
897 #[cfg(test)]
898 if let Some(counter) = &probe {
899 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
900 }
901 let cfg = commonware_runtime::tokio::Config::new().with_storage_directory(dir);
902 let runner = commonware_runtime::tokio::Runner::new(cfg);
903 // Return an owned, labelled child Context. `Context::clone` and
904 // `Metrics::with_label` were both removed in 2026.5.0;
905 // `Supervisor::child` returns an owned Context that clones the
906 // inner `executor: Arc<Executor>`, which keeps the tokio runtime
907 // alive after the bootstrap Runner is dropped at the end of
908 // `start`. The label is best-effort so any commonware metrics
909 // surfaced through this Context are easy to spot in a debugger.
910 runner
911 .start(|ctx| async move { commonware_runtime::Supervisor::child(&ctx, "mkit_history") })
912 })
913 .join()
914 .map_err(|_| HistoryError::RuntimeBootstrap("bootstrap thread panicked".to_string()))
915}
916
917// ---------------------------------------------------------------------
918// Tests
919// ---------------------------------------------------------------------
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924 use tempfile::TempDir;
925
926 /// Deterministic distinct commit hash generator.
927 fn synth(i: u64) -> Hash {
928 crate::hash::hash(&i.to_be_bytes())
929 }
930
931 fn fresh_mkit_dir() -> (TempDir, crate::layout::RepoLayout) {
932 let tmp = TempDir::new().unwrap();
933 let mkit_dir = crate::layout::RepoLayout::single(tmp.path());
934 std::fs::create_dir_all(mkit_dir.common_dir()).unwrap();
935 (tmp, mkit_dir)
936 }
937
938 fn fresh_executor() -> Arc<TokioExecutor> {
939 Arc::new(TokioExecutor::new().expect("tokio runtime"))
940 }
941
942 // ---- mem-only API (unchanged from issue #157) -----------------
943
944 #[test]
945 fn mem_empty_history_root_is_well_defined() {
946 let h1 = CommitHistory::open();
947 let h2 = CommitHistory::open();
948 assert_eq!(h1.root(), h2.root(), "empty root must be deterministic");
949 assert!(h1.is_empty());
950 assert_eq!(h1.len(), 0);
951 }
952
953 #[test]
954 fn mem_append_returns_dense_positions() {
955 let mut h = CommitHistory::open();
956 for i in 0..16u64 {
957 let pos = h.append(&synth(i)).unwrap();
958 assert_eq!(pos, Position(i), "positions must be dense and 0-based");
959 }
960 assert_eq!(h.len(), 16);
961 }
962
963 #[test]
964 fn mem_prove_and_verify_position_712_of_1000() {
965 let mut h = CommitHistory::open();
966 let commits: Vec<Hash> = (0..1000u64).map(synth).collect();
967 for c in &commits {
968 h.append(c).unwrap();
969 }
970 assert_eq!(h.len(), 1000);
971
972 let target = Position(712);
973 let proof = h.prove(target).unwrap();
974 let root = h.root();
975
976 assert!(
977 verify_inclusion(&commits[712], target, &proof, &root),
978 "honest proof must verify"
979 );
980 }
981
982 #[test]
983 fn mem_tampered_proof_fails_verification() {
984 let mut h = CommitHistory::open();
985 for i in 0..256u64 {
986 h.append(&synth(i)).unwrap();
987 }
988 let target = Position(42);
989 let mut proof = h.prove(target).unwrap();
990 let root = h.root();
991 let commit = synth(42);
992
993 assert!(verify_inclusion(&commit, target, &proof, &root));
994
995 assert!(
996 !proof.digests.is_empty(),
997 "non-trivial proof must carry at least one sibling"
998 );
999 let mut bytes: [u8; HASH_LEN] = [0u8; HASH_LEN];
1000 bytes.copy_from_slice(proof.digests[0].as_ref());
1001 bytes[0] ^= 0x01;
1002 proof.digests[0] = <<Blake3 as CHasher>::Digest as From<[u8; HASH_LEN]>>::from(bytes);
1003
1004 assert!(
1005 !verify_inclusion(&commit, target, &proof, &root),
1006 "tampered proof must fail"
1007 );
1008 }
1009
1010 /// SPEC-HISTORY-PROOF §3 enumerates six failure modes
1011 /// `verify_inclusion` MUST reject without panicking. Three are
1012 /// already covered above (tampered digest, wrong commit, wrong
1013 /// root); the remaining three — wrong position, mismatched leaf
1014 /// count, and a truncated/over-long `digests` vector — were only
1015 /// exercised indirectly, by trusting commonware's own test suite at
1016 /// the pinned version. Pin them directly here.
1017 #[test]
1018 fn verify_inclusion_rejects_wrong_position() {
1019 let mut h = CommitHistory::open();
1020 let commits: Vec<Hash> = (0..64u64).map(synth).collect();
1021 for c in &commits {
1022 h.append(c).unwrap();
1023 }
1024 let target = Position(42);
1025 let proof = h.prove(target).unwrap();
1026 let root = h.root();
1027
1028 assert!(verify_inclusion(&commits[42], target, &proof, &root));
1029 // Same commit_hash, proof, and root — only the claimed position
1030 // differs. The proof was built for position 42; claiming it
1031 // proves position 41 (or any other) instead must fail.
1032 assert!(!verify_inclusion(&commits[42], Position(41), &proof, &root));
1033 assert!(!verify_inclusion(&commits[42], Position(0), &proof, &root));
1034 }
1035
1036 #[test]
1037 fn verify_inclusion_rejects_mismatched_leaf_count() {
1038 let mut h = CommitHistory::open();
1039 let commits: Vec<Hash> = (0..64u64).map(synth).collect();
1040 for c in &commits {
1041 h.append(c).unwrap();
1042 }
1043 let target = Position(42);
1044 let mut proof = h.prove(target).unwrap();
1045 let root = h.root();
1046 assert!(verify_inclusion(&commits[42], target, &proof, &root));
1047
1048 // `proof.leaves` claims how many leaves the MMR had when the
1049 // proof was built. Disagreeing with the actual count (64) must
1050 // fail — this is the prover asserting a different-length
1051 // history than the root it's paired with actually commits to.
1052 proof.leaves = MmrLocation::new(63);
1053 assert!(!verify_inclusion(&commits[42], target, &proof, &root));
1054 }
1055
1056 #[test]
1057 fn verify_inclusion_rejects_truncated_or_over_long_digests() {
1058 let mut h = CommitHistory::open();
1059 let commits: Vec<Hash> = (0..64u64).map(synth).collect();
1060 for c in &commits {
1061 h.append(c).unwrap();
1062 }
1063 let target = Position(42);
1064 let proof = h.prove(target).unwrap();
1065 let root = h.root();
1066 assert!(verify_inclusion(&commits[42], target, &proof, &root));
1067 assert!(
1068 !proof.digests.is_empty(),
1069 "non-trivial proof must carry at least one digest"
1070 );
1071
1072 // Truncated: drop the last digest the fold-consumer expects.
1073 let mut truncated = proof.clone();
1074 truncated.digests.pop();
1075 assert!(!verify_inclusion(&commits[42], target, &truncated, &root));
1076
1077 // Over-long: append a bogus extra digest past what the
1078 // consumer-pointer walk expects to find.
1079 let mut over_long = proof;
1080 over_long.digests.push(over_long.digests[0]);
1081 assert!(!verify_inclusion(&commits[42], target, &over_long, &root));
1082 }
1083
1084 // ---- journaled API --------------------------------------------
1085
1086 #[test]
1087 fn open_at_rejects_invalid_branch() {
1088 let (_tmp, mkit_dir) = fresh_mkit_dir();
1089 let exec = fresh_executor();
1090 let err = CommitHistory::open_at(exec, &mkit_dir, "../escape").expect_err("invalid branch");
1091 assert!(matches!(err, HistoryError::InvalidBranch(_)));
1092 }
1093
1094 #[test]
1095 fn open_at_empty_root_matches_mem() {
1096 let (_tmp, mkit_dir) = fresh_mkit_dir();
1097 let exec = fresh_executor();
1098 let h_disk = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1099 let h_mem = CommitHistory::open();
1100 assert_eq!(
1101 h_disk.root(),
1102 h_mem.root(),
1103 "empty journaled root must match empty mem root"
1104 );
1105 assert!(h_disk.is_empty());
1106 }
1107
1108 #[test]
1109 fn open_at_round_trip_100_commits() {
1110 let (_tmp, mkit_dir) = fresh_mkit_dir();
1111 let exec = fresh_executor();
1112
1113 let commits: Vec<Hash> = (0..100u64).map(synth).collect();
1114 let (root_before, len_before) = {
1115 let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1116 for c in &commits {
1117 h.append(c).unwrap();
1118 }
1119 (h.root(), h.len())
1120 };
1121 // Re-open and verify the root survives.
1122 let h = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1123 assert_eq!(h.len(), len_before, "leaf count must survive reopen");
1124 assert_eq!(h.root(), root_before, "root must survive reopen");
1125 }
1126
1127 #[test]
1128 fn open_at_prove_after_reopen() {
1129 let (_tmp, mkit_dir) = fresh_mkit_dir();
1130 let exec = fresh_executor();
1131 let commits: Vec<Hash> = (0..64u64).map(synth).collect();
1132 let root = {
1133 let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1134 for c in &commits {
1135 h.append(c).unwrap();
1136 }
1137 h.root()
1138 };
1139 let h = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1140 let target = Position(17);
1141 let proof = h.prove(target).unwrap();
1142 assert!(verify_inclusion(&commits[17], target, &proof, &root));
1143 }
1144
1145 /// Issue #640: `write_ref_recording_history` does one `open_at`
1146 /// before the repo lock, then `update_ref_with_history`'s
1147 /// `reopen()` re-derives state under the lock. That sequence —
1148 /// `open_at` followed by `reopen()` — must pay for exactly ONE
1149 /// commonware Context bootstrap (one OS thread spawning a full
1150 /// tokio runtime, metrics task, and buffer pools), not two. A
1151 /// second bootstrap is pure waste: everything the first one built
1152 /// is torn down milliseconds later.
1153 #[test]
1154 fn reopen_reuses_the_bootstrapped_commonware_context_instead_of_rebootstrapping() {
1155 let (_tmp, mkit_dir) = fresh_mkit_dir();
1156 let exec = fresh_executor();
1157
1158 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1159 let _guard = bootstrap_probe::track(counter.clone());
1160
1161 // Mirrors write_ref_recording_history's pre-lock open.
1162 let mut h = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1163 assert_eq!(
1164 counter.load(std::sync::atomic::Ordering::SeqCst),
1165 1,
1166 "open_at must bootstrap the commonware Context exactly once"
1167 );
1168
1169 // Mirrors update_ref_with_history's post-lock reopen().
1170 h.reopen().unwrap();
1171 assert_eq!(
1172 counter.load(std::sync::atomic::Ordering::SeqCst),
1173 1,
1174 "reopen() must reuse the already-bootstrapped Context rather than \
1175 spawning a second bootstrap thread"
1176 );
1177 }
1178
1179 #[test]
1180 fn open_at_distinct_branches_have_distinct_roots() {
1181 let (_tmp, mkit_dir) = fresh_mkit_dir();
1182 let exec = fresh_executor();
1183 let mut main = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1184 let mut dev = CommitHistory::open_at(exec.clone(), &mkit_dir, "dev").unwrap();
1185 main.append(&synth(0)).unwrap();
1186 dev.append(&synth(1)).unwrap();
1187 assert_ne!(main.root(), dev.root());
1188 }
1189
1190 #[test]
1191 fn open_at_branch_with_slash_is_isolated() {
1192 // Two branches that sanitize to different partition names
1193 // must not share state.
1194 let (_tmp, mkit_dir) = fresh_mkit_dir();
1195 let exec = fresh_executor();
1196 let mut a = CommitHistory::open_at(exec.clone(), &mkit_dir, "feat/v1").unwrap();
1197 let mut b = CommitHistory::open_at(exec.clone(), &mkit_dir, "feat/v2").unwrap();
1198 a.append(&synth(0)).unwrap();
1199 assert_eq!(a.len(), 1);
1200 assert_eq!(b.len(), 0, "sibling branch must not see appended leaf");
1201 b.append(&synth(1)).unwrap();
1202 assert_ne!(a.root(), b.root());
1203 }
1204
1205 // ---- destroy (issue #648: branch-delete journal lifecycle) ----
1206
1207 #[test]
1208 fn destroy_removes_on_disk_partition() {
1209 let (_tmp, mkit_dir) = fresh_mkit_dir();
1210 let exec = fresh_executor();
1211 let commits: Vec<Hash> = (0..5u64).map(synth).collect();
1212
1213 let mut h = CommitHistory::open_at(exec, &mkit_dir, "feature").unwrap();
1214 for c in &commits {
1215 h.append(c).unwrap();
1216 }
1217 assert_eq!(h.len(), 5);
1218
1219 // Sanitized partition name for "feature" is "feature" →
1220 // "feature__journal-blobs" (same layout documented on
1221 // `HISTORY_DIR` and exercised by the truncation test above).
1222 let journal_blobs = mkit_dir.history_dir().join("feature__journal-blobs");
1223 let journal_metadata = mkit_dir.history_dir().join("feature__journal-metadata");
1224 assert!(
1225 journal_blobs.exists(),
1226 "journal blob dir must exist after appends"
1227 );
1228
1229 h.destroy().unwrap();
1230
1231 assert!(
1232 !journal_blobs.exists(),
1233 "destroy must remove the on-disk journal blob partition"
1234 );
1235 assert!(
1236 !journal_metadata.exists(),
1237 "destroy must remove the on-disk journal metadata partition"
1238 );
1239 }
1240
1241 #[test]
1242 fn destroyed_journal_reopens_empty_not_resuming_dead_incarnation() {
1243 // This is the exact invariant `mkit branch -d` + recreate under
1244 // the same name relies on (issue #648): once a branch's journal
1245 // has been destroyed, reopening the SAME sanitized partition
1246 // name must start completely fresh — not resume the deleted
1247 // incarnation's leaves.
1248 let (_tmp, mkit_dir) = fresh_mkit_dir();
1249 let exec = fresh_executor();
1250 let commits: Vec<Hash> = (0..3u64).map(synth).collect();
1251
1252 let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir, "feature").unwrap();
1253 for c in &commits {
1254 h.append(c).unwrap();
1255 }
1256 assert_eq!(h.len(), 3);
1257 h.destroy().unwrap();
1258
1259 let reopened = CommitHistory::open_at(exec, &mkit_dir, "feature").unwrap();
1260 assert_eq!(
1261 reopened.len(),
1262 0,
1263 "a destroyed journal must reopen with zero leaves, not resume the dead incarnation"
1264 );
1265 assert_eq!(
1266 reopened.root(),
1267 CommitHistory::open().root(),
1268 "a fresh reopen after destroy must match a genuinely empty MMR's root"
1269 );
1270 }
1271
1272 #[test]
1273 fn destroy_of_one_branch_does_not_touch_a_sibling_branch() {
1274 let (_tmp, mkit_dir) = fresh_mkit_dir();
1275 let exec = fresh_executor();
1276
1277 let mut a = CommitHistory::open_at(exec.clone(), &mkit_dir, "a").unwrap();
1278 let mut b = CommitHistory::open_at(exec.clone(), &mkit_dir, "b").unwrap();
1279 a.append(&synth(0)).unwrap();
1280 b.append(&synth(1)).unwrap();
1281 let b_root = b.root();
1282 drop(b);
1283
1284 a.destroy().unwrap();
1285
1286 let b_reopened = CommitHistory::open_at(exec, &mkit_dir, "b").unwrap();
1287 assert_eq!(
1288 b_reopened.len(),
1289 1,
1290 "destroying branch 'a' must not affect sibling branch 'b'"
1291 );
1292 assert_eq!(b_reopened.root(), b_root);
1293 }
1294
1295 #[test]
1296 fn open_at_root_matches_live_mem_root() {
1297 // Executor swap test: a journaled MMR's root must equal the
1298 // pure-mem MMR's root computed over the same leaf sequence.
1299 // This proves the executor and the persistence layer are
1300 // opaque to MMR semantics.
1301 let (_tmp, mkit_dir) = fresh_mkit_dir();
1302 let exec = fresh_executor();
1303 let commits: Vec<Hash> = (0..50u64).map(synth).collect();
1304
1305 let mut journaled = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1306 let mut mem = CommitHistory::open();
1307 for c in &commits {
1308 journaled.append(c).unwrap();
1309 mem.append(c).unwrap();
1310 }
1311 assert_eq!(
1312 journaled.root(),
1313 mem.root(),
1314 "journaled and mem MMRs must produce the same root for the same leaf sequence"
1315 );
1316 }
1317
1318 // ---- Negative-path verification (journaled flow) -------------
1319
1320 /// Mem-flavour-equivalent: appending must change the root. Same
1321 /// property as the deleted `root_changes_on_append` test, lifted
1322 /// onto the journaled flavour.
1323 #[test]
1324 fn journaled_root_changes_on_append() {
1325 let (_tmp, mkit_dir) = fresh_mkit_dir();
1326 let exec = fresh_executor();
1327 let mut h = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1328 let root_before = h.root();
1329 h.append(&synth(0)).unwrap();
1330 let root_after = h.root();
1331 assert_ne!(
1332 root_before, root_after,
1333 "appending a leaf must change the journaled MMR root"
1334 );
1335 }
1336
1337 /// Mem-flavour-equivalent: an honest inclusion proof must not verify
1338 /// against a different commit hash than the one that was appended.
1339 #[test]
1340 fn journaled_wrong_commit_fails_verification() {
1341 let (_tmp, mkit_dir) = fresh_mkit_dir();
1342 let exec = fresh_executor();
1343 let mut h = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1344 let h_a = synth(0);
1345 let h_other = synth(99);
1346 h.append(&h_a).unwrap();
1347 let proof = h.prove(Position(0)).unwrap();
1348 let root = h.root();
1349
1350 assert!(
1351 verify_inclusion(&h_a, Position(0), &proof, &root),
1352 "honest proof must verify with the appended commit"
1353 );
1354 assert!(
1355 !verify_inclusion(&h_other, Position(0), &proof, &root),
1356 "swapping in a different commit hash must fail verification"
1357 );
1358 }
1359
1360 /// Mem-flavour-equivalent: an inclusion proof from one branch's MMR
1361 /// must not verify against another branch's root.
1362 #[test]
1363 fn journaled_wrong_root_fails_verification() {
1364 let (_tmp, mkit_dir) = fresh_mkit_dir();
1365 let exec = fresh_executor();
1366
1367 let h_a = synth(0);
1368 let mut a = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1369 a.append(&h_a).unwrap();
1370 let proof = a.prove(Position(0)).unwrap();
1371 let root_a = a.root();
1372 assert!(
1373 verify_inclusion(&h_a, Position(0), &proof, &root_a),
1374 "sanity: honest proof verifies against its own root"
1375 );
1376
1377 // Independent branch with different leaves — distinct root.
1378 let mut b = CommitHistory::open_at(exec, &mkit_dir, "dev").unwrap();
1379 b.append(&synth(1)).unwrap();
1380 b.append(&synth(2)).unwrap();
1381 let root_b = b.root();
1382 assert_ne!(root_a, root_b, "distinct branches must have distinct roots");
1383
1384 assert!(
1385 !verify_inclusion(&h_a, Position(0), &proof, &root_b),
1386 "proof from one branch must not verify against another branch's root"
1387 );
1388 }
1389
1390 // ---- Crash recovery (commonware-native semantics) ----------
1391
1392 /// Simulate a torn write: truncate one journal blob mid-frame and
1393 /// verify that commonware's `Journaled::init` either rolls forward
1394 /// to the last valid size OR surfaces a recoverable error.
1395 ///
1396 /// SPEC-HISTORY-PROOF §4.4: mkit relies on commonware's native
1397 /// recovery; mkit's own contract is only that reopening a
1398 /// half-written journal does NOT panic and does NOT silently
1399 /// expose stale data.
1400 #[test]
1401 fn truncated_journal_rolls_forward_or_surfaces_corruption() {
1402 let (_tmp, mkit_dir) = fresh_mkit_dir();
1403 let exec = fresh_executor();
1404 let commits: Vec<Hash> = (0..32u64).map(synth).collect();
1405
1406 let len_before = {
1407 let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1408 for c in &commits {
1409 h.append(c).unwrap();
1410 }
1411 h.len()
1412 };
1413 assert_eq!(len_before, 32);
1414
1415 // Find the journal blob directory and chop a few bytes off
1416 // the tail of the largest file. The sanitized partition name
1417 // for branch "main" is "main" → "main__journal".
1418 // commonware lays out the journal partition's blobs in a
1419 // subdirectory named `<partition>-blobs`; the journal metadata
1420 // sidecar sits at `<partition>-metadata`. The truncation
1421 // target is whichever blob holds actual digest data.
1422 let journal_root = mkit_dir.history_dir().join("main__journal-blobs");
1423 assert!(
1424 journal_root.exists(),
1425 "journal blob dir must exist after appends"
1426 );
1427 let mut largest: Option<(std::path::PathBuf, u64)> = None;
1428 for entry in std::fs::read_dir(&journal_root).unwrap() {
1429 let entry = entry.unwrap();
1430 let meta = entry.metadata().unwrap();
1431 if meta.is_file() {
1432 let path = entry.path();
1433 let len = meta.len();
1434 if largest.as_ref().is_none_or(|(_, l)| len > *l) {
1435 largest = Some((path, len));
1436 }
1437 }
1438 }
1439 let (blob_path, blob_len) = largest.expect("at least one blob present after 32 appends");
1440 assert!(
1441 blob_len > 5,
1442 "blob must be large enough to truncate (got {blob_len} bytes)"
1443 );
1444
1445 // Truncate 5 bytes off the end — fewer than a digest (32 B), so
1446 // commonware sees a torn frame at the tail.
1447 let truncated_len = blob_len - 5;
1448 let f = std::fs::OpenOptions::new()
1449 .write(true)
1450 .open(&blob_path)
1451 .unwrap();
1452 f.set_len(truncated_len).unwrap();
1453 drop(f);
1454
1455 // Reopen. mkit's contract: either succeeds (rolled forward to
1456 // last valid size, possibly fewer leaves than before) OR
1457 // surfaces a `Corrupted` error. Crucially: no panic, no
1458 // silent-stale-root.
1459 let reopened = CommitHistory::open_at(exec, &mkit_dir, "main");
1460 match reopened {
1461 Ok(h) => {
1462 assert!(
1463 h.len() <= len_before,
1464 "rolled-forward leaf count must not exceed the pre-truncation count"
1465 );
1466 // The root of the rolled-forward state, if non-empty,
1467 // must equal the root of a freshly-built mem MMR over
1468 // the surviving leaves — proving roll-forward is
1469 // semantically consistent.
1470 if !h.is_empty() {
1471 let n = usize::try_from(h.len()).expect("leaf count fits in usize");
1472 let mut reference = CommitHistory::open();
1473 for c in &commits[..n] {
1474 reference.append(c).unwrap();
1475 }
1476 assert_eq!(
1477 h.root(),
1478 reference.root(),
1479 "rolled-forward root must equal a clean replay of the surviving leaves"
1480 );
1481 }
1482 }
1483 Err(HistoryError::Corrupted(_)) => {
1484 // Acceptable: commonware refused to recover. Surfaced
1485 // to the caller via the documented error variant.
1486 }
1487 Err(other) => panic!("unexpected error on truncated journal: {other:?}"),
1488 }
1489 }
1490
1491 // ---- Rebuild shim --------------------------------------------
1492
1493 #[test]
1494 fn rebuild_from_chain_matches_live_appends() {
1495 let (_tmp, mkit_dir_a) = fresh_mkit_dir();
1496 let (_tmp_b, mkit_dir_b) = fresh_mkit_dir();
1497 let exec = fresh_executor();
1498
1499 // Build the "reference" history by live appends.
1500 let commits: Vec<Hash> = (0..10u64).map(synth).collect();
1501 let reference_root = {
1502 let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir_a, "main").unwrap();
1503 for c in &commits {
1504 h.append(c).unwrap();
1505 }
1506 h.root()
1507 };
1508
1509 // Simulate the v0.1.x situation: refs/heads/main has a tip
1510 // commit, but `<mkit_dir>/history/` is empty. The walker
1511 // returns each commit's parent until root.
1512 let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir_b, "main").unwrap();
1513 let tip = commits[commits.len() - 1];
1514 let count = rebuild_from_chain::<TokioExecutor, _, std::io::Error>(&mut h, tip, |hash| {
1515 // Find this hash in `commits`, return the prior one or
1516 // None if at index 0.
1517 let idx = commits
1518 .iter()
1519 .position(|c| c == hash)
1520 .expect("walker called with unknown hash");
1521 if idx == 0 {
1522 Ok(None)
1523 } else {
1524 Ok(Some(commits[idx - 1]))
1525 }
1526 })
1527 .unwrap();
1528 assert_eq!(count, commits.len() as u64);
1529 assert_eq!(
1530 h.root(),
1531 reference_root,
1532 "rebuilt root must match live-append root"
1533 );
1534 }
1535
1536 /// SPEC-HISTORY-PROOF's backfill cost claim ("single-digit
1537 /// milliseconds for branches up to a few hundred thousand commits")
1538 /// only holds if the backfill fsyncs once for the whole chain, not
1539 /// once per commit. `journal.sync()` is fsync-latency-bound (~1-10ms
1540 /// each), so 500 unbatched syncs alone would blow well past the
1541 /// spec's budget. Instrumentation-based rather than wall-clock-based
1542 /// so it can't flake under CI load.
1543 #[test]
1544 fn rebuild_from_chain_batches_backfill_into_a_single_sync() {
1545 let (_tmp, mkit_dir) = fresh_mkit_dir();
1546 let exec = fresh_executor();
1547 let commits: Vec<Hash> = (0..500u64).map(synth).collect();
1548
1549 let mut h = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1550 reset_sync_call_count();
1551
1552 let tip = commits[commits.len() - 1];
1553 let count = rebuild_from_chain::<TokioExecutor, _, std::io::Error>(&mut h, tip, |hash| {
1554 let idx = commits
1555 .iter()
1556 .position(|c| c == hash)
1557 .expect("walker called with unknown hash");
1558 if idx == 0 {
1559 Ok(None)
1560 } else {
1561 Ok(Some(commits[idx - 1]))
1562 }
1563 })
1564 .unwrap();
1565
1566 assert_eq!(count, 500, "sanity: all 500 commits were backfilled");
1567 assert_eq!(h.len(), 500, "sanity: all 500 leaves landed in the MMR");
1568 assert_eq!(
1569 sync_call_count(),
1570 1,
1571 "backfilling 500 commits must fsync exactly once for the \
1572 whole batch, not once per commit"
1573 );
1574 }
1575
1576 /// A single live `append` (the non-backfill path) must still fsync
1577 /// — batching must not silently drop the per-append durability
1578 /// guarantee for ordinary commit-time writes.
1579 #[test]
1580 fn append_still_syncs_once_per_call() {
1581 let (_tmp, mkit_dir) = fresh_mkit_dir();
1582 let exec = fresh_executor();
1583 let mut h = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1584 reset_sync_call_count();
1585
1586 h.append(&synth(0)).unwrap();
1587 h.append(&synth(1)).unwrap();
1588 h.append(&synth(2)).unwrap();
1589
1590 assert_eq!(
1591 sync_call_count(),
1592 3,
1593 "each live `append` must still fsync on its own — only the \
1594 backfill path batches"
1595 );
1596 }
1597
1598 // ---- Sanitization --------------------------------------------
1599
1600 #[test]
1601 fn sanitize_branch_round_trip_invariants() {
1602 // Different ref-name byte sequences must encode to different
1603 // partition tokens.
1604 assert_ne!(sanitize_branch("feat/v1.0"), sanitize_branch("feat_v1_0"));
1605 // Plain alpha-numeric / dash names pass through unchanged.
1606 assert_eq!(sanitize_branch("main"), "main");
1607 assert_eq!(sanitize_branch("release-2026"), "release-2026");
1608 // `/` escapes to `_2f`.
1609 assert_eq!(sanitize_branch("a/b"), "a_2fb");
1610 // `.` escapes to `_2e`.
1611 assert_eq!(sanitize_branch("v1.0"), "v1_2e0");
1612 // `_` itself escapes to `_5f` to keep the encoding injective.
1613 assert_eq!(sanitize_branch("a_b"), "a_5fb");
1614 }
1615}