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}