Skip to main content

shiplog_bundle/
lib.rs

1//! Bundle writer for shiplog run outputs.
2//!
3//! Generates `bundle.manifest.json` (file checksums + sizes) and builds
4//! profile-scoped zip archives for `internal`, `manager`, and `public` handoff.
5
6use 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
23/// Files excluded from bundles regardless of profile. `redaction.aliases.json`
24/// contains plaintext-to-alias mappings that would defeat redaction.
25/// `bundle.manifest.json` is excluded because it is written *after*
26/// the file walk and must not checksum itself.
27const ALWAYS_EXCLUDED: &[&str] = &[FILE_REDACTION_ALIASES_JSON, FILE_BUNDLE_MANIFEST_JSON];
28
29/// Decide whether `rel_path` (forward-slash normalised, relative to the run
30/// directory) should be included in a bundle for the given profile.
31fn 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
45/// Write `bundle.manifest.json` containing SHA-256 checksums for all files
46/// included in the given profile scope.
47///
48/// # Examples
49///
50/// ```rust,no_run
51/// use shiplog_bundle::write_bundle_manifest;
52/// use shiplog_ids::RunId;
53/// use shiplog_schema::bundle::BundleProfile;
54/// use std::path::Path;
55///
56/// let manifest = write_bundle_manifest(
57///     Path::new("./out/run_123"),
58///     &RunId::now("example"),
59///     &BundleProfile::Internal,
60/// )?;
61/// println!("Bundled {} files", manifest.files.len());
62/// # Ok::<(), anyhow::Error>(())
63/// ```
64pub 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
102/// Write a profile-scoped zip archive from the run directory.
103///
104/// # Examples
105///
106/// ```rust,no_run
107/// use shiplog_bundle::write_zip;
108/// use shiplog_schema::bundle::BundleProfile;
109/// use std::path::Path;
110///
111/// write_zip(
112///     Path::new("./out/run_123"),
113///     Path::new("./out/run_123.zip"),
114///     &BundleProfile::Internal,
115/// )?;
116/// # Ok::<(), anyhow::Error>(())
117/// ```
118pub 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                // Normalise backslashes to forward slashes for cross-platform matching
176                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    /// Helper: create a minimal run directory for testing.
198    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}