use std::fmt;
use std::path::Path;
use crate::error::NapError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ContentHash(String);
impl ContentHash {
pub fn from_bytes(data: &[u8]) -> Self {
let hash = blake3::hash(data);
ContentHash(format!("blake3:{}", hash.to_hex()))
}
pub fn from_str_content(s: &str) -> Self {
Self::from_bytes(s.as_bytes())
}
pub fn from_file(path: &Path) -> Result<Self, NapError> {
let data = std::fs::read(path)?;
Ok(Self::from_bytes(&data))
}
pub fn parse(s: &str) -> Result<Self, NapError> {
if !s.starts_with("blake3:") {
return Err(NapError::Other(format!(
"content hash must start with 'blake3:', got '{s}'"
)));
}
let hex_part = &s[7..];
if hex_part.len() != 64 {
return Err(NapError::Other(format!(
"BLAKE3 hex digest must be 64 chars, got {}",
hex_part.len()
)));
}
hex::decode(hex_part)
.map_err(|e| NapError::Other(format!("invalid hex in content hash: {e}")))?;
Ok(ContentHash(s.to_string()))
}
pub fn hex_digest(&self) -> &str {
&self.0[7..]
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn verify(&self, data: &[u8]) -> Result<(), NapError> {
let actual = Self::from_bytes(data);
if *self != actual {
return Err(NapError::ContentHashMismatch {
expected: self.0.clone(),
actual: actual.0,
});
}
Ok(())
}
}
impl fmt::Display for ContentHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_deterministic() {
let hash_a = ContentHash::from_str_content("hello world");
let hash_b = ContentHash::from_str_content("hello world");
assert_eq!(hash_a, hash_b);
}
#[test]
fn test_hash_different_content() {
let hash_a = ContentHash::from_str_content("hello");
let hash_b = ContentHash::from_str_content("world");
assert_ne!(hash_a, hash_b);
}
#[test]
fn test_hash_format() {
let hash = ContentHash::from_str_content("test");
assert!(hash.as_str().starts_with("blake3:"));
assert_eq!(hash.hex_digest().len(), 64);
}
#[test]
fn test_parse_valid_hash() {
let hash = ContentHash::from_str_content("test");
let parsed = ContentHash::parse(hash.as_str()).unwrap();
assert_eq!(hash, parsed);
}
#[test]
fn test_parse_invalid_prefix() {
assert!(ContentHash::parse("sha256:abc123").is_err());
}
#[test]
fn test_verify_success() {
let hash = ContentHash::from_str_content("hello");
assert!(hash.verify(b"hello").is_ok());
}
#[test]
fn test_verify_mismatch() {
let hash = ContentHash::from_str_content("hello");
assert!(hash.verify(b"world").is_err());
}
}