1use 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
18pub const MANIFEST_VERSION: u32 = 1;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct FileEntry {
24 pub path: String,
26 pub sha256: String,
28}
29
30#[derive(Debug, Clone)]
32pub struct Manifest {
33 pub version: u32,
35 pub name: String,
37 pub pack_version: Version,
39 pub created: DateTime<Utc>,
41 pub files: Vec<FileEntry>,
43 pub digest: String,
45}
46
47impl std::fmt::Display for Manifest {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 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 writeln!(f, "[files]")?;
58 for entry in &self.files {
59 writeln!(f, "{} sha256:{}", entry.path, entry.sha256)?;
60 }
61 writeln!(f)?;
62
63 writeln!(f, "[digest]")?;
65 write!(f, "sha256:{}", self.digest)
66 }
67}
68
69impl Manifest {
70 pub fn generate(pack: &LoadedPack) -> Result<Self> {
72 let mut files = BTreeMap::new();
73
74 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 if pack.values_path.exists() {
83 let hash = hash_file(&pack.values_path)?;
84 files.insert("values.yaml".to_string(), hash);
85 }
86
87 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 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 .replace('\\', "/");
110 files.insert(rel_path, hash);
111 }
112
113 let file_entries: Vec<FileEntry> = files
115 .into_iter()
116 .map(|(path, sha256)| FileEntry { path, sha256 })
117 .collect();
118
119 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 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 if line.is_empty() {
149 continue;
150 }
151
152 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 if in_digest_section {
166 if let Some(hash) = line.strip_prefix("sha256:") {
168 digest = Some(hash.to_string());
169 }
170 } else if in_files_section {
171 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 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 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 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 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#[derive(Debug, Clone)]
283pub struct VerificationResult {
284 pub valid: bool,
286 pub mismatched: Vec<MismatchedFile>,
288 pub missing: Vec<String>,
290}
291
292#[derive(Debug, Clone)]
294pub struct MismatchedFile {
295 pub path: String,
297 pub expected: String,
299 pub actual: String,
301}
302
303fn 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
321fn hash_bytes(data: &[u8]) -> String {
323 let mut hasher = Sha256::new();
324 hasher.update(data);
325 hex::encode(hasher.finalize())
326}
327
328fn 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
340mod 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 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 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}