sherpack_core/
manifest.rs

1//! Package manifest for archive integrity verification
2//!
3//! The MANIFEST file is a text file included in every Sherpack archive that provides:
4//! - Package metadata (name, version, creation timestamp)
5//! - SHA256 checksums for all files
6//! - Overall archive digest for quick integrity verification
7
8use chrono::{DateTime, Utc};
9use semver::Version;
10use sha2::{Digest, Sha256};
11use std::collections::BTreeMap;
12use std::io::{BufReader, Read};
13use std::path::Path;
14
15use crate::error::{CoreError, Result};
16use crate::pack::LoadedPack;
17
18/// Current manifest format version
19pub const MANIFEST_VERSION: u32 = 1;
20
21/// A file entry in the manifest
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct FileEntry {
24    /// Relative path within the archive
25    pub path: String,
26    /// SHA256 hash of the file contents
27    pub sha256: String,
28}
29
30/// Package manifest containing checksums and metadata
31#[derive(Debug, Clone)]
32pub struct Manifest {
33    /// Manifest format version
34    pub version: u32,
35    /// Pack name
36    pub name: String,
37    /// Pack version
38    pub pack_version: Version,
39    /// Creation timestamp
40    pub created: DateTime<Utc>,
41    /// Files and their checksums (sorted by path)
42    pub files: Vec<FileEntry>,
43    /// Overall digest of all file checksums
44    pub digest: String,
45}
46
47impl std::fmt::Display for Manifest {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        // Header section
50        writeln!(f, "sherpack-manifest-version: {}", self.version)?;
51        writeln!(f, "name: {}", self.name)?;
52        writeln!(f, "version: {}", self.pack_version)?;
53        writeln!(f, "created: {}", self.created.to_rfc3339())?;
54        writeln!(f)?;
55
56        // Files section
57        writeln!(f, "[files]")?;
58        for entry in &self.files {
59            writeln!(f, "{} sha256:{}", entry.path, entry.sha256)?;
60        }
61        writeln!(f)?;
62
63        // Digest section
64        writeln!(f, "[digest]")?;
65        write!(f, "sha256:{}", self.digest)
66    }
67}
68
69impl Manifest {
70    /// Generate a manifest from a loaded pack
71    pub fn generate(pack: &LoadedPack) -> Result<Self> {
72        let mut files = BTreeMap::new();
73
74        // Add Pack.yaml
75        let pack_yaml_path = pack.root.join("Pack.yaml");
76        if pack_yaml_path.exists() {
77            let hash = hash_file(&pack_yaml_path)?;
78            files.insert("Pack.yaml".to_string(), hash);
79        }
80
81        // Add values.yaml
82        if pack.values_path.exists() {
83            let hash = hash_file(&pack.values_path)?;
84            files.insert("values.yaml".to_string(), hash);
85        }
86
87        // Add schema file if present
88        if let Some(schema_path) = &pack.schema_path
89            && schema_path.exists()
90        {
91            let hash = hash_file(schema_path)?;
92            let rel_path = schema_path
93                .file_name()
94                .map(|n| n.to_string_lossy().to_string())
95                .unwrap_or_else(|| "values.schema.yaml".to_string());
96            files.insert(rel_path, hash);
97        }
98
99        // Add template files
100        let template_files = pack.template_files()?;
101        for file_path in template_files {
102            let hash = hash_file(&file_path)?;
103            let rel_path = file_path
104                .strip_prefix(&pack.root)
105                .unwrap_or(&file_path)
106                .to_string_lossy()
107                // Normalize path separators for cross-platform compatibility
108                // Tar archives use forward slashes, so we must match that
109                .replace('\\', "/");
110            files.insert(rel_path, hash);
111        }
112
113        // Convert to FileEntry vec (already sorted by BTreeMap)
114        let file_entries: Vec<FileEntry> = files
115            .into_iter()
116            .map(|(path, sha256)| FileEntry { path, sha256 })
117            .collect();
118
119        // Calculate overall digest from all file hashes
120        let digest = calculate_digest(&file_entries);
121
122        Ok(Self {
123            version: MANIFEST_VERSION,
124            name: pack.pack.metadata.name.clone(),
125            pack_version: pack.pack.metadata.version.clone(),
126            created: Utc::now(),
127            files: file_entries,
128            digest,
129        })
130    }
131
132    /// Parse a manifest from its text representation
133    pub fn parse(content: &str) -> Result<Self> {
134        let mut version: Option<u32> = None;
135        let mut name: Option<String> = None;
136        let mut pack_version: Option<Version> = None;
137        let mut created: Option<DateTime<Utc>> = None;
138        let mut files = Vec::new();
139        let mut digest: Option<String> = None;
140
141        let mut in_files_section = false;
142        let mut in_digest_section = false;
143
144        for line in content.lines() {
145            let line = line.trim();
146
147            // Skip empty lines
148            if line.is_empty() {
149                continue;
150            }
151
152            // Section headers
153            if line == "[files]" {
154                in_files_section = true;
155                in_digest_section = false;
156                continue;
157            }
158            if line == "[digest]" {
159                in_files_section = false;
160                in_digest_section = true;
161                continue;
162            }
163
164            // Parse content based on section
165            if in_digest_section {
166                // Digest line: sha256:HASH
167                if let Some(hash) = line.strip_prefix("sha256:") {
168                    digest = Some(hash.to_string());
169                }
170            } else if in_files_section {
171                // File line: path sha256:HASH
172                if let Some((path, hash_part)) = line.rsplit_once(' ')
173                    && let Some(hash) = hash_part.strip_prefix("sha256:")
174                {
175                    files.push(FileEntry {
176                        path: path.to_string(),
177                        sha256: hash.to_string(),
178                    });
179                }
180            } else {
181                // Header section: key: value
182                if let Some((key, value)) = line.split_once(':') {
183                    let key = key.trim();
184                    let value = value.trim();
185
186                    match key {
187                        "sherpack-manifest-version" => {
188                            version = value.parse().ok();
189                        }
190                        "name" => {
191                            name = Some(value.to_string());
192                        }
193                        "version" => {
194                            pack_version = Version::parse(value).ok();
195                        }
196                        "created" => {
197                            created = DateTime::parse_from_rfc3339(value)
198                                .ok()
199                                .map(|dt| dt.with_timezone(&Utc));
200                        }
201                        _ => {}
202                    }
203                }
204            }
205        }
206
207        // Validate required fields
208        let version = version.ok_or_else(|| CoreError::InvalidManifest {
209            message: "Missing sherpack-manifest-version".to_string(),
210        })?;
211
212        let name = name.ok_or_else(|| CoreError::InvalidManifest {
213            message: "Missing name".to_string(),
214        })?;
215
216        let pack_version = pack_version.ok_or_else(|| CoreError::InvalidManifest {
217            message: "Missing or invalid version".to_string(),
218        })?;
219
220        let created = created.ok_or_else(|| CoreError::InvalidManifest {
221            message: "Missing or invalid created timestamp".to_string(),
222        })?;
223
224        let digest = digest.ok_or_else(|| CoreError::InvalidManifest {
225            message: "Missing digest".to_string(),
226        })?;
227
228        Ok(Self {
229            version,
230            name,
231            pack_version,
232            created,
233            files,
234            digest,
235        })
236    }
237
238    /// Verify that all files match their checksums
239    ///
240    /// Takes a function that reads file content given a relative path
241    pub fn verify_files<F>(&self, read_file: F) -> Result<VerificationResult>
242    where
243        F: Fn(&str) -> std::io::Result<Vec<u8>>,
244    {
245        let mut result = VerificationResult {
246            valid: true,
247            mismatched: Vec::new(),
248            missing: Vec::new(),
249        };
250
251        for entry in &self.files {
252            match read_file(&entry.path) {
253                Ok(content) => {
254                    let actual_hash = hash_bytes(&content);
255                    if actual_hash != entry.sha256 {
256                        result.valid = false;
257                        result.mismatched.push(MismatchedFile {
258                            path: entry.path.clone(),
259                            expected: entry.sha256.clone(),
260                            actual: actual_hash,
261                        });
262                    }
263                }
264                Err(_) => {
265                    result.valid = false;
266                    result.missing.push(entry.path.clone());
267                }
268            }
269        }
270
271        // Verify overall digest
272        let expected_digest = calculate_digest(&self.files);
273        if expected_digest != self.digest {
274            result.valid = false;
275        }
276
277        Ok(result)
278    }
279}
280
281/// Result of manifest verification
282#[derive(Debug, Clone)]
283pub struct VerificationResult {
284    /// Whether all verifications passed
285    pub valid: bool,
286    /// Files with mismatched checksums
287    pub mismatched: Vec<MismatchedFile>,
288    /// Files that are missing
289    pub missing: Vec<String>,
290}
291
292/// A file with a mismatched checksum
293#[derive(Debug, Clone)]
294pub struct MismatchedFile {
295    /// File path
296    pub path: String,
297    /// Expected SHA256 from manifest
298    pub expected: String,
299    /// Actual SHA256 of file
300    pub actual: String,
301}
302
303/// Calculate SHA256 hash of a file
304fn hash_file(path: &Path) -> Result<String> {
305    let file = std::fs::File::open(path)?;
306    let mut reader = BufReader::new(file);
307    let mut hasher = Sha256::new();
308    let mut buffer = [0u8; 8192];
309
310    loop {
311        let bytes_read = reader.read(&mut buffer)?;
312        if bytes_read == 0 {
313            break;
314        }
315        hasher.update(&buffer[..bytes_read]);
316    }
317
318    Ok(hex::encode(hasher.finalize()))
319}
320
321/// Calculate SHA256 hash of bytes
322fn hash_bytes(data: &[u8]) -> String {
323    let mut hasher = Sha256::new();
324    hasher.update(data);
325    hex::encode(hasher.finalize())
326}
327
328/// Calculate overall digest from file entries
329fn calculate_digest(files: &[FileEntry]) -> String {
330    let mut hasher = Sha256::new();
331    for entry in files {
332        hasher.update(entry.path.as_bytes());
333        hasher.update(b":");
334        hasher.update(entry.sha256.as_bytes());
335        hasher.update(b"\n");
336    }
337    hex::encode(hasher.finalize())
338}
339
340// We need hex encoding - add it inline to avoid another dependency
341mod hex {
342    const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
343
344    pub fn encode<T: AsRef<[u8]>>(data: T) -> String {
345        let bytes = data.as_ref();
346        let mut hex = String::with_capacity(bytes.len() * 2);
347        for &byte in bytes {
348            hex.push(HEX_CHARS[(byte >> 4) as usize] as char);
349            hex.push(HEX_CHARS[(byte & 0x0f) as usize] as char);
350        }
351        hex
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_manifest_roundtrip() {
361        let manifest = Manifest {
362            version: 1,
363            name: "myapp".to_string(),
364            pack_version: Version::new(1, 2, 3),
365            created: Utc::now(),
366            files: vec![
367                FileEntry {
368                    path: "Pack.yaml".to_string(),
369                    sha256: "abc123".to_string(),
370                },
371                FileEntry {
372                    path: "values.yaml".to_string(),
373                    sha256: "def456".to_string(),
374                },
375            ],
376            digest: "overall789".to_string(),
377        };
378
379        let text = manifest.to_string();
380        let parsed = Manifest::parse(&text).unwrap();
381
382        assert_eq!(parsed.version, manifest.version);
383        assert_eq!(parsed.name, manifest.name);
384        assert_eq!(parsed.pack_version, manifest.pack_version);
385        assert_eq!(parsed.files.len(), manifest.files.len());
386        assert_eq!(parsed.digest, manifest.digest);
387    }
388
389    #[test]
390    fn test_manifest_parse() {
391        let content = r#"sherpack-manifest-version: 1
392name: testpack
393version: 2.0.0
394created: 2025-01-15T10:30:00Z
395
396[files]
397Pack.yaml sha256:abc123
398values.yaml sha256:def456
399
400[digest]
401sha256:789xyz
402"#;
403
404        let manifest = Manifest::parse(content).unwrap();
405        assert_eq!(manifest.version, 1);
406        assert_eq!(manifest.name, "testpack");
407        assert_eq!(manifest.pack_version, Version::new(2, 0, 0));
408        assert_eq!(manifest.files.len(), 2);
409        assert_eq!(manifest.files[0].path, "Pack.yaml");
410        assert_eq!(manifest.files[0].sha256, "abc123");
411        assert_eq!(manifest.digest, "789xyz");
412    }
413
414    #[test]
415    fn test_hash_bytes() {
416        let hash = hash_bytes(b"hello world");
417        assert_eq!(
418            hash,
419            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
420        );
421    }
422
423    #[test]
424    fn test_verification() {
425        let files = vec![FileEntry {
426            path: "test.txt".to_string(),
427            sha256: hash_bytes(b"content"),
428        }];
429        let digest = calculate_digest(&files);
430
431        let manifest = Manifest {
432            version: 1,
433            name: "test".to_string(),
434            pack_version: Version::new(1, 0, 0),
435            created: Utc::now(),
436            files,
437            digest,
438        };
439
440        // Verify with correct content
441        let result = manifest
442            .verify_files(|path| {
443                if path == "test.txt" {
444                    Ok(b"content".to_vec())
445                } else {
446                    Err(std::io::Error::new(
447                        std::io::ErrorKind::NotFound,
448                        "not found",
449                    ))
450                }
451            })
452            .unwrap();
453
454        assert!(result.valid);
455        assert!(result.mismatched.is_empty());
456        assert!(result.missing.is_empty());
457
458        // Verify with wrong content
459        let result = manifest
460            .verify_files(|path| {
461                if path == "test.txt" {
462                    Ok(b"wrong content".to_vec())
463                } else {
464                    Err(std::io::Error::new(
465                        std::io::ErrorKind::NotFound,
466                        "not found",
467                    ))
468                }
469            })
470            .unwrap();
471
472        assert!(!result.valid);
473        assert_eq!(result.mismatched.len(), 1);
474    }
475}