1#![forbid(unsafe_code)]
4use 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
42enum 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 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 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
97struct TornFragment {
102 path: PathBuf,
103 offset: u64,
104}
105
106fn 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 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 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 return Ok(Some(TornFragment {
151 path: path.to_path_buf(),
152 offset: start + idx as u64 + 1,
153 }));
154 }
155 pos = start;
156 }
157 Ok(Some(TornFragment {
159 path: path.to_path_buf(),
160 offset: 0,
161 }))
162}
163
164pub const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
165
166#[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 #[serde(default, skip_serializing_if = "edge_is_attenuate")]
193 pub edge: EdgeType,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub sponsor: Option<Party>,
204 #[serde(default, skip_serializing_if = "is_derived_sponsor")]
210 pub sponsor_source: SponsorSource,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub mission: Option<MissionRef>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub decision_subject: Option<DecisionSubject>,
222 #[serde(default, skip_serializing_if = "is_false")]
227 pub notice_required: bool,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub challenge: Option<ChallengeRecord>,
231 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
252 pub digests: BTreeMap<String, String>,
253}
254
255pub const DIGEST_PARAMETERS: &str = "parameters";
259
260pub const DIGEST_AUTHORITY: &str = "authority";
262
263fn is_false(b: &bool) -> bool {
264 !*b
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct ChallengeRecord {
274 pub decision_ref: String,
276 pub decision_subject: String,
278 pub basis: Vec<String>,
280 pub requested_effect: String,
282 pub outcome: String,
284 pub outcome_basis: String,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub evidence_digest: Option<String>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306pub struct DecisionSubject {
307 pub handle: String,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub scheme: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
315 pub purpose: Option<String>,
316}
317
318impl<'de> Deserialize<'de> for DecisionSubject {
319 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct MissionRef {
356 pub approver: String,
357 pub s256: String,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct Party {
364 pub kind: String,
365 pub id: String,
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
374pub enum EdgeType {
375 #[default]
379 Attenuate,
380 Mint,
385}
386
387fn edge_is_attenuate(e: &EdgeType) -> bool {
388 matches!(e, EdgeType::Attenuate)
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
393pub enum SponsorSource {
394 #[default]
396 Derived,
397 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 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub kid: Option<String>,
420}
421
422#[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#[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 #[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
480fn 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
498pub struct Ledger {
502 location: Location,
503 active_path: PathBuf,
508 key: SigningKey,
509 file: File,
510 last_hash: String,
511 next_seq: u64,
512 verifiers: Vec<VerifyingKey>,
518 sync: bool,
523 rollover: Option<RolloverState>,
528}
529
530struct RolloverState {
531 policy: RolloverPolicy,
532 manifest: segment::Manifest,
533}
534
535fn 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 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 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 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
601fn 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 pub fn open(path: &Path, key: SigningKey) -> Result<Self, LedgerError> {
620 Self::open_with_verifiers(path, key, Vec::new())
621 }
622
623 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 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 pub fn open_anchored(
693 path: &Path,
694 key: SigningKey,
695 retired: Vec<VerifyingKey>,
696 anchor_path: &Path,
697 ) -> Result<Self, LedgerError> {
698 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 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 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 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 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 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 pub fn verifier_fingerprints(&self) -> Vec<String> {
835 self.verifiers.iter().map(key_fingerprint).collect()
836 }
837
838 pub fn set_sync(&mut self, sync: bool) -> &mut Self {
841 self.sync = sync;
842 self
843 }
844
845 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 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 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 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 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 line.push('\n');
923
924 self.file
925 .write_all(line.as_bytes())
926 .and_then(|_| self.file.flush())
927 .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 fn should_roll_over(
958 state: &RolloverState,
959 current_seq: u64,
960 current_bytes: u64,
961 next_ts_ms: u64,
962 ) -> bool {
963 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 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 pub fn root(&self) -> &str {
1027 &self.last_hash
1028 }
1029
1030 pub fn count(&self) -> u64 {
1032 self.next_seq
1033 }
1034
1035 pub fn self_verify(&self) -> Result<VerifyReport, LedgerError> {
1039 verify_inner(&self.location, &self.verifiers, None)
1040 }
1041
1042 pub fn pubkey_hex(&self) -> String {
1045 hex::encode(self.key.verifying_key().to_bytes())
1046 }
1047
1048 pub fn read_records(
1052 &self,
1053 offset: usize,
1054 limit: usize,
1055 ) -> Result<Vec<serde_json::Value>, LedgerError> {
1056 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 pub fn read_raw_records(
1084 &self,
1085 offset: usize,
1086 limit: usize,
1087 ) -> Result<Vec<Box<serde_json::value::RawValue>>, LedgerError> {
1088 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 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 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 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 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 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 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 pub fn snapshot_for_bundle(&self, ts_ms: u64) -> Result<EvidenceSnapshot, LedgerError> {
1230 let count = self.next_seq;
1232 let root = self.last_hash.clone();
1233
1234 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
1322pub struct Checkpoint {
1323 pub root: String,
1324 pub count: u64,
1325 pub ts_ms: u64,
1326 pub pubkey_hex: String,
1328 pub sig_b64: String,
1330}
1331
1332#[derive(Debug, Clone, Serialize, Deserialize)]
1340pub struct TreeHead {
1341 pub merkle_root: String,
1343 pub tree_size: u64,
1344 pub ts_ms: u64,
1345 pub pubkey_hex: String,
1347 pub sig_b64: String,
1349}
1350
1351pub type EvidenceSnapshot = (
1356 u64, Vec<Box<serde_json::value::RawValue>>, Checkpoint, TreeHead, );
1361
1362#[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#[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
1383fn 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
1393fn checkpoint_bytes(root: &str, count: u64, ts_ms: u64) -> Vec<u8> {
1395 commitment_bytes("decern-ledger-checkpoint", root, count, ts_ms)
1396}
1397
1398fn 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
1410fn key_fingerprint(vk: &VerifyingKey) -> String {
1413 hex::encode(vk.to_bytes())
1414}
1415
1416#[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
1428pub 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
1454struct ReadWindow {
1457 offset: usize,
1458 end: usize,
1459 records: Vec<serde_json::Value>,
1460}
1461
1462#[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 let report = verify_lines(lines, keys, sink)?;
1483 match torn {
1484 None => Ok(report),
1485 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
1497fn 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 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 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 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
1616pub(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
1632fn 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
1656pub 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#[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#[derive(Debug, Clone, Serialize)]
1711pub struct BundleVerdict {
1712 pub accepted: bool,
1713 pub format: String,
1714 pub records: usize,
1715 pub from: u64,
1716 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 pub merkle_root_ok: Option<bool>,
1726 pub anchor_ok: bool,
1727 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
1751fn 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
1765pub 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 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() }
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 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 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 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 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 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 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 let consistency_ok = match (against, &b.tree_head, full_tail && chain_ok) {
1930 (Some(earlier), Some(current), true) => {
1931 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(¤t.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
1994fn hex_to_32(s: &str) -> Option<[u8; 32]> {
1996 hex::decode(s).ok()?.try_into().ok()
1997}
1998
1999pub 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
2014pub 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 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
2036pub 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
2049fn 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 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, 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 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 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 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 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 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 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 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(); for i in 0..3 {
2226 l.append(entry(&format!("f{i}"), false)).unwrap();
2227 }
2228 let count = l.count() as usize; 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 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 let other = decern_crypto::generate().unwrap().verifying_key();
2257 assert!(!verify_evidence_bundle(&full, &[other], None).accepted);
2258
2259 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 let mut l = Ledger::open(&path, insider).unwrap();
2461 l.append(entry("MoveMoney", true)).unwrap();
2462 drop(l);
2463
2464 assert!(verify(&path, None).is_ok());
2466 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 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 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 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 assert!(verify_checkpoint_sig(&cp, &key.verifying_key()));
2532 assert!(!verify_checkpoint_sig(&cp, &other.verifying_key()));
2533 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); l.append(entry("Read", true)).unwrap();
2550 drop(l);
2551 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 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 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); drop(l);
2594
2595 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 assert!(!ledger_extends_checkpoint(&path, &cp).unwrap());
2602 }
2603
2604 #[test]
2605 fn decision_entry_serialization_is_stable() {
2606 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 #[test]
2625 fn rotation_keeps_the_whole_chain_verifiable() {
2626 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(); l.append(entry("Write", true)).unwrap(); l.rotate(new.clone());
2636 l.append(entry("MoveMoney", false)).unwrap(); drop(l);
2638
2639 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 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 assert!(
2671 Ledger::open(&path, new.clone()).is_err(),
2672 "reopen without the retired key must fail on the old-signed head"
2673 );
2674 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(); assert!(l.self_verify().is_ok());
2680 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 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(); drop(l);
2698
2699 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 assert!(verify_with_keys(&path, &[honest.verifying_key()]).is_ok());
2708 }
2709
2710 #[test]
2713 fn anchor_catches_truncation_across_a_reopen() {
2714 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(); drop(l);
2728
2729 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 assert!(
2736 Ledger::open(&path, key.clone()).is_ok(),
2737 "plain reopen cannot see the truncation"
2738 );
2739 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(); l.append(entry("Write", true)).unwrap(); drop(l);
2763
2764 let l = Ledger::open_anchored(&path, key, Vec::new(), &anchor).unwrap();
2766 assert_eq!(l.count(), 2);
2767 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 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; (
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 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 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); drop(l);
2848
2849 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 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 let path = tmp("legacy-kidless.ledger");
2877 std::fs::remove_file(&path).ok();
2878 let key = decern_crypto::generate().unwrap();
2879
2880 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 assert!(verify(&path, Some(&key.verifying_key())).is_ok());
2895 assert!(verify_with_keys(&path, &[key.verifying_key()]).is_ok());
2896 }
2897
2898 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), )
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 let report = verify_with_keys(&dir, &[vk]).unwrap();
2964 assert_eq!(report.entries, 8);
2965
2966 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; l.append(e0).unwrap();
2984 let mut e1 = entry("b", true);
2985 e1.ts_ms = 1500; 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 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(); 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 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 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 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 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(), )
3182 .unwrap();
3183 l.append(entry("a", true)).unwrap();
3184 }
3185 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 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 #[test]
3210 fn roll_over_ignores_a_planted_out_of_range_filename_instead_of_overflowing() {
3211 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 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 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 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 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 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 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; 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 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 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 #[test]
3461 fn roll_over_refuses_to_create_a_segment_past_the_8_digit_filename_ceiling() {
3462 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(); 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 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 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; l.append(e0).unwrap();
3531 let mut e1 = entry("b", true);
3532 e1.ts_ms = 1_780_000_000_500; 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 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 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 #[test]
3619 fn segmented_manifest_missing_its_head_segment_is_rejected_including_on_an_already_open_handle()
3620 {
3621 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 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 #[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 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 let report = verify(&path, Some(&vk)).unwrap();
3733 assert_eq!(report.entries, 1);
3734 assert!(report.signatures_checked);
3735
3736 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 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 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 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 let mut l = Ledger::open(&path, key.clone()).unwrap();
3787 l.append(entry("after-heal", true)).unwrap();
3788 drop(l);
3789
3790 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 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 std::fs::write(&path, format!("{}\n{}\n{}", lines[0], lines[1], lines[2])).unwrap();
3809
3810 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 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 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(); 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 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 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 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 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 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 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 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 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 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(); l.append(entry("act3", true)).unwrap(); 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 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 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 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 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 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 let torn = alines.last().unwrap();
4035 body.push_str(&torn[..torn.len() / 2]);
4036 std::fs::write(&active, &body).unwrap();
4037
4038 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}