1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::fmt;
6use std::path::Path;
7
8#[derive(Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)]
13pub struct ContentHash([u8; 32]);
14
15impl ContentHash {
16 pub fn from_bytes(data: &[u8]) -> Self {
18 let mut hasher = Sha256::new();
19 hasher.update(data);
20 let result = hasher.finalize();
21 Self(result.into())
22 }
23
24 pub fn from_str(s: &str) -> Self {
26 Self::from_bytes(s.as_bytes())
27 }
28
29 pub fn to_hex(&self) -> String {
31 self.0.iter().map(|b| format!("{:02x}", b)).collect()
32 }
33
34 pub fn as_bytes(&self) -> &[u8; 32] {
36 &self.0
37 }
38
39 pub fn from_file<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
41 let data = std::fs::read(path)?;
42 Ok(Self::from_bytes(&data))
43 }
44
45 pub fn to_prefixed_hex(&self) -> String {
47 format!("sha256:{}", self.to_hex())
48 }
49
50 pub fn from_prefixed_hex(s: &str) -> Option<Self> {
52 let hex = s.strip_prefix("sha256:")?;
53 if hex.len() != 64 {
54 return None;
55 }
56 let mut bytes = [0u8; 32];
57 for i in 0..32 {
58 bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
59 }
60 Some(Self(bytes))
61 }
62}
63
64impl fmt::Debug for ContentHash {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 write!(f, "ContentHash({})", &self.to_hex()[..16])
67 }
68}
69
70impl fmt::Display for ContentHash {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 write!(f, "{}", &self.to_hex()[..16])
73 }
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 #[test]
81 fn test_consistent_hashing() {
82 let h1 = ContentHash::from_str("hello");
83 let h2 = ContentHash::from_str("hello");
84 assert_eq!(h1, h2);
85 }
86
87 #[test]
88 fn test_different_content_different_hash() {
89 let h1 = ContentHash::from_str("hello");
90 let h2 = ContentHash::from_str("world");
91 assert_ne!(h1, h2);
92 }
93
94 #[test]
95 fn test_hex_output() {
96 let h = ContentHash::from_str("hello");
97 let hex = h.to_hex();
98 assert_eq!(hex.len(), 64); }
100
101 #[test]
102 fn test_prefixed_hex_roundtrip() {
103 let h = ContentHash::from_str("test data");
104 let prefixed = h.to_prefixed_hex();
105 assert!(prefixed.starts_with("sha256:"));
106 let parsed = ContentHash::from_prefixed_hex(&prefixed).unwrap();
107 assert_eq!(h, parsed);
108 }
109
110 #[test]
111 fn test_from_prefixed_hex_invalid() {
112 assert!(ContentHash::from_prefixed_hex("md5:abc").is_none());
113 assert!(ContentHash::from_prefixed_hex("sha256:tooshort").is_none());
114 }
115}