Skip to main content

agentmux_srv/backend/fs_ops/
host_jobs.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Copy and move between this computer and an SSH host, or within one host
5//! (spec §6.3 of SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md):
6//! the same `fs.op` as a local copy, with the same progress, cancel and
7//! conflict questions (`jobs::Jobs::start_host`), the host's side carried by
8//! its helper (`remote::files`).
9//!
10//! - A file goes to a temp file beside its destination, in pieces (ranged
11//!   reads, appends), and is renamed into place only when complete, never
12//!   over anything the user didn't choose to replace.
13//! - A folder onto a folder merges; anything else already there is asked
14//!   about (replace, skip, keep both).
15//! - Links aren't copied between two machines: where one points means
16//!   something only on its own side.
17//! - A move removes each source only once it landed; within one host it is a
18//!   rename on that host.
19//! - Nothing in a protected place is changed: this computer's rules
20//!   (`ProtectedPaths`), a host's system folders, root and home folder
21//!   (`remote::protected`).
22
23use 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/// One side of a transfer.
36#[derive(Clone)]
37pub enum End {
38    /// This computer.
39    Local,
40    /// An SSH host, through its helper; `home` in tidy form.
41    Host {
42        files: Arc<RemoteFiles>,
43        home: String,
44    },
45}
46
47/// What a path is, on either side.
48#[derive(Debug, Clone, Copy)]
49struct Info {
50    is_dir: bool,
51    is_link: bool,
52    /// A regular file (not a device, socket or pipe).
53    is_file: bool,
54    size: u64,
55    mtime: Option<u64>,
56    /// Unix permission bits; 0 where there are none.
57    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
70/// A rename that found its target taken.
71enum 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    /// Both are the same host's helper.
87    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    /// Why `path` may not be changed, if it may not.
112    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    /// Up to `len` bytes of a file from `offset`, and whether it ends there.
181    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    /// A new, empty temp file in `dir`, for a piece-by-piece copy.
206    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    /// Rename, never over anything.
236    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    /// Rename the file `from` over the file `to` in one step: what is there
258    /// goes only once the new one is in place.
259    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    /// Give a file the source's permission bits and modification time.
267    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    /// Flush a file's contents to disk.
303    fn sync(&self, rt: &Handle, path: &str) -> Result<(), String> {
304        match self {
305            // Read-only on Unix (fsync needs no write access, and a read-only
306            // file must sync too); Windows flushes only a handle it can write.
307            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    /// Remove a file, a link, or an empty folder. A read-only file goes too
318    /// (Windows refuses to remove one otherwise).
319    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    /// The last part of `path` as this side spells paths: `/` only on a host
334    /// and on Linux or macOS (a `\` there is part of a name); on Windows,
335    /// either separator.
336    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            // This computer's own rules: Windows takes either separator; on
345            // Linux and macOS a backslash is part of a name, as on a host.
346            End::Local if cfg!(windows) => file_name(path),
347            End::Local | End::Host { .. } => posix(path),
348        }
349    }
350
351    /// The display form of `path` on this side, naming the host.
352    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    /// `path` as this side spells it: a host's resolved under its home.
360    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
368/// `name` in `dir`, a host's POSIX path.
369fn 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
377/// The folder a host's POSIX path is in; `/` at the top.
378fn 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
385/// A local entry's Unix permission bits (0 elsewhere).
386fn 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
399/// Whether `name` is one plain name on a side (`windows`: this computer is
400/// Windows): never a path. A host's names can hold `\`, `:` or `..\` that
401/// would mean other folders here, so whatever a host names is checked before
402/// it becomes a path on this computer.
403fn 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        // A trailing dot or space is dropped by Windows: `a.` would be `a`.
411        if name.chars().any(bad) || name.ends_with('.') || name.ends_with(' ') {
412            return false;
413        }
414    }
415    true
416}
417
418impl End {
419    /// Whether `name` (from the other side) can be one name here.
420    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
428/// A local source file, opened once: never through a link swapped in after
429/// it was checked, never stuck opening a pipe; whatever was opened must be a
430/// regular file.
431fn 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
453/// Up to `len` bytes from an open file, and whether it ends there.
454fn 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
470/// `name (n).ext` for the first free n.
471fn 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/// What became of one entry.
486#[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    /// Each measured subtree's (items, bytes), by source path.
499    measured: std::cell::RefCell<std::collections::HashMap<String, (u64, u64)>>,
500}
501
502impl Transfer<'_> {
503    /// Items and file bytes under `path` on the source.
504    /// Items and file bytes under `path` on the source, each subtree's
505    /// totals kept for [`Self::pass_over`]. Stops early when the op is
506    /// canceled: every entry is a round trip to a host.
507    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    /// Count `path`'s subtree as handled without copying it (skipped,
529    /// failed, or moved by a rename), from what [`Self::measure`] found.
530    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        // A link moves within one host as itself (a rename there); copied, or
556        // between two machines, it isn't: where it points means something
557        // only on its own side, and the helper makes no links.
558        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        // Within one host, a move is a rename there. A link only ever moves
646        // this way (never read through): to replace, what is there goes first.
647        if info.is_link && replace {
648            // Only a move within one host gets here: in one step there.
649            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                // A link can't be copied instead.
679                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        // A local source: opened once, checked, then read from that handle.
770        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        // Pieces: the job's chunk, within what one message may carry.
783        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        // A move deletes the only other copy next: on disk first (before the
819        // source's mode, which may make the file read-only).
820        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        // The source's permissions and time, on the temp file before it goes
829        // in, so a moved program still runs. Best effort, as for a local
830        // copy: a disk that refuses (FAT, some network shares) still gets it.
831        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        // Replacing: in one step, so what is there goes only once the copy is.
835        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
877/// One source to transfer, as planned.
878struct Item {
879    src: String,
880    /// Copied into the folder it is already in: kept as `name (n)`.
881    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
890/// The local plan's rules (`jobs::plan`) where source and destination are
891/// one host's folders: a folder never into itself or a folder of its own; a
892/// move into the folder it is in is nothing to do, a copy there is a
893/// duplicate; and no destination lands on a folder holding another source.
894/// Between two machines none of this can happen.
895/// `sources` are `(as given, resolved)` pairs: the resolved one has its
896/// parent's links resolved (`Realpath`), its own name kept, so a link to an
897/// ancestor isn't taken for the ancestor; `dest_dir` is resolved too.
898fn 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    // E.g. copying `/a/d/d` into `/a` would merge into `/a/d`, which holds
936    // the source itself.
937    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
962/// `path`'s entry on `end`, awaited (before the op's thread exists).
963async 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
978/// `path` with its links resolved, awaited; as given when it can't be.
979async 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
991/// Start a copy or move of `sources` (on `src`) into `dest_dir` (on `dest`)
992/// as an `fs.op` (`emit` carries its events). At least one side is a host.
993pub 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    // Where things really are: the destination folder and each source's
1003    // parent with their links resolved (a source keeps its own name, so a
1004    // link is moved as a link). Every check below, and the transfer itself,
1005    // use these, so a link in a parent (`/tmp/x` to `/home`) can't spell its
1006    // way past a protected place.
1007    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    // Checked before answering, as a local copy's plan is (awaited here: only
1016    // the op's own thread blocks on the runtime).
1017    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    // Each source there, and nothing protected, before an op id is given: a
1045    // cut is used up only once the move has been accepted.
1046    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                    // Copied into the folder it is in: always beside itself,
1095                    // as `name (n)`, never a question about itself.
1096                    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    /// Up to a host and back down, folders and all, in pieces bigger than
1165    /// one read; links stay behind with a reason.
1166    // Linux: a host's paths are POSIX, so the test home's must be.
1167    #[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        // No temp file left on the host.
1203        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        // Back down, as a move: the host's copy goes once it landed.
1212        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    /// Something already at the destination is asked about; Skip leaves it,
1235    /// and Keep both puts the copy beside it.
1236    // Linux: a host's paths are POSIX, so the test home's must be.
1237    #[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        // Wait for the question, then keep both.
1257        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    /// Within one host a move is a rename there; nothing comes through here.
1290    #[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    /// Canceled mid-file: nothing half-written is left on the host.
1316    #[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        // A slow link: each piece takes a while, so the cancel lands halfway.
1323        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    /// A host's system folders are never written, whatever is asked.
1360    #[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        // Refused before an op exists.
1368        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    /// Within one host: never a folder into itself, a copy into its own
1383    /// folder is a duplicate, and nothing lands on a folder holding a source.
1384    #[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        // A copy beside itself: `a (2).txt`, nothing asked.
1414        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    /// Within one host a link moves as itself; copied, it is reported.
1440    #[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    /// A link that is the folder itself is caught (#4296); a FIFO is
1498    /// refused; the source's mode and time are kept.
1499    #[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        // `link` is `a`: copying `a` into it is into itself.
1509        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        // A FIFO isn't streamed (it would block): refused with why.
1522        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        // Mode and time come along.
1551        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    /// A protected folder named through a link in its parent is still that
1582    /// folder: refused (#4296).
1583    #[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        // `etc-parent` is `/`: `etc-parent/etc` is `/etc`.
1589        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    /// A missing source or a protected place is refused before the op starts.
1618    #[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    /// A read-only file moves between machines and lands read-only (synced
1648    /// before its mode is set, so the sync can open it).
1649    #[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    /// A local name with a backslash (Linux, macOS) goes up as itself.
1682    #[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    /// A host's name never becomes more than one name here (#4296).
1708    #[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        // Fine on a Unix machine: a backslash or colon is just a character.
1725        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    /// On a host a backslash is part of a name: `a\b.txt` stays itself.
1737    #[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        // Names from either side: a host's path, or this computer's.
1770        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}