Skip to main content

boxlite_shared/
tar.rs

1//! Tar archive pack/unpack for host↔guest file transfer.
2//!
3//! Both host (boxlite) and guest agent share this module to avoid
4//! duplicating tar building/extraction logic.
5
6use crate::{BoxliteError, BoxliteResult};
7use std::path::{Path, PathBuf};
8
9// ── Pack ──────────────────────────────────────────────────────────
10
11/// Controls how a source path is packed into a tar archive.
12pub struct PackContext {
13    /// Follow symlinks (copy target content) vs preserve them as links.
14    pub follow_symlinks: bool,
15    /// When packing a directory, include the directory itself as a top-level
16    /// entry (true) or flatten its contents into the archive root (false).
17    pub include_parent: bool,
18}
19
20/// Pack `src` (file or directory) into a tar archive at `tar_path`.
21///
22/// Runs blocking I/O on a dedicated thread via `spawn_blocking`.
23pub 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            // Add each top-level entry individually so we don't create a
51            // "." entry that produces an empty tar path on extraction.
52            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
85// ── Unpack ────────────────────────────────────────────────────────
86
87/// Controls how a tar archive is unpacked to a destination.
88pub struct UnpackContext {
89    /// Allow overwriting existing files/directories.
90    pub overwrite: bool,
91    /// Create parent directories if they don't exist.
92    pub mkdir_parents: bool,
93    /// Force directory extraction mode (skip single-file detection).
94    /// Set `true` when the caller knows the destination is a directory
95    /// (e.g. original path had trailing `/`).
96    pub force_directory: bool,
97}
98
99/// Unpack a tar archive to `dest`.
100///
101/// Automatically detects whether to extract as a single file (FileToFile)
102/// or into a directory (IntoDirectory) based on tar contents and dest path,
103/// unless `force_directory` is set.
104///
105/// Runs blocking I/O on a dedicated thread via `spawn_blocking`.
106pub 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
198// ── Private ───────────────────────────────────────────────────────
199
200enum ExtractionMode {
201    FileToFile,
202    IntoDirectory,
203}
204
205/// Inspect the destination path and tar contents to decide extraction mode.
206///
207/// Rules (evaluated in order):
208/// 1. Dest path has trailing `/` → directory mode
209/// 2. Dest exists as a directory → directory mode
210/// 3. Tar contains exactly one regular file → file-to-file mode
211/// 4. Fallback → directory mode
212fn 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    // ── Helpers ───────────────────────────────────────────────────
248
249    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    /// Create a tar containing a single file with the given entry name and content.
269    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    /// Create a tar containing a directory with files inside.
283    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    // ── pack: single file ────────────────────────────────────────
309
310    #[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        // Verify tar contains exactly one entry with the filename
329        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    // ── pack: directory with include_parent ───────────────────────
387
388    #[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        // Files nested under mydir/
408        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        // File directly in dest, not under flatdir/
444        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    // ── pack: symlinks ───────────────────────────────────────────
507
508    #[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        // Should be a regular file, not a symlink
574        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    // ── pack: error cases ────────────────────────────────────────
587
588    #[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    // ── unpack: detection modes ──────────────────────────────────
597
598    #[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(); // existing directory
619        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        // Empty tar + dir mode + mkdir_parents → creates empty directory
694        unpack(tar_path, dest.clone(), default_unpack(true))
695            .await
696            .unwrap();
697        assert!(dest.is_dir());
698    }
699
700    // ── unpack: force_directory ──────────────────────────────────
701
702    #[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    // ── unpack: overwrite ────────────────────────────────────────
732
733    #[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    // ── unpack: mkdir_parents ────────────────────────────────────
776
777    #[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    // ── roundtrip: pack + unpack ─────────────────────────────────
828
829    #[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    /// Regression test for #238: copy_in creates directory when destination is a file path.
879    #[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}