Skip to main content

nap_core/
content.rs

1//! SHA-256 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 sha2::{Digest, Sha256};
11use std::fmt;
12use std::path::Path;
13
14use crate::error::NapError;
15
16/// A SHA-256 content hash, displayed as `sha256:<hex>`.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
18pub struct ContentHash(String);
19
20impl ContentHash {
21    /// Compute the SHA-256 hash of raw bytes.
22    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    /// Compute the SHA-256 hash of a string.
30    pub fn from_str_content(s: &str) -> Self {
31        Self::from_bytes(s.as_bytes())
32    }
33
34    /// Compute the SHA-256 hash of a file's contents.
35    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    /// Parse a `sha256:<hex>` string into a ContentHash.
41    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        // Validate hex characters
55        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    /// Returns the raw hex digest without the `sha256:` prefix.
61    pub fn hex_digest(&self) -> &str {
62        &self.0[7..]
63    }
64
65    /// Returns the full `sha256:<hex>` string.
66    pub fn as_str(&self) -> &str {
67        &self.0
68    }
69
70    /// Verify that bytes match this hash. Returns an error on mismatch.
71    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}