1use 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
19pub const PAYLOAD_PREFIX: &str = "payload";
24
25pub const DEFAULT_COMPRESSION_LEVEL: i32 = 3;
31
32#[derive(Debug, Clone)]
34pub struct CreateOptions {
35 pub root: PathBuf,
37 pub out: PathBuf,
39 pub lds_version: String,
41 pub compression_level: i32,
43 pub rules: PackRules,
45 pub dry_run: bool,
51}
52
53impl CreateOptions {
54 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#[derive(Debug, Clone)]
69pub struct CreateReport {
70 pub manifest: Manifest,
72 pub out_path: PathBuf,
74 pub compressed_bytes: u64,
76 pub dry_run: bool,
78}
79
80pub 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 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 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
147fn 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
176fn 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
193fn 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 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
227fn 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
235fn 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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}