Skip to main content

lds_pack/
create.rs

1//! Archive creation: classify the project, then write `pack.toml` followed by
2//! the payload into a zstd-compressed tar stream.
3//!
4//! The `.git` directory is copied wholesale rather than converted to a
5//! `git bundle`. A bundle is built from an explicit set of refs, so everything
6//! outside that set — stashes, the reflog, dangling objects a rebase left
7//! behind — silently does not make the trip. Copying the directory removes the
8//! question entirely: whatever git had, the pack has.
9
10use std::fs::File;
11use std::io::{self, Write};
12use std::path::{Path, PathBuf};
13
14use crate::error::PackError;
15use crate::manifest::{MANIFEST_NAME, Manifest, PACK_FORMAT_VERSION, Stats};
16use crate::rules::PackRules;
17use crate::scan::{self, Entry, EntryKind, Scan};
18
19/// Directory prefix under which payload entries are stored inside the archive.
20///
21/// Keeping the payload under its own prefix means a project that happens to
22/// contain a top-level `pack.toml` cannot collide with the manifest.
23pub const PAYLOAD_PREFIX: &str = "payload";
24
25/// Default zstd compression level.
26///
27/// Level 3 is zstd's own default and sits at the knee of the curve: most of the
28/// ratio, a small fraction of the time of the high levels. A pack is dominated
29/// by already-compressed git objects, so spending longer buys little.
30pub const DEFAULT_COMPRESSION_LEVEL: i32 = 3;
31
32/// Inputs for [`create`].
33#[derive(Debug, Clone)]
34pub struct CreateOptions {
35    /// Project root to pack.
36    pub root: PathBuf,
37    /// Destination archive path.
38    pub out: PathBuf,
39    /// Version string recorded in the manifest.
40    pub lds_version: String,
41    /// zstd compression level.
42    pub compression_level: i32,
43    /// Which names count as secrets and caches.
44    pub rules: PackRules,
45    /// Classify and build the manifest, but write no archive.
46    ///
47    /// The point of a dry run is to answer "what would travel, and what would
48    /// be left behind?" — particularly after editing `[pack]` in config —
49    /// without producing a file that then has to be cleaned up.
50    pub dry_run: bool,
51}
52
53impl CreateOptions {
54    /// Build options with the default compression level and rules.
55    pub fn new(root: impl Into<PathBuf>, out: impl Into<PathBuf>, lds_version: &str) -> Self {
56        Self {
57            root: root.into(),
58            out: out.into(),
59            lds_version: lds_version.to_string(),
60            compression_level: DEFAULT_COMPRESSION_LEVEL,
61            rules: PackRules::default(),
62            dry_run: false,
63        }
64    }
65}
66
67/// What [`create`] produced.
68#[derive(Debug, Clone)]
69pub struct CreateReport {
70    /// Manifest embedded in the archive (or that would have been, on a dry run).
71    pub manifest: Manifest,
72    /// Path of the written archive, or where one would have been written.
73    pub out_path: PathBuf,
74    /// Size of the archive on disk, after compression. `0` on a dry run.
75    pub compressed_bytes: u64,
76    /// Whether this was a dry run, in which case nothing was written.
77    pub dry_run: bool,
78}
79
80/// Pack a project root into a single archive.
81///
82/// # Arguments
83///
84/// * `opts` — Source root, destination path, and compression level.
85///
86/// # Returns
87///
88/// A [`CreateReport`] carrying the manifest — including everything that was
89/// deliberately skipped — so the caller can report it without re-reading the
90/// archive.
91///
92/// # Errors
93///
94/// - [`PackError::NotADirectory`] if the root is not a directory.
95/// - [`PackError::Io`] on any read or write failure.
96/// - [`PackError::ManifestSerialize`] if the manifest cannot be serialized.
97pub fn create(opts: &CreateOptions) -> Result<CreateReport, PackError> {
98    let root = canonical_or_self(&opts.root);
99    let scanned = scan::scan_with(&root, &opts.rules)?;
100
101    let manifest = build_manifest(&root, &scanned, &opts.lds_version);
102    let manifest_toml = manifest.to_toml()?;
103
104    if opts.dry_run {
105        // Everything the caller needs to decide is in the manifest; stop
106        // before the first byte is written.
107        return Ok(CreateReport {
108            manifest,
109            out_path: opts.out.clone(),
110            compressed_bytes: 0,
111            dry_run: true,
112        });
113    }
114
115    if let Some(parent) = opts.out.parent()
116        && !parent.as_os_str().is_empty()
117    {
118        std::fs::create_dir_all(parent)?;
119    }
120
121    let file = File::create(&opts.out)?;
122    let encoder = zstd::stream::Encoder::new(file, opts.compression_level)?;
123    let mut builder = tar::Builder::new(encoder);
124    // Symlinks are stored as links. Following them would pull whole unrelated
125    // trees into the archive along with whatever they happen to contain.
126    builder.follow_symlinks(false);
127
128    write_manifest_entry(&mut builder, &manifest_toml)?;
129    for entry in &scanned.entries {
130        write_entry(&mut builder, entry)?;
131    }
132
133    let encoder = builder.into_inner()?;
134    let mut file = encoder.finish()?;
135    file.flush()?;
136
137    let compressed_bytes = file.metadata().map(|m| m.len()).unwrap_or(0);
138
139    Ok(CreateReport {
140        manifest,
141        out_path: opts.out.clone(),
142        compressed_bytes,
143        dry_run: false,
144    })
145}
146
147/// Assemble the manifest from a completed scan.
148fn build_manifest(root: &Path, scanned: &Scan, lds_version: &str) -> Manifest {
149    let project_name = root
150        .file_name()
151        .map(|n| n.to_string_lossy().into_owned())
152        .unwrap_or_else(|| "project".to_string());
153
154    Manifest {
155        format_version: PACK_FORMAT_VERSION,
156        created_at: chrono::Utc::now().to_rfc3339(),
157        source_root: root.to_string_lossy().into_owned(),
158        project_name,
159        lds_version: lds_version.to_string(),
160        stats: Stats {
161            file_count: scanned.file_count(),
162            symlink_count: scanned.symlink_count(),
163            total_bytes: scanned.total_bytes(),
164        },
165        no_link_report_applied: scanned.no_link_report_applied.clone(),
166        kept_over_secret: scanned.kept_over_secret.clone(),
167        skipped_noise: scanned.skipped_noise.clone(),
168        skipped_cache: scanned.skipped_cache.clone(),
169        skipped_secret: scanned.skipped_secret.clone(),
170        symlinks: scanned.symlinks.clone(),
171        worktrees: scanned.worktrees.clone(),
172        worktree_of: scanned.worktree_of.clone(),
173    }
174}
175
176/// Write `pack.toml` as the archive's first entry so it can be read without
177/// decompressing the payload.
178fn write_manifest_entry<W: Write>(
179    builder: &mut tar::Builder<W>,
180    manifest_toml: &str,
181) -> Result<(), PackError> {
182    let bytes = manifest_toml.as_bytes();
183    let mut header = tar::Header::new_gnu();
184    header.set_size(bytes.len() as u64);
185    header.set_mode(0o644);
186    header.set_mtime(now_epoch_secs());
187    header.set_entry_type(tar::EntryType::Regular);
188    header.set_cksum();
189    builder.append_data(&mut header, MANIFEST_NAME, bytes)?;
190    Ok(())
191}
192
193/// Write one scanned entry into the archive under the payload prefix.
194fn write_entry<W: Write>(builder: &mut tar::Builder<W>, entry: &Entry) -> Result<(), PackError> {
195    let archive_path = format!("{PAYLOAD_PREFIX}/{}", entry.rel);
196
197    match entry.kind {
198        EntryKind::Dir => {
199            builder.append_dir(&archive_path, &entry.abs)?;
200        }
201        EntryKind::File => {
202            // A file can vanish between the scan and the write (a build running
203            // in parallel, an editor swapping a temp file). Skip it rather than
204            // abandoning the whole pack.
205            match File::open(&entry.abs) {
206                Ok(mut f) => builder.append_file(&archive_path, &mut f)?,
207                Err(e) if e.kind() == io::ErrorKind::NotFound => {
208                    tracing::warn!("file vanished during pack, skipping: {}", entry.rel);
209                }
210                Err(e) => return Err(PackError::Io(e)),
211            }
212        }
213        EntryKind::Symlink => {
214            let target = std::fs::read_link(&entry.abs)?;
215            let mut header = tar::Header::new_gnu();
216            header.set_size(0);
217            header.set_mode(0o777);
218            header.set_mtime(now_epoch_secs());
219            header.set_entry_type(tar::EntryType::Symlink);
220            builder.append_link(&mut header, &archive_path, &target)?;
221        }
222    }
223
224    Ok(())
225}
226
227/// Seconds since the Unix epoch, saturating at 0 before it.
228fn now_epoch_secs() -> u64 {
229    std::time::SystemTime::now()
230        .duration_since(std::time::UNIX_EPOCH)
231        .map(|d| d.as_secs())
232        .unwrap_or(0)
233}
234
235/// Canonicalize a path, falling back to the input when it cannot be resolved.
236fn canonical_or_self(path: &Path) -> PathBuf {
237    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::inspect;
244    use std::fs;
245    use tempfile::TempDir;
246
247    fn touch(path: &Path, body: &str) {
248        if let Some(parent) = path.parent() {
249            fs::create_dir_all(parent).expect("mkdir");
250        }
251        fs::write(path, body).expect("write");
252    }
253
254    fn sample_project(root: &Path) {
255        touch(&root.join("src/main.rs"), "fn main() {}");
256        touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
257        touch(&root.join("workspace/journal.md"), "# journal\n");
258        touch(&root.join(".env"), "SECRET=1\n");
259        touch(&root.join("target/debug/app"), "binary");
260    }
261
262    /// A created pack exists, is non-empty, and its manifest is readable
263    /// without unpacking the payload.
264    #[test]
265    fn test_create_writes_readable_archive() {
266        let dir = TempDir::new().expect("tempdir");
267        let root = dir.path().join("proj");
268        fs::create_dir_all(&root).expect("mkdir");
269        sample_project(&root);
270
271        let out = dir.path().join("out/proj.pack");
272        let report = create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
273
274        assert!(out.is_file(), "archive should exist");
275        assert!(report.compressed_bytes > 0);
276        assert_eq!(report.manifest.project_name, "proj");
277        assert_eq!(report.manifest.format_version, PACK_FORMAT_VERSION);
278
279        let read_back = inspect::inspect(&out).expect("inspect");
280        assert_eq!(read_back.project_name, "proj");
281        assert_eq!(read_back.stats.file_count, report.manifest.stats.file_count);
282    }
283
284    /// Secrets are reported in the manifest and absent from the payload.
285    #[test]
286    fn test_create_reports_but_omits_secrets() {
287        let dir = TempDir::new().expect("tempdir");
288        let root = dir.path().join("proj");
289        fs::create_dir_all(&root).expect("mkdir");
290        sample_project(&root);
291
292        let out = dir.path().join("proj.pack");
293        let report = create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
294
295        assert!(
296            report
297                .manifest
298                .skipped_secret
299                .iter()
300                .any(|s| s.path == ".env"),
301            "secret must be reported"
302        );
303
304        let names = crate::inspect::list_payload_paths(&out).expect("list");
305        assert!(
306            !names.iter().any(|n| n == ".env"),
307            "secret must not be in the payload"
308        );
309        assert!(names.iter().any(|n| n == "src/main.rs"));
310        assert!(names.iter().any(|n| n == ".git/HEAD"));
311        assert!(!names.iter().any(|n| n.starts_with("target/")));
312    }
313
314    /// The destination's parent directory is created when absent.
315    #[test]
316    fn test_create_makes_parent_directory() {
317        let dir = TempDir::new().expect("tempdir");
318        let root = dir.path().join("proj");
319        fs::create_dir_all(&root).expect("mkdir");
320        touch(&root.join("a.txt"), "a");
321
322        let out = dir.path().join("deeply/nested/proj.pack");
323        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
324        assert!(out.is_file());
325    }
326
327    /// A dry run classifies everything but writes no archive.
328    #[test]
329    fn test_dry_run_writes_nothing() {
330        let dir = TempDir::new().expect("tempdir");
331        let root = dir.path().join("proj");
332        fs::create_dir_all(&root).expect("mkdir");
333        sample_project(&root);
334
335        let out = dir.path().join("would-be.pack");
336        let mut opts = CreateOptions::new(&root, &out, "0.13.3");
337        opts.dry_run = true;
338
339        let report = create(&opts).expect("dry run should succeed");
340
341        assert!(report.dry_run);
342        assert_eq!(report.compressed_bytes, 0);
343        assert!(!out.exists(), "dry run must not write an archive");
344        // The classification is still complete, which is the point of asking.
345        assert!(report.manifest.stats.file_count > 0);
346        assert!(
347            report
348                .manifest
349                .skipped_secret
350                .iter()
351                .any(|s| s.path == ".env"),
352            "a dry run still reports what would be left behind"
353        );
354    }
355
356    /// A project-specific secret name declared in config is honored end to end.
357    #[test]
358    fn test_custom_secret_glob_reaches_the_archive() {
359        let dir = TempDir::new().expect("tempdir");
360        let root = dir.path().join("proj");
361        fs::create_dir_all(&root).expect("mkdir");
362        touch(&root.join("keep-me.txt"), "content");
363        touch(&root.join("my-app-keys.json"), "SECRET");
364        touch(&root.join("prod.vault"), "SECRET");
365
366        let out = dir.path().join("proj.pack");
367        let mut opts = CreateOptions::new(&root, &out, "0.13.3");
368        opts.rules = crate::rules::PackRules::new(&crate::rules::RuleOverrides {
369            secret_globs: vec!["my-app-keys.json".to_string(), "*.vault".to_string()],
370            ..Default::default()
371        })
372        .expect("rules compile");
373
374        let report = create(&opts).expect("create");
375
376        let skipped: Vec<&str> = report
377            .manifest
378            .skipped_secret
379            .iter()
380            .map(|s| s.path.as_str())
381            .collect();
382        assert!(skipped.contains(&"my-app-keys.json"));
383        assert!(skipped.contains(&"prod.vault"));
384
385        let payload = crate::inspect::list_payload_paths(&out).expect("list");
386        assert!(payload.iter().any(|p| p == "keep-me.txt"));
387        assert!(!payload.iter().any(|p| p == "my-app-keys.json"));
388        assert!(!payload.iter().any(|p| p == "prod.vault"));
389    }
390
391    /// `keep` overrides a built-in exclusion end to end.
392    #[test]
393    fn test_keep_carries_a_builtin_secret() {
394        let dir = TempDir::new().expect("tempdir");
395        let root = dir.path().join("proj");
396        fs::create_dir_all(&root).expect("mkdir");
397        touch(&root.join(".npmrc"), "registry=...");
398
399        let out = dir.path().join("proj.pack");
400        let mut opts = CreateOptions::new(&root, &out, "0.13.3");
401        opts.rules = crate::rules::PackRules::new(&crate::rules::RuleOverrides {
402            keep: vec![".npmrc".to_string()],
403            ..Default::default()
404        })
405        .expect("rules compile");
406
407        let report = create(&opts).expect("create");
408        assert!(
409            report.manifest.skipped_secret.is_empty(),
410            "keep must remove it from the skip list"
411        );
412        let payload = crate::inspect::list_payload_paths(&out).expect("list");
413        assert!(payload.iter().any(|p| p == ".npmrc"));
414    }
415
416    /// An extra cache directory declared in config is pruned and recorded.
417    #[test]
418    fn test_custom_cache_dir_is_pruned() {
419        let dir = TempDir::new().expect("tempdir");
420        let root = dir.path().join("proj");
421        fs::create_dir_all(&root).expect("mkdir");
422        touch(&root.join("src/main.rs"), "fn main() {}");
423        touch(&root.join("dist/bundle.js"), "built");
424
425        let out = dir.path().join("proj.pack");
426        let mut opts = CreateOptions::new(&root, &out, "0.13.3");
427        opts.rules = crate::rules::PackRules::new(&crate::rules::RuleOverrides {
428            cache_dirs: vec!["dist".to_string()],
429            ..Default::default()
430        })
431        .expect("rules compile");
432
433        let report = create(&opts).expect("create");
434        assert!(
435            report
436                .manifest
437                .skipped_cache
438                .iter()
439                .any(|c| c.path == "dist"),
440            "custom cache must be recorded"
441        );
442        let payload = crate::inspect::list_payload_paths(&out).expect("list");
443        assert!(!payload.iter().any(|p| p.starts_with("dist")));
444        assert!(payload.iter().any(|p| p == "src/main.rs"));
445    }
446
447    /// Without that config, `dist/` is packed — it is source in many projects.
448    #[test]
449    fn test_dist_is_packed_by_default() {
450        let dir = TempDir::new().expect("tempdir");
451        let root = dir.path().join("proj");
452        fs::create_dir_all(&root).expect("mkdir");
453        touch(&root.join("dist/hand-written.js"), "source");
454
455        let out = dir.path().join("proj.pack");
456        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
457
458        let payload = crate::inspect::list_payload_paths(&out).expect("list");
459        assert!(payload.iter().any(|p| p == "dist/hand-written.js"));
460    }
461
462    /// Packing a path that is not a directory is an error.
463    #[test]
464    fn test_create_rejects_non_directory() {
465        let dir = TempDir::new().expect("tempdir");
466        let file = dir.path().join("f.txt");
467        touch(&file, "x");
468        let out = dir.path().join("o.pack");
469        assert!(matches!(
470            create(&CreateOptions::new(&file, &out, "0.13.3")),
471            Err(PackError::NotADirectory(_))
472        ));
473    }
474}