lys_core/checkpoint/body.rs
1//! [`CheckpointBody`] — the C2SP tlog-checkpoint body, byte-exact.
2//!
3//! # Invariants
4//!
5//! - [`CheckpointBody::encode`] emits exactly three newline-terminated
6//! lines: origin, tree size in strict ASCII decimal (no leading zeros,
7//! no sign), and the standard-base64-with-padding encoding of the
8//! 32-byte RFC 6962 root hash. No extension lines are ever emitted.
9//! - [`CheckpointBody::parse`] is strict on those three lines and tolerates
10//! (discards) non-empty extension lines after them, per the C2SP spec.
11//! Empty lines are malformed — a body can never contain a blank line,
12//! which is what keeps the signed-note envelope split unambiguous.
13//! - The origin is validated against the note key-name rules at
14//! construction: origins double as note key names (the checkpoint is
15//! signed under its origin), so an origin that cannot be a key name is
16//! rejected before anything is signed under it.
17//! - encode/parse round-trip byte-exactly.
18
19use base64::Engine;
20use base64::engine::general_purpose::STANDARD;
21
22use crate::error::{TrustError, TrustResult};
23use crate::merkle::RootHash;
24
25use super::verifier_key::validate_note_name;
26
27/// C2SP tlog-checkpoint body: origin, tree size, and RFC 6962 root hash.
28///
29/// The origin is validated against note-name rules at construction — the
30/// origin doubles as the signing key name, so `lys`-emitted checkpoints
31/// sign under the origin and verifiers enforce the binding.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct CheckpointBody {
34 origin: String,
35 tree_size: u64,
36 root_hash: [u8; 32],
37}
38
39impl CheckpointBody {
40 /// Builds a checkpoint body, validating the origin against the note
41 /// key-name rules.
42 ///
43 /// # Errors
44 ///
45 /// Returns [`TrustError::CheckpointEncoding`] if the origin is empty,
46 /// contains whitespace, or contains `'+'`.
47 pub fn new(origin: &str, tree_size: u64, root_hash: [u8; 32]) -> TrustResult<Self> {
48 validate_note_name(origin).map_err(|e| TrustError::CheckpointEncoding {
49 reason: format!("invalid checkpoint origin: {e}"),
50 })?;
51 Ok(Self {
52 origin: origin.to_string(),
53 tree_size,
54 root_hash,
55 })
56 }
57
58 /// Convenience constructor from the existing merkle root type via
59 /// [`RootHash::to_parts`].
60 ///
61 /// # Errors
62 ///
63 /// Returns [`TrustError::CheckpointEncoding`] if the origin is invalid
64 /// (see [`Self::new`]).
65 pub fn from_root(origin: &str, root: &RootHash) -> TrustResult<Self> {
66 let (root_hash, tree_size) = root.to_parts();
67 Self::new(origin, tree_size, root_hash)
68 }
69
70 /// Encodes the body as exactly
71 /// `"{origin}\n{tree_size}\n{base64_std_padded(root_hash)}\n"`.
72 pub fn encode(&self) -> String {
73 format!(
74 "{}\n{}\n{}\n",
75 self.origin,
76 self.tree_size,
77 STANDARD.encode(self.root_hash)
78 )
79 }
80
81 /// Strict parse of a body (typically one returned by `verify_note`).
82 ///
83 /// Line 1: a valid origin per the note key-name rules. Line 2: ASCII
84 /// decimal `u64`, no leading zeros unless the value is `"0"`, no sign,
85 /// no whitespace. Line 3: exactly 44 base64 characters decoding
86 /// canonically to 32 bytes. Extension lines (non-empty lines after
87 /// line 3) are tolerated and discarded, per the C2SP spec; an empty
88 /// line anywhere is malformed (bodies cannot contain blank lines — see
89 /// [`super::note::sign_note`]).
90 ///
91 /// # Errors
92 ///
93 /// Returns [`TrustError::CheckpointParsing`] on any violation,
94 /// including a body that does not end with `'\n'`.
95 pub fn parse(text: &str) -> TrustResult<Self> {
96 let Some(without_final_newline) = text.strip_suffix('\n') else {
97 return Err(TrustError::CheckpointParsing {
98 reason: "checkpoint body must end with a newline".to_string(),
99 });
100 };
101 let lines: Vec<&str> = without_final_newline.split('\n').collect();
102 if lines.len() < 3 {
103 return Err(TrustError::CheckpointParsing {
104 reason: format!(
105 "checkpoint body must have at least 3 lines, got {}",
106 lines.len()
107 ),
108 });
109 }
110 if lines.iter().any(|line| line.is_empty()) {
111 return Err(TrustError::CheckpointParsing {
112 reason: "checkpoint body must not contain empty lines".to_string(),
113 });
114 }
115 let origin = lines[0];
116 validate_note_name(origin).map_err(|e| TrustError::CheckpointParsing {
117 reason: format!("invalid checkpoint origin: {e}"),
118 })?;
119 let tree_size = parse_tree_size(lines[1])?;
120 let root_hash = parse_root_line(lines[2])?;
121 // Lines beyond the third are extension lines: tolerated, discarded.
122 Ok(Self {
123 origin: origin.to_string(),
124 tree_size,
125 root_hash,
126 })
127 }
128
129 /// The log's origin (its unique identity, and the note key name).
130 pub fn origin(&self) -> &str {
131 &self.origin
132 }
133
134 /// The tree size this checkpoint commits to.
135 pub fn tree_size(&self) -> u64 {
136 self.tree_size
137 }
138
139 /// The 32-byte RFC 6962 root hash this checkpoint commits to.
140 pub fn root_hash(&self) -> [u8; 32] {
141 self.root_hash
142 }
143
144 /// Bridges into the existing merkle verification helpers:
145 /// `RootHash::from_parts(self.root_hash, self.tree_size)`.
146 pub fn to_root(&self) -> RootHash {
147 RootHash::from_parts(self.root_hash, self.tree_size)
148 }
149}
150
151/// Parses a strict ASCII-decimal `u64` tree size: digits only, no sign, no
152/// whitespace, and no leading zeros unless the value is exactly `"0"`.
153fn parse_tree_size(line: &str) -> TrustResult<u64> {
154 if line.is_empty() || !line.bytes().all(|b| b.is_ascii_digit()) {
155 return Err(TrustError::CheckpointParsing {
156 reason: "tree size must be ASCII decimal digits only".to_string(),
157 });
158 }
159 if line.len() > 1 && line.starts_with('0') {
160 return Err(TrustError::CheckpointParsing {
161 reason: "tree size must not have leading zeros".to_string(),
162 });
163 }
164 line.parse::<u64>()
165 .map_err(|e| TrustError::CheckpointParsing {
166 reason: format!("tree size does not fit in u64: {e}"),
167 })
168}
169
170/// Parses the root-hash line: exactly 44 standard-base64 characters
171/// decoding canonically to 32 bytes.
172fn parse_root_line(line: &str) -> TrustResult<[u8; 32]> {
173 if line.len() != 44 {
174 return Err(TrustError::CheckpointParsing {
175 reason: format!(
176 "root hash line must be exactly 44 base64 characters, got {}",
177 line.len()
178 ),
179 });
180 }
181 let decoded = STANDARD
182 .decode(line)
183 .map_err(|e| TrustError::CheckpointParsing {
184 reason: format!("root hash line is not canonical standard base64: {e}"),
185 })?;
186 let root: [u8; 32] =
187 decoded
188 .as_slice()
189 .try_into()
190 .map_err(|_err| TrustError::CheckpointParsing {
191 reason: format!("root hash must decode to 32 bytes, got {}", decoded.len()),
192 })?;
193 Ok(root)
194}
195
196#[cfg(test)]
197#[path = "body_tests.rs"]
198mod tests;