Skip to main content

decern_ledger/
segment.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2026 Anivar Aravind
3//! Per-epoch/size segmentation for the sovereign single-file [`crate::Ledger`].
4//!
5//! The single-file ledger is one ever-growing `.jsonl` plus an anchor sidecar;
6//! opening it re-verifies the WHOLE chain, so a long-lived sovereign node's
7//! startup cost and single-file size both grow unbounded. This module adds an
8//! OPT-IN alternative: a directory of numbered segment files
9//! (`00000001.jsonl`, `00000002.jsonl`, ...) plus a `manifest.json` naming
10//! them in order, with exactly one active (unsealed, currently appended-to)
11//! segment at a time.
12//!
13//! The chain hash itself (`hash = SHA-256(entry_bytes ‖ prev_hex)`) never
14//! changes shape — a segment boundary is invisible to it. The first record of
15//! a new segment carries `prev = <last sealed hash of the previous segment>`
16//! exactly as any other record would, so cross-segment verification is the
17//! SAME chain walk `verify_lines` already does, just fed lines from more than
18//! one file, in order. This is what makes segmentation additive rather than a
19//! parallel, divergent verification path.
20//!
21//! Existing single-file logs and every existing `Ledger::open*` call site are
22//! completely unaffected: segmentation is reached only via the new
23//! [`crate::Ledger::open_segmented`]/`open_segmented_anchored` constructors on
24//! the WRITE side. On the READ side, every path-taking free function
25//! (`verify`, `verify_with_keys`, `read_verified`,
26//! `ledger_extends_checkpoint`) auto-detects: `path.is_dir()` means segmented,
27//! anything else means the single-file path exactly as before (a directory
28//! could never have been a valid single-file ledger anyway, so this can only
29//! ever change behavior in a case that previously always errored).
30//!
31//! Manifest is UNTRUSTED metadata, same trust level as the single-file case's
32//! bare file listing: it is a ROUTING HINT (which files, in what order) never
33//! a source of truth for chain height. Dropping the newest segment(s) from
34//! disk AND the manifest is a tail-truncation, exactly as deleting the tail of
35//! a single file would be — invisible to the chain walk itself, caught only by
36//! the externally-anchored checkpoint (`ledger_extends_checkpoint`,
37//! `Ledger::verify_against_anchor`), which re-derives root+count from actual
38//! segment BYTES, never from any count cached in the manifest.
39
40use std::fs::{self, File, OpenOptions};
41use std::io::{BufRead, BufReader, Lines, Read, Take, Write};
42use std::path::{Path, PathBuf};
43
44use serde::{Deserialize, Serialize};
45
46use crate::{LedgerError, io_err};
47
48const MANIFEST_FILE: &str = "manifest.json";
49
50/// When a segmented ledger rolls its active segment over to a new file. Both
51/// may be set (roll over on whichever fires first); both `None` is legal but
52/// pointless (a segmented ledger with exactly one ever-growing segment).
53#[derive(Debug, Clone, Copy, Default)]
54pub struct RolloverPolicy {
55    pub max_bytes: Option<u64>,
56    pub epoch_ms: Option<u64>,
57}
58
59impl RolloverPolicy {
60    pub fn max_bytes(max_bytes: u64) -> Self {
61        Self {
62            max_bytes: Some(max_bytes),
63            epoch_ms: None,
64        }
65    }
66
67    pub fn epoch_ms(epoch_ms: u64) -> Self {
68        Self {
69            max_bytes: None,
70            epoch_ms: Some(epoch_ms),
71        }
72    }
73
74    pub fn either(max_bytes: u64, epoch_ms: u64) -> Self {
75        Self {
76            max_bytes: Some(max_bytes),
77            epoch_ms: Some(epoch_ms),
78        }
79    }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub(crate) struct SegmentMeta {
84    pub file: String,
85    pub start_seq: u64,
86    /// `None` while this is the active (currently appended-to) segment.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub end_seq: Option<u64>,
89    /// The `ts_ms` of the entry that started this segment — the epoch-bucket
90    /// rollover check compares a candidate entry's `ts_ms` against this, not
91    /// wall-clock time, so rollover stays deterministic and testable (mirrors
92    /// the caller-supplied-time convention `decern-cli`'s `--now` flags already
93    /// use elsewhere; this crate makes no wall-clock call of its own).
94    pub opened_ms: u64,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub(crate) struct Manifest {
99    pub version: u32,
100    pub segments: Vec<SegmentMeta>,
101}
102
103impl Manifest {
104    pub(crate) fn active(&self) -> Option<&SegmentMeta> {
105        self.segments.iter().find(|s| s.end_seq.is_none())
106    }
107
108    pub(crate) fn active_mut(&mut self) -> Option<&mut SegmentMeta> {
109        self.segments.iter_mut().find(|s| s.end_seq.is_none())
110    }
111}
112
113pub(crate) fn segment_filename(index: u32) -> String {
114    format!("{index:08}.jsonl")
115}
116
117/// A segment filename must be EXACTLY the shape [`segment_filename`] produces
118/// — 8 ASCII digits + `.jsonl`, never a path separator, `..`, or an absolute
119/// path. The manifest is untrusted metadata (see module docs): every
120/// filename it names is validated the moment the manifest is loaded, so
121/// nothing downstream (`segment_paths`'s `dir.join`, `max_index`'s
122/// arithmetic) ever has to re-check a hostile string on its own. This closes
123/// two things at once: a manifest entry's `file` field smuggling a
124/// path-traversal payload (`dir.join("../secret")` escapes `dir` entirely,
125/// same for an absolute path — both are Rust/OS-standard `Path::join`
126/// behavior, not a bug in `join` itself), and an out-of-range index (e.g.
127/// `4294967295.jsonl`, exactly `u32::MAX`) that could overflow the `+ 1` in
128/// [`roll_over`] — the 8-digit cap keeps every valid index under 100,000,000,
129/// nowhere near overflow, so the two fixes are the same one check.
130fn validate_segment_filename(file: &str) -> Result<u32, LedgerError> {
131    let bad = || LedgerError::Tamper {
132        seq: 0,
133        why: format!(
134            "manifest names a segment file with an invalid shape: {file:?} (expected exactly \
135             8 digits + \".jsonl\", e.g. 00000001.jsonl)"
136        ),
137    };
138    let digits = file.strip_suffix(".jsonl").ok_or_else(bad)?;
139    if digits.len() != 8 || !digits.bytes().all(|b| b.is_ascii_digit()) {
140        return Err(bad());
141    }
142    digits.parse().map_err(|_| bad())
143}
144
145/// Directory-scan variant of [`validate_segment_filename`]: a file on disk
146/// that isn't a validly-shaped segment name (the manifest itself, a `.tmp`
147/// file, anything else) is just not a segment — silently skipped, not an
148/// error, since a segment directory legitimately holds non-segment files.
149fn parse_index(filename: &str) -> Option<u32> {
150    validate_segment_filename(filename).ok()
151}
152
153/// A valid manifest: has at least one segment; has EXACTLY one active
154/// (unsealed, `end_seq: None`) segment, which must be the LAST entry; its
155/// earliest segment starts at seq 0 (the true chain head); and every
156/// adjacent pair hands off exactly where the next one starts, with no gap,
157/// overlap, or reordering. Checked the moment a manifest is loaded (inside
158/// [`load_manifest`], so every caller — `open_segmented`'s reopen path AND
159/// every subsequent read through `segment_paths`, including
160/// `Ledger::read_records`/`read_raw_records` on an ALREADY-OPEN handle,
161/// neither of which re-runs the chain walk — gets this for free).
162///
163/// This is shape validation only: it can catch a manifest edit that changes
164/// which SEGMENTS exist or what order they're listed in, but it has no way
165/// to detect a manifest whose `file` field was swapped between two entries
166/// while their `start_seq`/`end_seq` stayed put — that would still look
167/// perfectly contiguous. Closing that would mean reading and checking each
168/// segment's own first record against its declared `start_seq`, which is
169/// exactly what the chain walk (`verify_lines`) already does — accepted as
170/// the same "raw reads are unverified by design" property `read_records`/
171/// `read_raw_records` already have for a plain single-file ledger (a
172/// hand-edited `.jsonl` has the identical gap), not something this shape
173/// check is meant to close.
174fn validate_manifest_shape(manifest: &Manifest) -> Result<(), LedgerError> {
175    if manifest.segments.is_empty() {
176        return Err(LedgerError::Tamper {
177            seq: 0,
178            why: "manifest names zero segments — a valid segmented ledger always has at least \
179                  one (segment::initialize never produces an empty list)"
180                .into(),
181        });
182    }
183    if manifest.segments[0].start_seq != 0 {
184        return Err(LedgerError::Tamper {
185            seq: 0,
186            why: format!(
187                "manifest's earliest segment {:?} starts at seq {}, not 0 — the true chain head \
188                 is missing from this manifest",
189                manifest.segments[0].file, manifest.segments[0].start_seq
190            ),
191        });
192    }
193    let active_count = manifest
194        .segments
195        .iter()
196        .filter(|s| s.end_seq.is_none())
197        .count();
198    if active_count > 1 {
199        return Err(LedgerError::Tamper {
200            seq: 0,
201            why: format!(
202                "manifest names {active_count} active (unsealed) segments — exactly one is valid"
203            ),
204        });
205    }
206    if active_count == 1 && manifest.segments.last().is_none_or(|s| s.end_seq.is_some()) {
207        return Err(LedgerError::Tamper {
208            seq: 0,
209            why: "manifest's active segment must be the last entry".into(),
210        });
211    }
212    // Every segment but the tail must be sealed (guaranteed by the active-count
213    // checks above) and must hand off exactly where the next one starts — no
214    // gap, overlap, or reordering. A manifest edit that reorders two sealed
215    // segments, or drops one from the middle while leaving the rest, changes
216    // apparent read order (`segment_paths` walks the array in listed order) or
217    // silently skips real committed records without tripping either check
218    // above.
219    for pair in manifest.segments.windows(2) {
220        let (prev, next) = (&pair[0], &pair[1]);
221        if prev.end_seq != Some(next.start_seq) {
222            return Err(LedgerError::Tamper {
223                seq: 0,
224                why: format!(
225                    "manifest segments {:?} (end_seq {:?}) and {:?} (start_seq {}) are not \
226                     contiguous — segments must be listed in order with no gap, overlap, or \
227                     reordering",
228                    prev.file, prev.end_seq, next.file, next.start_seq
229                ),
230            });
231        }
232    }
233    Ok(())
234}
235
236/// The highest segment index referenced by EITHER the manifest OR any file on
237/// disk matching the segment naming pattern. Used to pick the next segment's
238/// index on rollover: never derived from the manifest alone, so a rollover
239/// that created a new segment file but crashed before committing the manifest
240/// (an ORPHAN) can never be silently overwritten by a later rollover attempt
241/// choosing the same index.
242fn max_index(dir: &Path, manifest: &Manifest) -> Result<u32, LedgerError> {
243    let mut max = manifest
244        .segments
245        .iter()
246        .filter_map(|s| parse_index(&s.file))
247        .max()
248        .unwrap_or(0);
249    for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? {
250        let entry = entry.map_err(|e| io_err(dir, e))?;
251        if let Some(name) = entry.file_name().to_str()
252            && let Some(idx) = parse_index(name)
253        {
254            max = max.max(idx);
255        }
256    }
257    Ok(max)
258}
259
260/// Read `dir/manifest.json`; `None` if the directory has no manifest yet (a
261/// fresh segmented ledger about to be created).
262pub(crate) fn load_manifest(dir: &Path) -> Result<Option<Manifest>, LedgerError> {
263    let path = dir.join(MANIFEST_FILE);
264    match fs::read(&path) {
265        Ok(bytes) => {
266            let manifest: Manifest =
267                serde_json::from_slice(&bytes).map_err(|e| LedgerError::Serde(e.to_string()))?;
268            // Validate BEFORE returning — every caller (segment_paths on every
269            // read, open_segmented's reopen path) then trusts a manifest that
270            // already passed both checks, rather than re-validating (or, worse,
271            // forgetting to) at each call site.
272            for seg in &manifest.segments {
273                validate_segment_filename(&seg.file)?;
274            }
275            validate_manifest_shape(&manifest)?;
276            Ok(Some(manifest))
277        }
278        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
279        Err(e) => Err(io_err(&path, e)),
280    }
281}
282
283/// Persist the manifest atomically (temp file + fsync + rename + parent-dir
284/// fsync — the SAME durability discipline [`crate::save_anchor`] already
285/// uses). This rename is the single commit point for a rollover: creating the
286/// new segment file (before this call) is safely re-doable if a crash lands
287/// before the rename lands (the new file is just an orphan, ignored on
288/// reopen — see [`max_index`]); sealing the old segment's file permissions
289/// (after this call) is safely re-appliable on every open regardless of when
290/// a crash lands relative to it.
291pub(crate) fn save_manifest(dir: &Path, manifest: &Manifest) -> Result<(), LedgerError> {
292    let path = dir.join(MANIFEST_FILE);
293    let tmp = dir.join("manifest.json.tmp");
294    let bytes =
295        serde_json::to_vec_pretty(manifest).map_err(|e| LedgerError::Serde(e.to_string()))?;
296    {
297        let mut f = File::create(&tmp).map_err(|e| io_err(&tmp, e))?;
298        f.write_all(&bytes).map_err(|e| io_err(&tmp, e))?;
299        f.sync_all().map_err(|e| io_err(&tmp, e))?;
300    }
301    fs::rename(&tmp, &path).map_err(|e| io_err(&path, e))?;
302    if let Ok(dirf) = File::open(dir) {
303        let _ = dirf.sync_all();
304    }
305    Ok(())
306}
307
308/// Best-effort: mark a sealed segment file read-only to its owner (0400 on Unix). Defense
309/// in depth, never the real protection (that's the chain + signature +
310/// externally-anchored checkpoint, which no filesystem permission bit can
311/// substitute for) — so a failure here is silently ignored rather than
312/// failing the ledger open/append that triggered it, and it is re-applied on
313/// every open, making it eventually consistent with no crash-atomicity needs
314/// of its own.
315pub(crate) fn seal_file_permissions(path: &Path) {
316    #[cfg(unix)]
317    {
318        use std::os::unix::fs::PermissionsExt;
319        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o400));
320    }
321    #[cfg(not(unix))]
322    {
323        let _ = path;
324    }
325}
326
327/// The write-side counterpart of [`seal_file_permissions`]: ensure the ACTIVE
328/// segment is writable by its owner (0600 on Unix). Applied on every open, symmetrically
329/// with sealing every sealed segment — so a manifest edit that re-marks a
330/// previously-sealed (and thus read-only) segment as active again still
331/// reopens cleanly, rather than failing with a confusing permission-denied
332/// deep inside the append path. Best-effort, same non-load-bearing status as
333/// `seal_file_permissions`.
334pub(crate) fn unseal_file_permissions(path: &Path) {
335    #[cfg(unix)]
336    {
337        use std::os::unix::fs::PermissionsExt;
338        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
339    }
340    #[cfg(not(unix))]
341    {
342        let _ = path;
343    }
344}
345
346/// Every segment file the manifest names, in seq order, resolved to full
347/// paths — fails closed if any listed segment is missing from disk (whether
348/// the missing file is a sealed or the active one, its absence is exactly the
349/// tail/mid truncation this crate exists to catch).
350pub(crate) fn segment_paths(dir: &Path) -> Result<Vec<PathBuf>, LedgerError> {
351    let manifest = load_manifest(dir)?.ok_or_else(|| LedgerError::Io {
352        path: dir.display().to_string(),
353        err: "segmented ledger directory has no manifest.json (not a valid segmented ledger — \
354              use Ledger::open_segmented to create one)"
355            .into(),
356    })?;
357    manifest
358        .segments
359        .iter()
360        .map(|s| {
361            let p = dir.join(&s.file);
362            if p.exists() {
363                Ok(p)
364            } else {
365                Err(LedgerError::Tamper {
366                    seq: s.start_seq,
367                    why: format!(
368                        "segment {} is listed in the manifest but missing from disk (tail- or \
369                         mid-truncation, or a corrupted deployment)",
370                        s.file
371                    ),
372                })
373            }
374        })
375        .collect()
376}
377
378/// Create a brand-new segmented ledger directory: one empty active segment
379/// plus its manifest. Returns the manifest. `dir` must already exist (callers
380/// create it via `create_dir_all` first) and must NOT already hold a
381/// manifest — callers only reach this on the `load_manifest` `None` branch.
382pub(crate) fn initialize(dir: &Path) -> Result<Manifest, LedgerError> {
383    let first = SegmentMeta {
384        file: segment_filename(1),
385        start_seq: 0,
386        end_seq: None,
387        opened_ms: 0,
388    };
389    let seg_path = dir.join(&first.file);
390    OpenOptions::new()
391        .create_new(true)
392        .write(true)
393        .open(&seg_path)
394        .map_err(|e| io_err(&seg_path, e))?;
395    let manifest = Manifest {
396        version: 1,
397        segments: vec![first],
398    };
399    save_manifest(dir, &manifest)?;
400    Ok(manifest)
401}
402
403/// Roll the active segment over: seal it (in a cloned, not-yet-committed
404/// manifest) at `current_seq`, create the fresh active segment, atomically
405/// commit the new manifest, then chmod the now-sealed old segment. Returns
406/// the new manifest and the newly active segment's path — the caller
407/// (`Ledger::roll_over`) swaps its open file handle to it and keeps the
408/// returned manifest as its new in-memory state.
409pub(crate) fn roll_over(
410    dir: &Path,
411    manifest: &Manifest,
412    current_seq: u64,
413    next_ts_ms: u64,
414) -> Result<(Manifest, PathBuf), LedgerError> {
415    // checked_add rather than a plain `+ 1` that would silently wrap in a
416    // release build (matching this codebase's own established practice of
417    // checked/saturating arithmetic on untrusted-input-derived sizes, e.g.
418    // `read_verified`'s `offset.saturating_add(limit)`).
419    let next_index = max_index(dir, manifest)?
420        .checked_add(1)
421        .ok_or_else(|| LedgerError::Io {
422            path: dir.display().to_string(),
423            err: "segment index exhausted (at u32::MAX)".into(),
424        })?;
425    // `segment_filename`'s `{:08}` is a MINIMUM width, not a cap: past index
426    // 99,999,999 it emits 9+ digits, which `validate_segment_filename` (the
427    // read-side gate every reopen and every read passes through) then
428    // rejects as an invalid shape — so `checked_add` alone does NOT make
429    // rollover past this point safe, it only stops the much-further-out
430    // u32::MAX wraparound. Catch the real, much lower boundary HERE, before
431    // creating the segment file or touching the manifest, so the failure is
432    // a loud, immediate, uncommitted error at the rollover that would have
433    // crossed it — never a silent write that only reveals itself as a false
434    // `Tamper` on the NEXT read or reopen.
435    if next_index > 99_999_999 {
436        return Err(LedgerError::Io {
437            path: dir.display().to_string(),
438            err: format!(
439                "segment index exhausted: the next segment index ({next_index}) no longer fits \
440                 the 8-digit segment filename shape this ledger's segments use — this deployment \
441                 has performed the maximum ~100,000,000 supported segment rollovers"
442            ),
443        });
444    }
445    let new_filename = segment_filename(next_index);
446    let new_path = dir.join(&new_filename);
447    OpenOptions::new()
448        .create_new(true)
449        .write(true)
450        .open(&new_path)
451        .map_err(|e| io_err(&new_path, e))?;
452
453    let mut new_manifest = manifest.clone();
454    let old_active = new_manifest.active_mut().ok_or_else(|| LedgerError::Io {
455        path: dir.display().to_string(),
456        err: "segmented ledger manifest has no active segment to seal".into(),
457    })?;
458    old_active.end_seq = Some(current_seq);
459    let old_active_file = old_active.file.clone();
460    new_manifest.segments.push(SegmentMeta {
461        file: new_filename,
462        start_seq: current_seq,
463        end_seq: None,
464        opened_ms: next_ts_ms,
465    });
466
467    // Durably flush the outgoing segment's tail BEFORE the manifest rename seals
468    // its `end_seq`. Otherwise a crash here can leave the manifest claiming
469    // records as sealed history that never reached disk — surfacing on reopen as
470    // either a silently-shorter ledger or a false `Tamper` at the segment seam.
471    {
472        let old_path = dir.join(&old_active_file);
473        let f = OpenOptions::new()
474            .append(true)
475            .open(&old_path)
476            .map_err(|e| io_err(&old_path, e))?;
477        f.sync_all().map_err(|e| io_err(&old_path, e))?;
478    }
479
480    save_manifest(dir, &new_manifest)?; // <- the crash-atomic commit point
481    seal_file_permissions(&dir.join(&old_active_file)); // best-effort, after commit
482
483    Ok((new_manifest, new_path))
484}
485
486/// A lazily-opened, seamlessly chained line source across `paths`, in
487/// order — files are opened one at a time as the iterator advances, never all
488/// up front, so a caller that only needs the first few thousand records (a
489/// windowed read, or [`crate::root_at_count`]'s early exit once its target
490/// count is reached) never touches a later segment's bytes at all. This is
491/// the point of segmentation: bounded verification cost, not just bounded
492/// file size.
493pub(crate) struct ChainedLines {
494    paths: std::vec::IntoIter<PathBuf>,
495    current: Option<(PathBuf, Lines<BufReader<Take<File>>>)>,
496    done: bool,
497    /// Byte limit applied to the LAST file only (`u64::MAX` = unbounded, the
498    /// normal case). Used by the torn-tail heal path to read a file up to — but
499    /// not past — its final newline, so a crash-truncated trailing fragment is
500    /// never fed to the verifier. Every non-last file is always read in full.
501    last_limit: u64,
502}
503
504impl Iterator for ChainedLines {
505    type Item = Result<String, LedgerError>;
506
507    fn next(&mut self) -> Option<Self::Item> {
508        if self.done {
509            return None;
510        }
511        loop {
512            if let Some((path, lines)) = self.current.as_mut() {
513                match lines.next() {
514                    Some(Ok(line)) => return Some(Ok(line)),
515                    Some(Err(e)) => {
516                        self.done = true;
517                        return Some(Err(io_err(path, e)));
518                    }
519                    None => {
520                        self.current = None;
521                    }
522                }
523            } else {
524                let next_path = self.paths.next()?;
525                // `paths` is an ExactSizeIterator; if nothing remains after
526                // popping, this was the final file, so the last-file byte limit
527                // applies. All earlier files are read unbounded.
528                let limit = if self.paths.len() == 0 {
529                    self.last_limit
530                } else {
531                    u64::MAX
532                };
533                match File::open(&next_path) {
534                    Ok(f) => {
535                        self.current = Some((next_path, BufReader::new(f.take(limit)).lines()));
536                    }
537                    Err(e) => {
538                        self.done = true;
539                        return Some(Err(io_err(&next_path, e)));
540                    }
541                }
542            }
543        }
544    }
545}
546
547pub(crate) fn chained_lines(paths: Vec<PathBuf>) -> ChainedLines {
548    chained_lines_bounded(paths, u64::MAX)
549}
550
551/// Like [`chained_lines`], but reads at most `last_limit` bytes of the FINAL
552/// file (earlier files always in full). The torn-tail heal path passes the
553/// offset of the final newline so the unterminated trailing fragment is
554/// excluded from verification.
555pub(crate) fn chained_lines_bounded(paths: Vec<PathBuf>, last_limit: u64) -> ChainedLines {
556    ChainedLines {
557        paths: paths.into_iter(),
558        current: None,
559        done: false,
560        last_limit,
561    }
562}