engram_rs/
manifest.rs

1//! Manifest support for Engram archives
2//!
3//! # Manifest Scope
4//!
5//! The Engram manifest (`manifest.json`) is **reserved for format-level metadata only**:
6//! - Archive identification (name, version, description)
7//! - File inventory with integrity hashes (SHA-256)
8//! - Digital signatures for verification (Ed25519)
9//! - Format capabilities and compression metadata
10//!
11//! **Applications must use separate files for application-specific metadata:**
12//! - Recommended pattern: `<app-name>.json` (e.g., `crisis-frame.json`, `myapp.json`)
13//! - Applications may store multiple metadata files as needed
14//! - This allows multiple applications to coexist in one archive
15//!
16//! # Example Archive Structure
17//!
18//! ```text
19//! archive.eng
20//! ├── manifest.json           (Engram format metadata)
21//! ├── crisis-frame.json       (Crisis Frame backup metadata)
22//! ├── database/crisis.db      (Application data)
23//! └── logs/frame.log
24//! ```
25//!
26//! # Usage
27//!
28//! ```no_run
29//! use engram_rs::{ArchiveWriter, manifest::{Manifest, Author}};
30//! # use engram_rs::error::Result;
31//!
32//! # fn main() -> Result<()> {
33//! let mut writer = ArchiveWriter::create("backup.eng")?;
34//!
35//! // 1. Add Engram format manifest (reserved fields)
36//! let manifest = Manifest::new(
37//!     "backup-2025-11-30".to_string(),
38//!     "Crisis Frame Backup".to_string(),
39//!     Author::new("Crisis Frame System"),
40//!     "1.0.0".to_string()
41//! );
42//! writer.add_manifest(&serde_json::to_value(&manifest)?)?;
43//!
44//! // 2. Add application-specific manifest (separate file)
45//! let app_manifest = serde_json::json!({
46//!     "services": ["database", "logs", "config"],
47//!     "backup_type": "nightly",
48//!     "timestamp": "2025-11-30T08:00:00Z"
49//! });
50//! writer.add_file("crisis-frame.json",
51//!     serde_json::to_string_pretty(&app_manifest)?.as_bytes())?;
52//!
53//! // 3. Add application data
54//! writer.add_file_from_disk("database/crisis.db",
55//!     &std::path::Path::new("path/to/crisis.db"))?;
56//!
57//! writer.finalize()?;
58//! # Ok(())
59//! # }
60//! ```
61
62use crate::error::{EngramError, Result};
63use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
64use serde::{Deserialize, Serialize};
65use sha2::{Digest, Sha256};
66
67/// Engram manifest structure
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Manifest {
70    /// Manifest format version
71    pub version: String,
72
73    /// Archive identifier
74    pub id: String,
75
76    /// Human-readable name
77    pub name: String,
78
79    /// Archive description
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub description: Option<String>,
82
83    /// Author information
84    pub author: Author,
85
86    /// Archive metadata
87    pub metadata: Metadata,
88
89    /// Capabilities this engram declares
90    #[serde(default)]
91    pub capabilities: Vec<String>,
92
93    /// Files in the archive
94    #[serde(default)]
95    pub files: Vec<FileEntry>,
96
97    /// Cryptographic signatures
98    #[serde(default)]
99    pub signatures: Vec<SignatureEntry>,
100}
101
102/// Author information
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Author {
105    pub name: String,
106
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub email: Option<String>,
109
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub url: Option<String>,
112}
113
114impl Author {
115    /// Create a new author with just a name
116    pub fn new(name: impl Into<String>) -> Self {
117        Self {
118            name: name.into(),
119            email: None,
120            url: None,
121        }
122    }
123}
124
125/// Archive metadata
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Metadata {
128    /// Semantic version
129    pub version: String,
130
131    /// Creation timestamp (Unix epoch)
132    pub created: u64,
133
134    /// Last modified timestamp (Unix epoch)
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub modified: Option<u64>,
137
138    /// License identifier (SPDX)
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub license: Option<String>,
141
142    /// Tags for categorization
143    #[serde(default)]
144    pub tags: Vec<String>,
145}
146
147/// File entry in manifest
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct FileEntry {
150    /// File path in archive
151    pub path: String,
152
153    /// SHA-256 hash of uncompressed content
154    pub sha256: String,
155
156    /// File size (uncompressed)
157    pub size: u64,
158
159    /// MIME type
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub mime_type: Option<String>,
162}
163
164/// Cryptographic signature entry
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct SignatureEntry {
167    /// Signature algorithm (e.g., "ed25519")
168    pub algorithm: String,
169
170    /// Public key (hex-encoded)
171    pub public_key: String,
172
173    /// Signature (hex-encoded)
174    pub signature: String,
175
176    /// Timestamp when signature was created
177    pub timestamp: u64,
178
179    /// Signer identity
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub signer: Option<String>,
182}
183
184impl Manifest {
185    /// Create a new manifest
186    pub fn new(id: String, name: String, author: Author, version: String) -> Self {
187        Self {
188            version: "0.4.0".to_string(),
189            id,
190            name,
191            description: None,
192            author,
193            metadata: Metadata {
194                version,
195                created: std::time::SystemTime::now()
196                    .duration_since(std::time::UNIX_EPOCH)
197                    .unwrap()
198                    .as_secs(),
199                modified: None,
200                license: None,
201                tags: Vec::new(),
202            },
203            capabilities: Vec::new(),
204            files: Vec::new(),
205            signatures: Vec::new(),
206        }
207    }
208
209    /// Add a file entry to the manifest
210    pub fn add_file(&mut self, path: String, data: &[u8], mime_type: Option<String>) {
211        let sha256 = hex::encode(Sha256::digest(data));
212        self.files.push(FileEntry {
213            path,
214            sha256,
215            size: data.len() as u64,
216            mime_type,
217        });
218    }
219
220    /// Serialize to JSON
221    pub fn to_json(&self) -> Result<Vec<u8>> {
222        serde_json::to_vec_pretty(self).map_err(EngramError::from)
223    }
224
225    /// Parse from JSON
226    pub fn from_json(data: &[u8]) -> Result<Self> {
227        serde_json::from_slice(data).map_err(EngramError::from)
228    }
229
230    /// Calculate canonical hash for signing
231    ///
232    /// This creates a deterministic representation of the manifest
233    /// (excluding signatures) for cryptographic signing.
234    pub fn canonical_hash(&self) -> Result<[u8; 32]> {
235        // Create a copy without signatures
236        let mut manifest_copy = self.clone();
237        manifest_copy.signatures.clear();
238
239        // Serialize to JSON with sorted keys (serde_json does this by default)
240        let json = serde_json::to_vec(&manifest_copy)?;
241
242        // Hash the JSON
243        Ok(Sha256::digest(&json).into())
244    }
245
246    /// Sign the manifest with a signing key
247    pub fn sign(&mut self, signing_key: &SigningKey, signer: Option<String>) -> Result<()> {
248        let hash = self.canonical_hash()?;
249        let signature = signing_key.sign(&hash);
250
251        let public_key = signing_key.verifying_key();
252
253        self.signatures.push(SignatureEntry {
254            algorithm: "ed25519".to_string(),
255            public_key: hex::encode(public_key.to_bytes()),
256            signature: hex::encode(signature.to_bytes()),
257            timestamp: std::time::SystemTime::now()
258                .duration_since(std::time::UNIX_EPOCH)
259                .unwrap()
260                .as_secs(),
261            signer,
262        });
263
264        Ok(())
265    }
266
267    /// Verify all signatures in the manifest
268    pub fn verify_signatures(&self) -> Result<Vec<bool>> {
269        let mut results = Vec::new();
270        let hash = self.canonical_hash()?;
271
272        for sig_entry in &self.signatures {
273            let result = self.verify_signature_entry(sig_entry, &hash);
274            results.push(result.is_ok());
275        }
276
277        Ok(results)
278    }
279
280    /// Verify a single signature entry
281    fn verify_signature_entry(&self, entry: &SignatureEntry, hash: &[u8; 32]) -> Result<()> {
282        if entry.algorithm != "ed25519" {
283            return Err(EngramError::InvalidSignature);
284        }
285
286        // Decode public key
287        let public_key_bytes =
288            hex::decode(&entry.public_key).map_err(|_| EngramError::InvalidPublicKey)?;
289        let public_key_array: [u8; 32] = public_key_bytes
290            .try_into()
291            .map_err(|_| EngramError::InvalidPublicKey)?;
292        let public_key = VerifyingKey::from_bytes(&public_key_array)?;
293
294        // Decode signature
295        let signature_bytes =
296            hex::decode(&entry.signature).map_err(|_| EngramError::InvalidSignature)?;
297        let signature_array: [u8; 64] = signature_bytes
298            .try_into()
299            .map_err(|_| EngramError::InvalidSignature)?;
300        let signature = Signature::from_bytes(&signature_array);
301
302        // Verify
303        public_key.verify(hash, &signature)?;
304
305        Ok(())
306    }
307
308    /// Check if all signatures are valid
309    pub fn is_fully_signed(&self) -> Result<bool> {
310        if self.signatures.is_empty() {
311            return Ok(false);
312        }
313
314        let results = self.verify_signatures()?;
315        Ok(results.iter().all(|&valid| valid))
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use rand::rngs::OsRng;
323
324    #[test]
325    fn test_manifest_creation() {
326        let manifest = Manifest::new(
327            "test-engram".to_string(),
328            "Test Engram".to_string(),
329            Author {
330                name: "Test Author".to_string(),
331                email: Some("test@example.com".to_string()),
332                url: None,
333            },
334            "0.1.0".to_string(),
335        );
336
337        assert_eq!(manifest.version, "0.4.0");
338        assert_eq!(manifest.id, "test-engram");
339        assert_eq!(manifest.author.name, "Test Author");
340    }
341
342    #[test]
343    fn test_add_file() {
344        let mut manifest = Manifest::new(
345            "test".to_string(),
346            "Test".to_string(),
347            Author {
348                name: "Test".to_string(),
349                email: None,
350                url: None,
351            },
352            "0.1.0".to_string(),
353        );
354
355        let data = b"Hello, World!";
356        manifest.add_file("test.txt".to_string(), data, Some("text/plain".to_string()));
357
358        assert_eq!(manifest.files.len(), 1);
359        assert_eq!(manifest.files[0].path, "test.txt");
360        assert_eq!(manifest.files[0].size, 13);
361    }
362
363    #[test]
364    fn test_signature_roundtrip() {
365        let mut manifest = Manifest::new(
366            "test".to_string(),
367            "Test".to_string(),
368            Author {
369                name: "Test".to_string(),
370                email: None,
371                url: None,
372            },
373            "0.1.0".to_string(),
374        );
375
376        // Generate a key pair
377        let mut csprng = OsRng;
378        let signing_key = SigningKey::generate(&mut csprng);
379
380        // Sign the manifest
381        manifest
382            .sign(&signing_key, Some("Test Signer".to_string()))
383            .unwrap();
384
385        assert_eq!(manifest.signatures.len(), 1);
386        assert_eq!(manifest.signatures[0].algorithm, "ed25519");
387
388        // Verify signatures
389        let results = manifest.verify_signatures().unwrap();
390        assert_eq!(results.len(), 1);
391        assert!(results[0]);
392
393        assert!(manifest.is_fully_signed().unwrap());
394    }
395
396    #[test]
397    fn test_json_roundtrip() {
398        let manifest = Manifest::new(
399            "test".to_string(),
400            "Test".to_string(),
401            Author {
402                name: "Test".to_string(),
403                email: None,
404                url: None,
405            },
406            "0.1.0".to_string(),
407        );
408
409        let json = manifest.to_json().unwrap();
410        let parsed = Manifest::from_json(&json).unwrap();
411
412        assert_eq!(parsed.id, manifest.id);
413        assert_eq!(parsed.name, manifest.name);
414    }
415}