use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use sha2::Digest as _;
pub const AUDIT_FILE: &str = "audit-chain.jsonl";
const DOMAIN: &str = "gunnar-audit-v1";
pub const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
#[derive(Debug, thiserror::Error)]
pub enum AuditError {
#[error("the audit chain at {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("the audit chain entry on line {line} does not parse: {source}")]
Malformed {
line: usize,
source: serde_json::Error,
},
#[error("the audit chain at {path} could not be framed as a transaction: {detail}")]
Frame {
path: String,
detail: String,
},
#[error("the audit chain at {path} cannot be the replicated trail: {why}")]
Unframed {
path: String,
why: String,
},
#[error("the audit chain at {path} is the primary's trail and this box follows it (a twin, or restoring): it originates nothing into it")]
NotOriginated {
path: String,
},
#[error("the audit chain is broken at entry {seq} (line {line}): {what}")]
Broken {
seq: u64,
line: usize,
what: String,
},
}
#[derive(Debug, Clone, Copy)]
pub struct AuditEvent<'a> {
pub method: &'a str,
pub principal: &'a str,
pub fingerprint: &'a str,
pub scope: &'a str,
pub level: &'a str,
pub auth_mode: &'a str,
pub decision: &'a str,
pub reason: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuditEntry {
pub seq: u64,
pub at_unix_ms: i64,
pub method: String,
pub principal: String,
pub fingerprint: String,
pub scope: String,
pub level: String,
pub auth_mode: String,
pub decision: String,
pub reason: String,
pub prev: String,
pub hash: String,
}
impl AuditEntry {
pub fn compute_hash(&self) -> String {
let mut hasher = sha2::Sha256::new();
let mut field = |bytes: &[u8]| {
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
};
field(DOMAIN.as_bytes());
field(self.prev.as_bytes());
field(&self.seq.to_le_bytes());
field(&self.at_unix_ms.to_le_bytes());
field(self.method.as_bytes());
field(self.principal.as_bytes());
field(self.fingerprint.as_bytes());
field(self.scope.as_bytes());
field(self.level.as_bytes());
field(self.auth_mode.as_bytes());
field(self.decision.as_bytes());
field(self.reason.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(64);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
}
#[derive(Debug)]
pub struct AuditChain {
path: PathBuf,
tip: Mutex<Tip>,
framed: bool,
}
#[derive(Debug)]
struct Tip {
next_seq: u64,
hash: String,
queued: Vec<String>,
stale: bool,
unfiled: Vec<(u64, String)>,
}
#[derive(Debug, Clone)]
pub enum Recorded {
Written(AuditEntry),
Queued {
entry: AuditEntry,
pending: usize,
},
NotOriginated,
}
enum Wrote {
Yes,
Released,
Follower,
}
impl AuditChain {
pub fn open(path: impl Into<PathBuf>) -> Result<AuditChain, AuditError> {
let path = path.into();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| AuditError::Io {
path: parent.display().to_string(),
source,
})?;
}
let entries = read_entries(&path)?;
verify_entries(&entries)?;
let tip = match entries.last() {
Some(last) => Tip {
next_seq: last.seq + 1,
hash: last.hash.clone(),
queued: Vec::new(),
stale: false,
unfiled: Vec::new(),
},
None => Tip {
next_seq: 0,
hash: GENESIS.to_owned(),
queued: Vec::new(),
stale: false,
unfiled: Vec::new(),
},
};
Ok(AuditChain {
path,
tip: Mutex::new(tip),
framed: false,
})
}
pub fn open_framed(path: impl Into<PathBuf>) -> Result<AuditChain, AuditError> {
let path = path.into();
match crate::txlog::sink::log_for(&path) {
None => {
return Err(AuditError::Unframed {
path: path.display().to_string(),
why: "it is under no data root that has a transaction log — the layout put \
the `log` directory outside the data root, or this box originates \
nothing (a twin, a box being restored)"
.to_owned(),
})
}
Some((_, rel)) if rel != crate::txlog::AUDIT_REL => {
return Err(AuditError::Unframed {
path: path.display().to_string(),
why: format!(
"the log would name it {rel:?}, and a restore resolves only {:?}",
crate::txlog::AUDIT_REL
),
})
}
Some(_) => {}
}
let mut chain = AuditChain::open(path)?;
chain.framed = true;
Ok(chain)
}
pub fn is_framed(&self) -> bool {
self.framed
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn append(&self, event: AuditEvent<'_>, at_unix_ms: i64) -> Result<AuditEntry, AuditError> {
match self.record(event, at_unix_ms)? {
Recorded::Written(entry) | Recorded::Queued { entry, .. } => Ok(entry),
Recorded::NotOriginated => Err(AuditError::NotOriginated {
path: self.path.display().to_string(),
}),
}
}
fn framing_log(&self) -> Option<std::sync::Arc<crate::txlog::Log>> {
if self.framed {
crate::txlog::sink::log_for(&self.path).map(|(log, _)| log)
} else {
None
}
}
pub fn record(&self, event: AuditEvent<'_>, at_unix_ms: i64) -> Result<Recorded, AuditError> {
let _mutation = self.framing_log().map(|log| log.mutation());
let mut tip = self
.tip
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let log = self.framing_log();
if log.as_ref().is_some_and(|l| l.is_follower()) {
tip.queued.clear();
tip.unfiled.clear();
tip.stale = true;
return Ok(Recorded::NotOriginated);
}
if tip.stale {
let entries = read_entries(&self.path)?;
match entries.last() {
Some(last) => {
tip.next_seq = last.seq + 1;
tip.hash.clone_from(&last.hash);
}
None => {
tip.next_seq = 0;
tip.hash = GENESIS.to_owned();
}
}
tip.stale = false;
}
let released = log.as_ref().is_some_and(|l| l.is_released());
if !released {
self.flush_locked(&mut tip)?;
}
let mut entry = AuditEntry {
seq: tip.next_seq,
at_unix_ms,
method: event.method.to_owned(),
principal: event.principal.to_owned(),
fingerprint: event.fingerprint.to_owned(),
scope: event.scope.to_owned(),
level: event.level.to_owned(),
auth_mode: event.auth_mode.to_owned(),
decision: event.decision.to_owned(),
reason: event.reason.to_owned(),
prev: tip.hash.clone(),
hash: String::new(),
};
entry.hash = entry.compute_hash();
let line = serde_json::to_string(&entry).expect("an audit entry serialises");
debug_assert!(!line.contains('\n'), "a JSONL record holds no newline");
if released || !tip.queued.is_empty() {
return Ok(Self::hold(&mut tip, entry, line));
}
if self.framed {
match crate::txlog::sink::stage_line(&self.path, &line) {
Ok(Some(staged)) => {
tip.next_seq = entry.seq + 1;
tip.hash.clone_from(&entry.hash);
tip.unfiled.push((staged.seq(), line));
drop(tip);
staged.durable().map_err(|e| AuditError::Frame {
path: self.path.display().to_string(),
detail: e.to_string(),
})?;
let mut tip = self
.tip
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
self.file_durable(&mut tip, staged.log())?;
drop(tip);
drop(staged);
return Ok(Recorded::Written(entry));
}
Ok(None) => {} Err(crate::txlog::Error::Released(_)) => {
return Ok(Self::hold(&mut tip, entry, line))
}
Err(crate::txlog::Error::Follower) => {
tip.stale = true;
return Ok(Recorded::NotOriginated);
}
Err(source) => {
return Err(AuditError::Frame {
path: self.path.display().to_string(),
detail: source.to_string(),
});
}
}
}
match self.write_line(&line)? {
Wrote::Yes => {}
Wrote::Released => return Ok(Self::hold(&mut tip, entry, line)),
Wrote::Follower => {
tip.stale = true;
return Ok(Recorded::NotOriginated);
}
}
tip.next_seq = entry.seq + 1;
tip.hash.clone_from(&entry.hash);
Ok(Recorded::Written(entry))
}
fn hold(tip: &mut Tip, entry: AuditEntry, line: String) -> Recorded {
tip.queued.push(line);
tip.next_seq = entry.seq + 1;
tip.hash.clone_from(&entry.hash);
Recorded::Queued {
entry,
pending: tip.queued.len(),
}
}
pub fn queued(&self) -> usize {
self.tip
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.queued
.len()
}
pub fn flush(&self) -> Result<usize, AuditError> {
let mut tip = self
.tip
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if self
.framing_log()
.is_some_and(|l| l.is_released() || l.is_follower())
{
return Ok(0);
}
self.flush_locked(&mut tip)
}
fn file_durable(&self, tip: &mut Tip, log: &crate::txlog::Log) -> Result<usize, AuditError> {
let head = log.head_seq();
let n = tip
.unfiled
.iter()
.take_while(|(seq, _)| *seq <= head)
.count();
if n == 0 {
return Ok(0);
}
let mut bytes = Vec::with_capacity(tip.unfiled[..n].iter().map(|(_, l)| l.len() + 1).sum());
for (_, l) in &tip.unfiled[..n] {
bytes.extend_from_slice(l.as_bytes());
bytes.push(b'\n');
}
let io = |source: std::io::Error| AuditError::Io {
path: self.path.display().to_string(),
source,
};
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.map_err(io)?;
file.write_all(&bytes).map_err(io)?;
tip.unfiled.drain(..n);
Ok(n)
}
fn flush_locked(&self, tip: &mut Tip) -> Result<usize, AuditError> {
if !tip.unfiled.is_empty() {
if let Some(log) = self.framing_log() {
if !tip.queued.is_empty() {
let last = tip.unfiled.last().map(|(s, _)| *s).unwrap_or(0);
log.sync_through(last).map_err(|e| AuditError::Frame {
path: self.path.display().to_string(),
detail: e.to_string(),
})?;
}
self.file_durable(tip, &log)?;
}
if !tip.unfiled.is_empty() && !tip.queued.is_empty() {
return Ok(0);
}
}
let mut n = 0;
while let Some(line) = tip.queued.first().cloned() {
match self.write_line(&line)? {
Wrote::Yes => {
tip.queued.remove(0);
n += 1;
}
Wrote::Released | Wrote::Follower => break,
}
}
Ok(n)
}
fn write_line(&self, line: &str) -> Result<Wrote, AuditError> {
let _framed = if self.framed {
match crate::txlog::sink::append_line(&self.path, line) {
Ok(f) => Some(f),
Err(crate::txlog::Error::Released(_)) => return Ok(Wrote::Released),
Err(crate::txlog::Error::Follower) => return Ok(Wrote::Follower),
Err(source) => {
return Err(AuditError::Frame {
path: self.path.display().to_string(),
detail: source.to_string(),
})
}
}
} else {
None
};
if _framed.is_none() && self.path.parent().is_some_and(|p| !p.exists()) {
return Ok(Wrote::Released);
}
let io = |source: std::io::Error| AuditError::Io {
path: self.path.display().to_string(),
source,
};
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.map_err(io)?;
file.write_all(format!("{line}\n").as_bytes()).map_err(io)?;
file.sync_all().map_err(io)?;
Ok(Wrote::Yes)
}
pub fn entries(&self) -> Result<Vec<AuditEntry>, AuditError> {
read_entries(&self.path)
}
pub fn verify(&self) -> Result<usize, AuditError> {
let entries = read_entries(&self.path)?;
verify_entries(&entries)?;
Ok(entries.len())
}
pub fn tip(&self) -> String {
self.tip
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.hash
.clone()
}
}
fn read_entries(path: &Path) -> Result<Vec<AuditEntry>, AuditError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => {
return Err(AuditError::Io {
path: path.display().to_string(),
source,
})
}
};
let mut out = Vec::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let entry = serde_json::from_str(line).map_err(|source| AuditError::Malformed {
line: index + 1,
source,
})?;
out.push(entry);
}
Ok(out)
}
pub fn verify_entries(entries: &[AuditEntry]) -> Result<(), AuditError> {
let mut expected_prev = GENESIS.to_owned();
for (index, entry) in entries.iter().enumerate() {
let line = index + 1;
let broken = |what: String| AuditError::Broken {
seq: entry.seq,
line,
what,
};
if entry.seq != index as u64 {
return Err(broken(format!(
"it is the {}th entry in the file and claims seq {}; an entry has been removed \
or reordered",
index, entry.seq
)));
}
if entry.prev != expected_prev {
return Err(broken(format!(
"it names {} as the entry before it and the entry before it hashes to {}; \
the chain has been cut",
short(&entry.prev),
short(&expected_prev)
)));
}
let computed = entry.compute_hash();
if entry.hash != computed {
return Err(broken(format!(
"its contents hash to {} and it carries {}; this entry has been edited",
short(&computed),
short(&entry.hash)
)));
}
expected_prev = entry.hash.clone();
}
Ok(())
}
fn short(hash: &str) -> &str {
hash.get(..12).unwrap_or(hash)
}
#[cfg(test)]
mod tests {
use super::*;
fn event<'a>(method: &'a str, decision: &'a str) -> AuditEvent<'a> {
AuditEvent {
method,
principal: "alice",
fingerprint: "SHA256:abc",
scope: "repo:team/app",
level: "admin",
auth_mode: "required",
decision,
reason: "",
}
}
fn chain_of(path: &Path, count: usize) -> AuditChain {
let chain = AuditChain::open(path).expect("open");
for index in 0..count {
chain
.append(
event("Access.Grant", "allow"),
1_700_000_000_000 + index as i64,
)
.expect("append");
}
chain
}
#[test]
fn a_chain_links_each_entry_to_the_one_before_it_and_verifies() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(AUDIT_FILE);
let chain = chain_of(&path, 3);
assert_eq!(chain.verify().expect("a fresh chain verifies"), 3);
let entries = chain.entries().expect("read");
assert_eq!(entries[0].prev, GENESIS, "the first entry starts the chain");
assert_eq!(entries[1].prev, entries[0].hash);
assert_eq!(entries[2].prev, entries[1].hash);
assert_eq!(chain.tip(), entries[2].hash);
assert_eq!((entries[0].seq, entries[2].seq), (0, 2));
let reopened = AuditChain::open(&path).expect("reopen");
assert_eq!(reopened.tip(), entries[2].hash);
reopened
.append(event("Repos.Delete", "deny"), 1_700_000_009_000)
.expect("append after reopen");
assert_eq!(reopened.verify().expect("still holds"), 4);
let entries = reopened.entries().expect("read");
assert_eq!(entries[3].prev, entries[2].hash);
}
#[test]
fn an_edited_entry_is_named() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(AUDIT_FILE);
let _chain = chain_of(&path, 3);
let text = std::fs::read_to_string(&path).expect("read");
let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
lines[1] = lines[1].replace("\"decision\":\"allow\"", "\"decision\":\"deny\"");
std::fs::write(&path, lines.join("\n") + "\n").expect("rewrite");
let err = AuditChain::open(&path).expect_err("a rewritten chain must not open");
match err {
AuditError::Broken {
seq,
line,
ref what,
} => {
assert_eq!((seq, line), (1, 2), "the break must be named: {what}");
assert!(what.contains("edited"), "{what}");
}
other => panic!("the edit was not detected: {other}"),
}
}
#[test]
fn a_removed_entry_is_named() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(AUDIT_FILE);
let chain = chain_of(&path, 4);
assert_eq!(chain.verify().expect("green first"), 4);
let text = std::fs::read_to_string(&path).expect("read");
let lines: Vec<&str> = text.lines().collect();
let cut: Vec<&str> = lines
.iter()
.enumerate()
.filter(|(index, _)| *index != 1)
.map(|(_, line)| *line)
.collect();
std::fs::write(&path, cut.join("\n") + "\n").expect("rewrite");
let err = AuditChain::open(&path).expect_err("a cut chain must not open");
assert!(
matches!(
err,
AuditError::Broken {
seq: 2,
line: 2,
..
}
),
"the removal was not detected where it happened: {err}"
);
}
#[test]
fn a_truncated_tail_verifies_but_does_not_match_the_recorded_tip() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(AUDIT_FILE);
let chain = chain_of(&path, 4);
let recorded = chain.tip();
let text = std::fs::read_to_string(&path).expect("read");
let kept: Vec<&str> = text.lines().take(2).collect();
std::fs::write(&path, kept.join("\n") + "\n").expect("rewrite");
let shortened = AuditChain::open(&path).expect("a truncated chain is self-consistent");
assert_eq!(shortened.verify().expect("and verifies"), 2);
assert_ne!(
shortened.tip(),
recorded,
"a truncated chain reproduced the tip of the full one"
);
}
#[test]
fn a_field_holding_a_separator_cannot_impersonate_two_fields() {
let base = AuditEntry {
seq: 0,
at_unix_ms: 1,
method: "Access.Grant".into(),
principal: "alice".into(),
fingerprint: "SHA256:abc".into(),
scope: "server".into(),
level: "admin".into(),
auth_mode: "required".into(),
decision: "deny".into(),
reason: String::new(),
prev: GENESIS.into(),
hash: String::new(),
};
let mut shifted = base.clone();
shifted.principal = "alice\nSHA256:abc".into();
shifted.fingerprint = String::new();
assert_ne!(
base.compute_hash(),
shifted.compute_hash(),
"two entries with different field boundaries hash the same"
);
}
fn framed_root() -> (
tempfile::TempDir,
std::sync::Arc<crate::txlog::Log>,
PathBuf,
) {
let dir = tempfile::tempdir().expect("tempdir");
let log =
std::sync::Arc::new(crate::txlog::Log::open(dir.path().join("txlog")).expect("a log"));
crate::txlog::sink::register(dir.path(), &log);
let path = dir.path().join(crate::txlog::AUDIT_REL);
(dir, log, path)
}
fn a_decision(method: &str) -> AuditEvent<'_> {
AuditEvent {
method,
principal: "alice",
fingerprint: "SHA256:abc",
scope: "server",
level: "admin",
auth_mode: "required",
decision: "allow",
reason: "",
}
}
#[test]
fn a_framed_chain_puts_every_entry_in_the_transaction_log() {
let (_dir, log, path) = framed_root();
let chain = AuditChain::open_framed(&path).expect("the trail is framable where it lives");
assert!(chain.is_framed());
for m in ["Repos.Create", "Access.Grant", "Policy.SetProtection"] {
chain.append(a_decision(m), 1).expect("append");
}
assert_eq!(chain.verify().expect("verify"), 3);
assert_eq!(log.head_seq(), 3, "one frame per decision");
let mut rebuilt = String::new();
for seq in 1..=3 {
let e = log.read(seq).expect("a frame");
assert_eq!(e.kind(), Some(crate::txlog::Kind::Append));
match e.meta().expect("meta") {
crate::txlog::Meta::Append { log: rel } => assert_eq!(rel, crate::txlog::AUDIT_REL),
other => panic!("seq {seq} is {other:?}, not an Append of the audit trail"),
}
rebuilt.push_str(std::str::from_utf8(&e.blob).expect("utf8"));
rebuilt.push('\n');
}
let elsewhere = tempfile::tempdir().expect("tempdir");
let restored_path = elsewhere.path().join("audit-chain.jsonl");
std::fs::write(&restored_path, rebuilt).expect("write");
let restored = AuditChain::open(&restored_path).expect("a restored trail opens");
assert_eq!(restored.verify().expect("verify"), 3);
assert_eq!(restored.tip(), chain.tip());
let next = restored
.append(a_decision("Repos.List"), 2)
.expect("append");
assert_eq!(next.prev, chain.tip());
assert_eq!(next.seq, 3);
}
#[test]
fn an_unreplicable_chain_refuses_to_be_the_replicated_trail() {
let outside = tempfile::tempdir().expect("tempdir");
let err = AuditChain::open_framed(outside.path().join("audit-chain.jsonl"))
.expect_err("a chain under no data root is not the replicated trail");
assert!(matches!(err, AuditError::Unframed { .. }), "{err}");
assert!(format!("{err}").contains("under no data root"), "{err}");
let (dir, _log, _path) = framed_root();
let misplaced = dir.path().join("somewhere-else.jsonl");
let err = AuditChain::open_framed(&misplaced).expect_err("a misplaced chain is refused");
assert!(matches!(err, AuditError::Unframed { .. }), "{err}");
assert!(format!("{err}").contains(crate::txlog::AUDIT_REL), "{err}");
}
#[test]
fn a_twins_own_trail_is_never_a_transaction() {
let (dir, log, _path) = framed_root();
let local = dir.path().join(crate::txlog::AUDIT_LOCAL_REL);
let chain = AuditChain::open(&local).expect("the twin's own trail opens");
assert!(!chain.is_framed());
chain
.append(a_decision("Mirror.LogHead"), 1)
.expect("append");
assert_eq!(
log.head_seq(),
0,
"a twin originates nothing into the primary's log"
);
assert_eq!(chain.verify().expect("verify"), 1);
let _ = log;
}
#[test]
fn a_framed_chain_on_a_box_that_follows_originates_nothing_and_is_no_error() {
let (_dir, log, path) = framed_root();
let chain = AuditChain::open_framed(&path).expect("framed");
chain
.append(a_decision("Repos.Create"), 1)
.expect("the primary's own decision");
log.set_follower(true);
match chain
.record(a_decision("Admin.GrowStatus"), 2)
.expect("a follower's decision is not an error")
{
Recorded::NotOriginated => {}
other => panic!("a follower originates nothing: {other:?}"),
}
assert!(matches!(
chain.append(a_decision("Admin.GrowStatus"), 3),
Err(AuditError::NotOriginated { .. })
));
assert_eq!(
log.head_seq(),
1,
"nothing framed into the primary's numbers"
);
assert_eq!(
chain.verify().expect("verify"),
1,
"nothing written into the primary's chain"
);
let other = AuditChain::open(&path).expect("the applier's view");
let applied = other
.append(a_decision("Access.Grant"), 4)
.expect("applied");
log.set_follower(false);
let next = chain
.append(a_decision("Repos.List"), 5)
.expect("originates again");
assert_eq!(
(next.seq, next.prev.clone()),
(applied.seq + 1, applied.hash.clone())
);
assert_eq!(chain.verify().expect("verify"), 3);
}
#[test]
fn a_released_log_queues_the_trail_and_the_first_append_after_the_remount_writes_it_in_order() {
let (_dir, log, path) = framed_root();
let chain = AuditChain::open_framed(&path).expect("framed");
chain.append(a_decision("Repos.Create"), 1).expect("append");
log.release().expect("the grow releases the log");
for (i, m) in ["Admin.Capacity", "Admin.GrowStatus"]
.into_iter()
.enumerate()
{
match chain
.record(a_decision(m), 2 + i as i64)
.expect("a decision during the grow is not an error")
{
Recorded::Queued { pending, .. } => assert_eq!(pending, i + 1),
other => panic!("released: queued, not {other:?}"),
}
}
assert_eq!(chain.queued(), 2);
assert_eq!(
log.head_seq(),
1,
"nothing framed while the files are given up"
);
assert_eq!(
chain.verify().expect("verify"),
1,
"nothing written under the unmounted set"
);
log.reacquire().expect("the remount");
match chain
.record(a_decision("Admin.GrowStatus"), 9)
.expect("after the remount")
{
Recorded::Written(e) => assert_eq!(e.seq, 3),
other => panic!("written after the remount: {other:?}"),
}
assert_eq!(chain.queued(), 0);
assert_eq!(chain.verify().expect("the chain holds across the grow"), 4);
assert_eq!(log.head_seq(), 4, "every decision framed, in order");
let methods: Vec<String> = chain
.entries()
.unwrap()
.into_iter()
.map(|e| e.method)
.collect();
assert_eq!(
methods,
[
"Repos.Create",
"Admin.Capacity",
"Admin.GrowStatus",
"Admin.GrowStatus"
]
);
log.release().unwrap();
chain.record(a_decision("Admin.Capacity"), 10).unwrap();
log.reacquire().unwrap();
assert_eq!(chain.flush().expect("flush"), 1);
assert_eq!(
(chain.queued(), chain.verify().unwrap(), log.head_seq()),
(0, 5, 5)
);
}
#[test]
fn an_unframed_chain_whose_set_is_unmounted_queues_and_writes_after_the_remount() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
let local = data.join(crate::txlog::AUDIT_LOCAL_REL);
let chain = AuditChain::open(&local).expect("the twin's own trail opens");
chain
.append(a_decision("Mirror.LogHead"), 1)
.expect("before the grow");
let off = dir.path().join("data-off");
std::fs::rename(&data, &off).unwrap();
match chain
.record(a_decision("Admin.GrowStatus"), 2)
.expect("a decision while the set is off is not an error")
{
Recorded::Queued { pending, .. } => assert_eq!(pending, 1),
other => panic!("the set is off: queued, not {other:?}"),
}
assert!(
!data.exists(),
"nothing is created where the set is not mounted"
);
std::fs::rename(&off, &data).unwrap();
match chain
.record(a_decision("Admin.GrowStatus"), 3)
.expect("after the remount")
{
Recorded::Written(e) => assert_eq!(e.seq, 2),
other => panic!("written after the remount: {other:?}"),
}
assert_eq!(
(
chain.queued(),
chain.verify().expect("the chain holds across the grow")
),
(0, 3)
);
}
#[test]
fn concurrent_framed_decisions_with_freezes_land_once_in_order_and_never_deadlock() {
let (_dir, log, path) = framed_root();
let chain = std::sync::Arc::new(AuditChain::open_framed(&path).expect("framed"));
let (tx, rx) = std::sync::mpsc::channel();
{
let (chain, log) = (std::sync::Arc::clone(&chain), std::sync::Arc::clone(&log));
std::thread::spawn(move || {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let freezer = {
let (log, stop) = (std::sync::Arc::clone(&log), std::sync::Arc::clone(&stop));
std::thread::spawn(move || {
let mut n = 0u64;
while !stop.load(std::sync::atomic::Ordering::SeqCst) {
let g = log.freeze();
std::thread::sleep(std::time::Duration::from_micros(200));
drop(g);
n += 1;
}
n
})
};
std::thread::scope(|s| {
for t in 0..8 {
let chain = std::sync::Arc::clone(&chain);
s.spawn(move || {
for i in 0..40 {
chain
.append(
a_decision(if (t + i) % 2 == 0 {
"Repos.Create"
} else {
"Access.Grant"
}),
i,
)
.expect("a decision lands");
}
});
}
});
stop.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = tx.send(freezer.join().unwrap());
});
}
let freezes = rx
.recv_timeout(std::time::Duration::from_secs(120))
.expect("320 decisions under freezes end — no deadlock");
assert!(freezes > 0, "the freezer cut in");
assert_eq!(chain.verify().expect("the chain holds"), 320);
let file: Vec<String> = std::fs::read_to_string(&path)
.unwrap()
.lines()
.map(str::to_owned)
.collect();
let framed: Vec<String> = (1..=log.head_seq())
.map(|s| String::from_utf8(log.read(s).unwrap().blob).unwrap())
.collect();
assert_eq!(
file, framed,
"the file is exactly the log's frames, in the log's order"
);
}
}