Skip to main content

gunnar_server/
attest.rs

1//! The attestation log, on the server's own store.
2//!
3//! At receive-pack time gunnar already knows who pushed, with which key, into
4//! which repo, which references moved, which objects are new and what the pack
5//! hashed to. The attestation rule says gunnar does exactly two things with that:
6//!
7//! 1. emits a `PushAttestation` on the event stream, and
8//! 2. appends **one JSON line** to a log in its own store (this module).
9//!
10//! # The dependency edge that is deliberately absent
11//!
12//! **Nothing here links the Build Thing, nornir, skade, arrow or parquet.** The
13//! log is a file of newline-delimited JSON and the event is a proto message;
14//! anything that wants a warehouse row subscribes to `Events.Watch` and writes
15//! the row itself. `tests/feature_split.rs` asserts the server half's resolved
16//! dependency graph, and this module is why that assertion has to keep holding:
17//! the moment a writer is linked in here, the appliance case (the no-OS server rule), where none
18//! of those crates could be linked at all, stops building.
19//!
20//! # Why a failed append does not fail the push
21//!
22//! The push has already happened when this is called: the references moved and
23//! the client has been told so. Returning an error here could only turn a
24//! successful push into a reported failure, which is the worst outcome of all:
25//! gunnar and the client would disagree about what is in the repository. A
26//! failure is therefore logged at `error!` and nothing else, and the operator
27//! sees a gap in the log rather than a phantom rejection.
28
29use std::io::Write;
30use std::path::{Path, PathBuf};
31
32use gunnar_proto::PushAttestation;
33
34/// The attestation log's file name, directly under the data root.
35///
36/// One file for the whole server rather than one per repository: an attestation
37/// is a provenance record of a *push*, and the consumer of the attestation wants the whole
38/// stream in arrival order. The repository is a field in every line.
39pub const ATTESTATION_LOG: &str = "attestations.jsonl";
40
41/// An append-only JSONL file.
42#[derive(Debug, Clone)]
43pub struct AttestationLog {
44    path: PathBuf,
45}
46
47impl AttestationLog {
48    /// The log under a data root.
49    pub fn under(root: impl AsRef<Path>) -> Self {
50        AttestationLog::at(root.as_ref().join(ATTESTATION_LOG))
51    }
52
53    /// The log at an explicit path.
54    pub fn at(path: impl Into<PathBuf>) -> Self {
55        AttestationLog { path: path.into() }
56    }
57
58    pub fn path(&self) -> &Path {
59        &self.path
60    }
61
62    /// Append one attestation as one line.
63    ///
64    /// The whole record, JSON plus its newline, is handed to a single
65    /// `write_all` on a file opened `O_APPEND`, so two concurrent pushes cannot
66    /// interleave halves of two lines. A reader may therefore split on `\n`
67    /// without a parser, which is the only property JSONL actually promises.
68    pub fn append(&self, attestation: &PushAttestation) -> anyhow::Result<()> {
69        let line = gunnar_grpc::attestation_json_line(attestation)?;
70        self.append_line(&line).map(|_| ())
71    }
72
73    /// **Append one record that is not a push**, and return its sequence
74    /// number — the zero-based line index it now sits at, which is also its
75    /// leaf index in the [`TransparencyLog`] over this file.
76    ///
77    /// The metered appliance's entitlement facts go through here
78    /// (`crate::entitlement`): every plan change is then a line under the
79    /// same signed tree heads as every push, so a service provider who edits
80    /// their own plan history is caught by the same audit that catches a
81    /// rewritten push. The line must be one JSON object with a top-level key
82    /// that is not a push's — [`AttestationLog::read`] tells them apart by
83    /// [`ENTITLEMENT_KEY`] and [`RECLAIM_KEY`] — and must not contain a
84    /// newline.
85    ///
86    /// Counting the existing lines is a read of the whole file, and that is
87    /// accepted: this is the rare path (a plan changes a few times a year per
88    /// tenant), and a sequence number that could be wrong would be worse than
89    /// one that costs a read. Two writers racing here can still interleave
90    /// their counts; the caller serialises (`EntitlementSlot` holds a mutex
91    /// across count-and-append).
92    pub fn append_line(&self, line: &str) -> anyhow::Result<u64> {
93        anyhow::ensure!(
94            !line.contains('\n'),
95            "an attestation record must be one line; this one holds a newline"
96        );
97        let seq = self.lines()?.len() as u64;
98        let _framed = gunnar_core::txlog::sink::append_line(&self.path, line)?;
99        let mut record = line.to_owned();
100        record.push('\n');
101        let mut file = std::fs::OpenOptions::new()
102            .create(true)
103            .append(true)
104            .open(&self.path)?;
105        file.write_all(record.as_bytes())?;
106        // **Synced, like every other framed write under the data root.** The twin's side of the
107        // same frame (`twin::append_line_once`) syncs; this did not, and
108        // `tests/appliance_powercut_recovery.rs` is what an unsynced write under this root costs
109        // on xfs — "rename journaled, data blocks not", the file back as zeroes. The frame above
110        // is already durable and the twin already has the line, so without this the PRIMARY is
111        // the only box that loses it, which is the wrong way round for the box that serves.
112        file.sync_data()?;
113        Ok(seq)
114    }
115
116    /// The raw lines, in order, blank ones dropped. The line index is the
117    /// record's sequence number and its leaf index.
118    fn lines(&self) -> anyhow::Result<Vec<String>> {
119        let text = match std::fs::read_to_string(&self.path) {
120            Ok(text) => text,
121            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
122            Err(err) => return Err(err.into()),
123        };
124        Ok(text
125            .lines()
126            .filter(|line| !line.trim().is_empty())
127            .map(str::to_owned)
128            .collect())
129    }
130
131    /// Every push attestation in the log, in the order it was written.
132    ///
133    /// Exposed because a log nothing can read back is a log nobody can check.
134    /// A record that is not a push (an entitlement line or a cache-reclaim
135    /// line, see [`AttestationLog::append_line`]) is skipped, which is why the
136    /// **position in this vector is not a sequence number** — use
137    /// [`AttestationLog::read_indexed`] for anything that proves inclusion.
138    pub fn read(&self) -> anyhow::Result<Vec<PushAttestation>> {
139        Ok(self
140            .read_indexed()?
141            .into_iter()
142            .map(|(_, record)| record)
143            .collect())
144    }
145
146    /// [`AttestationLog::read`], with each push's line index — the number a
147    /// [`TransparencyLog`] proof is made against. The two are one function so
148    /// the index a verifier proves is the line the record was read from.
149    pub fn read_indexed(&self) -> anyhow::Result<Vec<(usize, PushAttestation)>> {
150        let mut out = Vec::new();
151        for (index, line) in self.lines()?.into_iter().enumerate() {
152            if is_non_push_line(&line) {
153                continue;
154            }
155            if is_crash_tail(&line) {
156                // The power cut's artifact, named rather than parsed. Kept as a LINE — the
157                // indices of everything before it are what proofs were made against — and not
158                // as a record. The line itself is not lost: `append_line` framed it before it
159                // wrote it, so the txlog holds it and the twin applied it.
160                tracing::warn!(
161                    target: gunnar_core::observe::TARGET_TWIN,
162                    path = %self.path.display(),
163                    index,
164                    bytes = line.len(),
165                    "a line of NUL bytes at the end of this log is a power cut's tear, not a record; \
166                     the records before it read as they did, and this box's copy of that one is torn \
167                     (the txlog and the twin hold it)"
168                );
169                continue;
170            }
171            let record: PushAttestation = serde_json::from_str(&line)
172                .map_err(|err| anyhow::anyhow!("attestation line {index} does not parse: {err}"))?;
173            // **A JSON object is not a push.** `PushAttestation`'s serde impl tolerates unknown
174            // keys and defaults everything else, so ANY object that is not a kind
175            // [`is_entitlement_line`] names came back from the line above as a push with no id,
176            // no repository, no principal and no pack hash — and `prove_pack`, `Repos.Chain` and
177            // every event listing then carried an invented record that `gunnar verify` would
178            // prove like a real one. An attestation always has an id; a record without one is a
179            // record this build does not know, and that is an error a reader sees.
180            anyhow::ensure!(
181                !record.attestation_id.is_empty(),
182                "attestation line {index} parses as a push with no attestation id — it is not a push. \
183                 A record kind this build does not know must be named in `is_entitlement_line`'s \
184                 predicate, never defaulted into a push that never happened"
185            );
186            out.push((index, record));
187        }
188        Ok(out)
189    }
190}
191
192/// **The artifact of a power cut on an appended log**: a line that is nothing but NUL bytes.
193///
194/// `tests/appliance_powercut_recovery.rs` paid for this shape once already, on
195/// `replication.json`: "rename journaled, data blocks not", and the file came back 0 bytes. An
196/// `O_APPEND` log tears the same way — xfs journals the new size before the blocks reach the
197/// platter, so the tail comes back as zeroes. It is not blank, so `lines()` keeps it; it is not
198/// JSON, so every reader used to die on it, for ever, on a box with no shell to edit it with.
199fn is_crash_tail(line: &str) -> bool {
200    !line.is_empty() && line.bytes().all(|b| b == 0)
201}
202
203/// The top-level key that marks an entitlement record in the push log. A
204/// push attestation has no such key; a line that has it is skipped by
205/// [`AttestationLog::read`] and written by `crate::entitlement`.
206pub const ENTITLEMENT_KEY: &str = "entitlement";
207
208/// The top-level key that marks a **cache-reclaim** record: the bytes of
209/// derived cache a namespace gave back when its plan fell to Suspended or
210/// Retention. Written by `crate::entitlement`, skipped by
211/// [`AttestationLog::read`] for the same reason [`ENTITLEMENT_KEY`] is.
212///
213/// It is on this log and not a log of its own because the reclaim is a
214/// *consequence of a plan change*: the fact that caused it is the line before
215/// it, under the same signed tree heads, so an operator asking "what did
216/// suspending this tenant actually give back" reads two adjacent lines and an
217/// appliance that rewrote either is caught by the same audit.
218pub const RECLAIM_KEY: &str = "cache_reclaim";
219
220/// Is this line one of the records that is **not** a push?
221///
222/// Decided by the shape of the JSON, not by a parse failure: a push line that
223/// does not parse must stay an error a reader sees, never be mistaken for a
224/// different kind of record and skipped.
225///
226/// **Every new record kind has to be named here, and the failure mode of
227/// forgetting is not the loud one it looks like.** The expectation was that an
228/// unlisted key would make [`AttestationLog::read_indexed`] error — it does
229/// hard-error on a line it cannot parse as a `PushAttestation`. That is not
230/// what happens. MEASURED 2026-09-16 by removing `RECLAIM_KEY` from this
231/// predicate and running
232/// `a_record_that_is_not_a_push_is_skipped_and_its_index_still_belongs_to_it`:
233/// `PushAttestation` tolerates unknown fields and defaults the rest, so the
234/// reclaim line came back as a **phantom push** — `(2, "")`, an attestation
235/// with no id, no repository, no principal and no pack hash — sitting between
236/// the real ones. A verifier would have proved it and an event listing would
237/// have shown it. The cost of a missing key here is an invented record, not an
238/// error, which is why the guard asserts the exact line indices rather than
239/// "it parses".
240fn is_non_push_line(line: &str) -> bool {
241    serde_json::from_str::<serde_json::Value>(line)
242        .ok()
243        .and_then(|value| {
244            value
245                .as_object()
246                .map(|map| map.contains_key(ENTITLEMENT_KEY) || map.contains_key(RECLAIM_KEY))
247        })
248        .unwrap_or(false)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use gunnar_proto::{RefUpdate, RepoPath};
255
256    fn sample(id: &str) -> PushAttestation {
257        PushAttestation {
258            attestation_id: id.to_owned(),
259            repo: Some(RepoPath::new("team", "project")),
260            principal_id: "tester".into(),
261            principal_name: "tester".into(),
262            key_fingerprint: "SHA256:zzz".into(),
263            credential: "key:SHA256:zzz".into(),
264            hash_kind: gunnar_proto::HashKind::Sha256 as i32,
265            ref_updates: vec![RefUpdate {
266                ref_name: "refs/heads/main".into(),
267                old_oid: "0".repeat(64),
268                new_oid: "a".repeat(64),
269            }],
270            new_object_oids: vec!["b".repeat(64)],
271            new_object_oids_unknown: String::new(),
272            new_object_count: 1,
273            // Empty: this sample's object set is known, and the count above is
274            // a count rather than an absence.
275            new_object_bytes: 512,
276            pack_hash: "c".repeat(64),
277            received_at_unix_ms: 1,
278            server_agent: gunnar_proto::AGENT.into(),
279            transport: "ssh".into(),
280        }
281    }
282
283    /// ★ **A line written by an older gunnar must still read.** The log is
284    /// append-only and outlives every release; the 1.4.0 server on gunnar.rs
285    /// refused `Repos.Chain` on alice/linux with *"attestation line 0 does
286    /// not parse: missing field `new_object_oids_unknown`"* — a field 1.3.x
287    /// never wrote, because it did not exist. Every field the schema grows
288    /// after a log has lines is absent from those lines, so the reader must
289    /// take an absent field as its default. Here: a 1.3.0-shaped line, with
290    /// no `new_object_oids_unknown`, read back through the real reader.
291    #[test]
292    fn a_line_from_before_a_field_existed_still_reads() {
293        let dir = tempfile::tempdir().expect("temp dir");
294        let log = AttestationLog::under(dir.path());
295        let mut value = serde_json::to_value(sample("old")).expect("a sample serialises");
296        value
297            .as_object_mut()
298            .expect("an object")
299            .remove("new_object_oids_unknown")
300            .expect("the sample carried the field the old server lacked");
301        std::fs::write(log.path(), format!("{value}\n")).expect("write a 1.3.0 line");
302
303        let back = log
304            .read()
305            .expect("a line from before the field existed must still parse");
306        assert_eq!(back.len(), 1);
307        assert_eq!(back[0].attestation_id, "old");
308        assert_eq!(
309            back[0].new_object_oids_unknown, "",
310            "absent reads as the default"
311        );
312    }
313
314    #[test]
315    fn two_appends_are_two_lines_and_read_back_in_order() {
316        let dir = tempfile::tempdir().expect("temp dir");
317        let log = AttestationLog::under(dir.path());
318        assert!(
319            log.read().expect("an absent log reads as empty").is_empty(),
320            "a log that has never been written must read as empty, not as an error"
321        );
322
323        log.append(&sample("first")).expect("append the first");
324        log.append(&sample("second")).expect("append the second");
325
326        // Applied output: the bytes on disk, not a round-trip through the same
327        // struct. A writer that overwrote instead of appending would still make
328        // `read()` return something.
329        let raw = std::fs::read_to_string(log.path()).expect("read the raw file");
330        assert_eq!(
331            raw.lines().count(),
332            2,
333            "an append-only log must have grown by one line: {raw}"
334        );
335        assert!(raw.ends_with('\n'), "every record is newline-terminated");
336
337        let back = log.read().expect("read the log");
338        assert_eq!(
339            back.iter()
340                .map(|a| a.attestation_id.as_str())
341                .collect::<Vec<_>>(),
342            ["first", "second"],
343            "the log must preserve arrival order"
344        );
345        assert_eq!(back[0].ref_updates[0].ref_name, "refs/heads/main");
346        assert_eq!(back[0].new_object_count, 1);
347    }
348
349    /// **Every non-push record kind has to be in [`is_non_push_line`], and
350    /// what it costs to forget is an INVENTED PUSH.**
351    ///
352    /// Seen RED 2026-09-16, with `RECLAIM_KEY` taken back out of the
353    /// predicate: the reclaim line did not error, it came back as
354    /// `(2, "")` — a `PushAttestation` with every field defaulted, because
355    /// prost's serde impl tolerates an unknown key and fills the rest in.
356    /// A record with no id, no repository, no principal and no pack hash then
357    /// sits in the middle of the real ones, and `gunnar verify` proves it like
358    /// any other leaf.
359    ///
360    /// That is why this asserts the exact `(line index, id)` pairs. "It still
361    /// parses" would have been green for the broken predicate.
362    #[test]
363    fn a_record_that_is_not_a_push_is_skipped_and_its_index_still_belongs_to_it() {
364        let dir = tempfile::tempdir().expect("temp dir");
365        let log = AttestationLog::under(dir.path());
366
367        log.append(&sample("before")).expect("a push");
368        let entitlement = serde_json::json!({ ENTITLEMENT_KEY: { "tenant": "acme" } });
369        assert_eq!(
370            log.append_line(&entitlement.to_string()).expect("a fact"),
371            1
372        );
373        let reclaim =
374            serde_json::json!({ RECLAIM_KEY: { "namespace": "acme", "bytes_freed": 4096 } });
375        assert_eq!(log.append_line(&reclaim.to_string()).expect("a reclaim"), 2);
376        log.append(&sample("after")).expect("another push");
377
378        let indexed = log.read_indexed().expect("the log still reads as pushes");
379        assert_eq!(
380            indexed
381                .iter()
382                .map(|(i, a)| (*i, a.attestation_id.as_str()))
383                .collect::<Vec<_>>(),
384            [(0, "before"), (3, "after")],
385            "the two non-push lines are skipped, and the pushes keep the LINE index a \
386             proof is made against — never their position in this vector"
387        );
388    }
389
390    /// **A power cut leaves a NUL tail, and a NUL tail must not kill every read
391    /// of the log for ever.**
392    ///
393    /// The same failure this repository has already paid for once:
394    /// `tests/appliance_powercut_recovery.rs` — "`ReplicationConfig::save` wrote
395    /// `replication.json` and renamed it with no fsync, so the power cut left it
396    /// **0 bytes** on xfs (rename journaled, data blocks not)". [`AttestationLog::append_line`]
397    /// is the same shape: `O_APPEND`, one `write_all`, and no `sync_data` — while
398    /// its own twin-side sibling (`twin::append_line_once`) does sync. xfs journals
399    /// the size before the data blocks reach the platter, so the artifact of a cut
400    /// is a line of NUL bytes at the end of the file.
401    ///
402    /// That line is not blank, so `lines()` keeps it; it is not JSON, so
403    /// [`AttestationLog::read`] and [`AttestationLog::read_indexed`] hard-error on
404    /// it — and every caller of theirs dies with them: `prove_pack` (a verifier
405    /// holding an artifact), `Repos.Chain`, the whole evidence door. For ever,
406    /// on an appliance with no shell to edit the file with.
407    ///
408    /// The record itself is NOT lost: `append_line` frames the line before it
409    /// writes it, so the txlog holds it and the twin applied it. Only this box's
410    /// copy is torn. So the good records before the tear must keep reading, and
411    /// the tear must be named rather than parsed.
412    #[test]
413    fn a_nul_tail_from_a_power_cut_does_not_kill_every_read_of_the_log() {
414        use std::io::Write as _;
415        let dir = tempfile::tempdir().expect("temp dir");
416        let log = AttestationLog::under(dir.path());
417        log.append(&sample("before")).expect("a push");
418        log.append(&sample("after")).expect("another push");
419
420        // What the cut leaves: the size is there, the bytes are not.
421        let mut f = std::fs::OpenOptions::new()
422            .append(true)
423            .open(log.path())
424            .expect("open");
425        f.write_all(&[0u8; 96]).expect("the torn tail");
426        f.write_all(b"\n")
427            .expect("and the newline xfs had already journalled");
428        drop(f);
429
430        let back = log
431            .read()
432            .expect("the records written before the cut still read");
433        assert_eq!(
434            back.iter()
435                .map(|a| a.attestation_id.as_str())
436                .collect::<Vec<_>>(),
437            ["before", "after"]
438        );
439
440        let indexed = log.read_indexed().expect("and so do their line indices");
441        assert_eq!(
442            indexed
443                .iter()
444                .map(|(i, a)| (*i, a.attestation_id.as_str()))
445                .collect::<Vec<_>>(),
446            [(0, "before"), (1, "after")]
447        );
448    }
449
450    /// **A record kind this build does not know must not be DEFAULTED into a push
451    /// that never happened.**
452    ///
453    /// Found while pinning the power cut, and worse than it: `PushAttestation`'s
454    /// serde impl tolerates unknown keys and defaults everything else, so ANY JSON
455    /// object that [`is_non_push_line`] does not name came back out of
456    /// [`AttestationLog::read_indexed`] as a push. MEASURED on master 2026-09-16:
457    /// one line of `{"this": "..."}` produced
458    /// `(1, PushAttestation { attestation_id: "", repo: None, principal_id: "",
459    /// pack_hash: "", .. })` sitting between the real records — an invented push
460    /// that `prove_pack` would prove, `Repos.Chain` would list and `gunnar verify`
461    /// would accept like any other leaf.
462    ///
463    /// The cost of forgetting to name a new record kind is therefore not an error
464    /// a reader sees; it is a phantom. An attestation always has an id, so a record
465    /// without one is refused by name.
466    #[test]
467    fn a_json_object_that_is_not_a_push_is_refused_and_never_defaulted_into_one() {
468        let dir = tempfile::tempdir().expect("temp dir");
469        let log = AttestationLog::under(dir.path());
470        log.append(&sample("before")).expect("a push");
471        log.append_line("{\"some_later_record_kind\": {\"nobody\": \"named it here\"}}")
472            .expect("a line");
473
474        let err = log
475            .read_indexed()
476            .expect_err("a record kind this build does not know");
477        let say = format!("{err:#}");
478        assert!(
479            say.contains("no attestation id") && say.contains("never happened"),
480            "{say}"
481        );
482    }
483}
484
485// ───────────────────────────────────────────────────────────────────────────
486// Push attestation, second half: the log becomes evidence
487// ───────────────────────────────────────────────────────────────────────────
488
489/// Where the signed tree heads are kept, beside the log they cover.
490///
491/// A second append-only file rather than a column in the first, because the two
492/// have different shapes: the log gains a line per push, and a head is issued
493/// when somebody asks. Putting heads in the same file would make every head a
494/// record that the next head has to cover, which is a log that can never be
495/// caught up with.
496pub const TRANSPARENCY_HEADS: &str = "transparency-heads.jsonl";
497
498/// Where signed build records are kept.
499///
500/// Its own file rather than lines in the attestation log, because the two are
501/// different records with different readers: `attestations.jsonl` parses as
502/// `PushAttestation` end to end, and a build record among them would break
503/// every reader of it. They get the same Merkle machinery — [`TransparencyLog`]
504/// is over *lines* — and their own heads.
505pub const BUILD_LOG: &str = "builds.jsonl";
506
507/// The signed tree heads over [`BUILD_LOG`].
508pub const BUILD_HEADS: &str = "build-heads.jsonl";
509
510/// Where signed forge events are kept — issues, reviews, merges.
511///
512/// **One file for the whole server**, like the attestation log and for the same
513/// reason: the consumer wants the stream in arrival order, and the repository
514/// is a field in every line. A file per repository would make "what happened on
515/// this server today" a directory walk.
516pub const FORGE_LOG: &str = "forge.jsonl";
517
518/// The signed tree heads over [`FORGE_LOG`].
519pub const FORGE_HEADS: &str = "forge-heads.jsonl";
520
521/// The signed group-membership log: one line per add/remove, each an `sshsig`
522/// the acting admin made under `gunnar-membership`. One file, in arrival order,
523/// with the group in every line, for the same reason [`FORGE_LOG`] is one file.
524pub const MEMBERSHIP_LOG: &str = "membership.jsonl";
525
526/// The signed tree heads over [`MEMBERSHIP_LOG`].
527pub const MEMBERSHIP_HEADS: &str = "membership-heads.jsonl";
528
529/// **The transparency log**: the push-attestation log, plus the evidence that it
530/// has not been rewritten.
531///
532/// # What it adds, and what it deliberately does not
533///
534/// It adds nothing to the attestation format. The Merkle tree is a **pure
535/// function of the lines already on disk** — leaf *i* is
536/// `SHA-256(0x00 ‖ line_i)` over the exact bytes of line *i*, no
537/// canonicalisation, no re-serialisation — so there is no second encoding to
538/// keep in step and no chance of hashing something other than what is stored.
539///
540/// A signed tree head at size N pins every line up to N. Rewriting line 3
541/// changes the root, so **every head issued since** stops verifying against the
542/// file: the tampering is detected by any holder of any later head, including
543/// one who never saw the original line. That is the property, and
544/// [`TransparencyLog::audit`] is the check.
545///
546/// What it does not add is a *consistency* proof between two heads (RFC 6962's
547/// second proof type). A verifier with two heads can today detect that the file
548/// changed under them; it cannot yet be shown that head M is a prefix-extension
549/// of head N without recomputing from the records. That is a real gap and it is
550/// named rather than papered over.
551#[derive(Debug, Clone)]
552pub struct TransparencyLog {
553    records: PathBuf,
554    heads: PathBuf,
555}
556
557/// One record's place in the log, and the proof that it is there.
558#[derive(Debug, Clone, PartialEq, Eq)]
559pub struct Inclusion {
560    /// Where in the log the record sits, counting from zero.
561    pub index: usize,
562    /// How many records the tree this proof is against holds.
563    pub size: usize,
564    /// The record's leaf hash, hex.
565    pub leaf: String,
566    /// The root the proof reconstructs, hex.
567    pub root: String,
568    /// The sibling hashes, hex, leaf-upwards.
569    pub proof: Vec<String>,
570}
571
572/// What an [`TransparencyLog::audit`] found.
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub enum AuditVerdict {
575    /// Every stored head verifies, and every head's root matches the records
576    /// the log holds at that size.
577    Consistent { records: usize, heads: usize },
578    /// A head does not verify against the key it names.
579    HeadNotSigned { size: u64, why: String },
580    /// A head verifies, and the records do not hash to the root it swore to.
581    /// **This is tampering**: the head is authentic and the file no longer is.
582    RecordsRewritten {
583        size: u64,
584        signed_root: String,
585        actual_root: String,
586    },
587    /// A head covers more records than the log holds. Lines were removed.
588    RecordsTruncated { size: u64, records: usize },
589}
590
591impl AuditVerdict {
592    /// `true` only for [`AuditVerdict::Consistent`]. A caller that treats every
593    /// other verdict as "some problem" is right to.
594    pub fn is_consistent(&self) -> bool {
595        matches!(self, AuditVerdict::Consistent { .. })
596    }
597}
598
599impl std::fmt::Display for AuditVerdict {
600    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
601        match self {
602            AuditVerdict::Consistent { records, heads } => write!(
603                f,
604                "consistent: {records} record(s) under {heads} signed head(s)"
605            ),
606            AuditVerdict::HeadNotSigned { size, why } => {
607                write!(f, "the head at size {size} does not verify: {why}")
608            }
609            AuditVerdict::RecordsRewritten {
610                size,
611                signed_root,
612                actual_root,
613            } => write!(
614                f,
615                "THE LOG HAS BEEN REWRITTEN: the head at size {size} was signed over root \
616                 {signed_root} and the records now hash to {actual_root}"
617            ),
618            AuditVerdict::RecordsTruncated { size, records } => write!(
619                f,
620                "THE LOG HAS BEEN TRUNCATED: a head covers {size} record(s) and the log holds \
621                 {records}"
622            ),
623        }
624    }
625}
626
627impl TransparencyLog {
628    /// The push-attestation log under a data root.
629    pub fn under(root: impl AsRef<Path>) -> Self {
630        let root = root.as_ref();
631        TransparencyLog::over(root.join(ATTESTATION_LOG), root.join(TRANSPARENCY_HEADS))
632    }
633
634    /// **Any append-only JSONL file**, with its own heads beside it.
635    ///
636    /// The Merkle machinery cares about *lines*, not about what is in them, so
637    /// the same log covers push attestations and build provenance without a
638    /// second implementation (one implementation). What is record-specific is exactly two
639    /// methods — [`Self::records`] and [`Self::prove_pack`] — and they say so.
640    pub fn over(records: impl Into<PathBuf>, heads: impl Into<PathBuf>) -> Self {
641        TransparencyLog {
642            records: records.into(),
643            heads: heads.into(),
644        }
645    }
646
647    /// This log's records, **read as push attestations**.
648    ///
649    /// Only meaningful for the push log; a build log's lines are a different
650    /// shape and this would refuse them. Everything else on this type is
651    /// record-agnostic.
652    pub fn records(&self) -> AttestationLog {
653        AttestationLog::at(self.records.clone())
654    }
655
656    /// Where the records are.
657    pub fn records_path(&self) -> &Path {
658        &self.records
659    }
660
661    /// Where the signed heads are.
662    pub fn heads_path(&self) -> &Path {
663        &self.heads
664    }
665
666    /// The raw record lines, exactly as they are on disk.
667    ///
668    /// **The bytes, not the parsed attestations.** A leaf is the hash of what
669    /// was written; re-serialising a parsed record could produce different
670    /// bytes for the same push — a different field order, a different escape —
671    /// and the root would then depend on the serialiser rather than on the log.
672    fn lines(&self) -> anyhow::Result<Vec<String>> {
673        let text = match std::fs::read_to_string(&self.records) {
674            Ok(text) => text,
675            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
676            Err(err) => return Err(err.into()),
677        };
678        Ok(text
679            .lines()
680            .filter(|line| !line.trim().is_empty())
681            .map(str::to_owned)
682            .collect())
683    }
684
685    /// The record lines, for a caller that decodes them itself.
686    ///
687    /// `pub` because the build log's records are not `PushAttestation`s and
688    /// have to be parsed by whoever knows their shape.
689    pub fn lines_of(&self) -> anyhow::Result<Vec<String>> {
690        self.lines()
691    }
692
693    fn leaves(&self) -> anyhow::Result<Vec<gunnar_core::transparency::Hash>> {
694        Ok(self
695            .lines()?
696            .iter()
697            .map(|line| gunnar_core::transparency::leaf_hash(line.as_bytes()))
698            .collect())
699    }
700
701    /// How many records the log holds.
702    pub fn len(&self) -> anyhow::Result<usize> {
703        Ok(self.lines()?.len())
704    }
705
706    pub fn is_empty(&self) -> anyhow::Result<bool> {
707        Ok(self.len()? == 0)
708    }
709
710    /// The current Merkle root, hex.
711    pub fn root(&self) -> anyhow::Result<String> {
712        Ok(gunnar_core::transparency::hex(
713            &gunnar_core::transparency::root(&self.leaves()?),
714        ))
715    }
716
717    /// Issue a signed tree head over everything the log currently holds, and
718    /// append it.
719    ///
720    /// Returns the head. Issuing one over an **empty** log is allowed and
721    /// meaningful: it is how a verifier learns the log exists and held nothing
722    /// at that moment, which is the only way "this server has never accepted a
723    /// push" can be evidence rather than an absence.
724    pub fn issue_head(
725        &self,
726        key: &gunnar_core::ssh_key::PrivateKey,
727        at_unix_ms: i64,
728    ) -> anyhow::Result<gunnar_core::SignedTreeHead> {
729        let leaves = self.leaves()?;
730        let head = gunnar_core::TreeHead {
731            size: leaves.len() as u64,
732            root: gunnar_core::transparency::hex(&gunnar_core::transparency::root(&leaves)),
733            at_unix_ms,
734        };
735        let signed = gunnar_core::transparency::sign_tree_head(key, head)?;
736        let mut line = serde_json::to_string(&signed)?;
737        line.push('\n');
738        let _framed =
739            gunnar_core::txlog::sink::append_line(&self.heads, line.trim_end_matches('\n'))?;
740        let mut file = std::fs::OpenOptions::new()
741            .create(true)
742            .append(true)
743            .open(&self.heads)?;
744        file.write_all(line.as_bytes())?;
745        Ok(signed)
746    }
747
748    /// Every signed head that has been issued, oldest first.
749    pub fn heads(&self) -> anyhow::Result<Vec<gunnar_core::SignedTreeHead>> {
750        let text = match std::fs::read_to_string(&self.heads) {
751            Ok(text) => text,
752            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
753            Err(err) => return Err(err.into()),
754        };
755        text.lines()
756            .filter(|line| !line.trim().is_empty())
757            .map(|line| serde_json::from_str(line).map_err(anyhow::Error::from))
758            .collect()
759    }
760
761    /// The inclusion proof for the record at `index`.
762    ///
763    /// `None` when the log does not hold that many records — a proof for a
764    /// record that is not there is not an empty proof.
765    pub fn prove(&self, index: usize) -> anyhow::Result<Option<Inclusion>> {
766        let leaves = self.leaves()?;
767        let Some(proof) = gunnar_core::transparency::inclusion_proof(&leaves, index) else {
768            return Ok(None);
769        };
770        Ok(Some(Inclusion {
771            index,
772            size: leaves.len(),
773            leaf: gunnar_core::transparency::hex(&leaves[index]),
774            root: gunnar_core::transparency::hex(&gunnar_core::transparency::root(&leaves)),
775            proof: proof.iter().map(gunnar_core::transparency::hex).collect(),
776        }))
777    }
778
779    /// Find the record for a pack hash, and prove it.
780    ///
781    /// The lookup a verifier actually makes: it holds an artifact, the artifact
782    /// names a pack, and it wants to know the push that landed it. Scans, which
783    /// is honest about the cost — an index over the log is a later problem and
784    /// pretending one exists would be worse than the scan.
785    pub fn prove_pack(&self, pack_hash: &str) -> anyhow::Result<Option<Inclusion>> {
786        // `read_indexed`, not `read` + `position`: an entitlement line between
787        // two pushes is a leaf too, so a push's vector position is not its
788        // line index, and a proof made against it would not verify.
789        let records = self.records().read_indexed()?;
790        let Some((index, _)) = records.iter().find(|(_, r)| r.pack_hash == pack_hash) else {
791            return Ok(None);
792        };
793        self.prove(*index)
794    }
795
796    /// **Check every signed head against the records on disk.**
797    ///
798    /// This is what the log is for. It answers three different questions, and
799    /// keeping them apart is the point:
800    ///
801    /// * a head that does not verify means somebody wrote a head they could not
802    ///   sign — or the key rotated and the verifier was not told;
803    /// * a head that *does* verify over a root the records no longer produce
804    ///   means the **records** were changed after they were signed for, which is
805    ///   tampering and nothing else;
806    /// * a head covering more records than exist means lines were removed.
807    ///
808    /// The first verdict wins and is returned; an operator fixes one thing at a
809    /// time.
810    pub fn audit(&self, key: &gunnar_core::ssh_key::PublicKey) -> anyhow::Result<AuditVerdict> {
811        let leaves = self.leaves()?;
812        let heads = self.heads()?;
813        for head in &heads {
814            if let Err(problem) = gunnar_core::transparency::verify_tree_head(head, key) {
815                return Ok(AuditVerdict::HeadNotSigned {
816                    size: head.head.size,
817                    why: problem.to_string(),
818                });
819            }
820            let size = head.head.size as usize;
821            if size > leaves.len() {
822                return Ok(AuditVerdict::RecordsTruncated {
823                    size: head.head.size,
824                    records: leaves.len(),
825                });
826            }
827            // The root over the FIRST `size` records: a head is a claim about a
828            // prefix, and checking it against the whole file would fail on
829            // every head but the newest.
830            let actual =
831                gunnar_core::transparency::hex(&gunnar_core::transparency::root(&leaves[..size]));
832            if actual != head.head.root {
833                return Ok(AuditVerdict::RecordsRewritten {
834                    size: head.head.size,
835                    signed_root: head.head.root.clone(),
836                    actual_root: actual,
837                });
838            }
839        }
840        Ok(AuditVerdict::Consistent {
841            records: leaves.len(),
842            heads: heads.len(),
843        })
844    }
845}