#![forbid(unsafe_code)]
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use decern_crypto::{Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub mod jcs;
pub mod merkle;
mod segment;
pub mod sharded;
pub use jcs::{canonicalize, parameter_digest};
pub use segment::RolloverPolicy;
pub use sharded::{ShardVerification, ShardedLedger, UNATTRIBUTED_SHARD, verify_sharded_dir};
enum Location {
Single(PathBuf),
Segmented(PathBuf),
}
impl Location {
fn detect(path: &Path) -> Self {
if path.is_dir() {
Location::Segmented(path.to_path_buf())
} else {
Location::Single(path.to_path_buf())
}
}
fn resolved_paths(&self) -> Result<Vec<PathBuf>, LedgerError> {
match self {
Location::Single(p) => Ok(vec![p.clone()]),
Location::Segmented(dir) => segment::segment_paths(dir),
}
}
fn lines(&self) -> Result<segment::ChainedLines, LedgerError> {
Ok(segment::chained_lines(self.resolved_paths()?))
}
fn prefix_lines(&self) -> Result<(segment::ChainedLines, Option<TornFragment>), LedgerError> {
let paths = self.resolved_paths()?;
let torn = match paths.last() {
Some(last) => scan_torn_tail(last)?,
None => None,
};
let limit = torn.as_ref().map(|t| t.offset).unwrap_or(u64::MAX);
Ok((segment::chained_lines_bounded(paths, limit), torn))
}
}
struct TornFragment {
path: PathBuf,
offset: u64,
}
fn scan_torn_tail(path: &Path) -> Result<Option<TornFragment>, LedgerError> {
use std::io::{Read, Seek, SeekFrom};
let mut f = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(io_err(path, e)),
};
let len = f.metadata().map_err(|e| io_err(path, e))?.len();
if len == 0 {
return Ok(None);
}
f.seek(SeekFrom::End(-1)).map_err(|e| io_err(path, e))?;
let mut last = [0u8; 1];
f.read_exact(&mut last).map_err(|e| io_err(path, e))?;
if last[0] == b'\n' {
return Ok(None);
}
const CHUNK: u64 = 64 * 1024;
let mut pos = len;
let mut buf = vec![0u8; CHUNK as usize];
while pos > 0 {
let read_len = CHUNK.min(pos);
let start = pos - read_len;
f.seek(SeekFrom::Start(start))
.map_err(|e| io_err(path, e))?;
let slice = &mut buf[..read_len as usize];
f.read_exact(slice).map_err(|e| io_err(path, e))?;
if let Some(idx) = slice.iter().rposition(|&b| b == b'\n') {
return Ok(Some(TornFragment {
path: path.to_path_buf(),
offset: start + idx as u64 + 1,
}));
}
pos = start;
}
Ok(Some(TornFragment {
path: path.to_path_buf(),
offset: 0,
}))
}
pub const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Entry {
pub seq: u64,
pub ts_ms: u64,
pub subject_type: String,
pub subject_id: String,
pub action: String,
pub resource_type: String,
pub resource_id: String,
pub context: serde_json::Value,
pub decision: bool,
pub reasons: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parameter_digest: Option<String>,
#[serde(default, skip_serializing_if = "edge_is_attenuate")]
pub edge: EdgeType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sponsor: Option<Party>,
#[serde(default, skip_serializing_if = "is_derived_sponsor")]
pub sponsor_source: SponsorSource,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Party {
pub kind: String,
pub id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum EdgeType {
#[default]
Attenuate,
Mint,
}
fn edge_is_attenuate(e: &EdgeType) -> bool {
matches!(e, EdgeType::Attenuate)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SponsorSource {
#[default]
Derived,
Explicit,
}
fn is_derived_sponsor(s: &SponsorSource) -> bool {
matches!(s, SponsorSource::Derived)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Record {
pub entry: Entry,
pub prev: String,
pub hash: String,
pub sig_b64: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kid: Option<String>,
}
#[derive(Serialize)]
struct RecordOut<'a> {
entry: &'a serde_json::value::RawValue,
prev: &'a str,
hash: &'a str,
sig_b64: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
kid: Option<&'a str>,
}
#[derive(Deserialize)]
struct RecordIn {
entry: Box<serde_json::value::RawValue>,
prev: String,
hash: String,
sig_b64: String,
#[serde(default)]
kid: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum LedgerError {
#[error("ledger I/O error at {path}: {err}")]
Io { path: String, err: String },
#[error("ledger serialization error: {0}")]
Serde(String),
#[error("TAMPER at seq {seq}: {why}")]
Tamper { seq: u64, why: String },
#[error(
"TORN TAIL: unterminated trailing record (crash mid-append); \
{healed_entries} verified records intact before it"
)]
TornTail {
healed_entries: u64,
healed_root: Option<String>,
torn_path: String,
torn_from_offset: u64,
},
}
fn chain_hash(entry_bytes: &[u8], prev_hex: &str) -> [u8; 32] {
let mut h = Sha256::new();
h.update(entry_bytes);
h.update(prev_hex.as_bytes());
h.finalize().into()
}
fn io_err(path: &Path, e: impl std::fmt::Display) -> LedgerError {
LedgerError::Io {
path: path.display().to_string(),
err: e.to_string(),
}
}
pub struct Ledger {
location: Location,
active_path: PathBuf,
key: SigningKey,
file: File,
last_hash: String,
next_seq: u64,
verifiers: Vec<VerifyingKey>,
sync: bool,
rollover: Option<RolloverState>,
}
struct RolloverState {
policy: RolloverPolicy,
manifest: segment::Manifest,
}
fn resolve_open_head(
location: &Location,
verifiers: &[VerifyingKey],
anchor: Option<&Path>,
) -> Result<(String, u64), LedgerError> {
match verify_inner(location, verifiers, None) {
Ok(report) => Ok((
report.root.unwrap_or_else(|| GENESIS.to_owned()),
report.entries,
)),
Err(LedgerError::TornTail {
healed_entries,
healed_root,
torn_path,
torn_from_offset,
}) => {
if let Some(anchor_path) = anchor
&& let Some(cp) = load_anchor(anchor_path)?
{
if !verifiers.iter().any(|k| verify_checkpoint_sig(&cp, k)) {
return Err(LedgerError::Tamper {
seq: cp.count,
why: "anchor signature is not from a trusted ledger key \
(forged or wrong-key anchor)"
.into(),
});
}
if !ledger_extends_checkpoint_at(location, &cp)? {
return Err(LedgerError::Tamper {
seq: cp.count,
why: format!(
"ledger no longer extends its anchor at count {} — the trailing \
record is unterminated AND the verified prefix is below the last \
committed height (acked history truncated, not a crash tail)",
cp.count
),
});
}
}
heal_torn_tail(Path::new(&torn_path), torn_from_offset)?;
Ok((
healed_root.unwrap_or_else(|| GENESIS.to_owned()),
healed_entries,
))
}
Err(e) => Err(e),
}
}
fn heal_torn_tail(path: &Path, offset: u64) -> Result<(), LedgerError> {
let f = OpenOptions::new()
.write(true)
.open(path)
.map_err(|e| io_err(path, e))?;
f.set_len(offset).map_err(|e| io_err(path, e))?;
f.sync_all().map_err(|e| io_err(path, e))?;
Ok(())
}
impl Ledger {
pub fn open(path: &Path, key: SigningKey) -> Result<Self, LedgerError> {
Self::open_with_verifiers(path, key, Vec::new())
}
pub fn open_with_verifiers(
path: &Path,
key: SigningKey,
retired: Vec<VerifyingKey>,
) -> Result<Self, LedgerError> {
Self::open_single_inner(path, key, retired, None)
}
fn open_single_inner(
path: &Path,
key: SigningKey,
retired: Vec<VerifyingKey>,
anchor: Option<&Path>,
) -> Result<Self, LedgerError> {
if path.is_dir() {
return Err(LedgerError::Io {
path: path.display().to_string(),
err: "this path is a segmented ledger directory — use Ledger::open_segmented \
instead of open/open_with_verifiers"
.into(),
});
}
let mut verifiers = retired;
let current = key.verifying_key();
if !verifiers.iter().any(|v| v.to_bytes() == current.to_bytes()) {
verifiers.push(current);
}
let location = Location::Single(path.to_owned());
let (last_hash, next_seq) = if path.exists() {
resolve_open_head(&location, &verifiers, anchor)?
} else {
(GENESIS.to_owned(), 0)
};
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| io_err(path, e))?;
Ok(Ledger {
location,
active_path: path.to_owned(),
key,
file,
last_hash,
next_seq,
verifiers,
sync: false,
rollover: None,
})
}
pub fn open_anchored(
path: &Path,
key: SigningKey,
retired: Vec<VerifyingKey>,
anchor_path: &Path,
) -> Result<Self, LedgerError> {
let ledger = Self::open_single_inner(path, key, retired, Some(anchor_path))?;
ledger.verify_against_anchor(anchor_path)?;
Ok(ledger)
}
pub fn open_segmented(
dir: &Path,
key: SigningKey,
retired: Vec<VerifyingKey>,
policy: RolloverPolicy,
) -> Result<Self, LedgerError> {
Self::open_segmented_inner(dir, key, retired, policy, None)
}
fn open_segmented_inner(
dir: &Path,
key: SigningKey,
retired: Vec<VerifyingKey>,
policy: RolloverPolicy,
anchor: Option<&Path>,
) -> Result<Self, LedgerError> {
let mut verifiers = retired;
let current = key.verifying_key();
if !verifiers.iter().any(|v| v.to_bytes() == current.to_bytes()) {
verifiers.push(current);
}
fs::create_dir_all(dir).map_err(|e| io_err(dir, e))?;
let manifest = match segment::load_manifest(dir)? {
Some(m) => m,
None => segment::initialize(dir)?,
};
let location = Location::Segmented(dir.to_owned());
for seg in manifest.segments.iter().filter(|s| s.end_seq.is_some()) {
segment::seal_file_permissions(&dir.join(&seg.file));
}
let active = manifest
.active()
.ok_or_else(|| LedgerError::Io {
path: dir.display().to_string(),
err: "segmented ledger manifest has no active (unsealed) segment".into(),
})?
.clone();
let active_path = dir.join(&active.file);
segment::unseal_file_permissions(&active_path);
let (last_hash, next_seq) = resolve_open_head(&location, &verifiers, anchor)?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&active_path)
.map_err(|e| io_err(&active_path, e))?;
Ok(Ledger {
location,
active_path,
key,
file,
last_hash,
next_seq,
verifiers,
sync: false,
rollover: Some(RolloverState { policy, manifest }),
})
}
pub fn open_segmented_anchored(
dir: &Path,
key: SigningKey,
retired: Vec<VerifyingKey>,
policy: RolloverPolicy,
anchor_path: &Path,
) -> Result<Self, LedgerError> {
let ledger = Self::open_segmented_inner(dir, key, retired, policy, Some(anchor_path))?;
ledger.verify_against_anchor(anchor_path)?;
Ok(ledger)
}
pub fn rotate(&mut self, new_key: SigningKey) {
let current = new_key.verifying_key();
if !self
.verifiers
.iter()
.any(|v| v.to_bytes() == current.to_bytes())
{
self.verifiers.push(current);
}
self.key = new_key;
}
pub fn verifier_fingerprints(&self) -> Vec<String> {
self.verifiers.iter().map(key_fingerprint).collect()
}
pub fn set_sync(&mut self, sync: bool) -> &mut Self {
self.sync = sync;
self
}
pub fn append(&mut self, mut entry: Entry) -> Result<Record, LedgerError> {
if let Some(state) = &self.rollover {
let current_bytes = self.file.metadata().map(|m| m.len()).unwrap_or(0);
if Self::should_roll_over(state, self.next_seq, current_bytes, entry.ts_ms) {
self.roll_over(entry.ts_ms)?;
}
}
let this_seq = self.next_seq;
if let Some(state) = &self.rollover {
let needs_rebase = state
.manifest
.active()
.is_some_and(|s| s.start_seq == this_seq && s.opened_ms != entry.ts_ms);
if needs_rebase {
let mut rebased = state.manifest.clone();
if let Some(active) = rebased.active_mut() {
active.opened_ms = entry.ts_ms;
}
if let Location::Segmented(dir) = &self.location {
segment::save_manifest(dir, &rebased)?;
}
if let Some(state) = &mut self.rollover {
state.manifest = rebased;
}
}
}
entry.seq = self.next_seq;
let entry_json =
serde_json::to_string(&entry).map_err(|e| LedgerError::Serde(e.to_string()))?;
let hash = chain_hash(entry_json.as_bytes(), &self.last_hash);
let sig = self.key.sign(&hash);
let hash_hex = hex::encode(hash);
let sig_b64 = B64.encode(sig.to_bytes());
let kid = key_fingerprint(&self.key.verifying_key());
let raw_entry = serde_json::value::RawValue::from_string(entry_json)
.map_err(|e| LedgerError::Serde(e.to_string()))?;
let mut line = serde_json::to_string(&RecordOut {
entry: &raw_entry,
prev: &self.last_hash,
hash: &hash_hex,
sig_b64: &sig_b64,
kid: Some(&kid),
})
.map_err(|e| LedgerError::Serde(e.to_string()))?;
line.push('\n');
self.file
.write_all(line.as_bytes())
.and_then(|_| self.file.flush())
.and_then(|_| {
if self.sync {
self.file.sync_data()
} else {
Ok(())
}
})
.map_err(|e| io_err(&self.active_path, e))?;
let record = Record {
entry,
prev: std::mem::replace(&mut self.last_hash, hash_hex.clone()),
hash: hash_hex,
sig_b64,
kid: Some(kid),
};
self.next_seq += 1;
Ok(record)
}
fn should_roll_over(
state: &RolloverState,
current_seq: u64,
current_bytes: u64,
next_ts_ms: u64,
) -> bool {
let active_is_empty = state
.manifest
.active()
.is_some_and(|s| s.start_seq == current_seq);
if active_is_empty {
return false;
}
if let Some(max) = state.policy.max_bytes
&& current_bytes >= max
{
return true;
}
if let Some(epoch) = state.policy.epoch_ms {
let opened = state
.manifest
.active()
.map(|s| s.opened_ms)
.unwrap_or(next_ts_ms);
if epoch > 0 && next_ts_ms / epoch != opened / epoch {
return true;
}
}
false
}
fn roll_over(&mut self, next_ts_ms: u64) -> Result<(), LedgerError> {
let Location::Segmented(dir) = &self.location else {
return Err(LedgerError::Io {
path: self.active_path.display().to_string(),
err: "internal error: roll_over called on a non-segmented ledger".into(),
});
};
let dir = dir.clone();
let state = self.rollover.as_ref().ok_or_else(|| LedgerError::Io {
path: dir.display().to_string(),
err: "internal error: roll_over called with no rollover state".into(),
})?;
let (new_manifest, new_path) =
segment::roll_over(&dir, &state.manifest, self.next_seq, next_ts_ms)?;
self.file = OpenOptions::new()
.append(true)
.open(&new_path)
.map_err(|e| io_err(&new_path, e))?;
self.active_path = new_path;
if let Some(state) = self.rollover.as_mut() {
state.manifest = new_manifest;
}
Ok(())
}
pub fn root(&self) -> &str {
&self.last_hash
}
pub fn count(&self) -> u64 {
self.next_seq
}
pub fn self_verify(&self) -> Result<VerifyReport, LedgerError> {
verify_inner(&self.location, &self.verifiers, None)
}
pub fn pubkey_hex(&self) -> String {
hex::encode(self.key.verifying_key().to_bytes())
}
pub fn read_records(
&self,
offset: usize,
limit: usize,
) -> Result<Vec<serde_json::Value>, LedgerError> {
let mut out = Vec::new();
for line in self.location.lines()?.skip(offset) {
if out.len() >= limit {
break;
}
let line = line?;
if line.trim().is_empty() {
continue;
}
let v = serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
out.push(v);
}
Ok(out)
}
pub fn read_raw_records(
&self,
offset: usize,
limit: usize,
) -> Result<Vec<Box<serde_json::value::RawValue>>, LedgerError> {
let mut out = Vec::new();
for line in self.location.lines()?.skip(offset) {
if out.len() >= limit {
break;
}
let line = line?;
if line.trim().is_empty() {
continue;
}
let v: Box<serde_json::value::RawValue> =
serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
out.push(v);
}
Ok(out)
}
pub fn checkpoint(&self, ts_ms: u64) -> Checkpoint {
let root = self.last_hash.clone();
let count = self.next_seq;
let sig = self.key.sign(&checkpoint_bytes(&root, count, ts_ms));
Checkpoint {
root,
count,
ts_ms,
pubkey_hex: self.pubkey_hex(),
sig_b64: B64.encode(sig.to_bytes()),
}
}
fn merkle_leaves(&self) -> Result<Vec<Vec<u8>>, LedgerError> {
let count = self.next_seq as usize;
let recs = self.read_records(0, count)?;
let mut leaves = Vec::with_capacity(recs.len());
for (i, r) in recs.iter().enumerate() {
let hash_hex = r
.get("hash")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| LedgerError::Tamper {
seq: i as u64,
why: "record missing hash field".into(),
})?;
let bytes = hex::decode(hash_hex).map_err(|_| LedgerError::Tamper {
seq: i as u64,
why: "record hash is not valid hex".into(),
})?;
leaves.push(bytes);
}
Ok(leaves)
}
pub fn tree_head(&self, ts_ms: u64) -> Result<TreeHead, LedgerError> {
let leaves = self.merkle_leaves()?;
let root_hex = hex::encode(merkle::tree_hash(&leaves));
let tree_size = leaves.len() as u64;
let sig = self.key.sign(&tree_head_bytes(&root_hex, tree_size, ts_ms));
Ok(TreeHead {
merkle_root: root_hex,
tree_size,
ts_ms,
pubkey_hex: self.pubkey_hex(),
sig_b64: B64.encode(sig.to_bytes()),
})
}
pub fn inclusion_proof(&self, seq: u64) -> Result<InclusionProof, LedgerError> {
let leaves = self.merkle_leaves()?;
let idx = seq as usize;
let path = merkle::inclusion_proof(&leaves, idx).ok_or_else(|| LedgerError::Tamper {
seq,
why: "inclusion index past the end of the log".into(),
})?;
Ok(InclusionProof {
leaf_index: seq,
tree_size: leaves.len() as u64,
leaf_data: hex::encode(&leaves[idx]),
audit_path: path.iter().map(hex::encode).collect(),
})
}
pub fn consistency_proof(&self, first_size: u64) -> Result<ConsistencyProof, LedgerError> {
let leaves = self.merkle_leaves()?;
let path = merkle::consistency_proof(&leaves, first_size as usize).ok_or_else(|| {
LedgerError::Tamper {
seq: first_size,
why: "consistency first_size out of range (need 1..=count)".into(),
}
})?;
Ok(ConsistencyProof {
first_size,
second_size: leaves.len() as u64,
proof: path.iter().map(hex::encode).collect(),
})
}
pub fn seal_anchor(&self, anchor_path: &Path, ts_ms: u64) -> Result<Checkpoint, LedgerError> {
let cp = self.checkpoint(ts_ms);
save_anchor(anchor_path, &cp)?;
Ok(cp)
}
pub fn verify_against_anchor(&self, anchor_path: &Path) -> Result<(), LedgerError> {
let Some(cp) = load_anchor(anchor_path)? else {
return Ok(());
};
if !self.verifiers.iter().any(|k| verify_checkpoint_sig(&cp, k)) {
return Err(LedgerError::Tamper {
seq: cp.count,
why:
"anchor signature is not from a trusted ledger key (forged or wrong-key anchor)"
.into(),
});
}
if !ledger_extends_checkpoint_at(&self.location, &cp)? {
return Err(LedgerError::Tamper {
seq: cp.count,
why: format!(
"ledger no longer extends its anchor at count {} — truncated or rewritten \
below the last committed height",
cp.count
),
});
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
pub root: String,
pub count: u64,
pub ts_ms: u64,
pub pubkey_hex: String,
pub sig_b64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeHead {
pub merkle_root: String,
pub tree_size: u64,
pub ts_ms: u64,
pub pubkey_hex: String,
pub sig_b64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InclusionProof {
pub leaf_index: u64,
pub tree_size: u64,
pub leaf_data: String,
pub audit_path: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsistencyProof {
pub first_size: u64,
pub second_size: u64,
pub proof: Vec<String>,
}
fn commitment_bytes(tag: &str, hex_field: &str, a: u64, b: u64) -> Vec<u8> {
format!("{tag}\x1f{hex_field}\x1f{a}\x1f{b}").into_bytes()
}
fn checkpoint_bytes(root: &str, count: u64, ts_ms: u64) -> Vec<u8> {
commitment_bytes("decern-ledger-checkpoint", root, count, ts_ms)
}
fn tree_head_bytes(merkle_root: &str, tree_size: u64, ts_ms: u64) -> Vec<u8> {
commitment_bytes("decern-ledger-tree-head", merkle_root, tree_size, ts_ms)
}
#[derive(Debug)]
pub struct VerifyReport {
pub entries: u64,
pub root: Option<String>,
pub signatures_checked: bool,
}
fn key_fingerprint(vk: &VerifyingKey) -> String {
hex::encode(vk.to_bytes())
}
pub fn verify(path: &Path, pubkey: Option<&VerifyingKey>) -> Result<VerifyReport, LedgerError> {
let loc = Location::detect(path);
match pubkey {
None => verify_inner(&loc, &[], None),
Some(k) => verify_inner(&loc, std::slice::from_ref(k), None),
}
}
pub fn read_verified(
path: &Path,
pubkey: Option<&VerifyingKey>,
offset: usize,
limit: usize,
) -> Result<(VerifyReport, Vec<serde_json::Value>), LedgerError> {
let loc = Location::detect(path);
let mut window = ReadWindow {
offset,
end: offset.saturating_add(limit),
records: Vec::new(),
};
let report = match pubkey {
None => verify_inner(&loc, &[], Some(&mut window)),
Some(k) => verify_inner(&loc, std::slice::from_ref(k), Some(&mut window)),
}?;
Ok((report, window.records))
}
struct ReadWindow {
offset: usize,
end: usize,
records: Vec<serde_json::Value>,
}
pub fn verify_with_keys(path: &Path, keys: &[VerifyingKey]) -> Result<VerifyReport, LedgerError> {
verify_inner(&Location::detect(path), keys, None)
}
fn verify_inner(
location: &Location,
keys: &[VerifyingKey],
sink: Option<&mut ReadWindow>,
) -> Result<VerifyReport, LedgerError> {
let (lines, torn) = location.prefix_lines()?;
let report = verify_lines(lines, keys, sink)?;
match torn {
None => Ok(report),
Some(t) => Err(LedgerError::TornTail {
healed_entries: report.entries,
healed_root: report.root,
torn_path: t.path.display().to_string(),
torn_from_offset: t.offset,
}),
}
}
fn verify_lines(
lines: impl Iterator<Item = Result<String, LedgerError>>,
keys: &[VerifyingKey],
mut sink: Option<&mut ReadWindow>,
) -> Result<VerifyReport, LedgerError> {
let check_sigs = !keys.is_empty();
let mut prev = GENESIS.to_owned();
let mut count: u64 = 0;
for (i, line) in lines.enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let record: RecordIn = serde_json::from_str(&line).map_err(|e| LedgerError::Tamper {
seq: i as u64,
why: format!("unparseable record: {e}"),
})?;
let entry_bytes = record.entry.get().as_bytes();
let entry: Entry =
serde_json::from_str(record.entry.get()).map_err(|e| LedgerError::Tamper {
seq: count,
why: format!("unparseable entry: {e}"),
})?;
if entry.seq != count {
return Err(LedgerError::Tamper {
seq: count,
why: format!("sequence break (found seq {})", entry.seq),
});
}
if record.prev != prev {
return Err(LedgerError::Tamper {
seq: count,
why: "broken chain link (prev mismatch — record edited, moved or removed)".into(),
});
}
let hash = chain_hash(entry_bytes, &record.prev);
if hex::encode(hash) != record.hash {
return Err(LedgerError::Tamper {
seq: count,
why: "entry altered (hash mismatch)".into(),
});
}
if check_sigs {
let sig_bytes: [u8; 64] = B64
.decode(&record.sig_b64)
.map_err(|_| LedgerError::Tamper {
seq: count,
why: "unparseable signature".into(),
})?
.try_into()
.map_err(|_| LedgerError::Tamper {
seq: count,
why: "signature length".into(),
})?;
let sig = decern_crypto::Signature::from_bytes(&sig_bytes);
let verified = match &record.kid {
Some(kid) => match keys.iter().find(|k| key_fingerprint(k) == *kid) {
Some(k) => k.verify(&hash, &sig).is_ok(),
None => {
return Err(LedgerError::Tamper {
seq: count,
why: format!(
"record signed by key {kid}, which is not in the trusted keyring"
),
});
}
},
None => keys.iter().any(|k| k.verify(&hash, &sig).is_ok()),
};
if !verified {
return Err(LedgerError::Tamper {
seq: count,
why: "signature invalid (chain rewritten with a different key?)".into(),
});
}
}
if let Some(w) = sink.as_deref_mut() {
let idx = count as usize;
if idx >= w.offset && idx < w.end {
let value: serde_json::Value =
serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
w.records.push(value);
}
}
prev = record.hash;
count += 1;
}
Ok(VerifyReport {
entries: count,
root: if count > 0 { Some(prev) } else { None },
signatures_checked: check_sigs,
})
}
pub(crate) fn verify_stored_records(
records: &[decern_store::StoredRecord],
keys: &[VerifyingKey],
) -> Result<VerifyReport, LedgerError> {
verify_lines(
records.iter().map(|r| Ok(r.record_json.clone())),
keys,
None,
)
}
pub fn verify_checkpoint_sig(cp: &Checkpoint, pubkey: &VerifyingKey) -> bool {
let Ok(bytes) = B64.decode(&cp.sig_b64) else {
return false;
};
let Ok(sig_arr): Result<[u8; 64], _> = bytes.try_into() else {
return false;
};
let sig = decern_crypto::Signature::from_bytes(&sig_arr);
pubkey
.verify(&checkpoint_bytes(&cp.root, cp.count, cp.ts_ms), &sig)
.is_ok()
}
pub fn verify_tree_head_sig(th: &TreeHead, pubkey: &VerifyingKey) -> bool {
let Ok(bytes) = B64.decode(&th.sig_b64) else {
return false;
};
let Ok(sig_arr): Result<[u8; 64], _> = bytes.try_into() else {
return false;
};
let sig = decern_crypto::Signature::from_bytes(&sig_arr);
pubkey
.verify(
&tree_head_bytes(&th.merkle_root, th.tree_size, th.ts_ms),
&sig,
)
.is_ok()
}
#[derive(Debug, Clone, Serialize)]
pub struct BundleVerdict {
pub accepted: bool,
pub format: String,
pub records: usize,
pub from: u64,
pub full_tail: bool,
pub chain_ok: bool,
pub record_sigs_ok: bool,
pub checkpoint_sig_ok: bool,
pub tree_head_present: bool,
pub tree_head_sig_ok: bool,
pub merkle_root_ok: Option<bool>,
pub anchor_ok: bool,
pub consistency_ok: Option<bool>,
pub errors: Vec<String>,
}
#[derive(Deserialize)]
struct BundleIn {
#[serde(default)]
format: String,
#[serde(default)]
span: SpanIn,
checkpoint: Checkpoint,
#[serde(default)]
tree_head: Option<TreeHead>,
#[serde(default)]
records: Vec<RecordIn>,
}
#[derive(Deserialize, Default)]
struct SpanIn {
#[serde(default)]
from: u64,
}
fn any_key_verifies(msg: &[u8], sig_b64: &str, keys: &[VerifyingKey]) -> bool {
let Ok(bytes) = B64.decode(sig_b64) else {
return false;
};
let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
return false;
};
let sig = decern_crypto::Signature::from_bytes(&arr);
keys.iter().any(|k| k.verify(msg, &sig).is_ok())
}
pub fn verify_evidence_bundle(
bundle_json: &str,
keys: &[VerifyingKey],
against: Option<&TreeHead>,
) -> BundleVerdict {
let mut errors: Vec<String> = Vec::new();
let b: BundleIn = match serde_json::from_str(bundle_json) {
Ok(b) => b,
Err(e) => {
return BundleVerdict {
accepted: false,
format: String::new(),
records: 0,
from: 0,
full_tail: false,
chain_ok: false,
record_sigs_ok: false,
checkpoint_sig_ok: false,
tree_head_present: false,
tree_head_sig_ok: false,
merkle_root_ok: None,
anchor_ok: false,
consistency_ok: None,
errors: vec![format!("bundle does not parse: {e}")],
};
}
};
let from = b.span.from;
let full_tail = from == 0;
let n = b.records.len();
let mut chain_ok = true;
let mut leaves: Vec<Vec<u8>> = Vec::with_capacity(n);
for (i, r) in b.records.iter().enumerate() {
let want = hex::encode(chain_hash(r.entry.get().as_bytes(), &r.prev));
if want != r.hash {
chain_ok = false;
errors.push(format!(
"record {i}: hash does not re-derive from its bytes"
));
}
let expected_prev = if i == 0 {
if full_tail {
GENESIS.to_owned()
} else {
r.prev.clone() }
} else {
b.records[i - 1].hash.clone()
};
if r.prev != expected_prev {
chain_ok = false;
errors.push(format!(
"record {i}: prev does not link to the previous record"
));
}
match hex::decode(&r.hash) {
Ok(bytes) => leaves.push(bytes),
Err(_) => {
chain_ok = false;
errors.push(format!("record {i}: hash is not valid hex"));
}
}
}
let mut record_sigs_ok = true;
for (i, r) in b.records.iter().enumerate() {
let Ok(msg) = hex::decode(&r.hash) else {
record_sigs_ok = false;
continue;
};
if !any_key_verifies(&msg, &r.sig_b64, keys) {
record_sigs_ok = false;
errors.push(format!(
"record {i}: signature not verified by any pinned key"
));
}
}
let checkpoint_sig_ok = keys.iter().any(|k| verify_checkpoint_sig(&b.checkpoint, k));
if !checkpoint_sig_ok {
errors.push("checkpoint signature not verified by any pinned key".into());
}
let tree_head_present = b.tree_head.is_some();
let tree_head_sig_ok = match &b.tree_head {
Some(th) => {
let ok = keys.iter().any(|k| verify_tree_head_sig(th, k));
if !ok {
errors.push("tree-head signature not verified by any pinned key".into());
}
ok
}
None => {
errors.push(
"bundle has no tree_head (Merkle commitment); this verifier requires the \
upgraded decern-evidence-bundle shape"
.into(),
);
false
}
};
let mut anchor_ok = true;
match b.records.last() {
Some(last) if last.hash == b.checkpoint.root => {}
Some(_) => {
anchor_ok = false;
errors.push("last record hash != checkpoint root".into());
}
None => {
if from != b.checkpoint.count {
anchor_ok = false;
errors.push("empty tail but from != checkpoint count".into());
}
}
}
if from + n as u64 != b.checkpoint.count {
anchor_ok = false;
errors.push("from + record count != checkpoint count".into());
}
if let Some(th) = &b.tree_head
&& th.tree_size != b.checkpoint.count
{
anchor_ok = false;
errors.push("tree_head size != checkpoint count".into());
}
let merkle_root_ok = match (&b.tree_head, full_tail && chain_ok) {
(Some(th), true) => {
let recomputed = hex::encode(merkle::tree_hash(&leaves));
let ok = recomputed == th.merkle_root;
if !ok {
errors.push("recomputed Merkle root != signed tree head".into());
}
Some(ok)
}
_ => None,
};
let consistency_ok = match (against, &b.tree_head, full_tail && chain_ok) {
(Some(earlier), Some(current), true) => {
let earlier_sig = keys.iter().any(|k| verify_tree_head_sig(earlier, k));
let first = earlier.tree_size as usize;
let ok = earlier_sig
&& first <= leaves.len()
&& merkle::consistency_proof(&leaves, first).is_some_and(|path| {
match (
hex_to_32(&earlier.merkle_root),
hex_to_32(¤t.merkle_root),
) {
(Some(fr), Some(sr)) => merkle::verify_consistency(
earlier.tree_size,
current.tree_size,
&fr,
&sr,
&path,
),
_ => false,
}
});
if !ok {
errors.push(
"consistency proof against the earlier anchored tree head FAILED \
(possible equivocation/truncation, or the earlier head is unsigned)"
.into(),
);
}
Some(ok)
}
(Some(_), _, false) => {
errors
.push("consistency check needs a full tail (from==0) to recompute; skipped".into());
Some(false)
}
_ => None,
};
let accepted = chain_ok
&& record_sigs_ok
&& checkpoint_sig_ok
&& tree_head_sig_ok
&& anchor_ok
&& merkle_root_ok.unwrap_or(true)
&& consistency_ok.unwrap_or(true);
BundleVerdict {
accepted,
format: b.format,
records: n,
from,
full_tail,
chain_ok,
record_sigs_ok,
checkpoint_sig_ok,
tree_head_present,
tree_head_sig_ok,
merkle_root_ok,
anchor_ok,
consistency_ok,
errors,
}
}
fn hex_to_32(s: &str) -> Option<[u8; 32]> {
hex::decode(s).ok()?.try_into().ok()
}
pub fn ledger_extends_checkpoint(path: &Path, cp: &Checkpoint) -> Result<bool, LedgerError> {
ledger_extends_checkpoint_at(&Location::detect(path), cp)
}
fn ledger_extends_checkpoint_at(location: &Location, cp: &Checkpoint) -> Result<bool, LedgerError> {
Ok(root_at_count(location, cp.count)?.as_deref() == Some(cp.root.as_str()))
}
pub fn save_anchor(anchor_path: &Path, cp: &Checkpoint) -> Result<(), LedgerError> {
let bytes = serde_json::to_vec_pretty(cp).map_err(|e| LedgerError::Serde(e.to_string()))?;
let tmp = anchor_path.with_extension("anchor-tmp");
{
let mut f = File::create(&tmp).map_err(|e| io_err(&tmp, e))?;
f.write_all(&bytes).map_err(|e| io_err(&tmp, e))?;
f.sync_all().map_err(|e| io_err(&tmp, e))?;
}
std::fs::rename(&tmp, anchor_path).map_err(|e| io_err(anchor_path, e))?;
if let Some(parent) = anchor_path.parent().filter(|p| !p.as_os_str().is_empty())
&& let Ok(dir) = File::open(parent)
{
let _ = dir.sync_all();
}
Ok(())
}
pub fn load_anchor(anchor_path: &Path) -> Result<Option<Checkpoint>, LedgerError> {
match std::fs::read(anchor_path) {
Ok(bytes) => Ok(Some(
serde_json::from_slice(&bytes).map_err(|e| LedgerError::Serde(e.to_string()))?,
)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(io_err(anchor_path, e)),
}
}
fn root_at_count(location: &Location, count: u64) -> Result<Option<String>, LedgerError> {
if count == 0 {
return Ok(Some(GENESIS.to_owned()));
}
let mut prev = GENESIS.to_owned();
let mut seen: u64 = 0;
let (lines, _torn) = location.prefix_lines()?;
for line in lines {
let line = line?;
if line.trim().is_empty() {
continue;
}
let record: RecordIn = serde_json::from_str(&line).map_err(|e| LedgerError::Tamper {
seq: seen,
why: format!("unparseable record: {e}"),
})?;
if record.prev != prev {
return Err(LedgerError::Tamper {
seq: seen,
why: "broken chain link (prev mismatch)".into(),
});
}
let hash = hex::encode(chain_hash(record.entry.get().as_bytes(), &record.prev));
if hash != record.hash {
return Err(LedgerError::Tamper {
seq: seen,
why: "entry altered (hash mismatch)".into(),
});
}
prev = record.hash;
seen += 1;
if seen == count {
return Ok(Some(prev));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn entry(action: &str, decision: bool) -> Entry {
Entry {
seq: 0, ts_ms: 1234,
subject_type: "Principal".into(),
subject_id: "agent1".into(),
action: action.into(),
resource_type: "Resource".into(),
resource_id: "claim1".into(),
context: json!({"now": 100}),
decision,
reasons: vec![],
..Default::default()
}
}
fn tmp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("decern-ledger-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
fn h32(hex_str: &str) -> [u8; 32] {
<[u8; 32]>::try_from(hex::decode(hex_str).unwrap()).unwrap()
}
#[test]
fn merkle_tree_head_and_proofs_verify_against_a_real_ledger() {
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let path = tmp("merkle-th.ledger");
let _ = std::fs::remove_file(&path);
let mut l = Ledger::open(&path, key.clone()).unwrap();
for i in 0..4 {
l.append(entry(&format!("a{i}"), true)).unwrap();
}
let th4 = l.tree_head(1_000).unwrap();
assert_eq!(th4.tree_size, 4);
assert!(verify_tree_head_sig(&th4, &vk));
for i in 0..3 {
l.append(entry(&format!("b{i}"), false)).unwrap();
}
let th7 = l.tree_head(2_000).unwrap();
assert_eq!(th7.tree_size, 7);
assert!(verify_tree_head_sig(&th7, &vk));
let other = decern_crypto::generate().unwrap().verifying_key();
assert!(
!verify_tree_head_sig(&th7, &other),
"wrong key must not verify"
);
let mut tampered = th7.clone();
tampered.merkle_root = "00".repeat(32);
assert!(
!verify_tree_head_sig(&tampered, &vk),
"tampered root rejected"
);
let cp = l.checkpoint(2_000);
let cross = TreeHead {
merkle_root: cp.root.clone(),
tree_size: cp.count,
ts_ms: cp.ts_ms,
pubkey_hex: cp.pubkey_hex.clone(),
sig_b64: cp.sig_b64.clone(),
};
assert!(
!verify_tree_head_sig(&cross, &vk),
"a checkpoint sig must not verify as a tree head"
);
let root7 = h32(&th7.merkle_root);
for seq in 0..7u64 {
let ip = l.inclusion_proof(seq).unwrap();
assert_eq!(ip.tree_size, 7);
let leaf_hash = merkle::hash_leaf(&hex::decode(&ip.leaf_data).unwrap());
let audit: Vec<[u8; 32]> = ip.audit_path.iter().map(|h| h32(h)).collect();
assert!(
merkle::verify_inclusion(seq, 7, &leaf_hash, &root7, &audit),
"record {seq} must prove included"
);
}
assert!(
l.inclusion_proof(7).is_err(),
"out-of-range inclusion rejected"
);
let root4 = h32(&th4.merkle_root);
let consist = l.consistency_proof(4).unwrap();
assert_eq!((consist.first_size, consist.second_size), (4, 7));
let cpath: Vec<[u8; 32]> = consist.proof.iter().map(|h| h32(h)).collect();
assert!(
merkle::verify_consistency(4, 7, &root4, &root7, &cpath),
"size-4 prefix must reconcile with the size-7 head"
);
let mut forged = root4;
forged[0] ^= 0xFF;
assert!(!merkle::verify_consistency(4, 7, &forged, &root7, &cpath));
let _ = std::fs::remove_file(&path);
}
#[test]
fn evidence_bundle_verifies_offline_and_catches_tamper() {
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let path = tmp("bundle-verify.ledger");
let _ = std::fs::remove_file(&path);
let mut l = Ledger::open(&path, key.clone()).unwrap();
for i in 0..5 {
l.append(entry(&format!("e{i}"), true)).unwrap();
}
let earlier = l.tree_head(500).unwrap(); for i in 0..3 {
l.append(entry(&format!("f{i}"), false)).unwrap();
}
let count = l.count() as usize;
let make_bundle = |from: usize| -> String {
let recs = l.read_raw_records(from, count - from).unwrap();
let records_arr = format!(
"[{}]",
recs.iter().map(|r| r.get()).collect::<Vec<_>>().join(",")
);
format!(
"{{\"format\":\"decern-evidence-bundle/1\",\"span\":{{\"from\":{from}}},\
\"checkpoint\":{cp},\"tree_head\":{th},\"records\":{records_arr}}}",
cp = serde_json::to_string(&l.checkpoint(900)).unwrap(),
th = serde_json::to_string(&l.tree_head(900).unwrap()).unwrap(),
)
};
let full = make_bundle(0);
let v = verify_evidence_bundle(&full, &[vk], Some(&earlier));
assert!(v.accepted, "full bundle must verify: {:?}", v.errors);
assert_eq!(v.merkle_root_ok, Some(true));
assert_eq!(v.consistency_ok, Some(true));
let other = decern_crypto::generate().unwrap().verifying_key();
assert!(!verify_evidence_bundle(&full, &[other], None).accepted);
let tampered = full.replacen("\"e0\"", "\"e0X\"", 1);
let vt = verify_evidence_bundle(&tampered, &[vk], None);
assert!(
!vt.accepted && !vt.chain_ok,
"tamper caught: {:?}",
vt.errors
);
let partial = make_bundle(3);
let vp = verify_evidence_bundle(&partial, &[vk], None);
assert!(vp.accepted, "partial tail verifies: {:?}", vp.errors);
assert_eq!(vp.merkle_root_ok, None);
let mut forged = earlier.clone();
forged.merkle_root = "11".repeat(32);
let vf = verify_evidence_bundle(&full, &[vk], Some(&forged));
assert_eq!(vf.consistency_ok, Some(false));
assert!(!vf.accepted);
let _ = std::fs::remove_file(&path);
}
#[test]
fn edge_type_omitted_when_attenuate_recorded_when_mint() {
let att = serde_json::to_string(&entry("issue_token", true)).unwrap();
assert!(
!att.contains("\"edge\""),
"attenuate edge must be omitted: {att}"
);
let mut e = entry("issue_token", true);
e.edge = EdgeType::Mint;
let mint = serde_json::to_string(&e).unwrap();
assert!(
mint.contains("\"edge\":\"Mint\""),
"mint edge recorded: {mint}"
);
assert_eq!(
serde_json::from_str::<Entry>(&mint).unwrap().edge,
EdgeType::Mint
);
assert_eq!(
serde_json::from_str::<Entry>(&att).unwrap().edge,
EdgeType::Attenuate
);
}
#[test]
fn append_verify_resume() {
let path = tmp("ok.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("Read", true)).unwrap();
l.append(entry("MoveMoney", false)).unwrap();
drop(l);
let mut l = Ledger::open(&path, key.clone()).unwrap();
let rec = l.append(entry("Read", true)).unwrap();
assert_eq!(rec.entry.seq, 2);
let report = verify(&path, Some(&key.verifying_key())).unwrap();
assert_eq!(report.entries, 3);
assert!(report.root.is_some());
}
#[test]
fn sync_enabled_ledger_appends_and_verifies() {
let path = tmp("synced.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.set_sync(true);
l.append(entry("Read", true)).unwrap();
l.append(entry("MoveMoney", false)).unwrap();
drop(l);
let report = verify(&path, Some(&key.verifying_key())).unwrap();
assert_eq!(report.entries, 2);
assert!(report.signatures_checked);
}
#[test]
fn read_raw_records_bytes_reproduce_the_stored_hash() {
let path = tmp("raw.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
let mut e = entry("Pay", true);
e.context = json!({"z": 1, "a": 2, "m": 3, "amount_minor": 500});
l.append(e).unwrap();
l.append(entry("Read", true)).unwrap();
let raw = l.read_raw_records(0, 100).unwrap();
assert_eq!(raw.len(), 2);
#[derive(serde::Deserialize)]
struct Rec {
entry: Box<serde_json::value::RawValue>,
prev: String,
hash: String,
}
let mut prev = GENESIS.to_owned();
for line in &raw {
let r: Rec = serde_json::from_str(line.get()).unwrap();
let got = hex::encode(chain_hash(r.entry.get().as_bytes(), &prev));
assert_eq!(got, r.hash, "verbatim bytes reproduce the stored hash");
assert_eq!(r.prev, prev, "chain link continuous");
prev = r.hash;
}
assert_eq!(prev, *l.root(), "final recomputed hash == head");
}
#[test]
fn read_verified_returns_a_verified_window_and_fails_closed_on_tamper() {
let path = tmp("readv.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("Read", true)).unwrap();
l.append(entry("MoveMoney", false)).unwrap();
l.append(entry("Read", true)).unwrap();
drop(l);
let (report, recs) = read_verified(&path, Some(&key.verifying_key()), 0, 100).unwrap();
assert_eq!(report.entries, 3);
assert!(report.signatures_checked);
assert_eq!(recs.len(), 3);
assert_eq!(recs[0]["entry"]["seq"], json!(0));
assert_eq!(recs[2]["entry"]["action"], json!("Read"));
let (report, recs) = read_verified(&path, None, 1, 1).unwrap();
assert_eq!(report.entries, 3, "whole chain scanned for integrity");
assert!(!report.signatures_checked, "no key → chain-only");
assert_eq!(recs.len(), 1);
assert_eq!(recs[0]["entry"]["seq"], json!(1));
assert_eq!(recs[0]["entry"]["action"], json!("MoveMoney"));
let text = std::fs::read_to_string(&path).unwrap();
let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
rec.entry.decision = true;
lines[1] = serde_json::to_string(&rec).unwrap();
std::fs::write(&path, lines.join("\n") + "\n").unwrap();
let err = read_verified(&path, Some(&key.verifying_key()), 0, 100).unwrap_err();
assert!(matches!(err, LedgerError::Tamper { seq: 1, .. }), "{err}");
}
#[test]
fn flipped_decision_detected() {
let path = tmp("tamper.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("Read", true)).unwrap();
l.append(entry("MoveMoney", false)).unwrap();
l.append(entry("Read", true)).unwrap();
drop(l);
let text = std::fs::read_to_string(&path).unwrap();
let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
rec.entry.decision = true;
lines[1] = serde_json::to_string(&rec).unwrap();
std::fs::write(&path, lines.join("\n") + "\n").unwrap();
let err = verify(&path, Some(&key.verifying_key())).unwrap_err();
assert!(matches!(err, LedgerError::Tamper { seq: 1, .. }), "{err}");
}
#[test]
fn rewrite_with_other_key_detected() {
let path = tmp("rewrite.ledger");
std::fs::remove_file(&path).ok();
let honest = decern_crypto::generate().unwrap();
let insider = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, insider).unwrap();
l.append(entry("MoveMoney", true)).unwrap();
drop(l);
assert!(verify(&path, None).is_ok());
let err = verify(&path, Some(&honest.verifying_key())).unwrap_err();
assert!(matches!(err, LedgerError::Tamper { .. }));
}
#[test]
fn hostile_float_context_cannot_false_tamper() {
let path = tmp("floats.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
for ctx in [
json!({"now": 100, "z": 1.0715660391465826e-75}),
json!({"v": 2.291712365432881e-9}),
json!({"now": 100, "a": 0.1, "b": 1e308, "c": -5.5e-324}),
] {
let mut e = entry("Read", false);
e.context = ctx;
l.append(e).unwrap();
}
drop(l);
let report = verify(&path, Some(&key.verifying_key())).expect("honest ledger must verify");
assert_eq!(report.entries, 3);
let mut l = Ledger::open(&path, key).expect("honest ledger must reopen");
l.append(entry("Read", true)).unwrap();
}
#[test]
fn corrupt_ledger_refuses_new_writes() {
let path = tmp("refuse.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("Read", true)).unwrap();
drop(l);
let text = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, text.replace("Read", "Raid")).unwrap();
assert!(Ledger::open(&path, key).is_err());
}
#[test]
fn checkpoint_signs_and_verifies() {
let path = tmp("cp-sig.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let other = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("Read", true)).unwrap();
l.append(entry("MoveMoney", false)).unwrap();
let cp = l.checkpoint(9_999);
assert_eq!(cp.count, 2);
assert_eq!(cp.root, l.root());
assert_eq!(cp.pubkey_hex, l.pubkey_hex());
assert!(verify_checkpoint_sig(&cp, &key.verifying_key()));
assert!(!verify_checkpoint_sig(&cp, &other.verifying_key()));
let mut forged = cp.clone();
forged.count = 3;
assert!(!verify_checkpoint_sig(&forged, &key.verifying_key()));
}
#[test]
fn append_only_log_extends_its_own_checkpoint() {
let path = tmp("cp-extend.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key).unwrap();
l.append(entry("Read", true)).unwrap();
l.append(entry("MoveMoney", false)).unwrap();
let cp = l.checkpoint(1); l.append(entry("Read", true)).unwrap();
drop(l);
assert!(ledger_extends_checkpoint(&path, &cp).unwrap());
}
#[test]
fn rewrite_below_a_held_checkpoint_is_caught() {
let path = tmp("cp-rewrite.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key).unwrap();
l.append(entry("Read", true)).unwrap();
l.append(entry("MoveMoney", false)).unwrap();
l.append(entry("Read", true)).unwrap();
let cp = l.checkpoint(1);
drop(l);
let text = std::fs::read_to_string(&path).unwrap();
let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
rec.entry.decision = true;
lines[1] = serde_json::to_string(&rec).unwrap();
std::fs::write(&path, lines.join("\n") + "\n").unwrap();
let r = ledger_extends_checkpoint(&path, &cp);
assert!(
matches!(r, Err(LedgerError::Tamper { .. })) || matches!(r, Ok(false)),
"rewrite below a checkpoint must break extension: {r:?}"
);
}
#[test]
fn truncation_below_a_held_checkpoint_is_caught() {
let path = tmp("cp-truncate.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key).unwrap();
l.append(entry("Read", true)).unwrap();
l.append(entry("MoveMoney", false)).unwrap();
l.append(entry("Read", true)).unwrap();
let cp = l.checkpoint(1); drop(l);
let text = std::fs::read_to_string(&path).unwrap();
let kept: Vec<&str> = text.lines().take(2).collect();
std::fs::write(&path, kept.join("\n") + "\n").unwrap();
assert!(!ledger_extends_checkpoint(&path, &cp).unwrap());
}
#[test]
fn decision_entry_serialization_is_stable() {
let js = serde_json::to_string(&entry("Read", true)).unwrap();
assert_eq!(
js,
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":[]}"#,
"Decision entry serialization drifted from the golden value"
);
assert_eq!(
hex::encode(chain_hash(js.as_bytes(), GENESIS)),
"7b53ca2b3294bd92166dc254d1b56ee12a2a95b76807c08867147729405f8194",
"Decision entry chain hash drifted from the golden value"
);
}
#[test]
fn rotation_keeps_the_whole_chain_verifiable() {
let path = tmp("rotate.ledger");
std::fs::remove_file(&path).ok();
let old = decern_crypto::generate().unwrap();
let new = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, old.clone()).unwrap();
l.append(entry("Read", true)).unwrap(); l.append(entry("Write", true)).unwrap(); l.rotate(new.clone());
l.append(entry("MoveMoney", false)).unwrap(); drop(l);
let report = verify_with_keys(&path, &[old.verifying_key(), new.verifying_key()]).unwrap();
assert_eq!(report.entries, 3);
assert!(report.signatures_checked);
assert!(
verify(&path, Some(&old.verifying_key())).is_err(),
"old key alone cannot verify the post-rotation tail"
);
assert!(
verify(&path, Some(&new.verifying_key())).is_err(),
"new key alone cannot verify the pre-rotation head"
);
}
#[test]
fn rotated_ledger_reopens_with_retired_verifiers() {
let path = tmp("rotate-reopen.ledger");
std::fs::remove_file(&path).ok();
let old = decern_crypto::generate().unwrap();
let new = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, old.clone()).unwrap();
l.append(entry("Read", true)).unwrap();
l.rotate(new.clone());
l.append(entry("Write", true)).unwrap();
drop(l);
assert!(
Ledger::open(&path, new.clone()).is_err(),
"reopen without the retired key must fail on the old-signed head"
);
let mut l =
Ledger::open_with_verifiers(&path, new.clone(), vec![old.verifying_key()]).unwrap();
assert_eq!(l.count(), 2);
l.append(entry("MoveMoney", true)).unwrap(); assert!(l.self_verify().is_ok());
let fps = l.verifier_fingerprints();
assert!(fps.contains(&key_fingerprint(&old.verifying_key())));
assert!(fps.contains(&key_fingerprint(&new.verifying_key())));
}
#[test]
fn a_record_signed_by_an_untrusted_key_is_tamper() {
let path = tmp("rotate-untrusted.ledger");
std::fs::remove_file(&path).ok();
let honest = decern_crypto::generate().unwrap();
let attacker = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, honest.clone()).unwrap();
l.append(entry("Read", true)).unwrap(); drop(l);
let err = verify_with_keys(&path, &[attacker.verifying_key()]).unwrap_err();
assert!(
matches!(err, LedgerError::Tamper { .. }),
"a record whose kid is not in the ring must be tamper: {err}"
);
assert!(verify_with_keys(&path, &[honest.verifying_key()]).is_ok());
}
#[test]
fn anchor_catches_truncation_across_a_reopen() {
let path = tmp("anchor-truncate.ledger");
let anchor = tmp("anchor-truncate.anchor");
std::fs::remove_file(&path).ok();
std::fs::remove_file(&anchor).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("Read", true)).unwrap();
l.append(entry("Write", true)).unwrap();
l.append(entry("MoveMoney", false)).unwrap();
l.seal_anchor(&anchor, 1).unwrap(); drop(l);
let text = std::fs::read_to_string(&path).unwrap();
let kept: Vec<&str> = text.lines().take(2).collect();
std::fs::write(&path, kept.join("\n") + "\n").unwrap();
assert!(
Ledger::open(&path, key.clone()).is_ok(),
"plain reopen cannot see the truncation"
);
let err = match Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor) {
Ok(_) => panic!("open_anchored must catch the truncation"),
Err(e) => e,
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"anchor must catch the truncation: {err}"
);
}
#[test]
fn anchor_accepts_a_legit_append_only_extension() {
let path = tmp("anchor-extend.ledger");
let anchor = tmp("anchor-extend.anchor");
std::fs::remove_file(&path).ok();
std::fs::remove_file(&anchor).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("Read", true)).unwrap();
l.seal_anchor(&anchor, 1).unwrap(); l.append(entry("Write", true)).unwrap(); drop(l);
let l = Ledger::open_anchored(&path, key, Vec::new(), &anchor).unwrap();
assert_eq!(l.count(), 2);
let missing = tmp("anchor-missing.anchor");
std::fs::remove_file(&missing).ok();
assert!(l.verify_against_anchor(&missing).is_ok());
}
#[test]
fn a_forged_anchor_from_an_untrusted_key_is_refused() {
let path = tmp("anchor-forged.ledger");
let anchor = tmp("anchor-forged.anchor");
std::fs::remove_file(&path).ok();
std::fs::remove_file(&anchor).ok();
let key = decern_crypto::generate().unwrap();
let attacker = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("Read", true)).unwrap();
l.append(entry("Write", true)).unwrap();
let honest = l.checkpoint(9); drop(l);
let sig = attacker.sign(&checkpoint_bytes(&honest.root, honest.count, honest.ts_ms));
let forged = Checkpoint {
root: honest.root,
count: honest.count,
ts_ms: honest.ts_ms,
pubkey_hex: hex::encode(attacker.verifying_key().to_bytes()),
sig_b64: B64.encode(sig.to_bytes()),
};
save_anchor(&anchor, &forged).unwrap();
let l = Ledger::open(&path, key).unwrap();
let err = l.verify_against_anchor(&anchor).unwrap_err();
assert!(
matches!(err, LedgerError::Tamper { .. }),
"a forged anchor must be refused: {err}"
);
}
#[test]
fn legacy_kidless_records_verify_against_the_ring() {
let path = tmp("legacy-kidless.ledger");
std::fs::remove_file(&path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("Read", true)).unwrap();
drop(l);
let kid = key_fingerprint(&key.verifying_key());
let text = std::fs::read_to_string(&path).unwrap();
let stripped = text.trim_end().replace(&format!(",\"kid\":\"{kid}\""), "");
assert!(!stripped.contains("kid"), "kid removed: {stripped}");
std::fs::write(&path, stripped + "\n").unwrap();
assert!(verify(&path, Some(&key.verifying_key())).is_ok());
assert!(verify_with_keys(&path, &[key.verifying_key()]).is_ok());
}
fn tmp_dir(name: &str) -> PathBuf {
let d = tmp(name);
std::fs::remove_dir_all(&d).ok();
d
}
#[test]
fn open_segmented_creates_a_directory_with_one_active_segment_and_a_manifest() {
let dir = tmp_dir("seg-init");
let key = decern_crypto::generate().unwrap();
let _l = Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()).unwrap();
assert!(dir.join("manifest.json").exists());
assert!(dir.join("00000001.jsonl").exists());
}
#[test]
fn segmented_append_and_reopen_preserves_root_and_count() {
let dir = tmp_dir("seg-reopen");
let key = decern_crypto::generate().unwrap();
{
let mut l =
Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
.unwrap();
for i in 0..5 {
l.append(entry(&format!("act{i}"), true)).unwrap();
}
}
let l2 = Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()).unwrap();
assert_eq!(l2.count(), 5);
let recs = l2.read_records(0, 10).unwrap();
assert_eq!(recs.len(), 5);
}
#[test]
fn segmented_size_rollover_creates_a_second_segment_and_chain_still_verifies() {
let dir = tmp_dir("seg-size-rollover");
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let mut l = Ledger::open_segmented(
&dir,
key,
Vec::new(),
RolloverPolicy::max_bytes(200), )
.unwrap();
for i in 0..8 {
l.append(entry(&format!("action-{i}"), true)).unwrap();
}
drop(l);
let segs: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
.collect();
assert!(
segs.len() >= 2,
"expected rollover to produce multiple segments, got {}",
segs.len()
);
let report = verify_with_keys(&dir, &[vk]).unwrap();
assert_eq!(report.entries, 8);
let recs = read_verified(&dir, None, 0, 100).unwrap().1;
let seqs: Vec<u64> = recs
.iter()
.map(|r| r["entry"]["seq"].as_u64().unwrap())
.collect();
assert_eq!(seqs, (0..8).collect::<Vec<_>>());
}
#[test]
fn segmented_epoch_rollover_triggers_on_a_bucket_change() {
let dir = tmp_dir("seg-epoch-rollover");
let key = decern_crypto::generate().unwrap();
let mut l =
Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(1000)).unwrap();
let mut e0 = entry("a", true);
e0.ts_ms = 500; l.append(e0).unwrap();
let mut e1 = entry("b", true);
e1.ts_ms = 1500; l.append(e1).unwrap();
drop(l);
let segs: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
.collect();
assert_eq!(segs.len(), 2, "epoch bucket change must trigger a rollover");
}
#[cfg(unix)]
#[test]
fn segmented_sealed_segment_is_chmod_read_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp_dir("seg-chmod");
let key = decern_crypto::generate().unwrap();
let mut l =
Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(150)).unwrap();
for i in 0..8 {
l.append(entry(&format!("action-{i}"), true)).unwrap();
}
drop(l);
let sealed = dir.join("00000001.jsonl");
let mode = std::fs::metadata(&sealed).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o444, "sealed segment must be read-only");
}
#[test]
fn segmented_read_records_offset_limit_spans_a_segment_boundary_and_matches_single_file() {
let single_path = tmp("seg-cmp-single.log");
std::fs::remove_file(&single_path).ok();
let seg_dir = tmp_dir("seg-cmp-segmented");
let key = decern_crypto::generate().unwrap();
let mut single = Ledger::open(&single_path, key.clone()).unwrap();
let mut segmented =
Ledger::open_segmented(&seg_dir, key, Vec::new(), RolloverPolicy::max_bytes(1))
.unwrap();
for i in 0..5 {
let e = entry(&format!("act{i}"), true);
single.append(e.clone()).unwrap();
segmented.append(e).unwrap();
}
let segs = std::fs::read_dir(&seg_dir)
.unwrap()
.filter(|e| {
e.as_ref()
.unwrap()
.file_name()
.to_string_lossy()
.ends_with(".jsonl")
})
.count();
assert!(segs >= 3, "expected several segments, got {segs}");
for (offset, limit) in [(0usize, 5usize), (1, 3), (2, 2), (3, 100), (4, 1), (0, 1)] {
let a = single.read_records(offset, limit).unwrap();
let b = segmented.read_records(offset, limit).unwrap();
assert_eq!(a, b, "read_records({offset}, {limit}) mismatch");
let ar = single.read_raw_records(offset, limit).unwrap();
let br = segmented.read_raw_records(offset, limit).unwrap();
let a_strs: Vec<&str> = ar.iter().map(|v| v.get()).collect();
let b_strs: Vec<&str> = br.iter().map(|v| v.get()).collect();
assert_eq!(
a_strs, b_strs,
"read_raw_records({offset}, {limit}) verbatim-byte mismatch"
);
}
}
#[test]
fn segmented_verify_fails_closed_when_a_manifest_listed_segment_is_missing() {
let dir = tmp_dir("seg-missing-segment");
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open_segmented(
&dir,
key.clone(),
Vec::new(),
RolloverPolicy::max_bytes(150),
)
.unwrap();
for i in 0..8 {
l.append(entry(&format!("action-{i}"), true)).unwrap();
}
drop(l);
std::fs::remove_file(dir.join("00000001.jsonl")).unwrap();
let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
Err(e) => e,
Ok(_) => panic!("expected open_segmented to fail on a missing segment"),
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper, got {err:?}"
);
}
#[test]
fn segmented_anchor_catches_a_truncation_that_deletes_a_whole_sealed_segment() {
let dir = tmp_dir("seg-anchor-truncation");
let anchor_path = tmp("seg-anchor-truncation.anchor");
std::fs::remove_file(&anchor_path).ok();
let key = decern_crypto::generate().unwrap();
let mut l = Ledger::open_segmented(
&dir,
key.clone(),
Vec::new(),
RolloverPolicy::max_bytes(150),
)
.unwrap();
for i in 0..8 {
l.append(entry(&format!("action-{i}"), true)).unwrap();
}
l.seal_anchor(&anchor_path, 999).unwrap();
drop(l);
let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
let last = manifest.segments.pop().unwrap();
if let Some(new_last) = manifest.segments.last_mut() {
new_last.end_seq = None;
}
segment::save_manifest(&dir, &manifest).unwrap();
std::fs::remove_file(dir.join(&last.file)).unwrap();
let err = match Ledger::open_segmented_anchored(
&dir,
key,
Vec::new(),
RolloverPolicy::default(),
&anchor_path,
) {
Err(e) => e,
Ok(_) => panic!("expected open_segmented_anchored to fail: anchor no longer extended"),
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper (anchor no longer extended), got {err:?}"
);
}
#[test]
fn open_on_a_segmented_directory_returns_a_clear_error_not_a_raw_os_error() {
let dir = tmp_dir("seg-vs-open");
let key = decern_crypto::generate().unwrap();
let _l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
.unwrap();
let err = match Ledger::open(&dir, key) {
Err(e) => e,
Ok(_) => panic!("expected Ledger::open to refuse a segmented directory"),
};
match err {
LedgerError::Io { err, .. } => assert!(
err.contains("open_segmented"),
"expected a clear pointer to open_segmented, got: {err}"
),
other => panic!("expected LedgerError::Io, got {other:?}"),
}
}
#[test]
fn open_segmented_ignores_an_orphan_segment_left_by_a_crashed_rollover() {
let dir = tmp_dir("seg-orphan");
let key = decern_crypto::generate().unwrap();
{
let mut l = Ledger::open_segmented(
&dir,
key.clone(),
Vec::new(),
RolloverPolicy::default(), )
.unwrap();
l.append(entry("a", true)).unwrap();
}
std::fs::write(dir.join("00000002.jsonl"), b"").unwrap();
let mut l =
Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
assert_eq!(l.count(), 1, "the orphan must not be silently adopted");
l.append(entry("b", true)).unwrap();
assert!(
dir.join("00000003.jsonl").exists(),
"rollover must skip past the orphan's index, not overwrite it"
);
let orphan_still_empty = std::fs::metadata(dir.join("00000002.jsonl")).unwrap().len();
assert_eq!(
orphan_still_empty, 0,
"orphan must never be silently reused/overwritten"
);
}
#[test]
fn roll_over_ignores_a_planted_out_of_range_filename_instead_of_overflowing() {
let dir = tmp_dir("seg-overflow-guard");
let key = decern_crypto::generate().unwrap();
let mut l =
Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
l.append(entry("a", true)).unwrap();
std::fs::write(dir.join("4294967295.jsonl"), b"").unwrap();
l.append(entry("b", true))
.expect("the decoy filename must not block a normal rollover");
assert!(
dir.join("00000002.jsonl").exists(),
"rollover must proceed to the next real index, unaffected by the decoy"
);
assert!(
!dir.join("00000000.jsonl").exists(),
"must never silently wrap to and create index 0"
);
}
#[test]
fn open_segmented_rejects_a_manifest_that_names_an_out_of_range_segment_filename() {
let dir = tmp_dir("seg-overflow-manifest");
let key = decern_crypto::generate().unwrap();
{
let mut l =
Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
.unwrap();
l.append(entry("a", true)).unwrap();
}
let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
manifest.segments[0].file = "4294967295.jsonl".into();
std::fs::write(
dir.join("manifest.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
Err(e) => e,
Ok(_) => panic!("expected an out-of-range manifest filename to be rejected"),
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper (invalid segment filename), got {err:?}"
);
}
#[test]
fn open_segmented_rejects_a_manifest_with_two_active_segments() {
let dir = tmp_dir("seg-dual-active");
let key = decern_crypto::generate().unwrap();
{
let mut l =
Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
.unwrap();
for i in 0..3 {
l.append(entry(&format!("a{i}"), true)).unwrap();
}
}
let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
manifest.segments[0].end_seq = None;
segment::save_manifest(&dir, &manifest).unwrap();
let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
Err(e) => e,
Ok(_) => panic!("expected a dual-active manifest to be rejected"),
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper (dual active segment), got {err:?}"
);
}
#[test]
fn open_segmented_rejects_a_manifest_whose_active_segment_is_not_the_last_entry() {
let dir = tmp_dir("seg-active-not-tail");
let key = decern_crypto::generate().unwrap();
{
let mut l =
Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
.unwrap();
for i in 0..3 {
l.append(entry(&format!("a{i}"), true)).unwrap();
}
}
let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
let last_end = manifest.segments.last().unwrap().end_seq;
manifest.segments[0].end_seq = None;
manifest.segments.last_mut().unwrap().end_seq = last_end.or(Some(3));
segment::save_manifest(&dir, &manifest).unwrap();
let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
Err(e) => e,
Ok(_) => panic!("expected a non-tail-active manifest to be rejected"),
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper (active segment not last), got {err:?}"
);
}
#[test]
fn segment_paths_rejects_a_path_traversal_filename_in_the_manifest() {
let dir = tmp_dir("seg-traversal");
let outside = tmp("seg-traversal-secret.jsonl");
std::fs::write(&outside, b"{\"leaked\":true}\n").unwrap();
let key = decern_crypto::generate().unwrap();
{
let mut l =
Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
.unwrap();
l.append(entry("a", true)).unwrap();
}
let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
manifest.segments[0].file = "../seg-traversal-secret.jsonl".into();
segment::save_manifest(&dir, &manifest).unwrap();
let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
Err(e) => e,
Ok(_) => panic!("expected a path-traversal filename to be rejected"),
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper (invalid segment filename), got {err:?}"
);
}
#[test]
fn fresh_epoch_only_ledger_does_not_waste_an_empty_first_segment() {
let dir = tmp_dir("seg-epoch-fresh-no-waste");
let key = decern_crypto::generate().unwrap();
let mut l =
Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
.unwrap();
let mut e = entry("first", true);
e.ts_ms = 1_780_000_000_000; l.append(e).unwrap();
drop(l);
assert!(
dir.join("00000001.jsonl").exists(),
"the first entry must land in segment 1"
);
assert!(
!dir.join("00000002.jsonl").exists(),
"a still-empty first segment must never be rolled over"
);
let bytes = std::fs::metadata(dir.join("00000001.jsonl")).unwrap().len();
assert!(bytes > 0, "segment 1 must actually hold the record");
}
#[test]
fn segmented_deleting_a_middle_segment_with_manifest_reconciled_is_still_caught() {
let dir = tmp_dir("seg-middle-gap");
let key = decern_crypto::generate().unwrap();
{
let mut l =
Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
.unwrap();
for i in 0..4 {
l.append(entry(&format!("a{i}"), true)).unwrap();
}
}
let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
assert!(manifest.segments.len() >= 3, "need a real middle segment");
let middle = manifest.segments.remove(1);
segment::save_manifest(&dir, &manifest).unwrap();
std::fs::remove_file(dir.join(&middle.file)).unwrap();
let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
Err(e) => e,
Ok(_) => panic!("expected a reconciled middle-segment gap to still be caught"),
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper (sequence break), got {err:?}"
);
}
#[test]
fn segmented_read_verified_offset_window_matches_read_records_across_a_boundary() {
let dir = tmp_dir("seg-read-verified-parity");
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let mut l =
Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
for i in 0..6 {
l.append(entry(&format!("a{i}"), true)).unwrap();
}
for (offset, limit) in [(0usize, 100usize), (2, 3), (1, 1), (5, 10)] {
let direct = l.read_records(offset, limit).unwrap();
let (_report, verified) = read_verified(&dir, Some(&vk), offset, limit).unwrap();
assert_eq!(
direct, verified,
"read_verified({offset}, {limit}) must match read_records exactly"
);
}
}
#[test]
fn roll_over_refuses_to_create_a_segment_past_the_8_digit_filename_ceiling() {
let dir = tmp_dir("seg-8digit-ceiling");
std::fs::create_dir_all(&dir).unwrap();
let seg_file = segment::segment_filename(99_999_999);
std::fs::write(dir.join(&seg_file), b"").unwrap();
let manifest = segment::Manifest {
version: 1,
segments: vec![segment::SegmentMeta {
file: seg_file,
start_seq: 0,
end_seq: None,
opened_ms: 0,
}],
};
segment::save_manifest(&dir, &manifest).unwrap();
let key = decern_crypto::generate().unwrap();
let mut l =
Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
.unwrap();
l.append(entry("a", true)).unwrap(); let err = match l.append(entry("b", true)) {
Err(e) => e,
Ok(_) => panic!("expected rollover past the 8-digit ceiling to be refused"),
};
assert!(
matches!(err, LedgerError::Io { .. }),
"expected a clean Io error at rollover time, got {err:?}"
);
drop(l);
let reopened =
Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
assert_eq!(
reopened.count(),
1,
"only the first append should have landed"
);
}
#[test]
fn segmented_epoch_policy_keeps_two_same_bucket_entries_in_one_segment() {
let dir = tmp_dir("seg-epoch-rebase");
let key = decern_crypto::generate().unwrap();
let mut l =
Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
.unwrap();
let mut e0 = entry("a", true);
e0.ts_ms = 1_780_000_000_000; l.append(e0).unwrap();
let mut e1 = entry("b", true);
e1.ts_ms = 1_780_000_000_500; l.append(e1).unwrap();
drop(l);
let segs: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
.collect();
assert_eq!(
segs.len(),
1,
"two entries in the same real epoch bucket must stay in one segment"
);
}
#[test]
fn open_segmented_rejects_a_manifest_with_reordered_sealed_segments() {
let dir = tmp_dir("seg-reordered");
let key = decern_crypto::generate().unwrap();
{
let mut l =
Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
.unwrap();
for i in 0..4 {
l.append(entry(&format!("a{i}"), true)).unwrap();
}
}
let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
assert!(
manifest.segments.len() >= 3,
"need at least two sealed segments to swap"
);
manifest.segments.swap(0, 1);
segment::save_manifest(&dir, &manifest).unwrap();
let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
Err(e) => e,
Ok(_) => panic!("expected a reordered manifest to be rejected"),
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper (non-contiguous segments), got {err:?}"
);
}
#[test]
fn open_segmented_rejects_a_manifest_with_zero_segments() {
let dir = tmp_dir("seg-zero-segments");
let key = decern_crypto::generate().unwrap();
{
let mut l =
Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
.unwrap();
l.append(entry("a", true)).unwrap();
}
let empty = segment::Manifest {
version: 1,
segments: vec![],
};
segment::save_manifest(&dir, &empty).unwrap();
let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
Err(e) => e,
Ok(_) => panic!("expected a zero-segment manifest to be rejected"),
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper (zero segments), got {err:?}"
);
}
#[test]
fn segmented_manifest_missing_its_head_segment_is_rejected_including_on_an_already_open_handle()
{
let dir = tmp_dir("seg-head-drop");
let key = decern_crypto::generate().unwrap();
let mut l =
Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
.unwrap();
for i in 0..4 {
l.append(entry(&format!("a{i}"), true)).unwrap();
}
let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
assert!(
manifest.segments.len() >= 3,
"need a real head segment to drop"
);
manifest.segments.remove(0);
segment::save_manifest(&dir, &manifest).unwrap();
let err = l
.read_records(0, 100)
.expect_err("head-dropped manifest must be rejected, not silently shifted");
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper (missing chain head), got {err:?}"
);
drop(l);
let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
Err(e) => e,
Ok(_) => panic!("expected a head-dropped manifest to be rejected on reopen"),
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"expected Tamper (missing chain head), got {err:?}"
);
}
#[cfg(unix)]
#[test]
fn opened_ms_rebase_rolls_back_in_memory_on_a_failed_persist_so_a_retry_still_persists_it() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp_dir("seg-rebase-rollback");
let key = decern_crypto::generate().unwrap();
let mut l =
Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
.unwrap();
let mut e0 = entry("a", true);
e0.ts_ms = 1_780_000_000_000;
let mut perms = std::fs::metadata(&dir).unwrap().permissions();
perms.set_mode(0o500);
std::fs::set_permissions(&dir, perms.clone()).unwrap();
l.append(e0.clone())
.expect_err("save_manifest should fail while the dir is read-only");
perms.set_mode(0o700);
std::fs::set_permissions(&dir, perms).unwrap();
l.append(e0).unwrap();
let manifest = segment::load_manifest(&dir).unwrap().unwrap();
assert_eq!(
manifest.segments[0].opened_ms, 1_780_000_000_000,
"the retry must have persisted the rebased opened_ms to disk"
);
}
fn seed_lines(path: &Path, key: &SigningKey, n: usize) -> Vec<String> {
std::fs::remove_file(path).ok();
let mut l = Ledger::open(path, key.clone()).unwrap();
for i in 0..n {
l.append(entry(&format!("act{i}"), true)).unwrap();
}
drop(l);
std::fs::read_to_string(path)
.unwrap()
.lines()
.map(str::to_owned)
.collect()
}
#[test]
fn crash_torn_tail_heals_as_torn_tail_not_tamper() {
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let path = tmp("torn-heals.ledger");
let lines = seed_lines(&path, &key, 3);
let torn = format!(
"{}\n{}\n{}",
lines[0],
lines[1],
&lines[2][..lines[2].len() / 2]
);
std::fs::write(&path, &torn).unwrap();
let err = verify(&path, Some(&vk)).unwrap_err();
match err {
LedgerError::TornTail { healed_entries, .. } => assert_eq!(healed_entries, 2),
other => panic!("expected TornTail, got {other:?}"),
}
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("after-heal", true)).unwrap();
drop(l);
let report = verify(&path, Some(&vk)).unwrap();
assert_eq!(report.entries, 3);
}
#[test]
fn unterminated_but_complete_final_record_is_discarded_then_appends_cleanly() {
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let path = tmp("torn-complete.ledger");
let lines = seed_lines(&path, &key, 3);
std::fs::write(&path, format!("{}\n{}\n{}", lines[0], lines[1], lines[2])).unwrap();
match verify(&path, Some(&vk)).unwrap_err() {
LedgerError::TornTail { healed_entries, .. } => assert_eq!(healed_entries, 2),
other => panic!("expected TornTail, got {other:?}"),
}
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("after-heal", true)).unwrap();
drop(l);
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(
text.lines().count(),
3,
"records must stay one-per-line: {text}"
);
assert!(
text.ends_with('\n'),
"healed log is newline-terminated again"
);
assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 3);
}
#[test]
fn attacker_ragged_truncation_below_anchor_stays_tamper() {
let key = decern_crypto::generate().unwrap();
let path = tmp("torn-below-anchor.ledger");
let anchor = tmp("torn-below-anchor.anchor");
std::fs::remove_file(&path).ok();
std::fs::remove_file(&anchor).ok();
let mut l = Ledger::open(&path, key.clone()).unwrap();
for i in 0..3 {
l.append(entry(&format!("act{i}"), true)).unwrap();
}
l.seal_anchor(&anchor, 1_000).unwrap(); drop(l);
let lines: Vec<String> = std::fs::read_to_string(&path)
.unwrap()
.lines()
.map(str::to_owned)
.collect();
let ragged = format!("{}\n{}", lines[0], &lines[1][..lines[1].len() / 2]);
std::fs::write(&path, &ragged).unwrap();
let len_before = std::fs::metadata(&path).unwrap().len();
let err = match Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor) {
Ok(_) => panic!("ragged truncation below the anchor must fail to open"),
Err(e) => e,
};
assert!(
matches!(err, LedgerError::Tamper { .. }),
"ragged truncation below the anchor must be Tamper, got {err:?}"
);
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
len_before,
"the file must not be mutated when the torn tail is really a truncation attack"
);
}
#[test]
fn terminated_final_record_with_broken_signature_stays_tamper() {
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let path = tmp("terminated-bad-sig.ledger");
let lines = seed_lines(&path, &key, 3);
let mut last: serde_json::Value = serde_json::from_str(&lines[2]).unwrap();
let sig = last["sig_b64"].as_str().unwrap().to_owned();
let flipped = if sig.starts_with('A') { 'B' } else { 'A' };
last["sig_b64"] = json!(format!("{flipped}{}", &sig[1..]));
let corrupt = format!(
"{}\n{}\n{}\n",
lines[0],
lines[1],
serde_json::to_string(&last).unwrap()
);
std::fs::write(&path, &corrupt).unwrap();
match verify(&path, Some(&vk)).unwrap_err() {
LedgerError::Tamper { .. } => {}
other => panic!("terminated bad-signature record must be Tamper, got {other:?}"),
}
}
#[test]
fn terminated_final_record_with_broken_chain_stays_tamper() {
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let path = tmp("terminated-bad-chain.ledger");
let lines = seed_lines(&path, &key, 3);
let mut last: serde_json::Value = serde_json::from_str(&lines[2]).unwrap();
last["hash"] = json!("00".repeat(32));
let corrupt = format!(
"{}\n{}\n{}\n",
lines[0],
lines[1],
serde_json::to_string(&last).unwrap()
);
std::fs::write(&path, &corrupt).unwrap();
match verify(&path, Some(&vk)).unwrap_err() {
LedgerError::Tamper { .. } => {}
other => panic!("terminated broken-chain record must be Tamper, got {other:?}"),
}
}
#[test]
fn healthy_log_ends_newline_terminated_and_reopens_clean() {
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let path = tmp("healthy-roundtrip.ledger");
seed_lines(&path, &key, 4);
let bytes = std::fs::read(&path).unwrap();
assert_eq!(*bytes.last().unwrap(), b'\n', "log is newline-terminated");
assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 4);
let mut l = Ledger::open(&path, key.clone()).unwrap();
l.append(entry("more", true)).unwrap();
drop(l);
assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 5);
}
#[test]
fn anchored_crash_tail_above_the_anchor_heals_and_opens() {
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let path = tmp("anchored-crash-tail.ledger");
let anchor = tmp("anchored-crash-tail.anchor");
std::fs::remove_file(&path).ok();
std::fs::remove_file(&anchor).ok();
let mut l = Ledger::open(&path, key.clone()).unwrap();
for i in 0..3 {
l.append(entry(&format!("act{i}"), true)).unwrap();
}
l.seal_anchor(&anchor, 1_000).unwrap(); l.append(entry("act3", true)).unwrap(); drop(l);
let lines: Vec<String> = std::fs::read_to_string(&path)
.unwrap()
.lines()
.map(str::to_owned)
.collect();
let torn = format!(
"{}\n{}\n{}\n{}",
lines[0],
lines[1],
lines[2],
&lines[3][..lines[3].len() / 2]
);
std::fs::write(&path, &torn).unwrap();
let mut l = Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor).unwrap();
l.append(entry("act3-again", true)).unwrap();
drop(l);
assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 4);
}
#[test]
fn segmented_torn_tail_in_active_segment_heals() {
let key = decern_crypto::generate().unwrap();
let vk = key.verifying_key();
let dir = tmp("segmented-torn");
std::fs::remove_dir_all(&dir).ok();
let policy = RolloverPolicy {
max_bytes: Some(1),
epoch_ms: None,
};
let mut l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), policy).unwrap();
for i in 0..4 {
l.append(entry(&format!("s{i}"), true)).unwrap();
}
drop(l);
let paths = segment::segment_paths(&dir).unwrap();
let active = paths.last().unwrap().clone();
let alines: Vec<String> = std::fs::read_to_string(&active)
.unwrap()
.lines()
.map(str::to_owned)
.collect();
assert!(!alines.is_empty(), "active segment should hold >=1 record");
let kept = &alines[..alines.len() - 1];
let mut body = kept.iter().map(|s| format!("{s}\n")).collect::<String>();
let torn = alines.last().unwrap();
body.push_str(&torn[..torn.len() / 2]);
std::fs::write(&active, &body).unwrap();
assert!(matches!(
verify(&dir, Some(&vk)).unwrap_err(),
LedgerError::TornTail { .. }
));
let healed_before = match verify(&dir, Some(&vk)).unwrap_err() {
LedgerError::TornTail { healed_entries, .. } => healed_entries,
_ => unreachable!(),
};
let mut l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), policy).unwrap();
l.append(entry("post-heal", true)).unwrap();
drop(l);
assert_eq!(
verify(&dir, Some(&vk)).unwrap().entries,
healed_before + 1,
"chain verifies across the segment boundary after healing the active tail"
);
}
}