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        claude: scanned.claude.clone(),
166        skipped_cache: scanned.skipped_cache.clone(),
167        skipped_secret: scanned.skipped_secret.clone(),
168        symlinks: scanned.symlinks.clone(),
169        worktrees: scanned.worktrees.clone(),
170    }
171}
172
173/// Write `pack.toml` as the archive's first entry so it can be read without
174/// decompressing the payload.
175fn write_manifest_entry<W: Write>(
176    builder: &mut tar::Builder<W>,
177    manifest_toml: &str,
178) -> Result<(), PackError> {
179    let bytes = manifest_toml.as_bytes();
180    let mut header = tar::Header::new_gnu();
181    header.set_size(bytes.len() as u64);
182    header.set_mode(0o644);
183    header.set_mtime(now_epoch_secs());
184    header.set_entry_type(tar::EntryType::Regular);
185    header.set_cksum();
186    builder.append_data(&mut header, MANIFEST_NAME, bytes)?;
187    Ok(())
188}
189
190/// Write one scanned entry into the archive under the payload prefix.
191fn write_entry<W: Write>(builder: &mut tar::Builder<W>, entry: &Entry) -> Result<(), PackError> {
192    let archive_path = format!("{PAYLOAD_PREFIX}/{}", entry.rel);
193
194    match entry.kind {
195        EntryKind::Dir => {
196            builder.append_dir(&archive_path, &entry.abs)?;
197        }
198        EntryKind::File => {
199            // A file can vanish between the scan and the write (a build running
200            // in parallel, an editor swapping a temp file). Skip it rather than
201            // abandoning the whole pack.
202            match File::open(&entry.abs) {
203                Ok(mut f) => builder.append_file(&archive_path, &mut f)?,
204                Err(e) if e.kind() == io::ErrorKind::NotFound => {
205                    tracing::warn!("file vanished during pack, skipping: {}", entry.rel);
206                }
207                Err(e) => return Err(PackError::Io(e)),
208            }
209        }
210        EntryKind::Symlink => {
211            let target = std::fs::read_link(&entry.abs)?;
212            let mut header = tar::Header::new_gnu();
213            header.set_size(0);
214            header.set_mode(0o777);
215            header.set_mtime(now_epoch_secs());
216            header.set_entry_type(tar::EntryType::Symlink);
217            builder.append_link(&mut header, &archive_path, &target)?;
218        }
219    }
220
221    Ok(())
222}
223
224/// Seconds since the Unix epoch, saturating at 0 before it.
225fn now_epoch_secs() -> u64 {
226    std::time::SystemTime::now()
227        .duration_since(std::time::UNIX_EPOCH)
228        .map(|d| d.as_secs())
229        .unwrap_or(0)
230}
231
232/// Canonicalize a path, falling back to the input when it cannot be resolved.
233fn canonical_or_self(path: &Path) -> PathBuf {
234    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::inspect;
241    use std::fs;
242    use tempfile::TempDir;
243
244    fn touch(path: &Path, body: &str) {
245        if let Some(parent) = path.parent() {
246            fs::create_dir_all(parent).expect("mkdir");
247        }
248        fs::write(path, body).expect("write");
249    }
250
251    fn sample_project(root: &Path) {
252        touch(&root.join("src/main.rs"), "fn main() {}");
253        touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
254        touch(&root.join("workspace/journal.md"), "# journal\n");
255        touch(&root.join(".env"), "SECRET=1\n");
256        touch(&root.join("target/debug/app"), "binary");
257    }
258
259    /// A created pack exists, is non-empty, and its manifest is readable
260    /// without unpacking the payload.
261    #[test]
262    fn test_create_writes_readable_archive() {
263        let dir = TempDir::new().expect("tempdir");
264        let root = dir.path().join("proj");
265        fs::create_dir_all(&root).expect("mkdir");
266        sample_project(&root);
267
268        let out = dir.path().join("out/proj.pack");
269        let report = create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
270
271        assert!(out.is_file(), "archive should exist");
272        assert!(report.compressed_bytes > 0);
273        assert_eq!(report.manifest.project_name, "proj");
274        assert_eq!(report.manifest.format_version, PACK_FORMAT_VERSION);
275
276        let read_back = inspect::inspect(&out).expect("inspect");
277        assert_eq!(read_back.project_name, "proj");
278        assert_eq!(read_back.stats.file_count, report.manifest.stats.file_count);
279    }
280
281    /// Secrets are reported in the manifest and absent from the payload.
282    #[test]
283    fn test_create_reports_but_omits_secrets() {
284        let dir = TempDir::new().expect("tempdir");
285        let root = dir.path().join("proj");
286        fs::create_dir_all(&root).expect("mkdir");
287        sample_project(&root);
288
289        let out = dir.path().join("proj.pack");
290        let report = create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
291
292        assert!(
293            report
294                .manifest
295                .skipped_secret
296                .iter()
297                .any(|s| s.path == ".env"),
298            "secret must be reported"
299        );
300
301        let names = crate::inspect::list_payload_paths(&out).expect("list");
302        assert!(
303            !names.iter().any(|n| n == ".env"),
304            "secret must not be in the payload"
305        );
306        assert!(names.iter().any(|n| n == "src/main.rs"));
307        assert!(names.iter().any(|n| n == ".git/HEAD"));
308        assert!(!names.iter().any(|n| n.starts_with("target/")));
309    }
310
311    /// The destination's parent directory is created when absent.
312    #[test]
313    fn test_create_makes_parent_directory() {
314        let dir = TempDir::new().expect("tempdir");
315        let root = dir.path().join("proj");
316        fs::create_dir_all(&root).expect("mkdir");
317        touch(&root.join("a.txt"), "a");
318
319        let out = dir.path().join("deeply/nested/proj.pack");
320        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
321        assert!(out.is_file());
322    }
323
324    /// A dry run classifies everything but writes no archive.
325    #[test]
326    fn test_dry_run_writes_nothing() {
327        let dir = TempDir::new().expect("tempdir");
328        let root = dir.path().join("proj");
329        fs::create_dir_all(&root).expect("mkdir");
330        sample_project(&root);
331
332        let out = dir.path().join("would-be.pack");
333        let mut opts = CreateOptions::new(&root, &out, "0.13.3");
334        opts.dry_run = true;
335
336        let report = create(&opts).expect("dry run should succeed");
337
338        assert!(report.dry_run);
339        assert_eq!(report.compressed_bytes, 0);
340        assert!(!out.exists(), "dry run must not write an archive");
341        // The classification is still complete, which is the point of asking.
342        assert!(report.manifest.stats.file_count > 0);
343        assert!(
344            report
345                .manifest
346                .skipped_secret
347                .iter()
348                .any(|s| s.path == ".env"),
349            "a dry run still reports what would be left behind"
350        );
351    }
352
353    /// A project-specific secret name declared in config is honored end to end.
354    #[test]
355    fn test_custom_secret_glob_reaches_the_archive() {
356        let dir = TempDir::new().expect("tempdir");
357        let root = dir.path().join("proj");
358        fs::create_dir_all(&root).expect("mkdir");
359        touch(&root.join("keep-me.txt"), "content");
360        touch(&root.join("my-app-keys.json"), "SECRET");
361        touch(&root.join("prod.vault"), "SECRET");
362
363        let out = dir.path().join("proj.pack");
364        let mut opts = CreateOptions::new(&root, &out, "0.13.3");
365        opts.rules = crate::rules::PackRules::new(&crate::rules::RuleOverrides {
366            secret_globs: vec!["my-app-keys.json".to_string(), "*.vault".to_string()],
367            ..Default::default()
368        })
369        .expect("rules compile");
370
371        let report = create(&opts).expect("create");
372
373        let skipped: Vec<&str> = report
374            .manifest
375            .skipped_secret
376            .iter()
377            .map(|s| s.path.as_str())
378            .collect();
379        assert!(skipped.contains(&"my-app-keys.json"));
380        assert!(skipped.contains(&"prod.vault"));
381
382        let payload = crate::inspect::list_payload_paths(&out).expect("list");
383        assert!(payload.iter().any(|p| p == "keep-me.txt"));
384        assert!(!payload.iter().any(|p| p == "my-app-keys.json"));
385        assert!(!payload.iter().any(|p| p == "prod.vault"));
386    }
387
388    /// `keep` overrides a built-in exclusion end to end.
389    #[test]
390    fn test_keep_carries_a_builtin_secret() {
391        let dir = TempDir::new().expect("tempdir");
392        let root = dir.path().join("proj");
393        fs::create_dir_all(&root).expect("mkdir");
394        touch(&root.join(".npmrc"), "registry=...");
395
396        let out = dir.path().join("proj.pack");
397        let mut opts = CreateOptions::new(&root, &out, "0.13.3");
398        opts.rules = crate::rules::PackRules::new(&crate::rules::RuleOverrides {
399            keep: vec![".npmrc".to_string()],
400            ..Default::default()
401        })
402        .expect("rules compile");
403
404        let report = create(&opts).expect("create");
405        assert!(
406            report.manifest.skipped_secret.is_empty(),
407            "keep must remove it from the skip list"
408        );
409        let payload = crate::inspect::list_payload_paths(&out).expect("list");
410        assert!(payload.iter().any(|p| p == ".npmrc"));
411    }
412
413    /// An extra cache directory declared in config is pruned and recorded.
414    #[test]
415    fn test_custom_cache_dir_is_pruned() {
416        let dir = TempDir::new().expect("tempdir");
417        let root = dir.path().join("proj");
418        fs::create_dir_all(&root).expect("mkdir");
419        touch(&root.join("src/main.rs"), "fn main() {}");
420        touch(&root.join("dist/bundle.js"), "built");
421
422        let out = dir.path().join("proj.pack");
423        let mut opts = CreateOptions::new(&root, &out, "0.13.3");
424        opts.rules = crate::rules::PackRules::new(&crate::rules::RuleOverrides {
425            cache_dirs: vec!["dist".to_string()],
426            ..Default::default()
427        })
428        .expect("rules compile");
429
430        let report = create(&opts).expect("create");
431        assert!(
432            report
433                .manifest
434                .skipped_cache
435                .iter()
436                .any(|c| c.path == "dist"),
437            "custom cache must be recorded"
438        );
439        let payload = crate::inspect::list_payload_paths(&out).expect("list");
440        assert!(!payload.iter().any(|p| p.starts_with("dist")));
441        assert!(payload.iter().any(|p| p == "src/main.rs"));
442    }
443
444    /// Without that config, `dist/` is packed — it is source in many projects.
445    #[test]
446    fn test_dist_is_packed_by_default() {
447        let dir = TempDir::new().expect("tempdir");
448        let root = dir.path().join("proj");
449        fs::create_dir_all(&root).expect("mkdir");
450        touch(&root.join("dist/hand-written.js"), "source");
451
452        let out = dir.path().join("proj.pack");
453        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
454
455        let payload = crate::inspect::list_payload_paths(&out).expect("list");
456        assert!(payload.iter().any(|p| p == "dist/hand-written.js"));
457    }
458
459    /// Packing a path that is not a directory is an error.
460    #[test]
461    fn test_create_rejects_non_directory() {
462        let dir = TempDir::new().expect("tempdir");
463        let file = dir.path().join("f.txt");
464        touch(&file, "x");
465        let out = dir.path().join("o.pack");
466        assert!(matches!(
467            create(&CreateOptions::new(&file, &out, "0.13.3")),
468            Err(PackError::NotADirectory(_))
469        ));
470    }
471}