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