Skip to main content

decern_ledger/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2026 Anivar Aravind
3#![forbid(unsafe_code)]
4//! decern-ledger — the tamper-evident decision ledger (the audit column).
5//!
6//! Every authority decision is appended as a hash-chained, Ed25519-signed
7//! record: `hash = SHA-256(entry_bytes ‖ prev_hash)`, signature over `hash`.
8//! Any edit, reorder or in-place deletion breaks the chain; a wholesale
9//! rewrite fails signature verification against the ledger key. What the
10//! chain alone cannot detect is truncation of the *tail* — that is what
11//! `root()` is for: export the head hash and anchor it externally (a
12//! regulator, a notary, another system). Anchored root + intact chain =
13//! complete, unmodified history.
14//!
15//! The chain hash covers the EXACT entry bytes as stored on disk (captured
16//! via serde_json's RawValue at verify time), never a re-serialization — so
17//! byte-stability is structural, not an assumption about JSON round-trips.
18//! (Float round-tripping is NOT stable in serde_json without the
19//! `float_roundtrip` feature; hashing re-serialized bytes was a confirmed
20//! false-tamper bug that could brick an honest ledger.)
21
22use std::fs::{self, File, OpenOptions};
23use std::io::Write;
24use std::path::{Path, PathBuf};
25
26use base64::Engine;
27use base64::engine::general_purpose::STANDARD as B64;
28use decern_crypto::{Signer, SigningKey, VerifyingKey};
29use std::collections::BTreeMap;
30
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33
34pub mod jcs;
35pub mod merkle;
36mod segment;
37pub mod sharded;
38pub use jcs::{canonicalize, digest};
39pub use segment::RolloverPolicy;
40pub use sharded::{ShardVerification, ShardedLedger, UNATTRIBUTED_SHARD, verify_sharded_dir};
41
42/// Where a [`Ledger`]'s bytes live: a single append-only file (the default)
43/// or a segmented directory (opt-in, see
44/// [`Ledger::open_segmented`]). `detect` is used ONLY by the path-taking free
45/// functions (`verify`, `verify_with_keys`, `read_verified`,
46/// `ledger_extends_checkpoint`) so they stay
47/// segmentation-transparent for external callers with zero signature changes;
48/// a `Ledger`'s own methods already know their kind from how they were
49/// opened and never need to re-detect it.
50enum Location {
51    Single(PathBuf),
52    Segmented(PathBuf),
53}
54
55impl Location {
56    fn detect(path: &Path) -> Self {
57        if path.is_dir() {
58            Location::Segmented(path.to_path_buf())
59        } else {
60            Location::Single(path.to_path_buf())
61        }
62    }
63
64    /// Every file this location's bytes live in, in seq order. `Single` is
65    /// always exactly one path (even if it doesn't exist yet — the same
66    /// "doesn't exist" a plain `File::open` would report); `Segmented` reads
67    /// the manifest and fails closed if a listed segment is missing from
68    /// disk.
69    fn resolved_paths(&self) -> Result<Vec<PathBuf>, LedgerError> {
70        match self {
71            Location::Single(p) => Ok(vec![p.clone()]),
72            Location::Segmented(dir) => segment::segment_paths(dir),
73        }
74    }
75
76    fn lines(&self) -> Result<segment::ChainedLines, LedgerError> {
77        Ok(segment::chained_lines(self.resolved_paths()?))
78    }
79
80    /// The lines of this location's PREFIX — every fully `\n`-terminated line —
81    /// with any crash-torn trailing fragment (bytes after the final newline of
82    /// the last file) excluded. Returns the iterator plus `Some(TornFragment)` when
83    /// such a fragment exists, or `None` when the last file ends cleanly (the
84    /// normal case). Verification and root re-derivation both consume THIS, so a
85    /// torn fragment is never mistaken for a corrupt record.
86    fn prefix_lines(&self) -> Result<(segment::ChainedLines, Option<TornFragment>), LedgerError> {
87        let paths = self.resolved_paths()?;
88        let torn = match paths.last() {
89            Some(last) => scan_torn_tail(last)?,
90            None => None,
91        };
92        let limit = torn.as_ref().map(|t| t.offset).unwrap_or(u64::MAX);
93        Ok((segment::chained_lines_bounded(paths, limit), torn))
94    }
95}
96
97/// A crash-torn trailing fragment discovered on the last file: the file does
98/// not end in `\n`, so everything from `offset` (the byte just past the final
99/// newline, or 0 if the file has no newline at all) to EOF was only partially
100/// written and must be discarded to recover the verified prefix.
101struct TornFragment {
102    path: PathBuf,
103    offset: u64,
104}
105
106/// Inspect `path`'s LAST byte. Returns `None` (no torn tail) when the file is
107/// missing, empty, or already ends in `\n`. Otherwise the file's final record
108/// was not newline-terminated → returns the offset just past its last `\n` (0
109/// if none), i.e. where the log must be truncated to drop the torn fragment.
110/// Keyed purely on newline-termination: given `append` writes `line + "\n"` in a
111/// single `write_all`, a present terminator proves the whole record reached the
112/// file, and its absence proves a partial write — so a terminated line is never
113/// a torn tail (a terminated-but-corrupt final record stays `Tamper`), and an
114/// unterminated one always is (even if its bytes happen to parse — it was never
115/// acked, and keeping it would fuse onto the next append on one physical line).
116fn scan_torn_tail(path: &Path) -> Result<Option<TornFragment>, LedgerError> {
117    use std::io::{Read, Seek, SeekFrom};
118    let mut f = match File::open(path) {
119        Ok(f) => f,
120        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
121        Err(e) => return Err(io_err(path, e)),
122    };
123    let len = f.metadata().map_err(|e| io_err(path, e))?.len();
124    if len == 0 {
125        return Ok(None);
126    }
127    // Cheap last-byte check first: a clean, newline-terminated file (the common
128    // case) costs one seek + one byte read and exits here.
129    f.seek(SeekFrom::End(-1)).map_err(|e| io_err(path, e))?;
130    let mut last = [0u8; 1];
131    f.read_exact(&mut last).map_err(|e| io_err(path, e))?;
132    if last[0] == b'\n' {
133        return Ok(None);
134    }
135    // Unterminated: scan backward in chunks for the final newline. The torn
136    // fragment is a single partially-written record, but records can be large,
137    // so loop rather than assume it fits one chunk.
138    const CHUNK: u64 = 64 * 1024;
139    let mut pos = len;
140    let mut buf = vec![0u8; CHUNK as usize];
141    while pos > 0 {
142        let read_len = CHUNK.min(pos);
143        let start = pos - read_len;
144        f.seek(SeekFrom::Start(start))
145            .map_err(|e| io_err(path, e))?;
146        let slice = &mut buf[..read_len as usize];
147        f.read_exact(slice).map_err(|e| io_err(path, e))?;
148        if let Some(idx) = slice.iter().rposition(|&b| b == b'\n') {
149            // Byte just past this newline, in absolute file coordinates.
150            return Ok(Some(TornFragment {
151                path: path.to_path_buf(),
152                offset: start + idx as u64 + 1,
153            }));
154        }
155        pos = start;
156    }
157    // No newline anywhere: the whole file is one torn (never-acked) record.
158    Ok(Some(TornFragment {
159        path: path.to_path_buf(),
160        offset: 0,
161    }))
162}
163
164pub const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
165
166/// One record in the ledger: a decision — "what happened, with everything needed
167/// to replay it". Several fields below are reserved and inert (see
168/// each field's note): no shipped path sets them, they are retained only for
169/// struct/type stability, and a plain decision leaves them at their defaults, which
170/// serialize to no bytes — so every existing writer and stored line is unchanged.
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct Entry {
173    pub seq: u64,
174    pub ts_ms: u64,
175    pub subject_type: String,
176    pub subject_id: String,
177    pub action: String,
178    pub resource_type: String,
179    pub resource_id: String,
180    pub context: serde_json::Value,
181    pub decision: bool,
182    pub reasons: Vec<String>,
183    /// RFC 8785 SHA-256 digest of the parameters a decision was made over — binds a
184    /// record to the EXACT arguments, closing the TOCTOU gap between "authorized"
185    /// and "executed". Set by `decern-serve` on decide / mission transitions.
186
187    /// The authority-graph edge type: `Attenuate` (default, omitted) = offline
188    /// narrowing WITHIN the delegator's namespace (a decern tenant); `Mint` = a
189    /// trusted-issuer crossing that no offline delegate can produce. Reserved and
190    /// inert: never set by any shipped path, defaulted and
191    /// skipped-when-default, so existing records' bytes and hashes are unchanged.
192    #[serde(default, skip_serializing_if = "edge_is_attenuate")]
193    pub edge: EdgeType,
194    /// The accountable-owner — who stands behind `subject_id` existing and
195    /// acting AT ALL. Resolved server-side from the directory's delegation chain —
196    /// never a decision input (stripped before the kernel) and safe to store in the
197    /// clear: it names a principal already visible elsewhere in the same tenant's
198    /// directory, not third-party PII — EXCEPT for a self-sponsored root principal,
199    /// where this equals `subject_id` verbatim. `None` on every record before this
200    /// field existed, and on any subject the directory doesn't recognize (e.g. a
201    /// global/static-token caller) — existing bytes and hashes are unchanged.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub sponsor: Option<Party>,
204    /// Whether `sponsor` above was computed (`Derived`, the default — the pure
205    /// root of the delegation chain) or set by an admin override, constrained
206    /// to that same chain. Lets an auditor tell asserted from computed without
207    /// re-deriving it. Default + skipped-when-default, so existing records'
208    /// bytes and hashes are unchanged.
209    #[serde(default, skip_serializing_if = "is_derived_sponsor")]
210    pub sponsor_source: SponsorSource,
211    /// The Mission that justified this decision, when decide ran under a live
212    /// approval. `None` when no mission was bound (or on pre-mission records).
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub mission: Option<MissionRef>,
215    /// The party the decision is *about* — the one it is taken upon, distinct
216    /// from the acting `subject_id` and from the accountable `sponsor`.
217    /// Descriptive, never an authorization input. Present only when that party
218    /// is a third party: a decision about the requester, or about the owner of
219    /// the resource named, carries none, because the record already says so.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub decision_subject: Option<DecisionSubject>,
222    /// Whether this decision is one an affected party should be told about. Recorded, not
223    /// acted on: telling them is the job of whoever enforces the decision, and this server
224    /// does not enforce. Recording it is what makes a notice that never went out a gap
225    /// someone can point at rather than a thing nobody can prove either way.
226    #[serde(default, skip_serializing_if = "is_false")]
227    pub notice_required: bool,
228    /// A challenge from the party this decision was about, and how it was answered.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub challenge: Option<ChallengeRecord>,
231    /// Digests of the things this record was bound to, by name.
232    ///
233    /// [`DIGEST_PARAMETERS`] binds the arguments a decision authorized. This binds
234    /// everything else worth pinning, without a new column each time something is: a
235    /// consumer of this crate records what its own decisions depend on under names it
236    /// chooses, and a reader who does not know a name can still see that something was
237    /// pinned and that it does not match.
238    ///
239    /// `decern-serve` writes [`DIGEST_AUTHORITY`]. The chain already proves a record was
240    /// not altered afterwards; it says nothing about what the record was decided
241    /// *against*, and that moves. Revoke a delegation tomorrow and an allow recorded today
242    /// still reads as an allow, with nothing to say what was true when — the trail is
243    /// immutable while the thing it refers to is not. A digest of the authority state
244    /// makes the decision addressable: a later reading can tell whether the authority it
245    /// was taken against is still the same one.
246    ///
247    /// Ordered, so the serialization is deterministic — this is inside the bytes the chain
248    /// hashes, and a map that serialized in a different order each time would break it.
249    /// Values are digests, not content: whatever is being pinned may be large, may be
250    /// about a person, and cannot be taken back out of an append-only log.
251    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
252    pub digests: BTreeMap<String, String>,
253}
254
255/// The exact arguments a decision authorized — what it was asked, not what it knew.
256/// Binding them means a later reading can tell that the thing authorized is the thing
257/// that was requested, rather than something substituted after the check.
258pub const DIGEST_PARAMETERS: &str = "parameters";
259
260/// The authority a decision was taken against — policy, schema and entity graph.
261pub const DIGEST_AUTHORITY: &str = "authority";
262
263fn is_false(b: &bool) -> bool {
264    !*b
265}
266
267/// A challenge and its answer, on the record.
268///
269/// Written here rather than kept beside the log because a challenge nobody can find later
270/// is the same as one that was never made — and because the point of answering is that the
271/// answer, and its reason, are as durable as the decision they concern.
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct ChallengeRecord {
274    /// The decision that was challenged.
275    pub decision_ref: String,
276    /// The handle the challenger proved standing as.
277    pub decision_subject: String,
278    /// The grounds given.
279    pub basis: Vec<String>,
280    /// What the challenger asked for, which is not what they necessarily got.
281    pub requested_effect: String,
282    /// What was done: the decision stood, or it was made again with the challenge in view.
283    pub outcome: String,
284    /// Why. An answer without a reason is a dismissal.
285    pub outcome_basis: String,
286    /// A digest of the evidence submitted, when any was — not the evidence itself.
287    /// Whatever a party sends to argue their case is likely to be about them, and this
288    /// log is append-only and signed: what lands here cannot be taken back. The digest
289    /// is enough to show later that what was weighed is what was sent.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub evidence_digest: Option<String>,
292}
293
294/// The party a decision is about, as a pseudonymous reference.
295///
296/// A handle, not an identity: it addresses a party without naming one, so a
297/// record can say who a decision concerned without becoming a place personal
298/// data accumulates. Resolving it back to a person is a separate authority's
299/// job, and deliberately not this one's.
300///
301/// Its integrity comes from the record that carries it — every entry here is
302/// signed and chained — so a handle read out of a verified record is as
303/// trustworthy as the record, and one read anywhere else is not trustworthy at
304/// all.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306pub struct DecisionSubject {
307    /// The pseudonymous reference itself.
308    pub handle: String,
309    /// The namespace the handle belongs to, and so how it could be resolved.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub scheme: Option<String>,
312    /// What the handle was minted for. Pairwise per purpose, so the same party
313    /// is not linkable across two of them.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub purpose: Option<String>,
316}
317
318impl<'de> Deserialize<'de> for DecisionSubject {
319    /// Accepts a bare handle or the full object, since a caller with nothing to
320    /// say about scheme or purpose should not have to write an object to say it.
321    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
322        #[derive(Deserialize)]
323        #[serde(untagged)]
324        enum Wire {
325            Handle(String),
326            Full {
327                handle: String,
328                #[serde(default)]
329                scheme: Option<String>,
330                #[serde(default)]
331                purpose: Option<String>,
332            },
333        }
334        Ok(match Wire::deserialize(d)? {
335            Wire::Handle(handle) => DecisionSubject {
336                handle,
337                scheme: None,
338                purpose: None,
339            },
340            Wire::Full {
341                handle,
342                scheme,
343                purpose,
344            } => DecisionSubject {
345                handle,
346                scheme,
347                purpose,
348            },
349        })
350    }
351}
352
353/// A Mission reference recorded on a decision Entry.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct MissionRef {
356    pub approver: String,
357    pub s256: String,
358}
359
360/// A party referenced by a record — the accountable owner named by `sponsor`,
361/// or the `decision_subject`. The acting subject is `subject_id` on the entry.
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct Party {
364    pub kind: String,
365    pub id: String,
366}
367
368/// How an authority-graph edge came to be — the attenuate-vs-mint distinction.
369/// Typing every issuance edge lets the record tell offline narrowing apart from a
370/// trusted-issuer crossing; the two carry different safety properties (only the
371/// former is safe to delegate offline). Only `Attenuate` is used;
372/// `Mint` is reserved and inert, retained so the enum stays stable.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
374pub enum EdgeType {
375    /// Narrowing WITHIN a subject namespace (a decern tenant): child scopes ⊆ delegator,
376    /// same tenant. Offline-delegable — an intermediate delegate produces it with no
377    /// issuer. The default and overwhelmingly common edge.
378    #[default]
379    Attenuate,
380    /// CROSSING a subject namespace: a fresh trusted-issuer binding (an external
381    /// token verified against its JWKS, or a redeemed cross-app assertion). NEVER
382    /// offline-delegable — "you cannot narrow your way into a subject you were never
383    /// given." Reserved and inert: no shipped path emits it.
384    Mint,
385}
386
387fn edge_is_attenuate(e: &EdgeType) -> bool {
388    matches!(e, EdgeType::Attenuate)
389}
390
391/// How `Entry::sponsor` was determined (see [`Entry::sponsor`]).
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
393pub enum SponsorSource {
394    /// The pure root of `subject_id`'s delegation chain — no admin override.
395    #[default]
396    Derived,
397    /// An admin explicitly set this sponsor, constrained at write time to
398    /// `subject_id`'s own delegation chain (never a genuine outsider).
399    Explicit,
400}
401
402fn is_derived_sponsor(s: &SponsorSource) -> bool {
403    matches!(s, SponsorSource::Derived)
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct Record {
408    pub entry: Entry,
409    pub prev: String,
410    pub hash: String,
411    pub sig_b64: String,
412    /// The fingerprint (hex Ed25519 public key) of the key that signed this record —
413    /// so a KEY-ROTATED log stays verifiable: each record names which key to check
414    /// it against, and a keyring verify picks that key. `None` on legacy records
415    /// written before rotation support (they are all signed by the ledger's original
416    /// key). Envelope-only — NOT part of the hashed entry, so adding it left every
417    /// existing record's hash and signature unchanged.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub kid: Option<String>,
420}
421
422/// Write-side record: `entry` is pre-serialized text, inlined verbatim, so
423/// the bytes we hash are exactly the bytes that land on disk.
424#[derive(Serialize)]
425struct RecordOut<'a> {
426    entry: &'a serde_json::value::RawValue,
427    prev: &'a str,
428    hash: &'a str,
429    sig_b64: &'a str,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    kid: Option<&'a str>,
432}
433
434/// Read-side record: `entry` captures the exact byte span from the line, so
435/// verification hashes what is actually stored, not a re-serialization.
436#[derive(Deserialize)]
437struct RecordIn {
438    entry: Box<serde_json::value::RawValue>,
439    prev: String,
440    hash: String,
441    sig_b64: String,
442    #[serde(default)]
443    kid: Option<String>,
444}
445
446#[derive(Debug, thiserror::Error)]
447#[non_exhaustive]
448pub enum LedgerError {
449    #[error("ledger I/O error at {path}: {err}")]
450    Io { path: String, err: String },
451    #[error("ledger serialization error: {0}")]
452    Serde(String),
453    #[error("TAMPER at seq {seq}: {why}")]
454    Tamper { seq: u64, why: String },
455    /// The log's FINAL physical line is structurally incomplete — the file does
456    /// not end in a newline, so the trailing record was only partially written
457    /// (a crash between/mid the single `append` write and its `flush`/`sync`).
458    /// DISTINCT from [`Tamper`](LedgerError::Tamper) on purpose: a benign
459    /// crash-during-append must never be reported as an attack. The verified
460    /// PREFIX — every fully-terminated, chain-valid, signature-valid record —
461    /// is intact and is the ledger; `healed_entries` is its length and
462    /// `healed_root` its head hash. The open path HEALS this (truncates the torn
463    /// fragment at `torn_from_offset` in `torn_path`) after first confirming the
464    /// prefix still extends any persisted anchor — because a crash can only ever
465    /// drop an un-acked tail, whereas a shorter-than-anchor prefix means acked
466    /// history was deleted and stays [`Tamper`](LedgerError::Tamper). The
467    /// read-only `verify*` entry points surface this variant rather than healing.
468    #[error(
469        "TORN TAIL: unterminated trailing record (crash mid-append); \
470         {healed_entries} verified records intact before it"
471    )]
472    TornTail {
473        healed_entries: u64,
474        healed_root: Option<String>,
475        torn_path: String,
476        torn_from_offset: u64,
477    },
478}
479
480/// A record's signature is over this 32-byte hash and nothing else. That is what keeps it
481/// out of [`commitment_bytes`]'s space without a tag of its own: every commitment is a
482/// tagged string far longer than 32 bytes, so no record signature can be replayed as one.
483/// Anything else signed by a ledger key must keep that property or take a tag.
484fn chain_hash(entry_bytes: &[u8], prev_hex: &str) -> [u8; 32] {
485    let mut h = Sha256::new();
486    h.update(entry_bytes);
487    h.update(prev_hex.as_bytes());
488    h.finalize().into()
489}
490
491fn io_err(path: &Path, e: impl std::fmt::Display) -> LedgerError {
492    LedgerError::Io {
493        path: path.display().to_string(),
494        err: e.to_string(),
495    }
496}
497
498/// Append-only writer. Opening an existing ledger verifies the whole chain
499/// (and, with this key, every signature) before accepting new entries —
500/// fail-closed: a corrupt audit trail refuses further writes.
501pub struct Ledger {
502    location: Location,
503    /// The path `file` is currently open on: the single file's path in
504    /// unsegmented mode, or the active segment's path in segmented mode. Used
505    /// only for error messages on the write path — reads always go through
506    /// `location`, never this field.
507    active_path: PathBuf,
508    key: SigningKey,
509    file: File,
510    last_hash: String,
511    next_seq: u64,
512    /// The full set of keys trusted to have signed this log: every RETIRED key plus
513    /// the CURRENT signing key. A key-rotated log has entries under more than one
514    /// key; verification (open, [`self_verify`](Ledger::self_verify)) checks each
515    /// record against the key its `kid` names, drawn from this ring — so rotation
516    /// never bricks a long-lived log.
517    verifiers: Vec<VerifyingKey>,
518    /// When true, every append `sync_data()`s to disk before returning — crash-DURABLE
519    /// (a "complete" log cannot lose its tail on power loss), at a per-append fsync cost.
520    /// Off by default (crash-consistent, fast); opt in via [`Ledger::set_sync`] for a
521    /// regulated deployment that must not lose a recorded decision.
522    sync: bool,
523    /// `Some` only for a segmented ledger (opened via
524    /// [`Ledger::open_segmented`]) — the rollover trigger policy plus the
525    /// in-memory manifest state `append` consults/updates. `None` for every
526    /// single-file ledger, the overwhelming majority, which never rolls over.
527    rollover: Option<RolloverState>,
528}
529
530struct RolloverState {
531    policy: RolloverPolicy,
532    manifest: segment::Manifest,
533}
534
535/// Resolve the head `(last_hash, next_seq)` an open should start appending from,
536/// healing a crash-torn tail iff it does not erase acked history.
537///
538/// This is the code that implements the task's core distinction: the anchor is
539/// the line between "a crash dropped an un-acked tail" (heal) and "someone
540/// deleted acked records" (tamper). On [`LedgerError::TornTail`] the persisted
541/// anchor is consulted BEFORE the file is mutated — if the verified prefix no
542/// longer extends the last committed height, the torn tail is really a ragged
543/// truncation of acked history and stays [`LedgerError::Tamper`], with the file
544/// left untouched. Only once the prefix is proven to still cover the anchor is
545/// the torn fragment physically discarded.
546fn resolve_open_head(
547    location: &Location,
548    verifiers: &[VerifyingKey],
549    anchor: Option<&Path>,
550) -> Result<(String, u64), LedgerError> {
551    match verify_inner(location, verifiers, None) {
552        Ok(report) => Ok((
553            report.root.unwrap_or_else(|| GENESIS.to_owned()),
554            report.entries,
555        )),
556        Err(LedgerError::TornTail {
557            healed_entries,
558            healed_root,
559            torn_path,
560            torn_from_offset,
561        }) => {
562            // Consult the anchor BEFORE any mutation.
563            if let Some(anchor_path) = anchor
564                && let Some(cp) = load_anchor(anchor_path)?
565            {
566                if !verifiers.iter().any(|k| verify_checkpoint_sig(&cp, k)) {
567                    return Err(LedgerError::Tamper {
568                        seq: cp.count,
569                        why: "anchor signature is not from a trusted ledger key \
570                                  (forged or wrong-key anchor)"
571                            .into(),
572                    });
573                }
574                // `ledger_extends_checkpoint_at` re-derives the head over the
575                // PREFIX only (torn fragment excluded), so a prefix shorter
576                // than the committed height reports `false` here.
577                if !ledger_extends_checkpoint_at(location, &cp)? {
578                    return Err(LedgerError::Tamper {
579                        seq: cp.count,
580                        why: format!(
581                            "ledger no longer extends its anchor at count {} — the trailing \
582                                 record is unterminated AND the verified prefix is below the last \
583                                 committed height (acked history truncated, not a crash tail)",
584                            cp.count
585                        ),
586                    });
587                }
588            }
589            // Prefix covers the anchor (or there is none): the torn fragment was
590            // never acked. Discard it durably, then adopt the healed head.
591            heal_torn_tail(Path::new(&torn_path), torn_from_offset)?;
592            Ok((
593                healed_root.unwrap_or_else(|| GENESIS.to_owned()),
594                healed_entries,
595            ))
596        }
597        Err(e) => Err(e),
598    }
599}
600
601/// Physically discard a crash-torn trailing fragment by truncating `path` to
602/// `offset` (the byte just past the log's final newline). fsync'd so the
603/// recovery is itself durable — a second crash cannot resurrect the fragment.
604/// Called only after [`resolve_open_head`] has proven the prefix still covers
605/// any anchor, so this never deletes acked history.
606fn heal_torn_tail(path: &Path, offset: u64) -> Result<(), LedgerError> {
607    let f = OpenOptions::new()
608        .write(true)
609        .open(path)
610        .map_err(|e| io_err(path, e))?;
611    f.set_len(offset).map_err(|e| io_err(path, e))?;
612    f.sync_all().map_err(|e| io_err(path, e))?;
613    Ok(())
614}
615
616impl Ledger {
617    /// Open a single-key ledger (the common case). Equivalent to
618    /// [`open_with_verifiers`](Ledger::open_with_verifiers) with no retired keys.
619    pub fn open(path: &Path, key: SigningKey) -> Result<Self, LedgerError> {
620        Self::open_with_verifiers(path, key, Vec::new())
621    }
622
623    /// Open a ledger that may have been KEY-ROTATED: `key` is the current signing
624    /// key, `retired` the public keys of every previously-active signing key. The
625    /// existing chain is verified against the whole keyring (each record by the key
626    /// its `kid` names; legacy kid-less records against any trusted key), so a
627    /// rotated log reopens cleanly. Fail-closed: a record signed by a key NOT in the
628    /// ring is tamper.
629    pub fn open_with_verifiers(
630        path: &Path,
631        key: SigningKey,
632        retired: Vec<VerifyingKey>,
633    ) -> Result<Self, LedgerError> {
634        Self::open_single_inner(path, key, retired, None)
635    }
636
637    /// Shared single-file open. `anchor` is passed through to
638    /// [`resolve_open_head`] so that, when a torn tail is found, the committed
639    /// height is consulted BEFORE the torn fragment is truncated — a ragged
640    /// truncation of acked history is rejected as tamper without ever mutating
641    /// the file.
642    fn open_single_inner(
643        path: &Path,
644        key: SigningKey,
645        retired: Vec<VerifyingKey>,
646        anchor: Option<&Path>,
647    ) -> Result<Self, LedgerError> {
648        if path.is_dir() {
649            return Err(LedgerError::Io {
650                path: path.display().to_string(),
651                err: "this path is a segmented ledger directory — use Ledger::open_segmented \
652                      instead of open/open_with_verifiers"
653                    .into(),
654            });
655        }
656        let mut verifiers = retired;
657        let current = key.verifying_key();
658        if !verifiers.iter().any(|v| v.to_bytes() == current.to_bytes()) {
659            verifiers.push(current);
660        }
661        let location = Location::Single(path.to_owned());
662        let (last_hash, next_seq) = if path.exists() {
663            resolve_open_head(&location, &verifiers, anchor)?
664        } else {
665            (GENESIS.to_owned(), 0)
666        };
667        let file = OpenOptions::new()
668            .create(true)
669            .append(true)
670            .open(path)
671            .map_err(|e| io_err(path, e))?;
672        Ok(Ledger {
673            location,
674            active_path: path.to_owned(),
675            key,
676            file,
677            last_hash,
678            next_seq,
679            verifiers,
680            sync: false,
681            rollover: None,
682        })
683    }
684
685    /// Open a ledger AND fail-closed check it against its persisted anchor in one
686    /// step — the constructor a server's startup uses. Equivalent to
687    /// [`open_with_verifiers`](Ledger::open_with_verifiers) followed by
688    /// [`verify_against_anchor`](Ledger::verify_against_anchor): the log must be
689    /// internally consistent under the keyring AND still extend its last committed
690    /// height, so a truncation across a restart refuses the open rather than silently
691    /// serving a shortened audit trail.
692    pub fn open_anchored(
693        path: &Path,
694        key: SigningKey,
695        retired: Vec<VerifyingKey>,
696        anchor_path: &Path,
697    ) -> Result<Self, LedgerError> {
698        // Pass the anchor INTO the open so a torn tail is classified against the
699        // committed height before any heal; the post-open check then also covers
700        // the non-torn truncation (records dropped at a clean line boundary, file
701        // still newline-terminated, so no torn tail was raised).
702        let ledger = Self::open_single_inner(path, key, retired, Some(anchor_path))?;
703        ledger.verify_against_anchor(anchor_path)?;
704        Ok(ledger)
705    }
706
707    /// Open (or create) a SEGMENTED ledger at `dir` — the opt-in alternative
708    /// to the single ever-growing file, for a long-lived sovereign deployment.
709    /// `dir` becomes a directory of numbered segment files plus a
710    /// `manifest.json`; `policy` controls when `append` rolls the active
711    /// segment over to a new one. An existing single-file ledger is never
712    /// silently upgraded — this only ever creates or reopens a directory, and
713    /// [`open_with_verifiers`](Ledger::open_with_verifiers) refuses to open a
714    /// directory in the other direction, so the two modes can't be confused
715    /// for each other by accident.
716    ///
717    /// Reopen is fail-closed exactly like the single-file constructors: the
718    /// whole chain (every segment, in order) is re-verified against the
719    /// keyring before any further append is accepted. Every SEALED segment is
720    /// (re-)marked read-only (0444 on Unix) on open — self-healing after a
721    /// crash that landed between committing the manifest and applying that
722    /// permission, since the permission bit is defense-in-depth only, never
723    /// the source of truth (see the `segment` module).
724    pub fn open_segmented(
725        dir: &Path,
726        key: SigningKey,
727        retired: Vec<VerifyingKey>,
728        policy: RolloverPolicy,
729    ) -> Result<Self, LedgerError> {
730        Self::open_segmented_inner(dir, key, retired, policy, None)
731    }
732
733    /// Shared segmented open — the multi-file analogue of
734    /// [`open_single_inner`](Ledger::open_single_inner). A torn tail can only
735    /// ever be in the ACTIVE (last) segment, since every sealed segment ended on
736    /// a committed boundary; `anchor` gates the heal against the committed height
737    /// exactly as in the single-file case.
738    fn open_segmented_inner(
739        dir: &Path,
740        key: SigningKey,
741        retired: Vec<VerifyingKey>,
742        policy: RolloverPolicy,
743        anchor: Option<&Path>,
744    ) -> Result<Self, LedgerError> {
745        let mut verifiers = retired;
746        let current = key.verifying_key();
747        if !verifiers.iter().any(|v| v.to_bytes() == current.to_bytes()) {
748            verifiers.push(current);
749        }
750        fs::create_dir_all(dir).map_err(|e| io_err(dir, e))?;
751        let manifest = match segment::load_manifest(dir)? {
752            Some(m) => m,
753            None => segment::initialize(dir)?,
754        };
755        let location = Location::Segmented(dir.to_owned());
756
757        // Reassert permissions BEFORE verify/heal: seal every sealed segment,
758        // and — crucially — UNSEAL the active segment first, so that if a torn
759        // tail lands in it, `resolve_open_head`'s `heal_torn_tail` (which opens
760        // the file `write(true)`) can truncate the fragment even when a prior
761        // crash left the active segment defensively 0444. Healing a torn tail is
762        // a write; it must never be blocked by the very permission bit the open
763        // path exists to self-heal.
764        for seg in manifest.segments.iter().filter(|s| s.end_seq.is_some()) {
765            segment::seal_file_permissions(&dir.join(&seg.file));
766        }
767        let active = manifest
768            .active()
769            .ok_or_else(|| LedgerError::Io {
770                path: dir.display().to_string(),
771                err: "segmented ledger manifest has no active (unsealed) segment".into(),
772            })?
773            .clone();
774        let active_path = dir.join(&active.file);
775        segment::unseal_file_permissions(&active_path);
776
777        let (last_hash, next_seq) = resolve_open_head(&location, &verifiers, anchor)?;
778
779        let file = OpenOptions::new()
780            .create(true)
781            .append(true)
782            .open(&active_path)
783            .map_err(|e| io_err(&active_path, e))?;
784        Ok(Ledger {
785            location,
786            active_path,
787            key,
788            file,
789            last_hash,
790            next_seq,
791            verifiers,
792            sync: false,
793            rollover: Some(RolloverState { policy, manifest }),
794        })
795    }
796
797    /// [`open_segmented`](Ledger::open_segmented) plus the same fail-closed
798    /// anchor check [`open_anchored`](Ledger::open_anchored) applies — a
799    /// segmented ledger's committed height is anchored exactly the same way
800    /// as a single-file one's (root+count don't care how many files the bytes
801    /// span).
802    pub fn open_segmented_anchored(
803        dir: &Path,
804        key: SigningKey,
805        retired: Vec<VerifyingKey>,
806        policy: RolloverPolicy,
807        anchor_path: &Path,
808    ) -> Result<Self, LedgerError> {
809        let ledger = Self::open_segmented_inner(dir, key, retired, policy, Some(anchor_path))?;
810        ledger.verify_against_anchor(anchor_path)?;
811        Ok(ledger)
812    }
813
814    /// Rotate the signing key. Entries already written stay verifiable under the
815    /// retired key (kept in the keyring); every subsequent entry is signed by
816    /// `new_key` and carries its `kid`. The chain is uninterrupted — no re-signing of
817    /// the past, and no republish. On the next restart, pass the retired public key
818    /// to [`open_with_verifiers`](Ledger::open_with_verifiers) so the whole log still
819    /// verifies.
820    pub fn rotate(&mut self, new_key: SigningKey) {
821        let current = new_key.verifying_key();
822        if !self
823            .verifiers
824            .iter()
825            .any(|v| v.to_bytes() == current.to_bytes())
826        {
827            self.verifiers.push(current);
828        }
829        self.key = new_key;
830    }
831
832    /// The public keys of every key trusted to have signed this log (retired +
833    /// current), as hex fingerprints — what an auditor pins across a rotation.
834    pub fn verifier_fingerprints(&self) -> Vec<String> {
835        self.verifiers.iter().map(key_fingerprint).collect()
836    }
837
838    /// Enable/disable fsync-per-append (see the `sync` field). Returns self for
839    /// builder-style config right after `open`.
840    pub fn set_sync(&mut self, sync: bool) -> &mut Self {
841        self.sync = sync;
842        self
843    }
844
845    /// Append `entry` to the log. `entry.seq` is assigned here; the serialized
846    /// bytes are hashed into the chain, signed, and written verbatim, so an
847    /// external verifier recomputes the exact same hash from what is on disk.
848    pub fn append(&mut self, mut entry: Entry) -> Result<Record, LedgerError> {
849        if let Some(state) = &self.rollover {
850            let current_bytes = self.file.metadata().map(|m| m.len()).unwrap_or(0);
851            if Self::should_roll_over(state, self.next_seq, current_bytes, entry.ts_ms) {
852                self.roll_over(entry.ts_ms)?;
853            }
854        }
855        // If this append is the active segment's first-ever record, rebase its
856        // `opened_ms` to this entry's real `ts_ms`. Every segment `roll_over`
857        // creates is already seeded correctly (its `opened_ms` IS the
858        // triggering entry's `ts_ms`, so this is a no-op there); the one case
859        // that needs it is segment 1 of a fresh ledger, whose `opened_ms`
860        // `segment::initialize` hardcodes to 0 before any real entry exists to
861        // read a timestamp from. Left unrebased, EVERY append after the first
862        // would compare a real ts_ms against that stale 0 — landing in a
863        // different epoch bucket almost every time and rolling over one record
864        // after the first no matter how close in time the two really are.
865        let this_seq = self.next_seq;
866        if let Some(state) = &self.rollover {
867            let needs_rebase = state
868                .manifest
869                .active()
870                .is_some_and(|s| s.start_seq == this_seq && s.opened_ms != entry.ts_ms);
871            if needs_rebase {
872                // Mutate a CLONE and only commit it to `self.rollover.manifest`
873                // after the persist below succeeds (mirroring `roll_over`'s own
874                // clone-then-commit-on-success discipline a few lines down) — a
875                // transient `save_manifest` failure (disk full, permission
876                // blip) must not leave the in-memory manifest disagreeing with
877                // what's actually on disk, which would otherwise let a
878                // same-timestamp retry see `opened_ms` already "correct" in
879                // memory and silently skip persisting it forever.
880                let mut rebased = state.manifest.clone();
881                if let Some(active) = rebased.active_mut() {
882                    active.opened_ms = entry.ts_ms;
883                }
884                if let Location::Segmented(dir) = &self.location {
885                    segment::save_manifest(dir, &rebased)?;
886                }
887                if let Some(state) = &mut self.rollover {
888                    state.manifest = rebased;
889                }
890            }
891        }
892        entry.seq = self.next_seq;
893        // Serialize the entry ONCE; these exact bytes are hashed, signed,
894        // and written. Nothing downstream re-serializes.
895        let entry_json =
896            serde_json::to_string(&entry).map_err(|e| LedgerError::Serde(e.to_string()))?;
897        let hash = chain_hash(entry_json.as_bytes(), &self.last_hash);
898        let sig = self.key.sign(&hash);
899        let hash_hex = hex::encode(hash);
900        let sig_b64 = B64.encode(sig.to_bytes());
901        // Stamp the signing key's fingerprint so a key-rotated log stays verifiable:
902        // this record names which key signed it. Envelope-only, not hashed.
903        let kid = key_fingerprint(&self.key.verifying_key());
904
905        let raw_entry = serde_json::value::RawValue::from_string(entry_json)
906            .map_err(|e| LedgerError::Serde(e.to_string()))?;
907        let mut line = serde_json::to_string(&RecordOut {
908            entry: &raw_entry,
909            prev: &self.last_hash,
910            hash: &hash_hex,
911            sig_b64: &sig_b64,
912            kid: Some(&kid),
913        })
914        .map_err(|e| LedgerError::Serde(e.to_string()))?;
915        // Terminate IN the same buffer and write it in ONE `write_all`, so the
916        // newline is never a separate syscall from the record it terminates.
917        // This shrinks the partial-write window and makes newline-termination the
918        // single, reliable signal the reopen path keys torn-tail detection on: a
919        // present `\n` proves the whole record landed, its absence proves a torn
920        // (never-acked) tail. (`write_all` may still short-write on a crash, but
921        // it can no longer succeed at the record yet skip a separate terminator.)
922        line.push('\n');
923
924        self.file
925            .write_all(line.as_bytes())
926            .and_then(|_| self.file.flush())
927            // Crash-DURABLE when enabled: force the bytes to disk before we return the
928            // record, so a power loss cannot drop the tail of a log the caller was told
929            // was written. Off by default (fast, crash-consistent).
930            .and_then(|_| {
931                if self.sync {
932                    self.file.sync_data()
933                } else {
934                    Ok(())
935                }
936            })
937            .map_err(|e| io_err(&self.active_path, e))?;
938
939        let record = Record {
940            entry,
941            prev: std::mem::replace(&mut self.last_hash, hash_hex.clone()),
942            hash: hash_hex,
943            sig_b64,
944            kid: Some(kid),
945        };
946        self.next_seq += 1;
947        Ok(record)
948    }
949
950    /// Whether the NEXT append (which will carry `next_ts_ms`, on top of
951    /// `current_bytes` already written to the active segment) should roll
952    /// over first. `epoch_ms` compares `next_ts_ms` against the active
953    /// segment's own `opened_ms` — the CALLER-SUPPLIED entry timestamp, never
954    /// wall-clock time (this crate makes no wall-clock call of its own, so
955    /// the decision stays deterministic and testable, matching the
956    /// caller-supplied-time convention used throughout `decern-cli`).
957    fn should_roll_over(
958        state: &RolloverState,
959        current_seq: u64,
960        current_bytes: u64,
961        next_ts_ms: u64,
962    ) -> bool {
963        // Never roll over a segment holding zero records yet. Without this,
964        // the very first append into a fresh epoch-policy ledger rolls over
965        // before writing anything: `segment::initialize` hardcodes the first
966        // segment's `opened_ms` to 0 (it's created before any entry exists to
967        // read a timestamp from), so a real ts_ms (~10^12) almost always
968        // lands in a different epoch bucket than bucket 0 — producing a
969        // permanently wasted, empty, sealed segment for no benefit. A segment
970        // can only ever hold MORE by staying active until its first record.
971        let active_is_empty = state
972            .manifest
973            .active()
974            .is_some_and(|s| s.start_seq == current_seq);
975        if active_is_empty {
976            return false;
977        }
978        if let Some(max) = state.policy.max_bytes
979            && current_bytes >= max
980        {
981            return true;
982        }
983        if let Some(epoch) = state.policy.epoch_ms {
984            let opened = state
985                .manifest
986                .active()
987                .map(|s| s.opened_ms)
988                .unwrap_or(next_ts_ms);
989            if epoch > 0 && next_ts_ms / epoch != opened / epoch {
990                return true;
991            }
992        }
993        false
994    }
995
996    /// Seal the active segment and switch to a fresh one. Only ever called
997    /// (from `append`) when `self.rollover` is `Some`, i.e. only for a
998    /// segmented ledger — see [`segment::roll_over`] for the crash-safety
999    /// argument (the manifest rename is the single atomic commit point).
1000    fn roll_over(&mut self, next_ts_ms: u64) -> Result<(), LedgerError> {
1001        let Location::Segmented(dir) = &self.location else {
1002            return Err(LedgerError::Io {
1003                path: self.active_path.display().to_string(),
1004                err: "internal error: roll_over called on a non-segmented ledger".into(),
1005            });
1006        };
1007        let dir = dir.clone();
1008        let state = self.rollover.as_ref().ok_or_else(|| LedgerError::Io {
1009            path: dir.display().to_string(),
1010            err: "internal error: roll_over called with no rollover state".into(),
1011        })?;
1012        let (new_manifest, new_path) =
1013            segment::roll_over(&dir, &state.manifest, self.next_seq, next_ts_ms)?;
1014        self.file = OpenOptions::new()
1015            .append(true)
1016            .open(&new_path)
1017            .map_err(|e| io_err(&new_path, e))?;
1018        self.active_path = new_path;
1019        if let Some(state) = self.rollover.as_mut() {
1020            state.manifest = new_manifest;
1021        }
1022        Ok(())
1023    }
1024
1025    /// The current head hash — export and anchor this externally.
1026    pub fn root(&self) -> &str {
1027        &self.last_hash
1028    }
1029
1030    /// Number of entries appended so far (the next sequence number).
1031    pub fn count(&self) -> u64 {
1032        self.next_seq
1033    }
1034
1035    /// Re-read and verify this ledger's own file against its whole keyring (every
1036    /// retired key plus the current one), so a rotated log verifies end-to-end. Used
1037    /// by the admin summary; O(entries), so not for the request hot path.
1038    pub fn self_verify(&self) -> Result<VerifyReport, LedgerError> {
1039        verify_inner(&self.location, &self.verifiers, None)
1040    }
1041
1042    /// Hex of the Ed25519 public key that signs this ledger's entries — the
1043    /// fingerprint an auditor pins.
1044    pub fn pubkey_hex(&self) -> String {
1045        hex::encode(self.key.verifying_key().to_bytes())
1046    }
1047
1048    /// A window of records for the admin ledger browser: skip `offset`, take up
1049    /// to `limit`, each as its stored JSON object. Reads the file, so it's an
1050    /// admin/audit path, never the decision hot path.
1051    pub fn read_records(
1052        &self,
1053        offset: usize,
1054        limit: usize,
1055    ) -> Result<Vec<serde_json::Value>, LedgerError> {
1056        // `skip(offset)` and the `limit` break apply to ONE chained iterator
1057        // across every segment (single-file mode: trivially one file) — a
1058        // GLOBAL record index, never a per-segment one. Wrapping this loop
1059        // per-segment instead would re-apply both per file, which is wrong on
1060        // both counts for a window that straddles a segment boundary.
1061        let mut out = Vec::new();
1062        for line in self.location.lines()?.skip(offset) {
1063            if out.len() >= limit {
1064                break;
1065            }
1066            let line = line?;
1067            if line.trim().is_empty() {
1068                continue;
1069            }
1070            let v = serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
1071            out.push(v);
1072        }
1073        Ok(out)
1074    }
1075
1076    /// A window of records as their VERBATIM stored bytes — the exact line each
1077    /// record was written as, preserved byte-for-byte (`RawValue`, no reparse).
1078    /// This is what an EXTERNALLY-VERIFIABLE evidence bundle must ship: the hash
1079    /// chain commits to the entry's stored bytes (`chain_hash(entry_bytes, prev)`),
1080    /// so a third party can only reproduce a record's hash from those exact bytes.
1081    /// `read_records` (which parses to `Value`) would re-serialize and reorder keys,
1082    /// breaking the hash — use this whenever the bytes are the proof, not the data.
1083    pub fn read_raw_records(
1084        &self,
1085        offset: usize,
1086        limit: usize,
1087    ) -> Result<Vec<Box<serde_json::value::RawValue>>, LedgerError> {
1088        // Same global-index discipline as `read_records` — see its comment.
1089        // This is the primitive an evidence bundle's span is built from, so a
1090        // span crossing a segment boundary must come out byte-identical to
1091        // requesting the same span from an equivalent single-file log.
1092        let mut out = Vec::new();
1093        for line in self.location.lines()?.skip(offset) {
1094            if out.len() >= limit {
1095                break;
1096            }
1097            let line = line?;
1098            if line.trim().is_empty() {
1099                continue;
1100            }
1101            let v: Box<serde_json::value::RawValue> =
1102                serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
1103            out.push(v);
1104        }
1105        Ok(out)
1106    }
1107
1108    /// Sign the current head into a [`Checkpoint`] for external anchoring — the
1109    /// operator-independent half of the audit story. Hand it to a notary / SCITT
1110    /// transparency service / another party; they can later prove the log was not
1111    /// rewritten below this point without trusting the operator.
1112    pub fn checkpoint(&self, ts_ms: u64) -> Checkpoint {
1113        let root = self.last_hash.clone();
1114        let count = self.next_seq;
1115        let sig = self.key.sign(&checkpoint_bytes(&root, count, ts_ms));
1116        Checkpoint {
1117            root,
1118            count,
1119            ts_ms,
1120            pubkey_hex: self.pubkey_hex(),
1121            sig_b64: B64.encode(sig.to_bytes()),
1122        }
1123    }
1124
1125    /// Read every record's chain hash, in order, as the Merkle LEAF DATA (the 32 raw bytes
1126    /// each record's `hash` hex encodes). The RFC 9162 tree is built over these, so a leaf
1127    /// is exactly what the chain already commits to — a verifier who checks the chain has
1128    /// already derived every leaf. Audit/export path only (scans the whole file). Fails
1129    /// closed on a record with a missing or non-hex `hash`.
1130    fn merkle_leaves(&self) -> Result<Vec<Vec<u8>>, LedgerError> {
1131        let count = self.next_seq as usize;
1132        leaves_from_records(&self.read_records(0, count)?)
1133    }
1134
1135    /// Sign the current MERKLE tree head — the RFC 9162 root over all record hashes,
1136    /// externally anchorable like [`checkpoint`](Ledger::checkpoint) but enabling COMPACT
1137    /// third-party inclusion/consistency proofs. `tree_size == count`. Signs through the
1138    /// ledger key (a keyless hash committed by a key, same as a checkpoint).
1139    pub fn tree_head(&self, ts_ms: u64) -> Result<TreeHead, LedgerError> {
1140        let leaves = self.merkle_leaves()?;
1141        let root_hex = hex::encode(merkle::tree_hash(&leaves));
1142        let tree_size = leaves.len() as u64;
1143        let sig = self.key.sign(&tree_head_bytes(&root_hex, tree_size, ts_ms));
1144        Ok(TreeHead {
1145            merkle_root: root_hex,
1146            tree_size,
1147            ts_ms,
1148            pubkey_hex: self.pubkey_hex(),
1149            sig_b64: B64.encode(sig.to_bytes()),
1150        })
1151    }
1152
1153    /// A compact RFC 9162 inclusion proof that the record at `seq` (0-based) is committed
1154    /// by the current tree head's root. `Err` if `seq` is past the end of the log.
1155    pub fn inclusion_proof(&self, seq: u64) -> Result<InclusionProof, LedgerError> {
1156        let leaves = self.merkle_leaves()?;
1157        let idx = seq as usize;
1158        let path = merkle::inclusion_proof(&leaves, idx).ok_or_else(|| LedgerError::Tamper {
1159            seq,
1160            why: "inclusion index past the end of the log".into(),
1161        })?;
1162        Ok(InclusionProof {
1163            leaf_index: seq,
1164            tree_size: leaves.len() as u64,
1165            leaf_data: hex::encode(&leaves[idx]),
1166            audit_path: path.iter().map(hex::encode).collect(),
1167        })
1168    }
1169
1170    /// Inclusion proofs for several records, over one pass of the log.
1171    ///
1172    /// [`inclusion_proof`](Ledger::inclusion_proof) derives every leaf in the log to prove
1173    /// one record is in it, which is the right shape for one proof and the wrong shape for
1174    /// a page of them: asking for `m` proofs that way reads and parses the whole log `m`
1175    /// times, and does it holding the lock an append needs. This derives the leaves once.
1176    ///
1177    /// Returns proofs in the order the sequences were given. A sequence past the end of the
1178    /// log fails the whole call rather than being skipped — a page of proofs with a hole in
1179    /// it, where the hole is silent, is worse than no page.
1180    pub fn inclusion_proofs(&self, seqs: &[u64]) -> Result<Vec<InclusionProof>, LedgerError> {
1181        let leaves = self.merkle_leaves()?;
1182        let tree_size = leaves.len() as u64;
1183        seqs.iter()
1184            .map(|&seq| {
1185                let idx = seq as usize;
1186                let path =
1187                    merkle::inclusion_proof(&leaves, idx).ok_or_else(|| LedgerError::Tamper {
1188                        seq,
1189                        why: "inclusion index past the end of the log".into(),
1190                    })?;
1191                Ok(InclusionProof {
1192                    leaf_index: seq,
1193                    tree_size,
1194                    leaf_data: hex::encode(&leaves[idx]),
1195                    audit_path: path.iter().map(hex::encode).collect(),
1196                })
1197            })
1198            .collect()
1199    }
1200
1201    /// A compact RFC 9162 consistency proof that the log of the first `first_size` records
1202    /// is an exact prefix of the current log — the operator-independent equivocation /
1203    /// truncation check against an EARLIER anchored tree head. `1 <= first_size <= count`.
1204    pub fn consistency_proof(&self, first_size: u64) -> Result<ConsistencyProof, LedgerError> {
1205        let leaves = self.merkle_leaves()?;
1206        let path = merkle::consistency_proof(&leaves, first_size as usize).ok_or_else(|| {
1207            LedgerError::Tamper {
1208                seq: first_size,
1209                why: "consistency first_size out of range (need 1..=count)".into(),
1210            }
1211        })?;
1212        Ok(ConsistencyProof {
1213            first_size,
1214            second_size: leaves.len() as u64,
1215            proof: path.iter().map(hex::encode).collect(),
1216        })
1217    }
1218
1219    /// A single-snapshot read for an evidence bundle: checkpoint, tree_head, and raw records
1220    /// are ALL derived from the SAME in-memory (self.last_hash, self.next_seq) state captured
1221    /// at one moment — unlike calling `checkpoint()`/`tree_head()`/`read_raw_records()` separately
1222    /// (which lets an `append()` land between calls and make the three mutually inconsistent:
1223    /// `checkpoint.count != tree_head.tree_size` or `checkpoint.root` computed over different
1224    /// records than `tree_head`).
1225    ///
1226    /// This is the single-file analog of [`ShardedLedger::evidence_snapshot`](crate::sharded::ShardedLedger::evidence_snapshot).
1227    /// The snapshot captures the log's state at call time; a concurrent `append()` does not
1228    /// change the returned values. Returns `(count, raw_records, checkpoint, tree_head)`.
1229    pub fn snapshot_for_bundle(&self, ts_ms: u64) -> Result<EvidenceSnapshot, LedgerError> {
1230        // Capture the head state once — this is the "snapshot" that all derived values build from.
1231        let count = self.next_seq;
1232        let root = self.last_hash.clone();
1233
1234        // All three outputs are now derived from this same (root, count) pair, so they are
1235        // mutually consistent even if an append happens after this point.
1236        let raw_records = self.read_raw_records(0, count as usize)?;
1237
1238        let cp_sig = self.key.sign(&checkpoint_bytes(&root, count, ts_ms));
1239        let checkpoint = Checkpoint {
1240            root,
1241            count,
1242            ts_ms,
1243            pubkey_hex: self.pubkey_hex(),
1244            sig_b64: B64.encode(cp_sig.to_bytes()),
1245        };
1246
1247        let leaves = leaves_from_records(&self.read_records(0, count as usize)?)?;
1248        let merkle_root = hex::encode(merkle::tree_hash(&leaves));
1249        let tree_size = leaves.len() as u64;
1250        let th_sig = self
1251            .key
1252            .sign(&tree_head_bytes(&merkle_root, tree_size, ts_ms));
1253        let tree_head = TreeHead {
1254            merkle_root,
1255            tree_size,
1256            ts_ms,
1257            pubkey_hex: self.pubkey_hex(),
1258            sig_b64: B64.encode(th_sig.to_bytes()),
1259        };
1260
1261        Ok((count, raw_records, checkpoint, tree_head))
1262    }
1263
1264    /// Seal the current head into the persisted ANCHOR file — the last committed
1265    /// height, durably recorded on THIS node (not only handed to an external notary).
1266    /// On the next [`open_anchored`](Ledger::open_anchored) /
1267    /// [`verify_against_anchor`](Ledger::verify_against_anchor) the log must still
1268    /// extend it, which is what makes a tail-truncation across a restart detectable
1269    /// — a plain reopen accepts any internally-consistent shorter chain and cannot.
1270    pub fn seal_anchor(&self, anchor_path: &Path, ts_ms: u64) -> Result<Checkpoint, LedgerError> {
1271        let cp = self.checkpoint(ts_ms);
1272        save_anchor(anchor_path, &cp)?;
1273        Ok(cp)
1274    }
1275
1276    /// Fail-closed truncation/rewrite check against the persisted anchor. Call it
1277    /// right after opening (or use [`open_anchored`](Ledger::open_anchored)): if an
1278    /// anchor exists it must (a) be signed by a key in this ledger's keyring — a
1279    /// forged anchor cannot be used to downgrade the committed height — and (b) still
1280    /// be extended by the log (at least `count` records that re-derive `root`). Any
1281    /// failure is `Tamper`: the log was truncated below, or rewritten at/below, its
1282    /// last committed height. No anchor file ⇒ `Ok` (nothing committed yet).
1283    pub fn verify_against_anchor(&self, anchor_path: &Path) -> Result<(), LedgerError> {
1284        let Some(cp) = load_anchor(anchor_path)? else {
1285            return Ok(());
1286        };
1287        // (a) The anchor must be vouched by a trusted ledger key (current or retired),
1288        // else an attacker could drop a self-signed anchor at a lower count to mask a
1289        // truncation.
1290        if !self.verifiers.iter().any(|k| verify_checkpoint_sig(&cp, k)) {
1291            return Err(LedgerError::Tamper {
1292                seq: cp.count,
1293                why:
1294                    "anchor signature is not from a trusted ledger key (forged or wrong-key anchor)"
1295                        .into(),
1296            });
1297        }
1298        // (b) The log must still extend the anchor's committed height.
1299        if !ledger_extends_checkpoint_at(&self.location, &cp)? {
1300            return Err(LedgerError::Tamper {
1301                seq: cp.count,
1302                why: format!(
1303                    "ledger no longer extends its anchor at count {} — truncated or rewritten \
1304                     below the last committed height",
1305                    cp.count
1306                ),
1307            });
1308        }
1309        Ok(())
1310    }
1311}
1312
1313/// A signed, externally-anchorable commitment to the ledger's state at a moment:
1314/// the head `root` over the first `count` entries, timestamped and signed by the
1315/// ledger key. It leaks no entry content — only a hash, a count, and a signature —
1316/// so it is safe to publish, hand to a notary, or submit to a SCITT transparency
1317/// service. Because the log is append-only, the root over the first `count` entries
1318/// is fixed forever; an external party holding a checkpoint can re-derive that root
1319/// from the file and, if it disagrees, prove the operator rewrote history — the
1320/// operator-INDEPENDENT verification a log inside the operator's own stack lacks.
1321#[derive(Debug, Clone, Serialize, Deserialize)]
1322pub struct Checkpoint {
1323    pub root: String,
1324    pub count: u64,
1325    pub ts_ms: u64,
1326    /// Hex of the Ed25519 ledger key that signed both the entries and this commitment.
1327    pub pubkey_hex: String,
1328    /// Ed25519 signature over the canonical commitment bytes.
1329    pub sig_b64: String,
1330}
1331
1332/// A signed, externally-anchorable commitment to the ledger's MERKLE state: the RFC 9162
1333/// tree root over the first `tree_size` record hashes, timestamped and signed by the ledger
1334/// key. Parallel to [`Checkpoint`] (the linear-chain head): a `TreeHead` enables COMPACT
1335/// third-party proofs — an inclusion proof shows one record is in the log without shipping
1336/// the whole tail, and a consistency proof between an anchored earlier `TreeHead` and a
1337/// later one proves nothing below the earlier size was rewritten or dropped (closing
1338/// equivocation). Leaks no entry content — only a root, a size, and a signature.
1339#[derive(Debug, Clone, Serialize, Deserialize)]
1340pub struct TreeHead {
1341    /// Hex of the RFC 9162 Merkle Tree Hash over the first `tree_size` record hashes.
1342    pub merkle_root: String,
1343    pub tree_size: u64,
1344    pub ts_ms: u64,
1345    /// Hex of the Ed25519 ledger key that signed this commitment.
1346    pub pubkey_hex: String,
1347    /// Ed25519 signature over the `decern-ledger-tree-head` domain-separated bytes.
1348    pub sig_b64: String,
1349}
1350
1351/// A single-snapshot evidence bundle: record count, raw bytes, and signed commitments
1352/// (checkpoint and merkle tree head) all derived from the same log state at one moment.
1353/// This is the return type of [`Ledger::snapshot_for_bundle`] and
1354/// [`ShardedLedger::evidence_snapshot`](sharded::ShardedLedger::evidence_snapshot).
1355pub type EvidenceSnapshot = (
1356    u64,                                   // record count
1357    Vec<Box<serde_json::value::RawValue>>, // raw record bytes
1358    Checkpoint,                            // signed linear-chain commitment
1359    TreeHead,                              // signed merkle-tree commitment
1360);
1361
1362/// A compact RFC 9162 inclusion proof (hex-encoded): the record at `leaf_index` in a tree
1363/// of `tree_size` leaves is committed by a [`TreeHead`]'s root. `leaf_data` is the record's
1364/// chain hash (the Merkle leaf data — a verifier hashes it with the `0x00` leaf prefix);
1365/// `audit_path` is the sibling hashes bottom-up.
1366#[derive(Debug, Clone, Serialize, Deserialize)]
1367pub struct InclusionProof {
1368    pub leaf_index: u64,
1369    pub tree_size: u64,
1370    pub leaf_data: String,
1371    pub audit_path: Vec<String>,
1372}
1373
1374/// A compact RFC 9162 consistency proof (hex-encoded) that the tree of the first
1375/// `first_size` leaves is an exact prefix of the tree of `second_size` leaves.
1376#[derive(Debug, Clone, Serialize, Deserialize)]
1377pub struct ConsistencyProof {
1378    pub first_size: u64,
1379    pub second_size: u64,
1380    pub proof: Vec<String>,
1381}
1382
1383/// Domain-separated commitment bytes: a fixed `tag` plus a hex field and two decimal
1384/// integers joined by a unit-separator byte (0x1F) that appears in neither hex nor a
1385/// decimal, so no two distinct field tuples collide AND no two tags cross-verify (a
1386/// checkpoint signature can never be replayed as a tree-head signature, or vice versa).
1387/// Shared by [`checkpoint_bytes`] and [`tree_head_bytes`] so the signing convention lives
1388/// in one place.
1389fn commitment_bytes(tag: &str, hex_field: &str, a: u64, b: u64) -> Vec<u8> {
1390    format!("{tag}\x1f{hex_field}\x1f{a}\x1f{b}").into_bytes()
1391}
1392
1393/// The chain-head commitment (linear hash-chain root over `count` entries).
1394fn checkpoint_bytes(root: &str, count: u64, ts_ms: u64) -> Vec<u8> {
1395    commitment_bytes("decern-ledger-checkpoint", root, count, ts_ms)
1396}
1397
1398/// The Merkle-tree-head commitment (RFC 9162 root over `tree_size` record hashes).
1399fn tree_head_bytes(merkle_root: &str, tree_size: u64, ts_ms: u64) -> Vec<u8> {
1400    commitment_bytes("decern-ledger-tree-head", merkle_root, tree_size, ts_ms)
1401}
1402
1403#[derive(Debug)]
1404pub struct VerifyReport {
1405    pub entries: u64,
1406    pub root: Option<String>,
1407    pub signatures_checked: bool,
1408}
1409
1410/// Hex of an Ed25519 public key — the `kid` fingerprint stamped on each record and
1411/// the id an auditor pins.
1412fn key_fingerprint(vk: &VerifyingKey) -> String {
1413    hex::encode(vk.to_bytes())
1414}
1415
1416/// Verify a ledger file: the hash chain always; every entry signature when a key is
1417/// supplied. Single-key convenience over [`verify_with_keys`] — for a key-ROTATED
1418/// log (entries under more than one key) use that with the full keyring.
1419#[must_use = "ledger verification failure must be checked"]
1420pub fn verify(path: &Path, pubkey: Option<&VerifyingKey>) -> Result<VerifyReport, LedgerError> {
1421    let loc = Location::detect(path);
1422    match pubkey {
1423        None => verify_inner(&loc, &[], None),
1424        Some(k) => verify_inner(&loc, std::slice::from_ref(k), None),
1425    }
1426}
1427
1428/// Verify the whole chain (fail-closed on any tamper) AND return a window of the parsed
1429/// records — the OFFLINE auditor read: an auditor holds the ledger file and, out of band,
1430/// the public key, but not the private signing key `Ledger::open` demands. With `pubkey`
1431/// each record's signature is checked too; without it, only the hash chain (still
1432/// fail-closed). The whole log is scanned to verify integrity; only records in
1433/// `[offset, offset+limit)` are materialized (memory stays bounded to the window). The
1434/// records are the exact stored JSON (as `read_records` returns them), NOT verbatim bytes.
1435pub fn read_verified(
1436    path: &Path,
1437    pubkey: Option<&VerifyingKey>,
1438    offset: usize,
1439    limit: usize,
1440) -> Result<(VerifyReport, Vec<serde_json::Value>), LedgerError> {
1441    let loc = Location::detect(path);
1442    let mut window = ReadWindow {
1443        offset,
1444        end: offset.saturating_add(limit),
1445        records: Vec::new(),
1446    };
1447    let report = match pubkey {
1448        None => verify_inner(&loc, &[], Some(&mut window)),
1449        Some(k) => verify_inner(&loc, std::slice::from_ref(k), Some(&mut window)),
1450    }?;
1451    Ok((report, window.records))
1452}
1453
1454/// A bounded collection window for [`read_verified`]: while the whole chain is scanned
1455/// for integrity, only records whose index falls in `[offset, end)` are materialized.
1456struct ReadWindow {
1457    offset: usize,
1458    end: usize,
1459    records: Vec<serde_json::Value>,
1460}
1461
1462/// Verify a ledger file against a KEYRING — the rotation-aware form. Each record is
1463/// checked against the key its `kid` names; a legacy record with no `kid` (written
1464/// before rotation support) is accepted against any key in the ring. A record whose
1465/// `kid` names a key NOT in the ring is tamper (fail-closed: an unknown signer is
1466/// never trusted). An empty ring means "signatures not checked" (chain only), same
1467/// as [`verify`] with `None`.
1468#[must_use = "ledger verification failure must be checked"]
1469pub fn verify_with_keys(path: &Path, keys: &[VerifyingKey]) -> Result<VerifyReport, LedgerError> {
1470    verify_inner(&Location::detect(path), keys, None)
1471}
1472
1473fn verify_inner(
1474    location: &Location,
1475    keys: &[VerifyingKey],
1476    sink: Option<&mut ReadWindow>,
1477) -> Result<VerifyReport, LedgerError> {
1478    let (lines, torn) = location.prefix_lines()?;
1479    // Verify the PREFIX (torn fragment already excluded). A failure here is a
1480    // fault in fully-terminated, acked history → genuine Tamper, propagated
1481    // as-is whether or not a torn fragment also exists.
1482    let report = verify_lines(lines, keys, sink)?;
1483    match torn {
1484        None => Ok(report),
1485        // Prefix is clean AND a torn fragment trails it → report it distinctly so
1486        // callers can tell a benign crash-mid-append from an attack. The open
1487        // path catches this and heals; read-only verifiers surface it.
1488        Some(t) => Err(LedgerError::TornTail {
1489            healed_entries: report.entries,
1490            healed_root: report.root,
1491            torn_path: t.path.display().to_string(),
1492            torn_from_offset: t.offset,
1493        }),
1494    }
1495}
1496
1497/// The shared per-record verify core: hash-chain always, signatures when `keys` is
1498/// non-empty. `lines` yields each stored record's raw JSON text in seq order (an
1499/// `Err` propagates a read failure as-is) — the SAME logic verifies a File-backed
1500/// [`Ledger`]'s lines (via [`verify_inner`]) and a [`sharded::ShardedLedger`] shard's
1501/// stored records (via [`verify_stored_records`]), so the two can never diverge on
1502/// what counts as tamper.
1503fn verify_lines(
1504    lines: impl Iterator<Item = Result<String, LedgerError>>,
1505    keys: &[VerifyingKey],
1506    mut sink: Option<&mut ReadWindow>,
1507) -> Result<VerifyReport, LedgerError> {
1508    let check_sigs = !keys.is_empty();
1509
1510    let mut prev = GENESIS.to_owned();
1511    let mut count: u64 = 0;
1512
1513    for (i, line) in lines.enumerate() {
1514        let line = line?;
1515        if line.trim().is_empty() {
1516            continue;
1517        }
1518        let record: RecordIn = serde_json::from_str(&line).map_err(|e| LedgerError::Tamper {
1519            seq: i as u64,
1520            why: format!("unparseable record: {e}"),
1521        })?;
1522
1523        // Hash the entry bytes EXACTLY as stored — no re-serialization.
1524        let entry_bytes = record.entry.get().as_bytes();
1525        let entry: Entry =
1526            serde_json::from_str(record.entry.get()).map_err(|e| LedgerError::Tamper {
1527                seq: count,
1528                why: format!("unparseable entry: {e}"),
1529            })?;
1530
1531        if entry.seq != count {
1532            return Err(LedgerError::Tamper {
1533                seq: count,
1534                why: format!("sequence break (found seq {})", entry.seq),
1535            });
1536        }
1537        if record.prev != prev {
1538            return Err(LedgerError::Tamper {
1539                seq: count,
1540                why: "broken chain link (prev mismatch — record edited, moved or removed)".into(),
1541            });
1542        }
1543
1544        let hash = chain_hash(entry_bytes, &record.prev);
1545        if hex::encode(hash) != record.hash {
1546            return Err(LedgerError::Tamper {
1547                seq: count,
1548                why: "entry altered (hash mismatch)".into(),
1549            });
1550        }
1551
1552        if check_sigs {
1553            let sig_bytes: [u8; 64] = B64
1554                .decode(&record.sig_b64)
1555                .map_err(|_| LedgerError::Tamper {
1556                    seq: count,
1557                    why: "unparseable signature".into(),
1558                })?
1559                .try_into()
1560                .map_err(|_| LedgerError::Tamper {
1561                    seq: count,
1562                    why: "signature length".into(),
1563                })?;
1564            let sig = decern_crypto::Signature::from_bytes(&sig_bytes);
1565
1566            // Pick the verifying key: the one this record's `kid` names, or — for a
1567            // legacy record with no `kid` — any key in the ring (a pre-rotation log
1568            // was signed by a single key that is in the ring).
1569            let verified = match &record.kid {
1570                Some(kid) => match keys.iter().find(|k| key_fingerprint(k) == *kid) {
1571                    Some(k) => k.verify_strict(&hash, &sig).is_ok(),
1572                    None => {
1573                        return Err(LedgerError::Tamper {
1574                            seq: count,
1575                            why: format!(
1576                                "record signed by key {kid}, which is not in the trusted keyring"
1577                            ),
1578                        });
1579                    }
1580                },
1581                None => keys.iter().any(|k| k.verify_strict(&hash, &sig).is_ok()),
1582            };
1583            if !verified {
1584                return Err(LedgerError::Tamper {
1585                    seq: count,
1586                    why: "signature invalid (chain rewritten with a different key?)".into(),
1587                });
1588            }
1589        }
1590
1591        // Materialize into the read window only when in range — the whole chain is still
1592        // scanned for integrity, but memory stays bounded to `[offset, end)`. Push the
1593        // WHOLE stored line (same shape `read_records` returns: `entry` + envelope), so a
1594        // read_verified consumer sees exactly what the admin projection would, but only
1595        // after the chain (and, with a key, the signatures) checked out.
1596        if let Some(w) = sink.as_deref_mut() {
1597            let idx = count as usize;
1598            if idx >= w.offset && idx < w.end {
1599                let value: serde_json::Value =
1600                    serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
1601                w.records.push(value);
1602            }
1603        }
1604
1605        prev = record.hash;
1606        count += 1;
1607    }
1608
1609    Ok(VerifyReport {
1610        entries: count,
1611        root: if count > 0 { Some(prev) } else { None },
1612        signatures_checked: check_sigs,
1613    })
1614}
1615
1616/// Verify one [`sharded::ShardedLedger`] shard's stored records — the hash-chain +
1617/// signature check [`sharded::ShardedLedger::self_verify`] runs, over the exact
1618/// byte-stable lines a [`decern_store::LedgerHeadStore`] persisted (never a
1619/// re-serialization — same byte-stability discipline as the File path). `records`
1620/// must already be in seq order (what `LedgerHeadStore::with_shard` hands back).
1621pub(crate) fn verify_stored_records(
1622    records: &[decern_store::StoredRecord],
1623    keys: &[VerifyingKey],
1624) -> Result<VerifyReport, LedgerError> {
1625    verify_lines(
1626        records.iter().map(|r| Ok(r.record_json.clone())),
1627        keys,
1628        None,
1629    )
1630}
1631
1632/// Verify a checkpoint's own signature against a pinned key — does the ledger key
1633/// that signs entries also vouch for this commitment? Does not read the ledger.
1634/// The Merkle leaf data of each record, in order: the 32 raw bytes its `hash` hex encodes.
1635/// One definition, shared by the signing side and the read-only verifying side — a leaf that
1636/// meant two different things in two places would produce proofs that verify nowhere.
1637fn leaves_from_records(recs: &[serde_json::Value]) -> Result<Vec<Vec<u8>>, LedgerError> {
1638    let mut leaves = Vec::with_capacity(recs.len());
1639    for (i, r) in recs.iter().enumerate() {
1640        let hash_hex = r
1641            .get("hash")
1642            .and_then(serde_json::Value::as_str)
1643            .ok_or_else(|| LedgerError::Tamper {
1644                seq: i as u64,
1645                why: "record missing hash field".into(),
1646            })?;
1647        let bytes = hex::decode(hash_hex).map_err(|_| LedgerError::Tamper {
1648            seq: i as u64,
1649            why: "record hash is not valid hex".into(),
1650        })?;
1651        leaves.push(bytes);
1652    }
1653    Ok(leaves)
1654}
1655
1656/// Merkle leaves of the ledger at `path`, read-only — no signing key required.
1657///
1658/// Producing a tree head needs the ledger key, because a commitment nobody signed commits
1659/// nobody. CHECKING one does not: an auditor holds the log and a public key, never the key
1660/// that wrote it. This is the path that makes an anchored commitment verifiable by someone
1661/// other than its author, which is the only thing that makes anchoring worth doing.
1662///
1663/// Verifies the whole chain on the way through, so leaves are never derived from records
1664/// that do not hold together.
1665pub fn merkle_leaves_at(
1666    path: &Path,
1667    pubkey: Option<&VerifyingKey>,
1668) -> Result<Vec<Vec<u8>>, LedgerError> {
1669    let (_report, records) = read_verified(path, pubkey, 0, usize::MAX)?;
1670    leaves_from_records(&records)
1671}
1672
1673#[must_use = "signature verification result must be checked"]
1674pub fn verify_checkpoint_sig(cp: &Checkpoint, pubkey: &VerifyingKey) -> bool {
1675    let Ok(bytes) = B64.decode(&cp.sig_b64) else {
1676        return false;
1677    };
1678    let Ok(sig_arr): Result<[u8; 64], _> = bytes.try_into() else {
1679        return false;
1680    };
1681    let sig = decern_crypto::Signature::from_bytes(&sig_arr);
1682    pubkey
1683        .verify_strict(&checkpoint_bytes(&cp.root, cp.count, cp.ts_ms), &sig)
1684        .is_ok()
1685}
1686
1687/// Verify a tree head's own signature against a pinned key — the Merkle counterpart of
1688/// [`verify_checkpoint_sig`]. The domain-separated `decern-ledger-tree-head` tag means this
1689/// never cross-verifies a checkpoint signature. Does not read the ledger.
1690#[must_use = "signature verification result must be checked"]
1691pub fn verify_tree_head_sig(th: &TreeHead, pubkey: &VerifyingKey) -> bool {
1692    let Ok(bytes) = B64.decode(&th.sig_b64) else {
1693        return false;
1694    };
1695    let Ok(sig_arr): Result<[u8; 64], _> = bytes.try_into() else {
1696        return false;
1697    };
1698    let sig = decern_crypto::Signature::from_bytes(&sig_arr);
1699    pubkey
1700        .verify_strict(
1701            &tree_head_bytes(&th.merkle_root, th.tree_size, th.ts_ms),
1702            &sig,
1703        )
1704        .is_ok()
1705}
1706
1707/// The per-check result of verifying an exported evidence bundle offline. `accepted` is
1708/// the AND of every APPLICABLE check (an `Option` check that is `None` did not apply and
1709/// does not gate acceptance). Serializable so a CLI can emit it as `--json` verbatim.
1710#[derive(Debug, Clone, Serialize)]
1711pub struct BundleVerdict {
1712    pub accepted: bool,
1713    pub format: String,
1714    pub records: usize,
1715    pub from: u64,
1716    /// True when the bundle is a full tail from genesis (`from == 0`) — only then can a
1717    /// verifier recompute the Merkle root and a consistency proof from the records alone.
1718    pub full_tail: bool,
1719    pub chain_ok: bool,
1720    pub record_sigs_ok: bool,
1721    pub checkpoint_sig_ok: bool,
1722    pub tree_head_present: bool,
1723    pub tree_head_sig_ok: bool,
1724    /// `Some(ok)` when recomputed from a full tail; `None` for a partial tail.
1725    pub merkle_root_ok: Option<bool>,
1726    pub anchor_ok: bool,
1727    /// `Some(ok)` when an `--against` earlier tree head was supplied AND checkable.
1728    pub consistency_ok: Option<bool>,
1729    pub errors: Vec<String>,
1730}
1731
1732#[derive(Deserialize)]
1733struct BundleIn {
1734    #[serde(default)]
1735    format: String,
1736    #[serde(default)]
1737    span: SpanIn,
1738    checkpoint: Checkpoint,
1739    #[serde(default)]
1740    tree_head: Option<TreeHead>,
1741    #[serde(default)]
1742    records: Vec<RecordIn>,
1743}
1744
1745#[derive(Deserialize, Default)]
1746struct SpanIn {
1747    #[serde(default)]
1748    from: u64,
1749}
1750
1751/// Does any key in `keys` verify `sig_b64` over `msg`? (Keyring-aware: a rotated log's
1752/// records are signed by different keys; a verifier holds the current + retired public
1753/// keys.) Fail-closed on a malformed signature.
1754fn any_key_verifies(msg: &[u8], sig_b64: &str, keys: &[VerifyingKey]) -> bool {
1755    let Ok(bytes) = B64.decode(sig_b64) else {
1756        return false;
1757    };
1758    let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
1759        return false;
1760    };
1761    let sig = decern_crypto::Signature::from_bytes(&arr);
1762    keys.iter().any(|k| k.verify_strict(msg, &sig).is_ok())
1763}
1764
1765/// Verify an exported `decern-evidence-bundle` OFFLINE against a PINNED keyring — the standalone
1766/// third-party check with no call back to the server. `bundle_json` is the RAW bundle file
1767/// text (NOT a re-serialized `Value`): each record's `entry` is captured as verbatim bytes,
1768/// because the chain commits to those exact bytes. `keys` is the pinned current + retired
1769/// public keys (obtained OUT OF BAND — never from the bundle). `against`, if given, is an
1770/// independently-anchored EARLIER tree head; a consistency check then proves the bundle did
1771/// not rewrite or drop anything below that earlier size (equivocation/truncation).
1772///
1773/// Checks (all fail-closed): every record hash re-derives from its verbatim bytes; the chain
1774/// links (genesis when `from == 0`); every record signature; the checkpoint signature; the
1775/// tree-head signature; the anchor (last hash == checkpoint root, positions match count ==
1776/// tree_size); and — for a full tail — the recomputed Merkle root equals the signed tree
1777/// head, plus any requested consistency proof against the anchored earlier head.
1778pub fn verify_evidence_bundle(
1779    bundle_json: &str,
1780    keys: &[VerifyingKey],
1781    against: Option<&TreeHead>,
1782) -> BundleVerdict {
1783    let mut errors: Vec<String> = Vec::new();
1784    let b: BundleIn = match serde_json::from_str(bundle_json) {
1785        Ok(b) => b,
1786        Err(e) => {
1787            return BundleVerdict {
1788                accepted: false,
1789                format: String::new(),
1790                records: 0,
1791                from: 0,
1792                full_tail: false,
1793                chain_ok: false,
1794                record_sigs_ok: false,
1795                checkpoint_sig_ok: false,
1796                tree_head_present: false,
1797                tree_head_sig_ok: false,
1798                merkle_root_ok: None,
1799                anchor_ok: false,
1800                consistency_ok: None,
1801                errors: vec![format!("bundle does not parse: {e}")],
1802            };
1803        }
1804    };
1805
1806    let from = b.span.from;
1807    let full_tail = from == 0;
1808    let n = b.records.len();
1809
1810    // 1) Per-record hash re-derivation + 2) chain links.
1811    let mut chain_ok = true;
1812    let mut leaves: Vec<Vec<u8>> = Vec::with_capacity(n);
1813    for (i, r) in b.records.iter().enumerate() {
1814        let want = hex::encode(chain_hash(r.entry.get().as_bytes(), &r.prev));
1815        if want != r.hash {
1816            chain_ok = false;
1817            errors.push(format!(
1818                "record {i}: hash does not re-derive from its bytes"
1819            ));
1820        }
1821        let expected_prev = if i == 0 {
1822            if full_tail {
1823                GENESIS.to_owned()
1824            } else {
1825                r.prev.clone() // no genesis anchor for a partial tail; link-only below
1826            }
1827        } else {
1828            b.records[i - 1].hash.clone()
1829        };
1830        if r.prev != expected_prev {
1831            chain_ok = false;
1832            errors.push(format!(
1833                "record {i}: prev does not link to the previous record"
1834            ));
1835        }
1836        match hex::decode(&r.hash) {
1837            Ok(bytes) => leaves.push(bytes),
1838            Err(_) => {
1839                chain_ok = false;
1840                errors.push(format!("record {i}: hash is not valid hex"));
1841            }
1842        }
1843    }
1844
1845    // 3) Every record signature against the pinned keyring.
1846    let mut record_sigs_ok = true;
1847    for (i, r) in b.records.iter().enumerate() {
1848        let Ok(msg) = hex::decode(&r.hash) else {
1849            record_sigs_ok = false;
1850            continue;
1851        };
1852        if !any_key_verifies(&msg, &r.sig_b64, keys) {
1853            record_sigs_ok = false;
1854            errors.push(format!(
1855                "record {i}: signature not verified by any pinned key"
1856            ));
1857        }
1858    }
1859
1860    // 4) Checkpoint + 5) tree-head signatures.
1861    let checkpoint_sig_ok = keys.iter().any(|k| verify_checkpoint_sig(&b.checkpoint, k));
1862    if !checkpoint_sig_ok {
1863        errors.push("checkpoint signature not verified by any pinned key".into());
1864    }
1865    let tree_head_present = b.tree_head.is_some();
1866    let tree_head_sig_ok = match &b.tree_head {
1867        Some(th) => {
1868            let ok = keys.iter().any(|k| verify_tree_head_sig(th, k));
1869            if !ok {
1870                errors.push("tree-head signature not verified by any pinned key".into());
1871            }
1872            ok
1873        }
1874        None => {
1875            // Fail-closed: this verifier attests the UPGRADED bundle (the server always
1876            // emits a signed Merkle tree head). A bundle without one gets no Merkle
1877            // commitment, so it is rejected with a legible reason rather than a silent
1878            // acceptance that overstates what was checked.
1879            errors.push(
1880                "bundle has no tree_head (Merkle commitment); this verifier requires the \
1881                 upgraded decern-evidence-bundle shape"
1882                    .into(),
1883            );
1884            false
1885        }
1886    };
1887
1888    // 6) Anchor: the tail terminates at the signed head and positions line up.
1889    let mut anchor_ok = true;
1890    match b.records.last() {
1891        Some(last) if last.hash == b.checkpoint.root => {}
1892        Some(_) => {
1893            anchor_ok = false;
1894            errors.push("last record hash != checkpoint root".into());
1895        }
1896        None => {
1897            // Empty tail: the checkpoint alone attests the head; only valid when from==count.
1898            if from != b.checkpoint.count {
1899                anchor_ok = false;
1900                errors.push("empty tail but from != checkpoint count".into());
1901            }
1902        }
1903    }
1904    if from + n as u64 != b.checkpoint.count {
1905        anchor_ok = false;
1906        errors.push("from + record count != checkpoint count".into());
1907    }
1908    if let Some(th) = &b.tree_head
1909        && th.tree_size != b.checkpoint.count
1910    {
1911        anchor_ok = false;
1912        errors.push("tree_head size != checkpoint count".into());
1913    }
1914
1915    // 7) Full-tail Merkle root recomputation.
1916    let merkle_root_ok = match (&b.tree_head, full_tail && chain_ok) {
1917        (Some(th), true) => {
1918            let recomputed = hex::encode(merkle::tree_hash(&leaves));
1919            let ok = recomputed == th.merkle_root;
1920            if !ok {
1921                errors.push("recomputed Merkle root != signed tree head".into());
1922            }
1923            Some(ok)
1924        }
1925        _ => None,
1926    };
1927
1928    // 8) Optional consistency against an anchored earlier tree head (full tail only).
1929    let consistency_ok = match (against, &b.tree_head, full_tail && chain_ok) {
1930        (Some(earlier), Some(current), true) => {
1931            // The earlier head must itself be pinned-key-signed, else it anchors nothing.
1932            let earlier_sig = keys.iter().any(|k| verify_tree_head_sig(earlier, k));
1933            let first = earlier.tree_size as usize;
1934            let ok = earlier_sig
1935                && first <= leaves.len()
1936                && merkle::consistency_proof(&leaves, first).is_some_and(|path| {
1937                    match (
1938                        hex_to_32(&earlier.merkle_root),
1939                        hex_to_32(&current.merkle_root),
1940                    ) {
1941                        (Some(fr), Some(sr)) => merkle::verify_consistency(
1942                            earlier.tree_size,
1943                            current.tree_size,
1944                            &fr,
1945                            &sr,
1946                            &path,
1947                        ),
1948                        _ => false,
1949                    }
1950                });
1951            if !ok {
1952                errors.push(
1953                    "consistency proof against the earlier anchored tree head FAILED \
1954                     (possible equivocation/truncation, or the earlier head is unsigned)"
1955                        .into(),
1956                );
1957            }
1958            Some(ok)
1959        }
1960        (Some(_), _, false) => {
1961            errors
1962                .push("consistency check needs a full tail (from==0) to recompute; skipped".into());
1963            Some(false)
1964        }
1965        _ => None,
1966    };
1967
1968    let accepted = chain_ok
1969        && record_sigs_ok
1970        && checkpoint_sig_ok
1971        && tree_head_sig_ok
1972        && anchor_ok
1973        && merkle_root_ok.unwrap_or(true)
1974        && consistency_ok.unwrap_or(true);
1975
1976    BundleVerdict {
1977        accepted,
1978        format: b.format,
1979        records: n,
1980        from,
1981        full_tail,
1982        chain_ok,
1983        record_sigs_ok,
1984        checkpoint_sig_ok,
1985        tree_head_present,
1986        tree_head_sig_ok,
1987        merkle_root_ok,
1988        anchor_ok,
1989        consistency_ok,
1990        errors,
1991    }
1992}
1993
1994/// Decode a 64-hex-char string into 32 bytes, or `None`.
1995fn hex_to_32(s: &str) -> Option<[u8; 32]> {
1996    hex::decode(s).ok()?.try_into().ok()
1997}
1998
1999/// The operator-independent tamper check: does the ledger at `path` still extend a
2000/// previously issued `cp`? Re-derives the head over the first `cp.count` records
2001/// from the stored bytes and confirms it equals `cp.root`. If the operator edited,
2002/// reordered, or truncated any entry at or before `count`, the re-derived root
2003/// diverges and this returns `Ok(false)` — caught by anyone holding the old
2004/// checkpoint, without trusting the operator. A well-behaved append-only log always
2005/// extends its past checkpoints.
2006pub fn ledger_extends_checkpoint(path: &Path, cp: &Checkpoint) -> Result<bool, LedgerError> {
2007    ledger_extends_checkpoint_at(&Location::detect(path), cp)
2008}
2009
2010fn ledger_extends_checkpoint_at(location: &Location, cp: &Checkpoint) -> Result<bool, LedgerError> {
2011    Ok(root_at_count(location, cp.count)?.as_deref() == Some(cp.root.as_str()))
2012}
2013
2014/// Persist a checkpoint as the ledger's anchor file, atomically (temp + fsync +
2015/// rename + parent-dir fsync) so a crash cannot leave a half-written or non-durable
2016/// anchor — a lost anchor write would silently lower the height truncation is checked
2017/// against. See [`Ledger::seal_anchor`].
2018pub fn save_anchor(anchor_path: &Path, cp: &Checkpoint) -> Result<(), LedgerError> {
2019    let bytes = serde_json::to_vec_pretty(cp).map_err(|e| LedgerError::Serde(e.to_string()))?;
2020    let tmp = anchor_path.with_extension("anchor-tmp");
2021    {
2022        let mut f = File::create(&tmp).map_err(|e| io_err(&tmp, e))?;
2023        f.write_all(&bytes).map_err(|e| io_err(&tmp, e))?;
2024        f.sync_all().map_err(|e| io_err(&tmp, e))?;
2025    }
2026    std::fs::rename(&tmp, anchor_path).map_err(|e| io_err(anchor_path, e))?;
2027    // fsync the parent dir so the rename (the anchor's new directory entry) is durable.
2028    if let Some(parent) = anchor_path.parent().filter(|p| !p.as_os_str().is_empty())
2029        && let Ok(dir) = File::open(parent)
2030    {
2031        let _ = dir.sync_all();
2032    }
2033    Ok(())
2034}
2035
2036/// Load the persisted anchor, or `None` if no anchor file exists yet. A present but
2037/// unparseable anchor is a hard error (fail-closed — a corrupt anchor is never
2038/// silently treated as "no committed height").
2039pub fn load_anchor(anchor_path: &Path) -> Result<Option<Checkpoint>, LedgerError> {
2040    match std::fs::read(anchor_path) {
2041        Ok(bytes) => Ok(Some(
2042            serde_json::from_slice(&bytes).map_err(|e| LedgerError::Serde(e.to_string()))?,
2043        )),
2044        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
2045        Err(e) => Err(io_err(anchor_path, e)),
2046    }
2047}
2048
2049/// Re-derive the hash-chain head after exactly the first `count` records. Mirrors
2050/// [`verify`]'s per-record hashing (stored bytes + prev), independently, so any
2051/// rewrite below `count` changes the result. `None` if the file holds fewer than
2052/// `count` records (truncated below the checkpoint).
2053fn root_at_count(location: &Location, count: u64) -> Result<Option<String>, LedgerError> {
2054    if count == 0 {
2055        return Ok(Some(GENESIS.to_owned()));
2056    }
2057    let mut prev = GENESIS.to_owned();
2058    let mut seen: u64 = 0;
2059    // Consume the PREFIX (any crash-torn trailing fragment excluded), so the
2060    // anchor/truncation check derives the head over ACKED history only and a
2061    // half-written tail can neither spoof a match nor spuriously error. A prefix
2062    // shorter than `count` still yields `None` — the truncation-below-anchor
2063    // signal, which is exactly how a ragged attacker truncation stays Tamper.
2064    let (lines, _torn) = location.prefix_lines()?;
2065    for line in lines {
2066        let line = line?;
2067        if line.trim().is_empty() {
2068            continue;
2069        }
2070        let record: RecordIn = serde_json::from_str(&line).map_err(|e| LedgerError::Tamper {
2071            seq: seen,
2072            why: format!("unparseable record: {e}"),
2073        })?;
2074        if record.prev != prev {
2075            return Err(LedgerError::Tamper {
2076                seq: seen,
2077                why: "broken chain link (prev mismatch)".into(),
2078            });
2079        }
2080        let hash = hex::encode(chain_hash(record.entry.get().as_bytes(), &record.prev));
2081        if hash != record.hash {
2082            return Err(LedgerError::Tamper {
2083                seq: seen,
2084                why: "entry altered (hash mismatch)".into(),
2085            });
2086        }
2087        prev = record.hash;
2088        seen += 1;
2089        if seen == count {
2090            return Ok(Some(prev));
2091        }
2092    }
2093    Ok(None)
2094}
2095
2096#[cfg(test)]
2097mod tests {
2098    use super::*;
2099    use decern_crypto::Verifier;
2100    use serde_json::json;
2101
2102    fn entry(action: &str, decision: bool) -> Entry {
2103        Entry {
2104            seq: 0, // assigned by append
2105            ts_ms: 1234,
2106            subject_type: "Principal".into(),
2107            subject_id: "agent1".into(),
2108            action: action.into(),
2109            resource_type: "Resource".into(),
2110            resource_id: "claim1".into(),
2111            context: json!({"now": 100}),
2112            decision,
2113            reasons: vec![],
2114            ..Default::default()
2115        }
2116    }
2117
2118    fn tmp(name: &str) -> PathBuf {
2119        let dir = std::env::temp_dir().join(format!("decern-ledger-test-{}", std::process::id()));
2120        std::fs::create_dir_all(&dir).unwrap();
2121        dir.join(name)
2122    }
2123
2124    fn h32(hex_str: &str) -> [u8; 32] {
2125        <[u8; 32]>::try_from(hex::decode(hex_str).unwrap()).unwrap()
2126    }
2127
2128    #[test]
2129    fn merkle_tree_head_and_proofs_verify_against_a_real_ledger() {
2130        let key = decern_crypto::generate().unwrap();
2131        let vk = key.verifying_key();
2132        let path = tmp("merkle-th.ledger");
2133        let _ = std::fs::remove_file(&path);
2134        let mut l = Ledger::open(&path, key.clone()).unwrap();
2135
2136        // Grow to 4 records and anchor an EARLIER tree head.
2137        for i in 0..4 {
2138            l.append(entry(&format!("a{i}"), true)).unwrap();
2139        }
2140        let th4 = l.tree_head(1_000).unwrap();
2141        assert_eq!(th4.tree_size, 4);
2142        assert!(verify_tree_head_sig(&th4, &vk));
2143
2144        // Grow to 7 records; a NEW tree head over the whole log.
2145        for i in 0..3 {
2146            l.append(entry(&format!("b{i}"), false)).unwrap();
2147        }
2148        let th7 = l.tree_head(2_000).unwrap();
2149        assert_eq!(th7.tree_size, 7);
2150        assert!(verify_tree_head_sig(&th7, &vk));
2151
2152        // The tree-head signature is pinned to the ledger key AND domain-separated: a
2153        // wrong key or a tampered root does not verify.
2154        let other = decern_crypto::generate().unwrap().verifying_key();
2155        assert!(
2156            !verify_tree_head_sig(&th7, &other),
2157            "wrong key must not verify"
2158        );
2159        let mut tampered = th7.clone();
2160        tampered.merkle_root = "00".repeat(32);
2161        assert!(
2162            !verify_tree_head_sig(&tampered, &vk),
2163            "tampered root rejected"
2164        );
2165        // A checkpoint signature must NOT cross-verify as a tree head (distinct domain tag).
2166        let cp = l.checkpoint(2_000);
2167        let cross = TreeHead {
2168            merkle_root: cp.root.clone(),
2169            tree_size: cp.count,
2170            ts_ms: cp.ts_ms,
2171            pubkey_hex: cp.pubkey_hex.clone(),
2172            sig_b64: cp.sig_b64.clone(),
2173        };
2174        assert!(
2175            !verify_tree_head_sig(&cross, &vk),
2176            "a checkpoint sig must not verify as a tree head"
2177        );
2178
2179        // Inclusion: every record is provably in the size-7 tree under th7's root.
2180        let root7 = h32(&th7.merkle_root);
2181        for seq in 0..7u64 {
2182            let ip = l.inclusion_proof(seq).unwrap();
2183            assert_eq!(ip.tree_size, 7);
2184            let leaf_hash = merkle::hash_leaf(&hex::decode(&ip.leaf_data).unwrap());
2185            let audit: Vec<[u8; 32]> = ip.audit_path.iter().map(|h| h32(h)).collect();
2186            assert!(
2187                merkle::verify_inclusion(seq, 7, &leaf_hash, &root7, &audit),
2188                "record {seq} must prove included"
2189            );
2190        }
2191        assert!(
2192            l.inclusion_proof(7).is_err(),
2193            "out-of-range inclusion rejected"
2194        );
2195
2196        // Consistency: the anchored size-4 tree is an exact prefix of the size-7 head —
2197        // the operator cannot have rewritten or dropped anything below seq 4.
2198        let root4 = h32(&th4.merkle_root);
2199        let consist = l.consistency_proof(4).unwrap();
2200        assert_eq!((consist.first_size, consist.second_size), (4, 7));
2201        let cpath: Vec<[u8; 32]> = consist.proof.iter().map(|h| h32(h)).collect();
2202        assert!(
2203            merkle::verify_consistency(4, 7, &root4, &root7, &cpath),
2204            "size-4 prefix must reconcile with the size-7 head"
2205        );
2206        // A forged earlier root (equivocation) does NOT reconcile.
2207        let mut forged = root4;
2208        forged[0] ^= 0xFF;
2209        assert!(!merkle::verify_consistency(4, 7, &forged, &root7, &cpath));
2210
2211        let _ = std::fs::remove_file(&path);
2212    }
2213
2214    #[test]
2215    fn evidence_bundle_verifies_offline_and_catches_tamper() {
2216        let key = decern_crypto::generate().unwrap();
2217        let vk = key.verifying_key();
2218        let path = tmp("bundle-verify.ledger");
2219        let _ = std::fs::remove_file(&path);
2220        let mut l = Ledger::open(&path, key.clone()).unwrap();
2221        for i in 0..5 {
2222            l.append(entry(&format!("e{i}"), true)).unwrap();
2223        }
2224        let earlier = l.tree_head(500).unwrap(); // size 5 — an externally-anchored head
2225        for i in 0..3 {
2226            l.append(entry(&format!("f{i}"), false)).unwrap();
2227        }
2228        let count = l.count() as usize; // 8
2229
2230        // Assemble the bundle from VERBATIM record bytes (`RawValue::get()`), exactly as
2231        // the server ships them — routing records through json!/Value would reorder the
2232        // entry keys (no preserve_order here) and break the very hashes we verify.
2233        let make_bundle = |from: usize| -> String {
2234            let recs = l.read_raw_records(from, count - from).unwrap();
2235            let records_arr = format!(
2236                "[{}]",
2237                recs.iter().map(|r| r.get()).collect::<Vec<_>>().join(",")
2238            );
2239            format!(
2240                "{{\"format\":\"decern-evidence-bundle/1\",\"span\":{{\"from\":{from}}},\
2241                 \"checkpoint\":{cp},\"tree_head\":{th},\"records\":{records_arr}}}",
2242                cp = serde_json::to_string(&l.checkpoint(900)).unwrap(),
2243                th = serde_json::to_string(&l.tree_head(900).unwrap()).unwrap(),
2244            )
2245        };
2246
2247        // Full tail: everything verifies, the Merkle root recomputes, and the size-5
2248        // anchored head is a proven prefix of the size-8 head.
2249        let full = make_bundle(0);
2250        let v = verify_evidence_bundle(&full, &[vk], Some(&earlier));
2251        assert!(v.accepted, "full bundle must verify: {:?}", v.errors);
2252        assert_eq!(v.merkle_root_ok, Some(true));
2253        assert_eq!(v.consistency_ok, Some(true));
2254
2255        // Wrong pinned key → rejected (record + checkpoint + tree-head sigs all fail).
2256        let other = decern_crypto::generate().unwrap().verifying_key();
2257        assert!(!verify_evidence_bundle(&full, &[other], None).accepted);
2258
2259        // Tampered record entry → its hash no longer re-derives → rejected.
2260        let tampered = full.replacen("\"e0\"", "\"e0X\"", 1);
2261        let vt = verify_evidence_bundle(&tampered, &[vk], None);
2262        assert!(
2263            !vt.accepted && !vt.chain_ok,
2264            "tamper caught: {:?}",
2265            vt.errors
2266        );
2267
2268        // Partial tail (from=3): the Merkle root can't be recomputed (None) but every other
2269        // check still passes, so the bundle is accepted.
2270        let partial = make_bundle(3);
2271        let vp = verify_evidence_bundle(&partial, &[vk], None);
2272        assert!(vp.accepted, "partial tail verifies: {:?}", vp.errors);
2273        assert_eq!(vp.merkle_root_ok, None);
2274
2275        // Equivocation: an earlier head with a forged (unsigned) root fails consistency.
2276        let mut forged = earlier.clone();
2277        forged.merkle_root = "11".repeat(32);
2278        let vf = verify_evidence_bundle(&full, &[vk], Some(&forged));
2279        assert_eq!(vf.consistency_ok, Some(false));
2280        assert!(!vf.accepted);
2281
2282        let _ = std::fs::remove_file(&path);
2283    }
2284
2285    #[test]
2286    fn edge_type_omitted_when_attenuate_recorded_when_mint() {
2287        // Attenuate (the default) is skipped → existing records are byte-identical,
2288        // so their hashes and signatures are unaffected.
2289        let att = serde_json::to_string(&entry("issue_token", true)).unwrap();
2290        assert!(
2291            !att.contains("\"edge\""),
2292            "attenuate edge must be omitted: {att}"
2293        );
2294
2295        // A mint (trusted-issuer crossing) is recorded explicitly.
2296        let mut e = entry("issue_token", true);
2297        e.edge = EdgeType::Mint;
2298        let mint = serde_json::to_string(&e).unwrap();
2299        assert!(
2300            mint.contains("\"edge\":\"Mint\""),
2301            "mint edge recorded: {mint}"
2302        );
2303
2304        // Both round-trip; a record with no `edge` deserializes as Attenuate.
2305        assert_eq!(
2306            serde_json::from_str::<Entry>(&mint).unwrap().edge,
2307            EdgeType::Mint
2308        );
2309        assert_eq!(
2310            serde_json::from_str::<Entry>(&att).unwrap().edge,
2311            EdgeType::Attenuate
2312        );
2313    }
2314
2315    #[test]
2316    fn append_verify_resume() {
2317        let path = tmp("ok.ledger");
2318        std::fs::remove_file(&path).ok();
2319        let key = decern_crypto::generate().unwrap();
2320
2321        let mut l = Ledger::open(&path, key.clone()).unwrap();
2322        l.append(entry("Read", true)).unwrap();
2323        l.append(entry("MoveMoney", false)).unwrap();
2324        drop(l);
2325
2326        // resume with the same key: chain verifies, seq continues
2327        let mut l = Ledger::open(&path, key.clone()).unwrap();
2328        let rec = l.append(entry("Read", true)).unwrap();
2329        assert_eq!(rec.entry.seq, 2);
2330
2331        let report = verify(&path, Some(&key.verifying_key())).unwrap();
2332        assert_eq!(report.entries, 3);
2333        assert!(report.root.is_some());
2334    }
2335
2336    #[test]
2337    fn sync_enabled_ledger_appends_and_verifies() {
2338        // fsync-per-append must be transparent to correctness: same chain, same verify.
2339        let path = tmp("synced.ledger");
2340        std::fs::remove_file(&path).ok();
2341        let key = decern_crypto::generate().unwrap();
2342        let mut l = Ledger::open(&path, key.clone()).unwrap();
2343        l.set_sync(true);
2344        l.append(entry("Read", true)).unwrap();
2345        l.append(entry("MoveMoney", false)).unwrap();
2346        drop(l);
2347        let report = verify(&path, Some(&key.verifying_key())).unwrap();
2348        assert_eq!(report.entries, 2);
2349        assert!(report.signatures_checked);
2350    }
2351
2352    #[test]
2353    fn read_raw_records_bytes_reproduce_the_stored_hash() {
2354        // The evidence-bundle contract: the VERBATIM bytes read back must let an
2355        // external party recompute each record's hash. `read_records` (parse→Value)
2356        // would reorder keys and break this; `read_raw_records` must not.
2357        let path = tmp("raw.ledger");
2358        std::fs::remove_file(&path).ok();
2359        let key = decern_crypto::generate().unwrap();
2360        let mut l = Ledger::open(&path, key.clone()).unwrap();
2361        // A context with several keys — reserialization would reorder them.
2362        let mut e = entry("Pay", true);
2363        e.context = json!({"z": 1, "a": 2, "m": 3, "amount_minor": 500});
2364        l.append(e).unwrap();
2365        l.append(entry("Read", true)).unwrap();
2366
2367        let raw = l.read_raw_records(0, 100).unwrap();
2368        assert_eq!(raw.len(), 2);
2369
2370        // Recompute the chain exactly as an external verifier would.
2371        #[derive(serde::Deserialize)]
2372        struct Rec {
2373            entry: Box<serde_json::value::RawValue>,
2374            prev: String,
2375            hash: String,
2376        }
2377        let mut prev = GENESIS.to_owned();
2378        for line in &raw {
2379            let r: Rec = serde_json::from_str(line.get()).unwrap();
2380            let got = hex::encode(chain_hash(r.entry.get().as_bytes(), &prev));
2381            assert_eq!(got, r.hash, "verbatim bytes reproduce the stored hash");
2382            assert_eq!(r.prev, prev, "chain link continuous");
2383            prev = r.hash;
2384        }
2385        assert_eq!(prev, *l.root(), "final recomputed hash == head");
2386    }
2387
2388    #[test]
2389    fn read_verified_returns_a_verified_window_and_fails_closed_on_tamper() {
2390        let path = tmp("readv.ledger");
2391        std::fs::remove_file(&path).ok();
2392        let key = decern_crypto::generate().unwrap();
2393        let mut l = Ledger::open(&path, key.clone()).unwrap();
2394        l.append(entry("Read", true)).unwrap();
2395        l.append(entry("MoveMoney", false)).unwrap();
2396        l.append(entry("Read", true)).unwrap();
2397        drop(l);
2398
2399        // Full read with the public key: chain + signatures verified, all 3 records back,
2400        // each carrying its `entry` (with the seq inside), same shape read_records returns.
2401        let (report, recs) = read_verified(&path, Some(&key.verifying_key()), 0, 100).unwrap();
2402        assert_eq!(report.entries, 3);
2403        assert!(report.signatures_checked);
2404        assert_eq!(recs.len(), 3);
2405        assert_eq!(recs[0]["entry"]["seq"], json!(0));
2406        assert_eq!(recs[2]["entry"]["action"], json!("Read"));
2407
2408        // Windowed (offset 1, limit 1): whole chain still verified, only the 2nd record
2409        // materialized.
2410        let (report, recs) = read_verified(&path, None, 1, 1).unwrap();
2411        assert_eq!(report.entries, 3, "whole chain scanned for integrity");
2412        assert!(!report.signatures_checked, "no key → chain-only");
2413        assert_eq!(recs.len(), 1);
2414        assert_eq!(recs[0]["entry"]["seq"], json!(1));
2415        assert_eq!(recs[0]["entry"]["action"], json!("MoveMoney"));
2416
2417        // Tamper: flip the deny to allow without re-chaining → a READ must refuse, never
2418        // hand back the altered record as if it were sound.
2419        let text = std::fs::read_to_string(&path).unwrap();
2420        let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
2421        let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
2422        rec.entry.decision = true;
2423        lines[1] = serde_json::to_string(&rec).unwrap();
2424        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
2425        let err = read_verified(&path, Some(&key.verifying_key()), 0, 100).unwrap_err();
2426        assert!(matches!(err, LedgerError::Tamper { seq: 1, .. }), "{err}");
2427    }
2428
2429    #[test]
2430    fn flipped_decision_detected() {
2431        let path = tmp("tamper.ledger");
2432        std::fs::remove_file(&path).ok();
2433        let key = decern_crypto::generate().unwrap();
2434        let mut l = Ledger::open(&path, key.clone()).unwrap();
2435        l.append(entry("Read", true)).unwrap();
2436        l.append(entry("MoveMoney", false)).unwrap();
2437        l.append(entry("Read", true)).unwrap();
2438        drop(l);
2439
2440        // flip the deny to an allow without re-chaining
2441        let text = std::fs::read_to_string(&path).unwrap();
2442        let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
2443        let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
2444        rec.entry.decision = true;
2445        lines[1] = serde_json::to_string(&rec).unwrap();
2446        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
2447
2448        let err = verify(&path, Some(&key.verifying_key())).unwrap_err();
2449        assert!(matches!(err, LedgerError::Tamper { seq: 1, .. }), "{err}");
2450    }
2451
2452    #[test]
2453    fn rewrite_with_other_key_detected() {
2454        let path = tmp("rewrite.ledger");
2455        std::fs::remove_file(&path).ok();
2456        let honest = decern_crypto::generate().unwrap();
2457        let insider = decern_crypto::generate().unwrap();
2458
2459        // insider fabricates a perfectly self-consistent chain with their own key
2460        let mut l = Ledger::open(&path, insider).unwrap();
2461        l.append(entry("MoveMoney", true)).unwrap();
2462        drop(l);
2463
2464        // chain alone verifies...
2465        assert!(verify(&path, None).is_ok());
2466        // ...but not against the honest ledger key
2467        let err = verify(&path, Some(&honest.verifying_key())).unwrap_err();
2468        assert!(matches!(err, LedgerError::Tamper { .. }));
2469    }
2470
2471    #[test]
2472    fn hostile_float_context_cannot_false_tamper() {
2473        // Regression: serde_json float round-trips are NOT byte-stable
2474        // without the float_roundtrip feature. These exact values used to
2475        // brick an honest ledger. The hash must cover stored bytes, so
2476        // append -> verify -> reopen must all succeed.
2477        let path = tmp("floats.ledger");
2478        std::fs::remove_file(&path).ok();
2479        let key = decern_crypto::generate().unwrap();
2480        let mut l = Ledger::open(&path, key.clone()).unwrap();
2481
2482        for ctx in [
2483            json!({"now": 100, "z": 1.0715660391465826e-75}),
2484            json!({"v": 2.291712365432881e-9}),
2485            json!({"now": 100, "a": 0.1, "b": 1e308, "c": -5.5e-324}),
2486        ] {
2487            let mut e = entry("Read", false);
2488            e.context = ctx;
2489            l.append(e).unwrap();
2490        }
2491        drop(l);
2492
2493        let report = verify(&path, Some(&key.verifying_key())).expect("honest ledger must verify");
2494        assert_eq!(report.entries, 3);
2495        // and it must reopen for new writes
2496        let mut l = Ledger::open(&path, key).expect("honest ledger must reopen");
2497        l.append(entry("Read", true)).unwrap();
2498    }
2499
2500    #[test]
2501    fn corrupt_ledger_refuses_new_writes() {
2502        let path = tmp("refuse.ledger");
2503        std::fs::remove_file(&path).ok();
2504        let key = decern_crypto::generate().unwrap();
2505        let mut l = Ledger::open(&path, key.clone()).unwrap();
2506        l.append(entry("Read", true)).unwrap();
2507        drop(l);
2508
2509        // corrupt it
2510        let text = std::fs::read_to_string(&path).unwrap();
2511        std::fs::write(&path, text.replace("Read", "Raid")).unwrap();
2512
2513        assert!(Ledger::open(&path, key).is_err());
2514    }
2515
2516    #[test]
2517    fn checkpoint_signs_and_verifies() {
2518        let path = tmp("cp-sig.ledger");
2519        std::fs::remove_file(&path).ok();
2520        let key = decern_crypto::generate().unwrap();
2521        let other = decern_crypto::generate().unwrap();
2522        let mut l = Ledger::open(&path, key.clone()).unwrap();
2523        l.append(entry("Read", true)).unwrap();
2524        l.append(entry("MoveMoney", false)).unwrap();
2525
2526        let cp = l.checkpoint(9_999);
2527        assert_eq!(cp.count, 2);
2528        assert_eq!(cp.root, l.root());
2529        assert_eq!(cp.pubkey_hex, l.pubkey_hex());
2530        // the ledger key vouches for the commitment; a different key does not
2531        assert!(verify_checkpoint_sig(&cp, &key.verifying_key()));
2532        assert!(!verify_checkpoint_sig(&cp, &other.verifying_key()));
2533        // and a forged field is rejected (signature covers root+count+ts)
2534        let mut forged = cp.clone();
2535        forged.count = 3;
2536        assert!(!verify_checkpoint_sig(&forged, &key.verifying_key()));
2537    }
2538
2539    #[test]
2540    fn append_only_log_extends_its_own_checkpoint() {
2541        let path = tmp("cp-extend.ledger");
2542        std::fs::remove_file(&path).ok();
2543        let key = decern_crypto::generate().unwrap();
2544        let mut l = Ledger::open(&path, key).unwrap();
2545        l.append(entry("Read", true)).unwrap();
2546        l.append(entry("MoveMoney", false)).unwrap();
2547        let cp = l.checkpoint(1); // an external party holds this
2548        // more activity happens afterwards
2549        l.append(entry("Read", true)).unwrap();
2550        drop(l);
2551        // the honest, append-only log still extends the held checkpoint
2552        assert!(ledger_extends_checkpoint(&path, &cp).unwrap());
2553    }
2554
2555    #[test]
2556    fn rewrite_below_a_held_checkpoint_is_caught() {
2557        let path = tmp("cp-rewrite.ledger");
2558        std::fs::remove_file(&path).ok();
2559        let key = decern_crypto::generate().unwrap();
2560        let mut l = Ledger::open(&path, key).unwrap();
2561        l.append(entry("Read", true)).unwrap();
2562        l.append(entry("MoveMoney", false)).unwrap();
2563        l.append(entry("Read", true)).unwrap();
2564        let cp = l.checkpoint(1);
2565        drop(l);
2566
2567        // operator flips the recorded deny to an allow (without re-chaining)
2568        let text = std::fs::read_to_string(&path).unwrap();
2569        let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
2570        let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
2571        rec.entry.decision = true;
2572        lines[1] = serde_json::to_string(&rec).unwrap();
2573        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
2574
2575        // the external holder's re-derivation refuses to confirm the checkpoint
2576        let r = ledger_extends_checkpoint(&path, &cp);
2577        assert!(
2578            matches!(r, Err(LedgerError::Tamper { .. })) || matches!(r, Ok(false)),
2579            "rewrite below a checkpoint must break extension: {r:?}"
2580        );
2581    }
2582
2583    #[test]
2584    fn truncation_below_a_held_checkpoint_is_caught() {
2585        let path = tmp("cp-truncate.ledger");
2586        std::fs::remove_file(&path).ok();
2587        let key = decern_crypto::generate().unwrap();
2588        let mut l = Ledger::open(&path, key).unwrap();
2589        l.append(entry("Read", true)).unwrap();
2590        l.append(entry("MoveMoney", false)).unwrap();
2591        l.append(entry("Read", true)).unwrap();
2592        let cp = l.checkpoint(1); // commits to 3 entries
2593        drop(l);
2594
2595        // operator drops the last recorded entry
2596        let text = std::fs::read_to_string(&path).unwrap();
2597        let kept: Vec<&str> = text.lines().take(2).collect();
2598        std::fs::write(&path, kept.join("\n") + "\n").unwrap();
2599
2600        // fewer records than the checkpoint committed to → does not extend
2601        assert!(!ledger_extends_checkpoint(&path, &cp).unwrap());
2602    }
2603
2604    #[test]
2605    fn decision_entry_serialization_is_stable() {
2606        // Golden serialization + chain hash for a plain Decision entry. The chain
2607        // commits to these exact bytes, so if field order or naming drifts, every
2608        // existing ledger stops verifying.
2609        let js = serde_json::to_string(&entry("Read", true)).unwrap();
2610        assert_eq!(
2611            js,
2612            r#"{"seq":0,"ts_ms":1234,"subject_type":"Principal","subject_id":"agent1","action":"Read","resource_type":"Resource","resource_id":"claim1","context":{"now":100},"decision":true,"reasons":[]}"#,
2613            "Decision entry serialization drifted from the golden value"
2614        );
2615        assert_eq!(
2616            hex::encode(chain_hash(js.as_bytes(), GENESIS)),
2617            "7b53ca2b3294bd92166dc254d1b56ee12a2a95b76807c08867147729405f8194",
2618            "Decision entry chain hash drifted from the golden value"
2619        );
2620    }
2621
2622    // ------------------------------- key rotation -------------------------------
2623
2624    #[test]
2625    fn rotation_keeps_the_whole_chain_verifiable() {
2626        // The core: rotating the signing key must NOT brick a long-lived log.
2627        let path = tmp("rotate.ledger");
2628        std::fs::remove_file(&path).ok();
2629        let old = decern_crypto::generate().unwrap();
2630        let new = decern_crypto::generate().unwrap();
2631
2632        let mut l = Ledger::open(&path, old.clone()).unwrap();
2633        l.append(entry("Read", true)).unwrap(); // signed by old
2634        l.append(entry("Write", true)).unwrap(); // signed by old
2635        l.rotate(new.clone());
2636        l.append(entry("MoveMoney", false)).unwrap(); // signed by new
2637        drop(l);
2638
2639        // The whole chain verifies under the keyring (old + new)...
2640        let report = verify_with_keys(&path, &[old.verifying_key(), new.verifying_key()]).unwrap();
2641        assert_eq!(report.entries, 3);
2642        assert!(report.signatures_checked);
2643
2644        // ...but NOT under either key alone: the pre-rotation records need `old`, the
2645        // post-rotation record needs `new`.
2646        assert!(
2647            verify(&path, Some(&old.verifying_key())).is_err(),
2648            "old key alone cannot verify the post-rotation tail"
2649        );
2650        assert!(
2651            verify(&path, Some(&new.verifying_key())).is_err(),
2652            "new key alone cannot verify the pre-rotation head"
2653        );
2654    }
2655
2656    #[test]
2657    fn rotated_ledger_reopens_with_retired_verifiers() {
2658        let path = tmp("rotate-reopen.ledger");
2659        std::fs::remove_file(&path).ok();
2660        let old = decern_crypto::generate().unwrap();
2661        let new = decern_crypto::generate().unwrap();
2662
2663        let mut l = Ledger::open(&path, old.clone()).unwrap();
2664        l.append(entry("Read", true)).unwrap();
2665        l.rotate(new.clone());
2666        l.append(entry("Write", true)).unwrap();
2667        drop(l);
2668
2669        // Reopening with the CURRENT key alone fails (the head was signed by `old`)...
2670        assert!(
2671            Ledger::open(&path, new.clone()).is_err(),
2672            "reopen without the retired key must fail on the old-signed head"
2673        );
2674        // ...but succeeds when the retired public key is supplied, and can append more.
2675        let mut l =
2676            Ledger::open_with_verifiers(&path, new.clone(), vec![old.verifying_key()]).unwrap();
2677        assert_eq!(l.count(), 2);
2678        l.append(entry("MoveMoney", true)).unwrap(); // signed by new, seq 2
2679        assert!(l.self_verify().is_ok());
2680        // the keyring an auditor pins spans both keys
2681        let fps = l.verifier_fingerprints();
2682        assert!(fps.contains(&key_fingerprint(&old.verifying_key())));
2683        assert!(fps.contains(&key_fingerprint(&new.verifying_key())));
2684    }
2685
2686    #[test]
2687    fn a_record_signed_by_an_untrusted_key_is_tamper() {
2688        // A record whose kid names a key NOT in the ring must be rejected — an
2689        // auditor cannot be fooled into trusting a signer of the attacker's choosing.
2690        let path = tmp("rotate-untrusted.ledger");
2691        std::fs::remove_file(&path).ok();
2692        let honest = decern_crypto::generate().unwrap();
2693        let attacker = decern_crypto::generate().unwrap();
2694
2695        let mut l = Ledger::open(&path, honest.clone()).unwrap();
2696        l.append(entry("Read", true)).unwrap(); // kid = honest fingerprint
2697        drop(l);
2698
2699        // A ring that does NOT contain the record's signing key: its kid names a key
2700        // not in the ring → tamper (fail-closed on an unknown signer), NOT skipped.
2701        let err = verify_with_keys(&path, &[attacker.verifying_key()]).unwrap_err();
2702        assert!(
2703            matches!(err, LedgerError::Tamper { .. }),
2704            "a record whose kid is not in the ring must be tamper: {err}"
2705        );
2706        // The honest ring verifies fine.
2707        assert!(verify_with_keys(&path, &[honest.verifying_key()]).is_ok());
2708    }
2709
2710    // ------------------------------ persisted anchor ------------------------------
2711
2712    #[test]
2713    fn anchor_catches_truncation_across_a_reopen() {
2714        // The core: a plain reopen accepts a truncated (still internally
2715        // consistent) log; the persisted anchor makes the truncation fail-closed.
2716        let path = tmp("anchor-truncate.ledger");
2717        let anchor = tmp("anchor-truncate.anchor");
2718        std::fs::remove_file(&path).ok();
2719        std::fs::remove_file(&anchor).ok();
2720        let key = decern_crypto::generate().unwrap();
2721
2722        let mut l = Ledger::open(&path, key.clone()).unwrap();
2723        l.append(entry("Read", true)).unwrap();
2724        l.append(entry("Write", true)).unwrap();
2725        l.append(entry("MoveMoney", false)).unwrap();
2726        l.seal_anchor(&anchor, 1).unwrap(); // committed height = 3
2727        drop(l);
2728
2729        // an insider truncates the last recorded decision
2730        let text = std::fs::read_to_string(&path).unwrap();
2731        let kept: Vec<&str> = text.lines().take(2).collect();
2732        std::fs::write(&path, kept.join("\n") + "\n").unwrap();
2733
2734        // a plain reopen is fooled — the 2-record log is internally consistent...
2735        assert!(
2736            Ledger::open(&path, key.clone()).is_ok(),
2737            "plain reopen cannot see the truncation"
2738        );
2739        // ...but open_anchored refuses: the log no longer extends its committed height.
2740        let err = match Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor) {
2741            Ok(_) => panic!("open_anchored must catch the truncation"),
2742            Err(e) => e,
2743        };
2744        assert!(
2745            matches!(err, LedgerError::Tamper { .. }),
2746            "anchor must catch the truncation: {err}"
2747        );
2748    }
2749
2750    #[test]
2751    fn anchor_accepts_a_legit_append_only_extension() {
2752        let path = tmp("anchor-extend.ledger");
2753        let anchor = tmp("anchor-extend.anchor");
2754        std::fs::remove_file(&path).ok();
2755        std::fs::remove_file(&anchor).ok();
2756        let key = decern_crypto::generate().unwrap();
2757
2758        let mut l = Ledger::open(&path, key.clone()).unwrap();
2759        l.append(entry("Read", true)).unwrap();
2760        l.seal_anchor(&anchor, 1).unwrap(); // committed height = 1
2761        l.append(entry("Write", true)).unwrap(); // honest append-only growth
2762        drop(l);
2763
2764        // reopening against the anchor succeeds — the log still extends height 1
2765        let l = Ledger::open_anchored(&path, key, Vec::new(), &anchor).unwrap();
2766        assert_eq!(l.count(), 2);
2767        // no anchor file at all is also fine (nothing committed yet)
2768        let missing = tmp("anchor-missing.anchor");
2769        std::fs::remove_file(&missing).ok();
2770        assert!(l.verify_against_anchor(&missing).is_ok());
2771    }
2772
2773    /// The Ed25519 identity point is a valid encoding of a key of order 1. Under the
2774    /// cofactorless verification equation, the signature `R = identity, S = 0` satisfies
2775    /// it for EVERY message — so an operator who hands an auditor this public key can
2776    /// hand them any log at all and have every record "verify". RFC 8032 §5.1.7 permits
2777    /// the cofactorless check; rejecting a small-order key is what makes verification
2778    /// mean something to a party who did not write the log.
2779    fn small_order_key_and_universal_forgery() -> (VerifyingKey, decern_crypto::Signature) {
2780        let mut identity = [0u8; 32];
2781        identity[0] = 1;
2782        let mut sig_bytes = [0u8; 64];
2783        sig_bytes[0] = 1; // R = identity, S = 0
2784        (
2785            VerifyingKey::from_bytes(&identity).expect("identity is a valid encoding"),
2786            decern_crypto::Signature::from_bytes(&sig_bytes),
2787        )
2788    }
2789
2790    #[test]
2791    fn a_small_order_key_cannot_verify_a_signature_it_never_made() {
2792        let (key, forgery) = small_order_key_and_universal_forgery();
2793        // The premise: this pair really does satisfy the permissive equation.
2794        assert!(
2795            key.verify(b"any message at all", &forgery).is_ok(),
2796            "the forgery must pass the cofactorless check, or this test proves nothing"
2797        );
2798        for msg in [&b"any message at all"[..], b"a fabricated record"] {
2799            assert!(
2800                key.verify_strict(msg, &forgery).is_err(),
2801                "a small-order key must not verify {}",
2802                String::from_utf8_lossy(msg)
2803            );
2804        }
2805    }
2806
2807    #[test]
2808    fn a_fabricated_anchor_under_a_small_order_key_is_refused() {
2809        let (key, forgery) = small_order_key_and_universal_forgery();
2810        let cp = Checkpoint {
2811            root: "00".repeat(32),
2812            count: 9999,
2813            ts_ms: 1,
2814            pubkey_hex: hex::encode(key.to_bytes()),
2815            sig_b64: B64.encode(forgery.to_bytes()),
2816        };
2817        assert!(
2818            !verify_checkpoint_sig(&cp, &key),
2819            "an anchor over a height that was never reached must not verify"
2820        );
2821        let th = TreeHead {
2822            merkle_root: "00".repeat(32),
2823            tree_size: 9999,
2824            ts_ms: 1,
2825            pubkey_hex: hex::encode(key.to_bytes()),
2826            sig_b64: B64.encode(forgery.to_bytes()),
2827        };
2828        assert!(!verify_tree_head_sig(&th, &key));
2829    }
2830
2831    #[test]
2832    fn a_forged_anchor_from_an_untrusted_key_is_refused() {
2833        // An attacker who truncates the log and drops a self-signed anchor at the
2834        // lower height must not be able to mask the truncation — the anchor's own
2835        // signature must come from a trusted ledger key.
2836        let path = tmp("anchor-forged.ledger");
2837        let anchor = tmp("anchor-forged.anchor");
2838        std::fs::remove_file(&path).ok();
2839        std::fs::remove_file(&anchor).ok();
2840        let key = decern_crypto::generate().unwrap();
2841        let attacker = decern_crypto::generate().unwrap();
2842
2843        let mut l = Ledger::open(&path, key.clone()).unwrap();
2844        l.append(entry("Read", true)).unwrap();
2845        l.append(entry("Write", true)).unwrap();
2846        let honest = l.checkpoint(9); // correct (root, count) for the current head
2847        drop(l);
2848
2849        // The attacker fabricates an anchor over the SAME (root, count) — so it still
2850        // "extends" the log — but signs it with their OWN key (the honest ledger's
2851        // records are kid-bound to `key`, so the attacker can't even open it).
2852        let sig = attacker.sign(&checkpoint_bytes(&honest.root, honest.count, honest.ts_ms));
2853        let forged = Checkpoint {
2854            root: honest.root,
2855            count: honest.count,
2856            ts_ms: honest.ts_ms,
2857            pubkey_hex: hex::encode(attacker.verifying_key().to_bytes()),
2858            sig_b64: B64.encode(sig.to_bytes()),
2859        };
2860        save_anchor(&anchor, &forged).unwrap();
2861
2862        // the honest ledger refuses the anchor: its signer is not in the keyring
2863        let l = Ledger::open(&path, key).unwrap();
2864        let err = l.verify_against_anchor(&anchor).unwrap_err();
2865        assert!(
2866            matches!(err, LedgerError::Tamper { .. }),
2867            "a forged anchor must be refused: {err}"
2868        );
2869    }
2870
2871    #[test]
2872    fn legacy_kidless_records_verify_against_the_ring() {
2873        // A record written before rotation support has no `kid`. It must still verify
2874        // against the trusted key (the try-each-key fallback), so upgrading the code
2875        // does not brick pre-existing logs.
2876        let path = tmp("legacy-kidless.ledger");
2877        std::fs::remove_file(&path).ok();
2878        let key = decern_crypto::generate().unwrap();
2879
2880        // Append normally, then strip the `kid` field to simulate a legacy line.
2881        // Do it by byte surgery on the trailing `,"kid":"<hex>"` — parsing to Value
2882        // and re-serializing would reorder the `entry` bytes and break the hash
2883        // (which is exactly why the stored bytes, not a reparse, are the proof).
2884        let mut l = Ledger::open(&path, key.clone()).unwrap();
2885        l.append(entry("Read", true)).unwrap();
2886        drop(l);
2887        let kid = key_fingerprint(&key.verifying_key());
2888        let text = std::fs::read_to_string(&path).unwrap();
2889        let stripped = text.trim_end().replace(&format!(",\"kid\":\"{kid}\""), "");
2890        assert!(!stripped.contains("kid"), "kid removed: {stripped}");
2891        std::fs::write(&path, stripped + "\n").unwrap();
2892
2893        // No kid on the record → verified against any trusted key in the ring.
2894        assert!(verify(&path, Some(&key.verifying_key())).is_ok());
2895        assert!(verify_with_keys(&path, &[key.verifying_key()]).is_ok());
2896    }
2897
2898    // ===================== per-epoch/size segmentation =====================
2899
2900    fn tmp_dir(name: &str) -> PathBuf {
2901        let d = tmp(name);
2902        std::fs::remove_dir_all(&d).ok();
2903        d
2904    }
2905
2906    #[test]
2907    fn open_segmented_creates_a_directory_with_one_active_segment_and_a_manifest() {
2908        let dir = tmp_dir("seg-init");
2909        let key = decern_crypto::generate().unwrap();
2910        let _l = Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()).unwrap();
2911        assert!(dir.join("manifest.json").exists());
2912        assert!(dir.join("00000001.jsonl").exists());
2913    }
2914
2915    #[test]
2916    fn segmented_append_and_reopen_preserves_root_and_count() {
2917        let dir = tmp_dir("seg-reopen");
2918        let key = decern_crypto::generate().unwrap();
2919        {
2920            let mut l =
2921                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
2922                    .unwrap();
2923            for i in 0..5 {
2924                l.append(entry(&format!("act{i}"), true)).unwrap();
2925            }
2926        }
2927        let l2 = Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()).unwrap();
2928        assert_eq!(l2.count(), 5);
2929        let recs = l2.read_records(0, 10).unwrap();
2930        assert_eq!(recs.len(), 5);
2931    }
2932
2933    #[test]
2934    fn segmented_size_rollover_creates_a_second_segment_and_chain_still_verifies() {
2935        let dir = tmp_dir("seg-size-rollover");
2936        let key = decern_crypto::generate().unwrap();
2937        let vk = key.verifying_key();
2938        let mut l = Ledger::open_segmented(
2939            &dir,
2940            key,
2941            Vec::new(),
2942            RolloverPolicy::max_bytes(200), // small enough that a few entries force rollover
2943        )
2944        .unwrap();
2945        for i in 0..8 {
2946            l.append(entry(&format!("action-{i}"), true)).unwrap();
2947        }
2948        drop(l);
2949
2950        let segs: Vec<_> = std::fs::read_dir(&dir)
2951            .unwrap()
2952            .filter_map(|e| e.ok())
2953            .filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
2954            .collect();
2955        assert!(
2956            segs.len() >= 2,
2957            "expected rollover to produce multiple segments, got {}",
2958            segs.len()
2959        );
2960
2961        // The whole segmented directory verifies end-to-end via the SAME free
2962        // function a single file would use — auto-detected via is_dir().
2963        let report = verify_with_keys(&dir, &[vk]).unwrap();
2964        assert_eq!(report.entries, 8);
2965
2966        // Every seq is present exactly once, in order, across the boundary.
2967        let recs = read_verified(&dir, None, 0, 100).unwrap().1;
2968        let seqs: Vec<u64> = recs
2969            .iter()
2970            .map(|r| r["entry"]["seq"].as_u64().unwrap())
2971            .collect();
2972        assert_eq!(seqs, (0..8).collect::<Vec<_>>());
2973    }
2974
2975    #[test]
2976    fn segmented_epoch_rollover_triggers_on_a_bucket_change() {
2977        let dir = tmp_dir("seg-epoch-rollover");
2978        let key = decern_crypto::generate().unwrap();
2979        let mut l =
2980            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(1000)).unwrap();
2981        let mut e0 = entry("a", true);
2982        e0.ts_ms = 500; // bucket 0
2983        l.append(e0).unwrap();
2984        let mut e1 = entry("b", true);
2985        e1.ts_ms = 1500; // bucket 1 — crosses the boundary, must roll over
2986        l.append(e1).unwrap();
2987        drop(l);
2988
2989        let segs: Vec<_> = std::fs::read_dir(&dir)
2990            .unwrap()
2991            .filter_map(|e| e.ok())
2992            .filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
2993            .collect();
2994        assert_eq!(segs.len(), 2, "epoch bucket change must trigger a rollover");
2995    }
2996
2997    #[cfg(unix)]
2998    #[test]
2999    fn segmented_sealed_segment_is_chmod_read_only() {
3000        use std::os::unix::fs::PermissionsExt;
3001        let dir = tmp_dir("seg-chmod");
3002        let key = decern_crypto::generate().unwrap();
3003        let mut l =
3004            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(150)).unwrap();
3005        for i in 0..8 {
3006            l.append(entry(&format!("action-{i}"), true)).unwrap();
3007        }
3008        drop(l);
3009        let sealed = dir.join("00000001.jsonl");
3010        let mode = std::fs::metadata(&sealed).unwrap().permissions().mode() & 0o777;
3011        assert_eq!(mode, 0o444, "sealed segment must be read-only");
3012    }
3013
3014    #[test]
3015    fn segmented_read_records_offset_limit_spans_a_segment_boundary_and_matches_single_file() {
3016        // offset/limit must be a GLOBAL record index across segments, not
3017        // re-applied per file. Build the SAME 5 entries
3018        // into a single-file ledger and a 2-segment ledger (rollover forced
3019        // after the 2nd entry) and assert every windowed read matches exactly.
3020        let single_path = tmp("seg-cmp-single.log");
3021        std::fs::remove_file(&single_path).ok();
3022        let seg_dir = tmp_dir("seg-cmp-segmented");
3023        let key = decern_crypto::generate().unwrap();
3024
3025        let mut single = Ledger::open(&single_path, key.clone()).unwrap();
3026        let mut segmented =
3027            Ledger::open_segmented(&seg_dir, key, Vec::new(), RolloverPolicy::max_bytes(1))
3028                .unwrap(); // 1 byte: rolls over before EVERY append after the first
3029
3030        for i in 0..5 {
3031            let e = entry(&format!("act{i}"), true);
3032            single.append(e.clone()).unwrap();
3033            segmented.append(e).unwrap();
3034        }
3035
3036        // Confirm the rollover actually produced multiple segments (else this
3037        // test would trivially pass without exercising the boundary at all).
3038        let segs = std::fs::read_dir(&seg_dir)
3039            .unwrap()
3040            .filter(|e| {
3041                e.as_ref()
3042                    .unwrap()
3043                    .file_name()
3044                    .to_string_lossy()
3045                    .ends_with(".jsonl")
3046            })
3047            .count();
3048        assert!(segs >= 3, "expected several segments, got {segs}");
3049
3050        for (offset, limit) in [(0usize, 5usize), (1, 3), (2, 2), (3, 100), (4, 1), (0, 1)] {
3051            let a = single.read_records(offset, limit).unwrap();
3052            let b = segmented.read_records(offset, limit).unwrap();
3053            assert_eq!(a, b, "read_records({offset}, {limit}) mismatch");
3054
3055            let ar = single.read_raw_records(offset, limit).unwrap();
3056            let br = segmented.read_raw_records(offset, limit).unwrap();
3057            let a_strs: Vec<&str> = ar.iter().map(|v| v.get()).collect();
3058            let b_strs: Vec<&str> = br.iter().map(|v| v.get()).collect();
3059            assert_eq!(
3060                a_strs, b_strs,
3061                "read_raw_records({offset}, {limit}) verbatim-byte mismatch"
3062            );
3063        }
3064    }
3065
3066    #[test]
3067    fn segmented_verify_fails_closed_when_a_manifest_listed_segment_is_missing() {
3068        let dir = tmp_dir("seg-missing-segment");
3069        let key = decern_crypto::generate().unwrap();
3070        let mut l = Ledger::open_segmented(
3071            &dir,
3072            key.clone(),
3073            Vec::new(),
3074            RolloverPolicy::max_bytes(150),
3075        )
3076        .unwrap();
3077        for i in 0..8 {
3078            l.append(entry(&format!("action-{i}"), true)).unwrap();
3079        }
3080        drop(l);
3081
3082        // Delete a SEALED segment's bytes but leave the manifest still naming
3083        // it — the mid-log analogue of a tail truncation. The manifest is
3084        // untrusted metadata; only the actual bytes are load-bearing.
3085        std::fs::remove_file(dir.join("00000001.jsonl")).unwrap();
3086
3087        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3088            Err(e) => e,
3089            Ok(_) => panic!("expected open_segmented to fail on a missing segment"),
3090        };
3091        assert!(
3092            matches!(err, LedgerError::Tamper { .. }),
3093            "expected Tamper, got {err:?}"
3094        );
3095    }
3096
3097    #[test]
3098    fn segmented_anchor_catches_a_truncation_that_deletes_a_whole_sealed_segment() {
3099        // Parity with the single-file case: the chain walk ALONE cannot
3100        // distinguish "the log always had 3 records" from "the log had 5 and
3101        // lost the last 2 (plus their segment + manifest entry)" — that is
3102        // exactly what an externally-persisted anchor exists to catch.
3103        let dir = tmp_dir("seg-anchor-truncation");
3104        let anchor_path = tmp("seg-anchor-truncation.anchor");
3105        std::fs::remove_file(&anchor_path).ok();
3106        let key = decern_crypto::generate().unwrap();
3107
3108        let mut l = Ledger::open_segmented(
3109            &dir,
3110            key.clone(),
3111            Vec::new(),
3112            RolloverPolicy::max_bytes(150),
3113        )
3114        .unwrap();
3115        for i in 0..8 {
3116            l.append(entry(&format!("action-{i}"), true)).unwrap();
3117        }
3118        l.seal_anchor(&anchor_path, 999).unwrap();
3119        drop(l);
3120
3121        // Attacker deletes the newest (active) segment's file AND its
3122        // manifest entry, then re-marks the new tail as active so the
3123        // manifest stays SHAPE-VALID (exactly one active segment) — the full,
3124        // sophisticated attacker capability, not just "delete a file and
3125        // leave a dangling reference" (that simpler case is covered by
3126        // `segmented_verify_fails_closed_when_a_manifest_listed_segment_is_missing`
3127        // and is caught earlier, by `segment_paths` itself).
3128        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3129        let last = manifest.segments.pop().unwrap();
3130        if let Some(new_last) = manifest.segments.last_mut() {
3131            new_last.end_seq = None;
3132        }
3133        segment::save_manifest(&dir, &manifest).unwrap();
3134        std::fs::remove_file(dir.join(&last.file)).unwrap();
3135
3136        let err = match Ledger::open_segmented_anchored(
3137            &dir,
3138            key,
3139            Vec::new(),
3140            RolloverPolicy::default(),
3141            &anchor_path,
3142        ) {
3143            Err(e) => e,
3144            Ok(_) => panic!("expected open_segmented_anchored to fail: anchor no longer extended"),
3145        };
3146        assert!(
3147            matches!(err, LedgerError::Tamper { .. }),
3148            "expected Tamper (anchor no longer extended), got {err:?}"
3149        );
3150    }
3151
3152    #[test]
3153    fn open_on_a_segmented_directory_returns_a_clear_error_not_a_raw_os_error() {
3154        let dir = tmp_dir("seg-vs-open");
3155        let key = decern_crypto::generate().unwrap();
3156        let _l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3157            .unwrap();
3158        let err = match Ledger::open(&dir, key) {
3159            Err(e) => e,
3160            Ok(_) => panic!("expected Ledger::open to refuse a segmented directory"),
3161        };
3162        match err {
3163            LedgerError::Io { err, .. } => assert!(
3164                err.contains("open_segmented"),
3165                "expected a clear pointer to open_segmented, got: {err}"
3166            ),
3167            other => panic!("expected LedgerError::Io, got {other:?}"),
3168        }
3169    }
3170
3171    #[test]
3172    fn open_segmented_ignores_an_orphan_segment_left_by_a_crashed_rollover() {
3173        let dir = tmp_dir("seg-orphan");
3174        let key = decern_crypto::generate().unwrap();
3175        {
3176            let mut l = Ledger::open_segmented(
3177                &dir,
3178                key.clone(),
3179                Vec::new(),
3180                RolloverPolicy::default(), // never auto-rolls; we simulate the crash by hand
3181            )
3182            .unwrap();
3183            l.append(entry("a", true)).unwrap();
3184        }
3185        // Simulate a rollover that created the new segment file but crashed
3186        // before the manifest commit: an extra, higher-numbered file the
3187        // manifest does not (yet) know about.
3188        std::fs::write(dir.join("00000002.jsonl"), b"").unwrap();
3189
3190        let mut l =
3191            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3192        assert_eq!(l.count(), 1, "the orphan must not be silently adopted");
3193        // The very next append forces a rollover (max_bytes=1); it must pick
3194        // an index that does NOT collide with the orphan.
3195        l.append(entry("b", true)).unwrap();
3196        assert!(
3197            dir.join("00000003.jsonl").exists(),
3198            "rollover must skip past the orphan's index, not overwrite it"
3199        );
3200        let orphan_still_empty = std::fs::metadata(dir.join("00000002.jsonl")).unwrap().len();
3201        assert_eq!(
3202            orphan_still_empty, 0,
3203            "orphan must never be silently reused/overwritten"
3204        );
3205    }
3206
3207    // ===== segmented-ledger hardening: rollover filenames =====
3208
3209    #[test]
3210    fn roll_over_ignores_a_planted_out_of_range_filename_instead_of_overflowing() {
3211        // A file named exactly u32::MAX (10 digits) would overflow the naive
3212        // `max_index + 1` in roll_over if it were adopted as a real segment
3213        // index. validate_segment_filename's 8-digit shape requirement makes
3214        // max_index's directory scan treat it as "not a segment file" at
3215        // all (the same as it already treats manifest.json itself) — so
3216        // rollover proceeds completely unaffected by the decoy, rather than
3217        // erroring OR overflowing.
3218        let dir = tmp_dir("seg-overflow-guard");
3219        let key = decern_crypto::generate().unwrap();
3220        let mut l =
3221            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3222        l.append(entry("a", true)).unwrap();
3223        std::fs::write(dir.join("4294967295.jsonl"), b"").unwrap();
3224
3225        l.append(entry("b", true))
3226            .expect("the decoy filename must not block a normal rollover");
3227        assert!(
3228            dir.join("00000002.jsonl").exists(),
3229            "rollover must proceed to the next real index, unaffected by the decoy"
3230        );
3231        assert!(
3232            !dir.join("00000000.jsonl").exists(),
3233            "must never silently wrap to and create index 0"
3234        );
3235    }
3236
3237    #[test]
3238    fn open_segmented_rejects_a_manifest_that_names_an_out_of_range_segment_filename() {
3239        // Unlike a decoy file merely sitting in the directory (ignored, see
3240        // the sibling test above), a MANIFEST entry naming an out-of-range
3241        // filename must be rejected outright — load_manifest validates every
3242        // segment's `file` field unconditionally, the moment the manifest is
3243        // read, before anything downstream (segment_paths, the active-segment
3244        // pick) can act on it.
3245        let dir = tmp_dir("seg-overflow-manifest");
3246        let key = decern_crypto::generate().unwrap();
3247        {
3248            let mut l =
3249                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3250                    .unwrap();
3251            l.append(entry("a", true)).unwrap();
3252        }
3253        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3254        manifest.segments[0].file = "4294967295.jsonl".into();
3255        // Bypass save_manifest's own (correct) behavior of writing whatever
3256        // it's given — write the tampered manifest directly so this test
3257        // exercises `load_manifest`'s READ-time validation, not a write-path
3258        // check that doesn't exist and shouldn't need to.
3259        std::fs::write(
3260            dir.join("manifest.json"),
3261            serde_json::to_vec_pretty(&manifest).unwrap(),
3262        )
3263        .unwrap();
3264
3265        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3266            Err(e) => e,
3267            Ok(_) => panic!("expected an out-of-range manifest filename to be rejected"),
3268        };
3269        assert!(
3270            matches!(err, LedgerError::Tamper { .. }),
3271            "expected Tamper (invalid segment filename), got {err:?}"
3272        );
3273    }
3274
3275    #[test]
3276    fn open_segmented_rejects_a_manifest_with_two_active_segments() {
3277        let dir = tmp_dir("seg-dual-active");
3278        let key = decern_crypto::generate().unwrap();
3279        {
3280            let mut l =
3281                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3282                    .unwrap();
3283            for i in 0..3 {
3284                l.append(entry(&format!("a{i}"), true)).unwrap();
3285            }
3286        }
3287        // Clear end_seq on an already-sealed, non-tail segment — a manifest
3288        // edit alone, no signing key, no byte-level tamper — producing a
3289        // shape-invalid "dual active" manifest.
3290        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3291        manifest.segments[0].end_seq = None;
3292        segment::save_manifest(&dir, &manifest).unwrap();
3293
3294        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3295            Err(e) => e,
3296            Ok(_) => panic!("expected a dual-active manifest to be rejected"),
3297        };
3298        assert!(
3299            matches!(err, LedgerError::Tamper { .. }),
3300            "expected Tamper (dual active segment), got {err:?}"
3301        );
3302    }
3303
3304    #[test]
3305    fn open_segmented_rejects_a_manifest_whose_active_segment_is_not_the_last_entry() {
3306        let dir = tmp_dir("seg-active-not-tail");
3307        let key = decern_crypto::generate().unwrap();
3308        {
3309            let mut l =
3310                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3311                    .unwrap();
3312            for i in 0..3 {
3313                l.append(entry(&format!("a{i}"), true)).unwrap();
3314            }
3315        }
3316        // Swap which segment is "active" WITHOUT changing which one is last —
3317        // mark the true tail sealed and an earlier one active, still exactly
3318        // one active segment overall (so the dual-active check alone would
3319        // not catch this), but not the last entry.
3320        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3321        let last_end = manifest.segments.last().unwrap().end_seq;
3322        manifest.segments[0].end_seq = None;
3323        manifest.segments.last_mut().unwrap().end_seq = last_end.or(Some(3));
3324        segment::save_manifest(&dir, &manifest).unwrap();
3325
3326        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3327            Err(e) => e,
3328            Ok(_) => panic!("expected a non-tail-active manifest to be rejected"),
3329        };
3330        assert!(
3331            matches!(err, LedgerError::Tamper { .. }),
3332            "expected Tamper (active segment not last), got {err:?}"
3333        );
3334    }
3335
3336    #[test]
3337    fn segment_paths_rejects_a_path_traversal_filename_in_the_manifest() {
3338        // A manifest entry's `file` field must be validated BEFORE it is ever
3339        // joined onto the segment directory — otherwise an unverified read
3340        // (read_records/read_raw_records, which the admin ledger browser and
3341        // the evidence-bundle endpoint both call) could be redirected to
3342        // return the content of an arbitrary file outside the ledger dir.
3343        let dir = tmp_dir("seg-traversal");
3344        let outside = tmp("seg-traversal-secret.jsonl");
3345        std::fs::write(&outside, b"{\"leaked\":true}\n").unwrap();
3346        let key = decern_crypto::generate().unwrap();
3347        {
3348            let mut l =
3349                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3350                    .unwrap();
3351            l.append(entry("a", true)).unwrap();
3352        }
3353        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3354        manifest.segments[0].file = "../seg-traversal-secret.jsonl".into();
3355        segment::save_manifest(&dir, &manifest).unwrap();
3356
3357        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3358            Err(e) => e,
3359            Ok(_) => panic!("expected a path-traversal filename to be rejected"),
3360        };
3361        assert!(
3362            matches!(err, LedgerError::Tamper { .. }),
3363            "expected Tamper (invalid segment filename), got {err:?}"
3364        );
3365    }
3366
3367    #[test]
3368    fn fresh_epoch_only_ledger_does_not_waste_an_empty_first_segment() {
3369        // segment::initialize hardcodes the first segment's opened_ms to 0;
3370        // without the "active segment is still empty" guard in
3371        // should_roll_over, the FIRST real append (a realistic ts_ms, order
3372        // 10^12) would roll over before writing anything, since its epoch
3373        // bucket almost never matches bucket 0.
3374        let dir = tmp_dir("seg-epoch-fresh-no-waste");
3375        let key = decern_crypto::generate().unwrap();
3376        let mut l =
3377            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
3378                .unwrap();
3379        let mut e = entry("first", true);
3380        e.ts_ms = 1_780_000_000_000; // a realistic, far-from-zero epoch ms
3381        l.append(e).unwrap();
3382        drop(l);
3383
3384        assert!(
3385            dir.join("00000001.jsonl").exists(),
3386            "the first entry must land in segment 1"
3387        );
3388        assert!(
3389            !dir.join("00000002.jsonl").exists(),
3390            "a still-empty first segment must never be rolled over"
3391        );
3392        let bytes = std::fs::metadata(dir.join("00000001.jsonl")).unwrap().len();
3393        assert!(bytes > 0, "segment 1 must actually hold the record");
3394    }
3395
3396    #[test]
3397    fn segmented_deleting_a_middle_segment_with_manifest_reconciled_is_still_caught() {
3398        // Dropping a middle segment (and editing the manifest to skip it) now
3399        // breaks start/end contiguity between the two neighbors, so
3400        // validate_manifest_shape's contiguity check rejects it at
3401        // load_manifest time — before verify_lines' unconditional per-record
3402        // seq/prev checks would ever get a chance to catch it independently.
3403        // Locks in defense-in-depth: two layers now catch this, not one.
3404        let dir = tmp_dir("seg-middle-gap");
3405        let key = decern_crypto::generate().unwrap();
3406        {
3407            let mut l =
3408                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3409                    .unwrap();
3410            for i in 0..4 {
3411                l.append(entry(&format!("a{i}"), true)).unwrap();
3412            }
3413        }
3414        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3415        assert!(manifest.segments.len() >= 3, "need a real middle segment");
3416        let middle = manifest.segments.remove(1);
3417        segment::save_manifest(&dir, &manifest).unwrap();
3418        std::fs::remove_file(dir.join(&middle.file)).unwrap();
3419
3420        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3421            Err(e) => e,
3422            Ok(_) => panic!("expected a reconciled middle-segment gap to still be caught"),
3423        };
3424        assert!(
3425            matches!(err, LedgerError::Tamper { .. }),
3426            "expected Tamper (sequence break), got {err:?}"
3427        );
3428    }
3429
3430    #[test]
3431    fn segmented_read_verified_offset_window_matches_read_records_across_a_boundary() {
3432        // read_verified windows by a per-record `count` incremented inside
3433        // verify_lines; read_records/read_raw_records window by Iterator::skip
3434        // over raw lines. These are two independently-implemented mechanisms
3435        // that only agree because no writer path ever emits a blank line —
3436        // this pins that agreement down for a segmented ledger with a
3437        // non-zero offset spanning a boundary, so a future change that
3438        // decouples the two counting schemes would be caught here.
3439        let dir = tmp_dir("seg-read-verified-parity");
3440        let key = decern_crypto::generate().unwrap();
3441        let vk = key.verifying_key();
3442        let mut l =
3443            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3444        for i in 0..6 {
3445            l.append(entry(&format!("a{i}"), true)).unwrap();
3446        }
3447
3448        for (offset, limit) in [(0usize, 100usize), (2, 3), (1, 1), (5, 10)] {
3449            let direct = l.read_records(offset, limit).unwrap();
3450            let (_report, verified) = read_verified(&dir, Some(&vk), offset, limit).unwrap();
3451            assert_eq!(
3452                direct, verified,
3453                "read_verified({offset}, {limit}) must match read_records exactly"
3454            );
3455        }
3456    }
3457
3458    // ===== segmented-ledger hardening: filename ceiling =====
3459
3460    #[test]
3461    fn roll_over_refuses_to_create_a_segment_past_the_8_digit_filename_ceiling() {
3462        // Fabricate a manifest whose sole active segment is already at the
3463        // maximum representable 8-digit index (99,999,999) — no need to
3464        // actually perform 100 million real rollovers to reach the
3465        // boundary. `segment_filename`'s `{:08}` is a MINIMUM width, not a
3466        // cap, so the next policy-triggered rollover would otherwise
3467        // silently create a 9-digit filename that `validate_segment_filename`
3468        // (the very check that closed the original planted-decoy overflow
3469        // bug) then rejects as Tamper on the NEXT read or reopen. The
3470        // rollover must instead refuse cleanly, at append() time.
3471        let dir = tmp_dir("seg-8digit-ceiling");
3472        std::fs::create_dir_all(&dir).unwrap();
3473        let seg_file = segment::segment_filename(99_999_999);
3474        std::fs::write(dir.join(&seg_file), b"").unwrap();
3475        let manifest = segment::Manifest {
3476            version: 1,
3477            segments: vec![segment::SegmentMeta {
3478                file: seg_file,
3479                start_seq: 0,
3480                end_seq: None,
3481                opened_ms: 0,
3482            }],
3483        };
3484        segment::save_manifest(&dir, &manifest).unwrap();
3485
3486        let key = decern_crypto::generate().unwrap();
3487        let mut l =
3488            Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3489                .unwrap();
3490        l.append(entry("a", true)).unwrap(); // lands in the pre-seeded segment (active_is_empty)
3491        let err = match l.append(entry("b", true)) {
3492            Err(e) => e,
3493            Ok(_) => panic!("expected rollover past the 8-digit ceiling to be refused"),
3494        };
3495        assert!(
3496            matches!(err, LedgerError::Io { .. }),
3497            "expected a clean Io error at rollover time, got {err:?}"
3498        );
3499        drop(l);
3500
3501        // Crucially: the refused rollover must not have half-committed
3502        // anything — the ledger must still open and verify cleanly
3503        // afterward, holding exactly the one entry that succeeded.
3504        let reopened =
3505            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3506        assert_eq!(
3507            reopened.count(),
3508            1,
3509            "only the first append should have landed"
3510        );
3511    }
3512
3513    #[test]
3514    fn segmented_epoch_policy_keeps_two_same_bucket_entries_in_one_segment() {
3515        // Regression for the opened_ms-never-rebased gap: segment 1's
3516        // opened_ms is hardcoded to 0 by segment::initialize (no entry
3517        // exists yet to read a real timestamp from). Without rebasing it to
3518        // the first real entry's ts_ms once that entry lands, EVERY append
3519        // after the first would compare a real ts_ms (~10^12) against the
3520        // stale 0 and roll over one record after the first no matter how
3521        // close in time the two really are — silently defeating epoch-based
3522        // grouping for the entirety of a fresh ledger's first bucket.
3523        let dir = tmp_dir("seg-epoch-rebase");
3524        let key = decern_crypto::generate().unwrap();
3525        let mut l =
3526            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
3527                .unwrap();
3528        let mut e0 = entry("a", true);
3529        e0.ts_ms = 1_780_000_000_000; // some real Unix-ms timestamp
3530        l.append(e0).unwrap();
3531        let mut e1 = entry("b", true);
3532        e1.ts_ms = 1_780_000_000_500; // 500ms later, same epoch bucket
3533        l.append(e1).unwrap();
3534        drop(l);
3535
3536        let segs: Vec<_> = std::fs::read_dir(&dir)
3537            .unwrap()
3538            .filter_map(|e| e.ok())
3539            .filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
3540            .collect();
3541        assert_eq!(
3542            segs.len(),
3543            1,
3544            "two entries in the same real epoch bucket must stay in one segment"
3545        );
3546    }
3547
3548    #[test]
3549    fn open_segmented_rejects_a_manifest_with_reordered_sealed_segments() {
3550        // Swapping the array order of two already-sealed segments (no
3551        // deletion, no active-segment tampering — both existing checks stay
3552        // silent) still breaks start/end contiguity between neighbors and
3553        // must be rejected, since segment_paths reads files strictly in
3554        // manifest array order.
3555        let dir = tmp_dir("seg-reordered");
3556        let key = decern_crypto::generate().unwrap();
3557        {
3558            let mut l =
3559                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3560                    .unwrap();
3561            for i in 0..4 {
3562                l.append(entry(&format!("a{i}"), true)).unwrap();
3563            }
3564        }
3565        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3566        assert!(
3567            manifest.segments.len() >= 3,
3568            "need at least two sealed segments to swap"
3569        );
3570        manifest.segments.swap(0, 1);
3571        segment::save_manifest(&dir, &manifest).unwrap();
3572
3573        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3574            Err(e) => e,
3575            Ok(_) => panic!("expected a reordered manifest to be rejected"),
3576        };
3577        assert!(
3578            matches!(err, LedgerError::Tamper { .. }),
3579            "expected Tamper (non-contiguous segments), got {err:?}"
3580        );
3581    }
3582
3583    #[test]
3584    fn open_segmented_rejects_a_manifest_with_zero_segments() {
3585        // segment::initialize never produces an empty segments list, so this
3586        // is a shape no legitimate code path can reach — rejecting it is
3587        // zero-false-positive-risk and closes a gap where the free,
3588        // path-only read functions (verify/read_verified, which never call
3589        // open_segmented's separate zero-active check) would otherwise
3590        // silently report a clean, empty ledger while real segment files
3591        // with real records sit untouched on disk.
3592        let dir = tmp_dir("seg-zero-segments");
3593        let key = decern_crypto::generate().unwrap();
3594        {
3595            let mut l =
3596                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3597                    .unwrap();
3598            l.append(entry("a", true)).unwrap();
3599        }
3600        let empty = segment::Manifest {
3601            version: 1,
3602            segments: vec![],
3603        };
3604        segment::save_manifest(&dir, &empty).unwrap();
3605
3606        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3607            Err(e) => e,
3608            Ok(_) => panic!("expected a zero-segment manifest to be rejected"),
3609        };
3610        assert!(
3611            matches!(err, LedgerError::Tamper { .. }),
3612            "expected Tamper (zero segments), got {err:?}"
3613        );
3614    }
3615
3616    // ===== segmented-ledger hardening: manifest integrity =====
3617
3618    #[test]
3619    fn segmented_manifest_missing_its_head_segment_is_rejected_including_on_an_already_open_handle()
3620    {
3621        // The contiguity check alone only verifies ADJACENT pairs — dropping
3622        // the earliest segment leaves every remaining pair still mutually
3623        // contiguous, so it needed its own explicit anchor-to-seq-0 check.
3624        // Exercise BOTH the reopen path AND the path this gap actually
3625        // mattered for: read_records on an ALREADY-OPEN handle, which
3626        // re-reads the manifest fresh on every call but never re-runs the
3627        // chain walk that would otherwise have caught this independently.
3628        let dir = tmp_dir("seg-head-drop");
3629        let key = decern_crypto::generate().unwrap();
3630        let mut l =
3631            Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3632                .unwrap();
3633        for i in 0..4 {
3634            l.append(entry(&format!("a{i}"), true)).unwrap();
3635        }
3636        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3637        assert!(
3638            manifest.segments.len() >= 3,
3639            "need a real head segment to drop"
3640        );
3641        manifest.segments.remove(0);
3642        segment::save_manifest(&dir, &manifest).unwrap();
3643
3644        let err = l
3645            .read_records(0, 100)
3646            .expect_err("head-dropped manifest must be rejected, not silently shifted");
3647        assert!(
3648            matches!(err, LedgerError::Tamper { .. }),
3649            "expected Tamper (missing chain head), got {err:?}"
3650        );
3651        drop(l);
3652
3653        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3654            Err(e) => e,
3655            Ok(_) => panic!("expected a head-dropped manifest to be rejected on reopen"),
3656        };
3657        assert!(
3658            matches!(err, LedgerError::Tamper { .. }),
3659            "expected Tamper (missing chain head), got {err:?}"
3660        );
3661    }
3662
3663    #[cfg(unix)]
3664    #[test]
3665    fn opened_ms_rebase_rolls_back_in_memory_on_a_failed_persist_so_a_retry_still_persists_it() {
3666        use std::os::unix::fs::PermissionsExt;
3667        // If the rebase mutated self.rollover.manifest BEFORE the fallible
3668        // save_manifest call (with no rollback on failure), a retry with the
3669        // identical timestamp would find opened_ms already "correct" in
3670        // memory and silently skip persisting it to disk forever. Force the
3671        // first attempt's persist to fail, then confirm a retry with the
3672        // SAME entry still lands the correction on disk.
3673        let dir = tmp_dir("seg-rebase-rollback");
3674        let key = decern_crypto::generate().unwrap();
3675        let mut l =
3676            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
3677                .unwrap();
3678
3679        let mut e0 = entry("a", true);
3680        e0.ts_ms = 1_780_000_000_000;
3681        let mut perms = std::fs::metadata(&dir).unwrap().permissions();
3682        perms.set_mode(0o500);
3683        std::fs::set_permissions(&dir, perms.clone()).unwrap();
3684        l.append(e0.clone())
3685            .expect_err("save_manifest should fail while the dir is read-only");
3686        perms.set_mode(0o700);
3687        std::fs::set_permissions(&dir, perms).unwrap();
3688
3689        l.append(e0).unwrap();
3690
3691        let manifest = segment::load_manifest(&dir).unwrap().unwrap();
3692        assert_eq!(
3693            manifest.segments[0].opened_ms, 1_780_000_000_000,
3694            "the retry must have persisted the rebased opened_ms to disk"
3695        );
3696    }
3697
3698    // ===================== torn-tail vs tamper (crash recovery) =====================
3699
3700    /// A ledger written before a field existed must still verify, byte for byte,
3701    /// after that field is added. Every optional column is `skip_serializing_if`
3702    /// for exactly this reason, and the chain hashes the bytes on disk — so the
3703    /// guarantee is only real if a record lacking the newest fields still checks
3704    /// out. Written as literal on-disk lines rather than by re-serializing, so
3705    /// this fails if a future field ever starts emitting a default.
3706    #[test]
3707    fn a_record_written_before_the_newest_columns_still_verifies() {
3708        let key = decern_crypto::generate().unwrap();
3709        let vk = key.verifying_key();
3710        let path = tmp("pre-columns-compat.ledger");
3711        std::fs::remove_file(&path).ok();
3712
3713        // Append through the current code, then confirm the bytes carry none of
3714        // the default-valued columns: their absence is what an older writer
3715        // produced, so today's output IS the old shape when nothing set them.
3716        let mut l = Ledger::open(&path, key.clone()).unwrap();
3717        l.append(entry("act0", true)).unwrap();
3718        drop(l);
3719
3720        let line = std::fs::read_to_string(&path).unwrap();
3721        assert!(
3722            !line.contains("decision_subject_source"),
3723            "a default source must not be written; old records would not have it: {line}"
3724        );
3725        assert!(
3726            !line.contains("sponsor_source"),
3727            "a default sponsor source must not be written either: {line}"
3728        );
3729
3730        // The chain covers the exact bytes above, so this is the real check: an
3731        // entry with none of the newer columns verifies as authentic.
3732        let report = verify(&path, Some(&vk)).unwrap();
3733        assert_eq!(report.entries, 1);
3734        assert!(report.signatures_checked);
3735
3736        // And it round-trips: reading it back yields the defaults rather than
3737        // failing to parse, which is what lets an old ledger be read at all.
3738        let (_r, records) = read_verified(&path, Some(&vk), 0, 1).unwrap();
3739        let rec = records.first().expect("one record");
3740        assert!(rec["entry"].get("decision_subject_source").is_none());
3741        std::fs::remove_file(&path).ok();
3742    }
3743
3744    /// Write `n` valid, chain+signature-valid records, then return the file's
3745    /// full lines so a test can rebuild a deliberately damaged tail.
3746    fn seed_lines(path: &Path, key: &SigningKey, n: usize) -> Vec<String> {
3747        std::fs::remove_file(path).ok();
3748        let mut l = Ledger::open(path, key.clone()).unwrap();
3749        for i in 0..n {
3750            l.append(entry(&format!("act{i}"), true)).unwrap();
3751        }
3752        drop(l);
3753        std::fs::read_to_string(path)
3754            .unwrap()
3755            .lines()
3756            .map(str::to_owned)
3757            .collect()
3758    }
3759
3760    #[test]
3761    fn crash_torn_tail_heals_as_torn_tail_not_tamper() {
3762        let key = decern_crypto::generate().unwrap();
3763        let vk = key.verifying_key();
3764        let path = tmp("torn-heals.ledger");
3765        let lines = seed_lines(&path, &key, 3);
3766
3767        // Simulate a crash mid-append of record 2: the first two records are
3768        // whole + newline-terminated; the third is a half-written fragment with
3769        // NO trailing newline.
3770        let torn = format!(
3771            "{}\n{}\n{}",
3772            lines[0],
3773            lines[1],
3774            &lines[2][..lines[2].len() / 2]
3775        );
3776        std::fs::write(&path, &torn).unwrap();
3777
3778        // The read-only verifier SURFACES this as TornTail, distinct from Tamper.
3779        let err = verify(&path, Some(&vk)).unwrap_err();
3780        match err {
3781            LedgerError::TornTail { healed_entries, .. } => assert_eq!(healed_entries, 2),
3782            other => panic!("expected TornTail, got {other:?}"),
3783        }
3784
3785        // Opening HEALS: the verified 2-record prefix is intact and appendable.
3786        let mut l = Ledger::open(&path, key.clone()).unwrap();
3787        l.append(entry("after-heal", true)).unwrap();
3788        drop(l);
3789
3790        // Reopen: clean, three records (2 healed + 1 new), no torn tail remains.
3791        let report = verify(&path, Some(&vk)).unwrap();
3792        assert_eq!(report.entries, 3);
3793    }
3794
3795    #[test]
3796    fn unterminated_but_complete_final_record_is_discarded_then_appends_cleanly() {
3797        // The strongest proof the rule is keyed on NEWLINE-TERMINATION, not on
3798        // parseability: the final record's bytes are entirely present and parse
3799        // fine — only its terminating '\n' never reached disk. It must still be
3800        // discarded, because keeping an unterminated line would fuse the next
3801        // append onto it as one physical line and corrupt the log.
3802        let key = decern_crypto::generate().unwrap();
3803        let vk = key.verifying_key();
3804        let path = tmp("torn-complete.ledger");
3805        let lines = seed_lines(&path, &key, 3);
3806
3807        // All three records complete; drop ONLY the trailing newline.
3808        std::fs::write(&path, format!("{}\n{}\n{}", lines[0], lines[1], lines[2])).unwrap();
3809
3810        // Surfaced as TornTail even though the final line parses cleanly.
3811        match verify(&path, Some(&vk)).unwrap_err() {
3812            LedgerError::TornTail { healed_entries, .. } => assert_eq!(healed_entries, 2),
3813            other => panic!("expected TornTail, got {other:?}"),
3814        }
3815
3816        let mut l = Ledger::open(&path, key.clone()).unwrap();
3817        l.append(entry("after-heal", true)).unwrap();
3818        drop(l);
3819
3820        // The append landed on its own line — not fused — and verifies.
3821        let text = std::fs::read_to_string(&path).unwrap();
3822        assert_eq!(
3823            text.lines().count(),
3824            3,
3825            "records must stay one-per-line: {text}"
3826        );
3827        assert!(
3828            text.ends_with('\n'),
3829            "healed log is newline-terminated again"
3830        );
3831        assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 3);
3832    }
3833
3834    #[test]
3835    fn attacker_ragged_truncation_below_anchor_stays_tamper() {
3836        // The discriminating case the review demands: an attacker truncates
3837        // MID-record so the file ends WITHOUT a newline (entering the heal path),
3838        // but the healed prefix falls BELOW the committed anchor height. This is
3839        // deletion of acked history, not a crash tail → must stay Tamper, and the
3840        // file must NOT be mutated before that verdict.
3841        let key = decern_crypto::generate().unwrap();
3842        let path = tmp("torn-below-anchor.ledger");
3843        let anchor = tmp("torn-below-anchor.anchor");
3844        std::fs::remove_file(&path).ok();
3845        std::fs::remove_file(&anchor).ok();
3846
3847        let mut l = Ledger::open(&path, key.clone()).unwrap();
3848        for i in 0..3 {
3849            l.append(entry(&format!("act{i}"), true)).unwrap();
3850        }
3851        l.seal_anchor(&anchor, 1_000).unwrap(); // commits to 3 records
3852        drop(l);
3853
3854        let lines: Vec<String> = std::fs::read_to_string(&path)
3855            .unwrap()
3856            .lines()
3857            .map(str::to_owned)
3858            .collect();
3859        // Keep 1 whole record + a ragged (unterminated) fragment of record 1 →
3860        // healed prefix = 1 < anchored 3.
3861        let ragged = format!("{}\n{}", lines[0], &lines[1][..lines[1].len() / 2]);
3862        std::fs::write(&path, &ragged).unwrap();
3863        let len_before = std::fs::metadata(&path).unwrap().len();
3864
3865        let err = match Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor) {
3866            Ok(_) => panic!("ragged truncation below the anchor must fail to open"),
3867            Err(e) => e,
3868        };
3869        assert!(
3870            matches!(err, LedgerError::Tamper { .. }),
3871            "ragged truncation below the anchor must be Tamper, got {err:?}"
3872        );
3873        // The verdict was reached WITHOUT healing/truncating the file.
3874        assert_eq!(
3875            std::fs::metadata(&path).unwrap().len(),
3876            len_before,
3877            "the file must not be mutated when the torn tail is really a truncation attack"
3878        );
3879    }
3880
3881    #[test]
3882    fn terminated_final_record_with_broken_signature_stays_tamper() {
3883        // A fully newline-terminated final record that fails signature is NOT a
3884        // torn tail — a terminated line can't be a partial write. Stays Tamper.
3885        let key = decern_crypto::generate().unwrap();
3886        let vk = key.verifying_key();
3887        let path = tmp("terminated-bad-sig.ledger");
3888        let lines = seed_lines(&path, &key, 3);
3889
3890        // Corrupt one base64 char of the last record's signature, keep the '\n'.
3891        let mut last: serde_json::Value = serde_json::from_str(&lines[2]).unwrap();
3892        let sig = last["sig_b64"].as_str().unwrap().to_owned();
3893        let flipped = if sig.starts_with('A') { 'B' } else { 'A' };
3894        last["sig_b64"] = json!(format!("{flipped}{}", &sig[1..]));
3895        let corrupt = format!(
3896            "{}\n{}\n{}\n",
3897            lines[0],
3898            lines[1],
3899            serde_json::to_string(&last).unwrap()
3900        );
3901        std::fs::write(&path, &corrupt).unwrap();
3902
3903        match verify(&path, Some(&vk)).unwrap_err() {
3904            LedgerError::Tamper { .. } => {}
3905            other => panic!("terminated bad-signature record must be Tamper, got {other:?}"),
3906        }
3907    }
3908
3909    #[test]
3910    fn terminated_final_record_with_broken_chain_stays_tamper() {
3911        // Same guarantee for a broken hash-chain link on a terminated final line.
3912        let key = decern_crypto::generate().unwrap();
3913        let vk = key.verifying_key();
3914        let path = tmp("terminated-bad-chain.ledger");
3915        let lines = seed_lines(&path, &key, 3);
3916
3917        // Flip a byte inside the final record's stored entry: breaks its hash
3918        // (and thus the chain), while the line stays newline-terminated.
3919        let mut last: serde_json::Value = serde_json::from_str(&lines[2]).unwrap();
3920        last["hash"] = json!("00".repeat(32));
3921        let corrupt = format!(
3922            "{}\n{}\n{}\n",
3923            lines[0],
3924            lines[1],
3925            serde_json::to_string(&last).unwrap()
3926        );
3927        std::fs::write(&path, &corrupt).unwrap();
3928
3929        match verify(&path, Some(&vk)).unwrap_err() {
3930            LedgerError::Tamper { .. } => {}
3931            other => panic!("terminated broken-chain record must be Tamper, got {other:?}"),
3932        }
3933    }
3934
3935    #[test]
3936    fn healthy_log_ends_newline_terminated_and_reopens_clean() {
3937        // The single-write append terminates every record in the same write, so
3938        // a clean shutdown always leaves a newline-terminated file with no torn
3939        // tail — the round-trip the heal path must never disturb.
3940        let key = decern_crypto::generate().unwrap();
3941        let vk = key.verifying_key();
3942        let path = tmp("healthy-roundtrip.ledger");
3943        seed_lines(&path, &key, 4);
3944
3945        let bytes = std::fs::read(&path).unwrap();
3946        assert_eq!(*bytes.last().unwrap(), b'\n', "log is newline-terminated");
3947        assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 4);
3948        // Reopen (no heal) and keep appending.
3949        let mut l = Ledger::open(&path, key.clone()).unwrap();
3950        l.append(entry("more", true)).unwrap();
3951        drop(l);
3952        assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 5);
3953    }
3954
3955    #[test]
3956    fn anchored_crash_tail_above_the_anchor_heals_and_opens() {
3957        // The POSITIVE control mirroring `attacker_ragged_truncation_below_anchor`:
3958        // an honest crash left an unterminated tail whose verified prefix STILL
3959        // covers the committed height. `open_anchored` must walk the anchor
3960        // check, heal, AND pass the post-heal `verify_against_anchor` — i.e. an
3961        // anchored deployment recovers from a crash instead of bricking.
3962        let key = decern_crypto::generate().unwrap();
3963        let vk = key.verifying_key();
3964        let path = tmp("anchored-crash-tail.ledger");
3965        let anchor = tmp("anchored-crash-tail.anchor");
3966        std::fs::remove_file(&path).ok();
3967        std::fs::remove_file(&anchor).ok();
3968
3969        let mut l = Ledger::open(&path, key.clone()).unwrap();
3970        for i in 0..3 {
3971            l.append(entry(&format!("act{i}"), true)).unwrap();
3972        }
3973        l.seal_anchor(&anchor, 1_000).unwrap(); // commits to 3 records
3974        l.append(entry("act3", true)).unwrap(); // a 4th, un-acked record
3975        drop(l);
3976
3977        let lines: Vec<String> = std::fs::read_to_string(&path)
3978            .unwrap()
3979            .lines()
3980            .map(str::to_owned)
3981            .collect();
3982        // Crash mid-append of record 3: records 0..=2 whole + terminated (== the
3983        // anchored height), record 3 a ragged unterminated fragment.
3984        let torn = format!(
3985            "{}\n{}\n{}\n{}",
3986            lines[0],
3987            lines[1],
3988            lines[2],
3989            &lines[3][..lines[3].len() / 2]
3990        );
3991        std::fs::write(&path, &torn).unwrap();
3992
3993        // Heals to the 3 acked records AND satisfies the anchor.
3994        let mut l = Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor).unwrap();
3995        l.append(entry("act3-again", true)).unwrap();
3996        drop(l);
3997        assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 4);
3998    }
3999
4000    #[test]
4001    fn segmented_torn_tail_in_active_segment_heals() {
4002        // The heal path routes through `open_segmented_inner` too, and
4003        // `prefix_lines` keys off the LAST (active) segment. A torn tail in the
4004        // active segment must heal, leaving the earlier segment's records intact
4005        // and the chain verifying across the boundary.
4006        let key = decern_crypto::generate().unwrap();
4007        let vk = key.verifying_key();
4008        let dir = tmp("segmented-torn");
4009        std::fs::remove_dir_all(&dir).ok();
4010
4011        // A tiny size policy forces a rollover, so records land in >1 segment.
4012        let policy = RolloverPolicy {
4013            max_bytes: Some(1),
4014            epoch_ms: None,
4015        };
4016        let mut l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), policy).unwrap();
4017        for i in 0..4 {
4018            l.append(entry(&format!("s{i}"), true)).unwrap();
4019        }
4020        drop(l);
4021
4022        // Locate the active (last) segment file and ragged-truncate its final line.
4023        let paths = segment::segment_paths(&dir).unwrap();
4024        let active = paths.last().unwrap().clone();
4025        let alines: Vec<String> = std::fs::read_to_string(&active)
4026            .unwrap()
4027            .lines()
4028            .map(str::to_owned)
4029            .collect();
4030        assert!(!alines.is_empty(), "active segment should hold >=1 record");
4031        let kept = &alines[..alines.len() - 1];
4032        let mut body = kept.iter().map(|s| format!("{s}\n")).collect::<String>();
4033        // Append a ragged (unterminated) fragment of the last record.
4034        let torn = alines.last().unwrap();
4035        body.push_str(&torn[..torn.len() / 2]);
4036        std::fs::write(&active, &body).unwrap();
4037
4038        // Read-only verify surfaces TornTail; open heals and stays appendable.
4039        assert!(matches!(
4040            verify(&dir, Some(&vk)).unwrap_err(),
4041            LedgerError::TornTail { .. }
4042        ));
4043        let healed_before = match verify(&dir, Some(&vk)).unwrap_err() {
4044            LedgerError::TornTail { healed_entries, .. } => healed_entries,
4045            _ => unreachable!(),
4046        };
4047        let mut l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), policy).unwrap();
4048        l.append(entry("post-heal", true)).unwrap();
4049        drop(l);
4050        assert_eq!(
4051            verify(&dir, Some(&vk)).unwrap().entries,
4052            healed_before + 1,
4053            "chain verifies across the segment boundary after healing the active tail"
4054        );
4055    }
4056}