use std::io::Write;
use std::path::{Path, PathBuf};
use gunnar_proto::PushAttestation;
pub const ATTESTATION_LOG: &str = "attestations.jsonl";
#[derive(Debug, Clone)]
pub struct AttestationLog {
path: PathBuf,
}
impl AttestationLog {
pub fn under(root: impl AsRef<Path>) -> Self {
AttestationLog::at(root.as_ref().join(ATTESTATION_LOG))
}
pub fn at(path: impl Into<PathBuf>) -> Self {
AttestationLog { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn append(&self, attestation: &PushAttestation) -> anyhow::Result<()> {
let line = gunnar_grpc::attestation_json_line(attestation)?;
self.append_line(&line).map(|_| ())
}
pub fn append_line(&self, line: &str) -> anyhow::Result<u64> {
anyhow::ensure!(
!line.contains('\n'),
"an attestation record must be one line; this one holds a newline"
);
let seq = self.lines()?.len() as u64;
let _framed = gunnar_core::txlog::sink::append_line(&self.path, line)?;
let mut record = line.to_owned();
record.push('\n');
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
file.write_all(record.as_bytes())?;
file.sync_data()?;
Ok(seq)
}
fn lines(&self) -> anyhow::Result<Vec<String>> {
let text = match std::fs::read_to_string(&self.path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err.into()),
};
Ok(text
.lines()
.filter(|line| !line.trim().is_empty())
.map(str::to_owned)
.collect())
}
pub fn read(&self) -> anyhow::Result<Vec<PushAttestation>> {
Ok(self
.read_indexed()?
.into_iter()
.map(|(_, record)| record)
.collect())
}
pub fn read_indexed(&self) -> anyhow::Result<Vec<(usize, PushAttestation)>> {
let mut out = Vec::new();
for (index, line) in self.lines()?.into_iter().enumerate() {
if is_non_push_line(&line) {
continue;
}
if is_crash_tail(&line) {
tracing::warn!(
target: gunnar_core::observe::TARGET_TWIN,
path = %self.path.display(),
index,
bytes = line.len(),
"a line of NUL bytes at the end of this log is a power cut's tear, not a record; \
the records before it read as they did, and this box's copy of that one is torn \
(the txlog and the twin hold it)"
);
continue;
}
let record: PushAttestation = serde_json::from_str(&line)
.map_err(|err| anyhow::anyhow!("attestation line {index} does not parse: {err}"))?;
anyhow::ensure!(
!record.attestation_id.is_empty(),
"attestation line {index} parses as a push with no attestation id — it is not a push. \
A record kind this build does not know must be named in `is_entitlement_line`'s \
predicate, never defaulted into a push that never happened"
);
out.push((index, record));
}
Ok(out)
}
}
fn is_crash_tail(line: &str) -> bool {
!line.is_empty() && line.bytes().all(|b| b == 0)
}
pub const ENTITLEMENT_KEY: &str = "entitlement";
pub const RECLAIM_KEY: &str = "cache_reclaim";
fn is_non_push_line(line: &str) -> bool {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|value| {
value
.as_object()
.map(|map| map.contains_key(ENTITLEMENT_KEY) || map.contains_key(RECLAIM_KEY))
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use gunnar_proto::{RefUpdate, RepoPath};
fn sample(id: &str) -> PushAttestation {
PushAttestation {
attestation_id: id.to_owned(),
repo: Some(RepoPath::new("team", "project")),
principal_id: "tester".into(),
principal_name: "tester".into(),
key_fingerprint: "SHA256:zzz".into(),
credential: "key:SHA256:zzz".into(),
hash_kind: gunnar_proto::HashKind::Sha256 as i32,
ref_updates: vec![RefUpdate {
ref_name: "refs/heads/main".into(),
old_oid: "0".repeat(64),
new_oid: "a".repeat(64),
}],
new_object_oids: vec!["b".repeat(64)],
new_object_oids_unknown: String::new(),
new_object_count: 1,
new_object_bytes: 512,
pack_hash: "c".repeat(64),
received_at_unix_ms: 1,
server_agent: gunnar_proto::AGENT.into(),
transport: "ssh".into(),
}
}
#[test]
fn a_line_from_before_a_field_existed_still_reads() {
let dir = tempfile::tempdir().expect("temp dir");
let log = AttestationLog::under(dir.path());
let mut value = serde_json::to_value(sample("old")).expect("a sample serialises");
value
.as_object_mut()
.expect("an object")
.remove("new_object_oids_unknown")
.expect("the sample carried the field the old server lacked");
std::fs::write(log.path(), format!("{value}\n")).expect("write a 1.3.0 line");
let back = log
.read()
.expect("a line from before the field existed must still parse");
assert_eq!(back.len(), 1);
assert_eq!(back[0].attestation_id, "old");
assert_eq!(back[0].new_object_oids_unknown, "", "absent reads as the default");
}
#[test]
fn two_appends_are_two_lines_and_read_back_in_order() {
let dir = tempfile::tempdir().expect("temp dir");
let log = AttestationLog::under(dir.path());
assert!(
log.read().expect("an absent log reads as empty").is_empty(),
"a log that has never been written must read as empty, not as an error"
);
log.append(&sample("first")).expect("append the first");
log.append(&sample("second")).expect("append the second");
let raw = std::fs::read_to_string(log.path()).expect("read the raw file");
assert_eq!(
raw.lines().count(),
2,
"an append-only log must have grown by one line: {raw}"
);
assert!(raw.ends_with('\n'), "every record is newline-terminated");
let back = log.read().expect("read the log");
assert_eq!(
back.iter()
.map(|a| a.attestation_id.as_str())
.collect::<Vec<_>>(),
["first", "second"],
"the log must preserve arrival order"
);
assert_eq!(back[0].ref_updates[0].ref_name, "refs/heads/main");
assert_eq!(back[0].new_object_count, 1);
}
#[test]
fn a_record_that_is_not_a_push_is_skipped_and_its_index_still_belongs_to_it() {
let dir = tempfile::tempdir().expect("temp dir");
let log = AttestationLog::under(dir.path());
log.append(&sample("before")).expect("a push");
let entitlement = serde_json::json!({ ENTITLEMENT_KEY: { "tenant": "acme" } });
assert_eq!(log.append_line(&entitlement.to_string()).expect("a fact"), 1);
let reclaim =
serde_json::json!({ RECLAIM_KEY: { "namespace": "acme", "bytes_freed": 4096 } });
assert_eq!(log.append_line(&reclaim.to_string()).expect("a reclaim"), 2);
log.append(&sample("after")).expect("another push");
let indexed = log.read_indexed().expect("the log still reads as pushes");
assert_eq!(
indexed
.iter()
.map(|(i, a)| (*i, a.attestation_id.as_str()))
.collect::<Vec<_>>(),
[(0, "before"), (3, "after")],
"the two non-push lines are skipped, and the pushes keep the LINE index a \
proof is made against — never their position in this vector"
);
}
#[test]
fn a_nul_tail_from_a_power_cut_does_not_kill_every_read_of_the_log() {
use std::io::Write as _;
let dir = tempfile::tempdir().expect("temp dir");
let log = AttestationLog::under(dir.path());
log.append(&sample("before")).expect("a push");
log.append(&sample("after")).expect("another push");
let mut f = std::fs::OpenOptions::new().append(true).open(log.path()).expect("open");
f.write_all(&[0u8; 96]).expect("the torn tail");
f.write_all(b"\n").expect("and the newline xfs had already journalled");
drop(f);
let back = log.read().expect("the records written before the cut still read");
assert_eq!(back.iter().map(|a| a.attestation_id.as_str()).collect::<Vec<_>>(), ["before", "after"]);
let indexed = log.read_indexed().expect("and so do their line indices");
assert_eq!(indexed.iter().map(|(i, a)| (*i, a.attestation_id.as_str())).collect::<Vec<_>>(), [(0, "before"), (1, "after")]);
}
#[test]
fn a_json_object_that_is_not_a_push_is_refused_and_never_defaulted_into_one() {
let dir = tempfile::tempdir().expect("temp dir");
let log = AttestationLog::under(dir.path());
log.append(&sample("before")).expect("a push");
log.append_line("{\"some_later_record_kind\": {\"nobody\": \"named it here\"}}").expect("a line");
let err = log.read_indexed().expect_err("a record kind this build does not know");
let say = format!("{err:#}");
assert!(say.contains("no attestation id") && say.contains("never happened"), "{say}");
}
}
pub const TRANSPARENCY_HEADS: &str = "transparency-heads.jsonl";
pub const BUILD_LOG: &str = "builds.jsonl";
pub const BUILD_HEADS: &str = "build-heads.jsonl";
pub const FORGE_LOG: &str = "forge.jsonl";
pub const FORGE_HEADS: &str = "forge-heads.jsonl";
pub const MEMBERSHIP_LOG: &str = "membership.jsonl";
pub const MEMBERSHIP_HEADS: &str = "membership-heads.jsonl";
#[derive(Debug, Clone)]
pub struct TransparencyLog {
records: PathBuf,
heads: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Inclusion {
pub index: usize,
pub size: usize,
pub leaf: String,
pub root: String,
pub proof: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuditVerdict {
Consistent { records: usize, heads: usize },
HeadNotSigned { size: u64, why: String },
RecordsRewritten {
size: u64,
signed_root: String,
actual_root: String,
},
RecordsTruncated { size: u64, records: usize },
}
impl AuditVerdict {
pub fn is_consistent(&self) -> bool {
matches!(self, AuditVerdict::Consistent { .. })
}
}
impl std::fmt::Display for AuditVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuditVerdict::Consistent { records, heads } => write!(
f,
"consistent: {records} record(s) under {heads} signed head(s)"
),
AuditVerdict::HeadNotSigned { size, why } => {
write!(f, "the head at size {size} does not verify: {why}")
}
AuditVerdict::RecordsRewritten {
size,
signed_root,
actual_root,
} => write!(
f,
"THE LOG HAS BEEN REWRITTEN: the head at size {size} was signed over root \
{signed_root} and the records now hash to {actual_root}"
),
AuditVerdict::RecordsTruncated { size, records } => write!(
f,
"THE LOG HAS BEEN TRUNCATED: a head covers {size} record(s) and the log holds \
{records}"
),
}
}
}
impl TransparencyLog {
pub fn under(root: impl AsRef<Path>) -> Self {
let root = root.as_ref();
TransparencyLog::over(root.join(ATTESTATION_LOG), root.join(TRANSPARENCY_HEADS))
}
pub fn over(records: impl Into<PathBuf>, heads: impl Into<PathBuf>) -> Self {
TransparencyLog {
records: records.into(),
heads: heads.into(),
}
}
pub fn records(&self) -> AttestationLog {
AttestationLog::at(self.records.clone())
}
pub fn records_path(&self) -> &Path {
&self.records
}
pub fn heads_path(&self) -> &Path {
&self.heads
}
fn lines(&self) -> anyhow::Result<Vec<String>> {
let text = match std::fs::read_to_string(&self.records) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err.into()),
};
Ok(text
.lines()
.filter(|line| !line.trim().is_empty())
.map(str::to_owned)
.collect())
}
pub fn lines_of(&self) -> anyhow::Result<Vec<String>> {
self.lines()
}
fn leaves(&self) -> anyhow::Result<Vec<gunnar_core::transparency::Hash>> {
Ok(self
.lines()?
.iter()
.map(|line| gunnar_core::transparency::leaf_hash(line.as_bytes()))
.collect())
}
pub fn len(&self) -> anyhow::Result<usize> {
Ok(self.lines()?.len())
}
pub fn is_empty(&self) -> anyhow::Result<bool> {
Ok(self.len()? == 0)
}
pub fn root(&self) -> anyhow::Result<String> {
Ok(gunnar_core::transparency::hex(
&gunnar_core::transparency::root(&self.leaves()?),
))
}
pub fn issue_head(
&self,
key: &gunnar_core::ssh_key::PrivateKey,
at_unix_ms: i64,
) -> anyhow::Result<gunnar_core::SignedTreeHead> {
let leaves = self.leaves()?;
let head = gunnar_core::TreeHead {
size: leaves.len() as u64,
root: gunnar_core::transparency::hex(&gunnar_core::transparency::root(&leaves)),
at_unix_ms,
};
let signed = gunnar_core::transparency::sign_tree_head(key, head)?;
let mut line = serde_json::to_string(&signed)?;
line.push('\n');
let _framed = gunnar_core::txlog::sink::append_line(&self.heads, line.trim_end_matches('\n'))?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.heads)?;
file.write_all(line.as_bytes())?;
Ok(signed)
}
pub fn heads(&self) -> anyhow::Result<Vec<gunnar_core::SignedTreeHead>> {
let text = match std::fs::read_to_string(&self.heads) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err.into()),
};
text.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).map_err(anyhow::Error::from))
.collect()
}
pub fn prove(&self, index: usize) -> anyhow::Result<Option<Inclusion>> {
let leaves = self.leaves()?;
let Some(proof) = gunnar_core::transparency::inclusion_proof(&leaves, index) else {
return Ok(None);
};
Ok(Some(Inclusion {
index,
size: leaves.len(),
leaf: gunnar_core::transparency::hex(&leaves[index]),
root: gunnar_core::transparency::hex(&gunnar_core::transparency::root(&leaves)),
proof: proof.iter().map(gunnar_core::transparency::hex).collect(),
}))
}
pub fn prove_pack(&self, pack_hash: &str) -> anyhow::Result<Option<Inclusion>> {
let records = self.records().read_indexed()?;
let Some((index, _)) = records.iter().find(|(_, r)| r.pack_hash == pack_hash) else {
return Ok(None);
};
self.prove(*index)
}
pub fn audit(&self, key: &gunnar_core::ssh_key::PublicKey) -> anyhow::Result<AuditVerdict> {
let leaves = self.leaves()?;
let heads = self.heads()?;
for head in &heads {
if let Err(problem) = gunnar_core::transparency::verify_tree_head(head, key) {
return Ok(AuditVerdict::HeadNotSigned {
size: head.head.size,
why: problem.to_string(),
});
}
let size = head.head.size as usize;
if size > leaves.len() {
return Ok(AuditVerdict::RecordsTruncated {
size: head.head.size,
records: leaves.len(),
});
}
let actual =
gunnar_core::transparency::hex(&gunnar_core::transparency::root(&leaves[..size]));
if actual != head.head.root {
return Ok(AuditVerdict::RecordsRewritten {
size: head.head.size,
signed_root: head.head.root.clone(),
actual_root: actual,
});
}
}
Ok(AuditVerdict::Consistent {
records: leaves.len(),
heads: heads.len(),
})
}
}