1use anyhow::{Context, Result};
7use chrono::Utc;
8use sha2::{Digest, Sha256};
9use shiplog_ids::RunId;
10use shiplog_schema::bundle::{BundleManifest, BundleProfile, FileChecksum};
11use std::fs::File;
12use std::io::{Read, Write};
13use std::path::{Path, PathBuf};
14
15pub mod layout;
16
17pub use layout::{
18 DIR_PROFILES, FILE_BUNDLE_MANIFEST_JSON, FILE_COVERAGE_MANIFEST_JSON, FILE_LEDGER_EVENTS_JSONL,
19 FILE_PACKET_MD, FILE_REDACTION_ALIASES_JSON, PROFILE_INTERNAL, PROFILE_MANAGER, PROFILE_PUBLIC,
20 RunArtifactPaths, zip_path_for_profile,
21};
22
23const ALWAYS_EXCLUDED: &[&str] = &[FILE_REDACTION_ALIASES_JSON, FILE_BUNDLE_MANIFEST_JSON];
28
29fn is_scoped_include(rel_path: &str, profile: &BundleProfile) -> bool {
32 match profile {
33 BundleProfile::Internal => true,
34 BundleProfile::Manager => {
35 rel_path == format!("{DIR_PROFILES}/{PROFILE_MANAGER}/{FILE_PACKET_MD}")
36 || rel_path == FILE_COVERAGE_MANIFEST_JSON
37 }
38 BundleProfile::Public => {
39 rel_path == format!("{DIR_PROFILES}/{PROFILE_PUBLIC}/{FILE_PACKET_MD}")
40 || rel_path == FILE_COVERAGE_MANIFEST_JSON
41 }
42 }
43}
44
45pub fn write_bundle_manifest(
65 out_dir: &Path,
66 run_id: &RunId,
67 profile: &BundleProfile,
68) -> Result<BundleManifest> {
69 let mut files = Vec::new();
70
71 for path in walk_files(out_dir, profile)? {
72 let bytes = std::fs::metadata(&path)
73 .with_context(|| format!("read metadata for {path:?}"))?
74 .len();
75 let sha256 = sha256_file(&path)?;
76 let rel = path
77 .strip_prefix(out_dir)
78 .unwrap_or(&path)
79 .to_string_lossy()
80 .replace('\\', "/");
81
82 files.push(FileChecksum {
83 path: rel,
84 sha256,
85 bytes,
86 });
87 }
88
89 let manifest = BundleManifest {
90 run_id: run_id.clone(),
91 generated_at: Utc::now(),
92 profile: profile.clone(),
93 files,
94 };
95
96 let text = serde_json::to_string_pretty(&manifest).context("serialize bundle manifest")?;
97 std::fs::write(out_dir.join(FILE_BUNDLE_MANIFEST_JSON), text)
98 .context("write bundle.manifest.json")?;
99 Ok(manifest)
100}
101
102pub fn write_zip(out_dir: &Path, zip_path: &Path, profile: &BundleProfile) -> Result<()> {
119 let file = File::create(zip_path).with_context(|| format!("create zip {zip_path:?}"))?;
120 let mut zip = zip::ZipWriter::new(file);
121 let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default()
122 .compression_method(zip::CompressionMethod::Deflated)
123 .unix_permissions(0o644);
124 let zip_target = zip_path
125 .canonicalize()
126 .unwrap_or_else(|_| zip_path.to_path_buf());
127
128 for path in walk_files(out_dir, profile)? {
129 let source = path.canonicalize().unwrap_or_else(|_| path.clone());
130 if source == zip_target {
131 continue;
132 }
133
134 let rel = path
135 .strip_prefix(out_dir)
136 .unwrap_or(&path)
137 .to_string_lossy()
138 .replace('\\', "/");
139
140 zip.start_file(rel, opts).context("start zip entry")?;
141 let mut f = File::open(&path).with_context(|| format!("open {path:?} for zip"))?;
142 let mut buf = Vec::new();
143 f.read_to_end(&mut buf)
144 .with_context(|| format!("read {path:?}"))?;
145 zip.write_all(&buf).context("write zip entry")?;
146 }
147
148 zip.finish().context("finalize zip archive")?;
149 Ok(())
150}
151
152fn sha256_file(path: &Path) -> Result<String> {
153 let mut f = File::open(path).with_context(|| format!("open {path:?} for hashing"))?;
154 let mut h = Sha256::new();
155 let mut bytes = Vec::new();
156 f.read_to_end(&mut bytes)
157 .with_context(|| format!("read {path:?}"))?;
158 h.update(&bytes);
159 Ok(hex::encode(h.finalize()))
160}
161
162fn walk_files(root: &Path, profile: &BundleProfile) -> Result<Vec<PathBuf>> {
163 let mut out = Vec::new();
164 let mut stack = vec![root.to_path_buf()];
165 while let Some(p) = stack.pop() {
166 for entry in std::fs::read_dir(&p).with_context(|| format!("read directory {p:?}"))? {
167 let entry = entry.with_context(|| format!("read entry in {p:?}"))?;
168 let path = entry.path();
169 if path.is_dir() {
170 stack.push(path);
171 } else if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
172 if ALWAYS_EXCLUDED.contains(&name) {
173 continue;
174 }
175 let rel = path
177 .strip_prefix(root)
178 .unwrap_or(&path)
179 .to_string_lossy()
180 .replace('\\', "/");
181 if is_scoped_include(&rel, profile) {
182 out.push(path);
183 }
184 } else {
185 out.push(path);
186 }
187 }
188 }
189 out.sort();
190 Ok(out)
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn make_test_dir(dir: &Path) {
199 std::fs::write(dir.join(FILE_PACKET_MD), "# Packet").unwrap();
200 std::fs::write(dir.join(FILE_LEDGER_EVENTS_JSONL), "").unwrap();
201 std::fs::write(dir.join(FILE_COVERAGE_MANIFEST_JSON), "{}").unwrap();
202 std::fs::write(
203 dir.join(FILE_REDACTION_ALIASES_JSON),
204 r#"{"version":1,"entries":{}}"#,
205 )
206 .unwrap();
207
208 let mgr = dir.join(DIR_PROFILES).join(PROFILE_MANAGER);
209 std::fs::create_dir_all(&mgr).unwrap();
210 std::fs::write(mgr.join(FILE_PACKET_MD), "# Manager").unwrap();
211
212 let pub_dir = dir.join(DIR_PROFILES).join(PROFILE_PUBLIC);
213 std::fs::create_dir_all(&pub_dir).unwrap();
214 std::fs::write(pub_dir.join(FILE_PACKET_MD), "# Public").unwrap();
215 }
216
217 fn file_names(files: &[PathBuf]) -> Vec<String> {
218 files
219 .iter()
220 .filter_map(|p| p.file_name().and_then(|s| s.to_str()).map(String::from))
221 .collect()
222 }
223
224 fn rel_paths(root: &Path, files: &[PathBuf]) -> Vec<String> {
225 files
226 .iter()
227 .map(|p| {
228 p.strip_prefix(root)
229 .unwrap_or(p)
230 .to_string_lossy()
231 .replace('\\', "/")
232 })
233 .collect()
234 }
235
236 #[test]
237 fn walk_files_excludes_redaction_aliases() {
238 let dir = tempfile::tempdir().unwrap();
239 std::fs::write(dir.path().join(FILE_PACKET_MD), "# Packet").unwrap();
240 std::fs::write(dir.path().join(FILE_REDACTION_ALIASES_JSON), "{}").unwrap();
241 std::fs::write(dir.path().join(FILE_LEDGER_EVENTS_JSONL), "").unwrap();
242
243 let files = walk_files(dir.path(), &BundleProfile::Internal).unwrap();
244 let names = file_names(&files);
245
246 assert!(names.contains(&FILE_PACKET_MD.to_string()));
247 assert!(names.contains(&FILE_LEDGER_EVENTS_JSONL.to_string()));
248 assert!(
249 !names.contains(&FILE_REDACTION_ALIASES_JSON.to_string()),
250 "redaction.aliases.json should be excluded from walk_files"
251 );
252 }
253
254 #[test]
255 fn bundle_manifest_excludes_redaction_aliases() {
256 let dir = tempfile::tempdir().unwrap();
257 std::fs::write(dir.path().join(FILE_PACKET_MD), "# Packet").unwrap();
258 std::fs::write(
259 dir.path().join(FILE_REDACTION_ALIASES_JSON),
260 r#"{"version":1,"entries":{}}"#,
261 )
262 .unwrap();
263 std::fs::write(dir.path().join(FILE_LEDGER_EVENTS_JSONL), "").unwrap();
264
265 let run_id = shiplog_ids::RunId::now("test");
266 let manifest =
267 write_bundle_manifest(dir.path(), &run_id, &BundleProfile::Internal).unwrap();
268 let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
269
270 assert!(
271 !paths
272 .iter()
273 .any(|p| p.contains(FILE_REDACTION_ALIASES_JSON)),
274 "redaction.aliases.json should not appear in bundle manifest"
275 );
276 assert!(
277 !paths.iter().any(|p| p.contains(FILE_BUNDLE_MANIFEST_JSON)),
278 "bundle.manifest.json should not appear in bundle manifest"
279 );
280 assert!(
281 paths.iter().any(|p| p.contains(FILE_PACKET_MD)),
282 "packet.md should appear in bundle manifest"
283 );
284 }
285
286 #[test]
287 fn zip_excludes_redaction_aliases() {
288 let dir = tempfile::tempdir().unwrap();
289 std::fs::write(dir.path().join(FILE_PACKET_MD), "# Packet").unwrap();
290 std::fs::write(
291 dir.path().join(FILE_REDACTION_ALIASES_JSON),
292 r#"{"version":1,"entries":{}}"#,
293 )
294 .unwrap();
295
296 let zip_path = dir.path().join("test.zip");
297 write_zip(dir.path(), &zip_path, &BundleProfile::Internal).unwrap();
298
299 let file = File::open(&zip_path).unwrap();
300 let archive = zip::ZipArchive::new(file).unwrap();
301 let names: Vec<String> = (0..archive.len())
302 .map(|i| archive.name_for_index(i).unwrap().to_string())
303 .collect();
304
305 assert!(
306 names.iter().any(|n| n.contains(FILE_PACKET_MD)),
307 "packet.md should be in zip"
308 );
309 assert!(
310 !names
311 .iter()
312 .any(|n| n.contains(FILE_REDACTION_ALIASES_JSON)),
313 "redaction.aliases.json should not be in zip"
314 );
315 }
316
317 #[test]
318 fn manager_profile_includes_only_manager_packet_and_coverage() {
319 let dir = tempfile::tempdir().unwrap();
320 make_test_dir(dir.path());
321
322 let files = walk_files(dir.path(), &BundleProfile::Manager).unwrap();
323 let rels = rel_paths(dir.path(), &files);
324
325 assert!(rels.contains(&FILE_COVERAGE_MANIFEST_JSON.to_string()));
326 assert!(rels.contains(&format!(
327 "{DIR_PROFILES}/{PROFILE_MANAGER}/{FILE_PACKET_MD}"
328 )));
329 assert!(!rels.contains(&FILE_PACKET_MD.to_string()));
330 assert!(!rels.contains(&FILE_LEDGER_EVENTS_JSONL.to_string()));
331 assert!(!rels.contains(&format!("{DIR_PROFILES}/{PROFILE_PUBLIC}/{FILE_PACKET_MD}")));
332 assert_eq!(rels.len(), 2);
333 }
334
335 #[test]
336 fn public_profile_includes_only_public_packet_and_coverage() {
337 let dir = tempfile::tempdir().unwrap();
338 make_test_dir(dir.path());
339
340 let files = walk_files(dir.path(), &BundleProfile::Public).unwrap();
341 let rels = rel_paths(dir.path(), &files);
342
343 assert!(rels.contains(&FILE_COVERAGE_MANIFEST_JSON.to_string()));
344 assert!(rels.contains(&format!("{DIR_PROFILES}/{PROFILE_PUBLIC}/{FILE_PACKET_MD}")));
345 assert!(!rels.contains(&FILE_PACKET_MD.to_string()));
346 assert!(!rels.contains(&format!(
347 "{DIR_PROFILES}/{PROFILE_MANAGER}/{FILE_PACKET_MD}"
348 )));
349 assert_eq!(rels.len(), 2);
350 }
351
352 #[test]
353 fn all_profiles_exclude_aliases() {
354 let dir = tempfile::tempdir().unwrap();
355 make_test_dir(dir.path());
356
357 for profile in [
358 BundleProfile::Internal,
359 BundleProfile::Manager,
360 BundleProfile::Public,
361 ] {
362 let files = walk_files(dir.path(), &profile).unwrap();
363 let names = file_names(&files);
364 assert!(
365 !names.contains(&FILE_REDACTION_ALIASES_JSON.to_string()),
366 "aliases leaked in {profile:?}"
367 );
368 }
369 }
370
371 #[test]
372 fn manifest_respects_profile() {
373 let dir = tempfile::tempdir().unwrap();
374 make_test_dir(dir.path());
375
376 let run_id = shiplog_ids::RunId::now("test");
377 let manifest = write_bundle_manifest(dir.path(), &run_id, &BundleProfile::Manager).unwrap();
378
379 assert_eq!(manifest.profile, BundleProfile::Manager);
380 assert_eq!(manifest.files.len(), 2);
381 }
382
383 #[test]
384 fn sha256_file_known_digest() {
385 let dir = tempfile::tempdir().unwrap();
386 let path = dir.path().join("hello.txt");
387 std::fs::write(&path, "hello world").unwrap();
388 let digest = sha256_file(&path).unwrap();
389 assert_eq!(
390 digest,
391 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
392 );
393 }
394
395 #[test]
396 fn sha256_file_empty_file() {
397 let dir = tempfile::tempdir().unwrap();
398 let path = dir.path().join("empty.txt");
399 std::fs::write(&path, "").unwrap();
400 let digest = sha256_file(&path).unwrap();
401 assert_eq!(
402 digest,
403 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
404 );
405 }
406
407 #[test]
408 fn zip_respects_profile() {
409 let dir = tempfile::tempdir().unwrap();
410 make_test_dir(dir.path());
411
412 let zip_path = dir.path().join("test.zip");
413 write_zip(dir.path(), &zip_path, &BundleProfile::Public).unwrap();
414
415 let file = File::open(&zip_path).unwrap();
416 let archive = zip::ZipArchive::new(file).unwrap();
417 let names: Vec<String> = (0..archive.len())
418 .map(|i| archive.name_for_index(i).unwrap().to_string())
419 .collect();
420
421 assert_eq!(names.len(), 2, "public zip should have exactly 2 files");
422 assert!(
423 names
424 .iter()
425 .any(|n| n.contains(&format!("{DIR_PROFILES}/{PROFILE_PUBLIC}/{FILE_PACKET_MD}")))
426 );
427 assert!(
428 names
429 .iter()
430 .any(|n| n.contains(FILE_COVERAGE_MANIFEST_JSON))
431 );
432 }
433}