1use std::fmt;
11use std::path::Path;
12
13use crate::error::NapError;
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
17pub struct ContentHash(String);
18
19impl ContentHash {
20 pub fn from_bytes(data: &[u8]) -> Self {
22 let hash = blake3::hash(data);
23 ContentHash(format!("blake3:{}", hash.to_hex()))
24 }
25
26 pub fn from_str_content(s: &str) -> Self {
28 Self::from_bytes(s.as_bytes())
29 }
30
31 pub fn from_file(path: &Path) -> Result<Self, NapError> {
33 let data = std::fs::read(path)?;
34 Ok(Self::from_bytes(&data))
35 }
36
37 pub fn parse(s: &str) -> Result<Self, NapError> {
39 if !s.starts_with("blake3:") {
40 return Err(NapError::Other(format!(
41 "content hash must start with 'blake3:', got '{s}'"
42 )));
43 }
44 let hex_part = &s[7..];
45 if hex_part.len() != 64 {
46 return Err(NapError::Other(format!(
47 "BLAKE3 hex digest must be 64 chars, got {}",
48 hex_part.len()
49 )));
50 }
51 hex::decode(hex_part)
53 .map_err(|e| NapError::Other(format!("invalid hex in content hash: {e}")))?;
54 Ok(ContentHash(s.to_string()))
55 }
56
57 pub fn hex_digest(&self) -> &str {
59 &self.0[7..]
60 }
61
62 pub fn as_str(&self) -> &str {
64 &self.0
65 }
66
67 pub fn verify(&self, data: &[u8]) -> Result<(), NapError> {
69 let actual = Self::from_bytes(data);
70 if *self != actual {
71 return Err(NapError::ContentHashMismatch {
72 expected: self.0.clone(),
73 actual: actual.0,
74 });
75 }
76 Ok(())
77 }
78}
79
80impl fmt::Display for ContentHash {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 write!(f, "{}", self.0)
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn test_hash_deterministic() {
92 let hash_a = ContentHash::from_str_content("hello world");
93 let hash_b = ContentHash::from_str_content("hello world");
94 assert_eq!(hash_a, hash_b);
95 }
96
97 #[test]
98 fn test_hash_different_content() {
99 let hash_a = ContentHash::from_str_content("hello");
100 let hash_b = ContentHash::from_str_content("world");
101 assert_ne!(hash_a, hash_b);
102 }
103
104 #[test]
105 fn test_hash_format() {
106 let hash = ContentHash::from_str_content("test");
107 assert!(hash.as_str().starts_with("blake3:"));
108 assert_eq!(hash.hex_digest().len(), 64);
109 }
110
111 #[test]
112 fn test_parse_valid_hash() {
113 let hash = ContentHash::from_str_content("test");
114 let parsed = ContentHash::parse(hash.as_str()).unwrap();
115 assert_eq!(hash, parsed);
116 }
117
118 #[test]
119 fn test_parse_invalid_prefix() {
120 assert!(ContentHash::parse("sha256:abc123").is_err());
121 }
122
123 #[test]
124 fn test_verify_success() {
125 let hash = ContentHash::from_str_content("hello");
126 assert!(hash.verify(b"hello").is_ok());
127 }
128
129 #[test]
130 fn test_verify_mismatch() {
131 let hash = ContentHash::from_str_content("hello");
132 assert!(hash.verify(b"world").is_err());
133 }
134}