Skip to main content

recall_wire/
audit.rs

1//! `GET /v1/audit/checkpoint`, `GET /v1/audit/entries` and
2//! `GET /v1/audit/consistency` — the Merkle tree over every authenticated
3//! action. See `docs/design/part5-plan.md`'s "PR 1: audit" for the design,
4//! and `docs/reference/api.md` for the authoritative shape of what shipped.
5//!
6//! A leaf's own JSON — `v`, `seq`, `at`, `action`, `actor`, `subject`,
7//! `request` — is not typed here: it varies by `action`, it is written once
8//! by `recall_server::audit::leaf` and never re-serialized (a verifier
9//! hashes the exact bytes it was given), and every consumer of it — this
10//! crate's fixtures, the entries route, an export — treats it as an opaque
11//! string. [`AuditEntriesResponse::entries`] is `Vec<String>` for exactly
12//! that reason.
13
14use serde::{Deserialize, Serialize};
15
16/// `GET`: the tree's current size and root.
17pub const CHECKPOINT_PATH: &str = "/v1/audit/checkpoint";
18
19/// `GET ?start=&end=`: leaves `start` to `end - 1`.
20pub const ENTRIES_PATH: &str = "/v1/audit/entries";
21
22/// `GET ?first=&second=`: the RFC 9162 §2.1.4 proof that `second` extends
23/// `first`.
24pub const CONSISTENCY_PATH: &str = "/v1/audit/consistency";
25
26/// The most leaves [`ENTRIES_PATH`] answers with in one page.
27pub const MAX_PAGE: u32 = 1000;
28
29/// The most bytes of leaves [`ENTRIES_PATH`] answers with in one page, 2
30/// MiB. A page whose leaves would come to more stops before the one that
31/// would cross it — never before its first — and its `end` says where it
32/// stopped. A typical leaf is under a kilobyte, so a full page of 1,000
33/// seldom meets it.
34pub const MAX_PAGE_BYTES: usize = 2 << 20;
35
36/// The leaf format this build writes and reads. Carried in the discovery
37/// document's `audit` capability so a client — or a future server version —
38/// knows which rules a leaf without its own `v` field long gone would have
39/// followed; every leaf this version writes carries `v` itself regardless.
40pub const LEAF_VERSION: u32 = 1;
41
42/// The response header every `GET /sync` answer also carries: `<tree_size>
43/// <root_hash>`, e.g. `1042 CsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I=` —
44/// the same two fields as [`AuditCheckpoint`], so a pull leaves the client a
45/// checkpoint without another request.
46pub const CHECKPOINT_HEADER: &str = "recall-audit-checkpoint";
47
48/// `GET /v1/audit/checkpoint`.
49#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
50pub struct AuditCheckpoint {
51    /// How many leaves the tree has.
52    pub tree_size: u64,
53    /// The tree's root hash, standard base64 (as in a C2SP checkpoint —
54    /// unlike `base_sha256` and the other file hashes on this API, which
55    /// stay lowercase hex).
56    pub root_hash: String,
57}
58
59impl AuditCheckpoint {
60    /// The [`CHECKPOINT_HEADER`] value for this checkpoint.
61    pub fn to_header_value(&self) -> String {
62        format!("{} {}", self.tree_size, self.root_hash)
63    }
64
65    /// Reads a [`CHECKPOINT_HEADER`] value back. [`None`] if it is not
66    /// `<tree_size> <root_hash>`.
67    pub fn parse_header_value(value: &str) -> Option<Self> {
68        let (size, root) = value.trim().split_once(' ')?;
69        Some(Self {
70            tree_size: size.parse().ok()?,
71            root_hash: root.to_string(),
72        })
73    }
74}
75
76/// `GET /v1/audit/entries`.
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct AuditEntriesResponse {
79    /// Echoed from the query.
80    pub start: u64,
81    /// One past the last leaf in `entries`: the query's `end`, unless the
82    /// page stopped early at [`MAX_PAGE_BYTES`], when it is where to ask
83    /// from next.
84    pub end: u64,
85    /// The tree's size when this was answered, so a caller paging through
86    /// knows where the end really is without a second request.
87    pub tree_size: u64,
88    /// Leaves `start` to `end - 1`, each exactly as stored: compact JSON,
89    /// never re-serialized.
90    pub entries: Vec<String>,
91}
92
93/// `GET /v1/audit/consistency`.
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
95pub struct AuditConsistencyResponse {
96    /// Echoed from the query.
97    pub first: u64,
98    /// Echoed from the query.
99    pub second: u64,
100    /// The proof nodes, standard base64, in the order RFC 9162 §2.1.4
101    /// builds them.
102    pub proof: Vec<String>,
103}
104
105/// The `audit` capability in the discovery document.
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
107pub struct AuditCapability {
108    /// The leaf format this server writes: [`LEAF_VERSION`].
109    pub leaf_version: u32,
110    /// The most entries one page of [`ENTRIES_PATH`] holds: [`MAX_PAGE`].
111    pub max_page: u32,
112    /// The most bytes of leaves one page holds: [`MAX_PAGE_BYTES`].
113    pub max_page_bytes: u64,
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn the_checkpoint_header_round_trips() {
122        let cp = AuditCheckpoint {
123            tree_size: 1042,
124            root_hash: "CsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I=".to_string(),
125        };
126        assert_eq!(
127            cp.to_header_value(),
128            "1042 CsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I="
129        );
130        assert_eq!(
131            AuditCheckpoint::parse_header_value(&cp.to_header_value()),
132            Some(cp)
133        );
134        assert_eq!(
135            AuditCheckpoint::parse_header_value("not a checkpoint"),
136            None
137        );
138        assert_eq!(AuditCheckpoint::parse_header_value("abc CsUY"), None);
139    }
140}