diff --git a/Cargo.lock b/Cargo.lock index bbdcaf6ef..39aaf3dc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4271,6 +4271,7 @@ dependencies = [ "rustix", "serde", "serde_json", + "sha2 0.11.0", "tempfile", "thiserror 2.0.21", "tracing", diff --git a/crates/herdr-client/Cargo.toml b/crates/herdr-client/Cargo.toml index 898047428..eba888801 100644 --- a/crates/herdr-client/Cargo.toml +++ b/crates/herdr-client/Cargo.toml @@ -25,6 +25,7 @@ crossbeam-channel.workspace = true herdr-protocol.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true tempfile.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/crates/herdr-client/README.md b/crates/herdr-client/README.md index 2cef8f2d6..13c676f65 100644 --- a/crates/herdr-client/README.md +++ b/crates/herdr-client/README.md @@ -641,6 +641,56 @@ arbitrary remote paths. The remote account and temporary-directory ancestors must remain trusted; shell-based cleanup cannot eliminate concurrent path replacement races by that account. +### One File In Place + +```text +fetch_remote_file(file: RemoteFile, out: impl Write, max_bytes: u64, + cancelled: &AtomicBool) -> Result +push_remote_file(file: RemoteFile, expect: Expect, access: RemoteFileAccess, + len: u64, input: impl Read, cancelled: &AtomicBool) -> Result<()> +``` + +These copy one file at the host's own path off an SSH host and write it back, +for the GUI's remote file links. `RemoteFile` names the target, the host's +path as the host spells it (absolute, or `~/` for the login user's home), and +the `RemoteShell`: `Posix` runs `/bin/sh` scripts, and `PowerShell` runs +Windows PowerShell 5.1+ scripts through `-EncodedCommand` on Windows OpenSSH +hosts. The path travels quoted in the script; the file's bytes travel only on +stdout and stdin. Both are blocking background-worker APIs, on every client +OS, with the bridge's SSH trust policy and a 30-second no-progress timeout. + +A fetch refuses a missing path, a folder or device, and a file over +`max_bytes` (`MAX_REMOTE_FILE_BYTES`, 64 MB, for the GUI) before streaming, and +returns the byte count and SHA-256 of what it wrote. A push writes a temporary +file beside the target and renames it over the target only when it holds +exactly `len` bytes and the host's file still meets `expect`: `Sha256` (the +file as fetched), `Absent`, or `Any`. A symlink or non-regular file is never +replaced. Another writer can still slip in between the check and the rename. + +A push never lets anyone read the bytes who could not read the file they came +from, except as noted below. `access` says where a new file's access comes from: `Keep` (the login +umask's default on POSIX, the folder's inherited ACL on Windows) or +`Like(path)`, another host file's. An existing target always keeps its own. +On POSIX the temporary file is 0600 and takes the target's (or the `Like` +file's) group and mode before the rename. The target is written in place +instead when its group cannot be given and its group and other bits differ, +or when `ls` (`+`, or macOS's `@`) or `getfacl` shows an ACL on the target or +the temporary file. That keeps owner, group, ACL and hard links, but is not +atomic. A new `Like` file whose access cannot be copied (an ACL, a group that +cannot be given, a source that is not a plain file) stays 0600. On Windows +the temporary file is created with the target's (or the `Like` file's) DACL, +and `Replace` keeps it; a `Like` source whose DACL cannot be read, or that is +not a file, is refused. + +Limitation: on a macOS host, an inheritable folder ACL entry can read the +temporary file while the upload is written, since macOS ACLs are not masked +by its 0600 mode. The final file is still right. + +Refusals map to `RemoteFileError` variants (`Missing`, `NotAFile`, +`TooLarge`, `Symlink`, `Conflict`, `Incomplete`, `NoHasher`, and `Access` +when the file's access cannot be kept, before the host's file changes); +anything else is `Script`. + ## Local Discovery `Local` checks `HERDR_SOCKET_PATH` (derive `-client.sock` from its file stem), diff --git a/crates/herdr-client/src/error.rs b/crates/herdr-client/src/error.rs index 02407379f..ec3dd520c 100644 --- a/crates/herdr-client/src/error.rs +++ b/crates/herdr-client/src/error.rs @@ -250,6 +250,8 @@ pub enum Error { source: Box, cleanup: Box, }, + #[error(transparent)] + RemoteFile(#[from] crate::remote_file::Error), #[error("host scripts require a Linux or macOS client")] ScriptUnsupported, #[error("could not start host script")] diff --git a/crates/herdr-client/src/lib.rs b/crates/herdr-client/src/lib.rs index 2ab5d4b25..bdca49b64 100644 --- a/crates/herdr-client/src/lib.rs +++ b/crates/herdr-client/src/lib.rs @@ -23,6 +23,7 @@ mod method; mod options; mod process; mod queue; +mod remote_file; mod remote_host; mod script; pub mod scrollback; @@ -68,6 +69,10 @@ pub use options::ConnectOptions; #[cfg(windows)] pub use process::CREATE_NO_WINDOW; pub use process::new_command; +pub use remote_file::{ + Access as RemoteFileAccess, Error as RemoteFileError, Expect, Fetched, MAX_REMOTE_FILE_BYTES, + RemoteFile, RemoteShell, fetch_remote_file, push_remote_file, +}; pub use remote_host::RemoteHost; pub use script::{ScriptHost, ScriptLimits, run_script, run_script_command, shell_quote}; pub use sessions::{ diff --git a/crates/herdr-client/src/remote_file.rs b/crates/herdr-client/src/remote_file.rs new file mode 100644 index 000000000..8b3513df9 --- /dev/null +++ b/crates/herdr-client/src/remote_file.rs @@ -0,0 +1,360 @@ +//! Copy one file from an SSH host and write it back, over the endpoint's +//! noninteractive SSH trust. +//! +//! A POSIX host runs the scripts under `/bin/sh`; a Windows OpenSSH host runs +//! them under Windows PowerShell 5.1 or later through `-EncodedCommand`. The +//! path travels quoted inside the script text; the file's bytes travel only on +//! the child's stdout (fetch) and stdin (push), never on a command line. +//! +//! Both shells share one exit-code contract: a script that refuses exits with +//! one of the codes below before it streams anything or replaces anything, and +//! [`fetch_remote_file`] and [`push_remote_file`] map those codes to [`Error`] +//! variants. Any other failure, including an SSH failure, is +//! [`Error::Script`]. +//! +//! A push writes a temporary file beside the target and renames it over the +//! target only after checking [`Expect`] against the host's file. The +//! temporary file never lets anyone read the upload who cannot read the file +//! its access comes from: the target when it exists, else the file +//! [`Access::Like`] names. A POSIX host creates it readable by the SSH user +//! alone (but see the macOS limitation below); Windows PowerShell and +//! PowerShell 7 create it with that file's DACL. +//! Another writer can still slip in between that check and the rename; the +//! host offers no lock that every editor honours. +//! +//! An existing file keeps its permission bits and, on a POSIX host, its +//! group, and becomes the SSH user's. A set-user-ID bit is not kept when the +//! file's owner changes, nor a set-group-ID bit when its group cannot be +//! kept: the file then grants no identity it did not grant before. On a +//! POSIX host it is overwritten in place instead, keeping its owner, group, +//! and ACL but not atomically, when the host cannot report its mode or +//! group, when an ACL shows on the file or on the temporary file, or when +//! the copy cannot be given the file's group +//! and the group and other bits differ. When it would write in place over a +//! file the SSH user cannot write, it refuses with [`Error::Access`] before +//! the host's file changes. It writes in place only in a folder where no one +//! but the SSH user and root can replace the file (the folder is theirs, +//! carries no ACL, and neither group nor others may write it unless it is +//! sticky and the file is theirs); elsewhere it refuses with +//! [`Error::SharedFolder`], and a push expecting nothing at the path refuses +//! with [`Error::Conflict`] rather than write over a file that appeared. +//! The POSIX copy is built in a private folder inside the target's folder, +//! so no one else can swap it for a link before it is renamed. +//! +//! A new file takes the access of the file [`Access::Like`] names, as a +//! kept-both copy does, or with [`Access::Keep`] what the host gives a new +//! file. On a POSIX host a new file whose source's access cannot be copied +//! (the source is missing, a link, not a regular file, or carries an ACL, or +//! its group cannot be given and its group and other bits differ) is never +//! refused: it stays readable by the SSH user alone. PowerShell refuses with +//! [`Error::Access`] instead when it cannot read the source's DACL or the +//! source is not a file. +//! +//! Limitation: on a macOS host, an inheritable ACL entry on the folder can +//! let others read the temporary file while the upload is written, since +//! macOS does not mask ACL entries by the file's 0600 mode. The final file is +//! still right, because the ACL check then writes the upload in place. + +use crate::{Result, script::ScriptLimits, script::run_script_command}; +use sha2::{Digest, Sha256}; +use std::{ + io::{self, Read, Write}, + process::Command, + sync::atomic::AtomicBool, + time::Duration, +}; + +mod posix; +mod powershell; + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests; + +/// The largest file a caller should fetch or push. +pub const MAX_REMOTE_FILE_BYTES: u64 = 64 * 1024 * 1024; + +/// Longest interval without progress on any of the child's streams. +const IDLE: Duration = Duration::from_secs(30); + +/// The path is a symbolic link (or, on Windows, another reparse point), which +/// a push never replaces. +const SYMLINK_EXIT: i32 = 65; +/// Nothing exists at the path. +const MISSING_EXIT: i32 = 66; +/// The path is a folder, device, or other non-regular file. +const NOT_A_FILE_EXIT: i32 = 67; +/// The file is larger than the fetch's limit. +const TOO_LARGE_EXIT: i32 = 68; +/// The host has no SHA-256 tool to check an expected hash with. +const NO_HASHER_EXIT: i32 = 69; +/// The pushed input was not the length the caller said it would be: the +/// transfer was cut off, or the file changed while it was read. +const SHORT_EXIT: i32 = 70; +/// The push cannot keep the file's access: it would write in place over a +/// target the SSH user cannot write, or PowerShell cannot read the ACL to +/// copy or its source is not a file. The host's file was not changed. +const ACCESS_EXIT: i32 = 71; +/// Users other than the SSH user and root can replace files in the target's +/// folder, so a POSIX push that could only write in place did not, or the +/// push's private work folder was not the SSH user's alone. The host's file +/// was not changed. +const SHARED_FOLDER_EXIT: i32 = 72; +/// The host's file no longer matches what the push expected. +const CONFLICT_EXIT: i32 = 75; + +/// Bytes in the megabyte that [`Error::TooLarge`] reports. +const MB: u64 = 1024 * 1024; + +/// The shell an SSH host runs the transfer scripts in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteShell { + /// `/bin/sh` on Linux, macOS, or another POSIX host. + Posix, + /// Windows PowerShell on a Windows OpenSSH host. + PowerShell, +} + +/// What a push requires of the host's file before replacing it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Expect { + /// A regular file, not a symbolic link, whose SHA-256 is this digest: + /// the file as it was fetched. + Sha256([u8; 32]), + /// Nothing at the path, not even a dangling symbolic link. + Absent, + /// Whatever regular file is there, or none. + Any, +} + +/// Where a pushed file's access comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access<'a> { + /// An existing target keeps its own access; a new file gets what the + /// host gives one: the umask's mode, or the folder's inherited ACL. + Keep, + /// A new file takes the access of the host file at this path, written as + /// a push's path is. An existing target still keeps its own access. + Like(&'a str), +} + +/// A completed fetch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fetched { + /// Bytes written to the output. + pub bytes: u64, + /// SHA-256 of those bytes, to pass back as [`Expect::Sha256`]. + pub sha256: [u8; 32], +} + +/// Why a fetch or push failed. +#[derive(Debug, thiserror::Error)] +pub enum Error { + // keep-sorted start + #[error("could not keep the file's access; nothing was changed")] + Access, + #[error("the host's file changed since it was fetched")] + Conflict, + #[error("the upload was cut off; the host's file is unchanged")] + Incomplete, + #[error("the file is not on the host")] + Missing, + #[error("the host has no SHA-256 tool (sha256sum, shasum, or openssl)")] + NoHasher, + #[error("the host's path is not a regular file")] + NotAFile, + #[error("could not reach the host's file: {0}")] + /// Boxed: the crate's root error holds this enum in turn. + Script(#[source] Box), + #[error( + "other users can replace files in that folder on the host, so it was not written in \ + place; nothing was changed" + )] + SharedFolder, + #[error("the host's path is a symbolic link, which is never replaced")] + Symlink, + #[error("the file is larger than {} MB", .limit.div_ceil(MB))] + TooLarge { limit: u64 }, + // keep-sorted end +} + +/// A file on an SSH host: where it is and the shell its scripts run in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteFile<'a> { + /// A `[user@]host` SSH target. + pub target: &'a str, + /// The shell the host runs scripts in. + pub shell: RemoteShell, + /// The host's absolute path, or one starting `~/` (also `~\` for + /// PowerShell) for the login user's home folder. + pub path: &'a str, +} + +/// Stream `file` to `out`, refusing a missing path, a folder or device, and a +/// file larger than `max_bytes`. Symbolic links are followed. +/// +/// Blocking: call only on a background worker. +/// +/// Args: +/// file: The host, its shell, and the path to read. +/// out: Receives the file's bytes. On failure it may hold a prefix. +/// max_bytes: The largest file accepted. +/// cancelled: Kills the transfer when set. +/// +/// Returns: +/// The byte count and SHA-256 of what was written to `out`. +/// +/// Errors: +/// [`crate::Error::RemoteFile`] for a refusal or a failed script, or a +/// target or platform error from [`crate::Error`]. +pub fn fetch_remote_file( + file: RemoteFile<'_>, + out: impl Write + Send, + max_bytes: u64, + cancelled: &AtomicBool, +) -> Result { + let script = match file.shell { + RemoteShell::Posix => posix::fetch_script(file.path, max_bytes), + RemoteShell::PowerShell => powershell::fetch_script(file.path, max_bytes), + }; + let command = remote_command(file.target, file.shell, &script)?; + Ok(fetch_with(command, out, max_bytes, cancelled)?) +} + +/// Replace `file` with `input`, only while the host's file still meets +/// `expect`. A symbolic link or non-regular file at the path is never +/// replaced, and the temporary file is removed on every failure. +/// +/// Blocking: call only on a background worker. +/// +/// Args: +/// file: The host, its shell, and the path to replace. The path's folder +/// must exist. +/// expect: What the host's file must be just before the rename. +/// access: Where a new file's access comes from. +/// len: The exact number of bytes `input` holds. The host replaces +/// nothing unless it received exactly that many, so a transfer cut +/// off early, which the host sees only as the end of its input, is +/// refused rather than committed. +/// input: The new contents. +/// cancelled: Kills the transfer when set; the host's file is unchanged +/// unless the rename already happened. +/// +/// Errors: +/// [`crate::Error::RemoteFile`] for a refusal, a conflict, a transfer +/// that was not `len` bytes ([`Error::Incomplete`]), access it cannot +/// keep ([`Error::Access`]), an in-place write refused in a folder +/// others can write ([`Error::SharedFolder`]), or a failed script, or a +/// target or platform +/// error from [`crate::Error`]. +pub fn push_remote_file( + file: RemoteFile<'_>, + expect: Expect, + access: Access<'_>, + len: u64, + input: impl Read + Send, + cancelled: &AtomicBool, +) -> Result<()> { + let script = match file.shell { + RemoteShell::Posix => posix::push_script(file.path, expect, access, len), + RemoteShell::PowerShell => powershell::push_script(file.path, expect, access, len), + }; + let command = remote_command(file.target, file.shell, &script)?; + Ok(push_with(command, input, cancelled)?) +} + +fn remote_command(target: &str, shell: RemoteShell, script: &str) -> Result { + match shell { + RemoteShell::Posix => crate::ssh::script_command(target, script), + RemoteShell::PowerShell => crate::ssh::powershell_script_command(target, script), + } +} + +/// Run a prepared fetch command, hashing its stdout on the way to `out`. +fn fetch_with( + command: Command, + out: impl Write + Send, + max_bytes: u64, + cancelled: &AtomicBool, +) -> std::result::Result { + let mut hashing = Hashing { + out, + hasher: Sha256::new(), + }; + let limits = ScriptLimits { + output: max_bytes, + idle: IDLE, + }; + let bytes = run_script_command(command, io::empty(), &mut hashing, limits, cancelled) + .map_err(|error| classify(error, Some(max_bytes)))?; + Ok(Fetched { + bytes, + sha256: hashing.hasher.finalize().into(), + }) +} + +/// Run a prepared push command with `input` on its stdin. The script prints +/// nothing, so any stdout is a failure. +fn push_with( + command: Command, + input: impl Read + Send, + cancelled: &AtomicBool, +) -> std::result::Result<(), Error> { + let limits = ScriptLimits { + output: 0, + idle: IDLE, + }; + run_script_command(command, input, io::sink(), limits, cancelled) + .map(|_| ()) + .map_err(|error| classify(error, None)) +} + +/// The typed refusal for a script's exit code. `limit` is the fetch's size +/// limit; a push has none, so a stray too-large exit stays a script failure. +fn classify(error: crate::Error, limit: Option) -> Error { + let code = match &error { + crate::Error::ScriptOutputLimit => { + if let Some(limit) = limit { + return Error::TooLarge { limit }; + } + None + } + crate::Error::ScriptExit { status, .. } => status.code(), + _ => None, + }; + match (code, limit) { + (Some(SYMLINK_EXIT), _) => Error::Symlink, + (Some(MISSING_EXIT), _) => Error::Missing, + (Some(NOT_A_FILE_EXIT), _) => Error::NotAFile, + (Some(TOO_LARGE_EXIT), Some(limit)) => Error::TooLarge { limit }, + (Some(NO_HASHER_EXIT), _) => Error::NoHasher, + (Some(CONFLICT_EXIT), _) => Error::Conflict, + (Some(ACCESS_EXIT), _) => Error::Access, + (Some(SHARED_FOLDER_EXIT), _) => Error::SharedFolder, + (Some(SHORT_EXIT), _) => Error::Incomplete, + _ => Error::Script(Box::new(error)), + } +} + +/// Lowercase hex, as every host hasher prints a digest. +fn hex(digest: &[u8; 32]) -> String { + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Writes through to `out`, hashing every byte that reached it. +struct Hashing { + out: W, + hasher: Sha256, +} + +impl Write for Hashing { + fn write(&mut self, buf: &[u8]) -> io::Result { + let n = self.out.write(buf)?; + self.hasher.update(&buf[..n]); + Ok(n) + } + + fn flush(&mut self) -> io::Result<()> { + self.out.flush() + } +} diff --git a/crates/herdr-client/src/remote_file/posix.rs b/crates/herdr-client/src/remote_file/posix.rs new file mode 100644 index 000000000..593ff4376 --- /dev/null +++ b/crates/herdr-client/src/remote_file/posix.rs @@ -0,0 +1,344 @@ +//! The `/bin/sh` scripts. They use only POSIX shell, `wc`, `cat`, `mktemp`, +//! `mv`, `rm`, `rmdir`, `id`, and `ls`, plus a SHA-256 tool when a push +//! expects a hash, and `stat`, `getfacl` where there is one, `chgrp`, and +//! `chmod` to keep a file's access. + +use super::{ + ACCESS_EXIT, Access, CONFLICT_EXIT, Expect, MISSING_EXIT, NO_HASHER_EXIT, NOT_A_FILE_EXIT, + SHARED_FOLDER_EXIT, SHORT_EXIT, SYMLINK_EXIT, TOO_LARGE_EXIT, hex, +}; +use crate::script::shell_quote; + +/// Sets `path` to the host's path, expanding a leading `~/` to `$HOME`. +fn path_assignment(path: &str) -> String { + assignment("path", path) +} + +/// Sets the shell variable `name` to the host's `path`, expanding a leading +/// `~/` to `$HOME`. +fn assignment(name: &str, path: &str) -> String { + if path == "~" { + return format!(r#"{name}="$HOME""#); + } + match path.strip_prefix("~/") { + Some(rest) => format!(r#"{name}="$HOME"/{}"#, shell_quote(rest)), + None => format!("{name}={}", shell_quote(path)), + } +} + +/// Refuses a missing path, a non-regular file, and a file over `max_bytes`, +/// then streams the file. `-e` and `-f` follow symbolic links. +pub(super) fn fetch_script(path: &str, max_bytes: u64) -> String { + // The shell's arithmetic is signed; no real limit comes near it. + let max = max_bytes.min(i64::MAX as u64); + format!( + r#"{assign} +if [ ! -e "$path" ]; then exit {MISSING_EXIT}; fi +if [ ! -f "$path" ]; then exit {NOT_A_FILE_EXIT}; fi +size=$(wc -c < "$path") || exit 1 +case $size in ''|*[!0-9\ ]*) exit 1 ;; esac +if [ $size -gt {max} ]; then exit {TOO_LARGE_EXIT}; fi +exec cat < "$path""#, + assign = path_assignment(path), + ) +} + +/// Refuses a symbolic link or a non-regular file at the path the shell +/// variable `var` holds. A push runs it before and after the copy, so a path +/// that changed kind meanwhile is still refused. +fn refuse_unsafe(var: &str) -> String { + format!( + r#"if [ -L "${var}" ]; then exit {SYMLINK_EXIT}; fi +if [ -e "${var}" ] && [ ! -f "${var}" ]; then exit {NOT_A_FILE_EXIT}; fi"# + ) +} + +/// `inode PATH` prints the inode number `ls -di` reports for `PATH`, or +/// nothing when `ls` fails. +const INODE_FUNCTION: &str = r#"inode() { +set -f +set -- $(ls -di -- "$1" 2>/dev/null) +set +f +printf '%s' "${1-}" +}"#; + +/// `folder_is_safe` succeeds when no one but the SSH user and root can +/// replace `./$name` in the working directory, the anchored target folder, +/// read with POSIX `ls -ldn`. Run after `cd -P`, so it describes the folder +/// rather than a link to it. +const FOLDER_IS_SAFE_FUNCTION: &str = r#"folder_is_safe() { +nl=' +' +folder_listing=$(ls -ldn . 2>/dev/null) || return 1 +set -f +set -- $folder_listing +set +f +folder_mode=${1-} +# The folder is the SSH user's or root's. +case ${3-} in "$uid"|0) ;; *) return 1 ;; esac +# No ACL shows on it: `+`, or macOS's `@` (extended attributes, which hide +# `+`) when `ls -lde` lists an ACL entry after the first line. +case $folder_mode in +??????????+*) return 1 ;; +??????????@*) +folder_acl=$(ls -lde . 2>/dev/null) || return 1 +case $folder_acl in *"$nl"*) return 1 ;; esac ;; +esac +# `getfacl -s`, where there is one, lists no ACL on it (BusyBox `ls` marks none). +if command -v getfacl >/dev/null 2>&1 && [ -n "$(getfacl -s . 2>/dev/null)" ]; then return 1; fi +# Neither its group nor others may write it... +case $folder_mode in ?????w*|????????w*) ;; *) return 0 ;; esac +# ...or it is sticky and the target is the SSH user's or root's, so no one +# else may rename or remove it. +case $folder_mode in ?????????[tT]*) ;; *) return 1 ;; esac +set -f +set -- $(ls -ldn -- "./$name" 2>/dev/null) +set +f +case ${3-} in "$uid"|0) return 0 ;; esac +return 1 +}"#; + +/// The `EXIT` trap: removes the names the push made with `mktemp` inside the +/// work folder and still holds (the copy and the probe files), then the +/// emptied folder from the anchored folder, never following a name another +/// user could have replaced (`rmdir` removes no link and no folder with +/// anything in it). It removes nothing else, so a folder of the SSH user's +/// own swapped in for the work folder keeps every file it held. +const CLEANUP_FUNCTION: &str = r#"cleanup() { +if [ -n "$inside" ]; then +for made in "$copy" "$probe" "$probe_to"; do +if [ -n "$made" ]; then rm -f -- "$made"; fi +done +cd .. || return +if [ "$(inode .)" != "$here" ]; then return; fi +fi +if [ -n "$work" ]; then rmdir -- "$work" 2>/dev/null; fi +}"#; + +/// Enters the private work folder and refuses unless it is the SSH user's +/// `drwx------` folder inside the anchored folder. `drwx--S---` passes too: +/// Linux gives a folder made in a set-group-ID folder that bit, which grants +/// its group nothing without the group's other bits. +fn enter_work_folder() -> String { + format!( + r#"cd -P -- "$work" || exit 1 +inside=1 +work_listing=$(ls -ldn . 2>/dev/null) || work_listing= +set -f +set -- $work_listing +set +f +case ${{1-}} in drwx------*|drwx--S---*) ;; *) exit {SHARED_FOLDER_EXIT} ;; esac +if [ "${{3-}}" != "$uid" ] || [ "$(inode ..)" != "$here" ]; then exit {SHARED_FOLDER_EXIT}; fi"# + ) +} + +/// Sets `no_follow` to the `mv` option that renames over a link to a folder +/// rather than into the folder: GNU's `-T` or BSD's `-h`, tried by renaming +/// one file the push just made in the work folder onto another, or nothing +/// when `mv` takes neither. Only those two new names are ever replaced or +/// removed. +const PICK_NO_FOLLOW: &str = r#"probe=$(mktemp ./p.XXXXXX) || exit 1 +probe_to=$(mktemp ./q.XXXXXX) || exit 1 +if mv -T -- "$probe" "$probe_to" 2>/dev/null; then no_follow=-T; probe= +elif mv -h -- "$probe" "$probe_to" 2>/dev/null; then no_follow=-h; probe= +else no_follow= +fi +for made in "$probe" "$probe_to"; do +if [ -n "$made" ]; then rm -f -- "$made"; fi +done +probe= +probe_to="#; + +/// Picks the first SHA-256 tool on the host, before any input is read, so a +/// host without one refuses without receiving the file. +fn choose_hasher() -> String { + format!( + r#"if command -v sha256sum >/dev/null 2>&1; then hasher='sha256sum' +elif command -v shasum >/dev/null 2>&1; then hasher='shasum -a 256' +elif command -v openssl >/dev/null 2>&1; then hasher='openssl dgst -sha256 -r' +else exit {NO_HASHER_EXIT}; fi"# + ) +} + +/// The check `expect` makes just before the rename. Each hasher reads the file +/// on stdin, so no file name reaches its output or its options. +fn expect_check(expect: Expect) -> String { + match expect { + Expect::Sha256(digest) => format!( + r#"if [ ! -f "$target" ]; then exit {CONFLICT_EXIT}; fi +sum=$($hasher < "$target") || exit 1 +if [ "${{sum%% *}}" != '{hex}' ]; then exit {CONFLICT_EXIT}; fi"#, + hex = hex(&digest), + ), + Expect::Absent => absent_check(), + Expect::Any => String::new(), + } +} + +/// Refuses a path where something now exists, even a dangling link. +fn absent_check() -> String { + format!(r#"if [ -e "$target" ] || [ -L "$target" ]; then exit {CONFLICT_EXIT}; fi"#) +} + +/// Gives the copy the group and permission bits of `$like`, the target when +/// it exists and otherwise `$source` (empty for [`Access::Keep`]), read with +/// GNU `stat -c` or BSD `stat -f`. `chgrp` runs only when the groups differ, +/// and before `chmod`, which restores any bit `chgrp` clears. When `chgrp` +/// fails, the copy keeps its own group, which is enough only when the group +/// and other bits are identical: otherwise members of the old group who are +/// not in the copy's would fall into "other" and gain or lose access. +/// +/// A set-user-ID bit is kept only when the copy's owner is `$like`'s owner: +/// when either owner cannot be read, or they differ, the bit is dropped, so +/// the file never runs as an account it did not before. A set-group-ID bit +/// is dropped the same way when `chgrp` fails and the copy keeps its own +/// group. Sticky stays: on a regular file it names no owner or group. +/// +/// The access is not kept when it cannot be read or copied: `$like` is a +/// symbolic link or not a regular file (GNU `stat` reads a link as 777, and a +/// folder has a mode of its own), no `stat` reads the mode or group, `ls -ld` +/// fails, `ls -ld` marks `$like` or the copy with `+` or macOS's `@` (an ACL, +/// or extended attributes that may hide one), or `getfacl -s` lists an ACL +/// on either (BusyBox `ls` never marks one). A folder's default ACL reaches +/// the copy, and `chmod` would raise its mask. +/// +/// Then an existing target is written over in place, which keeps its mode, +/// owner, group, ACL, and hard links but is not atomic, so a reader can see a +/// partly written file and a failure partway leaves one. That write opens the +/// target by name, so it happens only where no one else can replace that +/// name: a push expecting nothing there refuses with [`CONFLICT_EXIT`], one +/// in a folder that is not safe (see [`FOLDER_IS_SAFE_FUNCTION`]) with +/// [`SHARED_FOLDER_EXIT`], and one over a target the SSH user cannot write +/// with [`ACCESS_EXIT`], each before the host's file changes. A new file +/// whose source's access is not kept is never refused: it stays readable by +/// the SSH user alone. One with no source gets the mode the umask gives it. +fn keep_access(expect: Expect) -> String { + let absent = match expect { + Expect::Absent => absent_check(), + Expect::Sha256(_) | Expect::Any => String::new(), + }; + format!( + r#"if [ -e "$target" ]; then like=$target; else like=$source; fi +keep= +if [ -n "$like" ]; then +mode=$(stat -c %a -- "$like" 2>/dev/null) || mode=$(stat -f %Lp -- "$like" 2>/dev/null) || mode= +case $mode in ''|*[!0-7]*) mode= ;; esac +group=$(stat -c %g -- "$like" 2>/dev/null) || group=$(stat -f %g -- "$like" 2>/dev/null) || group= +case $group in ''|*[!0-9]*) group= ;; esac +tmp_group=$(stat -c %g -- "$copy" 2>/dev/null) || tmp_group=$(stat -f %g -- "$copy" 2>/dev/null) || tmp_group= +owner=$(stat -c %u -- "$like" 2>/dev/null) || owner=$(stat -f %u -- "$like" 2>/dev/null) || owner= +case $owner in ''|*[!0-9]*) owner= ;; esac +tmp_owner=$(stat -c %u -- "$copy" 2>/dev/null) || tmp_owner=$(stat -f %u -- "$copy" 2>/dev/null) || tmp_owner= +if [ -n "$mode" ] && [ -n "$group" ]; then keep=1; fi +if [ -L "$like" ] || [ ! -f "$like" ]; then keep=; fi +for listed in "$like" "$copy"; do +listing=$(ls -ld -- "$listed" 2>/dev/null) || listing= +case $listing in ''|??????????[+@]*) keep= ;; esac +if command -v getfacl >/dev/null 2>&1 && [ -n "$(getfacl -s -- "$listed" 2>/dev/null)" ]; then keep=; fi +done +if [ -n "$keep" ] && [ "$group" != "$tmp_group" ] && ! chgrp -- "$group" "$copy" 2>/dev/null; then +g=$(( (0$mode >> 3) & 7 )) +o=$(( 0$mode & 7 )) +if [ "$g" -ne "$o" ]; then +keep= +else +mode=$(printf '%o' $(( 0$mode & ~02000 ))) +fi +fi +fi +if [ -n "$keep" ]; then +if [ -z "$owner" ] || [ -z "$tmp_owner" ] || [ "$owner" != "$tmp_owner" ]; then +mode=$(printf '%o' $(( 0$mode & ~04000 ))) +fi +chmod -- "$mode" "$copy" || exit 1 +elif [ -e "$target" ]; then +{absent} +if [ -z "$safe" ]; then exit {SHARED_FOLDER_EXIT}; fi +if [ ! -w "$target" ]; then exit {ACCESS_EXIT}; fi +cat < "$copy" > "$target" || exit 1 +exit 0 +elif [ -z "$like" ]; then +chmod -- "$(printf '%o' $(( 0666 & ~0$old_umask )))" "$copy" || exit 1 +fi"# + ) +} + +/// Anchors the target's folder with `cd -P`, so no later step re-resolves +/// the folder's path, and copies stdin to a file `mktemp` makes in a private +/// work folder made inside it, where no one else can reach the copy; every +/// file the push writes there is one it just made, so a folder swapped in +/// for the work folder loses nothing. Refuses the copy unless it holds +/// exactly `len` bytes, checks `expect`, gives the copy the access of the +/// target or of `access`'s source, and renames it over the target, so the +/// file becomes the SSH user's. A push expecting nothing at the path checks +/// again just before the rename. When that access cannot be kept, it writes +/// over an existing target in place instead (see [`keep_access`]). The trap +/// removes the copy and the work folder on every exit. +pub(super) fn push_script(path: &str, expect: Expect, access: Access<'_>, len: u64) -> String { + // The shell's arithmetic is signed; no real length comes near it. + let len = len.min(i64::MAX as u64); + let hasher = match expect { + Expect::Sha256(_) => choose_hasher(), + Expect::Absent | Expect::Any => String::new(), + }; + let source = match access { + Access::Keep => "source=".to_owned(), + Access::Like(source) => assignment("source", source), + }; + let absent_before_rename = match expect { + Expect::Absent => absent_check(), + Expect::Sha256(_) | Expect::Any => String::new(), + }; + format!( + r#"old_umask=$(umask) +umask 077 +{assign} +{source} +{refuse_path} +{hasher} +case $path in +*/*) dir=${{path%/*}} ;; +*) dir=. ;; +esac +if [ -z "$dir" ]; then dir=/; fi +name=${{path##*/}} +case $source in ''|/*) ;; *) source=$PWD/$source ;; esac +uid=$(id -u 2>/dev/null) || exit 1 +case $uid in ''|*[!0-9]*) exit 1 ;; esac +{INODE_FUNCTION} +{FOLDER_IS_SAFE_FUNCTION} +{CLEANUP_FUNCTION} +cd -P -- "$dir" || exit 1 +here=$(inode .) +if [ -z "$here" ]; then exit 1; fi +if folder_is_safe; then safe=1; else safe=; fi +work= +inside= +copy= +probe= +probe_to= +trap cleanup EXIT +work=$(mktemp -d ./.herdr-push.XXXXXX) || exit 1 +{enter} +target=../$name +copy=$(mktemp ./f.XXXXXX) || exit 1 +cat > "$copy" || exit 1 +size=$(wc -c < "$copy") || exit 1 +case $size in ''|*[!0-9\ ]*) exit 1 ;; esac +if [ $size -ne {len} ]; then exit {SHORT_EXIT}; fi +{refuse_target} +{check} +{keep} +if [ "$(inode ..)" != "$here" ]; then exit {SHARED_FOLDER_EXIT}; fi +{PICK_NO_FOLLOW} +{absent_before_rename} +mv -f $no_follow -- "$copy" "$target" || exit 1 +copy="#, + assign = path_assignment(path), + refuse_path = refuse_unsafe("path"), + refuse_target = refuse_unsafe("target"), + check = expect_check(expect), + enter = enter_work_folder(), + keep = keep_access(expect), + ) +} diff --git a/crates/herdr-client/src/remote_file/powershell.rs b/crates/herdr-client/src/remote_file/powershell.rs new file mode 100644 index 000000000..1a7d6f7b3 --- /dev/null +++ b/crates/herdr-client/src/remote_file/powershell.rs @@ -0,0 +1,192 @@ +//! The PowerShell scripts, for Windows OpenSSH hosts. They need only Windows +//! PowerShell 5.1 and .NET Framework, and move bytes through the raw console +//! streams: PowerShell's own pipeline would re-encode them as text. + +use super::{ + ACCESS_EXIT, Access, CONFLICT_EXIT, Expect, MISSING_EXIT, NOT_A_FILE_EXIT, SHORT_EXIT, + SYMLINK_EXIT, TOO_LARGE_EXIT, hex, +}; + +/// `FileAttributes.ReparsePoint`: symbolic links, junctions, and other +/// redirections, none of which a push replaces. +const REPARSE_POINT: u32 = 0x400; +/// `FileAttributes.Directory | FileAttributes.Device`. +const NOT_A_FILE: u32 = 0x10 | 0x40; + +const PREAMBLE: &str = "$ErrorActionPreference = 'Stop'\n$ProgressPreference = 'SilentlyContinue'"; + +/// `value` as a single-quoted PowerShell string. PowerShell also ends a +/// single-quoted string at the typographic single quotes, so each of those is +/// doubled too. +fn literal(value: &str) -> String { + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('\''); + for ch in value.chars() { + if matches!(ch, '\'' | '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}') { + quoted.push(ch); + } + quoted.push(ch); + } + quoted.push('\''); + quoted +} + +/// The host's path as an expression, expanding a leading `~\` or `~/` to the +/// user's profile folder. +fn path_expression(path: &str) -> String { + if path == "~" { + return "$env:USERPROFILE".to_owned(); + } + match path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) { + Some(rest) => format!( + "[System.IO.Path]::Combine($env:USERPROFILE, {})", + literal(rest) + ), + None => literal(path), + } +} + +/// Refuses a missing path, a folder, and a file over `max_bytes`, then copies +/// the file to stdout. Opening the file follows symbolic links. +pub(super) fn fetch_script(path: &str, max_bytes: u64) -> String { + let max = max_bytes.min(i64::MAX as u64); + format!( + r#"{PREAMBLE} +$path = {path} +if ([System.IO.Directory]::Exists($path)) {{ exit {NOT_A_FILE_EXIT} }} +if (-not [System.IO.File]::Exists($path)) {{ exit {MISSING_EXIT} }} +try {{ + $file = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) +}} catch [System.IO.FileNotFoundException], [System.IO.DirectoryNotFoundException] {{ + exit {MISSING_EXIT} +}} +try {{ + if ($file.Length -gt {max}) {{ exit {TOO_LARGE_EXIT} }} + $stdout = [Console]::OpenStandardOutput() + $file.CopyTo($stdout) + $stdout.Flush() +}} finally {{ + $file.Dispose() +}} +exit 0"#, + path = path_expression(path), + ) +} + +/// The check `expect` makes just before the rename. The hash comes from .NET +/// rather than `Get-FileHash`, whose module fails to load in Windows +/// PowerShell when `PSModulePath` names PowerShell 7's modules first. +fn expect_check(expect: Expect) -> String { + match expect { + Expect::Sha256(digest) => format!( + r#"if (-not [System.IO.File]::Exists($path)) {{ exit {CONFLICT_EXIT} }} + $current = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try {{ + $digest = [System.Security.Cryptography.SHA256]::Create().ComputeHash($current) + }} finally {{ + $current.Dispose() + }} + $actual = [System.BitConverter]::ToString($digest).Replace('-', '').ToLowerInvariant() + if ($actual -ne '{hex}') {{ exit {CONFLICT_EXIT} }}"#, + hex = hex(&digest), + ), + Expect::Absent => { + format!(r#"if ($null -ne (Get-HerdrAttributes $path)) {{ exit {CONFLICT_EXIT} }}"#) + } + Expect::Any => String::new(), + } +} + +/// Copies stdin to a dot-named temporary file beside the target, refuses it +/// unless it holds exactly `len` bytes, checks `expect`, and replaces the +/// target with it. `finally` removes the copy on every exit that did not +/// rename it. +/// +/// The copy is created with the DACL of the target when it exists, else of +/// `access`'s source, so nobody who cannot read that file can read the upload +/// while it is in the copy; `Replace` then keeps the target's DACL. A new +/// file with [`Access::Keep`] takes the folder's inherited ACL. Windows +/// PowerShell (and PowerShell 3 to 5.0, which have no `PSEdition`) creates +/// the copy through the `FileStream` constructor taking a `FileSecurity`, +/// and PowerShell 7 through `FileSystemAclExtensions.Create`. When the source +/// is a reparse point or a folder, whose ACL is no file's to copy, or its +/// DACL cannot be read, the push exits with [`ACCESS_EXIT`] before it creates +/// the copy. `[NullString]::Value` asks `Replace` for no backup file; PowerShell +/// would pass `$null` as an empty string. +pub(super) fn push_script(path: &str, expect: Expect, access: Access<'_>, len: u64) -> String { + let source = match access { + Access::Keep => "$null".to_owned(), + Access::Like(source) => { + format!("[System.IO.Path]::GetFullPath({})", path_expression(source)) + } + }; + format!( + r#"{PREAMBLE} +$path = [System.IO.Path]::GetFullPath({path}) +$source = {source} +$core = $PSVersionTable.PSEdition -eq 'Core' +$sections = [System.Security.AccessControl.AccessControlSections]::Access +function Get-HerdrAttributes([string]$Target) {{ + try {{ + return [System.IO.File]::GetAttributes($Target) + }} catch [System.IO.FileNotFoundException], [System.IO.DirectoryNotFoundException] {{ + return $null + }} +}} +function Assert-HerdrReplaceable {{ + $attributes = Get-HerdrAttributes $path + if ($null -eq $attributes) {{ return }} + $flags = [int]$attributes + if (($flags -band {REPARSE_POINT}) -ne 0) {{ exit {SYMLINK_EXIT} }} + if (($flags -band {NOT_A_FILE}) -ne 0) {{ exit {NOT_A_FILE_EXIT} }} +}} +Assert-HerdrReplaceable +$folder = [System.IO.Path]::GetDirectoryName($path) +$name = '.herdr-push.' + [System.IO.Path]::GetRandomFileName() +$temporary = [System.IO.Path]::Combine($folder, $name) +$like = if ([System.IO.File]::Exists($path)) {{ $path }} else {{ $source }} +$security = $null +if ($null -ne $like) {{ + try {{ + $likeAttributes = Get-HerdrAttributes $like + if ($null -ne $likeAttributes -and ([int]$likeAttributes -band ({REPARSE_POINT} -bor {NOT_A_FILE})) -ne 0) {{ exit {ACCESS_EXIT} }} + $security = if ($core) {{ [System.IO.FileSystemAclExtensions]::GetAccessControl([System.IO.FileInfo]$like, $sections) }} else {{ [System.IO.File]::GetAccessControl($like, $sections) }} + }} catch {{ + exit {ACCESS_EXIT} + }} +}} +$write = [System.Security.AccessControl.FileSystemRights]::Write +$copy = if ($null -eq $security) {{ + [System.IO.File]::Open($temporary, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) +}} elseif ($core) {{ + [System.IO.FileSystemAclExtensions]::Create([System.IO.FileInfo]$temporary, [System.IO.FileMode]::CreateNew, $write, [System.IO.FileShare]::None, 4096, [System.IO.FileOptions]::None, $security) +}} else {{ + New-Object System.IO.FileStream($temporary, [System.IO.FileMode]::CreateNew, $write, [System.IO.FileShare]::None, 4096, [System.IO.FileOptions]::None, $security) +}} +$renamed = $false +$received = -1 +try {{ + try {{ + $stdin = [Console]::OpenStandardInput() + $stdin.CopyTo($copy) + $received = $copy.Length + }} finally {{ + $copy.Dispose() + }} + if ($received -ne {len}) {{ exit {SHORT_EXIT} }} + Assert-HerdrReplaceable + {check} + if ([System.IO.File]::Exists($path)) {{ + [System.IO.File]::Replace($temporary, $path, [NullString]::Value) + }} else {{ + [System.IO.File]::Move($temporary, $path) + }} + $renamed = $true +}} finally {{ + if (-not $renamed) {{ [System.IO.File]::Delete($temporary) }} +}} +exit 0"#, + path = path_expression(path), + check = expect_check(expect), + ) +} diff --git a/crates/herdr-client/src/remote_file/tests.rs b/crates/herdr-client/src/remote_file/tests.rs new file mode 100644 index 000000000..366eb46bb --- /dev/null +++ b/crates/herdr-client/src/remote_file/tests.rs @@ -0,0 +1,189 @@ +use super::*; +use std::path::Path; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::unix::fs::PermissionsExt; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod posix_exec; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod posix_setgid; +mod powershell_exec; +mod scripts; + +/// A file name every shell would reinterpret if it were not quoted: spaces, +/// both kinds of single quote, `$`, a backtick, non-ASCII, and a leading `-`. +const HOSTILE_NAME: &str = "-a b'c $x `y \u{e9}\u{2019}.bin"; + +/// Bytes a text-mode stream would mangle, then a deterministic noise tail. +fn payload(len: usize) -> Vec { + let mut bytes = vec![0x00, 0x0A, 0x0D, 0xFF, 0x0D, 0x0A, 0x1A, 0x00]; + let mut state: u32 = 0x1234_5678; + while bytes.len() < len { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + bytes.push(state.to_be_bytes()[0]); + } + bytes.truncate(len); + bytes +} + +/// The script failure inside `error`, if that is what it is. +fn script_error(error: &Error) -> Option<&crate::Error> { + match error { + Error::Script(inner) => Some(inner.as_ref()), + _ => None, + } +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +/// The names in `dir`, sorted, so a leftover temporary file shows up. +fn entries(dir: &Path) -> Vec { + let mut names = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + names.sort(); + names +} + +/// `script` as `-EncodedCommand` expects it: UTF-16LE, then base64. +fn encode(script: &str) -> String { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + let utf16 = script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + STANDARD.encode(utf16) +} + +fn decode(encoded: &str) -> String { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + let bytes = STANDARD.decode(encoded).unwrap(); + let units = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + String::from_utf16(&units).unwrap() +} + +/// Runs the POSIX scripts under this machine's `/bin/sh` through the same +/// runner an SSH host's scripts use. +#[cfg(any(target_os = "linux", target_os = "macos"))] +struct Fixture { + home: tempfile::TempDir, + /// The child's `PATH`; `None` keeps this process's. + path_env: Option, + /// The umask the script starts with, as an SSH login sets one. + umask: &'static str, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Fixture { + fn new() -> Self { + Self { + home: tempfile::tempdir().unwrap(), + path_env: None, + umask: "022", + } + } + + fn mode(&self, name: &str) -> u32 { + std::fs::metadata(self.path(name)) + .unwrap() + .permissions() + .mode() + & 0o7777 + } + + fn set_mode(&self, name: &str, mode: u32) { + std::fs::set_permissions(self.path(name), std::fs::Permissions::from_mode(mode)).unwrap(); + } + + fn path(&self, name: &str) -> std::path::PathBuf { + self.home.path().join(name) + } + + fn text(&self, name: &str) -> String { + self.path(name).to_str().unwrap().to_owned() + } + + fn command(&self, script: &str) -> Command { + let mut command = Command::new("/bin/sh"); + command + .args(["-c", &format!("umask {}\n{script}", self.umask)]) + .env("HOME", self.home.path()); + if let Some(path) = &self.path_env { + command.env("PATH", path); + } + command + } + + fn fetch(&self, path: &str, max_bytes: u64) -> std::result::Result<(Vec, Fetched), Error> { + let mut out = Vec::new(); + let command = self.command(&posix::fetch_script(path, max_bytes)); + let fetched = fetch_with(command, &mut out, max_bytes, &AtomicBool::new(false))?; + Ok((out, fetched)) + } + + fn push(&self, path: &str, expect: Expect, input: &[u8]) -> std::result::Result<(), Error> { + self.push_claiming(path, expect, input.len() as u64, input) + } + + /// A push that says it sends `len` bytes and sends `input`. + fn push_claiming( + &self, + path: &str, + expect: Expect, + len: u64, + input: &[u8], + ) -> std::result::Result<(), Error> { + let command = self.command(&posix::push_script(path, expect, Access::Keep, len)); + push_with(command, input, &AtomicBool::new(false)) + } + + /// A push of a new file at `path` that takes the access of `source`, as + /// a kept-both copy does. + fn push_like(&self, path: &str, source: &str, input: &[u8]) -> std::result::Result<(), Error> { + let script = posix::push_script( + path, + Expect::Absent, + Access::Like(source), + input.len() as u64, + ); + push_with(self.command(&script), input, &AtomicBool::new(false)) + } + + /// Runs the scripts with a `chgrp` that always fails first on `PATH`, as + /// for a user outside the file's group. Keep the returned folder alive. + fn fail_chgrp(&mut self) -> tempfile::TempDir { + self.shim("chgrp", "exit 1\n") + } + + /// Runs the scripts with a `/bin/sh` script named `tool` holding `body` + /// first on `PATH`. Keep the returned folder alive. The shim is written + /// from a child shell, so no sibling test's fork can hold it open for + /// writing when the script runs it. + fn shim(&mut self, tool: &str, body: &str) -> tempfile::TempDir { + let bin = tempfile::tempdir().unwrap(); + let shim = bin.path().join(tool); + crate::test_executable::write(&shim, format!("#!/bin/sh\n{body}"), 0o755).unwrap(); + let path = std::env::var_os("PATH").unwrap_or_default(); + let mut folders = vec![bin.path().to_owned()]; + folders.extend(std::env::split_paths(&path)); + self.path_env = Some(std::env::join_paths(folders).unwrap().into()); + bin + } +} + +/// Where this machine keeps `tool`. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn real_tool(tool: &str) -> std::path::PathBuf { + ["/bin", "/usr/bin"] + .iter() + .map(|folder| Path::new(folder).join(tool)) + .find(|candidate| candidate.exists()) + .unwrap() +} diff --git a/crates/herdr-client/src/remote_file/tests/posix_exec.rs b/crates/herdr-client/src/remote_file/tests/posix_exec.rs new file mode 100644 index 000000000..4f4d813a9 --- /dev/null +++ b/crates/herdr-client/src/remote_file/tests/posix_exec.rs @@ -0,0 +1,793 @@ +//! Runs the POSIX scripts under this machine's `/bin/sh` through the same +//! runner an SSH host's scripts use. + +use super::*; +use std::os::unix::fs::{PermissionsExt, symlink}; + +#[test] +fn fetch_streams_a_binary_file_exactly() { + let fixture = Fixture::new(); + let original = payload(3 * 1024 * 1024 + 17); + std::fs::write(fixture.path(HOSTILE_NAME), &original).unwrap(); + let (bytes, fetched) = fixture + .fetch(&fixture.text(HOSTILE_NAME), MAX_REMOTE_FILE_BYTES) + .unwrap(); + assert!(bytes == original, "fetched bytes differ"); + assert_eq!(fetched.bytes, original.len() as u64); + assert_eq!(fetched.sha256, sha256(&original)); + + let (home, _) = fixture + .fetch(&format!("~/{HOSTILE_NAME}"), MAX_REMOTE_FILE_BYTES) + .unwrap(); + assert!(home == original, "home-relative bytes differ"); +} + +#[test] +fn fetch_follows_a_symlink() { + let fixture = Fixture::new(); + std::fs::write(fixture.path("target"), b"linked").unwrap(); + symlink(fixture.path("target"), fixture.path("link")).unwrap(); + let (bytes, _) = fixture.fetch(&fixture.text("link"), 100).unwrap(); + assert_eq!(bytes, b"linked"); +} + +#[test] +fn fetch_refuses_missing_folders_devices_and_oversize_files() { + let fixture = Fixture::new(); + assert!(matches!( + fixture.fetch(&fixture.text("missing"), 10), + Err(Error::Missing) + )); + symlink(fixture.path("nowhere"), fixture.path("dangling")).unwrap(); + assert!(matches!( + fixture.fetch(&fixture.text("dangling"), 10), + Err(Error::Missing) + )); + std::fs::create_dir(fixture.path("folder")).unwrap(); + assert!(matches!( + fixture.fetch(&fixture.text("folder"), 10), + Err(Error::NotAFile) + )); + assert!(matches!( + fixture.fetch("/dev/null", 10), + Err(Error::NotAFile) + )); + std::fs::write(fixture.path("big"), payload(11)).unwrap(); + assert!(matches!( + fixture.fetch(&fixture.text("big"), 10), + Err(Error::TooLarge { limit: 10 }) + )); + assert!(fixture.fetch(&fixture.text("big"), 11).is_ok()); +} + +#[test] +fn push_replaces_a_file_that_still_matches_its_hash() { + let fixture = Fixture::new(); + let path = fixture.text(HOSTILE_NAME); + let first = payload(4096); + std::fs::write(&path, &first).unwrap(); + let second = payload(70_000).into_iter().rev().collect::>(); + fixture + .push(&path, Expect::Sha256(sha256(&first)), &second) + .unwrap(); + assert!(std::fs::read(&path).unwrap() == second); + assert_eq!(entries(fixture.home.path()), [HOSTILE_NAME]); + + let stale = fixture.push(&path, Expect::Sha256(sha256(&first)), b"stale"); + assert!(matches!(stale, Err(Error::Conflict))); + assert!(std::fs::read(&path).unwrap() == second); + assert_eq!(entries(fixture.home.path()), [HOSTILE_NAME]); + + let missing = fixture.push(&fixture.text("missing"), Expect::Sha256(sha256(b"")), b"x"); + assert!(matches!(missing, Err(Error::Conflict))); + assert_eq!(entries(fixture.home.path()), [HOSTILE_NAME]); +} + +#[test] +fn push_absent_creates_with_the_hosts_default_mode_and_refuses_an_existing_file() { + let mut fixture = Fixture::new(); + fixture + .push(&format!("~/{HOSTILE_NAME}"), Expect::Absent, b"new") + .unwrap(); + let path = fixture.path(HOSTILE_NAME); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(fixture.mode(HOSTILE_NAME), 0o644); + + fixture.umask = "0027"; + fixture + .push(&fixture.text("other"), Expect::Any, b"other") + .unwrap(); + assert_eq!(fixture.mode("other"), 0o640); + std::fs::remove_file(fixture.path("other")).unwrap(); + + let again = fixture.push(&fixture.text(HOSTILE_NAME), Expect::Absent, b"again"); + assert!(matches!(again, Err(Error::Conflict))); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(entries(fixture.home.path()), [HOSTILE_NAME]); + + symlink(fixture.path("nowhere"), fixture.path("dangling")).unwrap(); + let dangling = fixture.push(&fixture.text("dangling"), Expect::Absent, b"x"); + assert!(matches!(dangling, Err(Error::Symlink))); +} + +#[test] +fn push_keeps_an_existing_files_mode() { + let fixture = Fixture::new(); + for mode in [0o755, 0o644] { + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", mode); + let expect = Expect::Sha256(sha256(b"old")); + fixture.push(&fixture.text("file"), expect, b"new").unwrap(); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(fixture.mode("file"), mode); + fixture + .push(&fixture.text("file"), Expect::Any, b"any") + .unwrap(); + assert_eq!(fixture.mode("file"), mode); + } + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +/// Runs `program` with `args` and returns its trimmed stdout. +fn output_of(program: &str, args: &[&str]) -> String { + let output = Command::new(program).args(args).output().unwrap(); + assert!(output.status.success(), "{program} {args:?} failed"); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +/// Gives the file at `path` a group other than its own that this user may +/// give it: a supplementary group, or for root any other group. Each +/// candidate is tried in turn, since `chown` can refuse one `id -G` lists +/// (macOS's `everyone`, for one). +fn give_another_group(path: &Path) -> u32 { + use std::os::unix::fs::MetadataExt; + let gid = std::fs::metadata(path).unwrap().gid(); + let mut groups = output_of("id", &["-G"]) + .split_whitespace() + .map(|group| group.parse::().unwrap()) + .collect::>(); + if output_of("id", &["-u"]) == "0" { + groups.extend([1, 2]); + } + groups.retain(|&group| group != gid); + groups + .iter() + .copied() + .find(|&group| std::os::unix::fs::chown(path, None, Some(group)).is_ok()) + .unwrap_or_else(|| { + panic!( + "chown refused every other group of this user ({groups:?}); run as root or with a \ + supplementary group" + ) + }) +} + +#[test] +fn push_keeps_an_existing_files_group() { + use std::os::unix::fs::MetadataExt; + let fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", 0o640); + let group = give_another_group(&fixture.path("file")); + let before = std::fs::metadata(fixture.path("file")).unwrap(); + fixture + .push(&fixture.text("file"), Expect::Any, b"new") + .unwrap(); + let after = std::fs::metadata(fixture.path("file")).unwrap(); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(after.gid(), group); + assert_eq!(fixture.mode("file"), 0o640); + assert_ne!( + after.ino(), + before.ino(), + "the copy was renamed over the file" + ); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +// BSD `stat -f %Lp` reports only the rwx bits, so a BSD host drops the special bits on every rename. +#[cfg(target_os = "linux")] +#[test] +fn push_keeps_a_set_user_id_bit_it_can_keep() { + let fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", 0o4755); + fixture + .push(&fixture.text("file"), Expect::Any, b"new") + .unwrap(); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(fixture.mode("file"), 0o4755); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +/// A `stat` that reports a foreign owner for every path but the push's own +/// copy, so a test can act as a push over a file another account owns. +fn foreign_owner(fixture: &mut Fixture) -> tempfile::TempDir { + let body = format!( + "case \"$1 $2\" in\n'-c %u'|'-f %u')\n\ + for last; do :; done\n\ + case $last in */f.*) ;; *) printf '%s\\n' 4242; exit 0 ;; esac ;;\n\ + esac\nexec '{}' \"$@\"\n", + real_tool("stat").display() + ); + fixture.shim("stat", &body) +} + +#[test] +fn push_clears_a_set_user_id_bit_when_the_owner_changes() { + let mut fixture = Fixture::new(); + let _bin = foreign_owner(&mut fixture); + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", 0o4755); + fixture + .push(&fixture.text("file"), Expect::Any, b"new") + .unwrap(); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(fixture.mode("file"), 0o755); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +#[test] +fn push_like_clears_a_set_user_id_bit_when_the_source_has_another_owner() { + let mut fixture = Fixture::new(); + let _bin = foreign_owner(&mut fixture); + std::fs::write(fixture.path("source"), b"old").unwrap(); + fixture.set_mode("source", 0o4755); + fixture + .push_like(&fixture.text("new"), &fixture.text("source"), b"mine") + .unwrap(); + assert_eq!(std::fs::read(fixture.path("new")).unwrap(), b"mine"); + assert_eq!(fixture.mode("new"), 0o755); + assert_eq!(entries(fixture.home.path()), ["new", "source"]); +} + +#[test] +fn push_clears_a_set_group_id_bit_when_it_cannot_keep_the_group() { + let mut fixture = Fixture::new(); + let _bin = fixture.fail_chgrp(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + // `chown` clears the special bits, so the mode is set after the group. + give_another_group(&fixture.path("file")); + fixture.set_mode("file", 0o2755); + fixture + .push(&fixture.text("file"), Expect::Any, b"new") + .unwrap(); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(fixture.mode("file"), 0o755); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +// BSD `stat -f %Lp` reports only the rwx bits, so a BSD host drops the special bits on every rename. +#[cfg(target_os = "linux")] +#[test] +fn push_keeps_a_set_group_id_bit_it_can_keep() { + use std::os::unix::fs::MetadataExt; + let fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + // `chown` clears the special bits, so the mode is set after the group. + let group = give_another_group(&fixture.path("file")); + fixture.set_mode("file", 0o2755); + fixture + .push(&fixture.text("file"), Expect::Any, b"new") + .unwrap(); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(fixture.mode("file"), 0o2755); + assert_eq!( + std::fs::metadata(fixture.path("file")).unwrap().gid(), + group + ); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +#[test] +fn push_clears_a_set_user_id_bit_of_another_owners_file() { + use std::os::unix::fs::MetadataExt; + if output_of("id", &["-u"]) != "0" { + eprintln!("skipped: needs root"); + return; + } + let fixture = Fixture::new(); + let path = fixture.path("file"); + std::fs::write(&path, b"old").unwrap(); + // `chown` clears the special bits, so the mode is set after the owner. + std::os::unix::fs::chown(&path, Some(65534), None).unwrap(); + fixture.set_mode("file", 0o4755); + fixture + .push(&fixture.text("file"), Expect::Any, b"new") + .unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&path).unwrap().uid(), 0); + assert_eq!(fixture.mode("file"), 0o755); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +#[test] +fn push_renames_without_the_group_only_when_group_and_other_bits_match() { + use std::os::unix::fs::MetadataExt; + let mut fixture = Fixture::new(); + let _bin = fixture.fail_chgrp(); + // Renamed, the file's old group falls into "other", so only identical + // group and other bits keep who can read it. + let cases = [ + (0o644, true), + (0o640, false), + (0o604, false), + (0o664, false), + ]; + for (mode, renamed) in cases { + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", mode); + let group = give_another_group(&fixture.path("file")); + let before = std::fs::metadata(fixture.path("file")).unwrap(); + fixture + .push(&fixture.text("file"), Expect::Any, b"new") + .unwrap(); + let after = std::fs::metadata(fixture.path("file")).unwrap(); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(fixture.mode("file"), mode); + assert_eq!(after.ino() != before.ino(), renamed, "{mode:o} renamed"); + // Written in place, the file keeps its group; renamed, it takes the + // user's, whose bits grant no more than everyone's. + assert_eq!(after.gid() == group, !renamed, "{mode:o} group"); + assert_eq!(entries(fixture.home.path()), ["file"]); + std::fs::remove_file(fixture.path("file")).unwrap(); + } +} + +/// Makes the file at `path` unwritable to this user until dropped: by its +/// mode, or for root, whom no mode stops, with the immutable attribute. +struct Unwritable(std::path::PathBuf); + +impl Unwritable { + fn new(path: std::path::PathBuf) -> Self { + if output_of("id", &["-u"]) == "0" { + output_of("chattr", &["+i", path.to_str().unwrap()]); + } else { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o440)).unwrap(); + } + Self(path) + } +} + +impl Drop for Unwritable { + fn drop(&mut self) { + if output_of("id", &["-u"]) == "0" { + output_of("chattr", &["-i", self.0.to_str().unwrap()]); + } + } +} + +#[test] +fn push_refuses_an_in_place_write_to_a_file_it_cannot_write() { + let mut fixture = Fixture::new(); + let _bin = fixture.fail_chgrp(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", 0o640); + give_another_group(&fixture.path("file")); + let unwritable = Unwritable::new(fixture.path("file")); + let pushed = fixture.push(&fixture.text("file"), Expect::Any, b"new"); + assert!(matches!(pushed, Err(Error::Access)), "{pushed:?}"); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"old"); + assert_eq!(entries(fixture.home.path()), ["file"]); + drop(unwritable); +} + +#[test] +fn push_like_gives_a_new_file_its_sources_access() { + use std::os::unix::fs::MetadataExt; + let fixture = Fixture::new(); + std::fs::write(fixture.path(".env"), b"secret").unwrap(); + fixture.set_mode(".env", 0o600); + fixture + .push_like( + &fixture.text(".env.conflict"), + &fixture.text(".env"), + b"mine", + ) + .unwrap(); + assert_eq!( + std::fs::read(fixture.path(".env.conflict")).unwrap(), + b"mine" + ); + assert_eq!(fixture.mode(".env.conflict"), 0o600); + + std::fs::write(fixture.path("shared"), b"team").unwrap(); + fixture.set_mode("shared", 0o640); + let group = give_another_group(&fixture.path("shared")); + fixture + .push_like("~/shared.conflict", "~/shared", b"mine") + .unwrap(); + let copy = std::fs::metadata(fixture.path("shared.conflict")).unwrap(); + assert_eq!(fixture.mode("shared.conflict"), 0o640); + assert_eq!(copy.gid(), group); + + // A source that is gone gives no access to copy: the file stays private. + fixture + .push_like( + &fixture.text("gone.conflict"), + &fixture.text("gone"), + b"mine", + ) + .unwrap(); + assert_eq!(fixture.mode("gone.conflict"), 0o600); + let mut names = vec![ + ".env", + ".env.conflict", + "gone.conflict", + "shared", + "shared.conflict", + ]; + names.sort_unstable(); + assert_eq!(entries(fixture.home.path()), names); +} + +#[test] +fn push_like_a_link_or_folder_leaves_the_new_file_private() { + let fixture = Fixture::new(); + std::fs::write(fixture.path("shared"), b"team").unwrap(); + fixture.set_mode("shared", 0o640); + symlink(fixture.path("shared"), fixture.path("link")).unwrap(); + std::fs::create_dir(fixture.path("folder")).unwrap(); + fixture.set_mode("folder", 0o755); + for source in ["link", "folder"] { + let copy = format!("{source}.conflict"); + fixture + .push_like(&fixture.text(©), &fixture.text(source), b"mine") + .unwrap(); + assert_eq!(std::fs::read(fixture.path(©)).unwrap(), b"mine"); + assert_eq!(fixture.mode(©), 0o600, "{source}"); + } +} + +/// A folder's ACL can let another user replace the file, so the push, which +/// the copy's inherited ACL sends in place, refuses instead. +#[test] +#[ignore = "needs setfacl and an ACL-capable filesystem"] +fn push_under_a_folders_acl_refuses_to_write_in_place() { + let fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", 0o640); + let home = fixture.home.path().to_str().unwrap(); + output_of("setfacl", &["-m", "u:nobody:rwx", home]); + output_of("setfacl", &["-d", "-m", "u:nobody:r", home]); + let file = fixture.text("file"); + let pushed = fixture.push(&file, Expect::Any, b"new"); + assert!(matches!(pushed, Err(Error::SharedFolder)), "{pushed:?}"); + assert_eq!(std::fs::read(&file).unwrap(), b"old"); + assert_eq!(fixture.mode("file"), 0o640); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +#[test] +fn push_renames_over_a_file_whose_group_already_matches() { + use std::os::unix::fs::MetadataExt; + let fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + let before = std::fs::metadata(fixture.path("file")).unwrap(); + fixture + .push(&fixture.text("file"), Expect::Any, b"new") + .unwrap(); + let after = std::fs::metadata(fixture.path("file")).unwrap(); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(after.gid(), before.gid()); + assert_ne!( + after.ino(), + before.ino(), + "the copy was renamed over the file" + ); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +#[test] +#[ignore = "needs setfacl and an ACL-capable filesystem"] +fn push_overwrites_a_file_with_an_acl_in_place() { + use std::os::unix::fs::MetadataExt; + let fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", 0o640); + let file = fixture.text("file"); + output_of("setfacl", &["-m", "u:nobody:r", &file]); + let acl = output_of("getfacl", &["--omit-header", &file]); + assert!(acl.contains("user:nobody:r--"), "{acl}"); + let before = std::fs::metadata(&file).unwrap(); + let content = payload(70_000); + fixture.push(&file, Expect::Any, &content).unwrap(); + let after = std::fs::metadata(&file).unwrap(); + assert!(std::fs::read(&file).unwrap() == content); + assert_eq!(after.ino(), before.ino(), "the file was written in place"); + assert_eq!(output_of("getfacl", &["--omit-header", &file]), acl); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +#[test] +fn push_cut_off_short_of_its_length_replaces_nothing() { + let fixture = Fixture::new(); + let first = payload(4096); + std::fs::write(fixture.path("file"), &first).unwrap(); + let whole = payload(70_000); + // The host sees a transfer that ended early only as the end of its input. + let expects = [Expect::Sha256(sha256(&first)), Expect::Any]; + for expect in expects { + let short = fixture.push_claiming(&fixture.text("file"), expect, 70_000, &whole[..1000]); + assert!(matches!(short, Err(Error::Incomplete)), "{short:?}"); + assert!(std::fs::read(fixture.path("file")).unwrap() == first); + assert_eq!(entries(fixture.home.path()), ["file"], "no temporary file"); + } + let long = fixture.push_claiming(&fixture.text("file"), Expect::Any, 10, &whole[..11]); + assert!(matches!(long, Err(Error::Incomplete)), "{long:?}"); + let fresh = fixture.push_claiming(&fixture.text("new"), Expect::Absent, 5, b"new"); + assert!(matches!(fresh, Err(Error::Incomplete)), "{fresh:?}"); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +#[test] +fn push_without_stat_still_refuses_a_cut_off_transfer() { + let mut fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + let bin = tool_folder(WITHOUT_STAT); + fixture.path_env = Some(bin.path().to_owned()); + let short = fixture.push_claiming(&fixture.text("file"), Expect::Any, 9, b"new"); + assert!(matches!(short, Err(Error::Incomplete)), "{short:?}"); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"old"); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +/// Every tool a push runs except `stat` and the hashers. +const WITHOUT_STAT: &[&str] = &[ + "cat", "chmod", "id", "ls", "mktemp", "mv", "rm", "rmdir", "wc", +]; + +/// A folder holding links to just `tools`, from this machine's own folders. +fn tool_folder(tools: &[&str]) -> tempfile::TempDir { + let bin = tempfile::tempdir().unwrap(); + for tool in tools { + symlink(real_tool(tool), bin.path().join(tool)).unwrap(); + } + bin +} + +#[test] +fn push_without_stat_overwrites_in_place_and_keeps_the_mode() { + use std::os::unix::fs::MetadataExt; + let mut fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", 0o755); + // Only the SSH user can replace files in a 0700 folder, so writing in + // place there is safe. + std::fs::set_permissions(fixture.home.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let bin = tool_folder(WITHOUT_STAT); + fixture.path_env = Some(bin.path().to_owned()); + let before = std::fs::metadata(fixture.path("file")).unwrap(); + let content = payload(70_000); + fixture + .push(&fixture.text("file"), Expect::Any, &content) + .unwrap(); + let after = std::fs::metadata(fixture.path("file")).unwrap(); + assert!(std::fs::read(fixture.path("file")).unwrap() == content); + assert_eq!(after.ino(), before.ino(), "the file was written in place"); + assert_eq!(fixture.mode("file"), 0o755); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +#[test] +fn push_any_overwrites_but_never_replaces_a_symlink_or_folder() { + let fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture + .push(&fixture.text("file"), Expect::Any, b"any") + .unwrap(); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"any"); + + symlink(fixture.path("file"), fixture.path("link")).unwrap(); + for expect in [Expect::Any, Expect::Sha256(sha256(b"any"))] { + let linked = fixture.push(&fixture.text("link"), expect, b"through"); + assert!(matches!(linked, Err(Error::Symlink))); + } + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"any"); + assert!( + std::fs::symlink_metadata(fixture.path("link")) + .unwrap() + .file_type() + .is_symlink() + ); + + std::fs::create_dir(fixture.path("folder")).unwrap(); + let folder = fixture.push(&fixture.text("folder"), Expect::Any, b"x"); + assert!(matches!(folder, Err(Error::NotAFile))); + assert_eq!(entries(fixture.home.path()), ["file", "folder", "link"]); +} + +#[test] +fn push_without_a_hasher_refuses_before_writing() { + let mut fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + // Every tool the script runs before choosing a hasher is a shell builtin, + // so an empty PATH hides all three hashers without breaking the script. + let bin = tempfile::tempdir().unwrap(); + fixture.path_env = Some(bin.path().to_owned()); + let pushed = fixture.push( + &fixture.text("file"), + Expect::Sha256(sha256(b"old")), + b"new", + ); + assert!(matches!(pushed, Err(Error::NoHasher))); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"old"); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +#[test] +fn push_of_unread_output_is_a_script_failure() { + let fixture = Fixture::new(); + let command = fixture.command("echo stray"); + let pushed = push_with(command, &b""[..], &AtomicBool::new(false)); + let error = pushed.unwrap_err(); + assert!(matches!( + script_error(&error), + Some(crate::Error::ScriptOutputLimit) + )); +} + +/// Writes `file` holding `old` and a private `victim` file another user +/// would like the push to write through. +fn file_and_victim(fixture: &Fixture) { + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", 0o644); + std::fs::write(fixture.path("victim"), b"victim").unwrap(); + fixture.set_mode("victim", 0o600); +} + +fn assert_victim_unchanged(fixture: &Fixture) { + assert_eq!(std::fs::read(fixture.path("victim")).unwrap(), b"victim"); + assert_eq!(fixture.mode("victim"), 0o600); +} + +/// The `ls` stand-in marks the target with an ACL, which sends the push in +/// place, and swaps it for a link to the victim as it lists it, as another +/// user who can rename entries in a 0777 folder can. +#[test] +fn push_refuses_to_write_in_place_in_a_folder_others_can_write() { + let mut fixture = Fixture::new(); + file_and_victim(&fixture); + std::fs::set_permissions(fixture.home.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + let file = fixture.path("file"); + let body = format!( + "case \"$*\" in\n*'-ld -- '*/file)\n rm -f -- '{file}' && ln -s '{victim}' '{file}'\n \ + echo '-rw-r--r--+ 1 0 0 3 Jan 1 00:00 file'\n exit 0 ;;\nesac\nexec '{ls}' \"$@\"\n", + file = file.display(), + victim = fixture.path("victim").display(), + ls = real_tool("ls").display(), + ); + let _bin = fixture.shim("ls", &body); + let pushed = fixture.push(&fixture.text("file"), Expect::Any, b"new"); + assert!(matches!(pushed, Err(Error::SharedFolder)), "{pushed:?}"); + assert_victim_unchanged(&fixture); + assert_eq!(entries(fixture.home.path()), ["file", "victim"]); +} + +/// The `mktemp` stand-in moves what it made aside and plants a link to the +/// victim at the name it prints, as another user can in a shared folder. +#[test] +fn push_never_writes_through_a_link_planted_at_its_temporary_name() { + let mut fixture = Fixture::new(); + file_and_victim(&fixture); + let body = format!( + "made=$('{mktemp}' \"$@\") || exit 1\nmv -- \"$made\" '{aside}'\nln -s '{victim}' \ + \"$made\"\nprintf '%s\\n' \"$made\"\n", + mktemp = real_tool("mktemp").display(), + aside = fixture.path("aside").display(), + victim = fixture.path("victim").display(), + ); + let _bin = fixture.shim("mktemp", &body); + let pushed = fixture.push(&fixture.text("file"), Expect::Any, b"new"); + assert!(pushed.is_err(), "{pushed:?}"); + assert_victim_unchanged(&fixture); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"old"); +} + +/// The `chmod` stand-in first moves the push's temporary entry in the folder +/// aside and plants a link to the victim in its place, as another user can in +/// a shared folder. +#[test] +fn push_never_chmods_through_a_link_swapped_in_for_its_copy() { + let mut fixture = Fixture::new(); + file_and_victim(&fixture); + let body = format!( + "for entry in '{home}'/.file.*; do\n if [ -e \"$entry\" ] && [ ! -e '{aside}' ]; then\n \ + mv -- \"$entry\" '{aside}' && ln -s '{victim}' \"$entry\"\n fi\ndone\nexec '{chmod}' \ + \"$@\"\n", + home = fixture.home.path().display(), + aside = fixture.path("aside").display(), + victim = fixture.path("victim").display(), + chmod = real_tool("chmod").display(), + ); + let _bin = fixture.shim("chmod", &body); + let pushed = fixture.push(&fixture.text("file"), Expect::Any, b"new"); + assert!(pushed.is_ok(), "{pushed:?}"); + assert_victim_unchanged(&fixture); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(fixture.mode("file"), 0o644); +} + +/// "Keep both" with a file that appears at the new name after the conflict +/// check: the `stat` stand-in plants it and fails, which would send the push +/// in place over it. +#[test] +fn push_absent_never_writes_in_place_over_a_file_that_appeared() { + let mut fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + let copy = fixture.path("file.conflict"); + let body = format!( + "[ -e '{copy}' ] || printf planted > '{copy}'\nexit 1\n", + copy = copy.display() + ); + let _bin = fixture.shim("stat", &body); + let pushed = fixture.push_like( + &fixture.text("file.conflict"), + &fixture.text("file"), + b"mine", + ); + assert!(matches!(pushed, Err(Error::Conflict)), "{pushed:?}"); + assert_eq!(std::fs::read(©).unwrap(), b"planted"); + assert_eq!(entries(fixture.home.path()), ["file", "file.conflict"]); +} + +/// Another user can swap the push's work folder for a link to one of the +/// SSH user's own private folders beside it, which passes every check on +/// the work folder; the `mktemp` stand-in hands that folder out for `-d`. +/// The push must open, replace, and remove only names it created there. +#[test] +fn push_never_touches_files_in_a_private_folder_handed_out_as_its_work_folder() { + let mut fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + std::fs::create_dir(fixture.path("sibling")).unwrap(); + fixture.set_mode("sibling", 0o700); + let kept = ["f", "p", "q"]; + for name in kept { + std::fs::write(fixture.path("sibling").join(name), name).unwrap(); + } + let body = format!( + "case \"$1\" in -d) printf '%s\\n' ./sibling; exit 0 ;; esac\nexec '{mktemp}' \"$@\"\n", + mktemp = real_tool("mktemp").display(), + ); + let _bin = fixture.shim("mktemp", &body); + let pushed = fixture.push(&fixture.text("file"), Expect::Any, b"new"); + assert!(pushed.is_ok(), "{pushed:?}"); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(entries(&fixture.path("sibling")), kept); + for name in kept { + let held = std::fs::read(fixture.path("sibling").join(name)).unwrap(); + assert_eq!(held, name.as_bytes(), "{name}"); + } +} + +/// "Keep both" with a file that appears at the new name after the conflict +/// check and before the rename: the `mv` stand-in plants it on its first run. +#[test] +fn push_absent_never_renames_over_a_file_that_appeared() { + let mut fixture = Fixture::new(); + let target = fixture.path("new"); + let body = format!( + "[ -e '{target}' ] || printf planted > '{target}'\nexec '{mv}' \"$@\"\n", + target = target.display(), + mv = real_tool("mv").display(), + ); + let _bin = fixture.shim("mv", &body); + let pushed = fixture.push(&fixture.text("new"), Expect::Absent, b"mine"); + assert!(matches!(pushed, Err(Error::Conflict)), "{pushed:?}"); + assert_eq!(std::fs::read(&target).unwrap(), b"planted"); + assert_eq!(entries(fixture.home.path()), ["new"]); +} + +/// A file whose name is as long as a name can be: the work folder's name +/// must not grow from it. +#[test] +fn push_writes_a_file_with_the_longest_name() { + let fixture = Fixture::new(); + let name = "n".repeat(255); + std::fs::write(fixture.path(&name), b"old").unwrap(); + fixture + .push(&fixture.text(&name), Expect::Any, b"new") + .unwrap(); + assert_eq!(std::fs::read(fixture.path(&name)).unwrap(), b"new"); + assert_eq!(entries(fixture.home.path()), [name]); +} diff --git a/crates/herdr-client/src/remote_file/tests/posix_setgid.rs b/crates/herdr-client/src/remote_file/tests/posix_setgid.rs new file mode 100644 index 000000000..8514cd431 --- /dev/null +++ b/crates/herdr-client/src/remote_file/tests/posix_setgid.rs @@ -0,0 +1,38 @@ +//! Pushes into folders whose set-group-ID bit the push's work folder +//! inherits, run under this machine's `/bin/sh`. + +use super::*; + +/// Linux gives a folder made inside a set-group-ID folder the bit too, so the +/// private work folder lists as `drwx--S---`; a group-writable shared project +/// folder is the common case. +#[cfg(target_os = "linux")] +#[test] +fn push_succeeds_in_a_setgid_folder_the_user_owns() { + let fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + std::fs::set_permissions(fixture.home.path(), std::fs::Permissions::from_mode(0o2775)).unwrap(); + let pushed = fixture.push(&fixture.text("file"), Expect::Any, b"new"); + assert!(pushed.is_ok(), "{pushed:?}"); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"new"); + assert_eq!(entries(fixture.home.path()), ["file"]); +} + +/// The `mktemp` stand-in makes the work folder `drwx--s---`: set-group-ID, +/// and its group may enter it. +#[test] +fn push_refuses_a_work_folder_its_group_can_enter() { + let mut fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + let body = format!( + "case \"$1\" in -d)\n made=$('{mktemp}' \"$@\") || exit 1\n '{chmod}' 2710 \"$made\" || exit 1\n \ + printf '%s\\n' \"$made\"\n exit 0 ;;\nesac\nexec '{mktemp}' \"$@\"\n", + mktemp = real_tool("mktemp").display(), + chmod = real_tool("chmod").display(), + ); + let _bin = fixture.shim("mktemp", &body); + let pushed = fixture.push(&fixture.text("file"), Expect::Any, b"new"); + assert!(matches!(pushed, Err(Error::SharedFolder)), "{pushed:?}"); + assert_eq!(std::fs::read(fixture.path("file")).unwrap(), b"old"); + assert_eq!(entries(fixture.home.path()), ["file"]); +} diff --git a/crates/herdr-client/src/remote_file/tests/powershell_exec.rs b/crates/herdr-client/src/remote_file/tests/powershell_exec.rs new file mode 100644 index 000000000..46ffa3e9f --- /dev/null +++ b/crates/herdr-client/src/remote_file/tests/powershell_exec.rs @@ -0,0 +1,411 @@ +//! Runs the PowerShell scripts in this machine's PowerShell, as a Windows +//! OpenSSH host would after decoding `-EncodedCommand`. They drive the +//! PowerShell child directly, with no `ssh` in between. + +use super::*; +use std::{ + path::PathBuf, + process::{Command, Stdio}, +}; + +/// Each PowerShell on this machine that starts. +fn shells() -> Vec<&'static str> { + let candidates: &[&'static str] = if cfg!(windows) { + &["powershell.exe", "pwsh"] + } else { + &["pwsh"] + }; + candidates + .iter() + .copied() + .filter(|shell| { + Command::new(shell) + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "exit 0", + ]) + .stdin(Stdio::null()) + .output() + .is_ok_and(|output| output.status.success()) + }) + .collect() +} + +struct Run { + code: Option, + stdout: Vec, + stderr: String, +} + +/// Runs `script` with `stdin` on its stdin and `home` as its profile folder. +fn run(shell: &str, script: &str, stdin: &[u8], home: &Path) -> Run { + let mut child = Command::new(shell) + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + ]) + .arg(encode(script)) + .env("USERPROFILE", home) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + let mut output = child.stdout.take().unwrap(); + let mut errors = child.stderr.take().unwrap(); + let (stdout, stderr) = std::thread::scope(|scope| { + // A script that refuses exits without reading its input; its exit + // code is what the test checks. + scope.spawn(move || drop(input.write_all(stdin))); + let stderr = scope.spawn(move || { + let mut text = Vec::new(); + errors.read_to_end(&mut text).unwrap(); + String::from_utf8_lossy(&text).into_owned() + }); + let mut stdout = Vec::new(); + output.read_to_end(&mut stdout).unwrap(); + (stdout, stderr.join().unwrap()) + }); + let code = child.wait().unwrap().code(); + Run { + code, + stdout, + stderr, + } +} + +#[track_caller] +fn assert_code(run: &Run, code: i32) { + assert_eq!(run.code, Some(code), "stderr: {}", run.stderr); +} + +struct Fixture { + shell: &'static str, + home: tempfile::TempDir, +} + +impl Fixture { + fn path(&self, name: &str) -> PathBuf { + self.home.path().join(name) + } + + fn text(&self, name: &str) -> String { + self.path(name).to_str().unwrap().to_owned() + } + + /// The profile folder's names, less the `AppData` PowerShell creates in + /// any profile it starts with. + fn files(&self) -> Vec { + let mut names = entries(self.home.path()); + names.retain(|name| name != "AppData"); + names + } + + fn fetch(&self, path: &str, max_bytes: u64) -> Run { + run( + self.shell, + &powershell::fetch_script(path, max_bytes), + &[], + self.home.path(), + ) + } + + fn push(&self, path: &str, expect: Expect, input: &[u8]) -> Run { + self.push_claiming(path, expect, input.len() as u64, input) + } + + /// A push that says it sends `len` bytes and sends `input`. + fn push_claiming(&self, path: &str, expect: Expect, len: u64, input: &[u8]) -> Run { + run( + self.shell, + &powershell::push_script(path, expect, Access::Keep, len), + input, + self.home.path(), + ) + } + + /// A push of a new file at `path` that takes the access of `source`. + fn push_like(&self, path: &str, source: &str, input: &[u8]) -> Run { + let script = powershell::push_script( + path, + Expect::Absent, + Access::Like(source), + input.len() as u64, + ); + run(self.shell, &script, input, self.home.path()) + } +} + +fn fetches(fixture: &Fixture) { + let original = payload(3 * 1024 * 1024 + 17); + std::fs::write(fixture.path(HOSTILE_NAME), &original).unwrap(); + let fetched = fixture.fetch(&fixture.text(HOSTILE_NAME), MAX_REMOTE_FILE_BYTES); + assert_code(&fetched, 0); + assert!(fetched.stdout == original, "fetched bytes differ"); + + let home = fixture.fetch(&format!("~\\{HOSTILE_NAME}"), MAX_REMOTE_FILE_BYTES); + assert_code(&home, 0); + assert!(home.stdout == original, "home-relative bytes differ"); + assert_code( + &fixture.fetch(&format!("~/{HOSTILE_NAME}"), MAX_REMOTE_FILE_BYTES), + 0, + ); + + let too_large = fixture.fetch(&fixture.text(HOSTILE_NAME), original.len() as u64 - 1); + assert_code(&too_large, TOO_LARGE_EXIT); + assert!(too_large.stdout.is_empty()); + assert_code(&fixture.fetch(&fixture.text("missing"), 10), MISSING_EXIT); + std::fs::create_dir(fixture.path("folder")).unwrap(); + assert_code(&fixture.fetch(&fixture.text("folder"), 10), NOT_A_FILE_EXIT); +} + +fn pushes(fixture: &Fixture) { + let name = "it's a file.txt"; + let path = fixture.text(name); + let first = payload(4096); + std::fs::write(&path, &first).unwrap(); + let before = fixture.files(); + + let second = payload(5000).into_iter().rev().collect::>(); + assert_code( + &fixture.push(&path, Expect::Sha256(sha256(&first)), &second), + 0, + ); + assert!(std::fs::read(&path).unwrap() == second); + assert_eq!(fixture.files(), before); + + // A transfer cut off short of its length replaces nothing. + for expect in [Expect::Sha256(sha256(&second)), Expect::Any] { + let short = fixture.push_claiming(&path, expect, 5000, &first[..1000]); + assert_code(&short, SHORT_EXIT); + assert!(std::fs::read(&path).unwrap() == second); + assert_eq!(fixture.files(), before); + } + let fresh = fixture.text("never.txt"); + assert_code( + &fixture.push_claiming(&fresh, Expect::Absent, 9, b"new"), + SHORT_EXIT, + ); + assert_eq!(fixture.files(), before); + + let stale = fixture.push(&path, Expect::Sha256(sha256(&first)), b"stale"); + assert_code(&stale, CONFLICT_EXIT); + assert!(std::fs::read(&path).unwrap() == second); + assert_eq!(fixture.files(), before); + + assert_code( + &fixture.push(&path, Expect::Absent, b"absent"), + CONFLICT_EXIT, + ); + assert!(std::fs::read(&path).unwrap() == second); + assert_eq!(fixture.files(), before); + + assert_code(&fixture.push(&path, Expect::Any, b"any"), 0); + assert_eq!(std::fs::read(&path).unwrap(), b"any"); + + let fresh = format!("~\\{HOSTILE_NAME}.new"); + assert_code(&fixture.push(&fresh, Expect::Absent, &first), 0); + assert!(std::fs::read(fixture.path(&format!("{HOSTILE_NAME}.new"))).unwrap() == first); + + std::fs::create_dir(fixture.path("folder")).unwrap(); + assert_code( + &fixture.push(&fixture.text("folder"), Expect::Any, b"x"), + NOT_A_FILE_EXIT, + ); + let missing = fixture.push(&fixture.text("missing"), Expect::Sha256(sha256(b"")), b"x"); + assert_code(&missing, CONFLICT_EXIT); + + // A kept-both copy takes its source's ACL; a source whose ACL cannot be + // read is refused before the temporary file exists. + let kept = fixture.text("kept.txt"); + assert_code(&fixture.push_like(&kept, &path, b"kept"), 0); + assert_eq!(std::fs::read(&kept).unwrap(), b"kept"); + let gone = fixture.push_like(&fixture.text("gone.txt"), &fixture.text("missing"), b"x"); + assert_code(&gone, ACCESS_EXIT); + // A folder has an ACL of its own, which is no file's to copy. + let folder = fixture.push_like(&fixture.text("dir.txt"), &fixture.text("folder"), b"x"); + assert_code(&folder, ACCESS_EXIT); + let mut expected = before.clone(); + expected.extend([ + "folder".to_owned(), + format!("{HOSTILE_NAME}.new"), + "kept.txt".to_owned(), + ]); + expected.sort(); + assert_eq!(fixture.files(), expected); +} + +/// Runs `script` under Windows PowerShell and returns its trimmed stdout. +#[cfg(windows)] +fn windows_powershell(script: &str) -> String { + let output = Command::new("powershell.exe") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + ]) + .arg(encode(&format!( + "$ErrorActionPreference = 'Stop'\n{script}" + ))) + .stdin(Stdio::null()) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{script}\nstderr: {stderr}"); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +/// Waits for the push's temporary file to appear in `folder`. The deadline +/// is a hang guard only; a script that exits first fails at once. +#[cfg(windows)] +fn wait_for_temporary(folder: &Path, prefix: &str, child: &mut std::process::Child) -> PathBuf { + let deadline = std::time::Instant::now() + Duration::from_secs(300); + loop { + if let Some(name) = entries(folder) + .into_iter() + .find(|name| name.starts_with(prefix)) + { + return folder.join(name); + } + if let Some(status) = child.try_wait().unwrap() { + panic!("the push exited before creating its temporary file: {status}"); + } + assert!( + std::time::Instant::now() < deadline, + "no temporary file appeared" + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// The access-control part of `path`'s security descriptor, as SDDL. +#[cfg(windows)] +fn sddl(path: &Path) -> String { + windows_powershell(&format!( + "[System.IO.File]::GetAccessControl('{}', 'Access').GetSecurityDescriptorSddlForm('Access')", + path.to_str().unwrap() + )) +} + +/// A folder whose inheritable entries let Everyone read, holding +/// `secret.txt`, whose own protected ACL lets only this user in. +#[cfg(windows)] +fn guarded_secret() -> (tempfile::TempDir, PathBuf) { + let home = tempfile::tempdir().unwrap(); + let folder = home.path().to_str().unwrap(); + let target = home.path().join("secret.txt"); + windows_powershell(&format!( + r#"$acl = [System.IO.Directory]::GetAccessControl('{folder}') +$everyone = New-Object System.Security.Principal.SecurityIdentifier('S-1-1-0') +$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($everyone, 'Read', 'ContainerInherit, ObjectInherit', 'None', 'Allow'))) +[System.IO.Directory]::SetAccessControl('{folder}', $acl)"# + )); + std::fs::write(&target, b"old").unwrap(); + let target_text = target.to_str().unwrap(); + windows_powershell(&format!( + r#"$acl = New-Object System.Security.AccessControl.FileSecurity +$acl.SetAccessRuleProtection($true, $false) +$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($me, 'FullControl', 'Allow'))) +[System.IO.File]::SetAccessControl('{target_text}', $acl)"# + )); + assert!(!sddl(&target).contains(";WD)"), "the target is owner-only"); + (home, target) +} + +/// A push to a file whose ACL is stricter than its folder's inheritable +/// entries must not let the folder's readers read the upload while it is +/// still in the temporary file, under either PowerShell. +#[cfg(windows)] +#[test] +#[ignore = "spawns this machine's powershell.exe and pwsh"] +fn powershell_push_temporary_file_takes_the_targets_acl() { + for shell in shells() { + eprintln!("running under {shell}"); + let (home, target) = guarded_secret(); + let script = + powershell::push_script(target.to_str().unwrap(), Expect::Any, Access::Keep, 10); + let mut child = Command::new(shell) + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + ]) + .arg(encode(&script)) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + input.write_all(b"new").unwrap(); + input.flush().unwrap(); + let temporary = wait_for_temporary(home.path(), ".herdr-push.", &mut child); + // Reading an ACL needs only READ_CONTROL, which `FileShare.None` + // allows. + let temporary_sddl = sddl(&temporary); + drop(input); + let output = child.wait_with_output().unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(SHORT_EXIT), "stderr: {stderr}"); + assert!( + !temporary_sddl.contains(";WD)"), + "{shell}: the folder's Everyone entry reached the temporary file: {temporary_sddl}" + ); + assert_eq!(std::fs::read(&target).unwrap(), b"old"); + assert_eq!(entries(home.path()), ["secret.txt"]); + } +} + +/// A kept-both copy of an owner-only file must not take the folder's wider +/// inheritable entries, under either PowerShell. +#[cfg(windows)] +#[test] +#[ignore = "spawns this machine's powershell.exe and pwsh"] +fn powershell_push_kept_copy_takes_its_sources_acl() { + for shell in shells() { + eprintln!("running under {shell}"); + let (home, target) = guarded_secret(); + let fixture = Fixture { shell, home }; + let copy = fixture.text("secret.conflict.txt"); + let pushed = fixture.push_like(©, target.to_str().unwrap(), b"mine"); + assert_code(&pushed, 0); + assert_eq!(std::fs::read(©).unwrap(), b"mine"); + let copy_sddl = sddl(Path::new(©)); + assert!( + !copy_sddl.contains(";WD)"), + "{shell}: the folder's Everyone entry reached the kept copy: {copy_sddl}" + ); + } +} + +#[test] +#[ignore = "spawns this machine's powershell.exe and pwsh"] +fn powershell_scripts_fetch_and_push_files() { + let shells = shells(); + if shells.is_empty() { + eprintln!("skipped: no PowerShell on this machine"); + return; + } + for shell in shells { + eprintln!("running under {shell}"); + let fetch = Fixture { + shell, + home: tempfile::tempdir().unwrap(), + }; + fetches(&fetch); + let push = Fixture { + shell, + home: tempfile::tempdir().unwrap(), + }; + pushes(&push); + } +} diff --git a/crates/herdr-client/src/remote_file/tests/scripts.rs b/crates/herdr-client/src/remote_file/tests/scripts.rs new file mode 100644 index 000000000..e25ae3ba4 --- /dev/null +++ b/crates/herdr-client/src/remote_file/tests/scripts.rs @@ -0,0 +1,368 @@ +use super::*; +use crate::script::shell_quote; + +const HOSTILE_PATHS: [&str; 6] = [ + "/srv/a b", + "/srv/it's", + "/srv/-dash", + "/srv/$HOME", + "/srv/`id`", + "/srv/\u{e9}\u{2019}", +]; + +#[test] +fn posix_scripts_assign_the_path_as_one_quoted_word() { + for path in HOSTILE_PATHS { + let assignment = format!("path={}\n", shell_quote(path)); + assert!( + posix::fetch_script(path, 10).starts_with(&assignment), + "{path}" + ); + for expect in [Expect::Any, Expect::Absent, Expect::Sha256([0; 32])] { + let script = posix::push_script(path, expect, Access::Keep, 0); + assert!(script.contains(&format!("\n{assignment}")), "{path}"); + } + } + assert!(posix::fetch_script("/srv/it's", 10).starts_with("path='/srv/it'\\''s'\n")); +} + +#[test] +fn posix_scripts_expand_only_a_leading_home() { + let fetch = |path| posix::fetch_script(path, 10); + assert!(fetch("~/a b").starts_with("path=\"$HOME\"/'a b'\n")); + assert!(fetch("~/-x").starts_with("path=\"$HOME\"/'-x'\n")); + assert!(fetch("~").starts_with("path=\"$HOME\"\n")); + assert!(fetch("~root/x").starts_with("path='~root/x'\n")); + assert!(fetch("~\\x").starts_with("path='~\\x'\n")); + assert!(fetch("/srv/~/x").starts_with("path='/srv/~/x'\n")); +} + +#[test] +fn posix_scripts_never_pass_the_path_where_it_could_be_an_option() { + let fetch = posix::fetch_script("-x", 10); + assert!(fetch.contains("wc -c < \"$path\"")); + assert!(fetch.contains("exec cat < \"$path\"")); + let push = posix::push_script("-x", Expect::Sha256([0; 32]), Access::Keep, 0); + assert!(push.contains("$hasher < \"$target\"")); + assert!(push.contains("mv -f $no_follow -- \"$copy\" \"$target\"")); + assert!(push.contains("\ntarget=../$name\n")); +} + +#[test] +fn posix_push_consults_a_hasher_only_for_an_expected_hash() { + let digest = [0xAB; 32]; + let script = posix::push_script("/f", Expect::Sha256(digest), Access::Keep, 0); + assert!(script.contains("sha256sum")); + assert!(script.contains(&format!("!= '{}'", "ab".repeat(32)))); + for expect in [Expect::Absent, Expect::Any] { + assert!(!posix::push_script("/f", expect, Access::Keep, 0).contains("sha256sum")); + } +} + +#[test] +fn pushes_check_the_received_length_before_the_expectation() { + let posix = posix::push_script("/f", Expect::Sha256([0; 32]), Access::Keep, 42); + let counted = posix.find("-ne 42 ]; then exit 70").unwrap(); + assert!(counted < posix.find("$hasher < \"$target\"").unwrap()); + assert!(counted < posix.find("mv -f").unwrap()); + assert!( + posix::push_script("/f", Expect::Any, Access::Keep, u64::MAX) + .contains(&format!("-ne {} ]", i64::MAX)) + ); + let powershell = powershell::push_script("C:\\f", Expect::Sha256([0; 32]), Access::Keep, 42); + let counted = powershell.find("($received -ne 42) { exit 70 }").unwrap(); + assert!(counted < powershell.find("ComputeHash").unwrap()); + assert!(counted < powershell.find("::Replace(").unwrap()); +} + +#[test] +fn posix_push_names_where_a_new_files_access_comes_from() { + let keep = posix::push_script("/f", Expect::Absent, Access::Keep, 0); + assert!(keep.contains("\nsource=\n")); + let like = posix::push_script("/f.conflict", Expect::Absent, Access::Like("~/a b"), 0); + assert!(like.contains("\nsource=\"$HOME\"/'a b'\n")); + let hostile = posix::push_script("/f", Expect::Absent, Access::Like("/srv/it's"), 0); + assert!(hostile.contains("\nsource='/srv/it'\\''s'\n")); +} + +#[test] +fn posix_push_checks_it_can_write_in_place_before_writing() { + let script = posix::push_script("/f", Expect::Any, Access::Keep, 0); + let shared = script + .find("if [ -z \"$safe\" ]; then exit 72; fi") + .unwrap(); + let checked = script + .find("if [ ! -w \"$target\" ]; then exit 71; fi") + .unwrap(); + let written = script.find("cat < \"$copy\" > \"$target\"").unwrap(); + assert!(shared < written && checked < written); + // "Keep both" checks for a file that appeared before writing in place + // and again just before the rename. + let absent = posix::push_script("/f", Expect::Absent, Access::Keep, 0); + let check = "if [ -e \"$target\" ] || [ -L \"$target\" ]; then exit 75; fi"; + let in_place = absent.find("\nelif [ -e \"$target\" ]; then\n").unwrap(); + let conflict = in_place + absent[in_place..].find(check).unwrap(); + assert!(conflict < absent.find("cat < \"$copy\" > \"$target\"").unwrap()); + let renamed = absent.find("\nmv -f $no_follow").unwrap(); + let last = absent.rfind(check).unwrap(); + assert!(absent.find("probe_to=$(mktemp").unwrap() < last && last < renamed); + assert!( + script.contains("??????????[+@]*"), + "macOS marks an ACL with @ too" + ); + assert!( + script.contains("getfacl -s -- \"$listed\""), + "BusyBox ls marks none" + ); +} + +#[test] +fn powershell_push_reads_the_acl_before_creating_the_copy() { + let like = + powershell::push_script("C:\\f.conflict", Expect::Absent, Access::Like("C:\\a b"), 0); + assert!(like.contains("\n$source = [System.IO.Path]::GetFullPath('C:\\a b')\n")); + // A source that is not a file is refused, then one whose ACL cannot be + // read, both before the copy exists. + let not_a_file = like.find("Get-HerdrAttributes $like").unwrap(); + let read = like.find("GetAccessControl(").unwrap(); + let refused = like.rfind("exit 71").unwrap(); + let created = like.find("FileMode]::CreateNew").unwrap(); + assert!(not_a_file < read && read < refused && refused < created); + // A source the user cannot stat fails inside the `try`, so it is refused + // too rather than ending the script with a generic failure. + let guarded = like.find("try {\n $likeAttributes = Get-HerdrAttributes $like"); + assert!(guarded.is_some(), "{like}"); + assert!(like.contains("FileSystemAclExtensions]::Create(")); + let keep = powershell::push_script("C:\\f", Expect::Absent, Access::Keep, 0); + assert!(keep.contains("\n$source = $null\n")); +} + +#[test] +fn posix_fetch_limit_stays_in_signed_shell_arithmetic() { + assert!(posix::fetch_script("/f", u64::MAX).contains(&format!("-gt {} ]", i64::MAX))); + assert!(posix::fetch_script("/f", 42).contains("-gt 42 ]")); +} + +#[test] +fn powershell_scripts_quote_the_path_as_a_literal() { + let path = "C:\\a b\\it's \u{2018}x\u{2019} $env:X `y \u{e9}"; + let quoted = "'C:\\a b\\it''s \u{2018}\u{2018}x\u{2019}\u{2019} $env:X `y \u{e9}'"; + assert!(powershell::fetch_script(path, 10).contains(&format!("\n$path = {quoted}\n"))); + let push = powershell::push_script(path, Expect::Any, Access::Keep, 0); + assert!(push.contains(&format!("GetFullPath({quoted})"))); + assert!(powershell::fetch_script("-x", 10).contains("\n$path = '-x'\n")); + for path in HOSTILE_PATHS { + let script = powershell::fetch_script(path, 10); + let line = script.lines().nth(2).unwrap(); + assert!( + line.starts_with("$path = '") && line.ends_with('\''), + "{line}" + ); + } +} + +#[test] +fn powershell_scripts_expand_only_a_leading_home() { + let combine = "[System.IO.Path]::Combine($env:USERPROFILE, 'a b')"; + assert!(powershell::fetch_script("~\\a b", 10).contains(combine)); + assert!(powershell::fetch_script("~/a b", 10).contains(combine)); + assert!(powershell::fetch_script("~", 10).contains("\n$path = $env:USERPROFILE\n")); + assert!(powershell::fetch_script("~x\\a", 10).contains("\n$path = '~x\\a'\n")); +} + +#[test] +fn powershell_push_checks_each_expectation() { + let script = powershell::push_script("C:\\f", Expect::Sha256([0x0F; 32]), Access::Keep, 0); + assert!(script.contains("SHA256]::Create().ComputeHash($current)")); + assert!(script.contains(&format!("-ne '{}'", "0f".repeat(32)))); + let absent = powershell::push_script("C:\\f", Expect::Absent, Access::Keep, 0); + assert!(absent.contains("Get-HerdrAttributes $path)) { exit 75 }")); + assert!(!absent.contains("ComputeHash")); + let any = powershell::push_script("C:\\f", Expect::Any, Access::Keep, 0); + assert!(!any.contains("ComputeHash") && !any.contains("exit 75")); + for script in [script, absent, any] { + assert!(!script.contains("$input")); + assert!(script.contains("[Console]::OpenStandardInput()")); + assert!( + script.contains("[System.IO.File]::Replace($temporary, $path, [NullString]::Value)") + ); + } +} + +#[test] +fn encoded_scripts_decode_back_to_the_script() { + let script = powershell::push_script("C:\\\u{e9}\u{1F600}'", Expect::Absent, Access::Keep, 0); + assert_eq!(decode(&encode(&script)), script); +} + +/// The SSH command line carries exactly the encoding the local PowerShell +/// tests run, so they prove what a Windows host receives. +#[test] +fn powershell_ssh_command_carries_the_encoded_script() { + let script = powershell::fetch_script("~\\it's", 10); + let command = crate::ssh::powershell_script_command("user@host", &script).unwrap(); + let remote = command + .get_args() + .last() + .unwrap() + .to_str() + .unwrap() + .to_owned(); + let encoded = remote + .strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ") + .unwrap(); + assert_eq!(encoded, encode(&script)); + assert_eq!(decode(encoded), script); +} + +#[test] +fn hex_is_lowercase_and_padded() { + let mut digest = [0u8; 32]; + digest[0] = 0x0A; + digest[31] = 0xF0; + let text = hex(&digest); + assert_eq!(text.len(), 64); + assert!(text.starts_with("0a00") && text.ends_with("00f0")); +} + +#[cfg(unix)] +fn exit(code: i32) -> crate::Error { + use std::os::unix::process::ExitStatusExt; + crate::Error::ScriptExit { + status: std::process::ExitStatus::from_raw(code << 8), + stderr: String::new(), + } +} + +#[cfg(windows)] +fn exit(code: i32) -> crate::Error { + use std::os::windows::process::ExitStatusExt; + crate::Error::ScriptExit { + status: std::process::ExitStatus::from_raw(code.unsigned_abs()), + stderr: String::new(), + } +} + +#[test] +fn exit_codes_map_to_refusals() { + let limit = Some(MAX_REMOTE_FILE_BYTES); + assert!(matches!(classify(exit(65), limit), Error::Symlink)); + assert!(matches!(classify(exit(66), limit), Error::Missing)); + assert!(matches!(classify(exit(67), None), Error::NotAFile)); + assert!(matches!( + classify(exit(68), limit), + Error::TooLarge { + limit: MAX_REMOTE_FILE_BYTES + } + )); + assert!(matches!(classify(exit(69), None), Error::NoHasher)); + assert!(matches!(classify(exit(70), None), Error::Incomplete)); + assert!(matches!(classify(exit(71), None), Error::Access)); + assert!(matches!(classify(exit(72), None), Error::SharedFolder)); + assert!(matches!(classify(exit(75), None), Error::Conflict)); + assert!(matches!( + classify(crate::Error::ScriptOutputLimit, Some(7)), + Error::TooLarge { limit: 7 } + )); +} + +#[test] +fn other_failures_keep_the_script_error() { + let failed = classify(exit(1), Some(1)); + assert!( + matches!(script_error(&failed), Some(crate::Error::ScriptExit { status, .. }) if status.code() == Some(1)) + ); + assert!(matches!( + script_error(&classify(exit(68), None)), + Some(crate::Error::ScriptExit { .. }) + )); + assert!(matches!( + script_error(&classify(crate::Error::ScriptOutputLimit, None)), + Some(crate::Error::ScriptOutputLimit) + )); + let cancelled = classify(crate::Error::ScriptCancelled, Some(1)); + assert!(matches!( + script_error(&cancelled), + Some(crate::Error::ScriptCancelled) + )); + let source = std::error::Error::source(&cancelled).unwrap(); + assert_eq!( + source.to_string(), + crate::Error::ScriptCancelled.to_string() + ); +} + +#[test] +fn messages_are_short_and_plain() { + let too_large = Error::TooLarge { + limit: MAX_REMOTE_FILE_BYTES, + }; + assert_eq!(too_large.to_string(), "the file is larger than 64 MB"); + assert_eq!( + Error::TooLarge { limit: 1 }.to_string(), + "the file is larger than 1 MB" + ); + let root = crate::Error::from(Error::Conflict); + assert!(matches!(root, crate::Error::RemoteFile(Error::Conflict))); + assert_eq!( + root.to_string(), + "the host's file changed since it was fetched" + ); + assert_eq!(Error::Missing.to_string(), "the file is not on the host"); + assert_eq!( + Error::Incomplete.to_string(), + "the upload was cut off; the host's file is unchanged" + ); + assert_eq!( + Error::Access.to_string(), + "could not keep the file's access; nothing was changed" + ); + assert_eq!( + Error::SharedFolder.to_string(), + "other users can replace files in that folder on the host, so it was not written in \ + place; nothing was changed" + ); +} + +fn file<'a>(target: &'a str, shell: RemoteShell, path: &'a str) -> RemoteFile<'a> { + RemoteFile { + target, + shell, + path, + } +} + +#[test] +fn public_functions_refuse_an_invalid_target() { + let cancelled = AtomicBool::new(false); + let posix = file("-oProxyCommand=x", RemoteShell::Posix, "/f"); + let fetched = fetch_remote_file(posix, Vec::new(), 1, &cancelled); + assert!(matches!(fetched, Err(crate::Error::InvalidSshTarget))); + let windows = file("-oProxyCommand=x", RemoteShell::PowerShell, "C:\\f"); + let pushed = push_remote_file(windows, Expect::Any, Access::Keep, 0, &[][..], &cancelled); + assert!(matches!(pushed, Err(crate::Error::InvalidSshTarget))); +} + +/// Every client, Windows included, runs a host's scripts through its own +/// `ssh`; `/bin/sh` and `powershell.exe` are the host's, not the client's. +#[test] +fn remote_command_runs_ssh_for_either_host_shell() { + let cases = [ + (RemoteShell::Posix, "/bin/sh -c "), + (RemoteShell::PowerShell, "powershell.exe "), + ]; + for (shell, remote) in cases { + let command = remote_command("user@host", shell, "exit 0").unwrap(); + let program = Path::new(command.get_program()); + assert_eq!(program.file_stem().unwrap(), "ssh", "{shell:?}"); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(args.contains(&"user@host"), "{shell:?}: {args:?}"); + assert!( + args.last().unwrap().starts_with(remote), + "{shell:?}: {args:?}" + ); + } +} diff --git a/crates/herdr-client/src/ssh.rs b/crates/herdr-client/src/ssh.rs index 29b721407..86b8b4674 100644 --- a/crates/herdr-client/src/ssh.rs +++ b/crates/herdr-client/src/ssh.rs @@ -517,6 +517,15 @@ pub fn script_command(target: &str, script: &str) -> Result { Ok(command(target, &format!("/bin/sh -c {}", quote(script)))) } +/// A one-shot, noninteractive `ssh` child that runs `script` under Windows +/// PowerShell on a Windows OpenSSH host, passed as `-EncodedCommand` so +/// neither `cmd.exe` nor PowerShell as the login shell reinterprets it, with +/// the bridge's connection policy. The caller owns the child. +pub(crate) fn powershell_script_command(target: &str, script: &str) -> Result { + validate_target(target)?; + Ok(command(target, &powershell::encoded_command(script))) +} + pub(crate) fn connect( target: &str, session: &str, diff --git a/crates/herdr-client/src/ssh/powershell.rs b/crates/herdr-client/src/ssh/powershell.rs index f2a0d1342..a61ae29ce 100644 --- a/crates/herdr-client/src/ssh/powershell.rs +++ b/crates/herdr-client/src/ssh/powershell.rs @@ -94,7 +94,7 @@ pub(super) fn bridge_command(path: &str, session: &str, idle_timeout: bool) -> S /// `script` as a `powershell.exe` command line, encoded as UTF-16LE base64 /// so neither `cmd.exe` nor PowerShell as the login shell reinterprets it. -fn encoded_command(script: &str) -> String { +pub(super) fn encoded_command(script: &str) -> String { let utf16 = script .encode_utf16() .flat_map(u16::to_le_bytes) diff --git a/crates/herdr-client/src/upload_tests.rs b/crates/herdr-client/src/upload_tests.rs index 48bac3d0e..85d24ebef 100644 --- a/crates/herdr-client/src/upload_tests.rs +++ b/crates/herdr-client/src/upload_tests.rs @@ -357,8 +357,8 @@ fn remote_trap_owns_created_file_before_acknowledgement() { #[test] fn fake_ssh_requires_exit_success_and_redacts_stderr() { let temp = Temp::new(); - let fake = temp.file("fake-ssh", b"#!/bin/sh\nfor arg do remote=$arg; done\n/bin/sh -c \"$remote\"\nprintf 'SECRET\\033[31m' >&2\nexit 17\n"); - fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)).unwrap(); + let fake = temp.0.join("fake-ssh"); + crate::test_executable::write(&fake, b"#!/bin/sh\nfor arg do remote=$arg; done\n/bin/sh -c \"$remote\"\nprintf 'SECRET\\033[31m' >&2\nexit 17\n", 0o700).unwrap(); let path = temp.file("one.iso", b"abc"); let mut calls = 0; let error = upload( diff --git a/crates/herdr-gpui/README.md b/crates/herdr-gpui/README.md index 858039c19..9f63128a9 100644 --- a/crates/herdr-gpui/README.md +++ b/crates/herdr-gpui/README.md @@ -10,7 +10,7 @@ environment once on the connection worker so daemon plugins can find tools such per-user and Homebrew bin directories; the probe has a five-second timeout. It does not link or install Herdr, stop daemons, spawn a local PTY, or emulate a terminal. Herdr's remote bridge may start -the named remote session. SSH requires an installed POSIX Herdr, noninteractive authentication, +the named remote session. SSH requires Herdr installed on the host (macOS, Linux, or Windows), noninteractive authentication, and an already trusted host key. For hosts that need MFA or a password, configure `ControlMaster auto` with a `ControlPath` in `~/.ssh/config` and authenticate once with `ssh HOST` in a terminal: the app reuses that master connection while it lives, @@ -2262,8 +2262,9 @@ its dashes turn solid once its target is verified: A plain URL is read from its row. One that reaches the pane's right edge may wrap onto the next row; a daemon that offers `pane.link.resolve` reads it -whole, and otherwise it needs explicit terminal hyperlink metadata. Paths are -not links in panes on SSH hosts, whose files live on that host. +whole, and otherwise it needs explicit terminal hyperlink metadata. Paths in +panes on SSH hosts name that host's files; see +[Files on SSH Hosts](#files-on-ssh-hosts). A file opens in your terminal editor at the printed line, in a new pane split to the right of the pane that printed it (see [Code Navigation](#code-navigation)). @@ -2283,6 +2284,54 @@ With a daemon that offers it, a link click, a file path or `file://` hyperlink included, first goes to the daemon so a plugin's `[[link_handlers]]` entry can claim it; see [Herdr Plugins](#herdr-plugins). +### Files on SSH Hosts + +A path printed in a pane on an SSH device is a link too, +such as `C:\dev\notes.md:42` from a Windows host, `src\main.rs` under a +Windows working directory, or `~/notes.md` and `src/main.rs` on a POSIX host. +The host is not asked whether the file exists, so the path is underlined by its +shape. Relative paths join the pane's working directory on the host, and `~` +is the login user's home there. + +The modifier click copies the file over the device's own SSH login into a +private cache folder and opens the copy, under the same rule as a local file: a +document opens in its default application, and anything else opens the folder +holding the copy. Windows OpenSSH hosts are reached through Windows PowerShell +5.1 or later, and other hosts through `/bin/sh`. Files up to 64 MB are fetched; +a folder, a larger file, or a missing one shows why nothing opened. + +The copy is watched for saves. The first save asks whether to upload it: +**Upload**, **Upload future saves without asking**, or **Not now**. Each +upload checks that the host's file is still the one fetched; when it changed +on the host meanwhile, nothing is written and a dialog offers **Keep both** +(uploads your copy beside it as `notes.conflict.md`), **Overwrite anyway**, or +**Re-fetch**, which replaces the copy and drops your edits. If the Re-fetch +fails, the dialog comes back. Saving the copy back to the bytes it was +fetched or last uploaded with drops the question and closes its dialog. An +upload replaces the host's file only once all of it arrived, and the file +does not become readable by anyone who could not read it before. An existing +file keeps its permissions and ACL. A **Keep both** copy gets the original +file's access; on a POSIX host it is private to you when that access cannot +be copied, and on a Windows host the upload is refused instead. When an +existing file's access cannot be kept, it is not changed and the upload says +so. On a macOS host, a folder ACL that others inherit can let them read the +upload while it transfers. Clicking the path again reopens a copy with +unuploaded edits, and fetches a synced one anew; a click while the file is +still being fetched does nothing more. + +Each window keeps its own copies, so the same host file opened in two windows +is two copies, and a fetch in one window never replaces the other's edits. +Copies last until the app quits, when the cache is removed; a cache an earlier +run left behind is removed on the next fetch. `herdr-gpui remote-files` lists +the calling window's copies and their state. WSL and cloud panes are +unchanged. A `file://` +hyperlink naming the host itself, as `ls --hyperlink` prints, still counts as +another host's file, and on Windows a `file://` hyperlink naming a POSIX path +is not a link. On Windows, a host file whose name Windows cannot store (one +with `<>:"/\|?*` or a control character, ending in a dot or space, or a device +name such as `CON`) is copied under a safe local name, such as `a_b.txt` for +`a:b.txt`; dialogs and uploads keep the host's name and path. + ## Herdr Plugins Herdr runs plugins; this client never reads plugin manifests, runs plugin diff --git a/crates/herdr-gpui/config-gpui.example.toml b/crates/herdr-gpui/config-gpui.example.toml index 46ab740f2..d5d4cf3a5 100644 --- a/crates/herdr-gpui/config-gpui.example.toml +++ b/crates/herdr-gpui/config-gpui.example.toml @@ -136,7 +136,8 @@ open_links_in = "system" # in a pane split beside the one that printed it) or "system" (the default # application). Folders, images, and PDFs always open in the system's # application, as does every file on Windows. Holding Alt (Option) as well opens a -# file in the other one. +# file in the other one. A path an SSH pane prints opens a fetched copy in the +# system's application, and saves to it ask before uploading. open_files_in = "editor" # How that editor starts. Unset, the pane's $VISUAL or $EDITOR runs, then vi, # as `$EDITOR +LINE FILE`. `{file}` and `{line}` place the two yourself: diff --git a/crates/herdr-gpui/src/cli/control.rs b/crates/herdr-gpui/src/cli/control.rs index aee3a24a8..044d8e48b 100644 --- a/crates/herdr-gpui/src/cli/control.rs +++ b/crates/herdr-gpui/src/cli/control.rs @@ -78,6 +78,8 @@ pub enum ControlCommand { Host(HostCommand), Cloud(CloudCommand), Ports(PortsCommand), + /// The copies of SSH hosts' files that link clicks fetched. + RemoteFiles, Processes { pane: Option, }, @@ -184,6 +186,10 @@ pub const FAMILIES: &[(&str, &str)] = &[ "ports", "Listening ports, and forwarding them to this machine", ), + ( + "remote-files", + "Copies of SSH hosts' files that link clicks fetched", + ), ("processes", "The processes running under a pane"), ("usage", "AI plan usage on the selected host"), ("system", "The selected host's load"), @@ -335,6 +341,10 @@ fn command(family: &'static str, args: &mut Args) -> Result ControlCommand::Host(hosts::host(args)?), "cloud" => ControlCommand::Cloud(hosts::cloud(args)?), "ports" => ControlCommand::Ports(hosts::ports(args)?), + "remote-files" => { + hosts::remote_files(args)?; + ControlCommand::RemoteFiles + } "processes" => ControlCommand::Processes { pane: args.text_option("--pane")?, }, diff --git a/crates/herdr-gpui/src/cli/control/hosts.rs b/crates/herdr-gpui/src/cli/control/hosts.rs index 71ba0dd1a..ca1fdcd97 100644 --- a/crates/herdr-gpui/src/cli/control/hosts.rs +++ b/crates/herdr-gpui/src/cli/control/hosts.rs @@ -1,4 +1,4 @@ -//! `pr`, `host`, `cloud`, `ports`, and `security`. +//! `pr`, `host`, `cloud`, `ports`, `remote-files`, and `security`. use super::{Args, BatchCommand, CliError, ExecCommand, batch, exec, unknown}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -115,6 +115,15 @@ fn port(args: &mut Args) -> Result { .ok_or(CliError::InvalidValue("PORT")) } +/// `remote-files [list]`: listing is all it does. +pub(super) fn remote_files(args: &mut Args) -> Result<(), CliError> { + match args.verb() { + None => Ok(()), + Some(verb) if verb == "list" => Ok(()), + Some(verb) => Err(unknown("remote-files", verb)), + } +} + pub(super) fn ports(args: &mut Args) -> Result { let Some(verb) = args.verb() else { return Ok(PortsCommand::List); diff --git a/crates/herdr-gpui/src/control/client.rs b/crates/herdr-gpui/src/control/client.rs index 1380ad151..7dae8fcbb 100644 --- a/crates/herdr-gpui/src/control/client.rs +++ b/crates/herdr-gpui/src/control/client.rs @@ -448,6 +448,7 @@ pub(crate) fn run(invocation: Invocation) -> ExitCode { ControlCommand::Host(command) => hosts::host(command, &context), ControlCommand::Cloud(command) => hosts::cloud(command, &context), ControlCommand::Ports(command) => hosts::ports(command, &context), + ControlCommand::RemoteFiles => hosts::remote_files(&context), ControlCommand::Processes { pane } => local::processes(pane.as_deref()), ControlCommand::Usage => hosts::usage(&context), ControlCommand::System => hosts::system(&context), diff --git a/crates/herdr-gpui/src/control/client/help.rs b/crates/herdr-gpui/src/control/client/help.rs index 86c6081f1..f1bd7f763 100644 --- a/crates/herdr-gpui/src/control/client/help.rs +++ b/crates/herdr-gpui/src/control/client/help.rs @@ -112,6 +112,9 @@ fn family(name: &str) -> &'static str { "ports" => { "herdr-gpui ports [list]\n Ports the caller's workspace's processes listen on, and forwards.\n Listening ports are known only while show_listening_ports is on.\nherdr-gpui ports forward|unforward PORT\n Forward a port of the workspace's SSH host to this machine, or stop.\nherdr-gpui ports open PORT\n Show localhost:PORT in a browser tab, forwarding it first if remote." } + "remote-files" => { + "herdr-gpui remote-files [list]\n Copies of SSH hosts' files that link clicks fetched, with whether\n each is synced, edited, or in conflict. Uploads are confirmed in the\n app." + } "processes" => { "herdr-gpui processes [--pane ID]\n The processes under a pane (default HERDR_PANE_ID), with CPU and\n memory. Runs here; Herdr UI need not run." } diff --git a/crates/herdr-gpui/src/control/client/hosts.rs b/crates/herdr-gpui/src/control/client/hosts.rs index 579b849a5..166ae79a1 100644 --- a/crates/herdr-gpui/src/control/client/hosts.rs +++ b/crates/herdr-gpui/src/control/client/hosts.rs @@ -1,4 +1,4 @@ -//! `pr`, `host`, `cloud`, `ports`, `usage`, `system`, and `security`. +//! `pr`, `host`, `cloud`, `ports`, `remote-files`, `usage`, `system`, and `security`. mod batch; mod cloud; mod exec; @@ -14,7 +14,7 @@ use crate::{ cli::control::{CloudCommand, HostCommand, PortsCommand, PrCommand, SecurityCommand}, control::protocol::{ Request, Response, - hosts::{HostOp, Reply}, + hosts::{HostOp, RemoteCopy, Reply}, }, }; @@ -103,6 +103,27 @@ pub(super) fn ports(command: PortsCommand, context: &Context) -> crate::Result crate::Result { + match context.call(Request::Host, HostOp::RemoteFiles)? { + Response::Host { + reply: Reply::RemoteFiles { copies, omitted }, + } => remote_files_output(&copies, omitted), + response => Err(unexpected(response)), + } +} + +/// The copies, one per line, exiting [`super::exit::NOTHING`] when there +/// are none. +fn remote_files_output(copies: &[RemoteCopy], omitted: usize) -> crate::Result { + let json = serde_json::json!({ "copies": copies, "omitted": omitted }); + let output = Output::data(text::remote_files(copies, omitted), &json)?; + Ok(if copies.is_empty() && omitted == 0 { + output.nothing() + } else { + output + }) +} + pub(super) fn usage(context: &Context) -> crate::Result { match context.call(Request::Host, HostOp::Usage)? { Response::Host { diff --git a/crates/herdr-gpui/src/control/client/hosts/tests.rs b/crates/herdr-gpui/src/control/client/hosts/tests.rs index 0a75bcd18..4d1b016e9 100644 --- a/crates/herdr-gpui/src/control/client/hosts/tests.rs +++ b/crates/herdr-gpui/src/control/client/hosts/tests.rs @@ -10,6 +10,8 @@ use crate::control::protocol::{ }, }; +mod remote_files; + fn check(name: &str, outcome: &str) -> CheckInfo { CheckInfo { name: name.into(), diff --git a/crates/herdr-gpui/src/control/client/hosts/tests/remote_files.rs b/crates/herdr-gpui/src/control/client/hosts/tests/remote_files.rs new file mode 100644 index 000000000..9e6ddba4d --- /dev/null +++ b/crates/herdr-gpui/src/control/client/hosts/tests/remote_files.rs @@ -0,0 +1,105 @@ +use super::super::{super::exit, super::help, remote_files_output}; +use super::text; +use crate::{ + cli::{ + CliError, + control::{ControlCommand, parse}, + }, + control::protocol::hosts::{CopyState, RemoteCopy}, +}; +use std::ffi::OsString; + +fn copy(path: &str, state: CopyState, auto_upload: bool) -> RemoteCopy { + RemoteCopy { + host: "me@devbox".into(), + path: path.into(), + local: format!("/cache/1{path}"), + state, + auto_upload, + } +} + +fn command(args: &[&str]) -> Result { + let rest = args[1..].iter().map(OsString::from); + parse(OsString::from(args[0]), rest).map(|invocation| invocation.command) +} + +#[test] +fn remote_files_parses_with_or_without_list() { + assert_eq!( + command(&["remote-files"]).unwrap(), + ControlCommand::RemoteFiles + ); + assert_eq!( + command(&["remote-files", "list", "--json"]).unwrap(), + ControlCommand::RemoteFiles + ); + assert!(matches!( + command(&["remote-files", "upload"]).unwrap_err(), + CliError::UnknownCommand { + family: "remote-files", + .. + } + )); + assert!(command(&["remote-files", "list", "extra"]).is_err()); +} + +#[test] +fn remote_files_read_one_line_per_copy() { + let copies = [ + copy("/srv/a.md", CopyState::Conflict, false), + copy("/srv/b.md", CopyState::Synced, true), + ]; + let output = remote_files_output(&copies, 0).unwrap(); + assert_eq!( + output.text, + "conflict me@devbox:/srv/a.md -> /cache/1/srv/a.md\n\ + synced (auto-upload) me@devbox:/srv/b.md -> /cache/1/srv/b.md" + ); + assert_eq!(output.status, 0); + assert_eq!(output.json["omitted"], 0); + assert_eq!(output.json["copies"][0]["state"], "conflict"); + assert_eq!(output.json["copies"][1]["auto_upload"], true); + assert_eq!(output.json["copies"][1]["local"], "/cache/1/srv/b.md"); + let states = [ + (CopyState::Edited, "edited"), + (CopyState::Asking, "asking"), + (CopyState::Uploading, "uploading"), + (CopyState::Declined, "declined"), + (CopyState::Refetching, "refetching"), + ]; + for (state, name) in states { + let line = text::remote_files(&[copy("/x", state, false)], 0); + assert!(line.starts_with(&format!("{name} ")), "{line}"); + } +} + +#[test] +fn no_remote_files_is_nothing_yet() { + let output = remote_files_output(&[], 0).unwrap(); + assert_eq!(output.text, "No remote file copies."); + assert_eq!(output.status, exit::NOTHING); + assert_eq!( + output.json, + serde_json::json!({ "copies": [], "omitted": 0 }) + ); + // Copies that did not fit are still copies. + let cut = remote_files_output(&[copy("/srv/a.md", CopyState::Synced, false)], 3).unwrap(); + assert_eq!(cut.status, 0); + assert!( + cut.text.ends_with("… and 3 more copies that did not fit"), + "{}", + cut.text + ); +} + +#[test] +fn remote_files_has_its_help() { + let usage = help::usage(Some("remote-files")); + assert!( + usage.starts_with("herdr-gpui remote-files [list]\n"), + "{usage}" + ); + assert!(usage.contains("Uploads are confirmed in the\n app.")); + assert!(help::usage(None).contains(" remote-files ")); +} diff --git a/crates/herdr-gpui/src/control/client/hosts/text.rs b/crates/herdr-gpui/src/control/client/hosts/text.rs index 4e77390d6..350b33204 100644 --- a/crates/herdr-gpui/src/control/client/hosts/text.rs +++ b/crates/herdr-gpui/src/control/client/hosts/text.rs @@ -2,10 +2,49 @@ //! without an app; `finish` makes the app's text safe to print. use crate::control::protocol::{ app::HostInfo, - hosts::{CheckInfo, CommentInfo, LoadInfo, Ports, PrInfo, Reply}, + hosts::{CheckInfo, CommentInfo, CopyState, LoadInfo, Ports, PrInfo, RemoteCopy, Reply}, }; use std::fmt::Write as _; +fn copy_state(state: CopyState) -> &'static str { + match state { + CopyState::Synced => "synced", + CopyState::Edited => "edited", + CopyState::Asking => "asking", + CopyState::Uploading => "uploading", + CopyState::Conflict => "conflict", + CopyState::Declined => "declined", + CopyState::Refetching => "refetching", + } +} + +/// One line per copy: its state, the host's file, and the copy here. +pub(super) fn remote_files(copies: &[RemoteCopy], omitted: usize) -> String { + if copies.is_empty() && omitted == 0 { + return "No remote file copies.".to_owned(); + } + let mut text = String::new(); + for copy in copies { + let auto = if copy.auto_upload { + " (auto-upload)" + } else { + "" + }; + let _ = writeln!( + text, + "{}{auto} {}:{} -> {}", + copy_state(copy.state), + copy.host, + copy.path, + copy.local + ); + } + if omitted > 0 { + let _ = writeln!(text, "… and {omitted} more copies that did not fit"); + } + text.trim_end().to_owned() +} + pub(super) fn pr(pr: &PrInfo) -> String { let mut text = format!("#{} {}\n", pr.number, pr.title); let _ = writeln!( diff --git a/crates/herdr-gpui/src/control/protocol/hosts.rs b/crates/herdr-gpui/src/control/protocol/hosts.rs index 68501202a..74a4cc1d8 100644 --- a/crates/herdr-gpui/src/control/protocol/hosts.rs +++ b/crates/herdr-gpui/src/control/protocol/hosts.rs @@ -1,5 +1,6 @@ //! Hosts and what the app gathers about them: pull requests, listening and -//! forwarded ports, plan usage, load, and Dependabot alerts. +//! forwarded ports, plan usage, load, Dependabot alerts, and the copies of +//! their files that link clicks fetched. use serde::{Deserialize, Serialize}; /// How long a pull request's comments may take to load. @@ -53,6 +54,9 @@ pub(crate) enum HostOp { CloudSetup { id: String, }, + /// The copies of SSH hosts' files that link clicks fetched into the + /// window, and where each stands against its host. + RemoteFiles, } impl HostOp { @@ -145,6 +149,40 @@ pub(crate) struct LoadInfo { pub disk_total: Option, } +/// Where a fetched copy stands against its host's file. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum CopyState { + /// The copy holds the bytes the host had when last fetched or pushed. + Synced, + /// A save waits for the next one: its upload failed. + Edited, + /// The app asks, or is about to ask, whether to upload a save. + Asking, + Uploading, + /// The host's file changed since it was synced, so the save was not + /// uploaded. + Conflict, + /// The user chose not to upload this save. + Declined, + /// The host's file is being fetched over the copy. + Refetching, +} + +/// A copy of an SSH host's file that a link click fetched. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct RemoteCopy { + /// The SSH target. + pub host: String, + /// The file's path as the host names it. + pub path: String, + /// The copy's path on this machine. + pub local: String, + pub state: CopyState, + /// Whether saves upload without asking. + pub auto_upload: bool, +} + /// A workspace's listening ports, and its host's forwards. Boxed in /// [`Reply`], which every response carries. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -192,6 +230,12 @@ pub(crate) enum Reply { #[serde(default, skip_serializing_if = "Option::is_none")] note: Option, }, + /// The copies that fit one answer; `omitted` more did not. + RemoteFiles { + copies: Vec, + #[serde(default)] + omitted: usize, + }, } /// One repository the Security section read. diff --git a/crates/herdr-gpui/src/control/protocol/hosts/tests.rs b/crates/herdr-gpui/src/control/protocol/hosts/tests.rs index 4777a046b..37ebee837 100644 --- a/crates/herdr-gpui/src/control/protocol/hosts/tests.rs +++ b/crates/herdr-gpui/src/control/protocol/hosts/tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::control::protocol::{Call, Caller, MAX_WAIT_SECONDS, Request, Response}; +mod remote_files; mod security; #[test] diff --git a/crates/herdr-gpui/src/control/protocol/hosts/tests/remote_files.rs b/crates/herdr-gpui/src/control/protocol/hosts/tests/remote_files.rs new file mode 100644 index 000000000..d8b564cab --- /dev/null +++ b/crates/herdr-gpui/src/control/protocol/hosts/tests/remote_files.rs @@ -0,0 +1,79 @@ +use super::*; + +fn copy(state: CopyState) -> RemoteCopy { + RemoteCopy { + host: "me@devbox".into(), + path: "/srv/notes.md".into(), + local: "/cache/1/notes.md".into(), + state, + auto_upload: false, + } +} + +#[test] +fn the_remote_files_request_has_a_stable_wire_form() { + let request = Request::Host(Call { + caller: Caller::default(), + op: HostOp::RemoteFiles, + }); + let text = serde_json::to_string(&request).unwrap(); + assert_eq!( + text, + r#"{"method":"host","caller":{"pane_id":null,"workspace_id":null,"daemon_socket":null},"op":{"op":"remote_files"}}"# + ); + assert_eq!(serde_json::from_str::(&text).unwrap(), request); +} + +#[test] +fn every_copy_state_has_its_own_name_on_the_wire() { + let states = [ + (CopyState::Synced, "synced"), + (CopyState::Edited, "edited"), + (CopyState::Asking, "asking"), + (CopyState::Uploading, "uploading"), + (CopyState::Conflict, "conflict"), + (CopyState::Declined, "declined"), + (CopyState::Refetching, "refetching"), + ]; + for (state, name) in states { + let json = serde_json::to_value(copy(state)).unwrap(); + assert_eq!(json["state"], name); + assert_eq!( + serde_json::from_value::(json).unwrap(), + copy(state) + ); + } + let unknown = serde_json::json!({ + "host": "h", "path": "p", "local": "l", "state": "lost", "auto_upload": false + }); + assert!(serde_json::from_value::(unknown).is_err()); +} + +#[test] +fn the_remote_files_reply_round_trips() { + let reply = Reply::RemoteFiles { + copies: vec![ + copy(CopyState::Conflict), + RemoteCopy { + auto_upload: true, + ..copy(CopyState::Synced) + }, + ], + omitted: 2, + }; + let response = Response::Host { reply }; + let text = serde_json::to_string(&response).unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), response); + // An answer from an app that counts no omitted copies still reads. + let bare = r#"{"status":"host","reply":{"kind":"remote_files","copies":[]}}"#; + let parsed: Response = serde_json::from_str(bare).unwrap(); + assert_eq!( + parsed, + Response::Host { + reply: Reply::RemoteFiles { + copies: Vec::new(), + omitted: 0 + } + } + ); +} diff --git a/crates/herdr-gpui/src/control/server/hosts.rs b/crates/herdr-gpui/src/control/server/hosts.rs index bcb87a710..6688bfd87 100644 --- a/crates/herdr-gpui/src/control/server/hosts.rs +++ b/crates/herdr-gpui/src/control/server/hosts.rs @@ -1,9 +1,10 @@ -//! `pr`, `host`, `cloud add`, `ports`, `usage`, `system`, and `security`: what the -//! window gathers about its hosts, read from the state it already keeps. -//! Nothing here starts a lookup, a scan, or a sample of its own; a request -//! answers from the latest one, and says why when there is none. +//! `pr`, `host`, `cloud add`, `ports`, `usage`, `system`, `remote-files`, and +//! `security`: what the window gathers about its hosts, read from the state it +//! already keeps. Nothing here starts a lookup, a scan, or a sample of its own; +//! a request answers from the latest one, and says why when there is none. mod ports; mod pr; +mod remote_files; mod security; #[cfg(test)] @@ -61,6 +62,9 @@ pub(super) fn serve( HostOp::CloudSetup { id } => { in_free_window(caller, cx, |view, _, cx| cloud_setup(view, &id, cx)) } + HostOp::RemoteFiles => in_window(caller, cx, |view, _, _| { + remote_files::list(view.remote_files.list(), remote_files::LIST_BUDGET) + }), }; incoming.respond(response); } diff --git a/crates/herdr-gpui/src/control/server/hosts/remote_files.rs b/crates/herdr-gpui/src/control/server/hosts/remote_files.rs new file mode 100644 index 000000000..b5066ffa5 --- /dev/null +++ b/crates/herdr-gpui/src/control/server/hosts/remote_files.rs @@ -0,0 +1,58 @@ +//! `remote-files`: the copies of SSH hosts' files that link clicks fetched +//! into the window, read from what the window already keeps. +use crate::{ + control::protocol::{ + MAX_MESSAGE, Response, + hosts::{CopyState, RemoteCopy, Reply}, + }, + remote_files::{Listed, Policy, SyncState}, +}; + +/// Room for copies in one answer, short of the socket's message bound. +pub(super) const LIST_BUDGET: usize = MAX_MESSAGE - 4096; + +impl From for CopyState { + fn from(state: SyncState) -> Self { + match state { + SyncState::Synced => Self::Synced, + SyncState::Edited { .. } => Self::Edited, + SyncState::Asking { .. } => Self::Asking, + SyncState::Uploading { .. } => Self::Uploading, + SyncState::Conflict { .. } => Self::Conflict, + SyncState::Declined { .. } => Self::Declined, + SyncState::Refetching { .. } => Self::Refetching, + } + } +} + +impl From> for RemoteCopy { + fn from(listed: Listed<'_>) -> Self { + Self { + host: listed.target.to_owned(), + path: listed.remote_path.to_owned(), + local: listed.local.to_string_lossy().into_owned(), + state: listed.state.into(), + auto_upload: listed.policy == Policy::Always, + } + } +} + +/// The copies, by host and path, that fit `budget` bytes of JSON, and how +/// many more did not. +pub(super) fn list<'a>(listed: impl IntoIterator>, budget: usize) -> Response { + let mut all: Vec = listed.into_iter().map(RemoteCopy::from).collect(); + all.sort_by(|a, b| (&a.host, &a.path).cmp(&(&b.host, &b.path))); + let total = all.len(); + let mut used = 0; + let copies: Vec = all + .into_iter() + .take_while(|copy| { + used += serde_json::to_vec(copy).map_or(budget, |json| json.len() + 1); + used <= budget + }) + .collect(); + let omitted = total - copies.len(); + Response::Host { + reply: Reply::RemoteFiles { copies, omitted }, + } +} diff --git a/crates/herdr-gpui/src/control/server/hosts/tests.rs b/crates/herdr-gpui/src/control/server/hosts/tests.rs index 44f7ed74c..96b999398 100644 --- a/crates/herdr-gpui/src/control/server/hosts/tests.rs +++ b/crates/herdr-gpui/src/control/server/hosts/tests.rs @@ -8,6 +8,7 @@ mod cloud_setup; mod info; mod ports; mod pr; +mod remote_files; mod security; mod security_open; mod strict; diff --git a/crates/herdr-gpui/src/control/server/hosts/tests/remote_files.rs b/crates/herdr-gpui/src/control/server/hosts/tests/remote_files.rs new file mode 100644 index 000000000..f86fac66d --- /dev/null +++ b/crates/herdr-gpui/src/control/server/hosts/tests/remote_files.rs @@ -0,0 +1,115 @@ +use super::super::remote_files::{LIST_BUDGET, list}; +use super::*; +use crate::{ + control::protocol::hosts::{CopyState, RemoteCopy}, + remote_files::{Listed, Policy, SyncState}, +}; +use std::path::Path; + +fn listed<'a>(target: &'a str, remote_path: &'a str, state: SyncState) -> Listed<'a> { + Listed { + target, + remote_path, + local: Path::new("/cache/1/notes.md"), + state, + policy: Policy::Ask, + } +} + +fn copies(response: Response) -> (Vec, usize) { + match response { + Response::Host { + reply: Reply::RemoteFiles { copies, omitted }, + } => (copies, omitted), + other => panic!("not a remote files reply: {other:?}"), + } +} + +#[test] +fn each_copy_state_is_listed_as_its_own_state() { + let h = [7; 32]; + let cases = [ + (SyncState::Synced, CopyState::Synced), + (SyncState::Edited { sha256: h }, CopyState::Edited), + (SyncState::Asking { sha256: h }, CopyState::Asking), + (SyncState::Uploading { sha256: h }, CopyState::Uploading), + (SyncState::Conflict { sha256: h }, CopyState::Conflict), + (SyncState::Declined { sha256: h }, CopyState::Declined), + (SyncState::Refetching { sha256: h }, CopyState::Refetching), + ]; + for (state, expected) in cases { + let (listed, omitted) = copies(list([listed("me@devbox", "/srv/a", state)], LIST_BUDGET)); + assert_eq!(omitted, 0); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].state, expected, "{state:?}"); + } +} + +#[test] +fn a_copy_lists_its_host_paths_and_upload_policy() { + let always = Listed { + policy: Policy::Always, + ..listed("me@devbox", "/srv/notes.md", SyncState::Synced) + }; + let ask = listed("me@devbox", "/srv/a.md", SyncState::Synced); + let (listed, _) = copies(list([always, ask], LIST_BUDGET)); + // By host and path, whatever order the window keeps them in. + assert_eq!( + listed, + vec![ + RemoteCopy { + host: "me@devbox".into(), + path: "/srv/a.md".into(), + local: Path::new("/cache/1/notes.md") + .to_string_lossy() + .into_owned(), + state: CopyState::Synced, + auto_upload: false, + }, + RemoteCopy { + host: "me@devbox".into(), + path: "/srv/notes.md".into(), + local: Path::new("/cache/1/notes.md") + .to_string_lossy() + .into_owned(), + state: CopyState::Synced, + auto_upload: true, + }, + ] + ); +} + +#[test] +fn copies_past_the_answer_s_bound_are_counted_not_sent() { + let paths: Vec = (0..10).map(|n| format!("/srv/{n}.md")).collect(); + let all = || { + paths + .iter() + .map(|path| listed("me@devbox", path, SyncState::Synced)) + }; + let one = serde_json::to_vec(&RemoteCopy::from(listed( + "me@devbox", + &paths[0], + SyncState::Synced, + ))) + .unwrap() + .len() + + 1; + let (sent, omitted) = copies(list(all(), one * 3)); + assert_eq!((sent.len(), omitted), (3, paths.len() - 3)); + assert_eq!(sent[0].path, "/srv/0.md"); + let (sent, omitted) = copies(list(all(), LIST_BUDGET)); + assert_eq!((sent.len(), omitted), (paths.len(), 0)); + let (sent, omitted) = copies(list(all(), 0)); + assert_eq!((sent.len(), omitted), (0, paths.len())); +} + +#[gpui::test] +fn a_window_without_copies_lists_none(cx: &mut TestAppContext) { + let (view, cx) = window(cx); + cx.update(|_, cx| { + let view = view.read(cx); + let (listed, omitted) = copies(list(view.remote_files.list(), LIST_BUDGET)); + assert_eq!((listed.len(), omitted), (0, 0)); + }); +} diff --git a/crates/herdr-gpui/src/error.rs b/crates/herdr-gpui/src/error.rs index 0275cfc68..500673296 100644 --- a/crates/herdr-gpui/src/error.rs +++ b/crates/herdr-gpui/src/error.rs @@ -709,6 +709,8 @@ pub enum Error { RelativeConfigRoot, #[error("Could not install the agent tools on the SSH host: {0}")] RemoteAgentTools(#[source] herdr_client::Error), + #[error(transparent)] + RemoteFiles(#[from] crate::remote_files::Error), #[error("{method}: {source}")] Request { method: herdr_client::Method, diff --git a/crates/herdr-gpui/src/main.rs b/crates/herdr-gpui/src/main.rs index 5653209b6..a3ce0c339 100644 --- a/crates/herdr-gpui/src/main.rs +++ b/crates/herdr-gpui/src/main.rs @@ -125,6 +125,7 @@ mod px0; mod railway; mod release_notes; mod remote_browser; +mod remote_files; mod reorder; mod repo_items; mod restart_manager; diff --git a/crates/herdr-gpui/src/menu.rs b/crates/herdr-gpui/src/menu.rs index 645598dae..e4dbcbcc6 100644 --- a/crates/herdr-gpui/src/menu.rs +++ b/crates/herdr-gpui/src/menu.rs @@ -20,6 +20,7 @@ mod page; mod page_title; mod pr; mod pr_actions; +mod remote_file_dialogs; mod sessions; mod settings; mod shortcut_editor; diff --git a/crates/herdr-gpui/src/menu/chrome.rs b/crates/herdr-gpui/src/menu/chrome.rs index 8301f46ae..4ab5197e7 100644 --- a/crates/herdr-gpui/src/menu/chrome.rs +++ b/crates/herdr-gpui/src/menu/chrome.rs @@ -55,6 +55,7 @@ impl HerdrWindow { { crate::agent_skill::AgentSkill::choose(crate::agent_skill::Choice::Declined, cx); } + self.decline_remote_upload_on_dismiss(cx); let return_focus = self .menu .application @@ -508,6 +509,8 @@ impl HerdrWindow { | Page::VersionMismatch | Page::OpenCheckout | Page::OpenLink + | Page::RemoteConflict + | Page::RemoteUpload ) || self.cloud_batch_open(), |panel| { panel @@ -676,6 +679,10 @@ impl HerdrWindow { panel = panel.child(body); } else if page == Page::OpenCheckout { panel = panel.child(self.render_open_checkout(cx)); + } else if page == Page::RemoteUpload { + panel = panel.child(self.render_remote_upload(cx)); + } else if page == Page::RemoteConflict { + panel = panel.child(self.render_remote_conflict(cx)); } else if page == Page::Install { panel = panel .child(div().p(px(8.)).child("Herdr must be installed")) diff --git a/crates/herdr-gpui/src/menu/chrome/keys.rs b/crates/herdr-gpui/src/menu/chrome/keys.rs index 9787a8843..a6730e1c0 100644 --- a/crates/herdr-gpui/src/menu/chrome/keys.rs +++ b/crates/herdr-gpui/src/menu/chrome/keys.rs @@ -346,6 +346,12 @@ impl HerdrWindow { "enter" if self.menu.page == Some(Page::OpenCheckout) => { self.confirm_open_checkout(window, cx); } + "enter" if self.menu.page == Some(Page::RemoteUpload) => { + self.confirm_remote_upload(crate::remote_files::Answer::Upload, window, cx); + } + "enter" if self.menu.page == Some(Page::RemoteConflict) => { + self.confirm_remote_conflict(crate::remote_files::Resolution::KeepBoth, window, cx); + } "enter" if self.menu.page == Some(Page::VersionMismatch) => { self.act_on_version_mismatch(window, cx); } diff --git a/crates/herdr-gpui/src/menu/page.rs b/crates/herdr-gpui/src/menu/page.rs index 15cb8f728..e07bbf87b 100644 --- a/crates/herdr-gpui/src/menu/page.rs +++ b/crates/herdr-gpui/src/menu/page.rs @@ -73,6 +73,11 @@ pub(crate) enum Page { PrMerge, /// The open pull request's checks and review conversation. PrReview, + /// A remote file's host changed since its copy was synced, so a save + /// was not uploaded. + RemoteConflict, + /// Asks before a save to a remote file's copy is uploaded. + RemoteUpload, RemoveDevice, /// Confirming a saved WSL distribution should be forgotten. RemoveWsl, diff --git a/crates/herdr-gpui/src/menu/page_title.rs b/crates/herdr-gpui/src/menu/page_title.rs index 389346a6b..f0a5e473a 100644 --- a/crates/herdr-gpui/src/menu/page_title.rs +++ b/crates/herdr-gpui/src/menu/page_title.rs @@ -74,6 +74,8 @@ impl Page { Self::PrComment => "Comment on pull request", Self::PrMerge => "Merge pull request", Self::PrReview => "Pull request", + Self::RemoteConflict => "Remote file changed", + Self::RemoteUpload => "Upload changes", Self::RemoveDevice => "Remove device", Self::RemoveWsl => "Remove WSL distribution", Self::RenameDevice => "Rename device", diff --git a/crates/herdr-gpui/src/menu/remote_file_dialogs.rs b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs new file mode 100644 index 000000000..67e6ff021 --- /dev/null +++ b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs @@ -0,0 +1,240 @@ +//! The questions a save to a remote file's copy asks: whether to upload it, +//! and what to do when the host's file changed since it was synced. They +//! open only when no other page holds the window, and wait until then. + +use super::Page; +use crate::{ + HerdrWindow, + fonts::StyledFont, + remote_files::{Answer, Prompt, PromptKind, Resolution, basename}, +}; +use gpui::{prelude::*, *}; + +impl HerdrWindow { + /// The question the open page asks, if it is one of these. + pub(crate) fn shown_remote_file_prompt(&self) -> Option { + let kind = match self.menu.page? { + Page::RemoteUpload => PromptKind::Upload, + Page::RemoteConflict => PromptKind::Conflict, + _ => return None, + }; + Some(Prompt { + key: self.menu.remote_file.clone()?, + kind, + }) + } + + /// Shows `prompt` unless another page holds the window. `true` when it + /// opened. + pub(crate) fn show_remote_file_prompt( + &mut self, + prompt: &Prompt, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.menu.page.is_some() || !self.open_menu(window, cx) { + return false; + } + self.menu.remote_file = Some(prompt.key.clone()); + self.menu.page = Some(match prompt.kind { + PromptKind::Upload => Page::RemoteUpload, + PromptKind::Conflict => Page::RemoteConflict, + }); + true + } + + /// Closes the open question without answering it: its copy no longer + /// waits for the answer. The copy is forgotten first, so closing is not + /// taken as a "Not now". + pub(crate) fn close_remote_file_prompt(&mut self, window: &mut Window, cx: &mut Context) { + self.menu.remote_file = None; + self.dismiss_menu(window, cx); + } + + /// Closing the upload question without an answer is a "Not now". + pub(in crate::menu) fn decline_remote_upload_on_dismiss(&mut self, cx: &mut Context) { + if self.menu.page == Some(Page::RemoteUpload) + && let Some(key) = self.menu.remote_file.take() + { + self.answer_remote_upload(key, Answer::NotNow, cx); + } + } + + pub(in crate::menu) fn confirm_remote_upload( + &mut self, + answer: Answer, + window: &mut Window, + cx: &mut Context, + ) { + let key = self.menu.remote_file.take(); + self.dismiss_menu(window, cx); + if let Some(key) = key { + self.answer_remote_upload(key, answer, cx); + } + } + + pub(in crate::menu) fn confirm_remote_conflict( + &mut self, + resolution: Resolution, + window: &mut Window, + cx: &mut Context, + ) { + let key = self.menu.remote_file.take(); + self.dismiss_menu(window, cx); + if let Some(key) = key { + self.resolve_remote_conflict(key, resolution, cx); + } + } + + pub(in crate::menu) fn render_remote_upload(&self, cx: &mut Context) -> Div { + let Some((target, path)) = &self.menu.remote_file else { + return div(); + }; + let Some(copy) = self.remote_files.copy(target, path) else { + return div(); + }; + let theme = &self.theme; + div() + .debug_selector(|| "remote-upload".into()) + .child(title(format!( + "Upload your changes to {}?", + basename(path, copy.shell) + ))) + .child( + div().px(px(8.)).child( + div() + .debug_selector(|| "remote-upload-path".into()) + .rounded(px(crate::config::corners::CONTROL)) + .bg(rgb(theme.active)) + .px(px(10.)) + .py(px(6.)) + .overflow_hidden() + .text_font(&self.config.terminal) + .child(div().child(target.clone())) + .child(div().child(path.clone())), + ), + ) + .child(muted( + theme.muted, + "Your saved copy replaces the file on the host.", + )) + .child( + buttons() + .child(self.upload_button("remote-upload-yes", "Upload", Answer::Upload, cx)) + .child(self.upload_button( + "remote-upload-always", + "Upload future saves without asking", + Answer::Always, + cx, + )) + .child( + self.remote_file_button("remote-upload-no", "Not now", false) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.dismiss_menu(window, cx); + })), + ), + ) + } + + pub(in crate::menu) fn render_remote_conflict(&self, cx: &mut Context) -> Div { + let Some((target, path)) = &self.menu.remote_file else { + return div(); + }; + let Some(copy) = self.remote_files.copy(target, path) else { + return div(); + }; + div() + .debug_selector(|| "remote-conflict".into()) + .child(title(format!( + "{} changed on {target}", + basename(path, copy.shell) + ))) + .child(muted( + self.theme.muted, + "It changed since it was fetched, so your save was not uploaded.", + )) + .child( + buttons() + .child(self.conflict_button( + "remote-conflict-keep-both", + "Keep both", + Resolution::KeepBoth, + cx, + )) + .child(self.conflict_button( + "remote-conflict-overwrite", + "Overwrite anyway", + Resolution::Overwrite, + cx, + )) + .child(self.conflict_button( + "remote-conflict-refetch", + "Re-fetch", + Resolution::Refetch, + cx, + )), + ) + } + + fn remote_file_button( + &self, + id: &'static str, + label: &'static str, + primary: bool, + ) -> Stateful
{ + let active = self.theme.active; + div() + .id(id) + .debug_selector(move || id.into()) + .p(px(8.)) + .rounded(px(crate::config::corners::CONTROL)) + .when(primary, |button| button.bg(rgb(active))) + .when(!primary, |button| button.hover(|s| s.bg(rgb(active)))) + .cursor_pointer() + .child(label) + } + + fn upload_button( + &self, + id: &'static str, + label: &'static str, + answer: Answer, + cx: &mut Context, + ) -> Stateful
{ + self.remote_file_button(id, label, answer == Answer::Upload) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.confirm_remote_upload(answer, window, cx); + })) + } + + fn conflict_button( + &self, + id: &'static str, + label: &'static str, + resolution: Resolution, + cx: &mut Context, + ) -> Stateful
{ + self.remote_file_button(id, label, resolution == Resolution::KeepBoth) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.confirm_remote_conflict(resolution, window, cx); + })) + } +} + +fn title(text: String) -> Div { + div() + .p(px(8.)) + .font_weight(FontWeight::SEMIBOLD) + .child(text) +} + +fn muted(color: u32, text: &'static str) -> Div { + div().p(px(8.)).text_color(rgb(color)).child(text) +} + +fn buttons() -> Div { + div().flex().flex_wrap().gap(px(8.)).p(px(8.)) +} diff --git a/crates/herdr-gpui/src/menu/state.rs b/crates/herdr-gpui/src/menu/state.rs index e89ec0222..bcd606809 100644 --- a/crates/herdr-gpui/src/menu/state.rs +++ b/crates/herdr-gpui/src/menu/state.rs @@ -68,6 +68,8 @@ pub(crate) struct MenuState { pub(super) open_checkout: Option, /// The address the open-link question is about. pub(super) open_link: Option, + /// The copy the remote file questions are about. + pub(super) remote_file: Option, /// The batch the destroy-cloud-devices question is about. Dropping it /// unanswered cancels the batch. /// The batch the create-cloud-machines question is about. @@ -326,6 +328,7 @@ impl MenuState { session_edit: None, open_checkout: None, open_link: None, + remote_file: None, #[cfg(feature = "cloud")] cloud_create: None, #[cfg(feature = "cloud")] @@ -402,6 +405,7 @@ impl MenuState { self.session_edit = None; self.open_checkout = None; self.open_link = None; + self.remote_file = None; #[cfg(feature = "cloud")] { self.cloud_create = None; diff --git a/crates/herdr-gpui/src/remote_files.rs b/crates/herdr-gpui/src/remote_files.rs new file mode 100644 index 000000000..2d4c3296e --- /dev/null +++ b/crates/herdr-gpui/src/remote_files.rs @@ -0,0 +1,782 @@ +//! Files on SSH hosts that a link-modifier click opens here. The click +//! fetches the host's file over the endpoint's SSH trust into a private +//! cache folder, then opens the copy with the system's default application +//! under the same rule as a local path: a document itself, and anything +//! else the folder holding it. The window records each copy it made, keyed +//! by host and path, and the copies are removed when the app quits. +//! +//! While the window holds copies it watches them for saves (`watch`), and +//! sends a save back to the host under the rules in `sync`: it asks first, +//! unless the user chose to upload that copy's saves without asking, and a +//! host's file that changed since it was synced is never replaced without +//! the user's say. +//! +//! Every fetch, push, and disk access runs on the background executor; the +//! UI thread only starts them and applies their results. + +mod cache; +mod push; +mod sync; +mod uploads; +mod watch; + +use crate::window::{Flash, HerdrWindow, RemotePath}; +use gpui::{App, Context, Task}; +use herdr_client::{Expect, Fetched, MAX_REMOTE_FILE_BYTES, RemoteFileAccess, RemoteShell}; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Instant, +}; + +pub(crate) use cache::{Cache, basename}; +pub(crate) use sync::{Answer, Policy, Resolution, SyncState}; + +/// Bytes in the megabyte that [`Error::TooLarge`] reports. +const MB: u64 = 1024 * 1024; + +/// Why a remote file was not fetched, opened, or uploaded. +#[derive(Debug, thiserror::Error)] +pub enum Error { + // keep-sorted start + #[error("Could not keep the file's access; nothing was changed.")] + Access, + #[error("Could not use the remote file cache at {}: {source}", path.display())] + Cache { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Could not fetch the file: {0}")] + Fetch(#[source] herdr_client::Error), + #[error("Could not keep both: every name up to {last} is taken on the host")] + NamesTaken { last: String }, + #[error("No state folder for the remote file cache: set XDG_STATE_HOME, HOME, or LOCALAPPDATA")] + NoStateDir, + #[error("Could not upload the file: {0}")] + Push(#[source] herdr_client::Error), + #[error( + "Other users can replace files in that folder on the host, so it was not written in \ + place; nothing was changed." + )] + SharedFolder, + #[error("Not uploaded: the copy is larger than {} MB", .limit.div_ceil(MB))] + TooLarge { limit: u64 }, + #[error("Not uploaded: the copy kept changing while it was read; save it again")] + Unsettled, + // keep-sorted end +} + +impl Error { + /// Whether the host refused a push because its file was not what the + /// push expected. + fn is_conflict(&self) -> bool { + matches!( + self, + Self::Push(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Conflict + )) + ) + } +} + +pub(crate) type Result = std::result::Result; + +/// A copy's SSH target and host path. +pub(crate) type Key = (String, String); + +/// Streams the host's file `RemotePath` names into the writer. Blocking. +pub(crate) type Fetcher = Arc< + dyn Fn(&RemotePath, &mut (dyn Write + Send)) -> herdr_client::Result + Send + Sync, +>; + +/// Replaces the host's file `RemotePath` names with the reader's bytes, of +/// which there are exactly the given count, while it meets the expectation, +/// giving a new file the access named. Blocking. +pub(crate) type Pusher = Arc< + dyn Fn( + &RemotePath, + Expect, + RemoteFileAccess<'_>, + u64, + &mut (dyn Read + Send), + ) -> herdr_client::Result<()> + + Send + + Sync, +>; + +/// The cache copies are written to. Blocking. +pub(crate) type CacheSource = Arc Result> + Send + Sync>; + +/// The I/O fetches and pushes do: SSH and the process's cache in the app, +/// and fakes in tests. +#[derive(Clone)] +pub(crate) struct Io { + cache: CacheSource, + fetch: Fetcher, + push: Pusher, + /// Runs at each step of putting a fetched copy in place, with the + /// copy's path, so a test can save to it then. + #[cfg(test)] + step: StepHook, +} + +/// What a test runs at each [`Step`], with the copy's path. +#[cfg(test)] +type StepHook = Arc; + +/// A step of putting a fetched copy in place that a test can save at. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Step { + /// The copy was checked for saves since it was synced. + Checked, + /// The copy was moved aside, leaving its place empty. + MovedAside, + /// The host's bytes are at the copy's place. + Placed, +} + +impl Default for Io { + fn default() -> Self { + Self { + #[cfg(test)] + step: Arc::new(|_, _| {}), + cache: Arc::new(cache::shared), + // The scripts' idle timeout ends a transfer that stalls. + fetch: Arc::new(|remote, out| { + herdr_client::fetch_remote_file( + remote.remote_file(), + out, + MAX_REMOTE_FILE_BYTES, + &AtomicBool::new(false), + ) + }), + push: Arc::new(|remote, expect, access, len, input| { + herdr_client::push_remote_file( + remote.remote_file(), + expect, + access, + len, + input, + &AtomicBool::new(false), + ) + }), + } + } +} + +impl std::fmt::Debug for Io { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Io").finish_non_exhaustive() + } +} + +/// A local copy of a host's file, and where it stands against the host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CachedCopy { + pub(crate) local: PathBuf, + pub(crate) policy: Policy, + pub(crate) shell: RemoteShell, + pub(crate) state: SyncState, + /// The copy's metadata when it last matched the host. + pub(crate) synced_meta: watch::Meta, + /// The host's bytes as last fetched or pushed. + pub(crate) synced_sha256: sync::Sha256, + watch: watch::Watch, +} + +/// One copy as `RemoteFiles::list` shows it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Listed<'a> { + pub(crate) target: &'a str, + pub(crate) remote_path: &'a str, + pub(crate) local: &'a Path, + pub(crate) state: SyncState, + pub(crate) policy: Policy, +} + +/// Which question a copy waits to ask. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptKind { + Upload, + Conflict, +} + +/// A question waiting for the window to have no page open. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Prompt { + pub(crate) key: Key, + pub(crate) kind: PromptKind, +} + +/// The window whose copies a folder of the cache holds. Each window keeps +/// its own copies, so a fetch in one never writes over another's edits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Owner(u64); + +impl Default for Owner { + /// An owner no other window of this process has. + fn default() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + Self(NEXT.fetch_add(1, Ordering::Relaxed)) + } +} + +/// The copies one window fetched, by SSH target and host path. +#[derive(Debug, Default)] +pub(crate) struct RemoteFiles { + /// The cache the copies are in, once one was written. + cache: Option>, + copies: HashMap, + /// The copies a click's fetch or a conflict's refetch is writing, which + /// nothing else touches until it is done. + fetching: HashSet, + io: Io, + /// When the last watch run started. + last_watch: Option, + /// The folder of the cache this window's copies are in. + owner: Owner, + prompts: VecDeque, + /// Whether a watch run is underway. + watching: bool, +} + +impl RemoteFiles { + /// The copy last fetched of `path` on `target`. + pub(crate) fn copy(&self, target: &str, path: &str) -> Option<&CachedCopy> { + self.copies.get(&(target.to_owned(), path.to_owned())) + } + + /// Every copy, with where it stands against its host. + pub(crate) fn list(&self) -> impl Iterator> { + self.copies.iter().map(|((target, path), copy)| Listed { + target, + remote_path: path, + local: ©.local, + state: copy.state, + policy: copy.policy, + }) + } + + /// Whether a click on `remote` fetches it again rather than reopening + /// the copy already here. A synced copy is fetched again, since the + /// host's bytes may be newer; one holding edits not yet on the host is + /// reopened, keeping them. + pub(crate) fn fetch_again(&self, remote: &RemotePath) -> bool { + self.copy(&remote.target, &remote.path) + .is_none_or(|copy| copy.state == SyncState::Synced) + } + + /// Keeps a fetched copy, synced, with the upload policy an earlier copy + /// of the same file had. + fn record(&mut self, done: Done) { + self.cache = Some(done.cache); + let mut copy = done.copy; + if let Some(earlier) = self.copies.get(&done.key) { + copy.policy = earlier.policy; + } + self.copies.insert(done.key, copy); + } + + /// Steps `key`'s copy through `event`, returning what to do next. + fn handle(&mut self, key: &Key, event: sync::Event) -> sync::Action { + let Some(copy) = self.copies.get_mut(key) else { + return sync::Action::Nothing; + }; + let (state, policy, action) = sync::step(copy.state, copy.policy, event); + copy.state = state; + copy.policy = policy; + action + } + + /// Records that the host now holds what `pushed` sent. + fn synced(&mut self, key: &Key, pushed: push::Pushed) { + if let Some(copy) = self.copies.get_mut(key) { + copy.synced_sha256 = pushed.sha256; + copy.synced_meta = pushed.meta; + copy.watch = watch::Watch::default(); + } + } + + /// Queues `prompt` unless it already waits. + fn enqueue(&mut self, prompt: Prompt) { + if !self.prompts.contains(&prompt) { + self.prompts.push_back(prompt); + } + } + + /// Whether `prompt`'s copy is still in the state its question asks about. + fn applies(&self, prompt: &Prompt) -> bool { + self.copies.get(&prompt.key).is_some_and(|copy| { + matches!( + (prompt.kind, copy.state), + (PromptKind::Upload, SyncState::Asking { .. }) + | (PromptKind::Conflict, SyncState::Conflict { .. }) + ) + }) + } + + /// The first waiting question that still applies, dropping the ones its + /// copy moved past. + fn next_prompt(&mut self) -> Option { + while let Some(prompt) = self.prompts.front() { + if self.applies(prompt) { + return Some(prompt.clone()); + } + self.prompts.pop_front(); + } + None + } + + /// Queues again the conflict question of `key`'s copy when the copy is + /// in conflict and the question is neither waiting nor `shown`, as after + /// the user closed it without an answer. + fn reask_conflict(&mut self, key: Key, shown: Option<&Prompt>) { + let prompt = Prompt { + key, + kind: PromptKind::Conflict, + }; + if self.applies(&prompt) && shown != Some(&prompt) { + self.enqueue(prompt); + } + } + + /// Queues again the upload question of a copy still asking whose + /// question is neither waiting nor `shown`: another page replaced it + /// before the user answered. + fn requeue_unanswered(&mut self, shown: Option<&Prompt>) { + let orphans: Vec = self + .copies + .iter() + .filter(|(_, copy)| matches!(copy.state, SyncState::Asking { .. })) + .map(|(key, _)| key.clone()) + .filter(|key| { + let prompt = Prompt { + key: key.clone(), + kind: PromptKind::Upload, + }; + shown != Some(&prompt) && !self.prompts.contains(&prompt) + }) + .collect(); + for key in orphans { + self.prompts.push_back(Prompt { + key, + kind: PromptKind::Upload, + }); + } + } + + /// The copies a watch run looks at: all but those a transfer is + /// rewriting or reading, or a fetch is writing. + fn probes(&self) -> Vec { + self.copies + .iter() + .filter(|(key, copy)| !transferring(copy.state) && !self.fetching.contains(*key)) + .map(|(key, copy)| watch::Probe { + key: key.clone(), + local: copy.local.clone(), + synced: copy.synced_meta, + watch: copy.watch, + }) + .collect() + } + + /// Applies a watch run's findings, returning each settled save's next + /// action. + fn apply_scan(&mut self, seen: Vec) -> Vec<(Key, sync::Action)> { + let mut actions = Vec::new(); + for seen in seen { + // A fetch writing the copy now owns it until it is done. + if self.fetching.contains(&seen.key) { + continue; + } + let Some(copy) = self.copies.get_mut(&seen.key) else { + continue; + }; + // Synced again or mid-transfer since the run began: its findings + // describe a copy that is no longer there. + if copy.synced_meta != seen.synced || transferring(copy.state) { + continue; + } + let event = match seen.change { + watch::Change::Skipped => None, + watch::Change::Quiet => { + copy.watch.pending = None; + None + } + watch::Change::Pending(meta) => { + copy.watch.pending = Some(meta); + None + } + watch::Change::Settled { meta, sha256 } => Some(settle(copy, meta, sha256)), + }; + if let Some(event) = event { + actions.push((seen.key.clone(), self.handle(&seen.key, event))); + } + } + actions + } + + /// Forgets the copies and removes the cache folder holding them, which + /// every window of the process shares, off the UI thread. + pub(crate) fn remove_copies(&mut self, cx: &App) -> Task<()> { + self.copies.clear(); + self.prompts.clear(); + let cache = self.cache.take(); + cx.background_executor().spawn(async move { + if let Some(cache) = cache + && let Err(error) = cache.remove() + { + tracing::warn!(%error, "Remote file copies not removed"); + } + }) + } + + /// Lets the next poll start a watch run at once. + #[cfg(test)] + pub(crate) fn rewind_watch(&mut self) { + self.last_watch = None; + } +} + +/// Whether a push or fetch is reading or rewriting the copy. +fn transferring(state: SyncState) -> bool { + matches!( + state, + SyncState::Uploading { .. } | SyncState::Refetching { .. } + ) +} + +/// Records a settled save of `copy` and names it: bytes the host already +/// has are only a touch, which moves the synced metadata along. +fn settle(copy: &mut CachedCopy, meta: watch::Meta, sha256: sync::Sha256) -> sync::Event { + copy.watch.pending = None; + if sha256 == copy.synced_sha256 { + copy.synced_meta = meta; + copy.watch.handled = None; + sync::Event::Reverted + } else { + copy.watch.handled = Some(meta); + sync::Event::Saved(sha256) + } +} + +/// A finished fetch: the copy written, and what a click opens for it. +struct Done { + key: Key, + copy: CachedCopy, + cache: Arc, + /// The copy, or the folder holding it when it is not a document. + opened: Option, +} + +/// What a fetch did with the copy. +enum Fetch { + Written(Box), + /// The copy was saved since it was synced, so the host's bytes were not + /// written over it. + KeptEdits, +} + +/// Fetches `remote` into `owner`'s copy of it in the cache: written beside +/// it first and put in its place once whole, so a failed fetch leaves any +/// older copy as it was. With `keep_edits_since`, the copy's metadata when +/// it was last synced, a copy saved since then is left as it is, however +/// late the save comes (`swap_in`); without it the copy is replaced. +/// Blocking. +fn fetch_copy( + io: &Io, + owner: Owner, + remote: &RemotePath, + keep_edits_since: Option, +) -> Result { + let cache = (io.cache)()?; + let local = cache.copy_path(owner, &remote.target, &remote.path, remote.shell); + if let Some(folder) = local.parent() { + cache::create_private_dirs(folder)?; + } + let part = cache::part_path(&local); + let fetched = write_part(io, remote, &part).inspect_err(|_| remove_part(&part))?; + // Checked as late as possible: a save made while the fetch ran is kept. + if let Some(synced) = keep_edits_since + && edited_since(&local, synced).inspect_err(|_| remove_part(&part))? + { + remove_part(&part); + return Ok(Fetch::KeptEdits); + } + #[cfg(test)] + (io.step)(Step::Checked, &local); + // Read from the whole part, whose modification time and length the + // rename keeps: a save made as the copy lands then differs from it. + let synced_meta = std::fs::metadata(&part) + .and_then(|metadata| watch::Meta::of(&metadata)) + .map_err(|source| Error::Cache { + path: part.clone(), + source, + }) + .inspect_err(|_| remove_part(&part))?; + let placed = match keep_edits_since { + Some(synced) => swap_in(io, &part, &local, synced), + None => replace(io, &part, &local), + }; + if placed.inspect_err(|_| remove_part(&part))? == Placed::KeptEdits { + return Ok(Fetch::KeptEdits); + } + let opened = crate::window::opened(&local).and_then(|path| url::Url::from_file_path(path).ok()); + Ok(Fetch::Written(Box::new(Done { + key: (remote.target.clone(), remote.path.clone()), + copy: CachedCopy { + local, + policy: Policy::default(), + shell: remote.shell, + state: SyncState::Synced, + synced_meta, + synced_sha256: fetched.sha256, + watch: watch::Watch::default(), + }, + cache, + opened, + }))) +} + +/// Whether the copy at `local` differs from its metadata `synced`. A copy +/// that is gone holds no edits to keep. +fn edited_since(local: &Path, synced: watch::Meta) -> Result { + match std::fs::metadata(local).and_then(|metadata| watch::Meta::of(&metadata)) { + Ok(meta) => Ok(meta != synced), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(Error::Cache { + path: local.to_owned(), + source, + }), + } +} + +/// Whether a fetch put the host's bytes in place. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Placed { + Written, + /// A save was found at the copy's place, and stays there. + KeptEdits, +} + +/// Renames the whole `part` over the copy at `local`, whatever it holds. +fn replace(io: &Io, part: &Path, local: &Path) -> Result { + #[cfg(not(test))] + let _ = io; + std::fs::rename(part, local).map_err(|source| cache_error(local, source))?; + #[cfg(test)] + (io.step)(Step::Placed, local); + Ok(Placed::Written) +} + +/// Puts the whole `part` at `local` without ever replacing a save there. +/// The copy moves aside first, the part is linked into the place it left, +/// which fails rather than replace a save that took the place meanwhile, +/// and the copy moved aside then comes back over the host's bytes unless +/// its metadata proves it is still the one synced at `synced`. An editor +/// holding the copy open without letting it be renamed fails the fetch. +fn swap_in(io: &Io, part: &Path, local: &Path, synced: watch::Meta) -> Result { + #[cfg(not(test))] + let _ = io; + let kept = cache::kept_path(local); + let kept = match std::fs::rename(local, &kept) { + Ok(()) => Some(kept), + // No copy, so nothing to keep. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(source) => return Err(cache_error(local, source)), + }; + #[cfg(test)] + (io.step)(Step::MovedAside, local); + match place(part, local) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + // A save took the place in the gap: it stays, and the host's + // bytes are dropped. + remove_part(part); + if let Some(kept) = &kept { + discard_if_synced(kept, synced); + } + return Ok(Placed::KeptEdits); + } + Err(source) => { + if let Some(kept) = &kept { + put_back(kept, local, synced); + } + return Err(cache_error(local, source)); + } + } + #[cfg(test)] + (io.step)(Step::Placed, local); + remove_part(part); + let Some(kept) = kept else { + return Ok(Placed::Written); + }; + match meta_of(&kept) { + Ok(meta) if meta == synced => { + remove_kept(&kept); + return Ok(Placed::Written); + } + Ok(_) => {} + Err(error) => { + tracing::warn!(%error, kept = %kept.display(), "Remote file copy moved aside not checked; keeping it"); + } + } + // Saved after the check, or not proven unsaved: the save wins. + std::fs::rename(&kept, local).map_err(|source| { + tracing::warn!(kept = %kept.display(), "A save to a remote file copy was left beside it"); + cache_error(local, source) + })?; + Ok(Placed::KeptEdits) +} + +/// Links `part` in at `local`, failing with `AlreadyExists` rather than +/// replace a file there. A filesystem without hard links gets a rename +/// instead, which replaces a save made in the moment before it; the copy +/// moved aside is still checked afterwards. +fn place(part: &Path, local: &Path) -> std::io::Result<()> { + match std::fs::hard_link(part, local) { + Err(error) if error.kind() == std::io::ErrorKind::Unsupported => { + tracing::debug!(%error, copy = %local.display(), "No hard links in the remote file cache; renaming the copy into place"); + std::fs::rename(part, local) + } + linked => linked, + } +} + +/// Puts the copy moved aside to `kept` back at `local` after a failed +/// fetch. A save that took the place meanwhile stays there. +fn put_back(kept: &Path, local: &Path, synced: watch::Meta) { + match place(kept, local) { + Ok(()) => remove_kept(kept), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + discard_if_synced(kept, synced); + } + Err(error) => { + tracing::warn!(%error, kept = %kept.display(), "Remote file copy not put back after a failed fetch"); + } + } +} + +/// Removes the copy moved aside to `kept` when its metadata proves it the +/// one synced at `synced`; one that may hold a save stays beside the copy. +fn discard_if_synced(kept: &Path, synced: watch::Meta) { + match meta_of(kept) { + Ok(meta) if meta == synced => remove_kept(kept), + Ok(_) => { + tracing::warn!(kept = %kept.display(), "A save to a remote file copy was left beside it") + } + Err(error) => { + tracing::warn!(%error, kept = %kept.display(), "Remote file copy moved aside not checked; keeping it"); + } + } +} + +fn meta_of(path: &Path) -> std::io::Result { + std::fs::metadata(path).and_then(|metadata| watch::Meta::of(&metadata)) +} + +fn cache_error(path: &Path, source: std::io::Error) -> Error { + Error::Cache { + path: path.to_owned(), + source, + } +} + +fn remove_kept(kept: &Path) { + if let Err(error) = std::fs::remove_file(kept) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!(%error, kept = %kept.display(), "Remote file copy moved aside not removed"); + } +} + +fn write_part(io: &Io, remote: &RemotePath, part: &Path) -> Result { + let mut file = cache::create_private_file(part)?; + (io.fetch)(remote, &mut file).map_err(Error::Fetch) +} + +fn remove_part(part: &Path) { + if let Err(error) = std::fs::remove_file(part) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!(%error, part = %part.display(), "Partial remote file copy not removed"); + } +} + +impl HerdrWindow { + /// Fetches the host's file `remote` names into the cache and opens the + /// copy with the system's default application, or says why it could not. + /// A copy in conflict is reopened, and its question asked again. + pub(crate) fn open_remote_file(&mut self, remote: RemotePath, cx: &mut Context) { + if !self.remote_files.fetch_again(&remote) { + let shown = self.shown_remote_file_prompt(); + let key = (remote.target.clone(), remote.path.clone()); + self.remote_files.reask_conflict(key, shown.as_ref()); + if let Some(copy) = self.remote_files.copy(&remote.target, &remote.path) { + self.open_in_system_app(copy.local.clone(), cx); + } + return; + } + let key = (remote.target.clone(), remote.path.clone()); + // The fetch already running opens the copy when it is done. + if !self.remote_files.fetching.insert(key.clone()) { + return; + } + let name = basename(&remote.path, remote.shell); + self.show_flash( + Flash::success(format!("Fetching {name} from {}…", remote.target)), + cx, + ); + let io = self.remote_files.io.clone(); + let owner = self.remote_files.owner; + let synced = self + .remote_files + .copy(&remote.target, &remote.path) + .map(|copy| copy.synced_meta); + let fetch = cx + .background_executor() + .spawn(async move { fetch_copy(&io, owner, &remote, synced) }); + cx.spawn(async move |this, cx| { + let fetched = fetch.await; + // The window may have closed meanwhile; nothing opens then. + let _ = this.update(cx, |this, cx| this.remote_file_fetched(&key, fetched, cx)); + }) + .detach(); + } + + fn remote_file_fetched(&mut self, key: &Key, fetched: Result, cx: &mut Context) { + self.remote_files.fetching.remove(key); + match fetched { + // Saved while the fetch ran: the copy with the save opens, and + // the watch takes the save from there. + Ok(Fetch::KeptEdits) => { + if let Some(copy) = self.remote_files.copies.get(key) { + self.open_in_system_app(copy.local.clone(), cx); + } + } + Ok(Fetch::Written(done)) => { + let opened = done.opened.clone(); + self.remote_files.record(*done); + match opened { + Some(url) => cx.open_url(url.as_str()), + None => tracing::warn!("Fetched remote file copy could not be opened"), + } + } + Err(error) => { + tracing::warn!(%error, "Remote file not fetched"); + self.show_flash(Flash::warning(crate::Error::from(error).to_string()), cx); + } + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +pub(crate) mod tests; diff --git a/crates/herdr-gpui/src/remote_files/cache.rs b/crates/herdr-gpui/src/remote_files/cache.rs new file mode 100644 index 000000000..7bc3ce12a --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/cache.rs @@ -0,0 +1,335 @@ +//! The folder one run of this client keeps its copies of remote files in: +//! `remote-files/` under the client's state directory, private to +//! the user. The run holds a lock on `/.lock` while it lives, so the +//! first use in a later run removes the folders of runs that crashed or quit +//! without removing their own, and leaves a running instance's alone. + +use super::{Error, Owner, Result}; +use herdr_client::RemoteShell; +use sha2::{Digest, Sha256}; +use std::{ + borrow::Cow, + fs::{DirBuilder, File, OpenOptions}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, PoisonError, + atomic::{AtomicU64, Ordering}, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +/// The lock file each launch folder holds. +const LOCK: &str = ".lock"; + +/// The lock file's name until it is held; the sweep never opens it. +const LOCK_PENDING: &str = ".lock.new"; + +/// What a copy is called when the host's name for it is not a file name. +const FALLBACK_NAME: &str = "file"; + +/// The characters a Windows file name cannot hold, besides control characters. +const WINDOWS_REFUSED: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +/// Hex digits of SHA-256 naming a host's or a path's folder. +const HASH_DIGITS: usize = 16; + +/// The process's one cache, created at its first use. +static SHARED: Mutex>> = Mutex::new(None); + +/// One run's launch folder, locked while this value lives. +#[derive(Debug)] +pub(crate) struct Cache { + launch: PathBuf, + /// Released before the folder is removed, which Windows refuses while + /// a file in it is open. + lock: Mutex>, +} + +impl Cache { + /// Creates a new launch folder under `root`, holds its lock, and removes + /// the launch folders under `root` that no running process holds. + pub(crate) fn create(root: &Path) -> Result { + create_private_dirs(root)?; + let launch = root.join(launch_name()); + create_private_dirs(&launch)?; + // The lock is taken under a name the sweep ignores and only then + // renamed to `.lock`: another launch's sweep that found an unheld + // `.lock` in the moment between creating and locking it would take + // the lock and remove this live folder. The lock belongs to the open + // file, so it survives the rename. + let pending = launch.join(LOCK_PENDING); + let lock = private_options() + .create_new(true) + .open(&pending) + .map_err(|source| Error::Cache { + path: pending.clone(), + source, + })?; + lock.try_lock().map_err(|error| Error::Cache { + path: pending.clone(), + source: error.into(), + })?; + let lock_path = launch.join(LOCK); + std::fs::rename(&pending, &lock_path).map_err(|source| Error::Cache { + path: lock_path, + source, + })?; + sweep(root, &launch); + Ok(Self { + launch, + lock: Mutex::new(Some(lock)), + }) + } + + /// Where `owner`'s copy of `path` on `target` is kept: a folder per + /// window, per host, and per path, so copies never collide, holding the + /// file under the host's own name for it, so an application shows its + /// real name and type, split from its folders at `shell`'s separators. + pub(crate) fn copy_path( + &self, + owner: Owner, + target: &str, + path: &str, + shell: RemoteShell, + ) -> PathBuf { + self.launch + .join(format!("w{}", owner.0)) + .join(short_hash(target)) + .join(short_hash(path)) + .join(local_name(basename(path, shell), cfg!(windows)).as_ref()) + } + + #[cfg(test)] + pub(crate) fn launch(&self) -> &Path { + &self.launch + } + + /// Releases the lock and removes the launch folder with every copy in it. + pub(crate) fn remove(&self) -> Result<()> { + drop( + self.lock + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(), + ); + match std::fs::remove_dir_all(&self.launch) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(Error::Cache { + path: self.launch.clone(), + source: error, + }), + _ => Ok(()), + } + } +} + +/// The process's cache, created under the state directory at first use. +/// Blocking: call only on a background worker. +pub(crate) fn shared() -> Result> { + // A poisoned lock still holds either no cache or a whole one. + let mut shared = SHARED.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(cache) = shared.as_ref() { + return Ok(cache.clone()); + } + let root = crate::preferences::state_dir() + .ok_or(Error::NoStateDir)? + .join("remote-files"); + let cache = Arc::new(Cache::create(&root)?); + *shared = Some(cache.clone()); + Ok(cache) +} + +/// The last segment of a host's path, split at the separators of the host's +/// `shell`, or `file` when that is no plain file name. A backslash is a +/// plain name character on a POSIX host. +pub(crate) fn basename(path: &str, shell: RemoteShell) -> &str { + let separators: &[char] = match shell { + RemoteShell::Posix => &['/'], + RemoteShell::PowerShell => &['/', '\\'], + }; + let name = path.rsplit(separators).next().unwrap_or_default(); + if matches!(name, "" | "." | "..") || name.chars().any(char::is_control) { + FALLBACK_NAME + } else { + name + } +} + +/// The name a copy of the host's file `name` is stored under. A Windows +/// client cannot store every name a host can, so there a character Windows +/// refuses becomes `_`, as does a trailing dot or space, and a device name +/// such as `CON` or `com1.txt` gains a leading `_`. Only the copy is +/// renamed: the host's path stays the copy's key, so uploads still go there. +pub(super) fn local_name(name: &str, windows: bool) -> Cow<'_, str> { + if !windows { + return Cow::Borrowed(name); + } + let mut local: String = name + .chars() + .map(|c| { + if c.is_control() || WINDOWS_REFUSED.contains(&c) { + '_' + } else { + c + } + }) + .collect(); + if local.ends_with(['.', ' ']) { + local.pop(); + local.push('_'); + } + if windows_device(&local) { + local.insert(0, '_'); + } + if local == name { + Cow::Borrowed(name) + } else { + Cow::Owned(local) + } +} + +/// Whether Windows reads `name` as a device, whatever its extension. +fn windows_device(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or_default(); + let numbered = |prefix: &str| { + stem.get(..3) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix)) + && matches!(stem.as_bytes().get(3..), Some([b'1'..=b'9'])) + }; + ["CON", "PRN", "AUX", "NUL"] + .iter() + .any(|device| stem.eq_ignore_ascii_case(device)) + || numbered("COM") + || numbered("LPT") +} + +/// Where a fetch writes the copy at `local` before renaming it over it: +/// beside it, under a name no other fetch of this process has, so two +/// fetches of one copy never write or remove each other's bytes. +pub(super) fn part_path(local: &Path) -> PathBuf { + beside(local, "part") +} + +/// Where a fetch moves the copy at `local` aside while it puts the host's +/// bytes in its place: beside it, under a name no other fetch of this +/// process has. +pub(super) fn kept_path(local: &Path) -> PathBuf { + beside(local, "kept") +} + +/// Where a push stages the copy at `local` before sending it: beside it, +/// under a name no other push of this process has. +pub(super) fn staged_path(local: &Path) -> PathBuf { + beside(local, "push") +} + +/// A hidden name in `local`'s folder made of a serial number unique in this +/// process and `extension`. It leaves out `local`'s own name, which may +/// already be as long as a file name can be. The folder holds this one copy +/// alone, so skipping a name equal to the copy's own, which a host's file +/// named like `.0.part` would have, is all that keeps them apart; a failed +/// write or its cleanup then never touches the copy. +fn beside(local: &Path, extension: &str) -> PathBuf { + static SERIAL: AtomicU64 = AtomicU64::new(0); + beside_from(local, extension, || SERIAL.fetch_add(1, Ordering::Relaxed)) +} + +/// [`beside`] with the serial numbers drawn from `next`. +pub(super) fn beside_from(local: &Path, extension: &str, mut next: impl FnMut() -> u64) -> PathBuf { + loop { + let path = local.with_file_name(format!(".{}.{extension}", next())); + if path != local { + return path; + } + } +} + +/// Creates `path` and any missing parents, private to the user. +pub(super) fn create_private_dirs(path: &Path) -> Result<()> { + let mut builder = DirBuilder::new(); + builder.recursive(true); + // Windows has no mode bits; the folder inherits the private state directory's ACL. + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(path).map_err(|source| Error::Cache { + path: path.to_owned(), + source, + }) +} + +/// Creates the file at `path`, readable and writable only by the user and +/// never executable. Fails when a file is already there. +pub(super) fn create_private_file(path: &Path) -> Result { + private_options() + .create_new(true) + .open(path) + .map_err(|source| Error::Cache { + path: path.to_owned(), + source, + }) +} + +/// Options that write a file only the user may read or write, with no +/// execute bit, when they create it. +fn private_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.write(true); + // Windows has no mode bits; the file inherits its folder's ACL. + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options +} + +/// A folder name no other launch has: this process's ID, the time, and a +/// count of the caches it made. +fn launch_name() -> String { + static SERIAL: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let serial = SERIAL.fetch_add(1, Ordering::Relaxed); + format!("{}-{nanos:x}-{serial}", std::process::id()) +} + +/// The first digits of `text`'s SHA-256 in hex. +fn short_hash(text: &str) -> String { + let digest: [u8; 32] = Sha256::digest(text.as_bytes()).into(); + digest + .iter() + .take(HASH_DIGITS / 2) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Removes every launch folder under `root` but `own` whose lock no process +/// holds. A folder without a lock file may be one another run is creating +/// right now, so it is left alone. +fn sweep(root: &Path, own: &Path) { + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(error) => { + tracing::warn!(%error, root = %root.display(), "Remote file cache not swept"); + return; + } + }; + for folder in entries.flatten().map(|entry| entry.path()) { + if folder == own || !folder.is_dir() { + continue; + } + let Ok(lock) = OpenOptions::new().write(true).open(folder.join(LOCK)) else { + continue; + }; + if lock.try_lock().is_err() { + continue; + } + drop(lock); + if let Err(error) = std::fs::remove_dir_all(&folder) { + tracing::warn!(%error, folder = %folder.display(), "Stale remote file copies not removed"); + } + } +} diff --git a/crates/herdr-gpui/src/remote_files/push.rs b/crates/herdr-gpui/src/remote_files/push.rs new file mode 100644 index 000000000..1180cdefa --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/push.rs @@ -0,0 +1,146 @@ +//! Sending a copy back to its host. The push first stages the copy in a +//! private file beside it and sends that, so an editor rewriting the copy +//! during a slow upload never makes the host receive the start of one save +//! and the end of another; a copy that changes while it is staged is staged +//! again. The push sends exactly the staged length, and the host replaces +//! nothing unless that many bytes arrived, so a transfer cut off early +//! never lands as a truncated file. It hashes exactly the bytes it hands +//! over, so the hash stored as synced is the hash of what the host +//! received. Blocking. + +use super::{Error, Io, Result, cache, cache_error, sync::Sha256, watch::Meta}; +use crate::window::RemotePath; +use herdr_client::{Expect, RemoteFileAccess}; +use sha2::{Digest, Sha256 as Hasher}; +use std::{ + fs::File, + io::{self, Read}, + path::{Path, PathBuf}, +}; + +/// How many times a copy that keeps changing while it is staged is staged +/// before the push gives up; the watch then sees the latest save. +const STAGE_ATTEMPTS: usize = 3; + +/// A reader that hashes and counts what it reads. +pub(crate) struct Hashing { + inner: R, + hasher: Hasher, + count: u64, +} + +impl Hashing { + pub(crate) fn new(inner: R) -> Self { + Self { + inner, + hasher: Hasher::new(), + count: 0, + } + } + + /// The count and SHA-256 of the bytes read. + pub(crate) fn finish(self) -> (u64, Sha256) { + (self.count, self.hasher.finalize().into()) + } +} + +impl Read for Hashing { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let n = self.inner.read(buf)?; + self.count = self.count.saturating_add(n as u64); + self.hasher.update(&buf[..n]); + Ok(n) + } +} + +/// A finished push: what the host now holds, and the copy's metadata from +/// before it was read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Pushed { + pub(crate) meta: Meta, + pub(crate) sha256: Sha256, +} + +/// Pushes the copy at `local` to `remote` while the host's file meets +/// `expect`, refusing a copy larger than `limit` bytes. What is sent is a +/// staged snapshot of the copy, whose metadata is returned as the copy's, +/// so a save made after it is a later save to the watch. A new file on the +/// host takes the access `access` names. Blocking. +pub(crate) fn push_copy( + io: &Io, + remote: &RemotePath, + local: &Path, + (expect, access): (Expect, RemoteFileAccess<'_>), + limit: u64, +) -> Result { + let staged = Staged::of(local, limit)?; + let file = File::open(&staged.path).map_err(|source| cache_error(&staged.path, source))?; + let len = staged.meta.len; + let mut reader = Hashing::new(file.take(len)); + let pushed = (io.push)(remote, expect, access, len, &mut reader); + let (sent, sha256) = reader.finish(); + pushed.map_err(|error| match error { + herdr_client::Error::RemoteFile(herdr_client::RemoteFileError::Access) => Error::Access, + herdr_client::Error::RemoteFile(herdr_client::RemoteFileError::SharedFolder) => { + Error::SharedFolder + } + error => Error::Push(error), + })?; + // The host refuses a short transfer; this holds a push that ignored it. + if sent != len { + return Err(Error::Push(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Incomplete, + ))); + } + Ok(Pushed { + meta: staged.meta, + sha256, + }) +} + +/// A private snapshot of a copy, removed when dropped. +struct Staged { + path: PathBuf, + /// The copy's metadata both before and after it was read. + meta: Meta, +} + +impl Staged { + /// Copies the copy at `local` to a new private file beside it, again + /// while its metadata changed during the read, so the snapshot holds + /// one save whole. Refuses a copy larger than `limit` bytes. + fn of(local: &Path, limit: u64) -> Result { + for _ in 0..STAGE_ATTEMPTS { + let before = meta_of(local)?; + if before.len > limit { + return Err(Error::TooLarge { limit }); + } + let path = cache::staged_path(local); + // Armed only once the file is ours, so the drop removes nothing else. + let mut file = cache::create_private_file(&path)?; + let staged = Self { path, meta: before }; + let source = File::open(local).map_err(|source| cache_error(local, source))?; + let copied = io::copy(&mut source.take(before.len), &mut file) + .map_err(|source| cache_error(&staged.path, source))?; + drop(file); + if copied == before.len && meta_of(local)? == before { + return Ok(staged); + } + } + Err(Error::Unsettled) + } +} + +impl Drop for Staged { + fn drop(&mut self) { + if let Err(error) = std::fs::remove_file(&self.path) + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!(%error, path = %self.path.display(), "Staged upload not removed"); + } + } +} + +fn meta_of(path: &Path) -> Result { + super::meta_of(path).map_err(|source| cache_error(path, source)) +} diff --git a/crates/herdr-gpui/src/remote_files/sync.rs b/crates/herdr-gpui/src/remote_files/sync.rs new file mode 100644 index 000000000..ca13a3ee8 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/sync.rs @@ -0,0 +1,184 @@ +//! What a copy's saves lead to: the rules for asking, uploading, and +//! resolving a conflict, as one pure step from a copy's state, its upload +//! policy, and what just happened to what it does next. Nothing is pushed +//! unless the user chose Upload or Always for the copy, and nothing replaces +//! a host's changed file unless they chose Overwrite anyway. + +/// A SHA-256 digest. +pub(crate) type Sha256 = [u8; 32]; + +/// Whether a save asks before it is uploaded. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum Policy { + #[default] + Ask, + /// The user chose "Upload future saves without asking". + Always, +} + +/// Where a copy stands against the host's file. Each state after a save +/// holds the hash of that save. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum SyncState { + /// The copy holds the bytes the host had when last fetched or pushed. + #[default] + Synced, + /// A save waits for the next one: its upload failed. + Edited { sha256: Sha256 }, + /// The upload question for a save is open or waiting to open. + Asking { sha256: Sha256 }, + /// A push of the copy is running. + Uploading { sha256: Sha256 }, + /// The host's file is being fetched over the copy. + Refetching { sha256: Sha256 }, + /// The host's file changed since it was synced, so the save was not + /// uploaded. + Conflict { sha256: Sha256 }, + /// The user said "Not now" to this save. + Declined { sha256: Sha256 }, +} + +/// The upload question's answers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Answer { + Upload, + Always, + NotNow, +} + +/// The conflict question's answers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Resolution { + Overwrite, + Refetch, + KeepBoth, +} + +/// Something that happened to a copy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Event { + /// A settled save whose bytes differ from the synced ones. + Saved(Sha256), + /// A settled save whose bytes are the synced ones again. + Reverted, + Answered(Answer), + Resolved(Resolution), + /// A push to the host's own path succeeded. + Uploaded, + /// The push beside the host's file succeeded. + KeptBoth, + /// The host's file was not what the push expected. + Conflicted, + /// The push failed for another reason. + Failed, + RefetchFailed, +} + +/// Where a push goes and what it requires of the host's file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Push { + /// The host's path, still holding the synced bytes. + Expected, + /// The host's path, whatever it holds. + Any, + /// The `n`th free conflict name beside the host's path. + Beside(u8), +} + +/// What to do after a step. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Action { + Nothing, + /// Queue the upload question. + Ask, + /// Queue the conflict question. + ShowConflict, + Push(Push), + Refetch, +} + +/// A copy's state, policy, and next action after `event`. +pub(crate) fn step(state: SyncState, policy: Policy, event: Event) -> (SyncState, Policy, Action) { + use SyncState::*; + let policy = match (state, event) { + (Asking { .. }, Event::Answered(Answer::Always)) => Policy::Always, + _ => policy, + }; + let (state, action) = match (state, event) { + (Uploading { .. } | Refetching { .. }, Event::Saved(_) | Event::Reverted) => { + (state, Action::Nothing) + } + (Asking { .. }, Event::Saved(sha256)) => (Asking { sha256 }, Action::Nothing), + (Declined { sha256: declined }, Event::Saved(sha256)) if declined == sha256 => { + (state, Action::Nothing) + } + // The host still differs from what the copy was synced against, so + // the conflict question comes back rather than another push. + (Conflict { .. }, Event::Saved(sha256)) => (Conflict { sha256 }, Action::ShowConflict), + (_, Event::Saved(sha256)) => saved(policy, sha256), + // The copy holds no edits again: a question still waiting or open + // about them, including Overwrite anyway, no longer applies. + (Edited { .. } | Asking { .. } | Declined { .. } | Conflict { .. }, Event::Reverted) => { + (Synced, Action::Nothing) + } + (Asking { sha256 }, Event::Answered(Answer::Upload | Answer::Always)) => { + (Uploading { sha256 }, Action::Push(Push::Expected)) + } + (Asking { sha256 }, Event::Answered(Answer::NotNow)) => { + (Declined { sha256 }, Action::Nothing) + } + (Conflict { sha256 }, Event::Resolved(Resolution::Overwrite)) => { + (Uploading { sha256 }, Action::Push(Push::Any)) + } + (Conflict { sha256 }, Event::Resolved(Resolution::Refetch)) => { + (Refetching { sha256 }, Action::Refetch) + } + (Conflict { sha256 }, Event::Resolved(Resolution::KeepBoth)) => { + (Uploading { sha256 }, Action::Push(Push::Beside(1))) + } + (Uploading { .. }, Event::Uploaded) => (Synced, Action::Nothing), + (Uploading { sha256 }, Event::KeptBoth) => (Declined { sha256 }, Action::Nothing), + (Uploading { sha256 }, Event::Conflicted) => (Conflict { sha256 }, Action::ShowConflict), + (Uploading { sha256 }, Event::Failed) => (Edited { sha256 }, Action::Nothing), + // The conflict stands, so its question comes back for another try. + (Refetching { sha256 }, Event::RefetchFailed) => { + (Conflict { sha256 }, Action::ShowConflict) + } + _ => (state, Action::Nothing), + }; + (state, policy, action) +} + +fn saved(policy: Policy, sha256: Sha256) -> (SyncState, Action) { + match policy { + Policy::Ask => (SyncState::Asking { sha256 }, Action::Ask), + Policy::Always => ( + SyncState::Uploading { sha256 }, + Action::Push(Push::Expected), + ), + } +} + +/// The host's path for the copy kept beside `path` when both are kept: the +/// name's stem, `.conflict` (`.conflict-2` and on for `n` past one), then +/// its extension, split at the last dot. A name whose only dot leads it, +/// such as `.bashrc`, has no extension. Folders split at the host's own +/// separators: a backslash is a plain name character on a POSIX host. +pub(crate) fn conflict_path(path: &str, shell: herdr_client::RemoteShell, n: u8) -> String { + let separators: &[char] = match shell { + herdr_client::RemoteShell::Posix => &['/'], + herdr_client::RemoteShell::PowerShell => &['/', '\\'], + }; + let name_at = path.rfind(separators).map_or(0, |at| at + 1); + let (folder, name) = path.split_at(name_at); + let (stem, extension) = match name.rfind('.') { + Some(dot) if dot > 0 && dot + 1 < name.len() => name.split_at(dot), + _ => (name, ""), + }; + let suffix = if n <= 1 { + ".conflict".to_owned() + } else { + format!(".conflict-{n}") + }; + format!("{folder}{stem}{suffix}{extension}") +} diff --git a/crates/herdr-gpui/src/remote_files/tests.rs b/crates/herdr-gpui/src/remote_files/tests.rs new file mode 100644 index 000000000..2230e6a23 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests.rs @@ -0,0 +1,406 @@ +use super::*; +use gpui::{Entity, TestAppContext, VisualTestContext}; +use sha2::{Digest, Sha256}; +use std::sync::Mutex; + +mod answers; +mod dismissed_conflict; +mod fetch_meta; +mod fetch_swap; +mod fetches; +mod long_names; +mod saves; +mod windows_names; + +/// The bytes [`notes`] serves. +pub(crate) const NOTES: &[u8] = b"# Notes\n"; + +/// A host that has the file asked for, holding [`NOTES`]. +pub(crate) fn notes() -> herdr_client::Result<&'static [u8]> { + Ok(NOTES) +} + +/// A host that has nothing at the path asked for. +pub(crate) fn missing() -> herdr_client::Result<&'static [u8]> { + Err(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Missing, + )) +} + +/// A host whose file changed since it was fetched. +pub(crate) fn conflict() -> herdr_client::Result<()> { + Err(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Conflict, + )) +} + +/// A push the host received less of than it was told to expect. +pub(crate) fn cut_off() -> herdr_client::Result<()> { + Err(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Incomplete, + )) +} + +/// A fetch into a cache that keeps no edits, which always writes the copy. +fn fetch_fresh(io: &Io, owner: Owner, remote: &RemotePath) -> Result { + fetch_copy(io, owner, remote, None).map(|fetch| match fetch { + Fetch::Written(done) => *done, + Fetch::KeptEdits => panic!("a fetch keeping no edits kept some"), + }) +} + +/// How the fake host answers one push. +type PushReply = fn() -> herdr_client::Result<()>; + +/// A push asked of the fake host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Received { + pub(crate) path: String, + pub(crate) expect: Expect, + /// The host file whose access a new file was to take, if any. + pub(crate) like: Option, + pub(crate) bytes: Vec, +} + +/// An SSH host whose answers a test chooses, the fetches and pushes asked +/// of it, and a cache of its own in a temporary folder. +pub(crate) struct FakeHost { + asked: Arc>>, + cache: Arc, + /// Bytes every fetch serves in place of its reply, once set. + file: Arc>>>, + /// Answers for the next pushes, in order; a push past them succeeds. + push_replies: Arc>>, + received: Arc>>, + _root: tempfile::TempDir, +} + +impl FakeHost { + pub(crate) fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let cache = Arc::new(Cache::create(root.path()).unwrap()); + Self { + asked: Arc::default(), + cache, + file: Arc::default(), + push_replies: Arc::default(), + received: Arc::default(), + _root: root, + } + } + + /// I/O that answers every fetch with `reply`. + pub(crate) fn io(&self, reply: fn() -> herdr_client::Result<&'static [u8]>) -> Io { + let asked = self.asked.clone(); + let cache = self.cache.clone(); + let file = self.file.clone(); + let replies = self.push_replies.clone(); + let received = self.received.clone(); + Io { + step: Arc::new(|_, _| {}), + cache: Arc::new(move || Ok(cache.clone())), + fetch: Arc::new(move |remote, out| { + asked.lock().unwrap().push(remote.clone()); + let bytes = match file.lock().unwrap().clone() { + Some(bytes) => bytes, + None => reply()?.to_vec(), + }; + out.write_all(&bytes).unwrap(); + Ok(Fetched { + bytes: bytes.len() as u64, + sha256: Sha256::digest(&bytes).into(), + }) + }), + push: Arc::new(move |remote, expect, access, len, input| { + let mut bytes = Vec::new(); + input.read_to_end(&mut bytes).unwrap(); + let whole = bytes.len() as u64 == len; + let like = match access { + RemoteFileAccess::Keep => None, + RemoteFileAccess::Like(source) => Some(source.to_owned()), + }; + received.lock().unwrap().push(Received { + path: remote.path.clone(), + expect, + like, + bytes, + }); + // As a real host does, a transfer short of its length fails. + if !whole { + return cut_off(); + } + let reply = replies.lock().unwrap().pop_front(); + reply.map_or(Ok(()), |reply| reply()) + }), + } + } + + /// Has later fetches serve `bytes`. + pub(crate) fn change_file(&self, bytes: &[u8]) { + *self.file.lock().unwrap() = Some(bytes.to_vec()); + } + + /// Answers the next pushes with `replies`, in order. + pub(crate) fn answer_pushes(&self, replies: &[PushReply]) { + self.push_replies.lock().unwrap().extend(replies); + } + + /// Every push asked of this host so far, refused or not. + pub(crate) fn received(&self) -> Vec { + self.received.lock().unwrap().clone() + } + + /// Has `files` fetch from this host, answering with `reply`. + pub(crate) fn serve( + &self, + files: &mut RemoteFiles, + reply: fn() -> herdr_client::Result<&'static [u8]>, + ) { + *files = RemoteFiles { + io: self.io(reply), + ..RemoteFiles::default() + }; + } + + /// Every fetch asked of this host so far. + pub(crate) fn asked(&self) -> Vec { + self.asked.lock().unwrap().clone() + } +} + +fn remote(path: &str) -> RemotePath { + RemotePath { + target: "me@devbox".into(), + shell: RemoteShell::PowerShell, + path: path.into(), + line: None, + } +} + +/// A window fetching from `host`. +fn window<'a>( + host: &FakeHost, + cx: &'a mut TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + let (view, cx) = cx.add_window_view(crate::sidebar::layout_tests::fixture_window); + view.update(cx, |view, _| host.serve(&mut view.remote_files, notes)); + (view, cx) +} + +#[test] +fn a_copy_is_named_after_the_host_s_file() { + let (windows, posix) = (RemoteShell::PowerShell, RemoteShell::Posix); + assert_eq!(basename(r"C:\dev\notes.md", windows), "notes.md"); + assert_eq!(basename("/home/me/src/main.rs", posix), "main.rs"); + assert_eq!(basename("/home/me/src/main.rs", windows), "main.rs"); + assert_eq!(basename(r"~\a/b\Makefile", windows), "Makefile"); + assert_eq!(basename("README", posix), "README"); + for odd in [ + "/home/me/", + r"C:\dev\", + "/a/.", + "/a/..", + "", + "/a/b\u{7}c", + "~", + ] { + let expected = if odd == "~" { "~" } else { "file" }; + assert_eq!(basename(odd, windows), expected, "{odd:?}"); + } +} + +#[test] +fn a_copy_s_name_splits_at_the_host_s_own_separators() { + assert_eq!( + basename("/srv/foo\\bar.txt", RemoteShell::Posix), + "foo\\bar.txt", + "a backslash is part of a POSIX name" + ); + assert_eq!(basename(r"C:\a\b.txt", RemoteShell::PowerShell), "b.txt"); + assert_eq!(basename("/srv/foo\\", RemoteShell::Posix), "foo\\"); + assert_eq!(basename("/srv/foo\\", RemoteShell::PowerShell), "file"); +} + +#[test] +fn copies_are_kept_apart_by_host_and_path() { + let host = FakeHost::new(); + let cache = &host.cache; + let (owner, another) = (Owner::default(), Owner::default()); + let posix = RemoteShell::Posix; + let copy = cache.copy_path(owner, "me@devbox", "/a/notes.md", posix); + assert_eq!( + copy, + cache.copy_path(owner, "me@devbox", "/a/notes.md", posix), + "stable" + ); + assert!(copy.starts_with(cache.launch())); + assert_eq!(copy.file_name().unwrap(), "notes.md"); + let others = [ + cache.copy_path(owner, "me@other", "/a/notes.md", posix), + cache.copy_path(owner, "me@devbox", "/b/notes.md", posix), + cache.copy_path(another, "me@devbox", "/a/notes.md", posix), + ]; + for other in &others { + assert_ne!(©, other); + assert_eq!(other.file_name().unwrap(), "notes.md"); + } + // The window's folder under the launch folder, two folders of hex + // digits, then the file. + let relative = copy.strip_prefix(cache.launch()).unwrap(); + let window = relative.iter().next().unwrap().to_str().unwrap(); + assert_eq!(window, format!("w{}", owner.0)); + let folders: Vec<_> = relative.iter().skip(1).take(2).collect(); + assert_eq!(folders.len(), 2); + for folder in folders { + let folder = folder.to_str().unwrap(); + assert_eq!(folder.len(), 16, "{folder}"); + assert!(folder.chars().all(|c| c.is_ascii_hexdigit()), "{folder}"); + } +} + +#[test] +fn a_launch_removes_unlocked_launch_folders_and_keeps_locked_ones() { + let root = tempfile::tempdir().unwrap(); + let running = Cache::create(root.path()).unwrap(); + let crashed = root.path().join("1-dead-0"); + std::fs::create_dir(&crashed).unwrap(); + std::fs::write(crashed.join(".lock"), "").unwrap(); + std::fs::write(crashed.join("notes.md"), "old").unwrap(); + // A folder another launch is still creating has no lock file yet. + let creating = root.path().join("2-new-0"); + std::fs::create_dir(&creating).unwrap(); + // Nor one whose lock is not yet held: it is still `.lock.new` then. + let locking = root.path().join("3-new-0"); + std::fs::create_dir(&locking).unwrap(); + std::fs::write(locking.join(".lock.new"), "").unwrap(); + + let fresh = Cache::create(root.path()).unwrap(); + assert!( + locking.join(".lock.new").is_file(), + "an unheld pending lock is kept" + ); + assert!(!fresh.launch().join(".lock.new").exists()); + assert!(fresh.launch().join(".lock").is_file()); + assert!(!crashed.exists(), "a crashed launch's folder is removed"); + assert!( + running.launch().join(".lock").is_file(), + "a running one's is kept" + ); + assert!(creating.is_dir()); + assert!(fresh.launch().is_dir()); + + // Removing a cache releases its lock and its folder; removing it again + // finds nothing to do. + running.remove().unwrap(); + assert!(!running.launch().exists()); + running.remove().unwrap(); + assert!(fresh.launch().is_dir()); +} + +#[test] +fn a_fetch_writes_the_copy_privately_and_a_new_fetch_replaces_it() { + let host = FakeHost::new(); + let owner = Owner::default(); + let done = fetch_fresh(&host.io(notes), owner, &remote(r"C:\dev\notes.md")).unwrap(); + let local = done.copy.local.clone(); + assert_eq!(std::fs::read(&local).unwrap(), NOTES); + assert_eq!( + done.copy.synced_sha256, + <[u8; 32]>::from(Sha256::digest(NOTES)) + ); + assert_eq!(done.copy.synced_meta.len, NOTES.len() as u64); + assert_eq!(done.copy.shell, RemoteShell::PowerShell); + assert_eq!( + done.copy.synced_meta.modified, + std::fs::metadata(&local).unwrap().modified().unwrap() + ); + assert_eq!(done.key, ("me@devbox".into(), r"C:\dev\notes.md".into())); + // A document opens itself. + let opened = done.opened.unwrap().to_file_path().unwrap(); + assert_eq!(opened.file_name().unwrap(), "notes.md"); + assert_eq!( + std::fs::read_dir(local.parent().unwrap()).unwrap().count(), + 1, + "no .part left" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&local), 0o600); + assert_eq!(mode(local.parent().unwrap()), 0o700); + assert_eq!(mode(host.cache.launch()), 0o700); + } + + fn newer() -> herdr_client::Result<&'static [u8]> { + Ok(b"# Newer\n") + } + let again = fetch_fresh(&host.io(newer), owner, &remote(r"C:\dev\notes.md")).unwrap(); + assert_eq!(again.copy.local, local); + assert_eq!(std::fs::read(&local).unwrap(), b"# Newer\n"); +} + +#[test] +fn a_script_opens_the_folder_holding_its_copy() { + let host = FakeHost::new(); + let done = fetch_fresh( + &host.io(notes), + Owner::default(), + &remote("/home/me/run.sh"), + ) + .unwrap(); + let opened = done.opened.unwrap().to_file_path().unwrap(); + assert!(opened.is_dir(), "{}", opened.display()); + // `opened` has symlinks resolved (macOS keeps temporary folders behind + // `/var -> /private/var`), so compare both sides resolved. + let holder = done.copy.local.parent().unwrap(); + assert_eq!( + opened.canonicalize().unwrap(), + holder.canonicalize().unwrap() + ); +} + +#[test] +fn a_failed_fetch_leaves_the_older_copy_and_no_partial_file() { + let host = FakeHost::new(); + let path = r"C:\dev\notes.md"; + let owner = Owner::default(); + let local = fetch_fresh(&host.io(notes), owner, &remote(path)) + .unwrap() + .copy + .local; + + let Err(error) = fetch_fresh(&host.io(missing), owner, &remote(path)) else { + panic!("a missing file was fetched"); + }; + assert!(matches!( + &error, + Error::Fetch(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Missing + )) + )); + assert_eq!( + crate::Error::from(error).to_string(), + "Could not fetch the file: the file is not on the host" + ); + assert_eq!(std::fs::read(&local).unwrap(), NOTES); + assert_eq!( + std::fs::read_dir(local.parent().unwrap()).unwrap().count(), + 1 + ); + assert_eq!(host.asked().len(), 2); +} + +#[test] +fn a_fetch_into_an_unusable_cache_says_where() { + let root = tempfile::tempdir().unwrap(); + let blocked = root.path().join("blocked"); + std::fs::write(&blocked, "").unwrap(); + let Err(error) = Cache::create(&blocked) else { + panic!("a cache was made under a file"); + }; + assert!( + matches!(&error, Error::Cache { path, .. } if *path == blocked), + "{error:?}" + ); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/answers.rs b/crates/herdr-gpui/src/remote_files/tests/answers.rs new file mode 100644 index 000000000..df888642d --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/answers.rs @@ -0,0 +1,89 @@ +//! Answers to a copy's questions that arrive after the copy moved on. + +use super::*; +use std::time::Duration; + +const PATH: &str = "/home/me/notes.md"; + +/// One poll of the window, with a watch run due, and its work finished. +fn poll(view: &Entity, cx: &mut VisualTestContext) { + view.update_in(cx, |view, window, cx| { + view.remote_files.last_watch = None; + view.poll_remote_files(window, cx); + }); + cx.run_until_parked(); +} + +#[gpui::test] +fn saving_the_synced_bytes_back_closes_the_open_upload_question(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + let key = ("me@devbox".to_owned(), PATH.to_owned()); + let (local, synced) = view.read_with(cx, |view, _| { + let copy = &view.remote_files.copies[&key]; + (copy.local.clone(), copy.synced_meta) + }); + std::fs::write(&local, b"# Notes, edited\n").unwrap(); + // Two runs settle the save; the next poll opens its question. + for _ in 0..3 { + poll(&view, cx); + } + let page = |cx: &mut VisualTestContext| view.read_with(cx, |view, _| view.menu.page); + assert_eq!(page(cx), Some(crate::menu::Page::RemoteUpload)); + + // The user saves the synced bytes back while the question is open. + std::fs::write(&local, NOTES).unwrap(); + let file = std::fs::File::options().write(true).open(&local).unwrap(); + file.set_modified(synced.modified + Duration::from_secs(5)) + .unwrap(); + drop(file); + for _ in 0..2 { + poll(&view, cx); + } + view.read_with(cx, |view, _| { + assert_eq!(view.remote_files.copies[&key].state, SyncState::Synced); + assert_eq!(view.shown_remote_file_prompt(), None); + }); + assert_eq!(page(cx), None, "the question about the edits closed"); + assert!(host.received().is_empty(), "{:?}", host.received()); +} + +#[gpui::test] +fn overwrite_after_the_host_s_bytes_were_saved_back_pushes_nothing(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + let key = ("me@devbox".to_owned(), PATH.to_owned()); + view.update(cx, |view, cx| { + let files = &mut view.remote_files; + let copy = files.copies.get_mut(&key).unwrap(); + // The host refused a save's upload: its file changed since the sync. + copy.state = SyncState::Conflict { + sha256: Sha256::digest(b"# Notes, edited\n").into(), + }; + // Then the user saves the bytes the copy was synced with back. + std::fs::write(©.local, NOTES).unwrap(); + let file = std::fs::File::options() + .write(true) + .open(©.local) + .unwrap(); + file.set_modified(copy.synced_meta.modified + Duration::from_secs(5)) + .unwrap(); + drop(file); + // Two watch runs settle that save. + for _ in 0..2 { + let seen = watch::scan(files.probes()); + files.apply_scan(seen); + } + // The conflict question, still open, is answered after that. + view.resolve_remote_conflict(key.clone(), Resolution::Overwrite, cx); + }); + cx.run_until_parked(); + assert!(host.received().is_empty(), "{:?}", host.received()); + view.read_with(cx, |view, _| { + assert_eq!(view.remote_files.copies[&key].state, SyncState::Synced); + }); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs b/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs new file mode 100644 index 000000000..d0d860c0b --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs @@ -0,0 +1,142 @@ +//! A conflict question closed without an answer, which a click on the +//! copy's path brings back. + +use super::*; +use crate::menu::Page; + +const PATH: &str = "/home/me/notes.md"; +const EDITED: &[u8] = b"# Notes, edited\n"; + +fn key() -> Key { + ("me@devbox".to_owned(), PATH.to_owned()) +} + +/// One poll of the window, and its work finished. +fn poll(view: &Entity, cx: &mut VisualTestContext) { + view.update_in(cx, |view, window, cx| view.poll_remote_files(window, cx)); + cx.run_until_parked(); +} + +/// A link click on `PATH`, and its work finished. +fn click(view: &Entity, cx: &mut VisualTestContext) { + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); +} + +fn page(view: &Entity, cx: &mut VisualTestContext) -> Option { + view.read_with(cx, |view, _| view.menu.page) +} + +/// The questions waiting for the window, by kind. +fn waiting(view: &Entity, cx: &mut VisualTestContext) -> Vec { + view.read_with(cx, |view, _| { + view.remote_files + .prompts + .iter() + .map(|prompt| prompt.kind) + .collect() + }) +} + +fn set_state(view: &Entity, cx: &mut VisualTestContext, state: SyncState) { + view.update(cx, |view, _| { + view.remote_files.copies.get_mut(&key()).unwrap().state = state; + }); +} + +/// A window whose copy of `PATH` is in conflict, its question closed with +/// Escape. +fn dismissed<'a>( + host: &FakeHost, + cx: &'a mut TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + let (view, cx) = window(host, cx); + click(&view, cx); + set_state( + &view, + cx, + SyncState::Conflict { + sha256: Sha256::digest(EDITED).into(), + }, + ); + view.update(cx, |view, _| { + view.remote_files.enqueue(Prompt { + key: key(), + kind: PromptKind::Conflict, + }); + }); + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteConflict)); + cx.simulate_keystrokes("escape"); + assert_eq!(page(&view, cx), None); + poll(&view, cx); + assert_eq!(page(&view, cx), None, "nothing brings it back by itself"); + assert_eq!(waiting(&view, cx), []); + (view, cx) +} + +#[gpui::test] +fn a_click_on_a_conflicted_copy_s_path_asks_its_closed_question_again(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = dismissed(&host, cx); + // Something else opened since, so the copy's own opening shows. + cx.update(|_, cx| cx.open_url("about:blank")); + + click(&view, cx); + assert_eq!(host.asked().len(), 1, "not fetched again"); + assert!(cx.opened_url().unwrap().ends_with("/notes.md")); + assert_eq!(waiting(&view, cx), [PromptKind::Conflict]); + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteConflict)); + assert_eq!( + view.read_with(cx, |view, _| view.shown_remote_file_prompt()), + Some(Prompt { + key: key(), + kind: PromptKind::Conflict, + }) + ); +} + +#[gpui::test] +fn two_clicks_on_a_conflicted_copy_s_path_queue_its_question_once(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = dismissed(&host, cx); + click(&view, cx); + click(&view, cx); + assert_eq!(waiting(&view, cx), [PromptKind::Conflict]); + + // Nor does a click while the question is open queue it behind itself. + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteConflict)); + click(&view, cx); + assert_eq!(waiting(&view, cx), []); +} + +#[gpui::test] +fn a_click_on_a_copy_not_in_conflict_queues_no_question(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + click(&view, cx); + // A synced copy is fetched again. + click(&view, cx); + assert_eq!(host.asked().len(), 2); + assert_eq!(waiting(&view, cx), []); + + // One asking to be uploaded, whose question waits, is reopened as it is. + set_state( + &view, + cx, + SyncState::Asking { + sha256: Sha256::digest(EDITED).into(), + }, + ); + view.update(cx, |view, _| { + view.remote_files.enqueue(Prompt { + key: key(), + kind: PromptKind::Upload, + }); + }); + click(&view, cx); + assert_eq!(host.asked().len(), 2, "not fetched again"); + assert_eq!(waiting(&view, cx), [PromptKind::Upload]); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs b/crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs new file mode 100644 index 000000000..b3975a2dc --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs @@ -0,0 +1,43 @@ +//! The metadata a fetch records as its copy's synced state, against a save +//! made the moment the copy lands. + +use super::*; + +const PATH: &str = "/home/me/notes.md"; +/// Longer than [`NOTES`], so the save changes the copy's length even when +/// the clock has not moved on. +const EDITED: &[u8] = b"# Notes, saved as the copy landed\n"; + +#[test] +fn a_save_as_the_fetched_copy_lands_asks_to_be_uploaded() { + let host = FakeHost::new(); + let mut files = RemoteFiles::default(); + host.serve(&mut files, notes); + files.io.step = Arc::new(|step, local| { + if step == Step::Placed { + std::fs::write(local, EDITED).unwrap(); + } + }); + let done = fetch_fresh(&files.io, files.owner, &remote(PATH)).unwrap(); + assert_eq!( + done.copy.synced_meta.len, + NOTES.len() as u64, + "the synced metadata is the downloaded bytes', not the save's" + ); + let key = done.key.clone(); + files.record(done); + + // Two watch runs settle the save, which then asks to be uploaded. + let mut actions = Vec::new(); + for _ in 0..2 { + let seen = watch::scan(files.probes()); + actions.extend(files.apply_scan(seen)); + } + assert_eq!(actions, vec![(key.clone(), sync::Action::Ask)]); + assert_eq!( + files.copies[&key].state, + SyncState::Asking { + sha256: Sha256::digest(EDITED).into() + } + ); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs b/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs new file mode 100644 index 000000000..c1699ddff --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs @@ -0,0 +1,230 @@ +//! A repeat fetch that keeps edits, against saves made at each step of +//! putting the host's bytes in place, and the watch runs and conflict +//! refetches that must leave a fetching copy alone. + +use super::*; + +const PATH: &str = "/home/me/notes.md"; +/// Longer than [`NOTES`] and [`NEWER`], so a save changes the copy's length +/// even when the clock has not moved on. +const EDITED: &[u8] = b"# Notes, saved while the fetch ran\n"; +/// What the host holds by the time the copy is fetched again. +const NEWER: &[u8] = b"# Notes, newer\n"; + +/// Window-less files holding a synced copy of `PATH`, fetched from `host`, +/// whose later fetches serve [`NEWER`]. +fn synced(host: &FakeHost) -> (RemoteFiles, Key, PathBuf) { + let mut files = RemoteFiles::default(); + host.serve(&mut files, notes); + let done = fetch_fresh(&files.io, files.owner, &remote(PATH)).unwrap(); + let (key, local) = (done.key.clone(), done.copy.local.clone()); + files.record(done); + host.change_file(NEWER); + (files, key, local) +} + +/// Fetches `PATH` again over `key`'s copy, keeping edits made since it was +/// synced, with `at` run at each step of putting it in place. +fn refetch( + files: &mut RemoteFiles, + key: &Key, + at: impl Fn(Step, &Path) + Send + Sync + 'static, +) -> Result { + files.io.step = Arc::new(at); + let synced = files.copies[key].synced_meta; + fetch_copy(&files.io, files.owner, &remote(PATH), Some(synced)) +} + +/// How many files the folder holding `local` has. +fn entries(local: &Path) -> usize { + std::fs::read_dir(local.parent().unwrap()).unwrap().count() +} + +/// One watch run, done in place. +fn run(files: &mut RemoteFiles) -> Vec<(Key, sync::Action)> { + let seen = watch::scan(files.probes()); + files.apply_scan(seen) +} + +#[test] +fn an_in_place_save_after_the_check_is_kept() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + let fetched = refetch(&mut files, &key, |step, local| { + if step == Step::Checked { + std::fs::write(local, EDITED).unwrap(); + } + }); + assert!(matches!(fetched, Ok(Fetch::KeptEdits))); + assert_eq!(std::fs::read(&local).unwrap(), EDITED); + assert_eq!(entries(&local), 1, "no part or kept file left"); +} + +#[test] +fn a_temp_and_rename_save_after_the_move_aside_is_kept() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + let fetched = refetch(&mut files, &key, |step, local| { + if step == Step::MovedAside { + let temp = local.with_file_name("notes.md.save"); + std::fs::write(&temp, EDITED).unwrap(); + std::fs::rename(&temp, local).unwrap(); + } + }); + assert!(matches!(fetched, Ok(Fetch::KeptEdits))); + assert_eq!(std::fs::read(&local).unwrap(), EDITED); + assert_eq!(entries(&local), 1, "the synced copy moved aside is gone"); +} + +#[test] +fn a_save_through_a_handle_opened_before_the_move_aside_is_kept() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + let handle = Mutex::new(None::); + let fetched = refetch(&mut files, &key, move |step, local| match step { + Step::Checked => { + let file = std::fs::OpenOptions::new().write(true).open(local).unwrap(); + *handle.lock().unwrap() = Some(file); + } + Step::MovedAside => { + let mut file = handle.lock().unwrap().take().unwrap(); + file.write_all(EDITED).unwrap(); + } + Step::Placed => {} + }); + assert!(matches!(fetched, Ok(Fetch::KeptEdits))); + assert_eq!(std::fs::read(&local).unwrap(), EDITED); + assert_eq!(entries(&local), 1); +} + +#[test] +fn a_save_after_the_copy_is_placed_asks_to_be_uploaded() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + let fetched = refetch(&mut files, &key, |step, local| { + if step == Step::Placed { + std::fs::write(local, EDITED).unwrap(); + } + }); + let Ok(Fetch::Written(done)) = fetched else { + panic!("the host's bytes were not written"); + }; + assert_eq!( + done.copy.synced_meta.len, + NEWER.len() as u64, + "the synced metadata is the downloaded bytes', not the save's" + ); + assert_eq!(entries(&local), 1); + files.record(*done); + + // Two watch runs settle the save, which then asks to be uploaded. + let mut actions = Vec::new(); + for _ in 0..2 { + actions.extend(run(&mut files)); + } + assert_eq!(actions, vec![(key.clone(), sync::Action::Ask)]); + assert_eq!( + files.copies[&key].state, + SyncState::Asking { + sha256: Sha256::digest(EDITED).into() + } + ); +} + +#[test] +fn a_copy_deleted_before_the_fetch_is_written() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + std::fs::remove_file(&local).unwrap(); + let fetched = refetch(&mut files, &key, |_, _| {}); + assert!(matches!(fetched, Ok(Fetch::Written(_)))); + assert_eq!(std::fs::read(&local).unwrap(), NEWER); + assert_eq!(entries(&local), 1); +} + +#[cfg(windows)] +#[test] +fn a_copy_held_open_without_delete_sharing_is_left_as_it_was() { + use std::os::windows::fs::OpenOptionsExt; + /// `FILE_SHARE_READ | FILE_SHARE_WRITE`, without `FILE_SHARE_DELETE`. + const SHARE_READ_WRITE: u32 = 0x1 | 0x2; + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + let held = std::fs::OpenOptions::new() + .read(true) + .share_mode(SHARE_READ_WRITE) + .open(&local) + .unwrap(); + let fetched = refetch(&mut files, &key, |_, _| {}); + assert!( + matches!(&fetched, Err(Error::Cache { .. })), + "{:?}", + fetched.err() + ); + drop(held); + assert_eq!(std::fs::read(&local).unwrap(), NOTES); + assert_eq!(entries(&local), 1, "no part left"); +} + +#[test] +fn a_copy_being_fetched_is_not_watched_until_its_fetch_ends() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + std::fs::write(&local, EDITED).unwrap(); + // A run that began before the fetch reports after it started. + let seen = watch::scan(files.probes()); + files.fetching.insert(key.clone()); + assert_eq!(files.apply_scan(seen), vec![]); + assert_eq!(files.copies[&key].watch, watch::Watch::default()); + for _ in 0..2 { + assert_eq!(run(&mut files), vec![]); + } + assert_eq!(files.copies[&key].state, SyncState::Synced); + + files.fetching.remove(&key); + let mut actions = Vec::new(); + for _ in 0..2 { + actions.extend(run(&mut files)); + } + assert_eq!(actions, vec![(key, sync::Action::Ask)]); +} + +#[gpui::test] +fn a_conflict_refetch_holds_its_copy_while_it_runs(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + let key: Key = ("me@devbox".into(), PATH.into()); + let conflict = SyncState::Conflict { + sha256: Sha256::digest(EDITED).into(), + }; + view.update(cx, |view, _| { + view.remote_files.copies.get_mut(&key).unwrap().state = conflict; + view.remote_files.io = host.io(missing); + }); + + // A refetch that fails lets go of the copy. + view.update(cx, |view, cx| { + view.resolve_remote_conflict(key.clone(), Resolution::Refetch, cx); + assert!(view.remote_files.fetching.contains(&key), "held"); + }); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + assert!(!view.remote_files.fetching.contains(&key), "released"); + assert_eq!(view.remote_files.copies[&key].state, conflict); + }); + + // As does one that succeeds. + view.update(cx, |view, cx| { + view.remote_files.io = host.io(notes); + view.resolve_remote_conflict(key.clone(), Resolution::Refetch, cx); + assert!(view.remote_files.fetching.contains(&key), "held"); + }); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + assert!(!view.remote_files.fetching.contains(&key), "released"); + assert_eq!(view.remote_files.copies[&key].state, SyncState::Synced); + }); + assert_eq!(host.asked().len(), 3); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/fetches.rs b/crates/herdr-gpui/src/remote_files/tests/fetches.rs new file mode 100644 index 000000000..bbf4cab2e --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/fetches.rs @@ -0,0 +1,128 @@ +//! Fetches that overlap: clicks made while a fetch runs, and fetches of one +//! host file by windows that share the process's cache. + +use super::*; + +const PATH: &str = "/home/me/notes.md"; + +#[gpui::test] +fn a_click_while_its_file_is_fetched_starts_no_second_fetch(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, cx| { + view.open_remote_file(remote(PATH), cx); + view.open_remote_file(remote(PATH), cx); + }); + cx.run_until_parked(); + assert_eq!(host.asked().len(), 1); + + // Once that fetch is done, a click fetches again. + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + assert_eq!(host.asked().len(), 2); +} + +#[gpui::test] +fn a_failed_click_fetch_lets_the_next_click_fetch_again(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, _| host.serve(&mut view.remote_files, missing)); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + assert_eq!(host.asked().len(), 2); +} + +#[test] +fn overlapping_fetches_of_one_copy_both_return_and_leave_it_whole() { + let host = FakeHost::new(); + let owner = Owner::default(); + let (started, on_start) = std::sync::mpsc::channel(); + let (release, on_release) = std::sync::mpsc::channel::<()>(); + let on_release = Mutex::new(on_release); + let io = host.io(notes); + let fetch = io.fetch.clone(); + let gated = Io { + fetch: Arc::new(move |remote, out| { + started.send(()).unwrap(); + on_release.lock().unwrap().recv().unwrap(); + fetch(remote, out) + }), + ..io + }; + let (first, second) = std::thread::scope(|scope| { + let first = scope.spawn(|| fetch_fresh(&gated, owner, &remote(PATH))); + let second = scope.spawn(|| fetch_fresh(&gated, owner, &remote(PATH))); + // Both fetches are writing before either may finish. + on_start.recv().unwrap(); + on_start.recv().unwrap(); + release.send(()).unwrap(); + release.send(()).unwrap(); + (first.join().unwrap(), second.join().unwrap()) + }); + let (first, second) = (first.unwrap(), second.unwrap()); + assert_eq!(first.copy.local, second.copy.local); + assert_eq!(std::fs::read(&first.copy.local).unwrap(), NOTES); + assert_eq!( + std::fs::read_dir(first.copy.local.parent().unwrap()) + .unwrap() + .count(), + 1, + "no .part left" + ); + assert_eq!(host.asked().len(), 2); +} + +/// Fetches `PATH` into `files`, as a click in its window does, returning +/// where the copy is. +fn fetch_into(files: &mut RemoteFiles) -> PathBuf { + let done = fetch_fresh(&files.io, files.owner, &remote(PATH)).unwrap(); + let local = done.copy.local.clone(); + files.record(done); + local +} + +#[test] +fn a_fetch_in_another_window_keeps_this_window_s_edits() { + const EDITED: &[u8] = b"# Notes, edited\n"; + let host = FakeHost::new(); + let (mut first, mut second) = (RemoteFiles::default(), RemoteFiles::default()); + host.serve(&mut first, notes); + host.serve(&mut second, notes); + let local = fetch_into(&mut first); + std::fs::write(&local, EDITED).unwrap(); + // Two watch runs settle the save, which then asks to be uploaded. + for _ in 0..2 { + let seen = watch::scan(first.probes()); + first.apply_scan(seen); + } + let key = ("me@devbox".to_owned(), PATH.to_owned()); + assert!( + matches!(first.copies[&key].state, SyncState::Asking { .. }), + "{:?}", + first.copies[&key].state + ); + + let theirs = fetch_into(&mut second); + assert_eq!(std::fs::read(&local).unwrap(), EDITED); + assert_ne!(theirs, local); + assert_eq!(std::fs::read(&theirs).unwrap(), NOTES); +} + +#[test] +fn two_fetches_of_one_copy_write_apart() { + let host = FakeHost::new(); + let local = host.cache.launch().join("notes.md"); + let first = cache::part_path(&local); + let second = cache::part_path(&local); + assert_ne!(first, second); + assert_eq!(first.parent(), local.parent(), "beside the copy"); + assert_eq!(second.parent(), local.parent(), "beside the copy"); + + let _written = cache::create_private_file(&first).unwrap(); + assert!( + cache::create_private_file(&first).is_err(), + "a part is never opened over another fetch's" + ); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/long_names.rs b/crates/herdr-gpui/src/remote_files/tests/long_names.rs new file mode 100644 index 000000000..cfe71cf16 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/long_names.rs @@ -0,0 +1,71 @@ +//! Host files whose names are as long as a file name can be: the copy's +//! temporary files must not add to that name. + +use super::*; +use crate::remote_files::push::push_copy; + +/// A 255-byte name, the usual limit for one file name. +fn longest_name() -> String { + format!("{}.md", "n".repeat(252)) +} + +#[test] +fn temporary_names_beside_a_copy_stay_short() { + let local = Path::new("cache").join(longest_name()); + for temporary in [ + cache::part_path(&local), + cache::kept_path(&local), + cache::staged_path(&local), + ] { + assert_eq!(temporary.parent(), local.parent(), "beside the copy"); + let name = temporary.file_name().unwrap().len(); + assert!(name < 32, "{} is {name} bytes", temporary.display()); + } +} + +#[test] +fn a_copy_with_the_longest_name_is_fetched_and_pushed() { + let host = FakeHost::new(); + let files = { + let mut files = RemoteFiles::default(); + host.serve(&mut files, notes); + files + }; + let path = format!(r"C:\dev\{}", longest_name()); + let done = fetch_fresh(&files.io, files.owner, &remote(&path)).unwrap(); + assert_eq!(std::fs::read(&done.copy.local).unwrap(), NOTES); + std::fs::write(&done.copy.local, b"# Edited\n").unwrap(); + push_copy( + &files.io, + &remote(&path), + &done.copy.local, + (Expect::Any, RemoteFileAccess::Keep), + 1024, + ) + .unwrap(); + assert_eq!(host.received()[0].bytes, b"# Edited\n"); +} + +/// A host's file may be named like a temporary file. Its copy then shares +/// the folder with temporary names of the same shape, which must never be +/// its own, or a failed write's cleanup would remove the copy. +#[test] +fn a_temporary_name_is_never_the_copy_s_own() { + let local = Path::new("cache").join(".7.part"); + let mut serials = 6..; + let mut next = || serials.next().unwrap(); + assert_eq!( + cache::beside_from(&local, "part", &mut next), + Path::new("cache").join(".6.part") + ); + assert_eq!( + cache::beside_from(&local, "part", &mut next), + Path::new("cache").join(".8.part"), + "the copy's own name is skipped" + ); + assert_eq!( + cache::beside_from(&local, "kept", || 7), + Path::new("cache").join(".7.kept"), + "another extension is another name" + ); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/saves.rs b/crates/herdr-gpui/src/remote_files/tests/saves.rs new file mode 100644 index 000000000..10fa0648c --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/saves.rs @@ -0,0 +1,533 @@ +//! Saves to remote file copies: noticing them, the rules for uploading +//! them, and the push itself, without a window. + +use super::*; +use crate::remote_files::{ + push::{Hashing, push_copy}, + sync::{Action, Event, Push, conflict_path, step}, + watch::{Debounce, Meta, Watch, debounce}, +}; +use std::time::{Duration, SystemTime}; + +const PATH: &str = r"C:\dev\notes.md"; + +/// Window-less copies of `PATH`, fetched from `host`. +fn fetched(host: &FakeHost) -> (RemoteFiles, Key, PathBuf) { + let mut files = RemoteFiles::default(); + host.serve(&mut files, notes); + let done = fetch_fresh(&files.io, files.owner, &remote(PATH)).unwrap(); + let local = done.copy.local.clone(); + let key = done.key.clone(); + files.record(done); + (files, key, local) +} + +/// One watch run, done in place. +fn run(files: &mut RemoteFiles) -> Vec<(Key, Action)> { + let seen = watch::scan(files.probes()); + files.apply_scan(seen) +} + +fn sha(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +#[test] +fn a_kept_copy_is_named_beside_the_host_s_file() { + let windows = RemoteShell::PowerShell; + let posix = RemoteShell::Posix; + let cases = [ + (r"C:\dev\notes.md", windows, 1, r"C:\dev\notes.conflict.md"), + ( + r"C:\dev\notes.md", + windows, + 2, + r"C:\dev\notes.conflict-2.md", + ), + ( + r"C:\dev\notes.md", + windows, + 9, + r"C:\dev\notes.conflict-9.md", + ), + (r"~\.bashrc", windows, 1, r"~\.bashrc.conflict"), + ("/srv/Makefile", posix, 1, "/srv/Makefile.conflict"), + ( + "/srv/archive.tar.gz", + posix, + 1, + "/srv/archive.tar.conflict.gz", + ), + ( + "/srv/archive.tar.gz", + posix, + 3, + "/srv/archive.tar.conflict-3.gz", + ), + ("~/.bashrc", posix, 1, "~/.bashrc.conflict"), + // A backslash is part of a POSIX name, not a folder. + (r"/srv/a\b.txt", posix, 1, r"/srv/a\b.conflict.txt"), + (r"/srv/v1.0\notes", posix, 1, r"/srv/v1.conflict.0\notes"), + ]; + for (path, shell, n, expected) in cases { + assert_eq!(conflict_path(path, shell, n), expected, "{path} {n}"); + } +} + +#[test] +fn each_event_moves_a_copy_by_the_upload_rules() { + use SyncState::{Asking, Conflict, Declined, Edited, Refetching, Synced, Uploading}; + let (h, g) = ([1; 32], [2; 32]); + let ask = Policy::Ask; + let always = Policy::Always; + #[rustfmt::skip] + let cases = [ + (Synced, ask, Event::Saved(h), Asking { sha256: h }, ask, Action::Ask), + (Synced, always, Event::Saved(h), Uploading { sha256: h }, always, Action::Push(Push::Expected)), + (Asking { sha256: h }, ask, Event::Saved(g), Asking { sha256: g }, ask, Action::Nothing), + (Asking { sha256: h }, ask, Event::Answered(Answer::Upload), Uploading { sha256: h }, ask, Action::Push(Push::Expected)), + (Asking { sha256: h }, ask, Event::Answered(Answer::Always), Uploading { sha256: h }, always, Action::Push(Push::Expected)), + (Asking { sha256: h }, ask, Event::Answered(Answer::NotNow), Declined { sha256: h }, ask, Action::Nothing), + (Asking { sha256: h }, ask, Event::Reverted, Synced, ask, Action::Nothing), + (Declined { sha256: h }, ask, Event::Saved(h), Declined { sha256: h }, ask, Action::Nothing), + (Declined { sha256: h }, ask, Event::Saved(g), Asking { sha256: g }, ask, Action::Ask), + (Declined { sha256: h }, ask, Event::Reverted, Synced, ask, Action::Nothing), + (Uploading { sha256: h }, ask, Event::Saved(g), Uploading { sha256: h }, ask, Action::Nothing), + (Uploading { sha256: h }, ask, Event::Uploaded, Synced, ask, Action::Nothing), + (Uploading { sha256: h }, always, Event::Conflicted, Conflict { sha256: h }, always, Action::ShowConflict), + (Uploading { sha256: h }, ask, Event::Failed, Edited { sha256: h }, ask, Action::Nothing), + (Uploading { sha256: h }, ask, Event::KeptBoth, Declined { sha256: h }, ask, Action::Nothing), + (Edited { sha256: h }, ask, Event::Saved(g), Asking { sha256: g }, ask, Action::Ask), + (Edited { sha256: h }, always, Event::Saved(g), Uploading { sha256: g }, always, Action::Push(Push::Expected)), + (Edited { sha256: h }, ask, Event::Reverted, Synced, ask, Action::Nothing), + (Conflict { sha256: h }, ask, Event::Resolved(Resolution::Overwrite), Uploading { sha256: h }, ask, Action::Push(Push::Any)), + (Conflict { sha256: h }, ask, Event::Resolved(Resolution::Refetch), Refetching { sha256: h }, ask, Action::Refetch), + (Conflict { sha256: h }, ask, Event::Resolved(Resolution::KeepBoth), Uploading { sha256: h }, ask, Action::Push(Push::Beside(1))), + (Conflict { sha256: h }, always, Event::Saved(g), Conflict { sha256: g }, always, Action::ShowConflict), + (Conflict { sha256: h }, ask, Event::Reverted, Synced, ask, Action::Nothing), + (Refetching { sha256: h }, ask, Event::Saved(g), Refetching { sha256: h }, ask, Action::Nothing), + (Refetching { sha256: h }, ask, Event::RefetchFailed, Conflict { sha256: h }, ask, Action::ShowConflict), + // Answers that arrive for a question no longer asked do nothing. + (Synced, ask, Event::Answered(Answer::Upload), Synced, ask, Action::Nothing), + (Uploading { sha256: h }, ask, Event::Answered(Answer::Always), Uploading { sha256: h }, ask, Action::Nothing), + (Synced, ask, Event::Resolved(Resolution::Overwrite), Synced, ask, Action::Nothing), + ]; + for (state, policy, event, next, next_policy, action) in cases { + assert_eq!( + step(state, policy, event), + (next, next_policy, action), + "{state:?} {policy:?} {event:?}" + ); + } +} + +#[test] +fn a_change_counts_once_two_runs_see_it() { + let at = |secs| Meta { + modified: SystemTime::UNIX_EPOCH + Duration::from_secs(secs), + len: 8, + }; + let synced = at(1); + let fresh = Watch::default(); + assert_eq!(debounce(synced, &fresh, synced), Debounce::Quiet); + assert_eq!(debounce(synced, &fresh, at(2)), Debounce::Pending(at(2))); + let seen_once = Watch { + pending: Some(at(2)), + handled: None, + }; + assert_eq!( + debounce(synced, &seen_once, at(2)), + Debounce::Settled(at(2)) + ); + // Still being written: a different change starts the count again. + assert_eq!( + debounce(synced, &seen_once, at(3)), + Debounce::Pending(at(3)) + ); + let acted_on = Watch { + pending: None, + handled: Some(at(2)), + }; + assert_eq!(debounce(synced, &acted_on, at(2)), Debounce::Quiet); +} + +#[test] +fn a_save_is_a_remote_edit_on_the_second_run_that_sees_it() { + let host = FakeHost::new(); + let (mut files, key, local) = fetched(&host); + assert!(run(&mut files).is_empty(), "nothing changed"); + + std::fs::write(&local, b"# Edited notes\n").unwrap(); + assert!(run(&mut files).is_empty(), "one run is not enough"); + assert_eq!(files.copies[&key].state, SyncState::Synced); + assert_eq!(run(&mut files), vec![(key.clone(), Action::Ask)]); + assert_eq!( + files.copies[&key].state, + SyncState::Asking { + sha256: sha(b"# Edited notes\n") + } + ); + // The save already acted on stays quiet. + assert!(run(&mut files).is_empty()); + assert!(run(&mut files).is_empty()); + assert!(host.received().is_empty(), "nothing is pushed unasked"); +} + +#[test] +fn a_touch_that_keeps_the_host_s_bytes_is_no_remote_edit() { + let host = FakeHost::new(); + let (mut files, key, local) = fetched(&host); + let synced = files.copies[&key].synced_meta; + let file = std::fs::File::options().write(true).open(&local).unwrap(); + file.set_modified(synced.modified + Duration::from_secs(5)) + .unwrap(); + drop(file); + + assert!(run(&mut files).is_empty()); + assert_eq!(run(&mut files), vec![(key.clone(), Action::Nothing)]); + let copy = &files.copies[&key]; + assert_eq!(copy.state, SyncState::Synced); + assert_eq!( + copy.synced_meta.modified, + synced.modified + Duration::from_secs(5) + ); + assert!( + run(&mut files).is_empty(), + "the touch is now the synced state" + ); +} + +#[test] +fn a_copy_that_is_gone_is_skipped_until_it_returns() { + let host = FakeHost::new(); + let (mut files, key, local) = fetched(&host); + std::fs::write(&local, b"# Edited notes\n").unwrap(); + assert!(run(&mut files).is_empty()); + std::fs::remove_file(&local).unwrap(); + assert!(run(&mut files).is_empty()); + assert_eq!(files.copies[&key].state, SyncState::Synced); + std::fs::write(&local, b"# Edited notes\n").unwrap(); + // Back again, the save counts once, when it has settled. + let second = run(&mut files); + let third = run(&mut files); + assert_eq!([second, third].concat(), vec![(key, Action::Ask)]); +} + +#[test] +fn a_remote_push_sends_the_copy_and_hashes_what_it_sent() { + let host = FakeHost::new(); + let (files, _, local) = fetched(&host); + let expect = Expect::Sha256(sha(NOTES)); + let pushed = push_copy( + &files.io, + &remote(PATH), + &local, + (expect, RemoteFileAccess::Keep), + 1024, + ) + .unwrap(); + assert_eq!(pushed.sha256, sha(NOTES)); + assert_eq!( + pushed.meta, + files.copies.values().next().unwrap().synced_meta + ); + assert_eq!( + host.received(), + vec![Received { + path: PATH.into(), + expect, + like: None, + bytes: NOTES.to_vec(), + }] + ); +} + +#[test] +fn a_remote_copy_over_the_bound_is_not_pushed() { + let host = FakeHost::new(); + let (files, _, local) = fetched(&host); + let limit = NOTES.len() as u64 - 1; + let Err(error) = push_copy( + &files.io, + &remote(PATH), + &local, + (Expect::Any, RemoteFileAccess::Keep), + limit, + ) else { + panic!("a copy over the bound was pushed"); + }; + assert!( + matches!(error, Error::TooLarge { limit: l } if l == limit), + "{error:?}" + ); + assert!(host.received().is_empty()); + let error = Error::TooLarge { + limit: MAX_REMOTE_FILE_BYTES, + }; + assert_eq!( + crate::Error::from(error).to_string(), + "Not uploaded: the copy is larger than 64 MB" + ); + // At the bound exactly, it goes. + let at_bound = NOTES.len() as u64; + push_copy( + &files.io, + &remote(PATH), + &local, + (Expect::Any, RemoteFileAccess::Keep), + at_bound, + ) + .unwrap(); +} + +#[test] +fn the_remote_hashing_reader_counts_and_hashes_what_it_reads() { + let mut reader = Hashing::new(NOTES); + let mut read = Vec::new(); + reader.read_to_end(&mut read).unwrap(); + assert_eq!(read, NOTES); + assert_eq!(reader.finish(), (NOTES.len() as u64, sha(NOTES))); +} + +/// I/O from `host` whose push first runs `meanwhile` on the copy at +/// `local`, as an editor saving during the upload would. +fn saving_during_push(host: &FakeHost, local: PathBuf, meanwhile: fn(&Path)) -> Io { + let io = host.io(notes); + let push = io.push.clone(); + Io { + push: Arc::new(move |remote, expect, access, len, input| { + meanwhile(&local); + push(remote, expect, access, len, input) + }), + ..io + } +} + +#[test] +fn a_remote_copy_that_grows_during_its_push_sends_the_length_it_had() { + let host = FakeHost::new(); + let (_, _, local) = fetched(&host); + let io = saving_during_push(&host, local.clone(), |local| { + let mut file = std::fs::File::options().append(true).open(local).unwrap(); + file.write_all(b"more").unwrap(); + }); + let pushed = push_copy( + &io, + &remote(PATH), + &local, + (Expect::Any, RemoteFileAccess::Keep), + 1024, + ) + .unwrap(); + assert_eq!(host.received()[0].bytes, NOTES, "the staged length only"); + assert_eq!(pushed.sha256, sha(NOTES), "the hash of the bytes sent"); + assert_eq!(pushed.meta.len, NOTES.len() as u64); +} + +#[test] +fn a_remote_copy_rewritten_during_its_push_sends_the_save_staged_before() { + let host = FakeHost::new(); + let (_, _, local) = fetched(&host); + let io = saving_during_push(&host, local.clone(), |local| { + std::fs::write(local, b"#").unwrap(); + }); + let pushed = push_copy( + &io, + &remote(PATH), + &local, + (Expect::Any, RemoteFileAccess::Keep), + 1024, + ) + .unwrap(); + assert_eq!(host.received()[0].bytes, NOTES, "one save, whole"); + assert_eq!(pushed.sha256, sha(NOTES)); + assert_eq!(pushed.meta.len, NOTES.len() as u64); + assert_eq!(std::fs::read(&local).unwrap(), b"#", "the new save stays"); + assert_eq!( + std::fs::read_dir(local.parent().unwrap()).unwrap().count(), + 1, + "no staged file left" + ); +} + +#[test] +fn a_remote_push_that_reads_short_is_cut_off() { + let host = FakeHost::new(); + let (files, _, local) = fetched(&host); + let io = Io { + push: Arc::new(|_, _, _, _, input| { + let mut first = [0; 1]; + input + .read_exact(&mut first) + .map_err(herdr_client::Error::Io)?; + Ok(()) + }), + ..files.io.clone() + }; + let Err(error) = push_copy( + &io, + &remote(PATH), + &local, + (Expect::Any, RemoteFileAccess::Keep), + 1024, + ) else { + panic!("a cut-off push succeeded"); + }; + assert!( + matches!( + error, + Error::Push(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Incomplete + )) + ), + "{error:?}" + ); + assert_eq!( + crate::Error::from(error).to_string(), + "Could not upload the file: the upload was cut off; the host's file is unchanged" + ); +} + +#[test] +fn a_remote_save_made_while_a_click_fetches_is_kept() { + let host = FakeHost::new(); + let (mut files, key, local) = fetched(&host); + let synced = files.copies[&key].synced_meta; + let owner = files.owner; + let (started, on_start) = std::sync::mpsc::channel(); + let (release, on_release) = std::sync::mpsc::channel::<()>(); + let on_release = Mutex::new(on_release); + let io = host.io(notes); + let fetch = io.fetch.clone(); + let gated = Io { + fetch: Arc::new(move |remote, out| { + started.send(()).unwrap(); + on_release.lock().unwrap().recv().unwrap(); + fetch(remote, out) + }), + ..io + }; + let outcome = std::thread::scope(|scope| { + let fetching = scope.spawn(|| fetch_copy(&gated, owner, &remote(PATH), Some(synced))); + on_start.recv().unwrap(); + std::fs::write(&local, b"# Saved meanwhile\n").unwrap(); + release.send(()).unwrap(); + fetching.join().unwrap() + }); + assert!(matches!(outcome, Ok(Fetch::KeptEdits))); + assert_eq!(std::fs::read(&local).unwrap(), b"# Saved meanwhile\n"); + assert_eq!( + std::fs::read_dir(local.parent().unwrap()).unwrap().count(), + 1, + "no .part left" + ); + assert!(run(&mut files).is_empty()); + assert_eq!(run(&mut files), vec![(key, Action::Ask)]); + + // Unsaved since its sync, a copy takes the host's bytes. + let host = FakeHost::new(); + let (files, key, local) = fetched(&host); + host.change_file(b"# Newer\n"); + let synced = Some(files.copies[&key].synced_meta); + let written = fetch_copy(&files.io, files.owner, &remote(PATH), synced).unwrap(); + assert!(matches!(written, Fetch::Written(_))); + assert_eq!(std::fs::read(&local).unwrap(), b"# Newer\n"); +} + +#[test] +fn a_failed_remote_push_says_why() { + let error = Error::Push(conflict().unwrap_err()); + assert!(error.is_conflict()); + assert_eq!( + crate::Error::from(error).to_string(), + "Could not upload the file: the host's file changed since it was fetched" + ); + let missing = Error::Push(missing().unwrap_err()); + assert!(!missing.is_conflict()); +} + +#[test] +fn a_push_that_cannot_keep_the_file_s_access_says_nothing_changed() { + let host = FakeHost::new(); + let (files, _, local) = fetched(&host); + let io = Io { + push: Arc::new(|_, _, _, _, _| { + Err(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Access, + )) + }), + ..files.io.clone() + }; + let rule = (Expect::Any, RemoteFileAccess::Keep); + let Err(error) = push_copy(&io, &remote(PATH), &local, rule, 1024) else { + panic!("a push that could not keep the access succeeded"); + }; + assert!(matches!(error, Error::Access), "{error:?}"); + assert!(!error.is_conflict()); + assert_eq!( + crate::Error::from(error).to_string(), + "Could not keep the file's access; nothing was changed." + ); +} + +#[test] +fn a_push_refused_in_a_shared_folder_says_nothing_changed() { + let host = FakeHost::new(); + let (files, _, local) = fetched(&host); + let io = Io { + push: Arc::new(|_, _, _, _, _| { + Err(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::SharedFolder, + )) + }), + ..files.io.clone() + }; + let rule = (Expect::Any, RemoteFileAccess::Keep); + let Err(error) = push_copy(&io, &remote(PATH), &local, rule, 1024) else { + panic!("a push refused in a shared folder succeeded"); + }; + assert!(matches!(error, Error::SharedFolder), "{error:?}"); + assert!(!error.is_conflict()); + assert_eq!( + crate::Error::from(error).to_string(), + "Other users can replace files in that folder on the host, so it was not written in place; \ + nothing was changed." + ); +} + +#[test] +fn the_remote_copies_are_listed_with_their_state() { + let host = FakeHost::new(); + let (mut files, key, local) = fetched(&host); + files.copies.get_mut(&key).unwrap().policy = Policy::Always; + let listed: Vec<_> = files.list().collect(); + assert_eq!( + listed, + vec![Listed { + target: "me@devbox", + remote_path: PATH, + local: &local, + state: SyncState::Synced, + policy: Policy::Always, + }] + ); +} + +#[test] +fn a_remote_copy_with_edits_is_reopened_rather_than_fetched() { + let host = FakeHost::new(); + let (mut files, key, _) = fetched(&host); + assert!(files.fetch_again(&remote(PATH))); + let h = sha(b"edit"); + for state in [ + SyncState::Edited { sha256: h }, + SyncState::Asking { sha256: h }, + SyncState::Uploading { sha256: h }, + SyncState::Conflict { sha256: h }, + SyncState::Declined { sha256: h }, + ] { + files.copies.get_mut(&key).unwrap().state = state; + assert!(!files.fetch_again(&remote(PATH)), "{state:?}"); + } + assert!(files.fetch_again(&remote("/elsewhere")), "no copy yet"); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/windows_names.rs b/crates/herdr-gpui/src/remote_files/tests/windows_names.rs new file mode 100644 index 000000000..ae61533a5 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/windows_names.rs @@ -0,0 +1,90 @@ +//! A host's file name that a Windows client cannot store is renamed for the +//! local copy alone; every other client keeps the host's name. + +use super::*; +use cache::local_name; + +/// Names a POSIX host may hold, with what a Windows client calls its copy. +const RENAMED: &[(&str, &str)] = &[ + ("ab.txt", "a_b.txt"), + ("a:b.txt", "a_b.txt"), + ("q\"r.txt", "q_r.txt"), + ("a/b.txt", "a_b.txt"), + ("a\\b.txt", "a_b.txt"), + ("a|b.txt", "a_b.txt"), + ("why?.txt", "why_.txt"), + ("*.txt", "_.txt"), + ("a\u{1}b\u{7f}.txt", "a_b_.txt"), + ("notes.", "notes_"), + ("notes ", "notes_"), + ("con", "_con"), + ("con.txt", "_con.txt"), + ("COM1", "_COM1"), + ("lpt9.log", "_lpt9.log"), + ("Nul.tar.gz", "_Nul.tar.gz"), +]; + +/// Names a Windows client stores as they are. +const KEPT: &[&str] = &[ + "console.txt", + "notes.md", + "COM0", + "LPT10.log", + ".bashrc", + "\u{e9}t\u{e9} 2024.txt", +]; + +#[test] +fn a_windows_client_renames_a_name_it_cannot_store() { + for &(host, local) in RENAMED { + assert_eq!(local_name(host, true), local, "{host:?}"); + } +} + +#[test] +fn a_windows_client_keeps_a_name_it_can_store() { + for &name in KEPT { + assert_eq!(local_name(name, true), name, "{name:?}"); + } +} + +#[test] +fn other_clients_keep_every_host_name() { + for name in RENAMED + .iter() + .map(|&(host, _)| host) + .chain(KEPT.iter().copied()) + { + assert_eq!(local_name(name, false), name, "{name:?}"); + } +} + +#[test] +fn the_renamed_copy_still_belongs_to_the_host_s_path() { + let root = tempfile::tempdir().unwrap(); + let cache = Cache::create(root.path()).unwrap(); + let posix = RemoteShell::Posix; + let copy = cache.copy_path(Owner(1), "me@devbox", "/home/me/a:b.txt", posix); + let expected = if cfg!(windows) { "a_b.txt" } else { "a:b.txt" }; + assert_eq!(copy.file_name().unwrap(), expected); + assert_eq!( + basename("/home/me/a:b.txt", posix), + "a:b.txt", + "the host's name" + ); +} + +#[cfg(windows)] +#[test] +fn a_windows_client_renames_a_posix_name_holding_a_backslash() { + let root = tempfile::tempdir().unwrap(); + let cache = Cache::create(root.path()).unwrap(); + let copy = cache.copy_path( + Owner(1), + "me@devbox", + "/srv/foo\\bar.txt", + RemoteShell::Posix, + ); + assert_eq!(copy.file_name().unwrap(), "foo_bar.txt"); +} diff --git a/crates/herdr-gpui/src/remote_files/uploads.rs b/crates/herdr-gpui/src/remote_files/uploads.rs new file mode 100644 index 000000000..c3dce5dad --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/uploads.rs @@ -0,0 +1,312 @@ +//! The window's side of sending saves back: starting watch runs from its +//! poll tick, opening the questions they queue once no page is open, and +//! running the pushes and fetches the answers lead to. + +use super::{ + Answer, Error, Fetch, Key, MAX_REMOTE_FILE_BYTES, Prompt, PromptKind, Resolution, fetch_copy, + push::{Pushed, push_copy}, + sync::{self, Action, Event, Push}, + watch, +}; +use crate::window::{Flash, HerdrWindow, RemotePath}; +use gpui::{Context, Window}; +use herdr_client::{Expect, RemoteFileAccess}; +use std::time::{Duration, Instant}; + +/// The shortest time between two watch runs. +const WATCH_INTERVAL: Duration = Duration::from_secs(1); + +/// The highest `n` a kept-both copy's name tries. +const LAST_CONFLICT_NAME: u8 = 9; + +impl HerdrWindow { + /// Opens a waiting question when no page is open, and starts a watch run + /// when one is due. + pub(crate) fn poll_remote_files(&mut self, window: &mut Window, cx: &mut Context) { + self.show_next_remote_prompt(window, cx); + self.start_remote_watch(window, cx); + } + + /// Closes the open question when its copy left the state it asks about, + /// so no buttons stay up that would do nothing. + fn close_stale_remote_prompt(&mut self, window: &mut Window, cx: &mut Context) { + let Some(shown) = self.shown_remote_file_prompt() else { + return; + }; + if self.remote_files.applies(&shown) { + return; + } + self.close_remote_file_prompt(window, cx); + } + + fn show_next_remote_prompt(&mut self, window: &mut Window, cx: &mut Context) { + let shown = self.shown_remote_file_prompt(); + self.remote_files.requeue_unanswered(shown.as_ref()); + if self.menu.page.is_some() { + return; + } + let Some(prompt) = self.remote_files.next_prompt() else { + return; + }; + if self.show_remote_file_prompt(&prompt, window, cx) { + self.remote_files.prompts.pop_front(); + } + } + + fn start_remote_watch(&mut self, window: &mut Window, cx: &mut Context) { + let files = &mut self.remote_files; + let now = Instant::now(); + if files.watching + || files.copies.is_empty() + || files + .last_watch + .is_some_and(|last| now.duration_since(last) < WATCH_INTERVAL) + { + return; + } + files.last_watch = Some(now); + files.watching = true; + let probes = files.probes(); + let scan = cx + .background_executor() + .spawn(async move { watch::scan(probes) }); + cx.spawn_in(window, async move |this, cx| { + let seen = scan.await; + let _ = this.update_in(cx, |this, window, cx| { + this.remote_files_scanned(seen, window, cx); + }); + }) + .detach(); + } + + fn remote_files_scanned( + &mut self, + seen: Vec, + window: &mut Window, + cx: &mut Context, + ) { + self.remote_files.watching = false; + for (key, action) in self.remote_files.apply_scan(seen) { + self.act_on_remote_file(key, action, cx); + } + // A save back to the synced bytes ends the question about the edits. + self.close_stale_remote_prompt(window, cx); + } + + /// Answers the upload question about `key`'s copy. + pub(crate) fn answer_remote_upload( + &mut self, + key: Key, + answer: Answer, + cx: &mut Context, + ) { + let action = self.remote_files.handle(&key, Event::Answered(answer)); + self.act_on_remote_file(key, action, cx); + } + + /// Answers the conflict question about `key`'s copy. + pub(crate) fn resolve_remote_conflict( + &mut self, + key: Key, + resolution: Resolution, + cx: &mut Context, + ) { + let action = self.remote_files.handle(&key, Event::Resolved(resolution)); + self.act_on_remote_file(key, action, cx); + } + + fn act_on_remote_file(&mut self, key: Key, action: Action, cx: &mut Context) { + match action { + Action::Nothing => {} + Action::Ask => self.remote_files.enqueue(Prompt { + key, + kind: PromptKind::Upload, + }), + Action::ShowConflict => self.remote_files.enqueue(Prompt { + key, + kind: PromptKind::Conflict, + }), + Action::Push(push) => self.start_remote_push(key, push, cx), + Action::Refetch => self.start_remote_refetch(key, cx), + } + cx.notify(); + } + + /// The host file `push` goes to for `key`'s copy, what it expects there, + /// and the host file whose access a new file takes: a kept-both copy + /// takes the original's, so it lets no one read it who could not read + /// that. + fn push_target(&self, key: &Key, push: Push) -> Option<(RemotePath, Expect, Option)> { + let copy = self.remote_files.copies.get(key)?; + let (path, expect, like) = match push { + Push::Expected => (key.1.clone(), Expect::Sha256(copy.synced_sha256), None), + Push::Any => (key.1.clone(), Expect::Any, None), + Push::Beside(n) => ( + sync::conflict_path(&key.1, copy.shell, n), + Expect::Absent, + Some(key.1.clone()), + ), + }; + let remote = RemotePath { + target: key.0.clone(), + shell: copy.shell, + path, + line: None, + }; + Some((remote, expect, like)) + } + + fn start_remote_push(&mut self, key: Key, push: Push, cx: &mut Context) { + let (Some((remote, expect, like)), Some(copy)) = ( + self.push_target(&key, push), + self.remote_files.copies.get(&key), + ) else { + return; + }; + let local = copy.local.clone(); + let io = self.remote_files.io.clone(); + let task = cx.background_executor().spawn(async move { + let access = like + .as_deref() + .map_or(RemoteFileAccess::Keep, RemoteFileAccess::Like); + let pushed = push_copy( + &io, + &remote, + &local, + (expect, access), + MAX_REMOTE_FILE_BYTES, + ); + (remote, pushed) + }); + cx.spawn(async move |this, cx| { + let (remote, pushed) = task.await; + let _ = this.update(cx, |this, cx| { + this.remote_file_pushed(key, push, &remote, pushed, cx); + }); + }) + .detach(); + } + + fn remote_file_pushed( + &mut self, + key: Key, + push: Push, + remote: &RemotePath, + pushed: super::Result, + cx: &mut Context, + ) { + let error = match pushed { + Ok(pushed) => return self.remote_push_done(key, push, remote, pushed, cx), + Err(error) => error, + }; + let event = match push { + Push::Beside(n) if error.is_conflict() && n < LAST_CONFLICT_NAME => { + return self.start_remote_push(key, Push::Beside(n + 1), cx); + } + Push::Beside(_) if error.is_conflict() => { + let last = super::basename(&remote.path, remote.shell).to_owned(); + self.remote_push_failed(&Error::NamesTaken { last }, cx); + Event::Conflicted + } + _ if error.is_conflict() => Event::Conflicted, + _ => { + self.remote_push_failed(&error, cx); + Event::Failed + } + }; + let action = self.remote_files.handle(&key, event); + self.act_on_remote_file(key, action, cx); + } + + fn remote_push_done( + &mut self, + key: Key, + push: Push, + remote: &RemotePath, + pushed: Pushed, + cx: &mut Context, + ) { + let name = super::basename(&remote.path, remote.shell); + let (event, text) = match push { + Push::Beside(_) => ( + Event::KeptBoth, + format!("Uploaded your copy to {} as {name}", remote.target), + ), + Push::Expected | Push::Any => { + self.remote_files.synced(&key, pushed); + ( + Event::Uploaded, + format!("Uploaded {name} to {}", remote.target), + ) + } + }; + self.show_flash(Flash::success(text), cx); + let action = self.remote_files.handle(&key, event); + self.act_on_remote_file(key, action, cx); + } + + fn remote_push_failed(&mut self, error: &Error, cx: &mut Context) { + tracing::warn!(%error, "Remote file copy not uploaded"); + self.show_flash(Flash::warning(error.to_string()), cx); + } + + /// Fetches the host's file over `key`'s copy, dropping its edits, + /// without opening it again. + fn start_remote_refetch(&mut self, key: Key, cx: &mut Context) { + let Some(copy) = self.remote_files.copies.get(&key) else { + return; + }; + let remote = RemotePath { + target: key.0.clone(), + shell: copy.shell, + path: key.1.clone(), + line: None, + }; + // Another fetch writing the copy fails this one rather than race it. + if !self.remote_files.fetching.insert(key.clone()) { + return self.remote_refetch_failed(key, cx); + } + let io = self.remote_files.io.clone(); + let owner = self.remote_files.owner; + let fetch = cx + .background_executor() + // The user chose to drop the copy's edits, so none are kept. + .spawn(async move { fetch_copy(&io, owner, &remote, None) }); + cx.spawn(async move |this, cx| { + let fetched = fetch.await; + let _ = this.update(cx, |this, cx| this.remote_file_refetched(key, fetched, cx)); + }) + .detach(); + } + + fn remote_file_refetched( + &mut self, + key: Key, + fetched: super::Result, + cx: &mut Context, + ) { + self.remote_files.fetching.remove(&key); + let done = match fetched { + Ok(Fetch::Written(done)) => *done, + // A fetch that keeps no edits always writes the copy. + Ok(Fetch::KeptEdits) => return self.remote_refetch_failed(key, cx), + Err(error) => { + tracing::warn!(%error, "Remote file not fetched again"); + self.show_flash(Flash::warning(error.to_string()), cx); + return self.remote_refetch_failed(key, cx); + } + }; + let text = format!( + "Your copy of {} now holds the version on {}", + super::basename(&key.1, done.copy.shell), + key.0 + ); + self.remote_files.record(done); + self.show_flash(Flash::success(text), cx); + } + + fn remote_refetch_failed(&mut self, key: Key, cx: &mut Context) { + let action = self.remote_files.handle(&key, Event::RefetchFailed); + self.act_on_remote_file(key, action, cx); + } +} diff --git a/crates/herdr-gpui/src/remote_files/watch.rs b/crates/herdr-gpui/src/remote_files/watch.rs new file mode 100644 index 000000000..31bd3451c --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/watch.rs @@ -0,0 +1,131 @@ +//! Noticing saves to the copies. A background run stats every copy and +//! hashes one only once two runs in a row saw the same changed metadata, so +//! an editor's write-then-rename save has settled before it counts, without +//! waiting on a clock. Blocking: runs on the background executor. + +use super::{Key, push::Hashing, sync::Sha256}; +use std::{ + io, + path::{Path, PathBuf}, + time::SystemTime, +}; + +/// A copy's modification time and length, which change when it is saved. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Meta { + pub(crate) modified: SystemTime, + pub(crate) len: u64, +} + +impl Meta { + pub(crate) fn of(metadata: &std::fs::Metadata) -> io::Result { + Ok(Self { + modified: metadata.modified()?, + len: metadata.len(), + }) + } +} + +/// What the runs remember of one copy between them. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct Watch { + /// Changed metadata the last run saw, not yet seen twice. + pub(crate) pending: Option, + /// The metadata of the last save acted on. + pub(crate) handled: Option, +} + +/// What one run makes of a copy's metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Debounce { + /// As synced, or the save already acted on. + Quiet, + /// Changed, and not yet seen twice. + Pending(Meta), + /// The same change as the last run saw: a save to hash. + Settled(Meta), +} + +/// What `seen` means for a copy synced at `synced` and watched as `watch`. +pub(crate) fn debounce(synced: Meta, watch: &Watch, seen: Meta) -> Debounce { + if seen == synced || watch.handled == Some(seen) { + Debounce::Quiet + } else if watch.pending == Some(seen) { + Debounce::Settled(seen) + } else { + Debounce::Pending(seen) + } +} + +/// One copy as a run starts on it. +#[derive(Debug, Clone)] +pub(crate) struct Probe { + pub(crate) key: Key, + pub(crate) local: PathBuf, + pub(crate) synced: Meta, + pub(crate) watch: Watch, +} + +/// What a run found for one copy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Seen { + pub(crate) key: Key, + /// The synced metadata the run compared against: a copy synced again + /// meanwhile ignores the finding. + pub(crate) synced: Meta, + pub(crate) change: Change, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Change { + /// Not there, as while an editor's rename is underway, or unreadable. + Skipped, + Quiet, + Pending(Meta), + Settled { + meta: Meta, + sha256: Sha256, + }, +} + +/// Stats each probe's copy, and hashes the ones whose save settled. +pub(crate) fn scan(probes: Vec) -> Vec { + probes + .into_iter() + .map(|probe| Seen { + change: look(&probe), + key: probe.key, + synced: probe.synced, + }) + .collect() +} + +fn look(probe: &Probe) -> Change { + let seen = std::fs::metadata(&probe.local).and_then(|metadata| Meta::of(&metadata)); + let seen = match seen { + Ok(seen) => seen, + Err(error) => return skipped(&probe.local, &error), + }; + match debounce(probe.synced, &probe.watch, seen) { + Debounce::Quiet => Change::Quiet, + Debounce::Pending(meta) => Change::Pending(meta), + Debounce::Settled(meta) => match hash_file(&probe.local) { + Ok(sha256) => Change::Settled { meta, sha256 }, + Err(error) => skipped(&probe.local, &error), + }, + } +} + +/// A copy that is gone is skipped silently; any other failure is logged. +fn skipped(local: &Path, error: &io::Error) -> Change { + if error.kind() != io::ErrorKind::NotFound { + tracing::warn!(%error, copy = %local.display(), "Remote file copy not checked for saves"); + } + Change::Skipped +} + +fn hash_file(path: &Path) -> io::Result { + let mut reader = Hashing::new(std::fs::File::open(path)?); + io::copy(&mut reader, &mut io::sink())?; + Ok(reader.finish().1) +} diff --git a/crates/herdr-gpui/src/sidebar/layout_tests.rs b/crates/herdr-gpui/src/sidebar/layout_tests.rs index 5b271fd95..e0470f115 100644 --- a/crates/herdr-gpui/src/sidebar/layout_tests.rs +++ b/crates/herdr-gpui/src/sidebar/layout_tests.rs @@ -617,6 +617,7 @@ pub(crate) fn fixture_window(window: &mut Window, cx: &mut Context) checkpoints: Default::default(), port_forwards: Default::default(), remote_browser: Vec::new(), + remote_files: Default::default(), #[cfg(feature = "cloud")] cloud_jobs: Default::default(), #[cfg(feature = "cloud")] diff --git a/crates/herdr-gpui/src/window.rs b/crates/herdr-gpui/src/window.rs index f92760037..13f44cc90 100644 --- a/crates/herdr-gpui/src/window.rs +++ b/crates/herdr-gpui/src/window.rs @@ -20,6 +20,7 @@ mod device_themes; mod double_shift; mod file_drop; mod file_links; +pub(crate) use file_links::opened; mod find; mod flash; pub(crate) use commands::run_window_command; @@ -40,6 +41,8 @@ mod plugin_selection; pub(crate) mod prefix; mod reconnecting; mod regions; +mod remote_paths; +pub(crate) use remote_paths::RemotePath; mod render; mod report_issue; mod select_hint; @@ -277,6 +280,8 @@ pub(crate) struct HerdrWindow { pub(crate) regions: Vec, /// Pages remote agents asked for, waiting for their port's forward. pub(crate) remote_browser: Vec, + /// Copies of files on SSH hosts that clicks fetched; see `remote_files`. + pub(crate) remote_files: crate::remote_files::RemoteFiles, /// A `worktree.remove` queued after its dialog closed. pub(crate) removal: Option, /// Herdr's resize mode: direction keys resize the focused pane until @@ -552,6 +557,7 @@ impl HerdrWindow { self.ring_bell(window); self.poll_integrations(cx); self.poll_links(window, cx); + self.poll_remote_files(window, cx); if self.settings.task.is_none() { let mut reload = false; for endpoint in &self.endpoints { diff --git a/crates/herdr-gpui/src/window/construct.rs b/crates/herdr-gpui/src/window/construct.rs index f071f60e6..e911f4cef 100644 --- a/crates/herdr-gpui/src/window/construct.rs +++ b/crates/herdr-gpui/src/window/construct.rs @@ -239,6 +239,7 @@ impl HerdrWindow { pressed_terminal_link: None, regions: Vec::new(), remote_browser: Vec::new(), + remote_files: Default::default(), removal: None, resize_mode: false, review_files_width: crate::panel_resize::REVIEW_FILES, @@ -305,11 +306,12 @@ impl HerdrWindow { }; // Quitting need not drop this window, so its SSH children (port // forwards and listening-port tunnels) are killed here rather than - // left forwarding after the app is gone. - cx.on_app_quit(|this, _| { + // left forwarding after the app is gone. The copies of remote files + // clicks fetched go with it. + cx.on_app_quit(|this, cx| { this.port_forwards.stop_all(); this.tunnels.close_all(); - async {} + this.remote_files.remove_copies(cx) }) .detach(); #[cfg(feature = "integration-test")] diff --git a/crates/herdr-gpui/src/window/file_links.rs b/crates/herdr-gpui/src/window/file_links.rs index 05e81d873..4764853d0 100644 --- a/crates/herdr-gpui/src/window/file_links.rs +++ b/crates/herdr-gpui/src/window/file_links.rs @@ -4,7 +4,8 @@ //! exists off the UI thread, so a word that only looks like a path opens //! nothing. A hyperlink's path is a link without that check, so a click on //! one that is not there says so. A pane on an SSH host prints that host's -//! paths, which this machine cannot open, so none are read there. +//! paths, which a click fetches from there (see `remote_files`), so they +//! are links by their shape alone. A WSL or cloud pane's paths are not read. //! //! Terminal output is untrusted, and a hyperlink's text need not match its //! target, so a click never launches anything. Only a plain folder or a @@ -17,6 +18,7 @@ use super::HerdrWindow; use crate::editor::EditorTarget; use crate::terminal::{PaneLink, RowTarget, pane_link_at}; use gpui::{Context, Pixels, Point}; +use herdr_client::ConnectTarget; use std::{ fs::Metadata, path::{Component, Path, PathBuf, Prefix}, @@ -45,6 +47,9 @@ pub(crate) struct FileLink { /// whether or not its file is there, so a click on a missing one says /// so; a path read from printed text is a link only when it exists. pub(crate) explicit: bool, + /// The SSH target of the host the pane runs on, whose file the path + /// names; `None` for a pane on this machine. + pub(crate) remote: Option, } /// Why a path has no real one. @@ -156,8 +161,8 @@ fn real_path(path: &Path) -> Result { /// What a click on `path` opens, once symlinks are resolved: a plain folder /// or a document itself, and otherwise the nearest folder holding it that /// is not itself a bundle the system would launch. `None` when the path -/// does not exist or leads to another machine. -fn opened(path: &Path) -> Option { +/// does not exist or leads to another machine. Reads the disk. +pub(crate) fn opened(path: &Path) -> Option { let real = real_path(path).ok()?; let metadata = std::fs::metadata(&real).ok()?; let extension = real @@ -208,7 +213,8 @@ fn executable(_: &Metadata) -> bool { impl HerdrWindow { /// The row-local link of either kind under `position` in a pane. Paths - /// count only on a pane this machine runs. + /// count only on a pane this machine runs, or on an SSH host when this + /// client can reach its files. pub(crate) fn local_link_at(&self, position: Point) -> Option { if self.menu.page.is_some() || !self.live.surface_ready() @@ -224,8 +230,16 @@ impl HerdrWindow { self.cell_width, self.config.terminal.line_height(), )?; - (!matches!(link.link.target, RowTarget::Path { .. }) || !self.selected_is_remote()) - .then_some(link) + let paths = !self.selected_is_remote() || self.ssh_target().is_some(); + (!matches!(link.link.target, RowTarget::Path { .. }) || paths).then_some(link) + } + + /// The SSH target the selected endpoint runs on, if it runs on one. + fn ssh_target(&self) -> Option { + match &self.endpoints[self.selected_endpoint].connection.target { + ConnectTarget::Ssh { target, .. } => Some(target.clone()), + _ => None, + } } /// The file path under `position`, with the directory it is relative to. @@ -255,6 +269,7 @@ impl HerdrWindow { pane_id, cwd, explicit, + remote: self.ssh_target(), }) } diff --git a/crates/herdr-gpui/src/window/file_links/tests.rs b/crates/herdr-gpui/src/window/file_links/tests.rs index 0ca19b2dd..d5d42d059 100644 --- a/crates/herdr-gpui/src/window/file_links/tests.rs +++ b/crates/herdr-gpui/src/window/file_links/tests.rs @@ -10,6 +10,9 @@ use herdr_client::{ }; use std::sync::Arc; +mod remote; +mod remote_saves; + #[test] fn paths_resolve_under_home_or_the_pane_directory() { let home = Path::new("home"); @@ -20,6 +23,7 @@ fn paths_resolve_under_home_or_the_pane_directory() { pane_id: "pane".into(), cwd: cwd.map(|cwd| cwd.to_str().unwrap().into()), explicit: false, + remote: None, }; assert_eq!( link("~/notes/a.md", None).resolve(Some(home)), @@ -280,22 +284,36 @@ fn a_link_modifier_click_opens_a_printed_file_that_exists(cx: &mut gpui::TestApp assert_eq!(cx.opened_url().as_deref(), Some(expected.as_str())); } +/// A pane on an SSH host prints that host's paths, which a click fetches from +/// there, so they are links by their shape alone, shown without a check. A +/// WSL pane's are not links at all. #[gpui::test] -fn a_pane_on_an_ssh_host_has_no_file_links(cx: &mut gpui::TestAppContext) { +fn an_ssh_pane_s_paths_are_links_and_a_wsl_pane_s_are_not(cx: &mut gpui::TestAppContext) { let root = tempfile::tempdir().unwrap(); - std::fs::write(root.path().join("a.txt"), "a").unwrap(); - let (view, cx) = window("open ./a.txt", root.path(), cx); + let (view, cx) = window(r"open C:\dev\notes.md", root.path(), cx); let position = at(&view, cx, 7); view.update(cx, |view, _| { - assert!(view.file_link_at(position).is_some()); view.endpoints[view.selected_endpoint].connection.target = ConnectTarget::Ssh { - target: "unused".into(), + target: "me@devbox".into(), + session: "default".into(), + }; + }); + cx.simulate_mouse_move(position, None, Modifiers::secondary_key()); + view.read_with(cx, |view, _| { + let link = view.file_link_at(position); + assert!(link.is_some(), "{link:?}"); + assert!(view.hovered_local_link().is_some()); + }); + + view.update(cx, |view, _| { + view.endpoints[view.selected_endpoint].connection.target = ConnectTarget::Wsl { + distro: "Ubuntu".into(), session: "default".into(), }; assert!(view.file_link_at(position).is_none()); assert!(view.local_link_at(position).is_none()); - assert!(!view.terminal_link_hovered(position, Modifiers::secondary_key())); }); modifier_click(cx, position, Modifiers::secondary_key()); assert!(cx.opened_url().is_none()); + view.read_with(cx, |view, _| assert!(view.flash.is_none())); } diff --git a/crates/herdr-gpui/src/window/file_links/tests/remote.rs b/crates/herdr-gpui/src/window/file_links/tests/remote.rs new file mode 100644 index 000000000..da011f1a4 --- /dev/null +++ b/crates/herdr-gpui/src/window/file_links/tests/remote.rs @@ -0,0 +1,111 @@ +//! Clicks on paths a pane on an SSH host printed, fetched from a fake host. + +use super::*; +use crate::{ + remote_files::tests::{FakeHost, NOTES, missing, notes}, + window::{RemotePath, flash::Tone}, +}; +use herdr_client::RemoteShell; + +pub(super) const TARGET: &str = "me@devbox"; + +/// A window whose SSH pane prints `text` in `cwd`, fetching from `host`. +pub(super) fn on_host<'a>( + host: &FakeHost, + reply: fn() -> herdr_client::Result<&'static [u8]>, + text: &str, + cwd: &str, + cx: &'a mut gpui::TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + let (view, cx) = window(text, Path::new(cwd), cx); + view.update(cx, |view, _| { + view.endpoints[view.selected_endpoint].connection.target = ConnectTarget::Ssh { + target: TARGET.into(), + session: "default".into(), + }; + host.serve(&mut view.remote_files, reply); + }); + (view, cx) +} + +fn asked(shell: RemoteShell, path: &str, line: Option) -> Vec { + vec![RemotePath { + target: TARGET.into(), + shell, + path: path.into(), + line, + }] +} + +#[gpui::test] +fn a_click_on_a_printed_drive_path_fetches_it_and_opens_the_copy(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_host(&host, notes, r"see C:\dev\notes.md:3 now", r"C:\dev", cx); + let path_cell = at(&view, cx, 8); + modifier_click(cx, path_cell, Modifiers::secondary_key()); + + let path = r"C:\dev\notes.md"; + assert_eq!(host.asked(), asked(RemoteShell::PowerShell, path, Some(3))); + let opened = cx.opened_url().unwrap(); + assert!( + opened.starts_with("file://") && opened.ends_with("/notes.md"), + "{opened}" + ); + view.read_with(cx, |view, _| { + let copy = view.remote_files.copy(TARGET, path).unwrap(); + assert_eq!(std::fs::read(©.local).unwrap(), NOTES); + assert_eq!(copy.shell, RemoteShell::PowerShell); + let real = real_path(©.local).unwrap(); + assert_eq!(opened, url::Url::from_file_path(real).unwrap().as_str()); + let (flash, _) = view.flash.as_ref().unwrap(); + assert_eq!(flash.text.as_ref(), "Fetching notes.md from me@devbox…"); + }); + + // Another click fetches the host's file again into the same copy. + let path_cell = at(&view, cx, 8); + modifier_click(cx, path_cell, Modifiers::secondary_key()); + assert_eq!(host.asked().len(), 2); + assert!(cx.opened_url().unwrap().ends_with("/notes.md")); +} + +#[gpui::test] +fn a_relative_path_is_asked_for_under_the_pane_s_windows_directory(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_host(&host, notes, r"open src\main.rs", r"C:\dev\app", cx); + let path_cell = at(&view, cx, 7); + modifier_click(cx, path_cell, Modifiers::secondary_key()); + assert_eq!( + host.asked(), + asked(RemoteShell::PowerShell, r"C:\dev\app\src\main.rs", None) + ); + assert!(cx.opened_url().unwrap().ends_with("/main.rs")); +} + +#[gpui::test] +fn a_home_path_on_a_posix_host_is_left_for_it_to_expand(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_host(&host, notes, "open ~/x.md", "/home/me", cx); + let path_cell = at(&view, cx, 7); + modifier_click(cx, path_cell, Modifiers::secondary_key()); + assert_eq!(host.asked(), asked(RemoteShell::Posix, "~/x.md", None)); + assert!(cx.opened_url().unwrap().ends_with("/x.md")); +} + +#[gpui::test] +fn a_failed_fetch_says_why_and_opens_nothing(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_host(&host, missing, "open /home/me/gone.md", "/home/me", cx); + let path_cell = at(&view, cx, 7); + modifier_click(cx, path_cell, Modifiers::secondary_key()); + assert_eq!(host.asked().len(), 1); + assert!(cx.opened_url().is_none()); + view.read_with(cx, |view, _| { + let (flash, _) = view.flash.as_ref().unwrap(); + assert_eq!(flash.tone, Tone::Warning); + assert_eq!( + flash.text.as_ref(), + "Could not fetch the file: the file is not on the host" + ); + assert!(view.remote_files.copy(TARGET, "/home/me/gone.md").is_none()); + }); +} diff --git a/crates/herdr-gpui/src/window/file_links/tests/remote_saves.rs b/crates/herdr-gpui/src/window/file_links/tests/remote_saves.rs new file mode 100644 index 000000000..bc76ab1ed --- /dev/null +++ b/crates/herdr-gpui/src/window/file_links/tests/remote_saves.rs @@ -0,0 +1,353 @@ +//! Saves to the copy of a file an SSH pane printed, sent back to a fake +//! host. Watch runs are started by hand, with the once-a-second gate +//! rewound, so nothing here waits on a clock. + +use super::remote::{TARGET, on_host}; +use super::*; +use crate::{ + menu::Page, + remote_files::{ + SyncState, + tests::{FakeHost, NOTES, Received, conflict, cut_off, notes}, + }, + window::flash::Tone, +}; +use herdr_client::Expect; +use sha2::{Digest, Sha256}; + +const PATH: &str = "/home/me/notes.md"; +const EDITED: &[u8] = b"# Notes, edited\n"; + +fn sha(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +/// A host that refuses a push for a reason other than a conflict. +fn refused() -> herdr_client::Result<()> { + Err(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Missing, + )) +} + +/// A window that fetched `PATH` from `host` with a click on it. +fn fetched<'a>( + host: &FakeHost, + cx: &'a mut gpui::TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + let (view, cx) = on_host(host, notes, &format!("open {PATH}"), "/home/me", cx); + click_path(&view, cx); + assert_eq!(host.asked().len(), 1); + (view, cx) +} + +fn click_path(view: &Entity, cx: &mut VisualTestContext) { + let path_cell = at(view, cx, 7); + modifier_click(cx, path_cell, Modifiers::secondary_key()); +} + +fn local(view: &Entity, cx: &mut VisualTestContext) -> PathBuf { + view.read_with(cx, |view, _| { + view.remote_files.copy(TARGET, PATH).unwrap().local.clone() + }) +} + +fn state(view: &Entity, cx: &mut VisualTestContext) -> SyncState { + view.read_with(cx, |view, _| { + view.remote_files.copy(TARGET, PATH).unwrap().state + }) +} + +fn page(view: &Entity, cx: &mut VisualTestContext) -> Option { + view.read_with(cx, |view, _| view.menu.page) +} + +fn flash(view: &Entity, cx: &mut VisualTestContext) -> (Tone, String) { + view.read_with(cx, |view, _| { + let (flash, _) = view.flash.as_ref().unwrap(); + (flash.tone, flash.text.to_string()) + }) +} + +/// One poll of the window, with a watch run due, and its work finished. +fn poll(view: &Entity, cx: &mut VisualTestContext) { + view.update_in(cx, |view, window, cx| { + view.remote_files.rewind_watch(); + view.poll_remote_files(window, cx); + }); + cx.run_until_parked(); +} + +/// Saves `bytes` to the copy and polls until a save that settled would +/// have opened its question: two runs to settle it, one more to open it. +fn save(view: &Entity, cx: &mut VisualTestContext, bytes: &[u8]) { + std::fs::write(local(view, cx), bytes).unwrap(); + for _ in 0..3 { + poll(view, cx); + } +} + +fn press(cx: &mut VisualTestContext, id: &'static str) { + cx.update(|window, cx| crate::sidebar::layout_tests::full_draw(window, cx).clear(cx)); + let button = cx.debug_bounds(id).unwrap().center(); + cx.simulate_click(button, Modifiers::default()); + cx.run_until_parked(); +} + +fn pushed(path: &str, expect: Expect, bytes: &[u8]) -> Received { + Received { + path: path.into(), + expect, + like: None, + bytes: bytes.to_vec(), + } +} + +/// A kept-both push to `path`, which takes the access of the host's `PATH`. +fn pushed_beside(path: &str, bytes: &[u8]) -> Received { + Received { + like: Some(PATH.into()), + ..pushed(path, Expect::Absent, bytes) + } +} + +#[gpui::test] +fn a_remote_save_asks_and_upload_pushes_it_once(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = fetched(&host, cx); + save(&view, cx, EDITED); + assert_eq!(page(&view, cx), Some(Page::RemoteUpload)); + assert!(host.received().is_empty(), "nothing goes unasked"); + cx.update(|window, cx| crate::sidebar::layout_tests::full_draw(window, cx).clear(cx)); + assert!(cx.debug_bounds("remote-upload-path").is_some()); + + press(cx, "remote-upload-yes"); + assert_eq!( + host.received(), + vec![pushed(PATH, Expect::Sha256(sha(NOTES)), EDITED)] + ); + assert_eq!(page(&view, cx), None); + assert_eq!(state(&view, cx), SyncState::Synced); + assert_eq!( + flash(&view, cx), + (Tone::Success, "Uploaded notes.md to me@devbox".into()) + ); + view.read_with(cx, |view, _| { + let copy = view.remote_files.copy(TARGET, PATH).unwrap(); + assert_eq!(copy.synced_sha256, sha(EDITED)); + }); + for _ in 0..3 { + poll(&view, cx); + } + assert_eq!(host.received().len(), 1, "the pushed save is synced"); + assert_eq!(page(&view, cx), None); +} + +#[gpui::test] +fn after_upload_future_saves_a_remote_save_pushes_without_asking(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = fetched(&host, cx); + save(&view, cx, EDITED); + press(cx, "remote-upload-always"); + assert_eq!(host.received().len(), 1); + + let again: &[u8] = b"# Notes, edited twice\n"; + save(&view, cx, again); + assert_eq!(page(&view, cx), None, "no question"); + assert_eq!( + host.received(), + vec![ + pushed(PATH, Expect::Sha256(sha(NOTES)), EDITED), + pushed(PATH, Expect::Sha256(sha(EDITED)), again), + ] + ); + assert_eq!(state(&view, cx), SyncState::Synced); +} + +#[gpui::test] +fn not_now_keeps_quiet_until_the_remote_copy_changes_again(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = fetched(&host, cx); + save(&view, cx, EDITED); + cx.simulate_keystrokes("escape"); + assert_eq!(page(&view, cx), None); + assert_eq!( + state(&view, cx), + SyncState::Declined { + sha256: sha(EDITED) + } + ); + + // The same bytes saved again ask nothing. + let copy = local(&view, cx); + let modified = std::fs::metadata(©).unwrap().modified().unwrap(); + std::fs::File::options() + .write(true) + .open(©) + .unwrap() + .set_modified(modified + std::time::Duration::from_secs(5)) + .unwrap(); + for _ in 0..3 { + poll(&view, cx); + } + assert_eq!(page(&view, cx), None); + + save(&view, cx, b"# Notes, edited again\n"); + assert_eq!(page(&view, cx), Some(Page::RemoteUpload)); + press(cx, "remote-upload-no"); + assert!(host.received().is_empty()); +} + +/// A window whose upload of a save met a host file that changed. +fn conflicted<'a>( + host: &FakeHost, + cx: &'a mut gpui::TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + let (view, cx) = fetched(host, cx); + host.answer_pushes(&[conflict]); + save(&view, cx, EDITED); + press(cx, "remote-upload-yes"); + assert_eq!( + state(&view, cx), + SyncState::Conflict { + sha256: sha(EDITED) + } + ); + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteConflict)); + (view, cx) +} + +#[gpui::test] +fn overwrite_anyway_pushes_the_remote_copy_over_whatever_is_there(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = conflicted(&host, cx); + press(cx, "remote-conflict-overwrite"); + assert_eq!( + host.received(), + vec![ + pushed(PATH, Expect::Sha256(sha(NOTES)), EDITED), + pushed(PATH, Expect::Any, EDITED), + ] + ); + assert_eq!(state(&view, cx), SyncState::Synced); + assert_eq!(page(&view, cx), None); +} + +#[gpui::test] +fn re_fetch_replaces_the_remote_copy_with_the_host_s_file(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = conflicted(&host, cx); + let theirs: &[u8] = b"# Notes, changed on the host\n"; + host.change_file(theirs); + press(cx, "remote-conflict-refetch"); + assert_eq!(host.asked().len(), 2); + assert_eq!(std::fs::read(local(&view, cx)).unwrap(), theirs); + assert_eq!(state(&view, cx), SyncState::Synced); + assert_eq!( + flash(&view, cx), + ( + Tone::Success, + "Your copy of notes.md now holds the version on me@devbox".into() + ) + ); + assert_eq!(host.received().len(), 1, "nothing more is pushed"); + // The new bytes are the synced ones: no save is seen. + for _ in 0..3 { + poll(&view, cx); + } + assert_eq!(page(&view, cx), None); +} + +#[gpui::test] +fn keep_both_pushes_the_remote_copy_beside_under_a_free_name(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = conflicted(&host, cx); + host.answer_pushes(&[conflict]); + press(cx, "remote-conflict-keep-both"); + assert_eq!( + host.received()[1..], + [ + pushed_beside("/home/me/notes.conflict.md", EDITED), + pushed_beside("/home/me/notes.conflict-2.md", EDITED), + ] + ); + assert_eq!( + flash(&view, cx), + ( + Tone::Success, + "Uploaded your copy to me@devbox as notes.conflict-2.md".into() + ) + ); + // The copy still tracks the host's original path. + assert!(view.read_with(cx, |view, _| view.remote_files.copy(TARGET, PATH).is_some())); + assert_eq!(page(&view, cx), None); +} + +#[gpui::test] +fn a_remote_upload_question_waits_for_the_open_page(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = fetched(&host, cx); + view.update_in(cx, |view, window, cx| view.show_install_modal(window, cx)); + save(&view, cx, EDITED); + assert_eq!(page(&view, cx), Some(Page::Install), "the page is kept"); + assert!(matches!(state(&view, cx), SyncState::Asking { .. })); + + cx.simulate_keystrokes("escape"); + assert_eq!(page(&view, cx), None); + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteUpload)); + assert!(host.received().is_empty()); +} + +#[gpui::test] +fn a_click_on_a_remote_path_with_unsent_edits_reopens_the_copy(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = fetched(&host, cx); + host.answer_pushes(&[refused]); + save(&view, cx, EDITED); + cx.simulate_keystrokes("enter"); + assert_eq!(host.received().len(), 1); + assert_eq!( + flash(&view, cx), + ( + Tone::Warning, + "Could not upload the file: the file is not on the host".into() + ) + ); + assert_eq!( + state(&view, cx), + SyncState::Edited { + sha256: sha(EDITED) + } + ); + + click_path(&view, cx); + assert_eq!(host.asked().len(), 1, "not fetched again"); + assert!(cx.opened_url().unwrap().ends_with("/notes.md")); + assert_eq!(std::fs::read(local(&view, cx)).unwrap(), EDITED); +} + +#[gpui::test] +fn a_cut_off_remote_upload_warns_and_the_next_save_asks_again(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = fetched(&host, cx); + host.answer_pushes(&[cut_off]); + save(&view, cx, EDITED); + press(cx, "remote-upload-yes"); + assert_eq!( + flash(&view, cx), + ( + Tone::Warning, + "Could not upload the file: the upload was cut off; the host's file is unchanged" + .into() + ) + ); + assert_eq!( + state(&view, cx), + SyncState::Edited { + sha256: sha(EDITED) + } + ); + save(&view, cx, b"# Notes, edited once more\n"); + assert_eq!(page(&view, cx), Some(Page::RemoteUpload)); +} diff --git a/crates/herdr-gpui/src/window/links.rs b/crates/herdr-gpui/src/window/links.rs index a21a3b0c6..42ee0aec7 100644 --- a/crates/herdr-gpui/src/window/links.rs +++ b/crates/herdr-gpui/src/window/links.rs @@ -12,7 +12,7 @@ //! this client opens the address or file itself. A handler that fails spends //! the click. Daemons without these methods keep the row-local detector. -use super::{HerdrWindow, file_links::FileLink}; +use super::{HerdrWindow, RemotePath, file_links::FileLink}; use crate::terminal::PaneLink; use crate::{ browser::WebUrl, @@ -216,6 +216,11 @@ impl HerdrWindow { /// first question about a path starts a check on the background /// executor, whose answer is applied to wherever the pointer is then. fn file_found(&mut self, link: FileLink, cx: &mut Context) -> bool { + // A host's file counts by its shape: asking the host on every hover + // would cost an SSH round trip each time. + if link.remote.is_some() { + return RemotePath::from_link(&link).is_some(); + } if let Some((checked, found)) = &self.links.checked && *checked == link { @@ -248,16 +253,21 @@ impl HerdrWindow { /// disk again, and opens nothing for a printed path that is not there. fn clickable_file(&self, link: PaneLink) -> Option { let file = self.file_link(link)?; + if file.remote.is_some() { + return RemotePath::from_link(&file).map(|_| file); + } let missing = matches!(&self.links.checked, Some((checked, Some(false))) if *checked == file); (file.explicit || !missing).then_some(file) } /// Whether a press is on a link known to be one: anything but a printed - /// path whose check has not found it yet, which may name nothing. + /// path whose check has not found it yet, which may name nothing. A + /// host's file is one by its shape, as for a hover. pub(crate) fn verified_press(&self, press: &PressedLink) -> bool { press.file.as_ref().is_none_or(|file| { file.explicit + || file.remote.is_some() || matches!(&self.links.checked, Some((checked, Some(true))) if checked == file) }) } @@ -563,6 +573,13 @@ impl HerdrWindow { ) { match link { Some(Fallback::Web(url)) => self.open_web_link(url, in_tab, window, cx), + // The terminal editor never opens a pane on another host, so a + // host's file always opens in the system's application. + Some(Fallback::File { link, .. }) if link.remote.is_some() => { + if let Some(remote) = RemotePath::from_link(&link) { + self.open_remote_file(remote, cx); + } + } Some(Fallback::File { link, in_editor }) => self.open_file_link(link, in_editor, cx), Some(Fallback::External(url)) if url.trusted() => cx.open_url(url.as_str()), Some(Fallback::External(url)) => self.ask_to_open_link(url, window, cx), diff --git a/crates/herdr-gpui/src/window/links/tests/file_handlers.rs b/crates/herdr-gpui/src/window/links/tests/file_handlers.rs index b659b8ee6..db005f9a5 100644 --- a/crates/herdr-gpui/src/window/links/tests/file_handlers.rs +++ b/crates/herdr-gpui/src/window/links/tests/file_handlers.rs @@ -197,26 +197,32 @@ fn a_resolved_file_hyperlink_keeps_its_local_fallback(cx: &mut gpui::TestAppCont view.read_with(cx, |view, _| assert!(view.links.activating.is_none())); } -/// A pane on an SSH host prints that host's `file://` links, which never open -/// here, yet a quick click, before any hover resolved it, still reaches the -/// daemon so a handler on that host can claim it. Declined, nothing opens. +/// A pane on an SSH host prints that host's `file://` links. A quick click, +/// before any hover resolved one, still reaches the daemon first so a handler +/// on that host can claim it. Declined, the file is fetched from the host and +/// its copy opens here. A Windows client reads no path from a `file://` URL +/// naming a POSIX path, so there the link is no file and opens nothing. #[gpui::test] fn a_remote_file_hyperlink_still_reaches_plugin_handlers(cx: &mut gpui::TestAppContext) { + use crate::remote_files::tests::{FakeHost, notes}; + let opens = !cfg!(windows); + let host = FakeHost::new(); let mut peer = MockPeer::advertising(&["pane.link.resolve", "pane.link.activate"]); let (view, cx) = window(&peer, true, cx); - let url = "file://devbox/home/dev/notes.md"; + let url = "file:///home/dev/notes.md"; view.update(cx, |view, _| { view.live.surface = Some(hyperlinked(url)); let selected = view.selected_endpoint; view.endpoints[selected].connection.target = herdr_client::ConnectTarget::Ssh { - target: "devbox.invalid".into(), + target: "dev@devbox.invalid".into(), session: "default".into(), }; + host.serve(&mut view.remote_files, notes); assert!(view.selected_is_remote()); }); let link = at(&view, cx, 3, 0); view.read_with(cx, |view, _| { - assert!(view.file_link_at(link).is_none(), "not this machine's file"); + assert_eq!(view.file_link_at(link).is_some(), opens, "the host's file"); assert!(view.hovered_daemon_link().is_none()); }); @@ -224,6 +230,7 @@ fn a_remote_file_hyperlink_still_reaches_plugin_handlers(cx: &mut gpui::TestAppC cx.simulate_click(link, Modifiers::secondary_key()); let request = activation(&mut peer); assert_eq!(request["params"]["col"], 3); + assert!(host.asked().is_empty(), "the daemon answers first"); reply( &mut peer, &view, @@ -231,7 +238,23 @@ fn a_remote_file_hyperlink_still_reaches_plugin_handlers(cx: &mut gpui::TestAppC &request, json!({"result": {"type": "pane_link_activated", "url": url, "handled": handled}}), ); - assert!(cx.opened_url().is_none(), "handled={handled}"); + if handled || !opens { + assert!(cx.opened_url().is_none(), "handled={handled}"); + assert!(host.asked().is_empty(), "handled={handled}"); + } + } + if opens { + assert_eq!( + host.asked(), + vec![RemotePath { + target: "dev@devbox.invalid".into(), + shell: herdr_client::RemoteShell::Posix, + path: "/home/dev/notes.md".into(), + line: None, + }] + ); + let opened = cx.opened_url().unwrap(); + assert!(opened.ends_with("/notes.md"), "{opened}"); } // Without the modifier the click stays the pane's, as on any hyperlink // this client cannot open. diff --git a/crates/herdr-gpui/src/window/remote_paths.rs b/crates/herdr-gpui/src/window/remote_paths.rs new file mode 100644 index 000000000..462981f4e --- /dev/null +++ b/crates/herdr-gpui/src/window/remote_paths.rs @@ -0,0 +1,120 @@ +//! The path an SSH host reads for a file path one of its panes printed, and +//! the shell it reads it in. A Windows OpenSSH host prints drive and UNC +//! paths with either separator and reads them in PowerShell; any other host +//! reads POSIX paths in `/bin/sh`. Nothing here asks the host anything: `~` +//! and `..` are left for the host to resolve. + +use super::file_links::FileLink; +use herdr_client::RemoteShell; + +/// A file on an SSH host, as the host's fetch script names it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RemotePath { + /// The `[user@]host` SSH target the pane's endpoint runs on. + pub(crate) target: String, + pub(crate) shell: RemoteShell, + /// An absolute path in the shell's own form, or one under `~`. + pub(crate) path: String, + /// The line printed after the path. + pub(crate) line: Option, +} + +impl RemotePath { + /// The host's file `link` names, when the pane runs on an SSH host and + /// the path is absolute, under `~`, or relative to an absolute working + /// directory. + pub(crate) fn from_link(link: &FileLink) -> Option { + let target = link.remote.clone()?; + let (shell, path) = resolve(&link.path, link.cwd.as_deref())?; + Some(Self { + target, + shell, + path, + line: link.line, + }) + } + + /// The host's file, as the transfer functions name it. + pub(crate) fn remote_file(&self) -> herdr_client::RemoteFile<'_> { + herdr_client::RemoteFile { + target: &self.target, + shell: self.shell, + path: &self.path, + } + } +} + +/// The shell and the absolute or `~` path for `path` as printed under `cwd`. +fn resolve(path: &str, cwd: Option<&str>) -> Option<(RemoteShell, String)> { + let path = file_url_drive(path); + if windows(path) { + return Some((RemoteShell::PowerShell, backslashes(path))); + } + if path.starts_with('/') { + return Some((RemoteShell::Posix, path.to_owned())); + } + let cwd = cwd.map(file_url_drive); + if let Some(rest) = path.strip_prefix('~') { + // `~user` names another account's home, which is not resolved. + if !(rest.is_empty() || rest.starts_with(['/', '\\'])) { + return None; + } + let powershell = cwd.map_or(rest.starts_with('\\'), windows); + return Some(if powershell { + (RemoteShell::PowerShell, backslashes(path)) + } else { + (RemoteShell::Posix, path.to_owned()) + }); + } + // Empty, or relative to the root of a drive no cwd names. + if path.is_empty() || path.starts_with('\\') { + return None; + } + let relative = without_current(path); + let cwd = cwd?; + if windows(cwd) { + let cwd = backslashes(cwd); + let folder = cwd.trim_end_matches('\\'); + Some(( + RemoteShell::PowerShell, + format!("{folder}\\{}", backslashes(relative)), + )) + } else if cwd.starts_with('/') { + let folder = cwd.trim_end_matches('/'); + Some((RemoteShell::Posix, format!("{folder}/{relative}"))) + } else { + None + } +} + +/// Whether `path` starts with a drive and a separator, as in `C:\` or `c:/`, +/// or is a UNC path such as `\\server\share`. +fn windows(path: &str) -> bool { + path.starts_with(r"\\") + || matches!(path.as_bytes(), [letter, b':', b'\\' | b'/', ..] if letter.is_ascii_alphabetic()) +} + +/// A `file://` address's drive path, which reads `/C:/…` on a Unix client, +/// as the drive path `C:/…`; any other path unchanged. +fn file_url_drive(path: &str) -> &str { + match path.strip_prefix('/') { + Some(rest) if windows(rest) && !rest.starts_with('\\') => rest, + _ => path, + } +} + +/// `path` without the `./` or `.\` that only says it is relative. +fn without_current(mut path: &str) -> &str { + while let Some(rest) = path.strip_prefix("./").or_else(|| path.strip_prefix(r".\")) { + path = rest; + } + path +} + +fn backslashes(path: &str) -> String { + path.replace('/', "\\") +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests; diff --git a/crates/herdr-gpui/src/window/remote_paths/tests.rs b/crates/herdr-gpui/src/window/remote_paths/tests.rs new file mode 100644 index 000000000..544d5911e --- /dev/null +++ b/crates/herdr-gpui/src/window/remote_paths/tests.rs @@ -0,0 +1,161 @@ +use super::*; + +fn link(path: &str, cwd: Option<&str>) -> FileLink { + FileLink { + path: path.into(), + line: None, + pane_id: "pane".into(), + cwd: cwd.map(Into::into), + explicit: false, + remote: Some("me@devbox".into()), + } +} + +/// The shell and path the host is asked for, or `None` when there is none. +fn asked(path: &str, cwd: Option<&str>) -> Option<(RemoteShell, String)> { + RemotePath::from_link(&link(path, cwd)).map(|remote| (remote.shell, remote.path)) +} + +fn powershell(path: &str) -> Option<(RemoteShell, String)> { + Some((RemoteShell::PowerShell, path.into())) +} + +fn posix(path: &str) -> Option<(RemoteShell, String)> { + Some((RemoteShell::Posix, path.into())) +} + +#[test] +fn a_local_pane_has_no_remote_path() { + let local = FileLink { + remote: None, + ..link("/home/me/a.md", None) + }; + assert_eq!(RemotePath::from_link(&local), None); +} + +#[test] +fn a_drive_path_is_read_in_powershell_with_backslashes() { + assert_eq!( + asked(r"C:\dev\notes.md", None), + powershell(r"C:\dev\notes.md") + ); + assert_eq!( + asked("c:/dev/notes.md", None), + powershell(r"c:\dev\notes.md") + ); + assert_eq!( + asked(r"D:/dev\sub/x.rs", Some("/home/me")), + powershell(r"D:\dev\sub\x.rs") + ); +} + +#[test] +fn a_drive_path_keeps_its_target_and_line() { + let remote = RemotePath::from_link(&FileLink { + line: Some(42), + ..link(r"C:\dev\notes.md", None) + }) + .unwrap(); + assert_eq!( + remote, + RemotePath { + target: "me@devbox".into(), + shell: RemoteShell::PowerShell, + path: r"C:\dev\notes.md".into(), + line: Some(42), + } + ); +} + +#[test] +fn a_file_url_drive_path_loses_its_leading_slash() { + assert_eq!( + asked("/C:/Users/me/a.md", None), + powershell(r"C:\Users\me\a.md") + ); + // A relative path under such a cwd joins it as a drive path too. + assert_eq!( + asked("a.md", Some("/C:/Users/me")), + powershell(r"C:\Users\me\a.md") + ); +} + +#[test] +fn a_unc_path_is_read_in_powershell() { + assert_eq!( + asked(r"\\server\share\a.md", None), + powershell(r"\\server\share\a.md") + ); + assert_eq!( + asked("a.md", Some(r"\\server\share")), + powershell(r"\\server\share\a.md") + ); +} + +#[test] +fn a_posix_absolute_path_is_kept_as_is() { + assert_eq!(asked("/home/me/x.md", None), posix("/home/me/x.md")); + assert_eq!( + asked("/srv/a b/../x.md", Some(r"C:\dev")), + posix("/srv/a b/../x.md") + ); +} + +#[test] +fn a_home_path_is_left_for_the_host_to_expand() { + assert_eq!(asked("~/x.md", Some("/home/me")), posix("~/x.md")); + assert_eq!(asked("~/x.md", None), posix("~/x.md")); + assert_eq!(asked("~", None), posix("~")); + // A Windows host reads it in PowerShell, by the cwd or the separator. + assert_eq!(asked("~/x.md", Some(r"C:\Users\me")), powershell(r"~\x.md")); + assert_eq!(asked(r"~\x.md", None), powershell(r"~\x.md")); + // Another account's home is not resolved. + assert_eq!(asked("~other/x.md", Some("/home/me")), None); +} + +#[test] +fn a_relative_path_joins_a_posix_cwd_with_slashes() { + assert_eq!( + asked("src/main.rs", Some("/home/me/app")), + posix("/home/me/app/src/main.rs") + ); + assert_eq!( + asked("./src/main.rs", Some("/home/me/app/")), + posix("/home/me/app/src/main.rs") + ); + assert_eq!( + asked("../x.md", Some("/home/me/app")), + posix("/home/me/app/../x.md") + ); + assert_eq!(asked("a.md", Some("/")), posix("/a.md")); +} + +#[test] +fn a_relative_path_joins_a_windows_cwd_with_backslashes() { + assert_eq!( + asked(r"src\main.rs", Some(r"C:\dev\app")), + powershell(r"C:\dev\app\src\main.rs") + ); + assert_eq!( + asked("src/main.rs", Some("C:/dev/app/")), + powershell(r"C:\dev\app\src\main.rs") + ); + assert_eq!( + asked(r".\src\main.rs", Some(r"C:\dev\app")), + powershell(r"C:\dev\app\src\main.rs") + ); + assert_eq!( + asked(r"..\x.md", Some(r"C:\dev\app")), + powershell(r"C:\dev\app\..\x.md") + ); + assert_eq!(asked("a.md", Some(r"C:\")), powershell(r"C:\a.md")); +} + +#[test] +fn a_relative_path_without_an_absolute_cwd_has_no_remote_path() { + assert_eq!(asked("src/main.rs", None), None); + assert_eq!(asked("src/main.rs", Some("relative/dir")), None); + assert_eq!(asked("src/main.rs", Some("C:relative")), None); + assert_eq!(asked("", Some("/home/me")), None); + assert_eq!(asked(r"\dev\x.md", Some(r"C:\dev")), None); +} diff --git a/docs/cli.md b/docs/cli.md index bd57dc317..927cda0dd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -154,6 +154,7 @@ and builds without embedded pages return exit 8. See [px0](../crates/herdr-gpui/ | `cloud wait BATCH [--timeout S]` | Each machine of a batch once all are settled, or after S seconds (default 60, at most 600) | | `cloud batches` | The batches the app keeps, newest last; the oldest are left out when they would not fit in one answer | | `processes [--pane ID]` | The processes under a pane, with CPU and memory (no app needed) | +| `remote-files [list]` | Copies of SSH hosts' files that link clicks in this window fetched, and whether each is synced, edited, or in conflict. Uploads are confirmed in the app | | `search symbol\|file QUERY` | Go to Symbol or Go to File over the current checkout (no app needed) | ### Settings and services