use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use crate::error::{TrustError, TrustResult};
use crate::merkle::RootHash;
use super::verifier_key::validate_note_name;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CheckpointBody {
origin: String,
tree_size: u64,
root_hash: [u8; 32],
}
impl CheckpointBody {
pub fn new(origin: &str, tree_size: u64, root_hash: [u8; 32]) -> TrustResult<Self> {
validate_note_name(origin).map_err(|e| TrustError::CheckpointEncoding {
reason: format!("invalid checkpoint origin: {e}"),
})?;
Ok(Self {
origin: origin.to_string(),
tree_size,
root_hash,
})
}
pub fn from_root(origin: &str, root: &RootHash) -> TrustResult<Self> {
let (root_hash, tree_size) = root.to_parts();
Self::new(origin, tree_size, root_hash)
}
#[must_use]
pub fn encode(&self) -> String {
format!(
"{}\n{}\n{}\n",
self.origin,
self.tree_size,
STANDARD.encode(self.root_hash)
)
}
pub fn parse(text: &str) -> TrustResult<Self> {
let Some(without_final_newline) = text.strip_suffix('\n') else {
return Err(TrustError::CheckpointParsing {
reason: "checkpoint body must end with a newline".to_string(),
});
};
let lines: Vec<&str> = without_final_newline.split('\n').collect();
if lines.len() < 3 {
return Err(TrustError::CheckpointParsing {
reason: format!(
"checkpoint body must have at least 3 lines, got {}",
lines.len()
),
});
}
if lines.iter().any(|line| line.is_empty()) {
return Err(TrustError::CheckpointParsing {
reason: "checkpoint body must not contain empty lines".to_string(),
});
}
let origin = lines[0];
validate_note_name(origin).map_err(|e| TrustError::CheckpointParsing {
reason: format!("invalid checkpoint origin: {e}"),
})?;
let tree_size = parse_tree_size(lines[1])?;
let root_hash = parse_root_line(lines[2])?;
Ok(Self {
origin: origin.to_string(),
tree_size,
root_hash,
})
}
#[must_use]
pub fn origin(&self) -> &str {
&self.origin
}
#[must_use]
pub fn tree_size(&self) -> u64 {
self.tree_size
}
#[must_use]
pub fn root_hash(&self) -> [u8; 32] {
self.root_hash
}
#[must_use]
pub fn to_root(&self) -> RootHash {
RootHash::from_parts(self.root_hash, self.tree_size)
}
}
fn parse_tree_size(line: &str) -> TrustResult<u64> {
if line.is_empty() || !line.bytes().all(|b| b.is_ascii_digit()) {
return Err(TrustError::CheckpointParsing {
reason: "tree size must be ASCII decimal digits only".to_string(),
});
}
if line.len() > 1 && line.starts_with('0') {
return Err(TrustError::CheckpointParsing {
reason: "tree size must not have leading zeros".to_string(),
});
}
line.parse::<u64>()
.map_err(|e| TrustError::CheckpointParsing {
reason: format!("tree size does not fit in u64: {e}"),
})
}
fn parse_root_line(line: &str) -> TrustResult<[u8; 32]> {
if line.len() != 44 {
return Err(TrustError::CheckpointParsing {
reason: format!(
"root hash line must be exactly 44 base64 characters, got {}",
line.len()
),
});
}
let decoded = STANDARD
.decode(line)
.map_err(|e| TrustError::CheckpointParsing {
reason: format!("root hash line is not canonical standard base64: {e}"),
})?;
let root: [u8; 32] =
decoded
.as_slice()
.try_into()
.map_err(|_err| TrustError::CheckpointParsing {
reason: format!("root hash must decode to 32 bytes, got {}", decoded.len()),
})?;
Ok(root)
}
#[cfg(test)]
#[path = "body_tests.rs"]
mod tests;