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 = "Option::is_none")]
235 pub asserted_by: Option<AssertedBy>,
236 #[serde(default, skip_serializing_if = "is_false")]
241 pub notice_required: bool,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub challenge: Option<ChallengeRecord>,
245 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
266 pub digests: BTreeMap<String, String>,
267}
268
269pub const DIGEST_PARAMETERS: &str = "parameters";
273
274pub const DIGEST_AUTHORITY: &str = "authority";
276
277fn is_false(b: &bool) -> bool {
278 !*b
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285pub struct AssertedBy {
286 pub sub: String,
288 pub client_id: String,
290 pub iss: String,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ChallengeRecord {
301 pub decision_ref: String,
303 pub decision_subject: String,
305 pub basis: Vec<String>,
307 pub requested_effect: String,
309 pub outcome: String,
311 pub outcome_basis: String,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub evidence_digest: Option<String>,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333pub struct DecisionSubject {
334 pub handle: String,
336 #[serde(skip_serializing_if = "Option::is_none")]
338 pub scheme: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
342 pub purpose: Option<String>,
343}
344
345impl<'de> Deserialize<'de> for DecisionSubject {
346 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
349 #[derive(Deserialize)]
350 #[serde(untagged)]
351 enum Wire {
352 Handle(String),
353 Full {
354 handle: String,
355 #[serde(default)]
356 scheme: Option<String>,
357 #[serde(default)]
358 purpose: Option<String>,
359 },
360 }
361 Ok(match Wire::deserialize(d)? {
362 Wire::Handle(handle) => DecisionSubject {
363 handle,
364 scheme: None,
365 purpose: None,
366 },
367 Wire::Full {
368 handle,
369 scheme,
370 purpose,
371 } => DecisionSubject {
372 handle,
373 scheme,
374 purpose,
375 },
376 })
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct MissionRef {
383 pub approver: String,
384 pub s256: String,
385}
386
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390pub struct Party {
391 pub kind: String,
392 pub id: String,
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
401pub enum EdgeType {
402 #[default]
406 Attenuate,
407 Mint,
412}
413
414fn edge_is_attenuate(e: &EdgeType) -> bool {
415 matches!(e, EdgeType::Attenuate)
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
420pub enum SponsorSource {
421 #[default]
423 Derived,
424 Explicit,
427}
428
429fn is_derived_sponsor(s: &SponsorSource) -> bool {
430 matches!(s, SponsorSource::Derived)
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct Record {
435 pub entry: Entry,
436 pub prev: String,
437 pub hash: String,
438 pub sig_b64: String,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub kid: Option<String>,
447}
448
449#[derive(Serialize)]
452struct RecordOut<'a> {
453 entry: &'a serde_json::value::RawValue,
454 prev: &'a str,
455 hash: &'a str,
456 sig_b64: &'a str,
457 #[serde(skip_serializing_if = "Option::is_none")]
458 kid: Option<&'a str>,
459}
460
461#[derive(Deserialize)]
464struct RecordIn {
465 entry: Box<serde_json::value::RawValue>,
466 prev: String,
467 hash: String,
468 sig_b64: String,
469 #[serde(default)]
470 kid: Option<String>,
471}
472
473#[derive(Debug, thiserror::Error)]
474#[non_exhaustive]
475pub enum LedgerError {
476 #[error("ledger I/O error at {path}: {err}")]
477 Io { path: String, err: String },
478 #[error("ledger serialization error: {0}")]
479 Serde(String),
480 #[error("TAMPER at seq {seq}: {why}")]
481 Tamper { seq: u64, why: String },
482 #[error(
496 "TORN TAIL: unterminated trailing record (crash mid-append); \
497 {healed_entries} verified records intact before it"
498 )]
499 TornTail {
500 healed_entries: u64,
501 healed_root: Option<String>,
502 torn_path: String,
503 torn_from_offset: u64,
504 },
505}
506
507fn chain_hash(entry_bytes: &[u8], prev_hex: &str) -> [u8; 32] {
512 let mut h = Sha256::new();
513 h.update(entry_bytes);
514 h.update(prev_hex.as_bytes());
515 h.finalize().into()
516}
517
518fn io_err(path: &Path, e: impl std::fmt::Display) -> LedgerError {
519 LedgerError::Io {
520 path: path.display().to_string(),
521 err: e.to_string(),
522 }
523}
524
525pub struct Ledger {
529 location: Location,
530 active_path: PathBuf,
535 key: SigningKey,
536 file: File,
537 last_hash: String,
538 next_seq: u64,
539 verifiers: Vec<VerifyingKey>,
545 sync: bool,
550 rollover: Option<RolloverState>,
555}
556
557struct RolloverState {
558 policy: RolloverPolicy,
559 manifest: segment::Manifest,
560}
561
562fn resolve_open_head(
574 location: &Location,
575 verifiers: &[VerifyingKey],
576 anchor: Option<&Path>,
577) -> Result<(String, u64), LedgerError> {
578 match verify_inner(location, verifiers, None) {
579 Ok(report) => Ok((
580 report.root.unwrap_or_else(|| GENESIS.to_owned()),
581 report.entries,
582 )),
583 Err(LedgerError::TornTail {
584 healed_entries,
585 healed_root,
586 torn_path,
587 torn_from_offset,
588 }) => {
589 if let Some(anchor_path) = anchor
591 && let Some(cp) = load_anchor(anchor_path)?
592 {
593 if !verifiers.iter().any(|k| verify_checkpoint_sig(&cp, k)) {
594 return Err(LedgerError::Tamper {
595 seq: cp.count,
596 why: "anchor signature is not from a trusted ledger key \
597 (forged or wrong-key anchor)"
598 .into(),
599 });
600 }
601 if !ledger_extends_checkpoint_at(location, &cp)? {
605 return Err(LedgerError::Tamper {
606 seq: cp.count,
607 why: format!(
608 "ledger no longer extends its anchor at count {} — the trailing \
609 record is unterminated AND the verified prefix is below the last \
610 committed height (acked history truncated, not a crash tail)",
611 cp.count
612 ),
613 });
614 }
615 }
616 heal_torn_tail(Path::new(&torn_path), torn_from_offset)?;
619 Ok((
620 healed_root.unwrap_or_else(|| GENESIS.to_owned()),
621 healed_entries,
622 ))
623 }
624 Err(e) => Err(e),
625 }
626}
627
628fn heal_torn_tail(path: &Path, offset: u64) -> Result<(), LedgerError> {
634 let f = OpenOptions::new()
635 .write(true)
636 .open(path)
637 .map_err(|e| io_err(path, e))?;
638 f.set_len(offset).map_err(|e| io_err(path, e))?;
639 f.sync_all().map_err(|e| io_err(path, e))?;
640 Ok(())
641}
642
643fn open_append_owner_only(path: &Path) -> Result<File, LedgerError> {
656 #[cfg(unix)]
657 {
658 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
659 let file = OpenOptions::new()
660 .create(true)
661 .append(true)
662 .mode(0o600)
663 .open(path)
664 .map_err(|e| io_err(path, e))?;
665 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
666 Ok(file)
667 }
668 #[cfg(not(unix))]
669 {
670 OpenOptions::new()
671 .create(true)
672 .append(true)
673 .open(path)
674 .map_err(|e| io_err(path, e))
675 }
676}
677
678impl Ledger {
679 pub fn open(path: &Path, key: SigningKey) -> Result<Self, LedgerError> {
682 Self::open_with_verifiers(path, key, Vec::new())
683 }
684
685 pub fn open_with_verifiers(
692 path: &Path,
693 key: SigningKey,
694 retired: Vec<VerifyingKey>,
695 ) -> Result<Self, LedgerError> {
696 Self::open_single_inner(path, key, retired, None)
697 }
698
699 fn open_single_inner(
705 path: &Path,
706 key: SigningKey,
707 retired: Vec<VerifyingKey>,
708 anchor: Option<&Path>,
709 ) -> Result<Self, LedgerError> {
710 if path.is_dir() {
711 return Err(LedgerError::Io {
712 path: path.display().to_string(),
713 err: "this path is a segmented ledger directory — use Ledger::open_segmented \
714 instead of open/open_with_verifiers"
715 .into(),
716 });
717 }
718 let mut verifiers = retired;
719 let current = key.verifying_key();
720 if !verifiers.iter().any(|v| v.to_bytes() == current.to_bytes()) {
721 verifiers.push(current);
722 }
723 let location = Location::Single(path.to_owned());
724 let (last_hash, next_seq) = if path.exists() {
725 resolve_open_head(&location, &verifiers, anchor)?
726 } else {
727 (GENESIS.to_owned(), 0)
728 };
729 let file = open_append_owner_only(path)?;
730 Ok(Ledger {
731 location,
732 active_path: path.to_owned(),
733 key,
734 file,
735 last_hash,
736 next_seq,
737 verifiers,
738 sync: false,
739 rollover: None,
740 })
741 }
742
743 pub fn open_anchored(
751 path: &Path,
752 key: SigningKey,
753 retired: Vec<VerifyingKey>,
754 anchor_path: &Path,
755 ) -> Result<Self, LedgerError> {
756 let ledger = Self::open_single_inner(path, key, retired, Some(anchor_path))?;
761 ledger.verify_against_anchor(anchor_path)?;
762 Ok(ledger)
763 }
764
765 pub fn open_segmented(
783 dir: &Path,
784 key: SigningKey,
785 retired: Vec<VerifyingKey>,
786 policy: RolloverPolicy,
787 ) -> Result<Self, LedgerError> {
788 Self::open_segmented_inner(dir, key, retired, policy, None)
789 }
790
791 fn open_segmented_inner(
797 dir: &Path,
798 key: SigningKey,
799 retired: Vec<VerifyingKey>,
800 policy: RolloverPolicy,
801 anchor: Option<&Path>,
802 ) -> Result<Self, LedgerError> {
803 let mut verifiers = retired;
804 let current = key.verifying_key();
805 if !verifiers.iter().any(|v| v.to_bytes() == current.to_bytes()) {
806 verifiers.push(current);
807 }
808 fs::create_dir_all(dir).map_err(|e| io_err(dir, e))?;
809 let manifest = match segment::load_manifest(dir)? {
810 Some(m) => m,
811 None => segment::initialize(dir)?,
812 };
813 let location = Location::Segmented(dir.to_owned());
814
815 for seg in manifest.segments.iter().filter(|s| s.end_seq.is_some()) {
823 segment::seal_file_permissions(&dir.join(&seg.file));
824 }
825 let active = manifest
826 .active()
827 .ok_or_else(|| LedgerError::Io {
828 path: dir.display().to_string(),
829 err: "segmented ledger manifest has no active (unsealed) segment".into(),
830 })?
831 .clone();
832 let active_path = dir.join(&active.file);
833 segment::unseal_file_permissions(&active_path);
834
835 let (last_hash, next_seq) = resolve_open_head(&location, &verifiers, anchor)?;
836
837 let file = open_append_owner_only(&active_path)?;
838 Ok(Ledger {
839 location,
840 active_path,
841 key,
842 file,
843 last_hash,
844 next_seq,
845 verifiers,
846 sync: false,
847 rollover: Some(RolloverState { policy, manifest }),
848 })
849 }
850
851 pub fn open_segmented_anchored(
857 dir: &Path,
858 key: SigningKey,
859 retired: Vec<VerifyingKey>,
860 policy: RolloverPolicy,
861 anchor_path: &Path,
862 ) -> Result<Self, LedgerError> {
863 let ledger = Self::open_segmented_inner(dir, key, retired, policy, Some(anchor_path))?;
864 ledger.verify_against_anchor(anchor_path)?;
865 Ok(ledger)
866 }
867
868 pub fn rotate(&mut self, new_key: SigningKey) {
875 let current = new_key.verifying_key();
876 if !self
877 .verifiers
878 .iter()
879 .any(|v| v.to_bytes() == current.to_bytes())
880 {
881 self.verifiers.push(current);
882 }
883 self.key = new_key;
884 }
885
886 pub fn verifier_fingerprints(&self) -> Vec<String> {
889 self.verifiers.iter().map(key_fingerprint).collect()
890 }
891
892 pub fn set_sync(&mut self, sync: bool) -> &mut Self {
895 self.sync = sync;
896 self
897 }
898
899 pub fn append(&mut self, mut entry: Entry) -> Result<Record, LedgerError> {
903 if let Some(state) = &self.rollover {
904 let current_bytes = self.file.metadata().map(|m| m.len()).unwrap_or(0);
905 if Self::should_roll_over(state, self.next_seq, current_bytes, entry.ts_ms) {
906 self.roll_over(entry.ts_ms)?;
907 }
908 }
909 let this_seq = self.next_seq;
920 if let Some(state) = &self.rollover {
921 let needs_rebase = state
922 .manifest
923 .active()
924 .is_some_and(|s| s.start_seq == this_seq && s.opened_ms != entry.ts_ms);
925 if needs_rebase {
926 let mut rebased = state.manifest.clone();
935 if let Some(active) = rebased.active_mut() {
936 active.opened_ms = entry.ts_ms;
937 }
938 if let Location::Segmented(dir) = &self.location {
939 segment::save_manifest(dir, &rebased)?;
940 }
941 if let Some(state) = &mut self.rollover {
942 state.manifest = rebased;
943 }
944 }
945 }
946 entry.seq = self.next_seq;
947 let entry_json =
950 serde_json::to_string(&entry).map_err(|e| LedgerError::Serde(e.to_string()))?;
951 let hash = chain_hash(entry_json.as_bytes(), &self.last_hash);
952 let sig = self.key.sign(&hash);
953 let hash_hex = hex::encode(hash);
954 let sig_b64 = B64.encode(sig.to_bytes());
955 let kid = key_fingerprint(&self.key.verifying_key());
958
959 let raw_entry = serde_json::value::RawValue::from_string(entry_json)
960 .map_err(|e| LedgerError::Serde(e.to_string()))?;
961 let mut line = serde_json::to_string(&RecordOut {
962 entry: &raw_entry,
963 prev: &self.last_hash,
964 hash: &hash_hex,
965 sig_b64: &sig_b64,
966 kid: Some(&kid),
967 })
968 .map_err(|e| LedgerError::Serde(e.to_string()))?;
969 line.push('\n');
977
978 self.file
979 .write_all(line.as_bytes())
980 .and_then(|_| self.file.flush())
981 .and_then(|_| {
985 if self.sync {
986 self.file.sync_data()
987 } else {
988 Ok(())
989 }
990 })
991 .map_err(|e| io_err(&self.active_path, e))?;
992
993 let record = Record {
994 entry,
995 prev: std::mem::replace(&mut self.last_hash, hash_hex.clone()),
996 hash: hash_hex,
997 sig_b64,
998 kid: Some(kid),
999 };
1000 self.next_seq += 1;
1001 Ok(record)
1002 }
1003
1004 fn should_roll_over(
1012 state: &RolloverState,
1013 current_seq: u64,
1014 current_bytes: u64,
1015 next_ts_ms: u64,
1016 ) -> bool {
1017 let active_is_empty = state
1026 .manifest
1027 .active()
1028 .is_some_and(|s| s.start_seq == current_seq);
1029 if active_is_empty {
1030 return false;
1031 }
1032 if let Some(max) = state.policy.max_bytes
1033 && current_bytes >= max
1034 {
1035 return true;
1036 }
1037 if let Some(epoch) = state.policy.epoch_ms {
1038 let opened = state
1039 .manifest
1040 .active()
1041 .map(|s| s.opened_ms)
1042 .unwrap_or(next_ts_ms);
1043 if epoch > 0 && next_ts_ms / epoch != opened / epoch {
1044 return true;
1045 }
1046 }
1047 false
1048 }
1049
1050 fn roll_over(&mut self, next_ts_ms: u64) -> Result<(), LedgerError> {
1055 let Location::Segmented(dir) = &self.location else {
1056 return Err(LedgerError::Io {
1057 path: self.active_path.display().to_string(),
1058 err: "internal error: roll_over called on a non-segmented ledger".into(),
1059 });
1060 };
1061 let dir = dir.clone();
1062 let state = self.rollover.as_ref().ok_or_else(|| LedgerError::Io {
1063 path: dir.display().to_string(),
1064 err: "internal error: roll_over called with no rollover state".into(),
1065 })?;
1066 let (new_manifest, new_path) =
1067 segment::roll_over(&dir, &state.manifest, self.next_seq, next_ts_ms)?;
1068 self.file = OpenOptions::new()
1069 .append(true)
1070 .open(&new_path)
1071 .map_err(|e| io_err(&new_path, e))?;
1072 self.active_path = new_path;
1073 if let Some(state) = self.rollover.as_mut() {
1074 state.manifest = new_manifest;
1075 }
1076 Ok(())
1077 }
1078
1079 pub fn root(&self) -> &str {
1081 &self.last_hash
1082 }
1083
1084 pub fn count(&self) -> u64 {
1086 self.next_seq
1087 }
1088
1089 pub fn self_verify(&self) -> Result<VerifyReport, LedgerError> {
1093 verify_inner(&self.location, &self.verifiers, None)
1094 }
1095
1096 pub fn pubkey_hex(&self) -> String {
1099 hex::encode(self.key.verifying_key().to_bytes())
1100 }
1101
1102 pub fn raw_records(&self) -> Result<Vec<String>, LedgerError> {
1107 let mut out = Vec::new();
1108 for line in self.location.lines()? {
1109 let line = line?;
1110 if line.trim().is_empty() {
1111 continue;
1112 }
1113 out.push(line);
1114 }
1115 Ok(out)
1116 }
1117
1118 pub fn read_records(
1122 &self,
1123 offset: usize,
1124 limit: usize,
1125 ) -> Result<Vec<serde_json::Value>, LedgerError> {
1126 let mut out = Vec::new();
1132 for line in self.location.lines()?.skip(offset) {
1133 if out.len() >= limit {
1134 break;
1135 }
1136 let line = line?;
1137 if line.trim().is_empty() {
1138 continue;
1139 }
1140 let v = serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
1141 out.push(v);
1142 }
1143 Ok(out)
1144 }
1145
1146 pub fn read_raw_records(
1154 &self,
1155 offset: usize,
1156 limit: usize,
1157 ) -> Result<Vec<Box<serde_json::value::RawValue>>, LedgerError> {
1158 let mut out = Vec::new();
1163 for line in self.location.lines()?.skip(offset) {
1164 if out.len() >= limit {
1165 break;
1166 }
1167 let line = line?;
1168 if line.trim().is_empty() {
1169 continue;
1170 }
1171 let v: Box<serde_json::value::RawValue> =
1172 serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
1173 out.push(v);
1174 }
1175 Ok(out)
1176 }
1177
1178 pub fn checkpoint(&self, ts_ms: u64) -> Checkpoint {
1183 let root = self.last_hash.clone();
1184 let count = self.next_seq;
1185 let sig = self.key.sign(&checkpoint_bytes(&root, count, ts_ms));
1186 Checkpoint {
1187 root,
1188 count,
1189 ts_ms,
1190 pubkey_hex: self.pubkey_hex(),
1191 sig_b64: B64.encode(sig.to_bytes()),
1192 }
1193 }
1194
1195 fn merkle_leaves(&self) -> Result<Vec<Vec<u8>>, LedgerError> {
1201 let count = self.next_seq as usize;
1202 leaves_from_records(&self.read_records(0, count)?)
1203 }
1204
1205 pub fn tree_head(&self, ts_ms: u64) -> Result<TreeHead, LedgerError> {
1210 let leaves = self.merkle_leaves()?;
1211 let root_hex = hex::encode(merkle::tree_hash(&leaves));
1212 Ok(self.sign_tree_head(root_hex, leaves.len() as u64, ts_ms))
1213 }
1214
1215 pub fn sign_tree_head(&self, merkle_root: String, tree_size: u64, ts_ms: u64) -> TreeHead {
1221 let sig = self
1222 .key
1223 .sign(&tree_head_bytes(&merkle_root, tree_size, ts_ms));
1224 TreeHead {
1225 merkle_root,
1226 tree_size,
1227 ts_ms,
1228 pubkey_hex: self.pubkey_hex(),
1229 sig_b64: B64.encode(sig.to_bytes()),
1230 }
1231 }
1232
1233 pub fn inclusion_proof(&self, seq: u64) -> Result<InclusionProof, LedgerError> {
1236 let leaves = self.merkle_leaves()?;
1237 let idx = seq as usize;
1238 let path = merkle::inclusion_proof(&leaves, idx).ok_or_else(|| LedgerError::Tamper {
1239 seq,
1240 why: "inclusion index past the end of the log".into(),
1241 })?;
1242 Ok(InclusionProof {
1243 leaf_index: seq,
1244 tree_size: leaves.len() as u64,
1245 leaf_data: hex::encode(&leaves[idx]),
1246 audit_path: path.iter().map(hex::encode).collect(),
1247 })
1248 }
1249
1250 pub fn inclusion_proofs(&self, seqs: &[u64]) -> Result<Vec<InclusionProof>, LedgerError> {
1261 inclusion_proofs_over(&self.merkle_leaves()?, seqs)
1262 }
1263
1264 pub fn consistency_proof(&self, first_size: u64) -> Result<ConsistencyProof, LedgerError> {
1268 let leaves = self.merkle_leaves()?;
1269 let path = merkle::consistency_proof(&leaves, first_size as usize).ok_or_else(|| {
1270 LedgerError::Tamper {
1271 seq: first_size,
1272 why: "consistency first_size out of range (need 1..=count)".into(),
1273 }
1274 })?;
1275 Ok(ConsistencyProof {
1276 first_size,
1277 second_size: leaves.len() as u64,
1278 proof: path.iter().map(hex::encode).collect(),
1279 })
1280 }
1281
1282 pub fn snapshot_for_bundle(&self, ts_ms: u64) -> Result<EvidenceSnapshot, LedgerError> {
1293 let count = self.next_seq;
1295 let root = self.last_hash.clone();
1296
1297 let raw_records = self.read_raw_records(0, count as usize)?;
1300
1301 let cp_sig = self.key.sign(&checkpoint_bytes(&root, count, ts_ms));
1302 let checkpoint = Checkpoint {
1303 root,
1304 count,
1305 ts_ms,
1306 pubkey_hex: self.pubkey_hex(),
1307 sig_b64: B64.encode(cp_sig.to_bytes()),
1308 };
1309
1310 let leaves = leaves_from_records(&self.read_records(0, count as usize)?)?;
1311 let merkle_root = hex::encode(merkle::tree_hash(&leaves));
1312 let tree_size = leaves.len() as u64;
1313 let th_sig = self
1314 .key
1315 .sign(&tree_head_bytes(&merkle_root, tree_size, ts_ms));
1316 let tree_head = TreeHead {
1317 merkle_root,
1318 tree_size,
1319 ts_ms,
1320 pubkey_hex: self.pubkey_hex(),
1321 sig_b64: B64.encode(th_sig.to_bytes()),
1322 };
1323
1324 Ok((count, raw_records, checkpoint, tree_head))
1325 }
1326
1327 pub fn seal_anchor(&self, anchor_path: &Path, ts_ms: u64) -> Result<Checkpoint, LedgerError> {
1334 let cp = self.checkpoint(ts_ms);
1335 save_anchor(anchor_path, &cp)?;
1336 Ok(cp)
1337 }
1338
1339 pub fn verify_against_anchor(&self, anchor_path: &Path) -> Result<(), LedgerError> {
1347 let Some(cp) = load_anchor(anchor_path)? else {
1348 return Ok(());
1349 };
1350 if !self.verifiers.iter().any(|k| verify_checkpoint_sig(&cp, k)) {
1354 return Err(LedgerError::Tamper {
1355 seq: cp.count,
1356 why:
1357 "anchor signature is not from a trusted ledger key (forged or wrong-key anchor)"
1358 .into(),
1359 });
1360 }
1361 if !ledger_extends_checkpoint_at(&self.location, &cp)? {
1363 return Err(LedgerError::Tamper {
1364 seq: cp.count,
1365 why: format!(
1366 "ledger no longer extends its anchor at count {} — truncated or rewritten \
1367 below the last committed height",
1368 cp.count
1369 ),
1370 });
1371 }
1372 Ok(())
1373 }
1374}
1375
1376#[derive(Debug, Clone, Serialize, Deserialize)]
1385pub struct Checkpoint {
1386 pub root: String,
1387 pub count: u64,
1388 pub ts_ms: u64,
1389 pub pubkey_hex: String,
1391 pub sig_b64: String,
1393}
1394
1395#[derive(Debug, Clone, Serialize, Deserialize)]
1403pub struct TreeHead {
1404 pub merkle_root: String,
1406 pub tree_size: u64,
1407 pub ts_ms: u64,
1408 pub pubkey_hex: String,
1410 pub sig_b64: String,
1412}
1413
1414pub type EvidenceSnapshot = (
1419 u64, Vec<Box<serde_json::value::RawValue>>, Checkpoint, TreeHead, );
1424
1425#[derive(Debug, Clone, Serialize, Deserialize)]
1430pub struct InclusionProof {
1431 pub leaf_index: u64,
1432 pub tree_size: u64,
1433 pub leaf_data: String,
1434 pub audit_path: Vec<String>,
1435}
1436
1437#[derive(Debug, Clone, Serialize, Deserialize)]
1440pub struct ConsistencyProof {
1441 pub first_size: u64,
1442 pub second_size: u64,
1443 pub proof: Vec<String>,
1444}
1445
1446fn commitment_bytes(tag: &str, hex_field: &str, a: u64, b: u64) -> Vec<u8> {
1453 format!("{tag}\x1f{hex_field}\x1f{a}\x1f{b}").into_bytes()
1454}
1455
1456fn checkpoint_bytes(root: &str, count: u64, ts_ms: u64) -> Vec<u8> {
1458 commitment_bytes("decern-ledger-checkpoint", root, count, ts_ms)
1459}
1460
1461fn tree_head_bytes(merkle_root: &str, tree_size: u64, ts_ms: u64) -> Vec<u8> {
1463 commitment_bytes("decern-ledger-tree-head", merkle_root, tree_size, ts_ms)
1464}
1465
1466#[derive(Debug)]
1467pub struct VerifyReport {
1468 pub entries: u64,
1469 pub root: Option<String>,
1470 pub signatures_checked: bool,
1471}
1472
1473fn key_fingerprint(vk: &VerifyingKey) -> String {
1476 hex::encode(vk.to_bytes())
1477}
1478
1479#[must_use = "ledger verification failure must be checked"]
1483pub fn verify(path: &Path, pubkey: Option<&VerifyingKey>) -> Result<VerifyReport, LedgerError> {
1484 let loc = Location::detect(path);
1485 match pubkey {
1486 None => verify_inner(&loc, &[], None),
1487 Some(k) => verify_inner(&loc, std::slice::from_ref(k), None),
1488 }
1489}
1490
1491pub fn read_verified(
1499 path: &Path,
1500 pubkey: Option<&VerifyingKey>,
1501 offset: usize,
1502 limit: usize,
1503) -> Result<(VerifyReport, Vec<serde_json::Value>), LedgerError> {
1504 let loc = Location::detect(path);
1505 let mut window = ReadWindow {
1506 offset,
1507 end: offset.saturating_add(limit),
1508 records: Vec::new(),
1509 };
1510 let report = match pubkey {
1511 None => verify_inner(&loc, &[], Some(&mut window)),
1512 Some(k) => verify_inner(&loc, std::slice::from_ref(k), Some(&mut window)),
1513 }?;
1514 Ok((report, window.records))
1515}
1516
1517struct ReadWindow {
1520 offset: usize,
1521 end: usize,
1522 records: Vec<serde_json::Value>,
1523}
1524
1525#[must_use = "ledger verification failure must be checked"]
1532pub fn verify_with_keys(path: &Path, keys: &[VerifyingKey]) -> Result<VerifyReport, LedgerError> {
1533 verify_inner(&Location::detect(path), keys, None)
1534}
1535
1536fn verify_inner(
1537 location: &Location,
1538 keys: &[VerifyingKey],
1539 sink: Option<&mut ReadWindow>,
1540) -> Result<VerifyReport, LedgerError> {
1541 let (lines, torn) = location.prefix_lines()?;
1542 let report = verify_lines(lines, keys, sink)?;
1546 match torn {
1547 None => Ok(report),
1548 Some(t) => Err(LedgerError::TornTail {
1552 healed_entries: report.entries,
1553 healed_root: report.root,
1554 torn_path: t.path.display().to_string(),
1555 torn_from_offset: t.offset,
1556 }),
1557 }
1558}
1559
1560fn verify_lines(
1567 lines: impl Iterator<Item = Result<String, LedgerError>>,
1568 keys: &[VerifyingKey],
1569 mut sink: Option<&mut ReadWindow>,
1570) -> Result<VerifyReport, LedgerError> {
1571 let check_sigs = !keys.is_empty();
1572
1573 let mut prev = GENESIS.to_owned();
1574 let mut count: u64 = 0;
1575
1576 for (i, line) in lines.enumerate() {
1577 let line = line?;
1578 if line.trim().is_empty() {
1579 continue;
1580 }
1581 let record: RecordIn = serde_json::from_str(&line).map_err(|e| LedgerError::Tamper {
1582 seq: i as u64,
1583 why: format!("unparseable record: {e}"),
1584 })?;
1585
1586 let entry_bytes = record.entry.get().as_bytes();
1588 let entry: Entry =
1589 serde_json::from_str(record.entry.get()).map_err(|e| LedgerError::Tamper {
1590 seq: count,
1591 why: format!("unparseable entry: {e}"),
1592 })?;
1593
1594 if entry.seq != count {
1595 return Err(LedgerError::Tamper {
1596 seq: count,
1597 why: format!("sequence break (found seq {})", entry.seq),
1598 });
1599 }
1600 if record.prev != prev {
1601 return Err(LedgerError::Tamper {
1602 seq: count,
1603 why: "broken chain link (prev mismatch — record edited, moved or removed)".into(),
1604 });
1605 }
1606
1607 let hash = chain_hash(entry_bytes, &record.prev);
1608 if hex::encode(hash) != record.hash {
1609 return Err(LedgerError::Tamper {
1610 seq: count,
1611 why: "entry altered (hash mismatch)".into(),
1612 });
1613 }
1614
1615 if check_sigs {
1616 let sig_bytes: [u8; 64] = B64
1617 .decode(&record.sig_b64)
1618 .map_err(|_| LedgerError::Tamper {
1619 seq: count,
1620 why: "unparseable signature".into(),
1621 })?
1622 .try_into()
1623 .map_err(|_| LedgerError::Tamper {
1624 seq: count,
1625 why: "signature length".into(),
1626 })?;
1627 let sig = decern_crypto::Signature::from_bytes(&sig_bytes);
1628
1629 let verified = match &record.kid {
1633 Some(kid) => match keys.iter().find(|k| key_fingerprint(k) == *kid) {
1634 Some(k) => k.verify_strict(&hash, &sig).is_ok(),
1635 None => {
1636 return Err(LedgerError::Tamper {
1637 seq: count,
1638 why: format!(
1639 "record signed by key {kid}, which is not in the trusted keyring"
1640 ),
1641 });
1642 }
1643 },
1644 None => keys.iter().any(|k| k.verify_strict(&hash, &sig).is_ok()),
1645 };
1646 if !verified {
1647 return Err(LedgerError::Tamper {
1648 seq: count,
1649 why: "signature invalid (chain rewritten with a different key?)".into(),
1650 });
1651 }
1652 }
1653
1654 if let Some(w) = sink.as_deref_mut() {
1660 let idx = count as usize;
1661 if idx >= w.offset && idx < w.end {
1662 let value: serde_json::Value =
1663 serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
1664 w.records.push(value);
1665 }
1666 }
1667
1668 prev = record.hash;
1669 count += 1;
1670 }
1671
1672 Ok(VerifyReport {
1673 entries: count,
1674 root: if count > 0 { Some(prev) } else { None },
1675 signatures_checked: check_sigs,
1676 })
1677}
1678
1679pub(crate) fn verify_stored_records(
1685 records: &[decern_store::StoredRecord],
1686 keys: &[VerifyingKey],
1687) -> Result<VerifyReport, LedgerError> {
1688 verify_lines(
1689 records.iter().map(|r| Ok(r.record_json.clone())),
1690 keys,
1691 None,
1692 )
1693}
1694
1695pub fn leaves_from_lines(lines: &[String]) -> Result<Vec<Vec<u8>>, LedgerError> {
1705 #[derive(serde::Deserialize)]
1706 struct HashOnly {
1707 hash: String,
1708 }
1709 let mut leaves = Vec::with_capacity(lines.len());
1710 for (i, line) in lines.iter().enumerate() {
1711 let h: HashOnly = serde_json::from_str(line).map_err(|_| LedgerError::Tamper {
1712 seq: i as u64,
1713 why: "record missing hash field".into(),
1714 })?;
1715 let bytes = hex::decode(&h.hash).map_err(|_| LedgerError::Tamper {
1716 seq: i as u64,
1717 why: "record hash is not valid hex".into(),
1718 })?;
1719 leaves.push(bytes);
1720 }
1721 Ok(leaves)
1722}
1723
1724pub fn inclusion_proofs_over(
1729 leaves: &[Vec<u8>],
1730 seqs: &[u64],
1731) -> Result<Vec<InclusionProof>, LedgerError> {
1732 let tree_size = leaves.len() as u64;
1733 seqs.iter()
1734 .map(|&seq| {
1735 let idx = seq as usize;
1736 let path = merkle::inclusion_proof(leaves, idx).ok_or_else(|| LedgerError::Tamper {
1737 seq,
1738 why: "inclusion index past the end of the log".into(),
1739 })?;
1740 Ok(InclusionProof {
1741 leaf_index: seq,
1742 tree_size,
1743 leaf_data: hex::encode(&leaves[idx]),
1744 audit_path: path.iter().map(hex::encode).collect(),
1745 })
1746 })
1747 .collect()
1748}
1749
1750fn leaves_from_records(recs: &[serde_json::Value]) -> Result<Vec<Vec<u8>>, LedgerError> {
1751 let mut leaves = Vec::with_capacity(recs.len());
1752 for (i, r) in recs.iter().enumerate() {
1753 let hash_hex = r
1754 .get("hash")
1755 .and_then(serde_json::Value::as_str)
1756 .ok_or_else(|| LedgerError::Tamper {
1757 seq: i as u64,
1758 why: "record missing hash field".into(),
1759 })?;
1760 let bytes = hex::decode(hash_hex).map_err(|_| LedgerError::Tamper {
1761 seq: i as u64,
1762 why: "record hash is not valid hex".into(),
1763 })?;
1764 leaves.push(bytes);
1765 }
1766 Ok(leaves)
1767}
1768
1769pub fn merkle_leaves_at(
1779 path: &Path,
1780 pubkey: Option<&VerifyingKey>,
1781) -> Result<Vec<Vec<u8>>, LedgerError> {
1782 let (_report, records) = read_verified(path, pubkey, 0, usize::MAX)?;
1783 leaves_from_records(&records)
1784}
1785
1786#[must_use = "signature verification result must be checked"]
1787pub fn verify_checkpoint_sig(cp: &Checkpoint, pubkey: &VerifyingKey) -> bool {
1788 let Ok(bytes) = B64.decode(&cp.sig_b64) else {
1789 return false;
1790 };
1791 let Ok(sig_arr): Result<[u8; 64], _> = bytes.try_into() else {
1792 return false;
1793 };
1794 let sig = decern_crypto::Signature::from_bytes(&sig_arr);
1795 pubkey
1796 .verify_strict(&checkpoint_bytes(&cp.root, cp.count, cp.ts_ms), &sig)
1797 .is_ok()
1798}
1799
1800#[must_use = "signature verification result must be checked"]
1804pub fn verify_tree_head_sig(th: &TreeHead, pubkey: &VerifyingKey) -> bool {
1805 let Ok(bytes) = B64.decode(&th.sig_b64) else {
1806 return false;
1807 };
1808 let Ok(sig_arr): Result<[u8; 64], _> = bytes.try_into() else {
1809 return false;
1810 };
1811 let sig = decern_crypto::Signature::from_bytes(&sig_arr);
1812 pubkey
1813 .verify_strict(
1814 &tree_head_bytes(&th.merkle_root, th.tree_size, th.ts_ms),
1815 &sig,
1816 )
1817 .is_ok()
1818}
1819
1820#[derive(Debug, Clone, Serialize)]
1824pub struct BundleVerdict {
1825 pub accepted: bool,
1826 pub format: String,
1827 pub records: usize,
1828 pub from: u64,
1829 pub full_tail: bool,
1832 pub chain_ok: bool,
1833 pub record_sigs_ok: bool,
1834 pub checkpoint_sig_ok: bool,
1835 pub tree_head_present: bool,
1836 pub tree_head_sig_ok: bool,
1837 pub merkle_root_ok: Option<bool>,
1839 pub anchor_ok: bool,
1840 pub consistency_ok: Option<bool>,
1842 pub errors: Vec<String>,
1843}
1844
1845#[derive(Deserialize)]
1846struct BundleIn {
1847 #[serde(default)]
1848 format: String,
1849 #[serde(default)]
1850 span: SpanIn,
1851 checkpoint: Checkpoint,
1852 #[serde(default)]
1853 tree_head: Option<TreeHead>,
1854 #[serde(default)]
1855 records: Vec<RecordIn>,
1856}
1857
1858#[derive(Deserialize, Default)]
1859struct SpanIn {
1860 #[serde(default)]
1861 from: u64,
1862}
1863
1864fn any_key_verifies(msg: &[u8], sig_b64: &str, keys: &[VerifyingKey]) -> bool {
1868 let Ok(bytes) = B64.decode(sig_b64) else {
1869 return false;
1870 };
1871 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
1872 return false;
1873 };
1874 let sig = decern_crypto::Signature::from_bytes(&arr);
1875 keys.iter().any(|k| k.verify_strict(msg, &sig).is_ok())
1876}
1877
1878pub fn verify_evidence_bundle(
1892 bundle_json: &str,
1893 keys: &[VerifyingKey],
1894 against: Option<&TreeHead>,
1895) -> BundleVerdict {
1896 let mut errors: Vec<String> = Vec::new();
1897 let b: BundleIn = match serde_json::from_str(bundle_json) {
1898 Ok(b) => b,
1899 Err(e) => {
1900 return BundleVerdict {
1901 accepted: false,
1902 format: String::new(),
1903 records: 0,
1904 from: 0,
1905 full_tail: false,
1906 chain_ok: false,
1907 record_sigs_ok: false,
1908 checkpoint_sig_ok: false,
1909 tree_head_present: false,
1910 tree_head_sig_ok: false,
1911 merkle_root_ok: None,
1912 anchor_ok: false,
1913 consistency_ok: None,
1914 errors: vec![format!("bundle does not parse: {e}")],
1915 };
1916 }
1917 };
1918
1919 let from = b.span.from;
1920 let full_tail = from == 0;
1921 let n = b.records.len();
1922
1923 let mut chain_ok = true;
1925 let mut leaves: Vec<Vec<u8>> = Vec::with_capacity(n);
1926 for (i, r) in b.records.iter().enumerate() {
1927 let want = hex::encode(chain_hash(r.entry.get().as_bytes(), &r.prev));
1928 if want != r.hash {
1929 chain_ok = false;
1930 errors.push(format!(
1931 "record {i}: hash does not re-derive from its bytes"
1932 ));
1933 }
1934 let expected_prev = if i == 0 {
1935 if full_tail {
1936 GENESIS.to_owned()
1937 } else {
1938 r.prev.clone() }
1940 } else {
1941 b.records[i - 1].hash.clone()
1942 };
1943 if r.prev != expected_prev {
1944 chain_ok = false;
1945 errors.push(format!(
1946 "record {i}: prev does not link to the previous record"
1947 ));
1948 }
1949 match hex::decode(&r.hash) {
1950 Ok(bytes) => leaves.push(bytes),
1951 Err(_) => {
1952 chain_ok = false;
1953 errors.push(format!("record {i}: hash is not valid hex"));
1954 }
1955 }
1956 }
1957
1958 let mut record_sigs_ok = true;
1960 for (i, r) in b.records.iter().enumerate() {
1961 let Ok(msg) = hex::decode(&r.hash) else {
1962 record_sigs_ok = false;
1963 continue;
1964 };
1965 if !any_key_verifies(&msg, &r.sig_b64, keys) {
1966 record_sigs_ok = false;
1967 errors.push(format!(
1968 "record {i}: signature not verified by any pinned key"
1969 ));
1970 }
1971 }
1972
1973 let checkpoint_sig_ok = keys.iter().any(|k| verify_checkpoint_sig(&b.checkpoint, k));
1975 if !checkpoint_sig_ok {
1976 errors.push("checkpoint signature not verified by any pinned key".into());
1977 }
1978 let tree_head_present = b.tree_head.is_some();
1979 let tree_head_sig_ok = match &b.tree_head {
1980 Some(th) => {
1981 let ok = keys.iter().any(|k| verify_tree_head_sig(th, k));
1982 if !ok {
1983 errors.push("tree-head signature not verified by any pinned key".into());
1984 }
1985 ok
1986 }
1987 None => {
1988 errors.push(
1993 "bundle has no tree_head (Merkle commitment); this verifier requires the \
1994 upgraded decern-evidence-bundle shape"
1995 .into(),
1996 );
1997 false
1998 }
1999 };
2000
2001 let mut anchor_ok = true;
2003 match b.records.last() {
2004 Some(last) if last.hash == b.checkpoint.root => {}
2005 Some(_) => {
2006 anchor_ok = false;
2007 errors.push("last record hash != checkpoint root".into());
2008 }
2009 None => {
2010 if from != b.checkpoint.count {
2012 anchor_ok = false;
2013 errors.push("empty tail but from != checkpoint count".into());
2014 }
2015 }
2016 }
2017 if from + n as u64 != b.checkpoint.count {
2018 anchor_ok = false;
2019 errors.push("from + record count != checkpoint count".into());
2020 }
2021 if let Some(th) = &b.tree_head
2022 && th.tree_size != b.checkpoint.count
2023 {
2024 anchor_ok = false;
2025 errors.push("tree_head size != checkpoint count".into());
2026 }
2027
2028 let merkle_root_ok = match (&b.tree_head, full_tail && chain_ok) {
2030 (Some(th), true) => {
2031 let recomputed = hex::encode(merkle::tree_hash(&leaves));
2032 let ok = recomputed == th.merkle_root;
2033 if !ok {
2034 errors.push("recomputed Merkle root != signed tree head".into());
2035 }
2036 Some(ok)
2037 }
2038 _ => None,
2039 };
2040
2041 let consistency_ok = match (against, &b.tree_head, full_tail && chain_ok) {
2043 (Some(earlier), Some(current), true) => {
2044 let earlier_sig = keys.iter().any(|k| verify_tree_head_sig(earlier, k));
2046 let first = earlier.tree_size as usize;
2047 let ok = earlier_sig
2048 && first <= leaves.len()
2049 && merkle::consistency_proof(&leaves, first).is_some_and(|path| {
2050 match (
2051 hex_to_32(&earlier.merkle_root),
2052 hex_to_32(¤t.merkle_root),
2053 ) {
2054 (Some(fr), Some(sr)) => merkle::verify_consistency(
2055 earlier.tree_size,
2056 current.tree_size,
2057 &fr,
2058 &sr,
2059 &path,
2060 ),
2061 _ => false,
2062 }
2063 });
2064 if !ok {
2065 errors.push(
2066 "consistency proof against the earlier anchored tree head FAILED \
2067 (possible equivocation/truncation, or the earlier head is unsigned)"
2068 .into(),
2069 );
2070 }
2071 Some(ok)
2072 }
2073 (Some(_), _, false) => {
2074 errors
2075 .push("consistency check needs a full tail (from==0) to recompute; skipped".into());
2076 Some(false)
2077 }
2078 _ => None,
2079 };
2080
2081 let accepted = chain_ok
2082 && record_sigs_ok
2083 && checkpoint_sig_ok
2084 && tree_head_sig_ok
2085 && anchor_ok
2086 && merkle_root_ok.unwrap_or(true)
2087 && consistency_ok.unwrap_or(true);
2088
2089 BundleVerdict {
2090 accepted,
2091 format: b.format,
2092 records: n,
2093 from,
2094 full_tail,
2095 chain_ok,
2096 record_sigs_ok,
2097 checkpoint_sig_ok,
2098 tree_head_present,
2099 tree_head_sig_ok,
2100 merkle_root_ok,
2101 anchor_ok,
2102 consistency_ok,
2103 errors,
2104 }
2105}
2106
2107fn hex_to_32(s: &str) -> Option<[u8; 32]> {
2109 hex::decode(s).ok()?.try_into().ok()
2110}
2111
2112pub fn ledger_extends_checkpoint(path: &Path, cp: &Checkpoint) -> Result<bool, LedgerError> {
2120 ledger_extends_checkpoint_at(&Location::detect(path), cp)
2121}
2122
2123fn ledger_extends_checkpoint_at(location: &Location, cp: &Checkpoint) -> Result<bool, LedgerError> {
2124 Ok(root_at_count(location, cp.count)?.as_deref() == Some(cp.root.as_str()))
2125}
2126
2127pub fn save_anchor(anchor_path: &Path, cp: &Checkpoint) -> Result<(), LedgerError> {
2132 let bytes = serde_json::to_vec_pretty(cp).map_err(|e| LedgerError::Serde(e.to_string()))?;
2133 let tmp = anchor_path.with_extension("anchor-tmp");
2134 {
2135 let mut f = File::create(&tmp).map_err(|e| io_err(&tmp, e))?;
2136 f.write_all(&bytes).map_err(|e| io_err(&tmp, e))?;
2137 f.sync_all().map_err(|e| io_err(&tmp, e))?;
2138 }
2139 std::fs::rename(&tmp, anchor_path).map_err(|e| io_err(anchor_path, e))?;
2140 if let Some(parent) = anchor_path.parent().filter(|p| !p.as_os_str().is_empty())
2142 && let Ok(dir) = File::open(parent)
2143 {
2144 let _ = dir.sync_all();
2145 }
2146 Ok(())
2147}
2148
2149pub fn load_anchor(anchor_path: &Path) -> Result<Option<Checkpoint>, LedgerError> {
2153 match std::fs::read(anchor_path) {
2154 Ok(bytes) => Ok(Some(
2155 serde_json::from_slice(&bytes).map_err(|e| LedgerError::Serde(e.to_string()))?,
2156 )),
2157 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
2158 Err(e) => Err(io_err(anchor_path, e)),
2159 }
2160}
2161
2162fn root_at_count(location: &Location, count: u64) -> Result<Option<String>, LedgerError> {
2167 if count == 0 {
2168 return Ok(Some(GENESIS.to_owned()));
2169 }
2170 let mut prev = GENESIS.to_owned();
2171 let mut seen: u64 = 0;
2172 let (lines, _torn) = location.prefix_lines()?;
2178 for line in lines {
2179 let line = line?;
2180 if line.trim().is_empty() {
2181 continue;
2182 }
2183 let record: RecordIn = serde_json::from_str(&line).map_err(|e| LedgerError::Tamper {
2184 seq: seen,
2185 why: format!("unparseable record: {e}"),
2186 })?;
2187 if record.prev != prev {
2188 return Err(LedgerError::Tamper {
2189 seq: seen,
2190 why: "broken chain link (prev mismatch)".into(),
2191 });
2192 }
2193 let hash = hex::encode(chain_hash(record.entry.get().as_bytes(), &record.prev));
2194 if hash != record.hash {
2195 return Err(LedgerError::Tamper {
2196 seq: seen,
2197 why: "entry altered (hash mismatch)".into(),
2198 });
2199 }
2200 prev = record.hash;
2201 seen += 1;
2202 if seen == count {
2203 return Ok(Some(prev));
2204 }
2205 }
2206 Ok(None)
2207}
2208
2209#[cfg(test)]
2210mod tests {
2211 use super::*;
2212 use decern_crypto::Verifier;
2213 use serde_json::json;
2214
2215 fn entry(action: &str, decision: bool) -> Entry {
2216 Entry {
2217 seq: 0, ts_ms: 1234,
2219 subject_type: "Principal".into(),
2220 subject_id: "agent1".into(),
2221 action: action.into(),
2222 resource_type: "Resource".into(),
2223 resource_id: "claim1".into(),
2224 context: json!({"now": 100}),
2225 decision,
2226 reasons: vec![],
2227 ..Default::default()
2228 }
2229 }
2230
2231 fn tmp(name: &str) -> PathBuf {
2232 let dir = std::env::temp_dir().join(format!("decern-ledger-test-{}", std::process::id()));
2233 std::fs::create_dir_all(&dir).unwrap();
2234 dir.join(name)
2235 }
2236
2237 fn h32(hex_str: &str) -> [u8; 32] {
2238 <[u8; 32]>::try_from(hex::decode(hex_str).unwrap()).unwrap()
2239 }
2240
2241 #[test]
2245 fn the_out_of_lock_projection_path_equals_the_in_lock_one() {
2246 let key = decern_crypto::generate().unwrap();
2247 let vk = key.verifying_key();
2248 let path = tmp("raw-lines.ledger");
2249 let _ = std::fs::remove_file(&path);
2250 let mut l = Ledger::open(&path, key).unwrap();
2251 for i in 0..5 {
2252 l.append(entry(&format!("a{i}"), i % 2 == 0)).unwrap();
2253 }
2254
2255 let lines = l.raw_records().unwrap();
2256 assert_eq!(lines.len(), 5);
2257 let from_lines = leaves_from_lines(&lines).unwrap();
2258 let from_records = l.merkle_leaves().unwrap();
2259 assert_eq!(from_lines, from_records);
2260
2261 let seqs = [0u64, 3, 4];
2262 let over = inclusion_proofs_over(&from_lines, &seqs).unwrap();
2263 let method = l.inclusion_proofs(&seqs).unwrap();
2264 for (a, b) in over.iter().zip(&method) {
2265 assert_eq!(a.leaf_index, b.leaf_index);
2266 assert_eq!(a.tree_size, b.tree_size);
2267 assert_eq!(a.leaf_data, b.leaf_data);
2268 assert_eq!(a.audit_path, b.audit_path);
2269 }
2270
2271 let root_hex = hex::encode(merkle::tree_hash(&from_lines));
2272 let signed = l.sign_tree_head(root_hex, from_lines.len() as u64, 2_000);
2273 let direct = l.tree_head(2_000).unwrap();
2274 assert_eq!(signed.merkle_root, direct.merkle_root);
2275 assert_eq!(signed.tree_size, direct.tree_size);
2276 assert!(verify_tree_head_sig(&signed, &vk));
2277 }
2278
2279 #[test]
2282 fn a_line_without_a_hash_fails_leaf_derivation_closed() {
2283 let lines = vec![r#"{"entry":{}}"#.to_owned()];
2284 assert!(leaves_from_lines(&lines).is_err());
2285 let lines = vec![r#"{"hash":"zz"}"#.to_owned()];
2286 assert!(leaves_from_lines(&lines).is_err());
2287 }
2288
2289 #[test]
2290 fn merkle_tree_head_and_proofs_verify_against_a_real_ledger() {
2291 let key = decern_crypto::generate().unwrap();
2292 let vk = key.verifying_key();
2293 let path = tmp("merkle-th.ledger");
2294 let _ = std::fs::remove_file(&path);
2295 let mut l = Ledger::open(&path, key.clone()).unwrap();
2296
2297 for i in 0..4 {
2299 l.append(entry(&format!("a{i}"), true)).unwrap();
2300 }
2301 let th4 = l.tree_head(1_000).unwrap();
2302 assert_eq!(th4.tree_size, 4);
2303 assert!(verify_tree_head_sig(&th4, &vk));
2304
2305 for i in 0..3 {
2307 l.append(entry(&format!("b{i}"), false)).unwrap();
2308 }
2309 let th7 = l.tree_head(2_000).unwrap();
2310 assert_eq!(th7.tree_size, 7);
2311 assert!(verify_tree_head_sig(&th7, &vk));
2312
2313 let other = decern_crypto::generate().unwrap().verifying_key();
2316 assert!(
2317 !verify_tree_head_sig(&th7, &other),
2318 "wrong key must not verify"
2319 );
2320 let mut tampered = th7.clone();
2321 tampered.merkle_root = "00".repeat(32);
2322 assert!(
2323 !verify_tree_head_sig(&tampered, &vk),
2324 "tampered root rejected"
2325 );
2326 let cp = l.checkpoint(2_000);
2328 let cross = TreeHead {
2329 merkle_root: cp.root.clone(),
2330 tree_size: cp.count,
2331 ts_ms: cp.ts_ms,
2332 pubkey_hex: cp.pubkey_hex.clone(),
2333 sig_b64: cp.sig_b64.clone(),
2334 };
2335 assert!(
2336 !verify_tree_head_sig(&cross, &vk),
2337 "a checkpoint sig must not verify as a tree head"
2338 );
2339
2340 let root7 = h32(&th7.merkle_root);
2342 for seq in 0..7u64 {
2343 let ip = l.inclusion_proof(seq).unwrap();
2344 assert_eq!(ip.tree_size, 7);
2345 let leaf_hash = merkle::hash_leaf(&hex::decode(&ip.leaf_data).unwrap());
2346 let audit: Vec<[u8; 32]> = ip.audit_path.iter().map(|h| h32(h)).collect();
2347 assert!(
2348 merkle::verify_inclusion(seq, 7, &leaf_hash, &root7, &audit),
2349 "record {seq} must prove included"
2350 );
2351 }
2352 assert!(
2353 l.inclusion_proof(7).is_err(),
2354 "out-of-range inclusion rejected"
2355 );
2356
2357 let root4 = h32(&th4.merkle_root);
2360 let consist = l.consistency_proof(4).unwrap();
2361 assert_eq!((consist.first_size, consist.second_size), (4, 7));
2362 let cpath: Vec<[u8; 32]> = consist.proof.iter().map(|h| h32(h)).collect();
2363 assert!(
2364 merkle::verify_consistency(4, 7, &root4, &root7, &cpath),
2365 "size-4 prefix must reconcile with the size-7 head"
2366 );
2367 let mut forged = root4;
2369 forged[0] ^= 0xFF;
2370 assert!(!merkle::verify_consistency(4, 7, &forged, &root7, &cpath));
2371
2372 let _ = std::fs::remove_file(&path);
2373 }
2374
2375 #[test]
2376 fn evidence_bundle_verifies_offline_and_catches_tamper() {
2377 let key = decern_crypto::generate().unwrap();
2378 let vk = key.verifying_key();
2379 let path = tmp("bundle-verify.ledger");
2380 let _ = std::fs::remove_file(&path);
2381 let mut l = Ledger::open(&path, key.clone()).unwrap();
2382 for i in 0..5 {
2383 l.append(entry(&format!("e{i}"), true)).unwrap();
2384 }
2385 let earlier = l.tree_head(500).unwrap(); for i in 0..3 {
2387 l.append(entry(&format!("f{i}"), false)).unwrap();
2388 }
2389 let count = l.count() as usize; let make_bundle = |from: usize| -> String {
2395 let recs = l.read_raw_records(from, count - from).unwrap();
2396 let records_arr = format!(
2397 "[{}]",
2398 recs.iter().map(|r| r.get()).collect::<Vec<_>>().join(",")
2399 );
2400 format!(
2401 "{{\"format\":\"decern-evidence-bundle/1\",\"span\":{{\"from\":{from}}},\
2402 \"checkpoint\":{cp},\"tree_head\":{th},\"records\":{records_arr}}}",
2403 cp = serde_json::to_string(&l.checkpoint(900)).unwrap(),
2404 th = serde_json::to_string(&l.tree_head(900).unwrap()).unwrap(),
2405 )
2406 };
2407
2408 let full = make_bundle(0);
2411 let v = verify_evidence_bundle(&full, &[vk], Some(&earlier));
2412 assert!(v.accepted, "full bundle must verify: {:?}", v.errors);
2413 assert_eq!(v.merkle_root_ok, Some(true));
2414 assert_eq!(v.consistency_ok, Some(true));
2415
2416 let other = decern_crypto::generate().unwrap().verifying_key();
2418 assert!(!verify_evidence_bundle(&full, &[other], None).accepted);
2419
2420 let tampered = full.replacen("\"e0\"", "\"e0X\"", 1);
2422 let vt = verify_evidence_bundle(&tampered, &[vk], None);
2423 assert!(
2424 !vt.accepted && !vt.chain_ok,
2425 "tamper caught: {:?}",
2426 vt.errors
2427 );
2428
2429 let partial = make_bundle(3);
2432 let vp = verify_evidence_bundle(&partial, &[vk], None);
2433 assert!(vp.accepted, "partial tail verifies: {:?}", vp.errors);
2434 assert_eq!(vp.merkle_root_ok, None);
2435
2436 let mut forged = earlier.clone();
2438 forged.merkle_root = "11".repeat(32);
2439 let vf = verify_evidence_bundle(&full, &[vk], Some(&forged));
2440 assert_eq!(vf.consistency_ok, Some(false));
2441 assert!(!vf.accepted);
2442
2443 let _ = std::fs::remove_file(&path);
2444 }
2445
2446 #[test]
2447 fn edge_type_omitted_when_attenuate_recorded_when_mint() {
2448 let att = serde_json::to_string(&entry("issue_token", true)).unwrap();
2451 assert!(
2452 !att.contains("\"edge\""),
2453 "attenuate edge must be omitted: {att}"
2454 );
2455
2456 let mut e = entry("issue_token", true);
2458 e.edge = EdgeType::Mint;
2459 let mint = serde_json::to_string(&e).unwrap();
2460 assert!(
2461 mint.contains("\"edge\":\"Mint\""),
2462 "mint edge recorded: {mint}"
2463 );
2464
2465 assert_eq!(
2467 serde_json::from_str::<Entry>(&mint).unwrap().edge,
2468 EdgeType::Mint
2469 );
2470 assert_eq!(
2471 serde_json::from_str::<Entry>(&att).unwrap().edge,
2472 EdgeType::Attenuate
2473 );
2474 }
2475
2476 #[test]
2477 fn append_verify_resume() {
2478 let path = tmp("ok.ledger");
2479 std::fs::remove_file(&path).ok();
2480 let key = decern_crypto::generate().unwrap();
2481
2482 let mut l = Ledger::open(&path, key.clone()).unwrap();
2483 l.append(entry("Read", true)).unwrap();
2484 l.append(entry("MoveMoney", false)).unwrap();
2485 drop(l);
2486
2487 let mut l = Ledger::open(&path, key.clone()).unwrap();
2489 let rec = l.append(entry("Read", true)).unwrap();
2490 assert_eq!(rec.entry.seq, 2);
2491
2492 let report = verify(&path, Some(&key.verifying_key())).unwrap();
2493 assert_eq!(report.entries, 3);
2494 assert!(report.root.is_some());
2495 }
2496
2497 #[test]
2498 fn sync_enabled_ledger_appends_and_verifies() {
2499 let path = tmp("synced.ledger");
2501 std::fs::remove_file(&path).ok();
2502 let key = decern_crypto::generate().unwrap();
2503 let mut l = Ledger::open(&path, key.clone()).unwrap();
2504 l.set_sync(true);
2505 l.append(entry("Read", true)).unwrap();
2506 l.append(entry("MoveMoney", false)).unwrap();
2507 drop(l);
2508 let report = verify(&path, Some(&key.verifying_key())).unwrap();
2509 assert_eq!(report.entries, 2);
2510 assert!(report.signatures_checked);
2511 }
2512
2513 #[test]
2514 fn read_raw_records_bytes_reproduce_the_stored_hash() {
2515 let path = tmp("raw.ledger");
2519 std::fs::remove_file(&path).ok();
2520 let key = decern_crypto::generate().unwrap();
2521 let mut l = Ledger::open(&path, key.clone()).unwrap();
2522 let mut e = entry("Pay", true);
2524 e.context = json!({"z": 1, "a": 2, "m": 3, "amount_minor": 500});
2525 l.append(e).unwrap();
2526 l.append(entry("Read", true)).unwrap();
2527
2528 let raw = l.read_raw_records(0, 100).unwrap();
2529 assert_eq!(raw.len(), 2);
2530
2531 #[derive(serde::Deserialize)]
2533 struct Rec {
2534 entry: Box<serde_json::value::RawValue>,
2535 prev: String,
2536 hash: String,
2537 }
2538 let mut prev = GENESIS.to_owned();
2539 for line in &raw {
2540 let r: Rec = serde_json::from_str(line.get()).unwrap();
2541 let got = hex::encode(chain_hash(r.entry.get().as_bytes(), &prev));
2542 assert_eq!(got, r.hash, "verbatim bytes reproduce the stored hash");
2543 assert_eq!(r.prev, prev, "chain link continuous");
2544 prev = r.hash;
2545 }
2546 assert_eq!(prev, *l.root(), "final recomputed hash == head");
2547 }
2548
2549 #[test]
2550 fn read_verified_returns_a_verified_window_and_fails_closed_on_tamper() {
2551 let path = tmp("readv.ledger");
2552 std::fs::remove_file(&path).ok();
2553 let key = decern_crypto::generate().unwrap();
2554 let mut l = Ledger::open(&path, key.clone()).unwrap();
2555 l.append(entry("Read", true)).unwrap();
2556 l.append(entry("MoveMoney", false)).unwrap();
2557 l.append(entry("Read", true)).unwrap();
2558 drop(l);
2559
2560 let (report, recs) = read_verified(&path, Some(&key.verifying_key()), 0, 100).unwrap();
2563 assert_eq!(report.entries, 3);
2564 assert!(report.signatures_checked);
2565 assert_eq!(recs.len(), 3);
2566 assert_eq!(recs[0]["entry"]["seq"], json!(0));
2567 assert_eq!(recs[2]["entry"]["action"], json!("Read"));
2568
2569 let (report, recs) = read_verified(&path, None, 1, 1).unwrap();
2572 assert_eq!(report.entries, 3, "whole chain scanned for integrity");
2573 assert!(!report.signatures_checked, "no key → chain-only");
2574 assert_eq!(recs.len(), 1);
2575 assert_eq!(recs[0]["entry"]["seq"], json!(1));
2576 assert_eq!(recs[0]["entry"]["action"], json!("MoveMoney"));
2577
2578 let text = std::fs::read_to_string(&path).unwrap();
2581 let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
2582 let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
2583 rec.entry.decision = true;
2584 lines[1] = serde_json::to_string(&rec).unwrap();
2585 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
2586 let err = read_verified(&path, Some(&key.verifying_key()), 0, 100).unwrap_err();
2587 assert!(matches!(err, LedgerError::Tamper { seq: 1, .. }), "{err}");
2588 }
2589
2590 #[test]
2591 fn flipped_decision_detected() {
2592 let path = tmp("tamper.ledger");
2593 std::fs::remove_file(&path).ok();
2594 let key = decern_crypto::generate().unwrap();
2595 let mut l = Ledger::open(&path, key.clone()).unwrap();
2596 l.append(entry("Read", true)).unwrap();
2597 l.append(entry("MoveMoney", false)).unwrap();
2598 l.append(entry("Read", true)).unwrap();
2599 drop(l);
2600
2601 let text = std::fs::read_to_string(&path).unwrap();
2603 let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
2604 let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
2605 rec.entry.decision = true;
2606 lines[1] = serde_json::to_string(&rec).unwrap();
2607 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
2608
2609 let err = verify(&path, Some(&key.verifying_key())).unwrap_err();
2610 assert!(matches!(err, LedgerError::Tamper { seq: 1, .. }), "{err}");
2611 }
2612
2613 #[test]
2614 fn rewrite_with_other_key_detected() {
2615 let path = tmp("rewrite.ledger");
2616 std::fs::remove_file(&path).ok();
2617 let honest = decern_crypto::generate().unwrap();
2618 let insider = decern_crypto::generate().unwrap();
2619
2620 let mut l = Ledger::open(&path, insider).unwrap();
2622 l.append(entry("MoveMoney", true)).unwrap();
2623 drop(l);
2624
2625 assert!(verify(&path, None).is_ok());
2627 let err = verify(&path, Some(&honest.verifying_key())).unwrap_err();
2629 assert!(matches!(err, LedgerError::Tamper { .. }));
2630 }
2631
2632 #[test]
2633 fn hostile_float_context_cannot_false_tamper() {
2634 let path = tmp("floats.ledger");
2639 std::fs::remove_file(&path).ok();
2640 let key = decern_crypto::generate().unwrap();
2641 let mut l = Ledger::open(&path, key.clone()).unwrap();
2642
2643 for ctx in [
2644 json!({"now": 100, "z": 1.0715660391465826e-75}),
2645 json!({"v": 2.291712365432881e-9}),
2646 json!({"now": 100, "a": 0.1, "b": 1e308, "c": -5.5e-324}),
2647 ] {
2648 let mut e = entry("Read", false);
2649 e.context = ctx;
2650 l.append(e).unwrap();
2651 }
2652 drop(l);
2653
2654 let report = verify(&path, Some(&key.verifying_key())).expect("honest ledger must verify");
2655 assert_eq!(report.entries, 3);
2656 let mut l = Ledger::open(&path, key).expect("honest ledger must reopen");
2658 l.append(entry("Read", true)).unwrap();
2659 }
2660
2661 #[test]
2662 fn corrupt_ledger_refuses_new_writes() {
2663 let path = tmp("refuse.ledger");
2664 std::fs::remove_file(&path).ok();
2665 let key = decern_crypto::generate().unwrap();
2666 let mut l = Ledger::open(&path, key.clone()).unwrap();
2667 l.append(entry("Read", true)).unwrap();
2668 drop(l);
2669
2670 let text = std::fs::read_to_string(&path).unwrap();
2672 std::fs::write(&path, text.replace("Read", "Raid")).unwrap();
2673
2674 assert!(Ledger::open(&path, key).is_err());
2675 }
2676
2677 #[test]
2678 fn checkpoint_signs_and_verifies() {
2679 let path = tmp("cp-sig.ledger");
2680 std::fs::remove_file(&path).ok();
2681 let key = decern_crypto::generate().unwrap();
2682 let other = decern_crypto::generate().unwrap();
2683 let mut l = Ledger::open(&path, key.clone()).unwrap();
2684 l.append(entry("Read", true)).unwrap();
2685 l.append(entry("MoveMoney", false)).unwrap();
2686
2687 let cp = l.checkpoint(9_999);
2688 assert_eq!(cp.count, 2);
2689 assert_eq!(cp.root, l.root());
2690 assert_eq!(cp.pubkey_hex, l.pubkey_hex());
2691 assert!(verify_checkpoint_sig(&cp, &key.verifying_key()));
2693 assert!(!verify_checkpoint_sig(&cp, &other.verifying_key()));
2694 let mut forged = cp.clone();
2696 forged.count = 3;
2697 assert!(!verify_checkpoint_sig(&forged, &key.verifying_key()));
2698 }
2699
2700 #[test]
2701 fn append_only_log_extends_its_own_checkpoint() {
2702 let path = tmp("cp-extend.ledger");
2703 std::fs::remove_file(&path).ok();
2704 let key = decern_crypto::generate().unwrap();
2705 let mut l = Ledger::open(&path, key).unwrap();
2706 l.append(entry("Read", true)).unwrap();
2707 l.append(entry("MoveMoney", false)).unwrap();
2708 let cp = l.checkpoint(1); l.append(entry("Read", true)).unwrap();
2711 drop(l);
2712 assert!(ledger_extends_checkpoint(&path, &cp).unwrap());
2714 }
2715
2716 #[test]
2717 fn rewrite_below_a_held_checkpoint_is_caught() {
2718 let path = tmp("cp-rewrite.ledger");
2719 std::fs::remove_file(&path).ok();
2720 let key = decern_crypto::generate().unwrap();
2721 let mut l = Ledger::open(&path, key).unwrap();
2722 l.append(entry("Read", true)).unwrap();
2723 l.append(entry("MoveMoney", false)).unwrap();
2724 l.append(entry("Read", true)).unwrap();
2725 let cp = l.checkpoint(1);
2726 drop(l);
2727
2728 let text = std::fs::read_to_string(&path).unwrap();
2730 let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
2731 let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
2732 rec.entry.decision = true;
2733 lines[1] = serde_json::to_string(&rec).unwrap();
2734 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
2735
2736 let r = ledger_extends_checkpoint(&path, &cp);
2738 assert!(
2739 matches!(r, Err(LedgerError::Tamper { .. })) || matches!(r, Ok(false)),
2740 "rewrite below a checkpoint must break extension: {r:?}"
2741 );
2742 }
2743
2744 #[test]
2745 fn truncation_below_a_held_checkpoint_is_caught() {
2746 let path = tmp("cp-truncate.ledger");
2747 std::fs::remove_file(&path).ok();
2748 let key = decern_crypto::generate().unwrap();
2749 let mut l = Ledger::open(&path, key).unwrap();
2750 l.append(entry("Read", true)).unwrap();
2751 l.append(entry("MoveMoney", false)).unwrap();
2752 l.append(entry("Read", true)).unwrap();
2753 let cp = l.checkpoint(1); drop(l);
2755
2756 let text = std::fs::read_to_string(&path).unwrap();
2758 let kept: Vec<&str> = text.lines().take(2).collect();
2759 std::fs::write(&path, kept.join("\n") + "\n").unwrap();
2760
2761 assert!(!ledger_extends_checkpoint(&path, &cp).unwrap());
2763 }
2764
2765 #[test]
2766 fn decision_entry_serialization_is_stable() {
2767 let js = serde_json::to_string(&entry("Read", true)).unwrap();
2771 assert_eq!(
2772 js,
2773 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":[]}"#,
2774 "Decision entry serialization drifted from the golden value"
2775 );
2776 assert_eq!(
2777 hex::encode(chain_hash(js.as_bytes(), GENESIS)),
2778 "7b53ca2b3294bd92166dc254d1b56ee12a2a95b76807c08867147729405f8194",
2779 "Decision entry chain hash drifted from the golden value"
2780 );
2781 }
2782
2783 #[test]
2786 fn rotation_keeps_the_whole_chain_verifiable() {
2787 let path = tmp("rotate.ledger");
2789 std::fs::remove_file(&path).ok();
2790 let old = decern_crypto::generate().unwrap();
2791 let new = decern_crypto::generate().unwrap();
2792
2793 let mut l = Ledger::open(&path, old.clone()).unwrap();
2794 l.append(entry("Read", true)).unwrap(); l.append(entry("Write", true)).unwrap(); l.rotate(new.clone());
2797 l.append(entry("MoveMoney", false)).unwrap(); drop(l);
2799
2800 let report = verify_with_keys(&path, &[old.verifying_key(), new.verifying_key()]).unwrap();
2802 assert_eq!(report.entries, 3);
2803 assert!(report.signatures_checked);
2804
2805 assert!(
2808 verify(&path, Some(&old.verifying_key())).is_err(),
2809 "old key alone cannot verify the post-rotation tail"
2810 );
2811 assert!(
2812 verify(&path, Some(&new.verifying_key())).is_err(),
2813 "new key alone cannot verify the pre-rotation head"
2814 );
2815 }
2816
2817 #[test]
2818 fn rotated_ledger_reopens_with_retired_verifiers() {
2819 let path = tmp("rotate-reopen.ledger");
2820 std::fs::remove_file(&path).ok();
2821 let old = decern_crypto::generate().unwrap();
2822 let new = decern_crypto::generate().unwrap();
2823
2824 let mut l = Ledger::open(&path, old.clone()).unwrap();
2825 l.append(entry("Read", true)).unwrap();
2826 l.rotate(new.clone());
2827 l.append(entry("Write", true)).unwrap();
2828 drop(l);
2829
2830 assert!(
2832 Ledger::open(&path, new.clone()).is_err(),
2833 "reopen without the retired key must fail on the old-signed head"
2834 );
2835 let mut l =
2837 Ledger::open_with_verifiers(&path, new.clone(), vec![old.verifying_key()]).unwrap();
2838 assert_eq!(l.count(), 2);
2839 l.append(entry("MoveMoney", true)).unwrap(); assert!(l.self_verify().is_ok());
2841 let fps = l.verifier_fingerprints();
2843 assert!(fps.contains(&key_fingerprint(&old.verifying_key())));
2844 assert!(fps.contains(&key_fingerprint(&new.verifying_key())));
2845 }
2846
2847 #[test]
2848 fn a_record_signed_by_an_untrusted_key_is_tamper() {
2849 let path = tmp("rotate-untrusted.ledger");
2852 std::fs::remove_file(&path).ok();
2853 let honest = decern_crypto::generate().unwrap();
2854 let attacker = decern_crypto::generate().unwrap();
2855
2856 let mut l = Ledger::open(&path, honest.clone()).unwrap();
2857 l.append(entry("Read", true)).unwrap(); drop(l);
2859
2860 let err = verify_with_keys(&path, &[attacker.verifying_key()]).unwrap_err();
2863 assert!(
2864 matches!(err, LedgerError::Tamper { .. }),
2865 "a record whose kid is not in the ring must be tamper: {err}"
2866 );
2867 assert!(verify_with_keys(&path, &[honest.verifying_key()]).is_ok());
2869 }
2870
2871 #[test]
2874 fn anchor_catches_truncation_across_a_reopen() {
2875 let path = tmp("anchor-truncate.ledger");
2878 let anchor = tmp("anchor-truncate.anchor");
2879 std::fs::remove_file(&path).ok();
2880 std::fs::remove_file(&anchor).ok();
2881 let key = decern_crypto::generate().unwrap();
2882
2883 let mut l = Ledger::open(&path, key.clone()).unwrap();
2884 l.append(entry("Read", true)).unwrap();
2885 l.append(entry("Write", true)).unwrap();
2886 l.append(entry("MoveMoney", false)).unwrap();
2887 l.seal_anchor(&anchor, 1).unwrap(); drop(l);
2889
2890 let text = std::fs::read_to_string(&path).unwrap();
2892 let kept: Vec<&str> = text.lines().take(2).collect();
2893 std::fs::write(&path, kept.join("\n") + "\n").unwrap();
2894
2895 assert!(
2897 Ledger::open(&path, key.clone()).is_ok(),
2898 "plain reopen cannot see the truncation"
2899 );
2900 let err = match Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor) {
2902 Ok(_) => panic!("open_anchored must catch the truncation"),
2903 Err(e) => e,
2904 };
2905 assert!(
2906 matches!(err, LedgerError::Tamper { .. }),
2907 "anchor must catch the truncation: {err}"
2908 );
2909 }
2910
2911 #[test]
2912 fn anchor_accepts_a_legit_append_only_extension() {
2913 let path = tmp("anchor-extend.ledger");
2914 let anchor = tmp("anchor-extend.anchor");
2915 std::fs::remove_file(&path).ok();
2916 std::fs::remove_file(&anchor).ok();
2917 let key = decern_crypto::generate().unwrap();
2918
2919 let mut l = Ledger::open(&path, key.clone()).unwrap();
2920 l.append(entry("Read", true)).unwrap();
2921 l.seal_anchor(&anchor, 1).unwrap(); l.append(entry("Write", true)).unwrap(); drop(l);
2924
2925 let l = Ledger::open_anchored(&path, key, Vec::new(), &anchor).unwrap();
2927 assert_eq!(l.count(), 2);
2928 let missing = tmp("anchor-missing.anchor");
2930 std::fs::remove_file(&missing).ok();
2931 assert!(l.verify_against_anchor(&missing).is_ok());
2932 }
2933
2934 fn small_order_key_and_universal_forgery() -> (VerifyingKey, decern_crypto::Signature) {
2941 let mut identity = [0u8; 32];
2942 identity[0] = 1;
2943 let mut sig_bytes = [0u8; 64];
2944 sig_bytes[0] = 1; (
2946 VerifyingKey::from_bytes(&identity).expect("identity is a valid encoding"),
2947 decern_crypto::Signature::from_bytes(&sig_bytes),
2948 )
2949 }
2950
2951 #[test]
2952 fn a_small_order_key_cannot_verify_a_signature_it_never_made() {
2953 let (key, forgery) = small_order_key_and_universal_forgery();
2954 assert!(
2956 key.verify(b"any message at all", &forgery).is_ok(),
2957 "the forgery must pass the cofactorless check, or this test proves nothing"
2958 );
2959 for msg in [&b"any message at all"[..], b"a fabricated record"] {
2960 assert!(
2961 key.verify_strict(msg, &forgery).is_err(),
2962 "a small-order key must not verify {}",
2963 String::from_utf8_lossy(msg)
2964 );
2965 }
2966 }
2967
2968 #[test]
2969 fn a_fabricated_anchor_under_a_small_order_key_is_refused() {
2970 let (key, forgery) = small_order_key_and_universal_forgery();
2971 let cp = Checkpoint {
2972 root: "00".repeat(32),
2973 count: 9999,
2974 ts_ms: 1,
2975 pubkey_hex: hex::encode(key.to_bytes()),
2976 sig_b64: B64.encode(forgery.to_bytes()),
2977 };
2978 assert!(
2979 !verify_checkpoint_sig(&cp, &key),
2980 "an anchor over a height that was never reached must not verify"
2981 );
2982 let th = TreeHead {
2983 merkle_root: "00".repeat(32),
2984 tree_size: 9999,
2985 ts_ms: 1,
2986 pubkey_hex: hex::encode(key.to_bytes()),
2987 sig_b64: B64.encode(forgery.to_bytes()),
2988 };
2989 assert!(!verify_tree_head_sig(&th, &key));
2990 }
2991
2992 #[test]
2993 fn a_forged_anchor_from_an_untrusted_key_is_refused() {
2994 let path = tmp("anchor-forged.ledger");
2998 let anchor = tmp("anchor-forged.anchor");
2999 std::fs::remove_file(&path).ok();
3000 std::fs::remove_file(&anchor).ok();
3001 let key = decern_crypto::generate().unwrap();
3002 let attacker = decern_crypto::generate().unwrap();
3003
3004 let mut l = Ledger::open(&path, key.clone()).unwrap();
3005 l.append(entry("Read", true)).unwrap();
3006 l.append(entry("Write", true)).unwrap();
3007 let honest = l.checkpoint(9); drop(l);
3009
3010 let sig = attacker.sign(&checkpoint_bytes(&honest.root, honest.count, honest.ts_ms));
3014 let forged = Checkpoint {
3015 root: honest.root,
3016 count: honest.count,
3017 ts_ms: honest.ts_ms,
3018 pubkey_hex: hex::encode(attacker.verifying_key().to_bytes()),
3019 sig_b64: B64.encode(sig.to_bytes()),
3020 };
3021 save_anchor(&anchor, &forged).unwrap();
3022
3023 let l = Ledger::open(&path, key).unwrap();
3025 let err = l.verify_against_anchor(&anchor).unwrap_err();
3026 assert!(
3027 matches!(err, LedgerError::Tamper { .. }),
3028 "a forged anchor must be refused: {err}"
3029 );
3030 }
3031
3032 #[test]
3033 fn legacy_kidless_records_verify_against_the_ring() {
3034 let path = tmp("legacy-kidless.ledger");
3038 std::fs::remove_file(&path).ok();
3039 let key = decern_crypto::generate().unwrap();
3040
3041 let mut l = Ledger::open(&path, key.clone()).unwrap();
3046 l.append(entry("Read", true)).unwrap();
3047 drop(l);
3048 let kid = key_fingerprint(&key.verifying_key());
3049 let text = std::fs::read_to_string(&path).unwrap();
3050 let stripped = text.trim_end().replace(&format!(",\"kid\":\"{kid}\""), "");
3051 assert!(!stripped.contains("kid"), "kid removed: {stripped}");
3052 std::fs::write(&path, stripped + "\n").unwrap();
3053
3054 assert!(verify(&path, Some(&key.verifying_key())).is_ok());
3056 assert!(verify_with_keys(&path, &[key.verifying_key()]).is_ok());
3057 }
3058
3059 fn tmp_dir(name: &str) -> PathBuf {
3062 let d = tmp(name);
3063 std::fs::remove_dir_all(&d).ok();
3064 d
3065 }
3066
3067 #[test]
3068 fn open_segmented_creates_a_directory_with_one_active_segment_and_a_manifest() {
3069 let dir = tmp_dir("seg-init");
3070 let key = decern_crypto::generate().unwrap();
3071 let _l = Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()).unwrap();
3072 assert!(dir.join("manifest.json").exists());
3073 assert!(dir.join("00000001.jsonl").exists());
3074 }
3075
3076 #[test]
3077 fn segmented_append_and_reopen_preserves_root_and_count() {
3078 let dir = tmp_dir("seg-reopen");
3079 let key = decern_crypto::generate().unwrap();
3080 {
3081 let mut l =
3082 Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3083 .unwrap();
3084 for i in 0..5 {
3085 l.append(entry(&format!("act{i}"), true)).unwrap();
3086 }
3087 }
3088 let l2 = Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()).unwrap();
3089 assert_eq!(l2.count(), 5);
3090 let recs = l2.read_records(0, 10).unwrap();
3091 assert_eq!(recs.len(), 5);
3092 }
3093
3094 #[test]
3095 fn segmented_size_rollover_creates_a_second_segment_and_chain_still_verifies() {
3096 let dir = tmp_dir("seg-size-rollover");
3097 let key = decern_crypto::generate().unwrap();
3098 let vk = key.verifying_key();
3099 let mut l = Ledger::open_segmented(
3100 &dir,
3101 key,
3102 Vec::new(),
3103 RolloverPolicy::max_bytes(200), )
3105 .unwrap();
3106 for i in 0..8 {
3107 l.append(entry(&format!("action-{i}"), true)).unwrap();
3108 }
3109 drop(l);
3110
3111 let segs: Vec<_> = std::fs::read_dir(&dir)
3112 .unwrap()
3113 .filter_map(|e| e.ok())
3114 .filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
3115 .collect();
3116 assert!(
3117 segs.len() >= 2,
3118 "expected rollover to produce multiple segments, got {}",
3119 segs.len()
3120 );
3121
3122 let report = verify_with_keys(&dir, &[vk]).unwrap();
3125 assert_eq!(report.entries, 8);
3126
3127 let recs = read_verified(&dir, None, 0, 100).unwrap().1;
3129 let seqs: Vec<u64> = recs
3130 .iter()
3131 .map(|r| r["entry"]["seq"].as_u64().unwrap())
3132 .collect();
3133 assert_eq!(seqs, (0..8).collect::<Vec<_>>());
3134 }
3135
3136 #[test]
3137 fn segmented_epoch_rollover_triggers_on_a_bucket_change() {
3138 let dir = tmp_dir("seg-epoch-rollover");
3139 let key = decern_crypto::generate().unwrap();
3140 let mut l =
3141 Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(1000)).unwrap();
3142 let mut e0 = entry("a", true);
3143 e0.ts_ms = 500; l.append(e0).unwrap();
3145 let mut e1 = entry("b", true);
3146 e1.ts_ms = 1500; l.append(e1).unwrap();
3148 drop(l);
3149
3150 let segs: Vec<_> = std::fs::read_dir(&dir)
3151 .unwrap()
3152 .filter_map(|e| e.ok())
3153 .filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
3154 .collect();
3155 assert_eq!(segs.len(), 2, "epoch bucket change must trigger a rollover");
3156 }
3157
3158 #[cfg(unix)]
3159 #[test]
3160 fn segmented_sealed_segment_is_chmod_read_only() {
3161 use std::os::unix::fs::PermissionsExt;
3162 let dir = tmp_dir("seg-chmod");
3163 let key = decern_crypto::generate().unwrap();
3164 let mut l =
3165 Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(150)).unwrap();
3166 for i in 0..8 {
3167 l.append(entry(&format!("action-{i}"), true)).unwrap();
3168 }
3169 drop(l);
3170 let sealed = dir.join("00000001.jsonl");
3171 let mode = std::fs::metadata(&sealed).unwrap().permissions().mode() & 0o777;
3172 assert_eq!(
3173 mode, 0o400,
3174 "a sealed segment must be read-only AND not readable by group or other"
3175 );
3176 }
3177
3178 #[test]
3179 fn segmented_read_records_offset_limit_spans_a_segment_boundary_and_matches_single_file() {
3180 let single_path = tmp("seg-cmp-single.log");
3185 std::fs::remove_file(&single_path).ok();
3186 let seg_dir = tmp_dir("seg-cmp-segmented");
3187 let key = decern_crypto::generate().unwrap();
3188
3189 let mut single = Ledger::open(&single_path, key.clone()).unwrap();
3190 let mut segmented =
3191 Ledger::open_segmented(&seg_dir, key, Vec::new(), RolloverPolicy::max_bytes(1))
3192 .unwrap(); for i in 0..5 {
3195 let e = entry(&format!("act{i}"), true);
3196 single.append(e.clone()).unwrap();
3197 segmented.append(e).unwrap();
3198 }
3199
3200 let segs = std::fs::read_dir(&seg_dir)
3203 .unwrap()
3204 .filter(|e| {
3205 e.as_ref()
3206 .unwrap()
3207 .file_name()
3208 .to_string_lossy()
3209 .ends_with(".jsonl")
3210 })
3211 .count();
3212 assert!(segs >= 3, "expected several segments, got {segs}");
3213
3214 for (offset, limit) in [(0usize, 5usize), (1, 3), (2, 2), (3, 100), (4, 1), (0, 1)] {
3215 let a = single.read_records(offset, limit).unwrap();
3216 let b = segmented.read_records(offset, limit).unwrap();
3217 assert_eq!(a, b, "read_records({offset}, {limit}) mismatch");
3218
3219 let ar = single.read_raw_records(offset, limit).unwrap();
3220 let br = segmented.read_raw_records(offset, limit).unwrap();
3221 let a_strs: Vec<&str> = ar.iter().map(|v| v.get()).collect();
3222 let b_strs: Vec<&str> = br.iter().map(|v| v.get()).collect();
3223 assert_eq!(
3224 a_strs, b_strs,
3225 "read_raw_records({offset}, {limit}) verbatim-byte mismatch"
3226 );
3227 }
3228 }
3229
3230 #[test]
3231 fn segmented_verify_fails_closed_when_a_manifest_listed_segment_is_missing() {
3232 let dir = tmp_dir("seg-missing-segment");
3233 let key = decern_crypto::generate().unwrap();
3234 let mut l = Ledger::open_segmented(
3235 &dir,
3236 key.clone(),
3237 Vec::new(),
3238 RolloverPolicy::max_bytes(150),
3239 )
3240 .unwrap();
3241 for i in 0..8 {
3242 l.append(entry(&format!("action-{i}"), true)).unwrap();
3243 }
3244 drop(l);
3245
3246 std::fs::remove_file(dir.join("00000001.jsonl")).unwrap();
3250
3251 let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3252 Err(e) => e,
3253 Ok(_) => panic!("expected open_segmented to fail on a missing segment"),
3254 };
3255 assert!(
3256 matches!(err, LedgerError::Tamper { .. }),
3257 "expected Tamper, got {err:?}"
3258 );
3259 }
3260
3261 #[test]
3262 fn segmented_anchor_catches_a_truncation_that_deletes_a_whole_sealed_segment() {
3263 let dir = tmp_dir("seg-anchor-truncation");
3268 let anchor_path = tmp("seg-anchor-truncation.anchor");
3269 std::fs::remove_file(&anchor_path).ok();
3270 let key = decern_crypto::generate().unwrap();
3271
3272 let mut l = Ledger::open_segmented(
3273 &dir,
3274 key.clone(),
3275 Vec::new(),
3276 RolloverPolicy::max_bytes(150),
3277 )
3278 .unwrap();
3279 for i in 0..8 {
3280 l.append(entry(&format!("action-{i}"), true)).unwrap();
3281 }
3282 l.seal_anchor(&anchor_path, 999).unwrap();
3283 drop(l);
3284
3285 let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3293 let last = manifest.segments.pop().unwrap();
3294 if let Some(new_last) = manifest.segments.last_mut() {
3295 new_last.end_seq = None;
3296 }
3297 segment::save_manifest(&dir, &manifest).unwrap();
3298 std::fs::remove_file(dir.join(&last.file)).unwrap();
3299
3300 let err = match Ledger::open_segmented_anchored(
3301 &dir,
3302 key,
3303 Vec::new(),
3304 RolloverPolicy::default(),
3305 &anchor_path,
3306 ) {
3307 Err(e) => e,
3308 Ok(_) => panic!("expected open_segmented_anchored to fail: anchor no longer extended"),
3309 };
3310 assert!(
3311 matches!(err, LedgerError::Tamper { .. }),
3312 "expected Tamper (anchor no longer extended), got {err:?}"
3313 );
3314 }
3315
3316 #[test]
3317 fn open_on_a_segmented_directory_returns_a_clear_error_not_a_raw_os_error() {
3318 let dir = tmp_dir("seg-vs-open");
3319 let key = decern_crypto::generate().unwrap();
3320 let _l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3321 .unwrap();
3322 let err = match Ledger::open(&dir, key) {
3323 Err(e) => e,
3324 Ok(_) => panic!("expected Ledger::open to refuse a segmented directory"),
3325 };
3326 match err {
3327 LedgerError::Io { err, .. } => assert!(
3328 err.contains("open_segmented"),
3329 "expected a clear pointer to open_segmented, got: {err}"
3330 ),
3331 other => panic!("expected LedgerError::Io, got {other:?}"),
3332 }
3333 }
3334
3335 #[test]
3336 fn open_segmented_ignores_an_orphan_segment_left_by_a_crashed_rollover() {
3337 let dir = tmp_dir("seg-orphan");
3338 let key = decern_crypto::generate().unwrap();
3339 {
3340 let mut l = Ledger::open_segmented(
3341 &dir,
3342 key.clone(),
3343 Vec::new(),
3344 RolloverPolicy::default(), )
3346 .unwrap();
3347 l.append(entry("a", true)).unwrap();
3348 }
3349 std::fs::write(dir.join("00000002.jsonl"), b"").unwrap();
3353
3354 let mut l =
3355 Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3356 assert_eq!(l.count(), 1, "the orphan must not be silently adopted");
3357 l.append(entry("b", true)).unwrap();
3360 assert!(
3361 dir.join("00000003.jsonl").exists(),
3362 "rollover must skip past the orphan's index, not overwrite it"
3363 );
3364 let orphan_still_empty = std::fs::metadata(dir.join("00000002.jsonl")).unwrap().len();
3365 assert_eq!(
3366 orphan_still_empty, 0,
3367 "orphan must never be silently reused/overwritten"
3368 );
3369 }
3370
3371 #[test]
3374 fn roll_over_ignores_a_planted_out_of_range_filename_instead_of_overflowing() {
3375 let dir = tmp_dir("seg-overflow-guard");
3383 let key = decern_crypto::generate().unwrap();
3384 let mut l =
3385 Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3386 l.append(entry("a", true)).unwrap();
3387 std::fs::write(dir.join("4294967295.jsonl"), b"").unwrap();
3388
3389 l.append(entry("b", true))
3390 .expect("the decoy filename must not block a normal rollover");
3391 assert!(
3392 dir.join("00000002.jsonl").exists(),
3393 "rollover must proceed to the next real index, unaffected by the decoy"
3394 );
3395 assert!(
3396 !dir.join("00000000.jsonl").exists(),
3397 "must never silently wrap to and create index 0"
3398 );
3399 }
3400
3401 #[test]
3402 fn open_segmented_rejects_a_manifest_that_names_an_out_of_range_segment_filename() {
3403 let dir = tmp_dir("seg-overflow-manifest");
3410 let key = decern_crypto::generate().unwrap();
3411 {
3412 let mut l =
3413 Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3414 .unwrap();
3415 l.append(entry("a", true)).unwrap();
3416 }
3417 let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3418 manifest.segments[0].file = "4294967295.jsonl".into();
3419 std::fs::write(
3424 dir.join("manifest.json"),
3425 serde_json::to_vec_pretty(&manifest).unwrap(),
3426 )
3427 .unwrap();
3428
3429 let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3430 Err(e) => e,
3431 Ok(_) => panic!("expected an out-of-range manifest filename to be rejected"),
3432 };
3433 assert!(
3434 matches!(err, LedgerError::Tamper { .. }),
3435 "expected Tamper (invalid segment filename), got {err:?}"
3436 );
3437 }
3438
3439 #[test]
3440 fn open_segmented_rejects_a_manifest_with_two_active_segments() {
3441 let dir = tmp_dir("seg-dual-active");
3442 let key = decern_crypto::generate().unwrap();
3443 {
3444 let mut l =
3445 Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3446 .unwrap();
3447 for i in 0..3 {
3448 l.append(entry(&format!("a{i}"), true)).unwrap();
3449 }
3450 }
3451 let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3455 manifest.segments[0].end_seq = None;
3456 segment::save_manifest(&dir, &manifest).unwrap();
3457
3458 let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3459 Err(e) => e,
3460 Ok(_) => panic!("expected a dual-active manifest to be rejected"),
3461 };
3462 assert!(
3463 matches!(err, LedgerError::Tamper { .. }),
3464 "expected Tamper (dual active segment), got {err:?}"
3465 );
3466 }
3467
3468 #[test]
3469 fn open_segmented_rejects_a_manifest_whose_active_segment_is_not_the_last_entry() {
3470 let dir = tmp_dir("seg-active-not-tail");
3471 let key = decern_crypto::generate().unwrap();
3472 {
3473 let mut l =
3474 Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3475 .unwrap();
3476 for i in 0..3 {
3477 l.append(entry(&format!("a{i}"), true)).unwrap();
3478 }
3479 }
3480 let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3485 let last_end = manifest.segments.last().unwrap().end_seq;
3486 manifest.segments[0].end_seq = None;
3487 manifest.segments.last_mut().unwrap().end_seq = last_end.or(Some(3));
3488 segment::save_manifest(&dir, &manifest).unwrap();
3489
3490 let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3491 Err(e) => e,
3492 Ok(_) => panic!("expected a non-tail-active manifest to be rejected"),
3493 };
3494 assert!(
3495 matches!(err, LedgerError::Tamper { .. }),
3496 "expected Tamper (active segment not last), got {err:?}"
3497 );
3498 }
3499
3500 #[test]
3501 fn segment_paths_rejects_a_path_traversal_filename_in_the_manifest() {
3502 let dir = tmp_dir("seg-traversal");
3508 let outside = tmp("seg-traversal-secret.jsonl");
3509 std::fs::write(&outside, b"{\"leaked\":true}\n").unwrap();
3510 let key = decern_crypto::generate().unwrap();
3511 {
3512 let mut l =
3513 Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3514 .unwrap();
3515 l.append(entry("a", true)).unwrap();
3516 }
3517 let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3518 manifest.segments[0].file = "../seg-traversal-secret.jsonl".into();
3519 segment::save_manifest(&dir, &manifest).unwrap();
3520
3521 let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3522 Err(e) => e,
3523 Ok(_) => panic!("expected a path-traversal filename to be rejected"),
3524 };
3525 assert!(
3526 matches!(err, LedgerError::Tamper { .. }),
3527 "expected Tamper (invalid segment filename), got {err:?}"
3528 );
3529 }
3530
3531 #[test]
3532 fn fresh_epoch_only_ledger_does_not_waste_an_empty_first_segment() {
3533 let dir = tmp_dir("seg-epoch-fresh-no-waste");
3539 let key = decern_crypto::generate().unwrap();
3540 let mut l =
3541 Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
3542 .unwrap();
3543 let mut e = entry("first", true);
3544 e.ts_ms = 1_780_000_000_000; l.append(e).unwrap();
3546 drop(l);
3547
3548 assert!(
3549 dir.join("00000001.jsonl").exists(),
3550 "the first entry must land in segment 1"
3551 );
3552 assert!(
3553 !dir.join("00000002.jsonl").exists(),
3554 "a still-empty first segment must never be rolled over"
3555 );
3556 let bytes = std::fs::metadata(dir.join("00000001.jsonl")).unwrap().len();
3557 assert!(bytes > 0, "segment 1 must actually hold the record");
3558 }
3559
3560 #[test]
3561 fn segmented_deleting_a_middle_segment_with_manifest_reconciled_is_still_caught() {
3562 let dir = tmp_dir("seg-middle-gap");
3569 let key = decern_crypto::generate().unwrap();
3570 {
3571 let mut l =
3572 Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3573 .unwrap();
3574 for i in 0..4 {
3575 l.append(entry(&format!("a{i}"), true)).unwrap();
3576 }
3577 }
3578 let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3579 assert!(manifest.segments.len() >= 3, "need a real middle segment");
3580 let middle = manifest.segments.remove(1);
3581 segment::save_manifest(&dir, &manifest).unwrap();
3582 std::fs::remove_file(dir.join(&middle.file)).unwrap();
3583
3584 let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3585 Err(e) => e,
3586 Ok(_) => panic!("expected a reconciled middle-segment gap to still be caught"),
3587 };
3588 assert!(
3589 matches!(err, LedgerError::Tamper { .. }),
3590 "expected Tamper (sequence break), got {err:?}"
3591 );
3592 }
3593
3594 #[test]
3595 fn segmented_read_verified_offset_window_matches_read_records_across_a_boundary() {
3596 let dir = tmp_dir("seg-read-verified-parity");
3604 let key = decern_crypto::generate().unwrap();
3605 let vk = key.verifying_key();
3606 let mut l =
3607 Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3608 for i in 0..6 {
3609 l.append(entry(&format!("a{i}"), true)).unwrap();
3610 }
3611
3612 for (offset, limit) in [(0usize, 100usize), (2, 3), (1, 1), (5, 10)] {
3613 let direct = l.read_records(offset, limit).unwrap();
3614 let (_report, verified) = read_verified(&dir, Some(&vk), offset, limit).unwrap();
3615 assert_eq!(
3616 direct, verified,
3617 "read_verified({offset}, {limit}) must match read_records exactly"
3618 );
3619 }
3620 }
3621
3622 #[test]
3625 fn roll_over_refuses_to_create_a_segment_past_the_8_digit_filename_ceiling() {
3626 let dir = tmp_dir("seg-8digit-ceiling");
3636 std::fs::create_dir_all(&dir).unwrap();
3637 let seg_file = segment::segment_filename(99_999_999);
3638 std::fs::write(dir.join(&seg_file), b"").unwrap();
3639 let manifest = segment::Manifest {
3640 version: 1,
3641 segments: vec![segment::SegmentMeta {
3642 file: seg_file,
3643 start_seq: 0,
3644 end_seq: None,
3645 opened_ms: 0,
3646 }],
3647 };
3648 segment::save_manifest(&dir, &manifest).unwrap();
3649
3650 let key = decern_crypto::generate().unwrap();
3651 let mut l =
3652 Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3653 .unwrap();
3654 l.append(entry("a", true)).unwrap(); let err = match l.append(entry("b", true)) {
3656 Err(e) => e,
3657 Ok(_) => panic!("expected rollover past the 8-digit ceiling to be refused"),
3658 };
3659 assert!(
3660 matches!(err, LedgerError::Io { .. }),
3661 "expected a clean Io error at rollover time, got {err:?}"
3662 );
3663 drop(l);
3664
3665 let reopened =
3669 Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3670 assert_eq!(
3671 reopened.count(),
3672 1,
3673 "only the first append should have landed"
3674 );
3675 }
3676
3677 #[test]
3678 fn segmented_epoch_policy_keeps_two_same_bucket_entries_in_one_segment() {
3679 let dir = tmp_dir("seg-epoch-rebase");
3688 let key = decern_crypto::generate().unwrap();
3689 let mut l =
3690 Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
3691 .unwrap();
3692 let mut e0 = entry("a", true);
3693 e0.ts_ms = 1_780_000_000_000; l.append(e0).unwrap();
3695 let mut e1 = entry("b", true);
3696 e1.ts_ms = 1_780_000_000_500; l.append(e1).unwrap();
3698 drop(l);
3699
3700 let segs: Vec<_> = std::fs::read_dir(&dir)
3701 .unwrap()
3702 .filter_map(|e| e.ok())
3703 .filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
3704 .collect();
3705 assert_eq!(
3706 segs.len(),
3707 1,
3708 "two entries in the same real epoch bucket must stay in one segment"
3709 );
3710 }
3711
3712 #[test]
3713 fn open_segmented_rejects_a_manifest_with_reordered_sealed_segments() {
3714 let dir = tmp_dir("seg-reordered");
3720 let key = decern_crypto::generate().unwrap();
3721 {
3722 let mut l =
3723 Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3724 .unwrap();
3725 for i in 0..4 {
3726 l.append(entry(&format!("a{i}"), true)).unwrap();
3727 }
3728 }
3729 let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3730 assert!(
3731 manifest.segments.len() >= 3,
3732 "need at least two sealed segments to swap"
3733 );
3734 manifest.segments.swap(0, 1);
3735 segment::save_manifest(&dir, &manifest).unwrap();
3736
3737 let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3738 Err(e) => e,
3739 Ok(_) => panic!("expected a reordered manifest to be rejected"),
3740 };
3741 assert!(
3742 matches!(err, LedgerError::Tamper { .. }),
3743 "expected Tamper (non-contiguous segments), got {err:?}"
3744 );
3745 }
3746
3747 #[test]
3748 fn open_segmented_rejects_a_manifest_with_zero_segments() {
3749 let dir = tmp_dir("seg-zero-segments");
3757 let key = decern_crypto::generate().unwrap();
3758 {
3759 let mut l =
3760 Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3761 .unwrap();
3762 l.append(entry("a", true)).unwrap();
3763 }
3764 let empty = segment::Manifest {
3765 version: 1,
3766 segments: vec![],
3767 };
3768 segment::save_manifest(&dir, &empty).unwrap();
3769
3770 let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3771 Err(e) => e,
3772 Ok(_) => panic!("expected a zero-segment manifest to be rejected"),
3773 };
3774 assert!(
3775 matches!(err, LedgerError::Tamper { .. }),
3776 "expected Tamper (zero segments), got {err:?}"
3777 );
3778 }
3779
3780 #[test]
3783 fn segmented_manifest_missing_its_head_segment_is_rejected_including_on_an_already_open_handle()
3784 {
3785 let dir = tmp_dir("seg-head-drop");
3793 let key = decern_crypto::generate().unwrap();
3794 let mut l =
3795 Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3796 .unwrap();
3797 for i in 0..4 {
3798 l.append(entry(&format!("a{i}"), true)).unwrap();
3799 }
3800 let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3801 assert!(
3802 manifest.segments.len() >= 3,
3803 "need a real head segment to drop"
3804 );
3805 manifest.segments.remove(0);
3806 segment::save_manifest(&dir, &manifest).unwrap();
3807
3808 let err = l
3809 .read_records(0, 100)
3810 .expect_err("head-dropped manifest must be rejected, not silently shifted");
3811 assert!(
3812 matches!(err, LedgerError::Tamper { .. }),
3813 "expected Tamper (missing chain head), got {err:?}"
3814 );
3815 drop(l);
3816
3817 let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3818 Err(e) => e,
3819 Ok(_) => panic!("expected a head-dropped manifest to be rejected on reopen"),
3820 };
3821 assert!(
3822 matches!(err, LedgerError::Tamper { .. }),
3823 "expected Tamper (missing chain head), got {err:?}"
3824 );
3825 }
3826
3827 #[cfg(unix)]
3828 #[test]
3829 fn opened_ms_rebase_rolls_back_in_memory_on_a_failed_persist_so_a_retry_still_persists_it() {
3830 use std::os::unix::fs::PermissionsExt;
3831 let dir = tmp_dir("seg-rebase-rollback");
3838 let key = decern_crypto::generate().unwrap();
3839 let mut l =
3840 Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
3841 .unwrap();
3842
3843 let mut e0 = entry("a", true);
3844 e0.ts_ms = 1_780_000_000_000;
3845 let mut perms = std::fs::metadata(&dir).unwrap().permissions();
3846 perms.set_mode(0o500);
3847 std::fs::set_permissions(&dir, perms.clone()).unwrap();
3848 l.append(e0.clone())
3849 .expect_err("save_manifest should fail while the dir is read-only");
3850 perms.set_mode(0o700);
3851 std::fs::set_permissions(&dir, perms).unwrap();
3852
3853 l.append(e0).unwrap();
3854
3855 let manifest = segment::load_manifest(&dir).unwrap().unwrap();
3856 assert_eq!(
3857 manifest.segments[0].opened_ms, 1_780_000_000_000,
3858 "the retry must have persisted the rebased opened_ms to disk"
3859 );
3860 }
3861
3862 #[test]
3871 fn a_record_written_before_the_newest_columns_still_verifies() {
3872 let key = decern_crypto::generate().unwrap();
3873 let vk = key.verifying_key();
3874 let path = tmp("pre-columns-compat.ledger");
3875 std::fs::remove_file(&path).ok();
3876
3877 let mut l = Ledger::open(&path, key.clone()).unwrap();
3881 l.append(entry("act0", true)).unwrap();
3882 drop(l);
3883
3884 let line = std::fs::read_to_string(&path).unwrap();
3885 assert!(
3886 !line.contains("decision_subject_source"),
3887 "a default source must not be written; old records would not have it: {line}"
3888 );
3889 assert!(
3890 !line.contains("sponsor_source"),
3891 "a default sponsor source must not be written either: {line}"
3892 );
3893
3894 let report = verify(&path, Some(&vk)).unwrap();
3897 assert_eq!(report.entries, 1);
3898 assert!(report.signatures_checked);
3899
3900 let (_r, records) = read_verified(&path, Some(&vk), 0, 1).unwrap();
3903 let rec = records.first().expect("one record");
3904 assert!(rec["entry"].get("decision_subject_source").is_none());
3905 std::fs::remove_file(&path).ok();
3906 }
3907
3908 fn seed_lines(path: &Path, key: &SigningKey, n: usize) -> Vec<String> {
3911 std::fs::remove_file(path).ok();
3912 let mut l = Ledger::open(path, key.clone()).unwrap();
3913 for i in 0..n {
3914 l.append(entry(&format!("act{i}"), true)).unwrap();
3915 }
3916 drop(l);
3917 std::fs::read_to_string(path)
3918 .unwrap()
3919 .lines()
3920 .map(str::to_owned)
3921 .collect()
3922 }
3923
3924 #[test]
3925 fn crash_torn_tail_heals_as_torn_tail_not_tamper() {
3926 let key = decern_crypto::generate().unwrap();
3927 let vk = key.verifying_key();
3928 let path = tmp("torn-heals.ledger");
3929 let lines = seed_lines(&path, &key, 3);
3930
3931 let torn = format!(
3935 "{}\n{}\n{}",
3936 lines[0],
3937 lines[1],
3938 &lines[2][..lines[2].len() / 2]
3939 );
3940 std::fs::write(&path, &torn).unwrap();
3941
3942 let err = verify(&path, Some(&vk)).unwrap_err();
3944 match err {
3945 LedgerError::TornTail { healed_entries, .. } => assert_eq!(healed_entries, 2),
3946 other => panic!("expected TornTail, got {other:?}"),
3947 }
3948
3949 let mut l = Ledger::open(&path, key.clone()).unwrap();
3951 l.append(entry("after-heal", true)).unwrap();
3952 drop(l);
3953
3954 let report = verify(&path, Some(&vk)).unwrap();
3956 assert_eq!(report.entries, 3);
3957 }
3958
3959 #[test]
3960 fn unterminated_but_complete_final_record_is_discarded_then_appends_cleanly() {
3961 let key = decern_crypto::generate().unwrap();
3967 let vk = key.verifying_key();
3968 let path = tmp("torn-complete.ledger");
3969 let lines = seed_lines(&path, &key, 3);
3970
3971 std::fs::write(&path, format!("{}\n{}\n{}", lines[0], lines[1], lines[2])).unwrap();
3973
3974 match verify(&path, Some(&vk)).unwrap_err() {
3976 LedgerError::TornTail { healed_entries, .. } => assert_eq!(healed_entries, 2),
3977 other => panic!("expected TornTail, got {other:?}"),
3978 }
3979
3980 let mut l = Ledger::open(&path, key.clone()).unwrap();
3981 l.append(entry("after-heal", true)).unwrap();
3982 drop(l);
3983
3984 let text = std::fs::read_to_string(&path).unwrap();
3986 assert_eq!(
3987 text.lines().count(),
3988 3,
3989 "records must stay one-per-line: {text}"
3990 );
3991 assert!(
3992 text.ends_with('\n'),
3993 "healed log is newline-terminated again"
3994 );
3995 assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 3);
3996 }
3997
3998 #[test]
3999 fn attacker_ragged_truncation_below_anchor_stays_tamper() {
4000 let key = decern_crypto::generate().unwrap();
4006 let path = tmp("torn-below-anchor.ledger");
4007 let anchor = tmp("torn-below-anchor.anchor");
4008 std::fs::remove_file(&path).ok();
4009 std::fs::remove_file(&anchor).ok();
4010
4011 let mut l = Ledger::open(&path, key.clone()).unwrap();
4012 for i in 0..3 {
4013 l.append(entry(&format!("act{i}"), true)).unwrap();
4014 }
4015 l.seal_anchor(&anchor, 1_000).unwrap(); drop(l);
4017
4018 let lines: Vec<String> = std::fs::read_to_string(&path)
4019 .unwrap()
4020 .lines()
4021 .map(str::to_owned)
4022 .collect();
4023 let ragged = format!("{}\n{}", lines[0], &lines[1][..lines[1].len() / 2]);
4026 std::fs::write(&path, &ragged).unwrap();
4027 let len_before = std::fs::metadata(&path).unwrap().len();
4028
4029 let err = match Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor) {
4030 Ok(_) => panic!("ragged truncation below the anchor must fail to open"),
4031 Err(e) => e,
4032 };
4033 assert!(
4034 matches!(err, LedgerError::Tamper { .. }),
4035 "ragged truncation below the anchor must be Tamper, got {err:?}"
4036 );
4037 assert_eq!(
4039 std::fs::metadata(&path).unwrap().len(),
4040 len_before,
4041 "the file must not be mutated when the torn tail is really a truncation attack"
4042 );
4043 }
4044
4045 #[test]
4046 fn terminated_final_record_with_broken_signature_stays_tamper() {
4047 let key = decern_crypto::generate().unwrap();
4050 let vk = key.verifying_key();
4051 let path = tmp("terminated-bad-sig.ledger");
4052 let lines = seed_lines(&path, &key, 3);
4053
4054 let mut last: serde_json::Value = serde_json::from_str(&lines[2]).unwrap();
4056 let sig = last["sig_b64"].as_str().unwrap().to_owned();
4057 let flipped = if sig.starts_with('A') { 'B' } else { 'A' };
4058 last["sig_b64"] = json!(format!("{flipped}{}", &sig[1..]));
4059 let corrupt = format!(
4060 "{}\n{}\n{}\n",
4061 lines[0],
4062 lines[1],
4063 serde_json::to_string(&last).unwrap()
4064 );
4065 std::fs::write(&path, &corrupt).unwrap();
4066
4067 match verify(&path, Some(&vk)).unwrap_err() {
4068 LedgerError::Tamper { .. } => {}
4069 other => panic!("terminated bad-signature record must be Tamper, got {other:?}"),
4070 }
4071 }
4072
4073 #[test]
4074 fn terminated_final_record_with_broken_chain_stays_tamper() {
4075 let key = decern_crypto::generate().unwrap();
4077 let vk = key.verifying_key();
4078 let path = tmp("terminated-bad-chain.ledger");
4079 let lines = seed_lines(&path, &key, 3);
4080
4081 let mut last: serde_json::Value = serde_json::from_str(&lines[2]).unwrap();
4084 last["hash"] = json!("00".repeat(32));
4085 let corrupt = format!(
4086 "{}\n{}\n{}\n",
4087 lines[0],
4088 lines[1],
4089 serde_json::to_string(&last).unwrap()
4090 );
4091 std::fs::write(&path, &corrupt).unwrap();
4092
4093 match verify(&path, Some(&vk)).unwrap_err() {
4094 LedgerError::Tamper { .. } => {}
4095 other => panic!("terminated broken-chain record must be Tamper, got {other:?}"),
4096 }
4097 }
4098
4099 #[test]
4100 fn healthy_log_ends_newline_terminated_and_reopens_clean() {
4101 let key = decern_crypto::generate().unwrap();
4105 let vk = key.verifying_key();
4106 let path = tmp("healthy-roundtrip.ledger");
4107 seed_lines(&path, &key, 4);
4108
4109 let bytes = std::fs::read(&path).unwrap();
4110 assert_eq!(*bytes.last().unwrap(), b'\n', "log is newline-terminated");
4111 assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 4);
4112 let mut l = Ledger::open(&path, key.clone()).unwrap();
4114 l.append(entry("more", true)).unwrap();
4115 drop(l);
4116 assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 5);
4117 }
4118
4119 #[test]
4120 fn anchored_crash_tail_above_the_anchor_heals_and_opens() {
4121 let key = decern_crypto::generate().unwrap();
4127 let vk = key.verifying_key();
4128 let path = tmp("anchored-crash-tail.ledger");
4129 let anchor = tmp("anchored-crash-tail.anchor");
4130 std::fs::remove_file(&path).ok();
4131 std::fs::remove_file(&anchor).ok();
4132
4133 let mut l = Ledger::open(&path, key.clone()).unwrap();
4134 for i in 0..3 {
4135 l.append(entry(&format!("act{i}"), true)).unwrap();
4136 }
4137 l.seal_anchor(&anchor, 1_000).unwrap(); l.append(entry("act3", true)).unwrap(); drop(l);
4140
4141 let lines: Vec<String> = std::fs::read_to_string(&path)
4142 .unwrap()
4143 .lines()
4144 .map(str::to_owned)
4145 .collect();
4146 let torn = format!(
4149 "{}\n{}\n{}\n{}",
4150 lines[0],
4151 lines[1],
4152 lines[2],
4153 &lines[3][..lines[3].len() / 2]
4154 );
4155 std::fs::write(&path, &torn).unwrap();
4156
4157 let mut l = Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor).unwrap();
4159 l.append(entry("act3-again", true)).unwrap();
4160 drop(l);
4161 assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 4);
4162 }
4163
4164 #[test]
4165 fn segmented_torn_tail_in_active_segment_heals() {
4166 let key = decern_crypto::generate().unwrap();
4171 let vk = key.verifying_key();
4172 let dir = tmp("segmented-torn");
4173 std::fs::remove_dir_all(&dir).ok();
4174
4175 let policy = RolloverPolicy {
4177 max_bytes: Some(1),
4178 epoch_ms: None,
4179 };
4180 let mut l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), policy).unwrap();
4181 for i in 0..4 {
4182 l.append(entry(&format!("s{i}"), true)).unwrap();
4183 }
4184 drop(l);
4185
4186 let paths = segment::segment_paths(&dir).unwrap();
4188 let active = paths.last().unwrap().clone();
4189 let alines: Vec<String> = std::fs::read_to_string(&active)
4190 .unwrap()
4191 .lines()
4192 .map(str::to_owned)
4193 .collect();
4194 assert!(!alines.is_empty(), "active segment should hold >=1 record");
4195 let kept = &alines[..alines.len() - 1];
4196 let mut body = kept.iter().map(|s| format!("{s}\n")).collect::<String>();
4197 let torn = alines.last().unwrap();
4199 body.push_str(&torn[..torn.len() / 2]);
4200 std::fs::write(&active, &body).unwrap();
4201
4202 assert!(matches!(
4204 verify(&dir, Some(&vk)).unwrap_err(),
4205 LedgerError::TornTail { .. }
4206 ));
4207 let healed_before = match verify(&dir, Some(&vk)).unwrap_err() {
4208 LedgerError::TornTail { healed_entries, .. } => healed_entries,
4209 _ => unreachable!(),
4210 };
4211 let mut l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), policy).unwrap();
4212 l.append(entry("post-heal", true)).unwrap();
4213 drop(l);
4214 assert_eq!(
4215 verify(&dir, Some(&vk)).unwrap().entries,
4216 healed_before + 1,
4217 "chain verifies across the segment boundary after healing the active tail"
4218 );
4219 }
4220 #[cfg(unix)]
4225 #[test]
4226 fn a_ledger_file_is_not_readable_by_group_or_other() {
4227 use std::os::unix::fs::PermissionsExt;
4228 let path = tmp("perm-new.ledger");
4229 std::fs::remove_file(&path).ok();
4230 let key = decern_crypto::generate().unwrap();
4231 let mut led = Ledger::open(&path, key).unwrap();
4232 led.append(entry("Read", true)).unwrap();
4233 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
4234 assert_eq!(mode, 0o600, "ledger must be 0600, got {mode:o}");
4235 }
4236
4237 #[cfg(unix)]
4241 #[test]
4242 fn reopening_an_existing_world_readable_ledger_tightens_it() {
4243 use std::os::unix::fs::PermissionsExt;
4244 let path = tmp("perm-tighten.ledger");
4245 std::fs::remove_file(&path).ok();
4246 let key = decern_crypto::generate().unwrap();
4247 {
4248 let mut led = Ledger::open(&path, key.clone()).unwrap();
4249 led.append(entry("Read", true)).unwrap();
4250 }
4251 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
4252 let _led = Ledger::open(&path, key).unwrap();
4253 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
4254 assert_eq!(mode, 0o600, "reopen must tighten, got {mode:o}");
4255 }
4256}