Skip to main content

dejadb_core/format/
header.rs

1use sha2::{Digest, Sha256};
2
3use crate::error::{DejaDbError, Hash, Result};
4use crate::types::GrainType;
5
6/// The 9-byte fixed header for .mg blobs.
7#[derive(Debug, Clone, serde::Serialize)]
8pub struct MgHeader {
9    pub version: u8,
10    pub flags: u8,
11    pub grain_type: u8,
12    pub ns_hash: u16,
13    pub created_at_sec: u32,
14}
15
16impl MgHeader {
17    /// Build header from grain metadata.
18    pub fn new(grain_type: GrainType, namespace: Option<&str>, created_at_ms: i64) -> Self {
19        let ns_hash = match namespace {
20            Some(ns) if !ns.is_empty() => {
21                let hash = Sha256::digest(ns.as_bytes());
22                u16::from_be_bytes([hash[0], hash[1]])
23            }
24            _ => 0u16,
25        };
26        let epoch_secs = created_at_ms / 1000;
27        let created_at_sec = if epoch_secs < 0 {
28            0u32 // Clamp pre-epoch timestamps to zero
29        } else {
30            u32::try_from(epoch_secs).unwrap_or(u32::MAX) // Clamp overflow to max
31        };
32
33        MgHeader {
34            version: 0x01,
35            flags: 0x00,
36            grain_type: grain_type.type_byte(),
37            ns_hash,
38            created_at_sec,
39        }
40    }
41
42    /// Set the is_signed flag (bit 0 of the flags byte).
43    pub fn set_is_signed(&mut self, signed: bool) {
44        if signed {
45            self.flags |= 0x01;
46        } else {
47            self.flags &= !0x01;
48        }
49    }
50
51    /// Set flags for content_refs presence.
52    pub fn set_has_content_refs(&mut self, has: bool) {
53        if has {
54            self.flags |= 0x08; // bit 3
55        } else {
56            self.flags &= !0x08;
57        }
58    }
59
60    /// Set flags for embedding_refs presence.
61    pub fn set_has_embedding_refs(&mut self, has: bool) {
62        if has {
63            self.flags |= 0x10; // bit 4
64        } else {
65            self.flags &= !0x10;
66        }
67    }
68
69    /// Set AI-generated content flag (bit 5).
70    pub fn set_ai_generated(&mut self, is_ai: bool) {
71        if is_ai {
72            self.flags |= 0x20; // bit 5
73        } else {
74            self.flags &= !0x20;
75        }
76    }
77
78    /// Set sensitivity level (bits 6-7).
79    pub fn set_sensitivity(&mut self, level: u8) {
80        self.flags = (self.flags & 0x3F) | ((level & 0x03) << 6);
81    }
82
83    /// Serialize to 9 bytes.
84    pub fn to_bytes(&self) -> [u8; 9] {
85        let ns_bytes = self.ns_hash.to_be_bytes();
86        let ts_bytes = self.created_at_sec.to_be_bytes();
87        [
88            self.version,
89            self.flags,
90            self.grain_type,
91            ns_bytes[0],
92            ns_bytes[1],
93            ts_bytes[0],
94            ts_bytes[1],
95            ts_bytes[2],
96            ts_bytes[3],
97        ]
98    }
99
100    /// Parse from 9 bytes.
101    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
102        if bytes.len() < 9 {
103            return Err(DejaDbError::Format("header must be at least 9 bytes".into()));
104        }
105        if bytes[0] != 0x01 {
106            return Err(DejaDbError::Format(format!(
107                "unsupported version: {:#x}",
108                bytes[0]
109            )));
110        }
111        Ok(MgHeader {
112            version: bytes[0],
113            flags: bytes[1],
114            grain_type: bytes[2],
115            ns_hash: u16::from_be_bytes([bytes[3], bytes[4]]),
116            created_at_sec: u32::from_be_bytes([bytes[5], bytes[6], bytes[7], bytes[8]]),
117        })
118    }
119
120    pub fn is_signed(&self) -> bool {
121        self.flags & 0x01 != 0
122    }
123
124    pub fn is_encrypted(&self) -> bool {
125        self.flags & 0x02 != 0
126    }
127
128    pub fn is_compressed(&self) -> bool {
129        self.flags & 0x04 != 0
130    }
131
132    pub fn has_content_refs(&self) -> bool {
133        self.flags & 0x08 != 0
134    }
135
136    pub fn has_embedding_refs(&self) -> bool {
137        self.flags & 0x10 != 0
138    }
139
140    pub fn is_ai_generated(&self) -> bool {
141        self.flags & 0x20 != 0
142    }
143
144    pub fn sensitivity(&self) -> u8 {
145        (self.flags >> 6) & 0x03
146    }
147}
148
149/// Compute SHA-256 content address of complete blob bytes.
150pub fn content_address(blob: &[u8]) -> Hash {
151    let digest: [u8; 32] = Sha256::digest(blob).into();
152    Hash::from_bytes(&digest)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_header_roundtrip() {
161        let header = MgHeader::new(GrainType::Fact, Some("shared"), 1768471200000);
162        let bytes = header.to_bytes();
163        let parsed = MgHeader::from_bytes(&bytes).unwrap();
164        assert_eq!(parsed.version, 0x01);
165        assert_eq!(parsed.flags, 0x00);
166        assert_eq!(parsed.grain_type, 0x01);
167        assert_eq!(parsed.ns_hash, header.ns_hash);
168        assert_eq!(parsed.created_at_sec, 1768471200);
169    }
170
171    #[test]
172    fn test_namespace_hash_shared() {
173        // SHA-256("shared") first 2 bytes should produce 0xa4d2
174        let header = MgHeader::new(GrainType::Fact, Some("shared"), 1768471200000);
175        assert_eq!(header.ns_hash, 0xa4d2);
176    }
177
178    #[test]
179    fn test_header_bytes_vector1() {
180        // From OMS test vector 1: header should be: 01 00 01 a4 d2 69 68 ba a0
181        let header = MgHeader::new(GrainType::Fact, Some("shared"), 1768471200000);
182        let bytes = header.to_bytes();
183        assert_eq!(
184            bytes,
185            [0x01, 0x00, 0x01, 0xa4, 0xd2, 0x69, 0x68, 0xba, 0xa0]
186        );
187    }
188}