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 #[must_use]
73 pub fn encode(&self) -> String {
74 format!(
75 "{}\n{}\n{}\n",
76 self.origin,
77 self.tree_size,
78 STANDARD.encode(self.root_hash)
79 )
80 }
81
82 /// Strict parse of a body (typically one returned by `verify_note`).
83 ///
84 /// Line 1: a valid origin per the note key-name rules. Line 2: ASCII
85 /// decimal `u64`, no leading zeros unless the value is `"0"`, no sign,
86 /// no whitespace. Line 3: exactly 44 base64 characters decoding
87 /// canonically to 32 bytes. Extension lines (non-empty lines after
88 /// line 3) are tolerated and discarded, per the C2SP spec; an empty
89 /// line anywhere is malformed (bodies cannot contain blank lines — see
90 /// [`super::note::sign_note`]).
91 ///
92 /// # Errors
93 ///
94 /// Returns [`TrustError::CheckpointParsing`] on any violation,
95 /// including a body that does not end with `'\n'`.
96 pub fn parse(text: &str) -> TrustResult<Self> {
97 let Some(without_final_newline) = text.strip_suffix('\n') else {
98 return Err(TrustError::CheckpointParsing {
99 reason: "checkpoint body must end with a newline".to_string(),
100 });
101 };
102 let lines: Vec<&str> = without_final_newline.split('\n').collect();
103 if lines.len() < 3 {
104 return Err(TrustError::CheckpointParsing {
105 reason: format!(
106 "checkpoint body must have at least 3 lines, got {}",
107 lines.len()
108 ),
109 });
110 }
111 if lines.iter().any(|line| line.is_empty()) {
112 return Err(TrustError::CheckpointParsing {
113 reason: "checkpoint body must not contain empty lines".to_string(),
114 });
115 }
116 let origin = lines[0];
117 validate_note_name(origin).map_err(|e| TrustError::CheckpointParsing {
118 reason: format!("invalid checkpoint origin: {e}"),
119 })?;
120 let tree_size = parse_tree_size(lines[1])?;
121 let root_hash = parse_root_line(lines[2])?;
122 // Lines beyond the third are extension lines: tolerated, discarded.
123 Ok(Self {
124 origin: origin.to_string(),
125 tree_size,
126 root_hash,
127 })
128 }
129
130 /// The log's origin (its unique identity, and the note key name).
131 #[must_use]
132 pub fn origin(&self) -> &str {
133 &self.origin
134 }
135
136 /// The tree size this checkpoint commits to.
137 #[must_use]
138 pub fn tree_size(&self) -> u64 {
139 self.tree_size
140 }
141
142 /// The 32-byte RFC 6962 root hash this checkpoint commits to.
143 #[must_use]
144 pub fn root_hash(&self) -> [u8; 32] {
145 self.root_hash
146 }
147
148 /// Bridges into the existing merkle verification helpers:
149 /// `RootHash::from_parts(self.root_hash, self.tree_size)`.
150 #[must_use]
151 pub fn to_root(&self) -> RootHash {
152 RootHash::from_parts(self.root_hash, self.tree_size)
153 }
154}
155
156/// Parses a strict ASCII-decimal `u64` tree size: digits only, no sign, no
157/// whitespace, and no leading zeros unless the value is exactly `"0"`.
158fn parse_tree_size(line: &str) -> TrustResult<u64> {
159 if line.is_empty() || !line.bytes().all(|b| b.is_ascii_digit()) {
160 return Err(TrustError::CheckpointParsing {
161 reason: "tree size must be ASCII decimal digits only".to_string(),
162 });
163 }
164 if line.len() > 1 && line.starts_with('0') {
165 return Err(TrustError::CheckpointParsing {
166 reason: "tree size must not have leading zeros".to_string(),
167 });
168 }
169 line.parse::<u64>()
170 .map_err(|e| TrustError::CheckpointParsing {
171 reason: format!("tree size does not fit in u64: {e}"),
172 })
173}
174
175/// Parses the root-hash line: exactly 44 standard-base64 characters
176/// decoding canonically to 32 bytes.
177fn parse_root_line(line: &str) -> TrustResult<[u8; 32]> {
178 if line.len() != 44 {
179 return Err(TrustError::CheckpointParsing {
180 reason: format!(
181 "root hash line must be exactly 44 base64 characters, got {}",
182 line.len()
183 ),
184 });
185 }
186 let decoded = STANDARD
187 .decode(line)
188 .map_err(|e| TrustError::CheckpointParsing {
189 reason: format!("root hash line is not canonical standard base64: {e}"),
190 })?;
191 let root: [u8; 32] =
192 decoded
193 .as_slice()
194 .try_into()
195 .map_err(|_err| TrustError::CheckpointParsing {
196 reason: format!("root hash must decode to 32 bytes, got {}", decoded.len()),
197 })?;
198 Ok(root)
199}
200
201#[cfg(test)]
202#[path = "body_tests.rs"]
203mod tests;