1use std::io::{Read, Seek, SeekFrom, Write};
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26
27use tokio::runtime::Handle;
28
29use super::jobs::{EntrySide, HostJob, HostStop, JOBS};
30use super::platform::{self, display_path};
31use super::ProtectedPaths;
32use crate::backend::remote::files::{RemoteError, RemoteFiles};
33use crate::backend::rpc_types::{FsOpChoice, FsOpKind, FsOpStartResult};
34
35#[derive(Clone)]
37pub enum End {
38 Local,
40 Host {
42 files: Arc<RemoteFiles>,
43 home: String,
44 },
45}
46
47#[derive(Debug, Clone, Copy)]
49struct Info {
50 is_dir: bool,
51 is_link: bool,
52 is_file: bool,
54 size: u64,
55 mtime: Option<u64>,
56 mode: u32,
58}
59
60impl Info {
61 fn side(&self) -> EntrySide {
62 EntrySide {
63 is_dir: self.is_dir,
64 size: (!self.is_dir).then_some(self.size),
65 mtime: self.mtime,
66 }
67 }
68}
69
70enum RenameError {
72 Taken,
73 Other(String),
74}
75
76fn host_error(e: RemoteError) -> String {
77 e.message
78}
79
80impl End {
81 pub fn host(files: Arc<RemoteFiles>) -> Self {
82 let home = super::remote::resolve("/", &files.home);
83 End::Host { files, home }
84 }
85
86 fn same_host(&self, other: &End) -> bool {
88 match (self, other) {
89 (End::Host { files: a, .. }, End::Host { files: b, .. }) => a.conn == b.conn,
90 _ => false,
91 }
92 }
93
94 fn join(&self, dir: &str, name: &str) -> String {
95 match self {
96 End::Local => display_path(&Path::new(dir).join(name)),
97 End::Host { .. } => posix_join(dir, name),
98 }
99 }
100
101 fn parent(&self, path: &str) -> String {
102 match self {
103 End::Local => Path::new(path)
104 .parent()
105 .map(display_path)
106 .unwrap_or_default(),
107 End::Host { .. } => posix_parent(path),
108 }
109 }
110
111 fn refusal(&self, path: &str) -> Option<String> {
113 match self {
114 End::Local => ProtectedPaths::current().check(Path::new(path)).err(),
115 End::Host { files, home } => super::remote::protected(home, path).then(|| {
116 format!("AgentMux doesn't change {path} on {}: it's a system folder or your home folder.", files.conn)
117 }),
118 }
119 }
120
121 fn stat(&self, rt: &Handle, path: &str) -> Result<Option<Info>, String> {
122 match self {
123 End::Local => match std::fs::symlink_metadata(path) {
124 Ok(m) => Ok(Some(Info {
125 is_dir: m.is_dir(),
126 is_link: m.file_type().is_symlink(),
127 is_file: m.is_file(),
128 mode: local_mode(&m),
129 size: m.len(),
130 mtime: m
131 .modified()
132 .ok()
133 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
134 .map(|d| d.as_millis() as u64),
135 })),
136 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
137 Err(e) => Err(e.to_string()),
138 },
139 End::Host { files, .. } => match rt.block_on(files.stat(path)) {
140 Ok(e) => Ok(Some(Info {
141 is_dir: e.kind == agentmux_remote::fsproto::Kind::Dir && !e.symlink,
142 is_link: e.symlink,
143 is_file: e.kind == agentmux_remote::fsproto::Kind::File && !e.symlink,
144 mode: e.mode,
145 size: e.size,
146 mtime: (e.mtime_ms > 0).then_some(e.mtime_ms as u64),
147 })),
148 Err(e) if e.is_not_found() => Ok(None),
149 Err(e) => Err(host_error(e)),
150 },
151 }
152 }
153
154 fn list(&self, rt: &Handle, dir: &str) -> Result<Vec<String>, String> {
155 match self {
156 End::Local => {
157 let mut names: Vec<String> = std::fs::read_dir(dir)
158 .map_err(|e| e.to_string())?
159 .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
160 .collect();
161 names.sort();
162 Ok(names)
163 }
164 End::Host { files, .. } => Ok(rt
165 .block_on(files.list(dir))
166 .map_err(host_error)?
167 .into_iter()
168 .map(|e| e.name)
169 .collect()),
170 }
171 }
172
173 fn mkdir(&self, rt: &Handle, path: &str) -> Result<(), String> {
174 match self {
175 End::Local => std::fs::create_dir(path).map_err(|e| e.to_string()),
176 End::Host { files, .. } => rt.block_on(files.mkdir(path, false)).map_err(host_error),
177 }
178 }
179
180 fn read(
182 &self,
183 rt: &Handle,
184 path: &str,
185 offset: u64,
186 len: usize,
187 ) -> Result<(Vec<u8>, bool), String> {
188 match self {
189 End::Local => {
190 let mut f = std::fs::File::open(path).map_err(|e| e.to_string())?;
191 f.seek(SeekFrom::Start(offset)).map_err(|e| e.to_string())?;
192 let mut data = Vec::with_capacity(len);
193 f.take(len as u64)
194 .read_to_end(&mut data)
195 .map_err(|e| e.to_string())?;
196 let eof = data.len() < len;
197 Ok((data, eof))
198 }
199 End::Host { files, .. } => rt
200 .block_on(files.read_range(path, offset, len as u32))
201 .map_err(host_error),
202 }
203 }
204
205 fn create_temp(&self, rt: &Handle, dir: &str) -> Result<String, String> {
207 let name = format!(".agentmux-part-{}", uuid::Uuid::new_v4().simple());
208 let path = self.join(dir, &name);
209 match self {
210 End::Local => {
211 std::fs::OpenOptions::new()
212 .write(true)
213 .create_new(true)
214 .open(&path)
215 .map_err(|e| e.to_string())?;
216 }
217 End::Host { files, .. } => rt
218 .block_on(files.write(&path, Vec::new()))
219 .map_err(host_error)?,
220 }
221 Ok(path)
222 }
223
224 fn append(&self, rt: &Handle, path: &str, data: Vec<u8>) -> Result<(), String> {
225 match self {
226 End::Local => std::fs::OpenOptions::new()
227 .append(true)
228 .open(path)
229 .and_then(|mut f| f.write_all(&data))
230 .map_err(|e| e.to_string()),
231 End::Host { files, .. } => rt.block_on(files.append(path, data)).map_err(host_error),
232 }
233 }
234
235 fn rename_no_replace(&self, rt: &Handle, from: &str, to: &str) -> Result<(), RenameError> {
237 match self {
238 End::Local => {
239 platform::rename_no_replace(Path::new(from), Path::new(to)).map_err(|e| {
240 if e.kind() == std::io::ErrorKind::AlreadyExists {
241 RenameError::Taken
242 } else {
243 RenameError::Other(e.to_string())
244 }
245 })
246 }
247 End::Host { files, .. } => rt.block_on(files.rename(from, to)).map_err(|e| {
248 if e.kind == Some(agentmux_remote::fsproto::ErrKind::AlreadyExists) {
249 RenameError::Taken
250 } else {
251 RenameError::Other(e.message)
252 }
253 }),
254 }
255 }
256
257 fn replace(&self, rt: &Handle, from: &str, to: &str) -> Result<(), String> {
260 match self {
261 End::Local => std::fs::rename(from, to).map_err(|e| e.to_string()),
262 End::Host { files, .. } => rt.block_on(files.replace(from, to)).map_err(host_error),
263 }
264 }
265
266 fn set_meta(
268 &self,
269 rt: &Handle,
270 path: &str,
271 mode: u32,
272 mtime: Option<u64>,
273 ) -> Result<(), String> {
274 let mtime_ms = mtime.unwrap_or(0) as i64;
275 match self {
276 End::Local => {
277 if mtime_ms > 0 {
278 let t =
279 std::time::UNIX_EPOCH + std::time::Duration::from_millis(mtime_ms as u64);
280 std::fs::OpenOptions::new()
281 .write(true)
282 .open(path)
283 .and_then(|f| f.set_modified(t))
284 .map_err(|e| e.to_string())?;
285 }
286 #[cfg(unix)]
287 if mode != 0 {
288 use std::os::unix::fs::PermissionsExt;
289 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode & 0o7777))
290 .map_err(|e| e.to_string())?;
291 }
292 #[cfg(not(unix))]
293 let _ = mode;
294 Ok(())
295 }
296 End::Host { files, .. } => rt
297 .block_on(files.set_meta(path, mode, mtime_ms))
298 .map_err(host_error),
299 }
300 }
301
302 fn sync(&self, rt: &Handle, path: &str) -> Result<(), String> {
304 match self {
305 End::Local => std::fs::OpenOptions::new()
308 .read(cfg!(unix))
309 .write(!cfg!(unix))
310 .open(path)
311 .and_then(|f| f.sync_all())
312 .map_err(|e| e.to_string()),
313 End::Host { files, .. } => rt.block_on(files.sync(path)).map_err(host_error),
314 }
315 }
316
317 fn remove(&self, rt: &Handle, path: &str) -> Result<(), String> {
320 match self {
321 End::Local => {
322 let p = Path::new(path);
323 match std::fs::symlink_metadata(p) {
324 Ok(m) if m.is_file() => platform::remove_file_force(p),
325 _ => platform::remove_entry_no_follow(p),
326 }
327 .map_err(|e| e.to_string())
328 }
329 End::Host { files, .. } => rt.block_on(files.delete(path, false)).map_err(host_error),
330 }
331 }
332
333 fn name(&self, path: &str) -> String {
337 let posix = |p: &str| {
338 p.rsplit('/')
339 .find(|s| !s.is_empty())
340 .unwrap_or(p)
341 .to_string()
342 };
343 match self {
344 End::Local if cfg!(windows) => file_name(path),
347 End::Local | End::Host { .. } => posix(path),
348 }
349 }
350
351 fn show(&self, path: &str) -> String {
353 match self {
354 End::Local => path.to_string(),
355 End::Host { files, .. } => format!("{}:{path}", files.conn),
356 }
357 }
358
359 fn tidy(&self, path: &str) -> String {
361 match self {
362 End::Local => display_path(&PathBuf::from(path)),
363 End::Host { home, .. } => super::remote::resolve(home, path),
364 }
365 }
366}
367
368fn posix_join(dir: &str, name: &str) -> String {
370 if dir.ends_with('/') {
371 format!("{dir}{name}")
372 } else {
373 format!("{dir}/{name}")
374 }
375}
376
377fn posix_parent(path: &str) -> String {
379 match path.rfind('/') {
380 Some(0) | None => "/".to_string(),
381 Some(i) => path[..i].to_string(),
382 }
383}
384
385fn local_mode(m: &std::fs::Metadata) -> u32 {
387 #[cfg(unix)]
388 {
389 use std::os::unix::fs::PermissionsExt;
390 m.permissions().mode() & 0o7777
391 }
392 #[cfg(not(unix))]
393 {
394 let _ = m;
395 0
396 }
397}
398
399fn name_ok(name: &str, windows: bool) -> bool {
404 if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
405 return false;
406 }
407 if windows {
408 let bad =
409 |c: char| matches!(c, '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*') || c.is_control();
410 if name.chars().any(bad) || name.ends_with('.') || name.ends_with(' ') {
412 return false;
413 }
414 }
415 true
416}
417
418impl End {
419 fn accepts_name(&self, name: &str) -> bool {
421 match self {
422 End::Local => name_ok(name, cfg!(windows)),
423 End::Host { .. } => name_ok(name, false),
424 }
425 }
426}
427
428fn open_source(path: &str) -> Result<std::fs::File, String> {
432 #[cfg(unix)]
433 let f = {
434 use std::os::unix::fs::OpenOptionsExt;
435 std::fs::OpenOptions::new()
436 .read(true)
437 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
438 .open(path)
439 .map_err(|e| e.to_string())?
440 };
441 #[cfg(not(unix))]
442 let f = platform::open_no_follow(Path::new(path)).map_err(|e| e.to_string())?;
443 match f.metadata() {
444 Ok(m) if m.is_file() => Ok(f),
445 Ok(_) => Err(format!(
446 "“{}” changed while it was being copied: not a regular file now.",
447 file_name(path)
448 )),
449 Err(e) => Err(e.to_string()),
450 }
451}
452
453fn read_open(f: &mut std::fs::File, len: usize) -> Result<(Vec<u8>, bool), String> {
455 let mut data = Vec::with_capacity(len);
456 f.take(len as u64)
457 .read_to_end(&mut data)
458 .map_err(|e| e.to_string())?;
459 let eof = data.len() < len;
460 Ok((data, eof))
461}
462
463fn file_name(path: &str) -> String {
464 path.rsplit(['/', '\\'])
465 .find(|s| !s.is_empty())
466 .unwrap_or(path)
467 .to_string()
468}
469
470fn keep_both_name(end: &End, rt: &Handle, dir: &str, name: &str) -> Result<String, String> {
472 let (stem, ext) = match name.rfind('.') {
473 Some(i) if i > 0 => (&name[..i], &name[i..]),
474 _ => (name, ""),
475 };
476 for n in 2..10_000 {
477 let candidate = end.join(dir, &format!("{stem} ({n}){ext}"));
478 if end.stat(rt, &candidate)?.is_none() {
479 return Ok(candidate);
480 }
481 }
482 Err(format!("No free name for a copy of “{name}”."))
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487enum Outcome {
488 Done,
489 NotDone,
490 Stopped,
491}
492
493struct Transfer<'r> {
494 rt: &'r Handle,
495 kind: FsOpKind,
496 src: End,
497 dest: End,
498 measured: std::cell::RefCell<std::collections::HashMap<String, (u64, u64)>>,
500}
501
502impl Transfer<'_> {
503 fn measure(&self, job: &HostJob<'_, '_>, path: &str) -> (u64, u64) {
508 if job.is_canceled() {
509 return (0, 0);
510 }
511 let Ok(Some(info)) = self.src.stat(self.rt, path) else {
512 return (0, 0);
513 };
514 let mut total = (1u64, 0u64);
515 if info.is_dir {
516 for name in self.src.list(self.rt, path).unwrap_or_default() {
517 let (i, b) = self.measure(job, &self.src.join(path, &name));
518 total.0 += i;
519 total.1 += b;
520 }
521 } else if !info.is_link {
522 total.1 = info.size;
523 }
524 self.measured.borrow_mut().insert(path.to_string(), total);
525 total
526 }
527
528 fn pass_over(&self, job: &mut HostJob<'_, '_>, path: &str) {
531 let (items, bytes) = self.measured.borrow().get(path).copied().unwrap_or((1, 0));
532 job.advance(items, bytes);
533 }
534
535 fn entry(&self, job: &mut HostJob<'_, '_>, src: &str, dest: &str) -> Outcome {
536 if job.is_canceled() {
537 return Outcome::Stopped;
538 }
539 let shown = self.src.show(src);
540 job.set_current(&shown);
541 let info = match self.src.stat(self.rt, src) {
542 Ok(Some(i)) => i,
543 Ok(None) => {
544 job.fail(
545 &shown,
546 format!("“{}” isn't there anymore.", self.src.name(src)),
547 );
548 return Outcome::NotDone;
549 }
550 Err(e) => {
551 job.fail(&shown, e);
552 return Outcome::NotDone;
553 }
554 };
555 let one_host = self.src.same_host(&self.dest);
559 if info.is_link && !(one_host && self.kind == FsOpKind::Move) {
560 let why = if one_host {
561 format!(
562 "“{}” is a link: copying links on a host isn't available yet (moving one is).",
563 self.src.name(src)
564 )
565 } else {
566 format!(
567 "“{}” is a link: links aren't copied between two machines.",
568 self.src.name(src)
569 )
570 };
571 job.fail(&shown, why);
572 job.advance(1, 0);
573 return Outcome::NotDone;
574 }
575 if !info.is_dir && !info.is_link && !info.is_file {
576 job.fail(
577 &shown,
578 format!(
579 "“{}” isn't a regular file (a device, socket or pipe): not copied.",
580 self.src.name(src)
581 ),
582 );
583 job.advance(1, 0);
584 return Outcome::NotDone;
585 }
586 if let Some(why) = self.dest.refusal(dest) {
587 job.fail(&shown, why);
588 self.pass_over(job, src);
589 return Outcome::NotDone;
590 }
591 if self.kind == FsOpKind::Move {
592 if let Some(why) = self.src.refusal(src) {
593 job.fail(&shown, why);
594 self.pass_over(job, src);
595 return Outcome::NotDone;
596 }
597 }
598 let existing = match self.dest.stat(self.rt, dest) {
599 Ok(e) => e,
600 Err(e) => {
601 job.fail(&shown, e);
602 self.pass_over(job, src);
603 return Outcome::NotDone;
604 }
605 };
606 let mut dest = dest.to_string();
607 let mut replace = false;
608 if let Some(there) = existing {
609 if info.is_dir && there.is_dir {
610 return self.merge(job, src, &dest);
611 }
612 match job.ask(&shown, info.side(), &self.dest.show(&dest), there.side()) {
613 None => return Outcome::Stopped,
614 Some(FsOpChoice::Skip) => {
615 self.pass_over(job, src);
616 return Outcome::NotDone;
617 }
618 Some(FsOpChoice::KeepBoth) => {
619 let dir = self.dest.parent(&dest);
620 match keep_both_name(&self.dest, self.rt, &dir, &self.dest.name(&dest)) {
621 Ok(d) => dest = d,
622 Err(e) => {
623 job.fail(&shown, e);
624 self.pass_over(job, src);
625 return Outcome::NotDone;
626 }
627 }
628 }
629 Some(FsOpChoice::Replace) => {
630 if info.is_dir || there.is_dir {
631 job.fail(
632 &shown,
633 format!(
634 "“{}” and what's there aren't both files: not replaced.",
635 self.src.name(src)
636 ),
637 );
638 self.pass_over(job, src);
639 return Outcome::NotDone;
640 }
641 replace = true;
642 }
643 }
644 }
645 if info.is_link && replace {
648 return match self.src.replace(self.rt, src, &dest) {
650 Ok(()) => {
651 self.pass_over(job, src);
652 Outcome::Done
653 }
654 Err(e) => {
655 job.fail(&shown, e);
656 job.advance(1, 0);
657 Outcome::NotDone
658 }
659 };
660 }
661 if self.kind == FsOpKind::Move && !replace && one_host {
662 match self.src.rename_no_replace(self.rt, src, &dest) {
663 Ok(()) => {
664 self.pass_over(job, src);
665 return Outcome::Done;
666 }
667 Err(RenameError::Taken) => {
668 job.fail(
669 &shown,
670 format!(
671 "Something named “{}” appeared there.",
672 self.dest.name(&dest)
673 ),
674 );
675 self.pass_over(job, src);
676 return Outcome::NotDone;
677 }
678 Err(RenameError::Other(e)) if info.is_link => {
680 job.fail(&shown, e);
681 job.advance(1, 0);
682 return Outcome::NotDone;
683 }
684 Err(RenameError::Other(_)) => {}
685 }
686 }
687 if info.is_dir {
688 if let Err(e) = self.dest.mkdir(self.rt, &dest) {
689 job.fail(&shown, e);
690 self.pass_over(job, src);
691 return Outcome::NotDone;
692 }
693 job.advance(1, 0);
694 let out = self.children(job, src, &dest);
695 if out == Outcome::Done && self.kind == FsOpKind::Move {
696 if let Err(e) = self.src.remove(self.rt, src) {
697 job.fail(&shown, e);
698 return Outcome::NotDone;
699 }
700 }
701 return out;
702 }
703 self.file(job, src, &dest, &shown, info, replace)
704 }
705
706 fn merge(&self, job: &mut HostJob<'_, '_>, src: &str, dest: &str) -> Outcome {
707 job.advance(1, 0);
708 let out = self.children(job, src, dest);
709 if out == Outcome::Done && self.kind == FsOpKind::Move {
710 if let Err(e) = self.src.remove(self.rt, src) {
711 job.fail(&self.src.show(src), e);
712 return Outcome::NotDone;
713 }
714 }
715 out
716 }
717
718 fn children(&self, job: &mut HostJob<'_, '_>, src: &str, dest: &str) -> Outcome {
719 let names = match self.src.list(self.rt, src) {
720 Ok(n) => n,
721 Err(e) => {
722 job.fail(&self.src.show(src), e);
723 return Outcome::NotDone;
724 }
725 };
726 let mut all = Outcome::Done;
727 for name in names {
728 let child = self.src.join(src, &name);
729 if !self.dest.accepts_name(&name) {
730 job.fail(
731 &self.src.show(&child),
732 format!("“{name}” can't be a file name there: not copied."),
733 );
734 self.pass_over(job, &child);
735 all = Outcome::NotDone;
736 continue;
737 }
738 match self.entry(job, &child, &self.dest.join(dest, &name)) {
739 Outcome::Stopped => return Outcome::Stopped,
740 Outcome::NotDone => all = Outcome::NotDone,
741 Outcome::Done => {}
742 }
743 }
744 all
745 }
746
747 fn file(
748 &self,
749 job: &mut HostJob<'_, '_>,
750 src: &str,
751 dest: &str,
752 shown: &str,
753 info: Info,
754 replace: bool,
755 ) -> Outcome {
756 let size = info.size;
757 let dir = self.dest.parent(dest);
758 let temp = match self.dest.create_temp(self.rt, &dir) {
759 Ok(t) => t,
760 Err(e) => {
761 job.fail(shown, e);
762 job.advance(1, size);
763 return Outcome::NotDone;
764 }
765 };
766 let discard = |t: &Transfer<'_>| {
767 let _ = t.dest.remove(t.rt, &temp);
768 };
769 let mut local_src = match &self.src {
771 End::Local => match open_source(src) {
772 Ok(f) => Some(f),
773 Err(e) => {
774 discard(self);
775 job.fail(shown, e);
776 job.advance(1, size);
777 return Outcome::NotDone;
778 }
779 },
780 End::Host { .. } => None,
781 };
782 let chunk = job.chunk_size().clamp(64 * 1024, 4 << 20);
784 let mut offset = 0u64;
785 loop {
786 if job.is_canceled() {
787 discard(self);
788 return Outcome::Stopped;
789 }
790 let read = match local_src.as_mut() {
791 Some(f) => read_open(f, chunk),
792 None => self.src.read(self.rt, src, offset, chunk),
793 };
794 let (data, eof) = match read {
795 Ok(r) => r,
796 Err(e) => {
797 discard(self);
798 job.fail(shown, e);
799 job.advance(1, size.saturating_sub(offset));
800 return Outcome::NotDone;
801 }
802 };
803 let n = data.len() as u64;
804 if n > 0 {
805 if let Err(e) = self.dest.append(self.rt, &temp, data) {
806 discard(self);
807 job.fail(shown, e);
808 job.advance(1, size.saturating_sub(offset));
809 return Outcome::NotDone;
810 }
811 offset += n;
812 job.advance(0, n);
813 }
814 if eof || n == 0 {
815 break;
816 }
817 }
818 if self.kind == FsOpKind::Move {
821 if let Err(e) = self.dest.sync(self.rt, &temp) {
822 discard(self);
823 job.fail(shown, format!("Couldn't make sure it was written: {e}"));
824 job.advance(1, 0);
825 return Outcome::NotDone;
826 }
827 }
828 if let Err(e) = self.dest.set_meta(self.rt, &temp, info.mode, info.mtime) {
832 tracing::debug!(path = %shown, error = %e, "fs.op: a host copy's permissions or time weren't kept");
833 }
834 let placed = if replace {
836 self.dest
837 .replace(self.rt, &temp, dest)
838 .map_err(RenameError::Other)
839 } else {
840 self.dest.rename_no_replace(self.rt, &temp, dest)
841 };
842 match placed {
843 Ok(()) => {}
844 Err(RenameError::Taken) => {
845 discard(self);
846 job.fail(
847 shown,
848 format!(
849 "Something named “{}” appeared there meanwhile; nothing was replaced.",
850 self.dest.name(dest)
851 ),
852 );
853 job.advance(1, 0);
854 return Outcome::NotDone;
855 }
856 Err(RenameError::Other(e)) => {
857 discard(self);
858 job.fail(shown, e);
859 job.advance(1, 0);
860 return Outcome::NotDone;
861 }
862 }
863 job.advance(1, 0);
864 if self.kind == FsOpKind::Move {
865 if let Err(e) = self.src.remove(self.rt, src) {
866 job.fail(
867 shown,
868 format!("Copied, but the original couldn't be removed: {e}"),
869 );
870 return Outcome::NotDone;
871 }
872 }
873 Outcome::Done
874 }
875}
876
877struct Item {
879 src: String,
880 duplicate: bool,
882}
883
884fn under(path: &str, dir: &str) -> bool {
885 path.strip_prefix(dir)
886 .is_some_and(|rest| rest.starts_with('/'))
887 || (dir == "/" && path.starts_with('/') && path != "/")
888}
889
890fn plan_on_one_host(
899 kind: FsOpKind,
900 src: &End,
901 sources: &[(String, String)],
902 dest: &End,
903 dest_dir: &str,
904) -> Result<Vec<Item>, String> {
905 if !src.same_host(dest) {
906 return Ok(sources
907 .iter()
908 .map(|(s, _)| Item {
909 src: s.clone(),
910 duplicate: false,
911 })
912 .collect());
913 }
914 let verb = if kind == FsOpKind::Move {
915 "move"
916 } else {
917 "copy"
918 };
919 let mut items = Vec::new();
920 let mut keys = Vec::new();
921 for (s, key) in sources {
922 if dest_dir == key || under(dest_dir, key) {
923 return Err(format!("Can't {verb} a folder into itself."));
924 }
925 let in_dest_already = src.parent(key) == dest_dir;
926 if in_dest_already && kind == FsOpKind::Move {
927 continue;
928 }
929 items.push(Item {
930 src: s.clone(),
931 duplicate: in_dest_already,
932 });
933 keys.push(key.clone());
934 }
935 let landing: std::collections::HashSet<String> = items
938 .iter()
939 .filter(|i| !i.duplicate)
940 .map(|i| src.name(&i.src))
941 .collect();
942 for (item, key) in items.iter().zip(&keys) {
943 let rest = if dest_dir == "/" {
944 key.strip_prefix('/')
945 } else {
946 key.strip_prefix(&format!("{dest_dir}/"))
947 };
948 let Some(rest) = rest else { continue };
949 let mut parts = rest.split('/').filter(|p| !p.is_empty());
950 if let (Some(first), Some(_)) = (parts.next(), parts.next()) {
951 if landing.contains(first) {
952 return Err(format!(
953 "Can't {verb} there: “{first}” would land on the folder that holds “{}”.",
954 src.name(&item.src)
955 ));
956 }
957 }
958 }
959 Ok(items)
960}
961
962async fn stat_now(end: &End, path: &str) -> Result<Option<()>, String> {
964 match end {
965 End::Local => match std::fs::symlink_metadata(path) {
966 Ok(_) => Ok(Some(())),
967 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
968 Err(e) => Err(e.to_string()),
969 },
970 End::Host { files, .. } => match files.stat(path).await {
971 Ok(_) => Ok(Some(())),
972 Err(e) if e.is_not_found() => Ok(None),
973 Err(e) => Err(host_error(e)),
974 },
975 }
976}
977
978async fn realpath_now(end: &End, path: &str) -> String {
980 match end {
981 End::Local => std::fs::canonicalize(path)
982 .map(|p| display_path(&p))
983 .unwrap_or_else(|_| path.to_string()),
984 End::Host { files, .. } => files
985 .realpath(path)
986 .await
987 .unwrap_or_else(|_| path.to_string()),
988 }
989}
990
991pub async fn start(
994 kind: FsOpKind,
995 src: End,
996 sources: Vec<String>,
997 dest: End,
998 dest_dir: String,
999 emit: super::jobs::Emit,
1000) -> Result<FsOpStartResult, String> {
1001 let rt = Handle::current();
1002 let dest_dir = realpath_now(&dest, &dest.tidy(&dest_dir)).await;
1008 let mut resolved = Vec::with_capacity(sources.len());
1009 for s in &sources {
1010 let s = src.tidy(s);
1011 let parent = realpath_now(&src, &src.parent(&s)).await;
1012 resolved.push(src.join(&parent, &src.name(&s)));
1013 }
1014 let sources = resolved;
1015 let is_dir = match &dest {
1018 End::Local => std::fs::metadata(&dest_dir)
1019 .map(|m| m.is_dir())
1020 .map_err(|e| e.kind()),
1021 End::Host { files, .. } => files
1022 .stat(&dest_dir)
1023 .await
1024 .map(|e| e.kind == agentmux_remote::fsproto::Kind::Dir)
1025 .map_err(|e| {
1026 if e.is_not_found() {
1027 std::io::ErrorKind::NotFound
1028 } else {
1029 std::io::ErrorKind::Other
1030 }
1031 }),
1032 };
1033 match is_dir {
1034 Ok(true) => {}
1035 Ok(false) => return Err("The destination isn't a folder.".to_string()),
1036 Err(std::io::ErrorKind::NotFound) => {
1037 return Err("The destination folder isn't there anymore.".to_string())
1038 }
1039 Err(_) => return Err("Couldn't reach the destination folder.".to_string()),
1040 }
1041 if sources.is_empty() {
1042 return Err("Nothing to copy.".to_string());
1043 }
1044 for s in &sources {
1047 match stat_now(&src, s).await? {
1048 Some(_) => {}
1049 None => return Err(format!("“{}” isn't there anymore.", src.name(s))),
1050 }
1051 let name = src.name(s);
1052 if !dest.accepts_name(&name) {
1053 return Err(format!("“{name}” can't be a file name there."));
1054 }
1055 if let Some(why) = dest.refusal(&dest.join(&dest_dir, &name)) {
1056 return Err(why);
1057 }
1058 if kind == FsOpKind::Move {
1059 if let Some(why) = src.refusal(s) {
1060 return Err(why);
1061 }
1062 }
1063 }
1064 let keyed: Vec<(String, String)> = sources.iter().map(|s| (s.clone(), s.clone())).collect();
1065 let items = plan_on_one_host(kind, &src, &keyed, &dest, &dest_dir)?;
1066 let shown_dest = dest.show(&dest_dir);
1067 let requested = sources.len();
1068 JOBS.start_host(
1069 kind,
1070 requested,
1071 shown_dest,
1072 emit,
1073 Box::new(move |job| {
1074 let t = Transfer {
1075 rt: &rt,
1076 kind,
1077 src,
1078 dest,
1079 measured: Default::default(),
1080 };
1081 let (mut total_items, mut total_bytes) = (0, 0);
1082 for item in &items {
1083 let (i, b) = t.measure(job, &item.src);
1084 total_items += i;
1085 total_bytes += b;
1086 }
1087 if job.is_canceled() {
1088 return Err(HostStop::Canceled);
1089 }
1090 job.add_total(total_items, total_bytes);
1091 for item in &items {
1092 let name = t.src.name(&item.src);
1093 let target = if item.duplicate {
1094 match keep_both_name(&t.dest, &rt, &dest_dir, &name) {
1097 Ok(d) => d,
1098 Err(e) => {
1099 job.fail(&t.src.show(&item.src), e);
1100 t.pass_over(job, &item.src);
1101 continue;
1102 }
1103 }
1104 } else {
1105 t.dest.join(&dest_dir, &name)
1106 };
1107 if t.entry(job, &item.src, &target) == Outcome::Stopped {
1108 return Err(HostStop::Canceled);
1109 }
1110 }
1111 Ok(())
1112 }),
1113 )
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119 #[cfg(target_os = "linux")]
1120 use {crate::backend::rpc_types::{FsOpEvent, FsOpEventState}, std::sync::Mutex};
1121
1122 #[cfg(target_os = "linux")]
1123 fn events() -> (super::super::jobs::Emit, Arc<Mutex<Vec<FsOpEvent>>>) {
1124 let seen = Arc::new(Mutex::new(Vec::new()));
1125 let s = seen.clone();
1126 (
1127 Arc::new(move |e: &FsOpEvent| s.lock().unwrap().push(e.clone())),
1128 seen,
1129 )
1130 }
1131
1132 #[cfg(target_os = "linux")]
1133 async fn finished(seen: &Arc<Mutex<Vec<FsOpEvent>>>) -> FsOpEvent {
1134 for _ in 0..500 {
1135 if let Some(e) = seen
1136 .lock()
1137 .unwrap()
1138 .iter()
1139 .find(|e| {
1140 matches!(
1141 e.state,
1142 FsOpEventState::Done | FsOpEventState::Failed | FsOpEventState::Canceled
1143 )
1144 })
1145 .cloned()
1146 {
1147 return e;
1148 }
1149 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1150 }
1151 panic!("the op never finished");
1152 }
1153
1154 #[cfg(target_os = "linux")]
1155 async fn host(home: &Path) -> End {
1156 let (r, w) = crate::backend::remote::files::testing::helper(home.to_path_buf());
1157 End::host(
1158 RemoteFiles::over("testhost", r, w, Box::new(()))
1159 .await
1160 .unwrap(),
1161 )
1162 }
1163
1164 #[cfg(target_os = "linux")]
1168 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1169 async fn files_and_folders_go_up_to_a_host_and_back_down() {
1170 let local = tempfile::tempdir().unwrap();
1171 let remote = tempfile::tempdir().unwrap();
1172 let big: Vec<u8> = (0..(5 << 20) + 17).map(|i| (i % 251) as u8).collect();
1173 std::fs::create_dir_all(local.path().join("proj/src")).unwrap();
1174 std::fs::write(local.path().join("proj/src/big.bin"), &big).unwrap();
1175 std::fs::write(local.path().join("proj/readme.md"), b"hi").unwrap();
1176 let h = host(remote.path()).await;
1177
1178 let (emit, seen) = events();
1179 let src = display_path(&local.path().join("proj"));
1180 let dest_dir = display_path(remote.path());
1181 start(
1182 FsOpKind::Copy,
1183 End::Local,
1184 vec![src],
1185 h.clone(),
1186 dest_dir,
1187 emit,
1188 )
1189 .await
1190 .unwrap();
1191 let end = finished(&seen).await;
1192 assert_eq!(end.state, FsOpEventState::Done, "{end:?}");
1193 assert_eq!(end.done_bytes, end.total_bytes);
1194 assert_eq!(
1195 std::fs::read(remote.path().join("proj/src/big.bin")).unwrap(),
1196 big
1197 );
1198 assert_eq!(
1199 std::fs::read(remote.path().join("proj/readme.md")).unwrap(),
1200 b"hi"
1201 );
1202 assert!(std::fs::read_dir(remote.path().join("proj/src"))
1204 .unwrap()
1205 .all(|e| !e
1206 .unwrap()
1207 .file_name()
1208 .to_string_lossy()
1209 .starts_with(".agentmux-part")));
1210
1211 let down = tempfile::tempdir().unwrap();
1213 let (emit, seen) = events();
1214 let src = display_path(&remote.path().join("proj"));
1215 start(
1216 FsOpKind::Move,
1217 h,
1218 vec![src],
1219 End::Local,
1220 display_path(down.path()),
1221 emit,
1222 )
1223 .await
1224 .unwrap();
1225 let end = finished(&seen).await;
1226 assert_eq!(end.state, FsOpEventState::Done, "{end:?}");
1227 assert_eq!(
1228 std::fs::read(down.path().join("proj/src/big.bin")).unwrap(),
1229 big
1230 );
1231 assert!(!remote.path().join("proj").exists());
1232 }
1233
1234 #[cfg(target_os = "linux")]
1238 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1239 async fn an_existing_file_is_asked_about_never_overwritten_silently() {
1240 let local = tempfile::tempdir().unwrap();
1241 let remote = tempfile::tempdir().unwrap();
1242 std::fs::write(local.path().join("a.txt"), b"new").unwrap();
1243 std::fs::write(remote.path().join("a.txt"), b"old").unwrap();
1244 let h = host(remote.path()).await;
1245 let (emit, seen) = events();
1246 let op = start(
1247 FsOpKind::Copy,
1248 End::Local,
1249 vec![display_path(&local.path().join("a.txt"))],
1250 h,
1251 display_path(remote.path()),
1252 emit,
1253 )
1254 .await
1255 .unwrap();
1256 for _ in 0..500 {
1258 if seen
1259 .lock()
1260 .unwrap()
1261 .iter()
1262 .any(|e| e.state == FsOpEventState::Conflict)
1263 {
1264 break;
1265 }
1266 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1267 }
1268 let asked = seen
1269 .lock()
1270 .unwrap()
1271 .iter()
1272 .find(|e| e.state == FsOpEventState::Conflict)
1273 .cloned()
1274 .unwrap();
1275 let c = asked.conflict.unwrap();
1276 assert_eq!((c.source_size, c.dest_size), (Some(3), Some(3)));
1277 assert!(c.dest.starts_with("testhost:"), "{}", c.dest);
1278 JOBS.resolve(&op.op_id, FsOpChoice::KeepBoth, false)
1279 .unwrap();
1280 let end = finished(&seen).await;
1281 assert_eq!(end.state, FsOpEventState::Done, "{end:?}");
1282 assert_eq!(std::fs::read(remote.path().join("a.txt")).unwrap(), b"old");
1283 assert_eq!(
1284 std::fs::read(remote.path().join("a (2).txt")).unwrap(),
1285 b"new"
1286 );
1287 }
1288
1289 #[cfg(target_os = "linux")]
1291 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1292 async fn a_move_within_one_host_is_a_rename_there() {
1293 let remote = tempfile::tempdir().unwrap();
1294 std::fs::create_dir_all(remote.path().join("a")).unwrap();
1295 std::fs::create_dir_all(remote.path().join("b")).unwrap();
1296 std::fs::write(remote.path().join("a/x.txt"), b"x").unwrap();
1297 let h = host(remote.path()).await;
1298 let (emit, seen) = events();
1299 start(
1300 FsOpKind::Move,
1301 h.clone(),
1302 vec![display_path(&remote.path().join("a/x.txt"))],
1303 h,
1304 display_path(&remote.path().join("b")),
1305 emit,
1306 )
1307 .await
1308 .unwrap();
1309 let end = finished(&seen).await;
1310 assert_eq!(end.state, FsOpEventState::Done, "{end:?}");
1311 assert_eq!(std::fs::read(remote.path().join("b/x.txt")).unwrap(), b"x");
1312 assert!(!remote.path().join("a/x.txt").exists());
1313 }
1314
1315 #[cfg(target_os = "linux")]
1317 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1318 async fn a_canceled_upload_leaves_nothing_behind() {
1319 let local = tempfile::tempdir().unwrap();
1320 let remote = tempfile::tempdir().unwrap();
1321 std::fs::write(local.path().join("huge.bin"), vec![1u8; 16 << 20]).unwrap();
1322 let (r, w) = crate::backend::remote::files::testing::slow_helper(
1324 remote.path().to_path_buf(),
1325 std::time::Duration::from_millis(20),
1326 );
1327 let h = End::host(
1328 RemoteFiles::over("testhost", r, w, Box::new(()))
1329 .await
1330 .unwrap(),
1331 );
1332 let (emit, seen) = events();
1333 let op = start(
1334 FsOpKind::Copy,
1335 End::Local,
1336 vec![display_path(&local.path().join("huge.bin"))],
1337 h,
1338 display_path(remote.path()),
1339 emit,
1340 )
1341 .await
1342 .unwrap();
1343 for _ in 0..500 {
1344 if seen.lock().unwrap().iter().any(|e| e.done_bytes > 0) {
1345 break;
1346 }
1347 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1348 }
1349 JOBS.cancel(&op.op_id);
1350 let end = finished(&seen).await;
1351 assert_eq!(end.state, FsOpEventState::Canceled, "{end:?}");
1352 let left: Vec<String> = std::fs::read_dir(remote.path())
1353 .unwrap()
1354 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1355 .collect();
1356 assert!(left.is_empty(), "{left:?}");
1357 }
1358
1359 #[cfg(target_os = "linux")]
1361 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1362 async fn a_protected_folder_on_the_host_is_refused() {
1363 let local = tempfile::tempdir().unwrap();
1364 let remote = tempfile::tempdir().unwrap();
1365 std::fs::write(local.path().join("a.txt"), b"a").unwrap();
1366 let h = host(remote.path()).await;
1367 let err = start(
1369 FsOpKind::Copy,
1370 End::Local,
1371 vec![display_path(&local.path().join("a.txt"))],
1372 h,
1373 "/etc".into(),
1374 events().0,
1375 )
1376 .await
1377 .unwrap_err();
1378 assert!(err.contains("doesn't change"), "{err}");
1379 assert!(!std::path::Path::new("/etc/a.txt").exists());
1380 }
1381
1382 #[cfg(target_os = "linux")]
1385 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1386 async fn within_one_host_the_local_plans_rules_hold() {
1387 let remote = tempfile::tempdir().unwrap();
1388 let r = display_path(remote.path());
1389 std::fs::create_dir_all(remote.path().join("d/d/inner")).unwrap();
1390 std::fs::write(remote.path().join("d/a.txt"), b"a").unwrap();
1391 let h = host(remote.path()).await;
1392 let into_itself = start(
1393 FsOpKind::Copy,
1394 h.clone(),
1395 vec![format!("{r}/d")],
1396 h.clone(),
1397 format!("{r}/d/d"),
1398 events().0,
1399 )
1400 .await;
1401 assert!(into_itself.unwrap_err().contains("into itself"));
1402 let landing = start(
1403 FsOpKind::Copy,
1404 h.clone(),
1405 vec![format!("{r}/d/d")],
1406 h.clone(),
1407 r.clone(),
1408 events().0,
1409 )
1410 .await;
1411 assert!(landing.unwrap_err().contains("would land on the folder"));
1412
1413 let (emit, seen) = events();
1415 start(
1416 FsOpKind::Copy,
1417 h.clone(),
1418 vec![format!("{r}/d/a.txt")],
1419 h,
1420 format!("{r}/d"),
1421 emit,
1422 )
1423 .await
1424 .unwrap();
1425 let end = finished(&seen).await;
1426 assert_eq!(end.state, FsOpEventState::Done, "{end:?}");
1427 assert!(!seen
1428 .lock()
1429 .unwrap()
1430 .iter()
1431 .any(|e| e.state == FsOpEventState::Conflict));
1432 assert_eq!(
1433 std::fs::read(remote.path().join("d/a (2).txt")).unwrap(),
1434 b"a"
1435 );
1436 assert_eq!(std::fs::read(remote.path().join("d/a.txt")).unwrap(), b"a");
1437 }
1438
1439 #[cfg(target_os = "linux")]
1441 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1442 async fn a_link_moves_within_one_host_and_is_not_copied() {
1443 let remote = tempfile::tempdir().unwrap();
1444 let r = display_path(remote.path());
1445 std::fs::create_dir_all(remote.path().join("to")).unwrap();
1446 std::fs::write(remote.path().join("target.txt"), b"t").unwrap();
1447 std::os::unix::fs::symlink("target.txt", remote.path().join("l1")).unwrap();
1448 std::os::unix::fs::symlink("target.txt", remote.path().join("l2")).unwrap();
1449 let h = host(remote.path()).await;
1450 let (emit, seen) = events();
1451 start(
1452 FsOpKind::Move,
1453 h.clone(),
1454 vec![format!("{r}/l1")],
1455 h.clone(),
1456 format!("{r}/to"),
1457 emit,
1458 )
1459 .await
1460 .unwrap();
1461 let end = finished(&seen).await;
1462 assert!(end.failures.is_none(), "{end:?}");
1463 let moved = remote.path().join("to/l1");
1464 assert!(std::fs::symlink_metadata(&moved)
1465 .unwrap()
1466 .file_type()
1467 .is_symlink());
1468 assert_eq!(
1469 std::fs::read_link(&moved).unwrap(),
1470 std::path::Path::new("target.txt")
1471 );
1472
1473 let (emit, seen) = events();
1474 start(
1475 FsOpKind::Copy,
1476 h.clone(),
1477 vec![format!("{r}/l2")],
1478 h,
1479 format!("{r}/to"),
1480 emit,
1481 )
1482 .await
1483 .unwrap();
1484 let end = finished(&seen).await;
1485 let failures = end.failures.unwrap_or_default();
1486 assert!(
1487 failures[0]
1488 .error
1489 .as_deref()
1490 .unwrap_or("")
1491 .contains("copying links on a host"),
1492 "{failures:?}"
1493 );
1494 assert!(!remote.path().join("to/l2").exists());
1495 }
1496
1497 #[cfg(target_os = "linux")]
1500 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1501 async fn aliases_specials_and_metadata() {
1502 let local = tempfile::tempdir().unwrap();
1503 let remote = tempfile::tempdir().unwrap();
1504 let r = display_path(remote.path());
1505 std::fs::create_dir_all(remote.path().join("a")).unwrap();
1506 std::os::unix::fs::symlink(remote.path().join("a"), remote.path().join("link")).unwrap();
1507 let h = host(remote.path()).await;
1508 let err = start(
1510 FsOpKind::Copy,
1511 h.clone(),
1512 vec![format!("{r}/a")],
1513 h.clone(),
1514 format!("{r}/link"),
1515 events().0,
1516 )
1517 .await
1518 .unwrap_err();
1519 assert!(err.contains("into itself"), "{err}");
1520
1521 let fifo = remote.path().join("pipe");
1523 assert!(std::process::Command::new("mkfifo")
1524 .arg(&fifo)
1525 .status()
1526 .unwrap()
1527 .success());
1528 let (emit, seen) = events();
1529 start(
1530 FsOpKind::Copy,
1531 h.clone(),
1532 vec![format!("{r}/pipe")],
1533 End::Local,
1534 display_path(local.path()),
1535 emit,
1536 )
1537 .await
1538 .unwrap();
1539 let end = finished(&seen).await;
1540 let failures = end.failures.unwrap_or_default();
1541 assert!(
1542 failures[0]
1543 .error
1544 .as_deref()
1545 .unwrap_or("")
1546 .contains("isn't a regular file"),
1547 "{failures:?}"
1548 );
1549
1550 use std::os::unix::fs::PermissionsExt;
1552 let tool = local.path().join("tool.sh");
1553 std::fs::write(&tool, b"#!/bin/sh\n").unwrap();
1554 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o750)).unwrap();
1555 let when = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000);
1556 std::fs::File::options()
1557 .write(true)
1558 .open(&tool)
1559 .unwrap()
1560 .set_modified(when)
1561 .unwrap();
1562 let (emit, seen) = events();
1563 start(
1564 FsOpKind::Move,
1565 End::Local,
1566 vec![display_path(&tool)],
1567 h,
1568 r.clone(),
1569 emit,
1570 )
1571 .await
1572 .unwrap();
1573 let end = finished(&seen).await;
1574 assert_eq!(end.state, FsOpEventState::Done, "{end:?}");
1575 let landed = std::fs::metadata(remote.path().join("tool.sh")).unwrap();
1576 assert_eq!(landed.permissions().mode() & 0o7777, 0o750);
1577 assert_eq!(landed.modified().unwrap(), when);
1578 assert!(!tool.exists());
1579 }
1580
1581 #[cfg(target_os = "linux")]
1584 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1585 async fn a_linked_parent_does_not_spell_past_protection() {
1586 let remote = tempfile::tempdir().unwrap();
1587 let r = display_path(remote.path());
1588 std::os::unix::fs::symlink("/", remote.path().join("etc-parent")).unwrap();
1590 let h = host(remote.path()).await;
1591 let local = tempfile::tempdir().unwrap();
1592 std::fs::write(local.path().join("a.txt"), b"a").unwrap();
1593 let err = start(
1594 FsOpKind::Copy,
1595 End::Local,
1596 vec![display_path(&local.path().join("a.txt"))],
1597 h.clone(),
1598 format!("{r}/etc-parent/etc"),
1599 events().0,
1600 )
1601 .await
1602 .unwrap_err();
1603 assert!(err.contains("doesn't change"), "{err}");
1604 let moved = start(
1605 FsOpKind::Move,
1606 h,
1607 vec![format!("{r}/etc-parent/etc")],
1608 End::Local,
1609 display_path(local.path()),
1610 events().0,
1611 )
1612 .await
1613 .unwrap_err();
1614 assert!(moved.contains("doesn't change"), "{moved}");
1615 }
1616
1617 #[cfg(target_os = "linux")]
1619 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1620 async fn bad_sources_are_refused_before_an_op_exists() {
1621 let remote = tempfile::tempdir().unwrap();
1622 let r = display_path(remote.path());
1623 let h = host(remote.path()).await;
1624 let missing = start(
1625 FsOpKind::Move,
1626 h.clone(),
1627 vec![format!("{r}/nope")],
1628 End::Local,
1629 "/tmp".into(),
1630 events().0,
1631 )
1632 .await;
1633 assert!(missing.unwrap_err().contains("isn't there anymore"));
1634 std::fs::write(remote.path().join("f"), b"f").unwrap();
1635 let protected = start(
1636 FsOpKind::Copy,
1637 h,
1638 vec![format!("{r}/f")],
1639 End::Local,
1640 "/etc".into(),
1641 events().0,
1642 )
1643 .await;
1644 assert!(protected.is_err());
1645 }
1646
1647 #[cfg(target_os = "linux")]
1650 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1651 async fn a_read_only_file_moves_and_stays_read_only() {
1652 use std::os::unix::fs::PermissionsExt;
1653 let local = tempfile::tempdir().unwrap();
1654 let remote = tempfile::tempdir().unwrap();
1655 let f = local.path().join("ro.txt");
1656 std::fs::write(&f, b"ro").unwrap();
1657 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o444)).unwrap();
1658 let h = host(remote.path()).await;
1659 let (emit, seen) = events();
1660 start(
1661 FsOpKind::Move,
1662 End::Local,
1663 vec![display_path(&f)],
1664 h,
1665 display_path(remote.path()),
1666 emit,
1667 )
1668 .await
1669 .unwrap();
1670 let end = finished(&seen).await;
1671 assert!(end.failures.is_none(), "{end:?}");
1672 let landed = remote.path().join("ro.txt");
1673 assert_eq!(std::fs::read(&landed).unwrap(), b"ro");
1674 assert_eq!(
1675 std::fs::metadata(&landed).unwrap().permissions().mode() & 0o777,
1676 0o444
1677 );
1678 assert!(!f.exists());
1679 }
1680
1681 #[cfg(target_os = "linux")]
1683 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1684 async fn a_local_backslash_name_goes_up_as_itself() {
1685 let local = tempfile::tempdir().unwrap();
1686 let remote = tempfile::tempdir().unwrap();
1687 let name = format!("a{}b.txt", '\u{5c}');
1688 std::fs::write(local.path().join(&name), b"x").unwrap();
1689 let h = host(remote.path()).await;
1690 let (emit, seen) = events();
1691 start(
1692 FsOpKind::Copy,
1693 End::Local,
1694 vec![display_path(&local.path().join(&name))],
1695 h,
1696 display_path(remote.path()),
1697 emit,
1698 )
1699 .await
1700 .unwrap();
1701 let end = finished(&seen).await;
1702 assert_eq!(end.state, FsOpEventState::Done, "{end:?}");
1703 assert_eq!(std::fs::read(remote.path().join(&name)).unwrap(), b"x");
1704 assert!(!remote.path().join("b.txt").exists());
1705 }
1706
1707 #[test]
1709 fn only_a_plain_name_crosses_to_this_computer() {
1710 for bad in ["", ".", "..", "a/b", "nul\0x"] {
1711 assert!(!name_ok(bad, false) && !name_ok(bad, true), "{bad:?}");
1712 }
1713 for bad in [
1714 "..\\..\\x",
1715 "a\\b.txt",
1716 "c:x",
1717 "a:b",
1718 "x?",
1719 "trail.",
1720 "trail ",
1721 ] {
1722 assert!(!name_ok(bad, true), "{bad:?} on Windows");
1723 }
1724 assert!(name_ok("a\\b.txt", false) && name_ok("a:b", false));
1726 assert!(name_ok("notes.md", true) && name_ok(".bashrc", true));
1727 }
1728
1729 #[test]
1730 fn one_hosts_plan_matches_the_local_rules() {
1731 assert!(
1732 under("/a/b", "/a") && under("/a", "/") && !under("/ab", "/a") && !under("/a", "/a")
1733 );
1734 }
1735
1736 #[cfg(target_os = "linux")]
1738 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1739 async fn a_backslash_is_part_of_a_hosts_file_name() {
1740 let local = tempfile::tempdir().unwrap();
1741 let remote = tempfile::tempdir().unwrap();
1742 std::fs::write(remote.path().join("a\\b.txt"), b"x").unwrap();
1743 let h = host(remote.path()).await;
1744 let (emit, seen) = events();
1745 let src = format!("{}/a\\b.txt", display_path(remote.path()));
1746 start(
1747 FsOpKind::Copy,
1748 h,
1749 vec![src],
1750 End::Local,
1751 display_path(local.path()),
1752 emit,
1753 )
1754 .await
1755 .unwrap();
1756 let end = finished(&seen).await;
1757 assert_eq!(end.state, FsOpEventState::Done, "{end:?}");
1758 assert_eq!(std::fs::read(local.path().join("a\\b.txt")).unwrap(), b"x");
1759 assert!(!local.path().join("b.txt").exists());
1760 }
1761
1762 #[test]
1763 fn host_paths_join_and_split_as_posix() {
1764 assert_eq!(posix_join("/home/u", "a.txt"), "/home/u/a.txt");
1765 assert_eq!(posix_join("/", "a"), "/a");
1766 assert_eq!(posix_parent("/home/u/a.txt"), "/home/u");
1767 assert_eq!(posix_parent("/a"), "/");
1768 assert_eq!(posix_parent("/"), "/");
1769 assert_eq!(file_name("/a/b/c.txt"), "c.txt");
1771 assert_eq!(file_name("C:\\x\\y.md"), "y.md");
1772 assert_eq!(file_name("/a/b/"), "b");
1773 }
1774}