use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
const GENESIS: &str = "genesis";
#[derive(Serialize)]
pub struct Entry<'a> {
pub ts: u64,
pub tool: &'a str,
pub kind: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub risk: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effect: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub taint: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<&'a str>,
pub decision: &'a str,
pub arguments: &'a Value,
}
pub struct Ledger {
file: File,
seq: u64,
prev_hash: String,
}
impl Ledger {
pub fn open(path: &Path) -> std::io::Result<Self> {
let (seq, prev_hash) = std::fs::read_to_string(path)
.ok()
.and_then(|s| last_chain_state(&s))
.unwrap_or((0, GENESIS.to_string()));
let file = OpenOptions::new().create(true).append(true).open(path)?;
Ok(Self {
file,
seq,
prev_hash,
})
}
pub fn append(&mut self, entry: &Entry) {
let Ok(payload) = serde_json::to_value(entry) else {
return;
};
let hash = chain_hash(&self.prev_hash, self.seq, &canonical(&payload));
let Value::Object(mut map) = payload else {
return;
};
map.insert(
"_fg".to_string(),
json!({ "seq": self.seq, "prev": self.prev_hash, "hash": hash }),
);
if let Ok(mut line) = serde_json::to_string(&Value::Object(map)) {
line.push('\n');
if self.file.write_all(line.as_bytes()).is_ok() {
let _ = self.file.flush();
self.prev_hash = hash;
self.seq += 1;
}
}
}
}
fn canonical(v: &Value) -> String {
serde_json::to_string(v).unwrap_or_default()
}
fn chain_hash(prev: &str, seq: u64, canon: &str) -> String {
let mut h = Sha256::new();
h.update(prev.as_bytes());
h.update([b'\n']);
h.update(seq.to_string().as_bytes());
h.update([b'\n']);
h.update(canon.as_bytes());
hex(&h.finalize())
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
fn last_chain_state(contents: &str) -> Option<(u64, String)> {
let last = contents.lines().rev().find(|l| !l.trim().is_empty())?;
let v: Value = serde_json::from_str(last).ok()?;
let fg = v.get("_fg")?;
let seq = fg.get("seq")?.as_u64()?;
let hash = fg.get("hash")?.as_str()?.to_string();
Some((seq + 1, hash))
}
#[derive(Debug, PartialEq, Eq)]
pub struct VerifyReport {
pub entries: usize,
pub intact: bool,
pub broken_line: Option<usize>,
pub detail: Option<String>,
}
pub fn verify(path: &Path) -> std::io::Result<VerifyReport> {
Ok(verify_str(&std::fs::read_to_string(path)?))
}
pub fn verify_str(contents: &str) -> VerifyReport {
let mut prev = GENESIS.to_string();
let mut expected_seq = 0u64;
let mut count = 0usize;
for (i, line) in contents.lines().enumerate() {
let lineno = i + 1;
if line.trim().is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<Value>(line) else {
return broken(count, lineno, "line is not valid JSON");
};
let Some(fg) = v.get("_fg") else {
return broken(count, lineno, "entry has no `_fg` integrity envelope");
};
let (Some(seq), Some(stored_prev), Some(stored_hash)) = (
fg.get("seq").and_then(Value::as_u64),
fg.get("prev").and_then(Value::as_str),
fg.get("hash").and_then(Value::as_str),
) else {
return broken(count, lineno, "`_fg` envelope is malformed");
};
if stored_prev != prev {
return broken(
count,
lineno,
format!(
"chain link broken: expected prev {prev}, found {stored_prev} \
(an entry was inserted, deleted, or reordered)"
),
);
}
if seq != expected_seq {
return broken(
count,
lineno,
format!("sequence gap: expected {expected_seq}, found {seq}"),
);
}
let mut payload = v.clone();
if let Value::Object(map) = &mut payload {
map.remove("_fg");
}
if chain_hash(stored_prev, seq, &canonical(&payload)) != stored_hash {
return broken(
count,
lineno,
"entry content does not match its hash (it was edited)",
);
}
prev = stored_hash.to_string();
expected_seq += 1;
count += 1;
}
VerifyReport {
entries: count,
intact: true,
broken_line: None,
detail: None,
}
}
fn broken(entries: usize, line: usize, detail: impl Into<String>) -> VerifyReport {
VerifyReport {
entries,
intact: false,
broken_line: Some(line),
detail: Some(detail.into()),
}
}
pub fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tmp(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("fg_ledger_{}_{}.jsonl", name, std::process::id()))
}
fn sample<'a>(ts: u64, tool: &'a str, args: &'a Value) -> Entry<'a> {
Entry {
ts,
tool,
kind: "mutation",
risk: Some("high"),
effect: None,
taint: None,
policy: None,
decision: "dry-run",
arguments: args,
}
}
#[test]
fn appends_one_json_line_per_entry_and_omits_empty_fields() {
let path = tmp("omit");
let _ = std::fs::remove_file(&path);
{
let mut l = Ledger::open(&path).unwrap();
l.append(&Entry {
ts: 1,
tool: "send_email",
kind: "mutation",
risk: Some("high"),
effect: Some("sends to attacker@evil.com"),
taint: Some("attacker@evil.com"),
policy: None,
decision: "denied",
arguments: &json!({"to": "attacker@evil.com"}),
});
l.append(&Entry {
ts: 2,
tool: "read_file",
kind: "read-only",
risk: None,
effect: None,
taint: None,
policy: None,
decision: "forwarded",
arguments: &json!({"path": "/y"}),
});
}
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2, "one JSON line per entry");
let e1: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(e1["tool"], "send_email");
assert_eq!(e1["decision"], "denied");
assert_eq!(e1["taint"], "attacker@evil.com");
let e2: Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(e2["kind"], "read-only");
assert!(e2.get("risk").is_none());
assert!(e2.get("taint").is_none());
assert!(verify_str(&content).intact);
let _ = std::fs::remove_file(&path);
}
#[test]
fn an_untouched_chain_verifies() {
let path = tmp("intact");
let _ = std::fs::remove_file(&path);
{
let mut l = Ledger::open(&path).unwrap();
for i in 0..3 {
l.append(&sample(i, "write_file", &json!({"path": format!("f{i}")})));
}
}
let report = verify(&path).unwrap();
assert_eq!(
report,
VerifyReport {
entries: 3,
intact: true,
broken_line: None,
detail: None
}
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn editing_an_entry_is_detected_at_that_line() {
let path = tmp("edit");
let _ = std::fs::remove_file(&path);
{
let mut l = Ledger::open(&path).unwrap();
for i in 0..3 {
l.append(&sample(i, "write_file", &json!({"path": format!("f{i}")})));
}
}
let content = std::fs::read_to_string(&path).unwrap();
let mut lines: Vec<String> = content.lines().map(String::from).collect();
lines[1] = lines[1].replace("\"decision\":\"dry-run\"", "\"decision\":\"executed\"");
let tampered = lines.join("\n");
let report = verify_str(&tampered);
assert!(!report.intact);
assert_eq!(report.broken_line, Some(2), "the edited line is pinpointed");
assert_eq!(report.entries, 1, "one entry verified before the break");
let _ = std::fs::remove_file(&path);
}
#[test]
fn deleting_or_reordering_an_entry_breaks_the_chain() {
let path = tmp("delete");
let _ = std::fs::remove_file(&path);
{
let mut l = Ledger::open(&path).unwrap();
for i in 0..3 {
l.append(&sample(i, "write_file", &json!({"path": format!("f{i}")})));
}
}
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
let deleted = format!("{}\n{}", lines[0], lines[2]);
let report = verify_str(&deleted);
assert!(!report.intact);
assert_eq!(report.broken_line, Some(2));
let reordered = format!("{}\n{}\n{}", lines[0], lines[2], lines[1]);
assert!(!verify_str(&reordered).intact);
let _ = std::fs::remove_file(&path);
}
#[test]
fn the_chain_continues_across_a_reopen() {
let path = tmp("reopen");
let _ = std::fs::remove_file(&path);
{
let mut l = Ledger::open(&path).unwrap();
l.append(&sample(1, "a", &json!({})));
}
{
let mut l = Ledger::open(&path).unwrap();
l.append(&sample(2, "b", &json!({})));
l.append(&sample(3, "c", &json!({})));
}
let report = verify(&path).unwrap();
assert!(report.intact, "reopened chain stays unbroken");
assert_eq!(report.entries, 3);
let _ = std::fs::remove_file(&path);
}
}