Skip to main content

nap_core/
content.rs

1//! BLAKE3 content addressing for NAP resources.
2//!
3//! Every representation (image, voice model, mesh, etc.) is hash-addressable.
4//! Manifests point at content by hash, making everything:
5//! - **Immutable** — content at a hash never changes
6//! - **Cacheable** — same hash = same content, globally
7//! - **Deduplicated** — identical content shares one hash
8//! - **Verifiable** — compare hash to verify integrity
9
10use std::fmt;
11use std::path::Path;
12
13use crate::error::NapError;
14
15/// A BLAKE3 content hash, displayed as `blake3:<hex>`.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
17pub struct ContentHash(String);
18
19impl ContentHash {
20    /// Compute the BLAKE3 hash of raw bytes.
21    pub fn from_bytes(data: &[u8]) -> Self {
22        let hash = blake3::hash(data);
23        ContentHash(format!("blake3:{}", hash.to_hex()))
24    }
25
26    /// Compute the BLAKE3 hash of a string.
27    pub fn from_str_content(s: &str) -> Self {
28        Self::from_bytes(s.as_bytes())
29    }
30
31    /// Compute the BLAKE3 hash of a file's contents.
32    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    /// Parse a `blake3:<hex>` string into a ContentHash.
38    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        // Validate hex characters
52        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    /// Returns the raw hex digest without the `blake3:` prefix.
58    pub fn hex_digest(&self) -> &str {
59        &self.0[7..]
60    }
61
62    /// Returns the full `blake3:<hex>` string.
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66
67    /// Verify that bytes match this hash. Returns an error on mismatch.
68    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}