Skip to main content

computer_transfer/
unpack.rs

1//! Writes a tar stream made by `pack` to disk.
2
3use std::{
4    collections::{HashMap, hash_map::Entry},
5    fs,
6    io::{self, BufWriter, Read},
7    path::{Path, PathBuf},
8    time::{Duration, UNIX_EPOCH},
9};
10
11use computer_protocol::{SKIP_REPORT_ENTRY, SkipList};
12use tar::{Archive, EntryType};
13use uuid::Uuid;
14
15use crate::{
16    Failure, Progress,
17    rules::{self, Action, Kind, PathProblem, Platform, Wanted},
18};
19
20const MAX_REPORT_BYTES: u64 = 1024 * 1024;
21const WRITE_BUFFER: usize = 128 * 1024;
22
23/// What was written and where.
24#[derive(Debug)]
25pub struct Unpacked {
26    /// Final path of the file or folder that was created or merged into.
27    pub path: PathBuf,
28    pub progress: Progress,
29    /// Entries left out here, followed by the ones the sender left out.
30    pub skipped: SkipList,
31}
32
33/// Removes its file when dropped before [`Temp::place`].
34struct Temp(Option<PathBuf>);
35
36impl Temp {
37    fn new(dir: &Path) -> Self {
38        Self(Some(dir.join(format!(
39            ".computer-use-transfer-{}.tmp",
40            Uuid::new_v4().simple()
41        ))))
42    }
43
44    fn path(&self) -> &Path {
45        self.0
46            .as_deref()
47            .expect("a temporary file has a path until it is placed")
48    }
49
50    fn place(mut self, to: &Path) -> io::Result<()> {
51        fs::rename(self.path(), to)?;
52        self.0 = None;
53        Ok(())
54    }
55}
56
57impl Drop for Temp {
58    fn drop(&mut self) {
59        if let Some(path) = self.0.take() {
60            let _ = fs::remove_file(path);
61        }
62    }
63}
64
65/// Counts the bytes that pass through, so progress is right when a copy stops halfway.
66struct Counted<'a, R> {
67    inner: R,
68    bytes: &'a mut u64,
69}
70
71impl<R: Read> Read for Counted<'_, R> {
72    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
73        let n = self.inner.read(buf)?;
74        *self.bytes += n as u64;
75        Ok(n)
76    }
77}
78
79fn kind_at(path: &Path) -> Option<Kind> {
80    let meta = fs::symlink_metadata(path).ok()?;
81    let kind = meta.file_type();
82    Some(if kind.is_symlink() {
83        Kind::Link
84    } else if kind.is_dir() {
85        Kind::Folder
86    } else if kind.is_file() {
87        Kind::File
88    } else {
89        Kind::Other
90    })
91}
92
93fn followed_kind(path: &Path) -> Option<Kind> {
94    let meta = fs::metadata(path).ok()?;
95    Some(if meta.is_dir() {
96        Kind::Folder
97    } else if meta.is_file() {
98        Kind::File
99    } else {
100        Kind::Other
101    })
102}
103
104/// Tells before any data is sent whether a root named `name` can land at `dest`.
105///
106/// Applies the same rules as [`unpack`] does with the first entry, and returns the final path.
107///
108/// # Errors
109///
110/// Refuses with a message naming the path that is in the way, or the name that cannot exist here.
111pub fn check_destination(
112    dest: &Path,
113    name: &str,
114    folder: bool,
115    overwrite: bool,
116    platform: Platform,
117) -> Result<PathBuf, String> {
118    if let Some(problem) = rules::name_problem(name, platform) {
119        return Err(format!("{name} cannot be created here: {problem}"));
120    }
121    let wanted = if folder { Wanted::Folder } else { Wanted::File };
122    rules::destination(dest, followed_kind(dest), name, wanted, overwrite, kind_at)
123        .map(|found| found.path)
124}
125
126/// Writes the archive read from `reader` to `dest`, following the destination rule of `cp -r`.
127///
128/// The root entry decides everything before anything is written: the final path, the refusal
129/// when it exists and `overwrite` is off, and the refusal of a file against a folder. The same
130/// checks run again for every entry of a folder, so a merge replaces same-named files and keeps
131/// the others. Files are written under a temporary name and renamed into place, and an
132/// unfinished temporary file is removed on every failure.
133///
134/// Entries with an absolute path or `..` stop the transfer. An entry whose path goes through a
135/// symbolic link or a file is refused, so links made by the archive cannot lead later entries
136/// outside the destination. Names `platform` cannot hold, and links it cannot create, are
137/// skipped and listed.
138///
139/// # Errors
140///
141/// Fails with the reason and how far the transfer got.
142pub fn unpack<R: Read>(
143    reader: R,
144    dest: &Path,
145    overwrite: bool,
146    platform: Platform,
147) -> Result<Unpacked, Failure> {
148    let mut unpacker = Unpacker {
149        platform,
150        overwrite,
151        root: PathBuf::new(),
152        progress: Progress::default(),
153        skipped: SkipList::default(),
154        sender_skipped: SkipList::default(),
155        skipped_dirs: Vec::new(),
156        checked_parent: None,
157        saw_end: false,
158        written: HashMap::new(),
159    };
160    match unpacker.run(reader, dest) {
161        Ok(()) => {
162            let Unpacker {
163                root,
164                progress,
165                mut skipped,
166                sender_skipped,
167                ..
168            } = unpacker;
169            skipped.merge(sender_skipped);
170            Ok(Unpacked {
171                path: root,
172                progress,
173                skipped,
174            })
175        }
176        Err(message) => Err(Failure::new(message, unpacker.progress)),
177    }
178}
179
180struct Unpacker {
181    platform: Platform,
182    overwrite: bool,
183    root: PathBuf,
184    progress: Progress,
185    skipped: SkipList,
186    sender_skipped: SkipList,
187    /// Folders that were skipped, so their contents are skipped without a report each.
188    skipped_dirs: Vec<PathBuf>,
189    /// Folder whose path was last checked for links, which most next entries share.
190    checked_parent: Option<PathBuf>,
191    /// Whether the sender's closing report entry arrived.
192    saw_end: bool,
193    /// On systems that ignore case, the lowercased path of every entry written, with the name it had.
194    written: HashMap<String, String>,
195}
196
197fn read_error(error: &io::Error) -> String {
198    format!("reading the transfer failed: {error}")
199}
200
201impl Unpacker {
202    fn run<R: Read>(&mut self, reader: R, dest: &Path) -> Result<(), String> {
203        let mut archive = Archive::new(reader);
204        let mut entries = archive.entries().map_err(|e| read_error(&e))?;
205        let mut first = entries
206            .next()
207            .ok_or("the transfer held no data")?
208            .map_err(|e| read_error(&e))?;
209        let name = self.root_name(&first)?;
210        let wanted = match first.header().entry_type() {
211            EntryType::Directory => Wanted::Folder,
212            EntryType::Regular | EntryType::Continuous => Wanted::File,
213            other => {
214                return Err(format!(
215                    "the transfer starts with an unexpected entry ({other:?})"
216                ));
217            }
218        };
219        let found = rules::destination(
220            dest,
221            followed_kind(dest),
222            &name,
223            wanted,
224            self.overwrite,
225            kind_at,
226        )?;
227        self.root = found.path;
228        if let Some(parent) = self.root.parent() {
229            fs::create_dir_all(parent).map_err(|e| io_message(&e, parent))?;
230        }
231        let root = self.root.clone();
232        if wanted == Wanted::Folder {
233            self.make_dir(&root, found.action)?;
234        } else {
235            self.write_file(&mut first, &root)?;
236        }
237
238        for entry in entries {
239            let mut entry = entry.map_err(|e| read_error(&e))?;
240            self.entry(&mut entry, &name, wanted)?;
241        }
242        if !self.saw_end {
243            return Err(
244                "the transfer ended before the sender finished, so files may be missing".to_owned(),
245            );
246        }
247        Ok(())
248    }
249
250    fn root_name<R: Read>(&self, entry: &tar::Entry<'_, R>) -> Result<String, String> {
251        let path = entry.path().map_err(|e| read_error(&e))?;
252        let names = rules::inside(&path).map_err(|_| "the transfer starts with a bad path")?;
253        let [name] = names.as_slice() else {
254            return Err("the transfer must start with one file or folder".to_owned());
255        };
256        let name = name
257            .to_str()
258            .ok_or("the name of the first entry is not valid UTF-8")?;
259        match rules::name_problem(name, self.platform) {
260            Some(problem) => Err(format!("{name} cannot be created here: {problem}")),
261            None => Ok(name.to_owned()),
262        }
263    }
264
265    fn entry<R: Read>(
266        &mut self,
267        entry: &mut tar::Entry<'_, R>,
268        root_name: &str,
269        root: Wanted,
270    ) -> Result<(), String> {
271        let raw = entry.path().map_err(|e| read_error(&e))?.into_owned();
272        if raw == Path::new(SKIP_REPORT_ENTRY) {
273            return self.read_report(entry);
274        }
275        let kind = entry.header().entry_type();
276        if matches!(kind, EntryType::XGlobalHeader) {
277            return Ok(());
278        }
279        if root == Wanted::File {
280            return Err("the transfer holds more than one file where one was expected".to_owned());
281        }
282        let names = rules::inside(&raw).map_err(|problem| {
283            let why = match problem {
284                PathProblem::Absolute => "is an absolute path",
285                PathProblem::ParentDir => "contains ..",
286                PathProblem::Empty => "is empty",
287            };
288            format!(
289                "the transfer holds an entry whose path {why}: {}",
290                raw.display()
291            )
292        })?;
293        if names[0] != root_name {
294            return Err(format!(
295                "the transfer holds an entry outside its root: {}",
296                raw.display()
297            ));
298        }
299        let mut rel = PathBuf::new();
300        for name in &names[1..] {
301            rel.push(name);
302        }
303        if rel.as_os_str().is_empty() {
304            return Ok(());
305        }
306        if self.skipped_dirs.iter().any(|dir| rel.starts_with(dir)) {
307            return Ok(());
308        }
309        if let Some(problem) = self.name_problem(&rel) {
310            if kind == EntryType::Directory {
311                self.skipped_dirs.push(rel.clone());
312            }
313            self.skipped.push(rel.display().to_string(), problem);
314            return Ok(());
315        }
316        if let Some(first) = self.case_clash(&rel) {
317            if kind == EntryType::Directory {
318                self.skipped_dirs.push(rel.clone());
319            }
320            self.skipped.push(
321                rel.display().to_string(),
322                format!(
323                    "it differs only by case from {first}, and this system treats them as one name"
324                ),
325            );
326            return Ok(());
327        }
328        let target = self.prepare_parent(&rel)?;
329        match kind {
330            EntryType::Directory => {
331                let action = rules::check_existing(
332                    &target,
333                    kind_at(&target),
334                    Wanted::Folder,
335                    self.overwrite,
336                )?;
337                self.make_dir(&target, action)
338            }
339            EntryType::Regular | EntryType::Continuous => self.write_file(entry, &target),
340            EntryType::Symlink => self.write_link(entry, &rel, &target),
341            other => {
342                self.skipped.push(
343                    rel.display().to_string(),
344                    format!("it is not a file, folder, or link ({other:?})"),
345                );
346                Ok(())
347            }
348        }
349    }
350
351    fn read_report<R: Read>(&mut self, entry: &mut tar::Entry<'_, R>) -> Result<(), String> {
352        let mut json = Vec::new();
353        entry
354            .take(MAX_REPORT_BYTES)
355            .read_to_end(&mut json)
356            .map_err(|e| read_error(&e))?;
357        if let Ok(list) = serde_json::from_slice::<SkipList>(&json) {
358            self.sender_skipped = list;
359        }
360        self.saw_end = true;
361        Ok(())
362    }
363
364    /// The earlier entry that `rel` collides with on a system that ignores case, remembering `rel` otherwise.
365    fn case_clash(&mut self, rel: &Path) -> Option<String> {
366        if !self.platform.ignores_case() {
367            return None;
368        }
369        let shown = rel.display().to_string();
370        match self.written.entry(shown.to_lowercase()) {
371            Entry::Occupied(known) if *known.get() != shown => Some(known.get().clone()),
372            Entry::Occupied(_) => None,
373            Entry::Vacant(slot) => {
374                slot.insert(shown);
375                None
376            }
377        }
378    }
379
380    fn name_problem(&self, rel: &Path) -> Option<String> {
381        for part in rel.components() {
382            let name = part.as_os_str();
383            let Some(name) = name.to_str() else {
384                return Some("the name is not valid UTF-8".to_owned());
385            };
386            if let Some(problem) = rules::name_problem(name, self.platform) {
387                return Some(problem);
388            }
389        }
390        None
391    }
392
393    /// The path for `rel` under the root, after making sure every folder above it is a real folder.
394    fn prepare_parent(&mut self, rel: &Path) -> Result<PathBuf, String> {
395        let target = self.root.join(rel);
396        let Some(parent_rel) = rel.parent().filter(|p| !p.as_os_str().is_empty()) else {
397            return Ok(target);
398        };
399        let parent = self.root.join(parent_rel);
400        if self.checked_parent.as_deref() == Some(parent.as_path()) {
401            return Ok(target);
402        }
403        let mut current = self.root.clone();
404        for part in parent_rel.components() {
405            current.push(part);
406            match fs::symlink_metadata(&current) {
407                Ok(meta) if meta.is_dir() => {}
408                Ok(meta) if meta.is_symlink() => {
409                    return Err(format!(
410                        "{} is a symbolic link, so entries under it are refused",
411                        current.display()
412                    ));
413                }
414                Ok(_) => {
415                    return Err(format!(
416                        "{} is a file where a folder is needed",
417                        current.display()
418                    ));
419                }
420                Err(error) if error.kind() == io::ErrorKind::NotFound => {
421                    fs::create_dir(&current).map_err(|e| io_message(&e, &current))?;
422                    self.progress.folders += 1;
423                }
424                Err(error) => return Err(io_message(&error, &current)),
425            }
426        }
427        self.checked_parent = Some(parent);
428        Ok(target)
429    }
430
431    fn make_dir(&mut self, path: &Path, action: Action) -> Result<(), String> {
432        if action != Action::Merge {
433            fs::create_dir_all(path).map_err(|e| io_message(&e, path))?;
434        }
435        self.progress.folders += 1;
436        Ok(())
437    }
438
439    fn write_file<R: Read>(
440        &mut self,
441        entry: &mut tar::Entry<'_, R>,
442        target: &Path,
443    ) -> Result<(), String> {
444        rules::check_existing(target, kind_at(target), Wanted::File, self.overwrite)?;
445        let parent = target
446            .parent()
447            .ok_or_else(|| format!("{} has no folder to write in", target.display()))?;
448        let temp = Temp::new(parent);
449        let mode = entry.header().mode().unwrap_or(0o644);
450        let modified = entry.header().mtime().ok();
451        let file = fs::OpenOptions::new()
452            .write(true)
453            .create_new(true)
454            .open(temp.path())
455            .map_err(|e| io_message(&e, target))?;
456        let mut bytes = 0;
457        let copied = {
458            let mut out = BufWriter::with_capacity(WRITE_BUFFER, file);
459            let mut input = Counted {
460                inner: &mut *entry,
461                bytes: &mut bytes,
462            };
463            io::copy(&mut input, &mut out)
464                .and_then(|_| out.into_inner().map_err(io::IntoInnerError::into_error))
465        };
466        self.progress.bytes += bytes;
467        let file = copied.map_err(|e| format!("writing {} failed: {e}", target.display()))?;
468        if bytes != entry.size() {
469            return Err(format!(
470                "the transfer ended in the middle of {}",
471                target.display()
472            ));
473        }
474        set_mode(&file, mode).map_err(|e| io_message(&e, target))?;
475        if let Some(secs) = modified.filter(|secs| *secs > 0) {
476            let _ = file.set_modified(UNIX_EPOCH + Duration::from_secs(secs));
477        }
478        drop(file);
479        temp.place(target).map_err(|e| io_message(&e, target))?;
480        self.progress.files += 1;
481        Ok(())
482    }
483
484    fn write_link<R: Read>(
485        &mut self,
486        entry: &tar::Entry<'_, R>,
487        rel: &Path,
488        target: &Path,
489    ) -> Result<(), String> {
490        let link = entry
491            .link_name()
492            .map_err(|e| read_error(&e))?
493            .ok_or("a link in the transfer has no target")?
494            .into_owned();
495        let action = rules::check_existing(target, kind_at(target), Wanted::Link, self.overwrite)?;
496        if action == Action::Replace {
497            fs::remove_file(target).map_err(|e| io_message(&e, target))?;
498        }
499        match make_link(&link, target) {
500            Ok(()) => self.progress.files += 1,
501            Err(error) if self.platform == Platform::Windows => self.skipped.push(
502                rel.display().to_string(),
503                format!("Windows would not create the link: {error}"),
504            ),
505            Err(error) => return Err(io_message(&error, target)),
506        }
507        Ok(())
508    }
509}
510
511fn io_message(error: &io::Error, path: &Path) -> String {
512    format!("{}: {error}", path.display())
513}
514
515#[cfg(unix)]
516fn set_mode(file: &fs::File, mode: u32) -> io::Result<()> {
517    use std::os::unix::fs::PermissionsExt;
518    file.set_permissions(fs::Permissions::from_mode(mode & 0o777))
519}
520
521#[cfg(not(unix))]
522#[expect(
523    clippy::unnecessary_wraps,
524    reason = "matches the signature of the Unix version"
525)]
526fn set_mode(_file: &fs::File, _mode: u32) -> io::Result<()> {
527    Ok(())
528}
529
530#[cfg(unix)]
531fn make_link(link: &Path, at: &Path) -> io::Result<()> {
532    std::os::unix::fs::symlink(link, at)
533}
534
535#[cfg(windows)]
536fn make_link(link: &Path, at: &Path) -> io::Result<()> {
537    let points_at_folder = at.parent().is_some_and(|parent| parent.join(link).is_dir());
538    if points_at_folder {
539        std::os::windows::fs::symlink_dir(link, at)
540    } else {
541        std::os::windows::fs::symlink_file(link, at)
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use std::io::Cursor;
548
549    use tar::{Builder, Header};
550
551    use super::*;
552    use crate::pack::pack;
553
554    struct Dir(PathBuf);
555
556    impl Dir {
557        fn new() -> Self {
558            let path = std::env::temp_dir().join(format!("computer-transfer-{}", Uuid::new_v4()));
559            fs::create_dir_all(&path).expect("temp dir is creatable");
560            Self(fs::canonicalize(path).expect("temp dir exists"))
561        }
562
563        fn join(&self, name: &str) -> PathBuf {
564            self.0.join(name)
565        }
566    }
567
568    impl Drop for Dir {
569        fn drop(&mut self) {
570            let _ = fs::remove_dir_all(&self.0);
571        }
572    }
573
574    fn packed(source: &Path) -> Vec<u8> {
575        let mut out = Vec::new();
576        pack(source, &mut out, Platform::current()).unwrap();
577        out
578    }
579
580    fn put(dest: &Path, archive: &[u8], overwrite: bool) -> Result<Unpacked, Failure> {
581        unpack(Cursor::new(archive), dest, overwrite, Platform::current())
582    }
583
584    fn raw_archive(entries: &[(&str, EntryType, &[u8], Option<&str>)]) -> Vec<u8> {
585        let mut builder = Builder::new(Vec::new());
586        for (path, kind, data, link) in entries {
587            let mut header = Header::new_gnu();
588            header.as_old_mut().name[..path.len()].copy_from_slice(path.as_bytes());
589            if let Some(link) = link {
590                header.as_old_mut().linkname[..link.len()].copy_from_slice(link.as_bytes());
591            }
592            header.set_entry_type(*kind);
593            header.set_mode(0o644);
594            header.set_size(data.len() as u64);
595            header.set_cksum();
596            builder.append(&header, *data).unwrap();
597        }
598        if !entries.iter().any(|(path, ..)| *path == SKIP_REPORT_ENTRY) {
599            let empty = serde_json::to_vec(&SkipList::default()).unwrap();
600            let mut header = Header::new_gnu();
601            header.as_old_mut().name[..SKIP_REPORT_ENTRY.len()]
602                .copy_from_slice(SKIP_REPORT_ENTRY.as_bytes());
603            header.set_size(empty.len() as u64);
604            header.set_cksum();
605            builder.append(&header, empty.as_slice()).unwrap();
606        }
607        builder.into_inner().unwrap()
608    }
609
610    fn files_under(dir: &Path) -> Vec<String> {
611        let mut found = Vec::new();
612        let mut stack = vec![dir.to_path_buf()];
613        while let Some(current) = stack.pop() {
614            for entry in fs::read_dir(&current).unwrap() {
615                let path = entry.unwrap().path();
616                let shown = path.strip_prefix(dir).unwrap().to_string_lossy();
617                found.push(shown.replace('\\', "/"));
618                if fs::symlink_metadata(&path).unwrap().is_dir() {
619                    stack.push(path);
620                }
621            }
622        }
623        found.sort();
624        found
625    }
626
627    fn source_tree(base: &Dir) -> PathBuf {
628        let root = base.join("tree");
629        fs::create_dir_all(root.join("empty")).unwrap();
630        fs::create_dir_all(root.join("deep/er")).unwrap();
631        let binary: Vec<u8> = (0..=255u8).cycle().take(300_000).collect();
632        fs::write(root.join("blob.bin"), &binary).unwrap();
633        fs::write(root.join("deep/er/note.txt"), "héllo\n").unwrap();
634        root
635    }
636
637    #[test]
638    fn a_folder_arrives_with_its_bytes_and_empty_folders() {
639        let base = Dir::new();
640        let root = source_tree(&base);
641        let out = Dir::new();
642        let done = put(&out.0, &packed(&root), false).unwrap();
643
644        assert_eq!(done.path, out.join("tree"));
645        assert_eq!(
646            done.progress,
647            Progress {
648                files: 2,
649                folders: 4,
650                bytes: 300_000 + "héllo\n".len() as u64
651            }
652        );
653        assert_eq!(
654            fs::read(out.join("tree/blob.bin")).unwrap(),
655            fs::read(root.join("blob.bin")).unwrap()
656        );
657        assert_eq!(
658            fs::read_to_string(out.join("tree/deep/er/note.txt")).unwrap(),
659            "héllo\n"
660        );
661        assert!(out.join("tree/empty").is_dir());
662        assert!(done.skipped.is_empty());
663    }
664
665    #[test]
666    fn a_file_goes_inside_an_existing_folder_or_takes_the_new_name() {
667        let base = Dir::new();
668        let file = base.join("report.txt");
669        fs::write(&file, "data").unwrap();
670        let archive = packed(&file);
671
672        let out = Dir::new();
673        let inside = put(&out.0, &archive, false).unwrap();
674        assert_eq!(inside.path, out.join("report.txt"));
675
676        let renamed = put(&out.join("new/name.md"), &archive, false).unwrap();
677        assert_eq!(renamed.path, out.join("new/name.md"));
678        assert_eq!(fs::read_to_string(out.join("new/name.md")).unwrap(), "data");
679    }
680
681    #[test]
682    fn an_existing_folder_is_refused_by_name_and_nothing_in_it_is_touched() {
683        let base = Dir::new();
684        let archive = packed(&source_tree(&base));
685        let out = Dir::new();
686        fs::create_dir(out.join("tree")).unwrap();
687        fs::write(out.join("tree/keep.txt"), "mine").unwrap();
688        fs::write(out.join("tree/blob.bin"), "old").unwrap();
689
690        let error = put(&out.0, &archive, false).unwrap_err();
691        assert_eq!(
692            files_under(&out.0),
693            ["tree", "tree/blob.bin", "tree/keep.txt"]
694        );
695        assert_eq!(
696            fs::read_to_string(out.join("tree/blob.bin")).unwrap(),
697            "old"
698        );
699        assert!(
700            error.message.contains("tree") && error.message.contains("overwrite"),
701            "{error}"
702        );
703        assert_eq!(error.progress, Progress::default());
704    }
705
706    #[test]
707    fn overwrite_merges_a_folder_replacing_same_named_files_and_keeping_others() {
708        let base = Dir::new();
709        let archive = packed(&source_tree(&base));
710        let out = Dir::new();
711        fs::create_dir_all(out.join("tree/deep")).unwrap();
712        fs::write(out.join("tree/keep.txt"), "mine").unwrap();
713        fs::write(out.join("tree/blob.bin"), "old").unwrap();
714
715        put(&out.0, &archive, true).unwrap();
716        assert_eq!(
717            fs::read_to_string(out.join("tree/keep.txt")).unwrap(),
718            "mine"
719        );
720        assert_eq!(fs::read(out.join("tree/blob.bin")).unwrap().len(), 300_000);
721        assert!(out.join("tree/deep/er/note.txt").is_file());
722        assert!(
723            !files_under(&out.0)
724                .iter()
725                .any(|name| name.contains(".computer-use-transfer-"))
726        );
727    }
728
729    #[test]
730    fn a_file_never_replaces_a_folder_during_a_merge() {
731        let base = Dir::new();
732        let archive = packed(&source_tree(&base));
733        let out = Dir::new();
734        fs::create_dir_all(out.join("tree/blob.bin/inner")).unwrap();
735
736        let error = put(&out.0, &archive, true).unwrap_err();
737        assert!(error.message.contains("is a folder"), "{error}");
738        assert!(out.join("tree/blob.bin/inner").is_dir());
739    }
740
741    #[test]
742    fn a_folder_cannot_replace_a_file_at_the_root() {
743        let base = Dir::new();
744        let archive = packed(&source_tree(&base));
745        let out = Dir::new();
746        fs::write(out.join("target"), "file").unwrap();
747        let error = put(&out.join("target"), &archive, true).unwrap_err();
748        assert!(error.message.contains("not a folder"), "{error}");
749        assert_eq!(fs::read_to_string(out.join("target")).unwrap(), "file");
750    }
751
752    #[test]
753    fn a_cut_off_stream_leaves_no_half_file_and_no_temporary_file() {
754        let base = Dir::new();
755        let archive = packed(&source_tree(&base));
756        let cut = &archive[..archive.len() / 2];
757        let out = Dir::new();
758
759        let error = put(&out.0, cut, false).unwrap_err();
760        assert!(
761            !files_under(&out.0)
762                .iter()
763                .any(|name| name.contains(".computer-use-transfer-"))
764        );
765        assert!(!out.join("tree/blob.bin").exists());
766        assert!(error.progress.bytes < 300_000, "{error:?}");
767    }
768
769    #[test]
770    fn absolute_and_parent_paths_are_refused() {
771        for path in ["tree/../../escape", "/etc/escape"] {
772            let archive = raw_archive(&[
773                ("tree/", EntryType::Directory, b"", None),
774                (path, EntryType::Regular, b"x", None),
775            ]);
776            let out = Dir::new();
777            let error = put(&out.join("dest"), &archive, false).unwrap_err();
778            assert!(
779                error.message.contains("..") || error.message.contains("absolute"),
780                "{path}: {error}"
781            );
782            assert_eq!(files_under(&out.0), ["dest"]);
783        }
784    }
785
786    #[cfg(unix)]
787    #[test]
788    fn an_entry_cannot_go_through_a_link_made_earlier_in_the_archive() {
789        let outside = Dir::new();
790        let archive = raw_archive(&[
791            ("tree/", EntryType::Directory, b"", None),
792            (
793                "tree/out",
794                EntryType::Symlink,
795                b"",
796                Some(outside.0.to_str().unwrap()),
797            ),
798            ("tree/out/pwned", EntryType::Regular, b"x", None),
799        ]);
800        let out = Dir::new();
801        let error = put(&out.0, &archive, false).unwrap_err();
802        assert!(error.message.contains("symbolic link"), "{error}");
803        assert_eq!(files_under(&outside.0), Vec::<String>::new());
804    }
805
806    #[cfg(unix)]
807    #[test]
808    fn executables_links_and_pipes_are_handled_like_cp_would() {
809        use std::os::unix::fs::PermissionsExt;
810
811        let base = Dir::new();
812        let root = source_tree(&base);
813        fs::write(root.join("run.sh"), "#!/bin/sh\n").unwrap();
814        fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
815        std::os::unix::fs::symlink("blob.bin", root.join("link")).unwrap();
816        std::os::unix::fs::symlink("/nonexistent/absolute", root.join("dangling")).unwrap();
817        let fifo = std::process::Command::new("mkfifo")
818            .arg(root.join("pipe"))
819            .status()
820            .unwrap();
821        assert!(fifo.success());
822
823        let out = Dir::new();
824        let done = put(&out.0, &packed(&root), false).unwrap();
825        let mode = |name: &str| fs::metadata(out.join(name)).unwrap().permissions().mode() & 0o777;
826        assert_eq!(mode("tree/run.sh"), 0o755);
827        assert_eq!(mode("tree/blob.bin"), 0o644);
828        assert_eq!(
829            fs::read_link(out.join("tree/link")).unwrap(),
830            Path::new("blob.bin")
831        );
832        assert_eq!(
833            fs::read_link(out.join("tree/dangling")).unwrap(),
834            Path::new("/nonexistent/absolute")
835        );
836        assert!(!out.join("tree/pipe").exists());
837        assert_eq!(done.skipped.entries.len(), 1);
838        assert_eq!(done.skipped.entries[0].path, "tree/pipe");
839
840        let again = put(&out.0, &packed(&root), true).unwrap();
841        assert_eq!(again.progress.files, done.progress.files);
842        let refused = put(&out.0, &packed(&root), false).unwrap_err();
843        assert!(refused.message.contains("already exists"), "{refused}");
844    }
845
846    #[test]
847    fn names_the_target_cannot_hold_are_skipped_with_their_contents_and_listed() {
848        let archive = raw_archive(&[
849            ("tree/", EntryType::Directory, b"", None),
850            ("tree/fine.txt", EntryType::Regular, b"ok", None),
851            ("tree/a:b.txt", EntryType::Regular, b"x", None),
852            ("tree/CON/", EntryType::Directory, b"", None),
853            ("tree/CON/inner.txt", EntryType::Regular, b"x", None),
854        ]);
855        let out = Dir::new();
856        let done = unpack(Cursor::new(archive), &out.0, false, Platform::Windows).unwrap();
857        assert_eq!(files_under(&out.0), ["tree", "tree/fine.txt"]);
858        let skipped: Vec<_> = done
859            .skipped
860            .entries
861            .iter()
862            .map(|s| s.path.as_str())
863            .collect();
864        assert_eq!(skipped, ["a:b.txt", "CON"]);
865    }
866
867    #[test]
868    fn the_senders_skips_follow_the_receivers_own() {
869        let mut sent = SkipList::default();
870        sent.push("tree/pipe", "it is a pipe");
871        let report = serde_json::to_vec(&sent).unwrap();
872        let archive = raw_archive(&[
873            ("tree/", EntryType::Directory, b"", None),
874            ("tree/a:b", EntryType::Regular, b"x", None),
875            (SKIP_REPORT_ENTRY, EntryType::Regular, &report, None),
876        ]);
877        let out = Dir::new();
878        let done = unpack(Cursor::new(archive), &out.0, false, Platform::Windows).unwrap();
879        let skipped: Vec<_> = done
880            .skipped
881            .entries
882            .iter()
883            .map(|s| s.path.as_str())
884            .collect();
885        assert_eq!(skipped, ["a:b", "tree/pipe"]);
886    }
887
888    #[test]
889    fn a_stream_cut_exactly_between_entries_is_not_a_finished_transfer() {
890        let base = Dir::new();
891        let archive = packed(&source_tree(&base));
892        let report_and_end = 512 + 512 + 1024;
893        let cut = &archive[..archive.len() - report_and_end];
894        let out = Dir::new();
895
896        let error = put(&out.0, cut, false).unwrap_err();
897        assert!(error.message.contains("ended before"), "{error}");
898        assert_eq!(
899            error.progress.files, 2,
900            "everything before the cut was written"
901        );
902        assert!(put(&out.join("again"), &archive, false).is_ok());
903    }
904
905    #[test]
906    fn names_that_differ_only_by_case_are_skipped_where_case_is_ignored() {
907        let archive = raw_archive(&[
908            ("tree/", EntryType::Directory, b"", None),
909            ("tree/A.txt", EntryType::Regular, b"first", None),
910            ("tree/a.txt", EntryType::Regular, b"second", None),
911            ("tree/Dir/", EntryType::Directory, b"", None),
912            ("tree/dir/", EntryType::Directory, b"", None),
913            ("tree/dir/inner", EntryType::Regular, b"x", None),
914        ]);
915        let out = Dir::new();
916        let done = unpack(Cursor::new(archive), &out.0, true, Platform::Windows).unwrap();
917        assert_eq!(files_under(&out.0), ["tree", "tree/A.txt", "tree/Dir"]);
918        assert_eq!(fs::read_to_string(out.join("tree/A.txt")).unwrap(), "first");
919        let skipped: Vec<_> = done
920            .skipped
921            .entries
922            .iter()
923            .map(|s| {
924                (
925                    s.path.as_str(),
926                    s.reason.contains("differs only by case from"),
927                )
928            })
929            .collect();
930        assert_eq!(skipped, [("a.txt", true), ("dir", true)]);
931    }
932
933    #[test]
934    fn check_destination_applies_the_root_rules_before_any_data_moves() {
935        let out = Dir::new();
936        fs::create_dir(out.join("taken")).unwrap();
937        fs::write(out.join("file"), "x").unwrap();
938        let check = |dest: &str, name: &str, folder, overwrite| {
939            check_destination(&out.join(dest), name, folder, overwrite, Platform::Unix)
940        };
941        assert_eq!(
942            check(".", "new", true, false),
943            Ok(out.join(".").join("new"))
944        );
945        let error = check(".", "taken", true, false).unwrap_err();
946        assert!(error.contains("already exists"), "{error}");
947        assert!(check(".", "taken", true, true).is_ok());
948        assert!(
949            check("file", "x", true, true)
950                .unwrap_err()
951                .contains("not a folder")
952        );
953        let windows = check_destination(&out.0, "a:b", false, false, Platform::Windows);
954        assert!(windows.unwrap_err().contains("cannot be created"));
955    }
956}