Skip to main content

rig_tools/
remote.rs

1//! Workspaces on other machines: [`Remote`], one machine reached through a
2//! transport, and [`Hosts`], which sends each path written `HOST:PATH` to
3//! that host's `Remote` and every other path to the workspace it wraps.
4//!
5//! A transport is a command that runs its last argument, a shell line, on
6//! the other machine with the remote user's shell in their home, forwards
7//! its stdin, keeps its stdout and stderr apart, ends with its exit status,
8//! and ends with 255 when the transport itself failed: `ssh` does, and so
9//! does `koh exec`. Every file operation is one short `sh` script run that
10//! way, at most [`CALLS_PER_HOST`] at once, and every process a local
11//! transport process running the command there.
12//!
13//! ```no_run
14//! use std::path::Path;
15//! use std::sync::Arc;
16//! use rig_tools::process::Launcher;
17//! use rig_tools::remote::{Hosts, Remote};
18//! use rig_tools::workspace::{Local, Workspace};
19//!
20//! let bench = ["koh", "exec", "bench", "--sh"];
21//! let dev = ["ssh", "-o", "BatchMode=yes", "dev@box"];
22//! let hosts = Hosts::new(
23//!     Arc::new(Local),
24//!     [
25//!         Remote::new("bench", bench, Launcher::default())?,
26//!         Remote::new("box", dev, Launcher::default())?,
27//!     ],
28//! );
29//! let text = hosts.read(Path::new("bench:~/strata/src/lib.rs"), 4096)?;
30//! # Ok::<(), std::io::Error>(())
31//! ```
32
33use std::collections::BTreeMap;
34use std::ffi::{OsStr, OsString};
35use std::fmt;
36use std::io::{self, ErrorKind, Write as _};
37use std::path::{Component, Path, PathBuf};
38use std::process::Stdio;
39use std::sync::{Arc, Condvar, Mutex, PoisonError};
40use std::thread;
41
42use ignore::overrides::OverrideBuilder;
43
44use rig_core::tool::{ContentHash, content_hash};
45
46use crate::process::{Launcher, Process, Spawn};
47use crate::workspace::{
48    Contents, Excerpt, Listing, Stat, Workspace, excerpt_of, not_text, read_file, too_large,
49};
50use crate::{MAX_BYTES, MAX_FILE_BYTES};
51
52/// The exit status with which a transport says it failed, as `ssh` does.
53pub const TRANSPORT_FAILED: i32 = 255;
54/// How many files one script of [`Remote::read_files`] reads.
55const FILES_PER_CALL: usize = 100;
56/// How many file operations run on one host at once; the rest wait for a
57/// turn. Each is a connection of its own, and a server may drop some of a
58/// burst of new connections from one client, as one that takes only two
59/// handshakes from a client at a time does.
60pub const CALLS_PER_HOST: usize = 2;
61
62/// The exit codes the scripts below say what went wrong with.
63const NOT_FOUND: i32 = 2;
64const NOT_A_FILE: i32 = 3;
65const READ_ONLY: i32 = 4;
66const A_DIRECTORY: i32 = 5;
67const ROOT_IS_A_FILE: i32 = 6;
68
69/// Another machine, reached through a transport (see the [module](self)
70/// docs). Its paths are that machine's: absolute, or under `~`, its user's
71/// home; a relative path is relative to that home, where the transport
72/// starts. Paths are folded without asking the machine
73/// ([`canonical`](Workspace::canonical)), so links there are not followed.
74#[derive(Clone)]
75pub struct Remote {
76    name: String,
77    transport: Vec<OsString>,
78    /// How the transport starts for a file operation: in no directory of
79    /// its own.
80    launcher: Launcher,
81    /// The file operations running there now, shared by its clones.
82    running: Arc<(Mutex<usize>, Condvar)>,
83}
84
85/// Whether a script may run again when its transport failed: one that only
86/// reads may, one that writes may not, as it may have done its work.
87#[derive(Clone, Copy, PartialEq, Eq)]
88enum Again {
89    Once,
90    Never,
91}
92
93/// A turn to run a file operation, given back when dropped.
94struct Turn<'a>(&'a (Mutex<usize>, Condvar));
95
96impl<'a> Turn<'a> {
97    /// Waits until fewer than [`CALLS_PER_HOST`] operations run.
98    fn take(running: &'a (Mutex<usize>, Condvar)) -> Self {
99        let (count, freed) = running;
100        let mut count = count.lock().unwrap_or_else(PoisonError::into_inner);
101        while *count >= CALLS_PER_HOST {
102            count = freed.wait(count).unwrap_or_else(PoisonError::into_inner);
103        }
104        *count += 1;
105        Self(running)
106    }
107}
108
109impl Drop for Turn<'_> {
110    fn drop(&mut self) {
111        let (count, freed) = self.0;
112        let mut count = count.lock().unwrap_or_else(PoisonError::into_inner);
113        *count = count.saturating_sub(1);
114        freed.notify_one();
115    }
116}
117
118/// What a remote script printed and how it ended.
119struct Ran {
120    code: Option<i32>,
121    out: Vec<u8>,
122    err: String,
123}
124
125impl fmt::Debug for Remote {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        f.debug_struct("Remote")
128            .field("name", &self.name)
129            .field("transport", &self.transport)
130            .finish()
131    }
132}
133
134impl Remote {
135    /// The machine `name`, such as `bench`, reached through `transport`,
136    /// such as `["ssh", "dev@box"]`, which file operations start under
137    /// `launcher`'s policy (its wrapper and variables; not its directory).
138    ///
139    /// # Errors
140    ///
141    /// An [`InvalidInput`](ErrorKind::InvalidInput) error when `name` is
142    /// empty or holds a `:`, `/` or space, or `transport` is empty.
143    pub fn new(
144        name: impl Into<String>,
145        transport: impl IntoIterator<Item = impl Into<OsString>>,
146        mut launcher: Launcher,
147    ) -> io::Result<Self> {
148        let name = name.into();
149        let transport: Vec<OsString> = transport.into_iter().map(Into::into).collect();
150        if name.is_empty() || name.contains([':', '/']) || name.contains(char::is_whitespace) {
151            return Err(io::Error::new(
152                ErrorKind::InvalidInput,
153                format!("`{name}` is no host name: it is empty or holds a `:`, `/` or space"),
154            ));
155        }
156        if transport.is_empty() {
157            return Err(io::Error::new(
158                ErrorKind::InvalidInput,
159                format!("the transport of {name} is empty"),
160            ));
161        }
162        launcher.cwd = None;
163        Ok(Self {
164            name,
165            transport,
166            launcher,
167            running: Arc::default(),
168        })
169    }
170
171    /// Its name.
172    pub fn name(&self) -> &str {
173        &self.name
174    }
175
176    /// The transport and `script` as its last argument, run with `sh`
177    /// there whatever the remote user's shell is.
178    fn command(&self, script: &str) -> io::Result<(OsString, Vec<OsString>)> {
179        let Some((program, args)) = self.transport.split_first() else {
180            return Err(io::Error::other(format!(
181                "the transport of {} is empty",
182                self.name
183            )));
184        };
185        let mut args = args.to_vec();
186        args.push(format!("sh -c {}", quote(script)).into());
187        Ok((program.clone(), args))
188    }
189
190    /// Runs `script` there once, with `stdin`, when its turn comes, and
191    /// says how it ended.
192    fn once(&self, script: &str, stdin: Option<&[u8]>) -> io::Result<Ran> {
193        let (program, args) = self.command(script)?;
194        let _turn = Turn::take(&self.running);
195        let mut command = self.launcher.command(&program);
196        command
197            .args(args)
198            .stdin(match stdin {
199                Some(_) => Stdio::piped(),
200                None => Stdio::null(),
201            })
202            .stdout(Stdio::piped())
203            .stderr(Stdio::piped());
204        let mut child = command
205            .spawn()
206            .map_err(|error| self.unstarted(&program, error))?;
207        let pipe = child.stdin.take();
208        let output = thread::scope(|scope| {
209            if let (Some(mut pipe), Some(bytes)) = (pipe, stdin) {
210                scope.spawn(move || pipe.write_all(bytes).ok());
211            }
212            child.wait_with_output()
213        })?;
214        Ok(Ran {
215            code: output.status.code(),
216            out: output.stdout,
217            err: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
218        })
219    }
220
221    /// Runs `script` there, failing when the transport failed, after
222    /// trying again once when `again` allows.
223    fn run(&self, script: &str, stdin: Option<&[u8]>, again: Again) -> io::Result<Ran> {
224        let mut ran = self.once(script, stdin)?;
225        if ran.code == Some(TRANSPORT_FAILED) && again == Again::Once {
226            ran = self.once(script, stdin)?;
227        }
228        match ran.code {
229            Some(TRANSPORT_FAILED) => Err(self.failed(&ran, again)),
230            _ => Ok(ran),
231        }
232    }
233
234    /// Runs `script` there: an exit code the script names becomes its
235    /// error, another an error with its stderr.
236    fn checked(&self, script: &str, stdin: Option<&[u8]>, again: Again) -> io::Result<Vec<u8>> {
237        let ran = self.run(script, stdin, again)?;
238        self.outcome(ran, again)
239    }
240
241    /// What `ran` comes to: its output on exit 0, the error an exit code
242    /// the scripts name stands for, or [`failed`](Self::failed).
243    fn outcome(&self, ran: Ran, again: Again) -> io::Result<Vec<u8>> {
244        match ran.code {
245            Some(0) => Ok(ran.out),
246            Some(NOT_FOUND) => Err(ErrorKind::NotFound.into()),
247            Some(NOT_A_FILE) => Err(io::Error::new(
248                ErrorKind::InvalidInput,
249                "not a regular file",
250            )),
251            Some(READ_ONLY) => Err(io::Error::new(
252                ErrorKind::PermissionDenied,
253                "the file is read-only; leave it unchanged or ask the user",
254            )),
255            Some(A_DIRECTORY) => Err(ErrorKind::IsADirectory.into()),
256            _ => Err(self.failed(&ran, again)),
257        }
258    }
259
260    /// What a call fails with when its script ended as `ran` did, with no
261    /// exit status the script names: how it ended, what it said, and, for
262    /// a script that writes, that it may have done its work. The transport
263    /// failed when it ended with [`TRANSPORT_FAILED`], after the tries
264    /// `again` allowed.
265    fn failed(&self, ran: &Ran, again: Again) -> io::Error {
266        let (kind, ended) = self.ended(ran);
267        let after = match again {
268            Again::Once if ran.code == Some(TRANSPORT_FAILED) => " (tried twice)",
269            Again::Once => "",
270            Again::Never => {
271                "; the change may have been made or not, so read the file before trying again"
272            }
273        };
274        io::Error::new(kind, format!("{ended}{after}"))
275    }
276
277    /// How a script that ended as `ran` did ended, and what it said.
278    fn ended(&self, ran: &Ran) -> (ErrorKind, String) {
279        let name = &self.name;
280        let (kind, how) = match ran.code {
281            Some(TRANSPORT_FAILED) => (
282                ErrorKind::NotConnected,
283                format!("the transport to {name} failed (exit status {TRANSPORT_FAILED})"),
284            ),
285            Some(code) => (
286                ErrorKind::Other,
287                format!("the operation on {name} ended with exit status {code}"),
288            ),
289            None => (
290                ErrorKind::Other,
291                format!("the transport to {name} was killed by a signal"),
292            ),
293        };
294        let said = match ran.err.as_str() {
295            "" => " and said nothing".to_owned(),
296            err => format!(": {err}"),
297        };
298        (kind, format!("{how}{said}"))
299    }
300
301    /// Runs `script`, which writes `bytes` to the file `path`, once. When
302    /// it ends with no exit status it names, such as a transport that
303    /// dropped mid-write, the file is hashed there again (a read, so tried
304    /// twice) and the error says whether it holds `bytes`: whether the
305    /// write happened.
306    fn written(&self, path: &Path, script: &str, bytes: &[u8]) -> io::Result<Vec<u8>> {
307        let ran = self.once(script, Some(bytes))?;
308        if matches!(
309            ran.code,
310            Some(0 | NOT_FOUND | NOT_A_FILE | READ_ONLY | A_DIRECTORY)
311        ) {
312            return self.outcome(ran, Again::Never);
313        }
314        let (kind, ended) = self.ended(&ran);
315        let check = format!(
316            "p={}; [ -f \"$p\" ] || exit {NOT_FOUND}; \
317             h=$({{ sha256sum || shasum -a 256; }} < \"$p\" 2>/dev/null); echo \"${{h%% *}}\"",
318            quote_path(path)
319        );
320        let checked = self.checked(&check, None, Again::Once);
321        let sent = content_hash(bytes).0;
322        let found = match checked.as_deref().map(|out| String::from_utf8_lossy(out)) {
323            Ok(hash) if hash.trim() == sent => {
324                "it holds what this write sent, so the write happened".to_owned()
325            }
326            Ok(hash) if !hash.trim().is_empty() => "it does not hold what this write sent, so \
327                 the write did not happen; make the change again"
328                .to_owned(),
329            Err(error) if error.kind() == ErrorKind::NotFound => {
330                "it is not there, so the write did not happen; make the change again".to_owned()
331            }
332            Ok(_) => "it could not be hashed there, so the write may or may not have \
333                 happened; read the file before going on"
334                .to_owned(),
335            Err(error) => format!(
336                "that failed too ({error}), so the write may or may not have happened; read \
337                 the file before going on"
338            ),
339        };
340        Err(io::Error::new(
341            kind,
342            format!("the write failed: {ended}. The file was checked again: {found}"),
343        ))
344    }
345
346    /// What a call fails with when its transport could not start.
347    fn unstarted(&self, program: &OsStr, error: io::Error) -> io::Error {
348        io::Error::other(format!(
349            "could not start `{}`, the transport to {}: {error}",
350            program.to_string_lossy(),
351            self.name
352        ))
353    }
354}
355
356impl Workspace for Remote {
357    /// Its name.
358    fn host(&self) -> Option<&str> {
359        Some(&self.name)
360    }
361
362    fn stat(&self, path: &Path) -> io::Result<Stat> {
363        let script = format!(
364            "p={}; if [ -f \"$p\" ]; then wc -c < \"$p\" || exit 1; \
365             if [ -w \"$p\" ]; then echo w; else echo r; fi; \
366             elif [ -d \"$p\" ]; then echo d; elif [ -e \"$p\" ]; then echo o; else echo m; fi",
367            quote_path(path)
368        );
369        let out = String::from_utf8_lossy(&self.checked(&script, None, Again::Once)?).into_owned();
370        let mut words = out.split_whitespace();
371        Ok(match (words.next(), words.next()) {
372            (Some("d"), _) => Stat::Dir,
373            (Some("o"), _) => Stat::Other,
374            (Some("m"), _) => Stat::Missing,
375            (Some(size), Some(access)) => Stat::File {
376                size: size.parse().map_err(io::Error::other)?,
377                read_only: access == "r",
378                modified: None,
379            },
380            _ => return Err(io::Error::other(format!("stat said `{}`", out.trim()))),
381        })
382    }
383
384    fn read(&self, path: &Path, limit: u64) -> io::Result<Contents> {
385        let script = format!(
386            "p={}; [ -f \"$p\" ] || {{ [ -e \"$p\" ] && exit {NOT_A_FILE}; exit {NOT_FOUND}; }}; \
387             wc -c < \"$p\" && head -c {limit} -- \"$p\"",
388            quote_path(path)
389        );
390        let out = self.checked(&script, None, Again::Once)?;
391        let Some((size, bytes)) = split_line(&out) else {
392            return Err(io::Error::other("the remote read said nothing"));
393        };
394        Ok(Contents {
395            bytes: bytes.to_vec(),
396            size: size.trim().parse().map_err(io::Error::other)?,
397        })
398    }
399
400    /// One script that sends the file's size, its line count, its hash and
401    /// only the lines asked, or the whole file when it is at most
402    /// [`MAX_BYTES`], all of a copy made aside first, so they agree when
403    /// the file changes meanwhile. A machine with neither `sha256sum` nor
404    /// `shasum` sends a larger file whole in a second call, to hash it here.
405    fn excerpt(&self, path: &Path, first: usize, count: usize) -> io::Result<Excerpt> {
406        let first = first.max(1);
407        let last = first.saturating_add(count.max(1) - 1);
408        let script = format!(
409            "p={}; [ -f \"$p\" ] || {{ [ -e \"$p\" ] && exit {NOT_A_FILE}; exit {NOT_FOUND}; }}; \
410             s=$(wc -c < \"$p\") || exit 1; [ $s -le {MAX_FILE_BYTES} ] || {{ echo $s; exit 0; }}; \
411             t=$(mktemp) || exit 1; trap 'rm -f \"$t\"' EXIT; cat -- \"$p\" > \"$t\" || exit 1; \
412             s=$(wc -c < \"$t\") && echo $s && wc -l < \"$t\" && tail -c 1 \"$t\" | wc -l && \
413             {{ sha256sum || shasum -a 256 || echo -; }} < \"$t\" 2>/dev/null && \
414             if [ $s -le {MAX_BYTES} ]; then cat \"$t\"; else sed -n '{first},{last}p;{last}q' \"$t\"; fi",
415            quote_path(path)
416        );
417        let out = self.checked(&script, None, Again::Once)?;
418        let mut rest = out.as_slice();
419        let mut line = || {
420            let (line, after) = split_line(rest)?;
421            rest = after;
422            Some(line.trim().to_owned())
423        };
424        let size: u64 = line()
425            .and_then(|size| size.parse().ok())
426            .ok_or_else(|| io::Error::other("the remote read said no size"))?;
427        if size > MAX_FILE_BYTES {
428            return Err(too_large(size));
429        }
430        let (Some(newlines), Some(ends_in_newline), Some(hash)) = (line(), line(), line()) else {
431            return Err(io::Error::other("the remote read stopped short"));
432        };
433        let text = String::from_utf8(rest.to_vec()).map_err(|_| not_text());
434        if size <= MAX_BYTES as u64 {
435            return Ok(Excerpt::of(&text?, first, count));
436        }
437        let hash = hash.split_whitespace().next().unwrap_or_default();
438        if hash.len() != 64 {
439            return excerpt_of(self, path, first, count);
440        }
441        let newlines: usize = newlines.parse().map_err(io::Error::other)?;
442        let unended = size > 0 && ends_in_newline == "0";
443        Ok(Excerpt {
444            text: text?,
445            total: newlines + usize::from(unended),
446            hash: ContentHash(hash.to_ascii_lowercase()),
447            whole: None,
448        })
449    }
450
451    /// The files in scripts of 100 each, every file copied
452    /// aside before it is sent, so one that changes meanwhile is sent whole
453    /// as it was.
454    fn read_files(&self, paths: &[PathBuf], limit: u64) -> Vec<Option<Vec<u8>>> {
455        let mut read = Vec::with_capacity(paths.len());
456        for chunk in paths.chunks(FILES_PER_CALL) {
457            let quoted: Vec<String> = chunk.iter().map(|path| quote_path(path)).collect();
458            let script = format!(
459                "t=$(mktemp) || exit 1; trap 'rm -f \"$t\"' EXIT; for p in {}; do \
460                 if [ -f \"$p\" ] && n=$(wc -c < \"$p\") && [ $((n)) -le {limit} ] && \
461                 cat -- \"$p\" > \"$t\" 2>/dev/null; then wc -c < \"$t\"; cat \"$t\"; \
462                 else echo -; fi; done",
463                quoted.join(" ")
464            );
465            let out = match self.run(&script, None, Again::Once) {
466                Ok(ran) if ran.code == Some(0) => ran.out,
467                _ => Vec::new(),
468            };
469            let mut rest = out.as_slice();
470            for _ in chunk {
471                let file = split_line(rest).and_then(|(size, after)| {
472                    let size: usize = size.trim().parse().ok()?;
473                    let (bytes, after) = after.split_at_checked(size)?;
474                    Some((bytes, after))
475                });
476                match file {
477                    Some((bytes, after)) => {
478                        read.push(Some(bytes.to_vec()));
479                        rest = after;
480                    }
481                    None => {
482                        read.push(None);
483                        rest = split_line(rest).map_or(&[][..], |(_, after)| after);
484                    }
485                }
486            }
487        }
488        read
489    }
490
491    /// [`replace`](Workspace::replace)s it, sending back nothing of what
492    /// it held.
493    fn write(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
494        self.replace(path, bytes, 0).map(drop)
495    }
496
497    /// Writes a temporary file beside it and moves it over the file, so a
498    /// lost connection never leaves it half written, keeping an existing
499    /// file's permissions; the copy the temporary file starts as is what
500    /// is sent back, in the same call.
501    fn replace(&self, path: &Path, bytes: &[u8], limit: u64) -> io::Result<io::Result<Contents>> {
502        let script = format!(
503            "p={}; if [ -e \"$p\" ]; then [ -f \"$p\" ] || exit {NOT_A_FILE}; \
504             [ -w \"$p\" ] || exit {READ_ONLY}; fi; d=$(dirname -- \"$p\"); \
505             mkdir -p -- \"$d\" && t=$(mktemp \"$d/.rig-write.XXXXXX\") || exit 1; \
506             if [ -e \"$p\" ]; then cp -p -- \"$p\" \"$t\" && wc -c < \"$t\" && \
507             head -c {limit} -- \"$t\"; \
508             else chmod \"$(printf %o $((0666 & ~$(umask))))\" \"$t\"; fi && \
509             cat > \"$t\" && mv -f -- \"$t\" \"$p\" || {{ rm -f -- \"$t\"; exit 1; }}",
510            quote_path(path)
511        );
512        let out = self.written(path, &script, bytes)?;
513        Ok(match split_line(&out) {
514            None => Err(ErrorKind::NotFound.into()),
515            Some((size, bytes)) => size
516                .trim()
517                .parse()
518                .map(|size| Contents {
519                    bytes: bytes.to_vec(),
520                    size,
521                })
522                .map_err(io::Error::other),
523        })
524    }
525
526    /// One script: a copy of the file is made beside it and hashed there,
527    /// then filled with `bytes` and moved over the file when the hash is
528    /// `held`, or sent back when it is not. A machine with neither
529    /// `sha256sum` nor `shasum` always sends it back.
530    fn write_if(
531        &self,
532        path: &Path,
533        bytes: &[u8],
534        held: &ContentHash,
535    ) -> io::Result<Option<Contents>> {
536        let script = format!(
537            "p={}; [ -f \"$p\" ] || {{ [ -e \"$p\" ] && exit {NOT_A_FILE}; exit {NOT_FOUND}; }}; \
538             [ -w \"$p\" ] || exit {READ_ONLY}; \
539             t=$(mktemp \"$(dirname -- \"$p\")/.rig-write.XXXXXX\") || exit 1; \
540             cp -p -- \"$p\" \"$t\" || {{ rm -f -- \"$t\"; exit 1; }}; \
541             h=$({{ sha256sum || shasum -a 256; }} < \"$t\" 2>/dev/null); \
542             if [ \"${{h%% *}}\" = {} ]; then \
543             cat > \"$t\" && mv -f -- \"$t\" \"$p\" && echo = || {{ rm -f -- \"$t\"; exit 1; }}; \
544             else wc -c < \"$t\" && head -c {MAX_FILE_BYTES} -- \"$t\"; s=$?; rm -f -- \"$t\"; exit $s; fi",
545            quote_path(path),
546            quote(&held.0)
547        );
548        let out = self.written(path, &script, bytes)?;
549        let Some((first, rest)) = split_line(&out) else {
550            return Err(io::Error::other("the remote write said nothing"));
551        };
552        if first == "=" {
553            return Ok(None);
554        }
555        Ok(Some(Contents {
556            bytes: rest.to_vec(),
557            size: first.trim().parse().map_err(io::Error::other)?,
558        }))
559    }
560
561    fn remove(&self, path: &Path) -> io::Result<()> {
562        let script = format!(
563            "p={}; [ -d \"$p\" ] && exit {A_DIRECTORY}; \
564             [ -e \"$p\" ] || [ -L \"$p\" ] || exit {NOT_FOUND}; rm -f -- \"$p\"",
565            quote_path(path)
566        );
567        self.checked(&script, None, Again::Never).map(drop)
568    }
569
570    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
571        let script = format!(
572            "f={}; t={}; [ -e \"$f\" ] || exit {NOT_FOUND}; \
573             mkdir -p -- \"$(dirname -- \"$t\")\" && mv -f -- \"$f\" \"$t\"",
574            quote_path(from),
575            quote_path(to)
576        );
577        self.checked(&script, None, Again::Never).map(drop)
578    }
579
580    /// Folds `.` and `..` without asking the machine, a relative path under
581    /// `~`; a `..` above `~` stays, as the remote `sh` reads it.
582    fn canonical(&self, path: &Path) -> PathBuf {
583        let joined = match path.is_absolute() || path.starts_with("~") {
584            true => path.to_path_buf(),
585            false => Path::new("~").join(path),
586        };
587        let mut out = PathBuf::new();
588        for component in joined.components() {
589            match component {
590                Component::CurDir => {}
591                Component::ParentDir => match out.components().next_back() {
592                    Some(Component::Normal(last)) if last != "~" => {
593                        out.pop();
594                    }
595                    Some(Component::RootDir) => {}
596                    _ => out.push(".."),
597                },
598                other => out.push(other),
599            }
600        }
601        out
602    }
603
604    /// As git lists them there in a git work tree (unless `listing` is
605    /// [`All`](Listing::All)), else as `find` does, then filtered by
606    /// [`Listing::keeps`] and `glob`.
607    fn files(&self, root: &Path, glob: Option<&str>, listing: Listing) -> io::Result<Vec<PathBuf>> {
608        let globs = glob
609            .map(|glob| {
610                OverrideBuilder::new(root)
611                    .add(glob)
612                    .and_then(|builder| builder.build())
613                    .map_err(|error| {
614                        io::Error::new(ErrorKind::InvalidInput, format!("bad glob: {error}"))
615                    })
616            })
617            .transpose()?;
618        let git = match listing {
619            Listing::All => "false",
620            Listing::Visible | Listing::Hidden => {
621                "git rev-parse --is-inside-work-tree >/dev/null 2>&1"
622            }
623        };
624        let script = format!(
625            "r={}; [ -f \"$r\" ] && exit {ROOT_IS_A_FILE}; cd \"$r\" || exit {NOT_FOUND}; \
626             if {git}; then git ls-files -z -co --exclude-standard; \
627             else find . -type f -print0; fi",
628            quote_path(root)
629        );
630        let ran = self.run(&script, None, Again::Once)?;
631        match ran.code {
632            Some(ROOT_IS_A_FILE) => return Ok(vec![root.to_path_buf()]),
633            Some(0) => {}
634            Some(NOT_FOUND) => return Err(ErrorKind::NotFound.into()),
635            _ => return Err(self.failed(&ran, Again::Once)),
636        }
637        let mut files: Vec<PathBuf> = String::from_utf8_lossy(&ran.out)
638            .split('\0')
639            .map(|name| Path::new(name.trim_start_matches("./")))
640            .filter(|name| !name.as_os_str().is_empty() && listing.keeps(name))
641            .map(|name| root.join(name))
642            .filter(|path| {
643                globs
644                    .as_ref()
645                    .is_none_or(|globs| globs.matched(path, false).is_whitelist())
646            })
647            .collect();
648        files.sort();
649        Ok(files)
650    }
651
652    /// Runs `program` there in `dir` (`None`: the remote home) with `sh -l`,
653    /// so the remote user's login environment holds, and the launcher's
654    /// variables, from a local transport started under the launcher's
655    /// wrapper (the launcher's directory is this machine's, so it is not
656    /// used). The process is marked [`through`](Process::through) this
657    /// host: stopping it stops the local transport (`SIGTERM`, then
658    /// `SIGKILL`), and whether that stops the command there is the
659    /// transport's to say.
660    fn spawn(
661        &self,
662        launcher: &Launcher,
663        dir: Option<&Path>,
664        program: &OsStr,
665        args: &[OsString],
666        how: Spawn,
667    ) -> io::Result<Process> {
668        let dir = dir.unwrap_or(Path::new("~"));
669        let mut script = format!("cd {} && exec env", quote_path(&self.canonical(dir)));
670        for (name, value) in &launcher.env {
671            let set = format!("{}={}", name.to_string_lossy(), value.to_string_lossy());
672            script.push_str(&format!(" {}", quote(&set)));
673        }
674        script.push_str(" sh -lc 'exec \"$0\" \"$@\"'");
675        for word in std::iter::once(program).chain(args.iter().map(OsString::as_os_str)) {
676            script.push_str(&format!(" {}", quote(&word.to_string_lossy())));
677        }
678        let (transport, line) = self.command(&script)?;
679        let mut launcher = launcher.clone();
680        launcher.cwd = None;
681        launcher
682            .spawn(&transport, line, how)
683            .map(|process| process.through(&self.name))
684            .map_err(|error| self.unstarted(&transport, error))
685    }
686}
687
688/// A workspace (the inner one, such as [`Local`](crate::workspace::Local))
689/// whose paths written `HOST:PATH`, `HOST` the name of one of its
690/// [`Remote`]s, are that host's `PATH`, as `scp` reads them:
691/// `bench:~/strata/src/lib.rs`, `bench:/srv/app`. Every other path is the
692/// inner workspace's. Every path it gives back for a remote file
693/// ([`canonical`](Workspace::canonical), [`files`](Workspace::files)) keeps
694/// the `HOST:` form, so the facts and footprints the tools make of them can
695/// be passed back as they are; a relative path joined onto a `HOST:` base
696/// stays there.
697///
698/// Its [`host`](Workspace::host) is the inner workspace's, as its project
699/// is there; a call on a `HOST:` path waits for the transport.
700#[derive(Clone, Debug)]
701pub struct Hosts {
702    inner: Arc<dyn Workspace>,
703    hosts: BTreeMap<String, Remote>,
704}
705
706impl Hosts {
707    /// `inner`, and `remotes` by their names; a later remote replaces an
708    /// earlier one of the same name.
709    pub fn new(inner: Arc<dyn Workspace>, remotes: impl IntoIterator<Item = Remote>) -> Self {
710        let hosts = remotes
711            .into_iter()
712            .map(|remote| (remote.name.clone(), remote))
713            .collect();
714        Self { inner, hosts }
715    }
716
717    /// The remote `path` names and the path there: `~` for `HOST:` alone.
718    pub fn route(&self, path: &Path) -> Option<(&Remote, PathBuf)> {
719        let (host, rest) = path.to_str()?.split_once(':')?;
720        let remote = self.hosts.get(host)?;
721        Some((
722            remote,
723            PathBuf::from(if rest.is_empty() { "~" } else { rest }),
724        ))
725    }
726
727    /// `path` on `remote` as this workspace names it: `HOST:PATH`.
728    fn named(remote: &Remote, path: &Path) -> PathBuf {
729        let mut named = OsString::from(&remote.name);
730        named.push(":");
731        named.push(path);
732        PathBuf::from(named)
733    }
734}
735
736impl Workspace for Hosts {
737    fn host(&self) -> Option<&str> {
738        self.inner.host()
739    }
740
741    fn join(&self, base: &Path, path: &Path) -> PathBuf {
742        match self.route(path) {
743            Some(_) => path.to_path_buf(),
744            None => self.inner.join(base, path),
745        }
746    }
747
748    fn stat(&self, path: &Path) -> io::Result<Stat> {
749        match self.route(path) {
750            Some((remote, path)) => remote.stat(&path),
751            None => self.inner.stat(path),
752        }
753    }
754
755    fn read(&self, path: &Path, limit: u64) -> io::Result<Contents> {
756        match self.route(path) {
757            Some((remote, path)) => remote.read(&path, limit),
758            None => self.inner.read(path, limit),
759        }
760    }
761
762    /// All in one go on their host when every path is on one, else one by
763    /// one.
764    fn read_files(&self, paths: &[PathBuf], limit: u64) -> Vec<Option<Vec<u8>>> {
765        let first = paths.first().and_then(|path| self.route(path));
766        let there = first.and_then(|(remote, _)| {
767            let there: Option<Vec<PathBuf>> = paths
768                .iter()
769                .map(|path| {
770                    let (other, there) = self.route(path)?;
771                    (other.name == remote.name).then_some(there)
772                })
773                .collect();
774            Some((remote, there?))
775        });
776        match there {
777            Some((remote, there)) => remote.read_files(&there, limit),
778            None => paths
779                .iter()
780                .map(|path| read_file(self, path, limit))
781                .collect(),
782        }
783    }
784
785    fn excerpt(&self, path: &Path, first: usize, count: usize) -> io::Result<Excerpt> {
786        match self.route(path) {
787            Some((remote, path)) => remote.excerpt(&path, first, count),
788            None => self.inner.excerpt(path, first, count),
789        }
790    }
791
792    fn write(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
793        match self.route(path) {
794            Some((remote, path)) => remote.write(&path, bytes),
795            None => self.inner.write(path, bytes),
796        }
797    }
798
799    fn replace(&self, path: &Path, bytes: &[u8], limit: u64) -> io::Result<io::Result<Contents>> {
800        match self.route(path) {
801            Some((remote, path)) => remote.replace(&path, bytes, limit),
802            None => self.inner.replace(path, bytes, limit),
803        }
804    }
805
806    fn write_if(
807        &self,
808        path: &Path,
809        bytes: &[u8],
810        held: &ContentHash,
811    ) -> io::Result<Option<Contents>> {
812        match self.route(path) {
813            Some((remote, path)) => remote.write_if(&path, bytes, held),
814            None => self.inner.write_if(path, bytes, held),
815        }
816    }
817
818    fn remove(&self, path: &Path) -> io::Result<()> {
819        match self.route(path) {
820            Some((remote, path)) => remote.remove(&path),
821            None => self.inner.remove(path),
822        }
823    }
824
825    /// Within one host, or within the inner workspace; a move between them
826    /// is refused.
827    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
828        match (self.route(from), self.route(to)) {
829            (None, None) => self.inner.rename(from, to),
830            (Some((one, from)), Some((other, to))) if one.name == other.name => {
831                one.rename(&from, &to)
832            }
833            _ => Err(io::Error::new(
834                ErrorKind::Unsupported,
835                "a file is not moved between machines; read it and write it there instead",
836            )),
837        }
838    }
839
840    fn canonical(&self, path: &Path) -> PathBuf {
841        match self.route(path) {
842            Some((remote, path)) => Self::named(remote, &remote.canonical(&path)),
843            None => self.inner.canonical(path),
844        }
845    }
846
847    fn files(&self, root: &Path, glob: Option<&str>, listing: Listing) -> io::Result<Vec<PathBuf>> {
848        match self.route(root) {
849            Some((remote, root)) => Ok(remote
850                .files(&root, glob, listing)?
851                .iter()
852                .map(|path| Self::named(remote, path))
853                .collect()),
854            None => self.inner.files(root, glob, listing),
855        }
856    }
857
858    /// On the host `dir` names, else in the inner workspace.
859    fn spawn(
860        &self,
861        launcher: &Launcher,
862        dir: Option<&Path>,
863        program: &OsStr,
864        args: &[OsString],
865        how: Spawn,
866    ) -> io::Result<Process> {
867        match dir.and_then(|dir| self.route(dir)) {
868            Some((remote, dir)) => remote.spawn(launcher, Some(&dir), program, args, how),
869            None => self.inner.spawn(launcher, dir, program, args, how),
870        }
871    }
872
873    /// A remote root is looked at every second, as the default does.
874    fn watch(
875        self: Arc<Self>,
876        root: &Path,
877        recursive: bool,
878        listing: Listing,
879    ) -> io::Result<crate::watch::Watch> {
880        match self.route(root) {
881            Some(_) => crate::watch::poll(self, root, recursive, listing),
882            None => self.inner.clone().watch(root, recursive, listing),
883        }
884    }
885}
886
887/// `bytes` split at its first `\n`: the line before it, as text, and
888/// what follows.
889fn split_line(bytes: &[u8]) -> Option<(String, &[u8])> {
890    let newline = bytes.iter().position(|&byte| byte == b'\n')?;
891    let (line, rest) = bytes.split_at(newline);
892    Some((
893        String::from_utf8_lossy(line).into_owned(),
894        rest.get(1..).unwrap_or_default(),
895    ))
896}
897
898/// `text` quoted for `sh`.
899fn quote(text: &str) -> String {
900    format!("'{}'", text.replace('\'', "'\\''"))
901}
902
903/// A remote path quoted for `sh`, a leading `~` kept as the home
904/// directory.
905fn quote_path(path: &Path) -> String {
906    let path = path.to_string_lossy();
907    match path.strip_prefix("~/") {
908        Some(rest) => format!("\"$HOME\"/{}", quote(rest)),
909        None if path == "~" => "\"$HOME\"".to_owned(),
910        None => quote(&path),
911    }
912}
913
914#[cfg(test)]
915mod tests;