1use crate::{BoxliteError, BoxliteResult};
7use std::path::{Path, PathBuf};
8
9pub struct PackContext {
13 pub follow_symlinks: bool,
15 pub include_parent: bool,
18}
19
20pub async fn pack(src: PathBuf, tar_path: PathBuf, opts: PackContext) -> BoxliteResult<()> {
24 tokio::task::spawn_blocking(move || pack_blocking(&src, &tar_path, &opts))
25 .await
26 .map_err(|e| BoxliteError::Storage(format!("pack task join error: {}", e)))?
27}
28
29fn pack_blocking(src: &Path, tar_path: &Path, opts: &PackContext) -> BoxliteResult<()> {
30 let tar_file = std::fs::File::create(tar_path).map_err(|e| {
31 BoxliteError::Storage(format!(
32 "failed to create tar {}: {}",
33 tar_path.display(),
34 e
35 ))
36 })?;
37 let mut builder = tar::Builder::new(tar_file);
38 builder.follow_symlinks(opts.follow_symlinks);
39
40 if src.is_dir() {
41 if opts.include_parent {
42 let base = src
43 .file_name()
44 .map(|s| s.to_owned())
45 .unwrap_or_else(|| std::ffi::OsStr::new("root").to_owned());
46 builder
47 .append_dir_all(base, src)
48 .map_err(|e| BoxliteError::Storage(format!("failed to archive dir: {}", e)))?;
49 } else {
50 for entry in std::fs::read_dir(src).map_err(|e| {
53 BoxliteError::Storage(format!("failed to read dir {}: {}", src.display(), e))
54 })? {
55 let entry = entry.map_err(|e| {
56 BoxliteError::Storage(format!("failed to read dir entry: {}", e))
57 })?;
58 let name = entry.file_name();
59 let path = entry.path();
60 if path.is_dir() {
61 builder.append_dir_all(&name, &path).map_err(|e| {
62 BoxliteError::Storage(format!("failed to archive dir: {}", e))
63 })?;
64 } else {
65 builder.append_path_with_name(&path, &name).map_err(|e| {
66 BoxliteError::Storage(format!("failed to archive file: {}", e))
67 })?;
68 }
69 }
70 }
71 } else {
72 let name = src
73 .file_name()
74 .ok_or_else(|| BoxliteError::Config("source file has no name".into()))?;
75 builder
76 .append_path_with_name(src, name)
77 .map_err(|e| BoxliteError::Storage(format!("failed to archive file: {}", e)))?;
78 }
79
80 builder
81 .finish()
82 .map_err(|e| BoxliteError::Storage(format!("failed to finish tar: {}", e)))
83}
84
85pub struct UnpackContext {
89 pub overwrite: bool,
91 pub mkdir_parents: bool,
93 pub force_directory: bool,
97}
98
99pub async fn unpack(tar_path: PathBuf, dest: PathBuf, opts: UnpackContext) -> BoxliteResult<()> {
107 tokio::task::spawn_blocking(move || unpack_blocking(&tar_path, &dest, &opts))
108 .await
109 .map_err(|e| BoxliteError::Storage(format!("unpack task join error: {}", e)))?
110}
111
112fn unpack_blocking(tar_path: &Path, dest: &Path, opts: &UnpackContext) -> BoxliteResult<()> {
113 let mode = if opts.force_directory {
114 ExtractionMode::IntoDirectory
115 } else {
116 detect_extraction_mode(dest, tar_path)?
117 };
118
119 match mode {
120 ExtractionMode::FileToFile => {
121 if let Some(parent) = dest.parent() {
122 if opts.mkdir_parents && !parent.exists() {
123 std::fs::create_dir_all(parent).map_err(|e| {
124 BoxliteError::Storage(format!(
125 "failed to create parent dir {}: {}",
126 parent.display(),
127 e
128 ))
129 })?;
130 } else if !parent.exists() {
131 return Err(BoxliteError::Storage(format!(
132 "parent directory of {} does not exist",
133 dest.display()
134 )));
135 }
136 }
137 if !opts.overwrite && dest.exists() {
138 return Err(BoxliteError::Storage(format!(
139 "destination {} exists and overwrite=false",
140 dest.display()
141 )));
142 }
143 let tar_file = std::fs::File::open(tar_path).map_err(|e| {
144 BoxliteError::Storage(format!("failed to open tar {}: {}", tar_path.display(), e))
145 })?;
146 let mut archive = tar::Archive::new(tar_file);
147 let mut entries = archive
148 .entries()
149 .map_err(|e| BoxliteError::Storage(format!("failed to read tar entries: {}", e)))?;
150 if let Some(entry) = entries.next() {
151 let mut entry = entry.map_err(|e| {
152 BoxliteError::Storage(format!("failed to read tar entry: {}", e))
153 })?;
154 entry.unpack(dest).map_err(|e| {
155 BoxliteError::Storage(format!(
156 "failed to unpack file to {}: {}",
157 dest.display(),
158 e
159 ))
160 })?;
161 }
162 Ok(())
163 }
164 ExtractionMode::IntoDirectory => {
165 if !dest.exists() {
166 if opts.mkdir_parents {
167 std::fs::create_dir_all(dest).map_err(|e| {
168 BoxliteError::Storage(format!(
169 "failed to create destination {}: {}",
170 dest.display(),
171 e
172 ))
173 })?;
174 } else {
175 return Err(BoxliteError::Storage(format!(
176 "destination {} does not exist",
177 dest.display()
178 )));
179 }
180 }
181 if dest.exists() && !opts.overwrite {
182 return Err(BoxliteError::Storage(format!(
183 "destination {} exists and overwrite=false",
184 dest.display()
185 )));
186 }
187 let tar_file = std::fs::File::open(tar_path).map_err(|e| {
188 BoxliteError::Storage(format!("failed to open tar {}: {}", tar_path.display(), e))
189 })?;
190 let mut archive = tar::Archive::new(tar_file);
191 archive
192 .unpack(dest)
193 .map_err(|e| BoxliteError::Storage(format!("failed to extract archive: {}", e)))
194 }
195 }
196}
197
198enum ExtractionMode {
201 FileToFile,
202 IntoDirectory,
203}
204
205fn detect_extraction_mode(dest: &Path, tar_path: &Path) -> BoxliteResult<ExtractionMode> {
213 if dest.as_os_str().to_string_lossy().ends_with('/') {
214 return Ok(ExtractionMode::IntoDirectory);
215 }
216 if dest.is_dir() {
217 return Ok(ExtractionMode::IntoDirectory);
218 }
219 let tar_file = std::fs::File::open(tar_path).map_err(|e| {
220 BoxliteError::Storage(format!("failed to open tar {}: {}", tar_path.display(), e))
221 })?;
222 let mut archive = tar::Archive::new(tar_file);
223 if let Ok(entries) = archive.entries() {
224 let mut count = 0u32;
225 let mut is_regular = false;
226 for entry in entries {
227 count += 1;
228 if count > 1 {
229 break;
230 }
231 if let Ok(e) = entry {
232 is_regular = e.header().entry_type() == tar::EntryType::Regular;
233 }
234 }
235 if count == 1 && is_regular {
236 return Ok(ExtractionMode::FileToFile);
237 }
238 }
239 Ok(ExtractionMode::IntoDirectory)
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use tempfile::TempDir;
246
247 fn uc(overwrite: bool, mkdir_parents: bool, force_directory: bool) -> UnpackContext {
250 UnpackContext {
251 overwrite,
252 mkdir_parents,
253 force_directory,
254 }
255 }
256
257 fn default_unpack(overwrite: bool) -> UnpackContext {
258 uc(overwrite, true, false)
259 }
260
261 fn default_pack() -> PackContext {
262 PackContext {
263 follow_symlinks: true,
264 include_parent: true,
265 }
266 }
267
268 fn create_single_file_tar(tar_path: &Path, entry_name: &str, content: &[u8]) {
270 let tar_file = std::fs::File::create(tar_path).unwrap();
271 let mut builder = tar::Builder::new(tar_file);
272 let mut header = tar::Header::new_gnu();
273 header.set_size(content.len() as u64);
274 header.set_mode(0o644);
275 header.set_cksum();
276 builder
277 .append_data(&mut header, entry_name, content)
278 .unwrap();
279 builder.finish().unwrap();
280 }
281
282 fn create_dir_tar(tar_path: &Path) {
284 let tar_file = std::fs::File::create(tar_path).unwrap();
285 let mut builder = tar::Builder::new(tar_file);
286
287 let mut dir_header = tar::Header::new_gnu();
288 dir_header.set_entry_type(tar::EntryType::Directory);
289 dir_header.set_size(0);
290 dir_header.set_mode(0o755);
291 dir_header.set_cksum();
292 builder
293 .append_data(&mut dir_header, "mydir/", &[] as &[u8])
294 .unwrap();
295
296 let content = b"inside dir";
297 let mut file_header = tar::Header::new_gnu();
298 file_header.set_size(content.len() as u64);
299 file_header.set_mode(0o644);
300 file_header.set_cksum();
301 builder
302 .append_data(&mut file_header, "mydir/file.txt", &content[..])
303 .unwrap();
304
305 builder.finish().unwrap();
306 }
307
308 #[tokio::test]
311 async fn pack_single_file() {
312 let tmp = TempDir::new().unwrap();
313 let src = tmp.path().join("hello.txt");
314 std::fs::write(&src, b"hello").unwrap();
315
316 let tar_path = tmp.path().join("out.tar");
317 pack(
318 src,
319 tar_path.clone(),
320 PackContext {
321 follow_symlinks: true,
322 include_parent: false,
323 },
324 )
325 .await
326 .unwrap();
327
328 let tar_file = std::fs::File::open(&tar_path).unwrap();
330 let mut archive = tar::Archive::new(tar_file);
331 let entries: Vec<_> = archive.entries().unwrap().collect();
332 assert_eq!(entries.len(), 1);
333 }
334
335 #[tokio::test]
336 async fn pack_empty_file() {
337 let tmp = TempDir::new().unwrap();
338 let src = tmp.path().join("empty.txt");
339 std::fs::write(&src, b"").unwrap();
340
341 let tar_path = tmp.path().join("out.tar");
342 pack(
343 src,
344 tar_path.clone(),
345 PackContext {
346 follow_symlinks: true,
347 include_parent: false,
348 },
349 )
350 .await
351 .unwrap();
352
353 let dest = tmp.path().join("dest.txt");
354 unpack(tar_path, dest.clone(), default_unpack(true))
355 .await
356 .unwrap();
357 assert_eq!(std::fs::read(&dest).unwrap().len(), 0);
358 }
359
360 #[tokio::test]
361 async fn pack_binary_content_fidelity() {
362 let tmp = TempDir::new().unwrap();
363 let data: Vec<u8> = (0..=255).collect();
364 let src = tmp.path().join("binary.bin");
365 std::fs::write(&src, &data).unwrap();
366
367 let tar_path = tmp.path().join("out.tar");
368 pack(
369 src,
370 tar_path.clone(),
371 PackContext {
372 follow_symlinks: true,
373 include_parent: false,
374 },
375 )
376 .await
377 .unwrap();
378
379 let dest = tmp.path().join("dest.bin");
380 unpack(tar_path, dest.clone(), default_unpack(true))
381 .await
382 .unwrap();
383 assert_eq!(std::fs::read(&dest).unwrap(), data);
384 }
385
386 #[tokio::test]
389 async fn pack_dir_include_parent_true() {
390 let tmp = TempDir::new().unwrap();
391 let src_dir = tmp.path().join("mydir");
392 std::fs::create_dir(&src_dir).unwrap();
393 std::fs::write(src_dir.join("a.txt"), "aaa").unwrap();
394 std::fs::write(src_dir.join("b.txt"), "bbb").unwrap();
395
396 let tar_path = tmp.path().join("out.tar");
397 pack(src_dir, tar_path.clone(), default_pack())
398 .await
399 .unwrap();
400
401 let dest = tmp.path().join("dest");
402 std::fs::create_dir(&dest).unwrap();
403 unpack(tar_path, dest.clone(), default_unpack(true))
404 .await
405 .unwrap();
406
407 assert_eq!(
409 std::fs::read_to_string(dest.join("mydir").join("a.txt")).unwrap(),
410 "aaa"
411 );
412 assert_eq!(
413 std::fs::read_to_string(dest.join("mydir").join("b.txt")).unwrap(),
414 "bbb"
415 );
416 }
417
418 #[tokio::test]
419 async fn pack_dir_include_parent_false_flattens() {
420 let tmp = TempDir::new().unwrap();
421 let src_dir = tmp.path().join("flatdir");
422 std::fs::create_dir(&src_dir).unwrap();
423 std::fs::write(src_dir.join("f.txt"), "flat").unwrap();
424
425 let tar_path = tmp.path().join("out.tar");
426 pack(
427 src_dir,
428 tar_path.clone(),
429 PackContext {
430 follow_symlinks: true,
431 include_parent: false,
432 },
433 )
434 .await
435 .unwrap();
436
437 let dest = tmp.path().join("dest");
438 std::fs::create_dir(&dest).unwrap();
439 unpack(tar_path, dest.clone(), uc(true, false, true))
440 .await
441 .unwrap();
442
443 assert_eq!(std::fs::read_to_string(dest.join("f.txt")).unwrap(), "flat");
445 }
446
447 #[tokio::test]
448 async fn pack_empty_directory() {
449 let tmp = TempDir::new().unwrap();
450 let src_dir = tmp.path().join("emptydir");
451 std::fs::create_dir(&src_dir).unwrap();
452
453 let tar_path = tmp.path().join("out.tar");
454 pack(src_dir, tar_path.clone(), default_pack())
455 .await
456 .unwrap();
457
458 let dest = tmp.path().join("dest");
459 std::fs::create_dir(&dest).unwrap();
460 unpack(tar_path, dest.clone(), default_unpack(true))
461 .await
462 .unwrap();
463 assert!(dest.join("emptydir").is_dir());
464 }
465
466 #[tokio::test]
467 async fn pack_nested_directory() {
468 let tmp = TempDir::new().unwrap();
469 let src_dir = tmp.path().join("deep");
470 std::fs::create_dir_all(src_dir.join("a").join("b").join("c")).unwrap();
471 std::fs::write(
472 src_dir.join("a").join("b").join("c").join("file.txt"),
473 "deep",
474 )
475 .unwrap();
476 std::fs::write(src_dir.join("top.txt"), "top").unwrap();
477
478 let tar_path = tmp.path().join("out.tar");
479 pack(src_dir, tar_path.clone(), default_pack())
480 .await
481 .unwrap();
482
483 let dest = tmp.path().join("dest");
484 std::fs::create_dir(&dest).unwrap();
485 unpack(tar_path, dest.clone(), default_unpack(true))
486 .await
487 .unwrap();
488
489 assert_eq!(
490 std::fs::read_to_string(
491 dest.join("deep")
492 .join("a")
493 .join("b")
494 .join("c")
495 .join("file.txt")
496 )
497 .unwrap(),
498 "deep"
499 );
500 assert_eq!(
501 std::fs::read_to_string(dest.join("deep").join("top.txt")).unwrap(),
502 "top"
503 );
504 }
505
506 #[tokio::test]
509 async fn pack_follow_symlinks_false_preserves_link() {
510 let tmp = TempDir::new().unwrap();
511 let src_dir = tmp.path().join("linkdir");
512 std::fs::create_dir(&src_dir).unwrap();
513 std::fs::write(src_dir.join("target.txt"), "target content").unwrap();
514 std::os::unix::fs::symlink("target.txt", src_dir.join("link.txt")).unwrap();
515
516 let tar_path = tmp.path().join("out.tar");
517 pack(
518 src_dir,
519 tar_path.clone(),
520 PackContext {
521 follow_symlinks: false,
522 include_parent: true,
523 },
524 )
525 .await
526 .unwrap();
527
528 let dest = tmp.path().join("dest");
529 std::fs::create_dir(&dest).unwrap();
530 unpack(tar_path, dest.clone(), default_unpack(true))
531 .await
532 .unwrap();
533
534 let link_path = dest.join("linkdir").join("link.txt");
535 assert!(link_path
536 .symlink_metadata()
537 .unwrap()
538 .file_type()
539 .is_symlink());
540 assert_eq!(
541 std::fs::read_link(&link_path).unwrap().to_str().unwrap(),
542 "target.txt"
543 );
544 }
545
546 #[tokio::test]
547 async fn pack_follow_symlinks_true_dereferences() {
548 let tmp = TempDir::new().unwrap();
549 let src_dir = tmp.path().join("derefdir");
550 std::fs::create_dir(&src_dir).unwrap();
551 std::fs::write(src_dir.join("target.txt"), "deref content").unwrap();
552 std::os::unix::fs::symlink("target.txt", src_dir.join("link.txt")).unwrap();
553
554 let tar_path = tmp.path().join("out.tar");
555 pack(
556 src_dir,
557 tar_path.clone(),
558 PackContext {
559 follow_symlinks: true,
560 include_parent: true,
561 },
562 )
563 .await
564 .unwrap();
565
566 let dest = tmp.path().join("dest");
567 std::fs::create_dir(&dest).unwrap();
568 unpack(tar_path, dest.clone(), default_unpack(true))
569 .await
570 .unwrap();
571
572 let link_path = dest.join("derefdir").join("link.txt");
573 assert!(link_path.is_file());
575 assert!(!link_path
576 .symlink_metadata()
577 .unwrap()
578 .file_type()
579 .is_symlink());
580 assert_eq!(
581 std::fs::read_to_string(&link_path).unwrap(),
582 "deref content"
583 );
584 }
585
586 #[tokio::test]
589 async fn pack_nonexistent_source_errors() {
590 let tmp = TempDir::new().unwrap();
591 let tar_path = tmp.path().join("out.tar");
592 let result = pack(tmp.path().join("does-not-exist"), tar_path, default_pack()).await;
593 assert!(result.is_err());
594 }
595
596 #[tokio::test]
599 async fn unpack_single_file_to_nonexistent_path_uses_file_mode() {
600 let tmp = TempDir::new().unwrap();
601 let tar_path = tmp.path().join("single.tar");
602 create_single_file_tar(&tar_path, "hello.txt", b"hello");
603
604 let dest = tmp.path().join("output.txt");
605 unpack(tar_path, dest.clone(), default_unpack(true))
606 .await
607 .unwrap();
608 assert!(dest.is_file());
609 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "hello");
610 }
611
612 #[tokio::test]
613 async fn unpack_single_file_to_existing_dir_uses_dir_mode() {
614 let tmp = TempDir::new().unwrap();
615 let tar_path = tmp.path().join("single.tar");
616 create_single_file_tar(&tar_path, "hello.txt", b"hello");
617
618 let dest = tmp.path().to_path_buf(); unpack(tar_path, dest.clone(), default_unpack(true))
620 .await
621 .unwrap();
622 assert!(dest.join("hello.txt").is_file());
623 }
624
625 #[tokio::test]
626 async fn unpack_trailing_slash_forces_dir_mode() {
627 let tmp = TempDir::new().unwrap();
628 let tar_path = tmp.path().join("single.tar");
629 create_single_file_tar(&tar_path, "hello.txt", b"hello");
630
631 let dest = tmp.path().join("dirout");
632 std::fs::create_dir(&dest).unwrap();
633 let dest_with_slash = PathBuf::from(format!("{}/", dest.display()));
634 unpack(tar_path, dest_with_slash, default_unpack(true))
635 .await
636 .unwrap();
637 assert!(dest.join("hello.txt").is_file());
638 }
639
640 #[tokio::test]
641 async fn unpack_multi_entry_tar_uses_dir_mode() {
642 let tmp = TempDir::new().unwrap();
643 let tar_path = tmp.path().join("multi.tar");
644 create_dir_tar(&tar_path);
645
646 let dest = tmp.path().join("output");
647 std::fs::create_dir(&dest).unwrap();
648 unpack(tar_path, dest.clone(), default_unpack(true))
649 .await
650 .unwrap();
651
652 assert!(dest.join("mydir").join("file.txt").is_file());
653 assert_eq!(
654 std::fs::read_to_string(dest.join("mydir").join("file.txt")).unwrap(),
655 "inside dir"
656 );
657 }
658
659 #[tokio::test]
660 async fn unpack_single_dir_entry_uses_dir_mode() {
661 let tmp = TempDir::new().unwrap();
662 let tar_path = tmp.path().join("dir_only.tar");
663
664 let tar_file = std::fs::File::create(&tar_path).unwrap();
665 let mut builder = tar::Builder::new(tar_file);
666 let mut header = tar::Header::new_gnu();
667 header.set_entry_type(tar::EntryType::Directory);
668 header.set_size(0);
669 header.set_mode(0o755);
670 header.set_cksum();
671 builder
672 .append_data(&mut header, "somedir/", &[] as &[u8])
673 .unwrap();
674 builder.finish().unwrap();
675
676 let dest = tmp.path().join("output");
677 unpack(tar_path, dest.clone(), default_unpack(true))
678 .await
679 .unwrap();
680 assert!(dest.join("somedir").is_dir());
681 }
682
683 #[tokio::test]
684 async fn unpack_empty_tar_uses_dir_mode() {
685 let tmp = TempDir::new().unwrap();
686 let tar_path = tmp.path().join("empty.tar");
687
688 let tar_file = std::fs::File::create(&tar_path).unwrap();
689 let builder = tar::Builder::new(tar_file);
690 builder.into_inner().unwrap();
691
692 let dest = tmp.path().join("output");
693 unpack(tar_path, dest.clone(), default_unpack(true))
695 .await
696 .unwrap();
697 assert!(dest.is_dir());
698 }
699
700 #[tokio::test]
703 async fn force_directory_overrides_single_file_detection() {
704 let tmp = TempDir::new().unwrap();
705 let src = tmp.path().join("file.txt");
706 std::fs::write(&src, b"data").unwrap();
707
708 let tar_path = tmp.path().join("out.tar");
709 pack(
710 src,
711 tar_path.clone(),
712 PackContext {
713 follow_symlinks: true,
714 include_parent: false,
715 },
716 )
717 .await
718 .unwrap();
719
720 let dest = tmp.path().join("dir_dest");
721 std::fs::create_dir(&dest).unwrap();
722 unpack(tar_path, dest.clone(), uc(true, false, true))
723 .await
724 .unwrap();
725 assert_eq!(
726 std::fs::read_to_string(dest.join("file.txt")).unwrap(),
727 "data"
728 );
729 }
730
731 #[tokio::test]
734 async fn unpack_overwrite_true_replaces_file() {
735 let tmp = TempDir::new().unwrap();
736 let tar_path = tmp.path().join("file.tar");
737 create_single_file_tar(&tar_path, "data.txt", b"new content");
738
739 let dest = tmp.path().join("data.txt");
740 std::fs::write(&dest, b"old content").unwrap();
741
742 unpack(tar_path, dest.clone(), default_unpack(true))
743 .await
744 .unwrap();
745 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "new content");
746 }
747
748 #[tokio::test]
749 async fn unpack_overwrite_false_rejects_existing_file() {
750 let tmp = TempDir::new().unwrap();
751 let tar_path = tmp.path().join("file.tar");
752 create_single_file_tar(&tar_path, "data.txt", b"new content");
753
754 let dest = tmp.path().join("data.txt");
755 std::fs::write(&dest, b"old content").unwrap();
756
757 let result = unpack(tar_path, dest.clone(), default_unpack(false)).await;
758 assert!(result.is_err());
759 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "old content");
760 }
761
762 #[tokio::test]
763 async fn unpack_overwrite_false_rejects_existing_dir() {
764 let tmp = TempDir::new().unwrap();
765 let tar_path = tmp.path().join("dir.tar");
766 create_dir_tar(&tar_path);
767
768 let dest = tmp.path().join("output");
769 std::fs::create_dir(&dest).unwrap();
770
771 let result = unpack(tar_path, dest, uc(false, false, false)).await;
772 assert!(result.is_err());
773 }
774
775 #[tokio::test]
778 async fn unpack_mkdir_parents_creates_parent_dirs_for_file() {
779 let tmp = TempDir::new().unwrap();
780 let tar_path = tmp.path().join("file.tar");
781 create_single_file_tar(&tar_path, "data.txt", b"content");
782
783 let dest = tmp.path().join("a").join("b").join("c").join("data.txt");
784 unpack(tar_path, dest.clone(), default_unpack(true))
785 .await
786 .unwrap();
787
788 assert!(dest.is_file());
789 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "content");
790 }
791
792 #[tokio::test]
793 async fn unpack_mkdir_parents_creates_dest_dir() {
794 let tmp = TempDir::new().unwrap();
795 let tar_path = tmp.path().join("dir.tar");
796 create_dir_tar(&tar_path);
797
798 let dest = tmp.path().join("x").join("y").join("z");
799 unpack(tar_path, dest.clone(), uc(true, true, true))
800 .await
801 .unwrap();
802 assert!(dest.join("mydir").join("file.txt").is_file());
803 }
804
805 #[tokio::test]
806 async fn unpack_no_mkdir_parents_errors_on_missing_parent() {
807 let tmp = TempDir::new().unwrap();
808 let tar_path = tmp.path().join("file.tar");
809 create_single_file_tar(&tar_path, "data.txt", b"content");
810
811 let dest = tmp.path().join("nonexistent").join("data.txt");
812 let result = unpack(tar_path, dest, uc(true, false, false)).await;
813 assert!(result.is_err());
814 }
815
816 #[tokio::test]
817 async fn unpack_no_mkdir_parents_errors_on_missing_dest_dir() {
818 let tmp = TempDir::new().unwrap();
819 let tar_path = tmp.path().join("dir.tar");
820 create_dir_tar(&tar_path);
821
822 let dest = tmp.path().join("nonexistent");
823 let result = unpack(tar_path, dest, uc(true, false, true)).await;
824 assert!(result.is_err());
825 }
826
827 #[tokio::test]
830 async fn roundtrip_single_file() {
831 let tmp = TempDir::new().unwrap();
832 let src = tmp.path().join("hello.txt");
833 std::fs::write(&src, b"hello").unwrap();
834
835 let tar_path = tmp.path().join("out.tar");
836 pack(
837 src,
838 tar_path.clone(),
839 PackContext {
840 follow_symlinks: true,
841 include_parent: false,
842 },
843 )
844 .await
845 .unwrap();
846
847 let dest = tmp.path().join("dest.txt");
848 unpack(tar_path, dest.clone(), default_unpack(true))
849 .await
850 .unwrap();
851 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "hello");
852 }
853
854 #[tokio::test]
855 async fn roundtrip_dir_with_parent() {
856 let tmp = TempDir::new().unwrap();
857 let src_dir = tmp.path().join("src");
858 std::fs::create_dir(&src_dir).unwrap();
859 std::fs::write(src_dir.join("hello.txt"), b"hello").unwrap();
860
861 let tar_path = tmp.path().join("out.tar");
862 pack(src_dir, tar_path.clone(), default_pack())
863 .await
864 .unwrap();
865
866 let dest_dir = tmp.path().join("dest");
867 std::fs::create_dir(&dest_dir).unwrap();
868 unpack(tar_path, dest_dir.clone(), default_unpack(true))
869 .await
870 .unwrap();
871
872 assert_eq!(
873 std::fs::read_to_string(dest_dir.join("src").join("hello.txt")).unwrap(),
874 "hello"
875 );
876 }
877
878 #[tokio::test]
880 async fn issue_238_file_to_file_path_not_directory() {
881 let tmp = TempDir::new().unwrap();
882 let src_file = tmp.path().join("script.py");
883 std::fs::write(&src_file, b"print('hello')\n").unwrap();
884
885 let tar_path = tmp.path().join("issue238.tar");
886 pack(
887 src_file,
888 tar_path.clone(),
889 PackContext {
890 follow_symlinks: true,
891 include_parent: false,
892 },
893 )
894 .await
895 .unwrap();
896
897 let workspace = tmp.path().join("workspace");
898 std::fs::create_dir(&workspace).unwrap();
899 let dest_file = workspace.join("script.py");
900 unpack(tar_path, dest_file.clone(), default_unpack(true))
901 .await
902 .unwrap();
903
904 assert!(
905 dest_file.is_file(),
906 "script.py should be a file (issue #238)"
907 );
908 assert!(
909 !dest_file.is_dir(),
910 "script.py must NOT be a directory (issue #238)"
911 );
912 assert_eq!(
913 std::fs::read_to_string(&dest_file).unwrap(),
914 "print('hello')\n"
915 );
916 }
917
918 #[tokio::test]
919 async fn roundtrip_file_to_existing_dir_extracts_inside() {
920 let tmp = TempDir::new().unwrap();
921 let src_file = tmp.path().join("source.py");
922 std::fs::write(&src_file, b"print('hello')").unwrap();
923 let tar_path = tmp.path().join("file.tar");
924 pack(
925 src_file,
926 tar_path.clone(),
927 PackContext {
928 follow_symlinks: true,
929 include_parent: false,
930 },
931 )
932 .await
933 .unwrap();
934
935 let dest_dir = tmp.path().join("workspace");
936 std::fs::create_dir(&dest_dir).unwrap();
937 unpack(tar_path, dest_dir.clone(), default_unpack(true))
938 .await
939 .unwrap();
940
941 let extracted = dest_dir.join("source.py");
942 assert!(extracted.is_file());
943 assert_eq!(
944 std::fs::read_to_string(&extracted).unwrap(),
945 "print('hello')"
946 );
947 }
948
949 #[tokio::test]
950 async fn roundtrip_filename_with_spaces() {
951 let tmp = TempDir::new().unwrap();
952 let src = tmp.path().join("my file.txt");
953 std::fs::write(&src, "spaces\n").unwrap();
954
955 let tar_path = tmp.path().join("out.tar");
956 pack(
957 src,
958 tar_path.clone(),
959 PackContext {
960 follow_symlinks: true,
961 include_parent: false,
962 },
963 )
964 .await
965 .unwrap();
966
967 let dest = tmp.path().join("my file out.txt");
968 unpack(tar_path, dest.clone(), default_unpack(true))
969 .await
970 .unwrap();
971 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "spaces\n");
972 }
973}