1use 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#[derive(Debug)]
25pub struct Unpacked {
26 pub path: PathBuf,
28 pub progress: Progress,
29 pub skipped: SkipList,
31}
32
33struct 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
65struct 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
104pub 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
126pub 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 skipped_dirs: Vec<PathBuf>,
189 checked_parent: Option<PathBuf>,
191 saw_end: bool,
193 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 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 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(¤t) {
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(¤t).map_err(|e| io_message(&e, ¤t))?;
422 self.progress.folders += 1;
423 }
424 Err(error) => return Err(io_message(&error, ¤t)),
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(¤t).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}