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/Cargo.toml b/Cargo.toml index a0d501734..35437f588 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ objc = "0.2" # The Secret Service client GPUI's Linux platform links, with its async-io backend. oo7 = { default-features = false, features = ["async-std", "native_crypto"], version = "0.6" } objc2 = "0.6.4" -objc2-app-kit = { default-features = false, features = ["NSApplication", "NSColor", "NSDockTile", "NSDragging", "NSEvent", "NSGraphics", "NSGraphicsContext", "NSImage", "NSImageRep", "NSPasteboard", "NSPasteboardItem", "NSResponder", "NSTextInputContext", "NSTrackingArea", "NSView", "NSWindow", "block2", "objc2-core-foundation", "std"], version = "0.3.2" } +objc2-app-kit = { default-features = false, features = ["NSApplication", "NSColor", "NSDockTile", "NSDragging", "NSEvent", "NSGraphics", "NSGraphicsContext", "NSImage", "NSImageRep", "NSPasteboard", "NSPasteboardItem", "NSResponder", "NSTextInputContext", "NSTrackingArea", "NSView", "NSWindow", "NSWorkspace", "block2", "objc2-core-foundation", "std"], version = "0.3.2" } objc2-foundation = { default-features = false, features = ["NSArray", "NSBundle", "NSData", "NSError", "NSProcessInfo", "NSString", "NSURL", "std"], version = "0.3.2" } # The WebKit snapshot behind page annotation screenshots and the popups pages # open, at the versions wry uses. 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..9c40c74e3 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, @@ -2260,10 +2260,21 @@ its dashes turn solid once its target is verified: - **A `file://` address on another host**, or a target that is no valid address, keeps its dashes; a click says why nothing opens. -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. +A plain URL or path that reaches the pane's right edge is a link of its own +when nothing on the next row continues it. One that wraps onto the next rows, +by the terminal or by an application's word wrap, including inside boxes, +table cells, and indented replies, is underlined on every row and opens +whole. A daemon that offers `pane.link.resolve` reads a URL the terminal +wrapped itself. Text that runs to the end of the pane's bottom row, or ends +in `…`, is not read as a link, since it may be cut off. Paths in panes on SSH +hosts name that host's files; see [Files on SSH Hosts](#files-on-ssh-hosts). + +A printed path or a `file://` link may end in a `?query` or `#fragment`, as +in `index.html?sheet=1`; the file is looked for without it. A web page, a +PDF, or a file with no extension then opens in your default browser with it; +other files open as before, without it. On Linux, and on macOS when Safari is +the default browser, the system opener opens the page without the query. A +printed path's `#L10` names the line a file opens at in the editor. 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 +2294,75 @@ 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 or a larger file shows why nothing opened. + +When a path ran onto a second line and its first line alone reads as a +different path, the click asks before fetching anything, even for a path whose +copy is already cached. The dialog "Open from {target}?" says the path ran onto +a second line, which may be other output, and holds the joined path in an +editable field. **Open** (Enter) fetches what the field holds, **First line +only** puts the first line's part in the field without opening, and **Cancel** +or Escape opens nothing. The click's query, fragment or line is kept only while +the field holds the joined path. + +When the host has no file at the clicked path, a dialog shows the path that was +asked for in an editable field. **Open** fetches what the field holds, +resolving a relative path under the pane's working directory, and asks again if +that path is missing too. The click's query, fragment or line is kept only +while the field is unchanged. **Cancel** or Escape opens nothing. + +In both dialogs a typed path is read in the clicked host's own shell. On a +Windows host an edited `~/notes.md` is fetched as `~\notes.md`, and `/srv/x.md` +as `\srv\x.md` on the drive of the pane's working directory. On a POSIX host +`~\notes.md` is fetched as `~/notes.md`, and **Open** does nothing for a drive +or UNC path. + +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/default_browser.rs b/crates/herdr-gpui/src/default_browser.rs new file mode 100644 index 000000000..ba82444ee --- /dev/null +++ b/crates/herdr-gpui/src/default_browser.rs @@ -0,0 +1,225 @@ +//! Opens a URL in the user's default web browser by starting the browser +//! itself with the URL as one argument. The system opener drops the query +//! and fragment of a `file://` URL; a browser started this way keeps them. +//! +//! On Windows the browser's command comes from the `https` (then `http`) +//! URL association in the registry, and [`command_line`] fills the URL into +//! it. On macOS it is the executable of the application Launch Services +//! picks for an `https` URL; Safari ignores a URL passed this way, so it is +//! refused as [`Error::Unsupported`], as is every other platform. Callers +//! fall back to the system opener on any error: this module never does. + +#[cfg(target_os = "macos")] +mod launch_services; +#[cfg(target_os = "macos")] +use launch_services as platform; +#[cfg(windows)] +mod registry; +#[cfg(windows)] +use registry as platform; + +#[cfg(test)] +mod tests; + +#[cfg(any(windows, target_os = "macos"))] +use std::path::Path; +#[cfg(any(windows, target_os = "macos", test))] +use std::path::PathBuf; + +pub(crate) type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + // keep-sorted start + /// The default browser's application bundle has no executable to start. + #[cfg(target_os = "macos")] + #[error("The default browser at {} has no executable to start.", app.display())] + BrowserBundle { app: PathBuf }, + #[cfg(any(windows, target_os = "macos"))] + #[error("No default web browser is set.")] + NoDefaultBrowser, + #[cfg(windows)] + #[error("Could not read the registry key {key}: {source}")] + Registry { + key: String, + #[source] + source: windows_result::Error, + }, + #[cfg(any(windows, target_os = "macos"))] + #[error("Could not start the default browser {}: {source}", program.display())] + Spawn { + program: PathBuf, + #[source] + source: std::io::Error, + }, + #[cfg(any(windows, test))] + #[error("The default browser's command line could not be read: {command}")] + UnparsableCommand { command: String }, + #[cfg(not(windows))] + #[error("The default browser cannot be started with a URL on this platform.")] + Unsupported, + // keep-sorted end +} + +/// Start the default browser on `url`, without waiting for it to exit. +/// Blocking: it reads the browser's registration and starts a process, so +/// run it on the background executor. +// A test window never starts a real browser (see `window::start_browser`), +// so nothing calls this under test. +#[cfg_attr(test, allow(dead_code))] +pub(crate) fn open(url: &url::Url) -> Result<()> { + platform::open(url.as_str()) +} + +/// No other platform can start its default browser with a URL intact. +#[cfg(not(any(windows, target_os = "macos")))] +mod platform { + pub(super) fn open(_: &str) -> super::Result<()> { + Err(super::Error::Unsupported) + } +} + +/// Split a Windows `shell\open\command` `template` into the program and its +/// arguments with `CommandLineToArgvW`'s rules, then put `url` in place of +/// each `%1`, `%l` or `%L`, dropping a `%*` argument. With no placeholder +/// the URL becomes the last argument. The URL is always inside one argument, +/// whatever quotes or spaces it holds. +#[cfg(any(windows, test))] +pub(super) fn command_line(template: &str, url: &str) -> Result<(PathBuf, Vec)> { + let unparsable = || Error::UnparsableCommand { + command: template.to_owned(), + }; + let (program, rest) = split_program(template).ok_or_else(unparsable)?; + let tokens = split_arguments(rest).ok_or_else(unparsable)?; + let mut placed = false; + let mut args: Vec = tokens + .into_iter() + .filter(|token| token != "%*") + .map(|token| { + let (arg, replaced) = fill_placeholders(&token, url); + placed |= replaced; + arg + }) + .collect(); + if !placed { + args.push(url.to_owned()); + } + Ok((PathBuf::from(program), args)) +} + +/// The program is the first token: a quoted one ends at the next quote with +/// no escapes, an unquoted one at the first whitespace. +#[cfg(any(windows, test))] +fn split_program(template: &str) -> Option<(&str, &str)> { + let template = template.trim_start_matches(is_blank); + let (program, rest) = match template.strip_prefix('"') { + Some(quoted) => quoted.split_once('"')?, + None => template.split_once(is_blank).unwrap_or((template, "")), + }; + (!program.is_empty()).then_some((program, rest)) +} + +/// Split the arguments after the program: whitespace outside quotes ends an +/// argument, `2n` backslashes before a quote give `n` backslashes and toggle +/// quoting, `2n+1` give `n` and a literal quote, and other backslashes are +/// literal. An unterminated quote is refused. +#[cfg(any(windows, test))] +fn split_arguments(rest: &str) -> Option> { + let mut args = Vec::new(); + let mut current = String::new(); + let mut started = false; + let mut quoted = false; + let mut backslashes = 0; + for character in rest.chars() { + if character == '\\' { + backslashes += 1; + started = true; + continue; + } + if character == '"' { + current.extend(std::iter::repeat_n('\\', backslashes / 2)); + if backslashes % 2 == 1 { + current.push('"'); + } else { + quoted = !quoted; + } + backslashes = 0; + started = true; + continue; + } + current.extend(std::iter::repeat_n('\\', backslashes)); + backslashes = 0; + if is_blank(character) && !quoted { + if started { + args.push(std::mem::take(&mut current)); + started = false; + } + continue; + } + current.push(character); + started = true; + } + if quoted { + return None; + } + current.extend(std::iter::repeat_n('\\', backslashes)); + if started { + args.push(current); + } + Some(args) +} + +/// Replace every `%1`, `%l` and `%L` in `token` with `url` in one pass, so a +/// placeholder-like sequence inside the URL is never replaced in turn. +#[cfg(any(windows, test))] +fn fill_placeholders(token: &str, url: &str) -> (String, bool) { + let mut filled = String::with_capacity(token.len()); + let mut replaced = false; + let mut characters = token.chars().peekable(); + while let Some(character) = characters.next() { + if character == '%' && matches!(characters.peek(), Some('1' | 'l' | 'L')) { + characters.next(); + filled.push_str(url); + replaced = true; + } else { + filled.push(character); + } + } + (filled, replaced) +} + +#[cfg(any(windows, test))] +fn is_blank(character: char) -> bool { + character == ' ' || character == '\t' +} + +/// Start `program` with `args` and reap it on a thread of its own, so a +/// browser that hands the URL to a running instance and exits is not left +/// as a zombie. +#[cfg(any(windows, target_os = "macos"))] +fn spawn(program: &Path, args: &[String]) -> Result<()> { + let mut child = herdr_client::new_command(program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|source| Error::Spawn { + program: program.to_owned(), + source, + })?; + let reaper = std::thread::Builder::new() + .name("herdr-browser-reap".into()) + .spawn(move || { + // The browser's exit status says nothing about whether the URL + // opened, and the caller has already moved on; only a failed + // wait is worth a log line. + if let Err(error) = child.wait() { + tracing::warn!(%error, "could not wait for the default browser process"); + } + }); + if let Err(error) = reaper { + tracing::warn!(%error, "could not start the thread that reaps the default browser process"); + } + Ok(()) +} diff --git a/crates/herdr-gpui/src/default_browser/launch_services.rs b/crates/herdr-gpui/src/default_browser/launch_services.rs new file mode 100644 index 000000000..f538ed3a8 --- /dev/null +++ b/crates/herdr-gpui/src/default_browser/launch_services.rs @@ -0,0 +1,33 @@ +//! The default browser from Launch Services: the application macOS opens an +//! `https` URL with, started through its bundle's executable. + +use super::{Error, Result, spawn}; +use objc2_app_kit::NSWorkspace; +use objc2_foundation::{NSBundle, NSString, NSURL}; + +/// Safari ignores a URL given on its command line. +const SAFARI: &str = "com.apple.Safari"; +const PROBE: &str = "https://example.com"; + +pub(super) fn open(url: &str) -> Result<()> { + let probe = NSURL::URLWithString(&NSString::from_str(PROBE)).ok_or(Error::NoDefaultBrowser)?; + let app = NSWorkspace::sharedWorkspace() + .URLForApplicationToOpenURL(&probe) + .ok_or(Error::NoDefaultBrowser)?; + let app_path = app.to_file_path().ok_or(Error::NoDefaultBrowser)?; + let unstartable = || Error::BrowserBundle { + app: app_path.clone(), + }; + let bundle = NSBundle::bundleWithURL(&app).ok_or_else(unstartable)?; + if bundle + .bundleIdentifier() + .is_some_and(|id| id.to_string() == SAFARI) + { + return Err(Error::Unsupported); + } + let program = bundle + .executableURL() + .and_then(|executable| executable.to_file_path()) + .ok_or_else(unstartable)?; + spawn(&program, &[url.to_owned()]) +} diff --git a/crates/herdr-gpui/src/default_browser/registry.rs b/crates/herdr-gpui/src/default_browser/registry.rs new file mode 100644 index 000000000..c0a94761c --- /dev/null +++ b/crates/herdr-gpui/src/default_browser/registry.rs @@ -0,0 +1,54 @@ +//! The default browser's command from the Windows registry: the ProgId the +//! user chose for `https` (else `http`) links, and that ProgId's +//! `shell\open\command`. + +use super::{Error, Result, command_line, spawn}; +use windows_registry::{CLASSES_ROOT, CURRENT_USER, Key}; +use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND; + +const ASSOCIATIONS: &str = r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations"; + +pub(super) fn open(url: &str) -> Result<()> { + let prog_id = prog_id()?; + let key = format!(r"{prog_id}\shell\open\command"); + let template = read(CLASSES_ROOT, &key, "")?.ok_or(Error::NoDefaultBrowser)?; + let (program, args) = command_line(&template, url)?; + spawn(&program, &args) +} + +/// Where Windows records the user's choice for a scheme, newest first: +/// Windows 11 writes `UserChoiceLatest\ProgId`, and may leave the older +/// `UserChoice` key out. +const CHOICES: [&str; 2] = [r"UserChoiceLatest\ProgId", "UserChoice"]; + +/// The ProgId the user chose for `https`, else `http`; a missing key or +/// value means that scheme has no choice recorded there. +fn prog_id() -> Result { + for scheme in ["https", "http"] { + for choice in CHOICES { + let key = format!(r"{ASSOCIATIONS}\{scheme}\{choice}"); + if let Some(prog_id) = read(CURRENT_USER, &key, "ProgId")?.filter(|id| !id.is_empty()) { + return Ok(prog_id); + } + } + } + Err(Error::NoDefaultBrowser) +} + +/// The string `value` of `root\key` (`""` is the key's default value), or +/// `None` when the key or the value does not exist. +fn read(root: &Key, key: &str, value: &str) -> Result> { + match root.open(key).and_then(|opened| opened.get_string(value)) { + Ok(text) => Ok(Some(text)), + Err(error) + if windows_result::WIN32_ERROR::from_error(&error) + .is_some_and(|code| code.0 == ERROR_FILE_NOT_FOUND) => + { + Ok(None) + } + Err(source) => Err(Error::Registry { + key: key.to_owned(), + source, + }), + } +} diff --git a/crates/herdr-gpui/src/default_browser/tests.rs b/crates/herdr-gpui/src/default_browser/tests.rs new file mode 100644 index 000000000..29999cb67 --- /dev/null +++ b/crates/herdr-gpui/src/default_browser/tests.rs @@ -0,0 +1,119 @@ +use super::*; + +const URL: &str = "file:///X:/dev/x/.tmp/mocks/m/index.html?sheet=1#top"; + +fn parsed(template: &str, url: &str) -> (PathBuf, Vec) { + match command_line(template, url) { + Ok(parsed) => parsed, + Err(error) => panic!("{template:?} did not parse: {error}"), + } +} + +fn assert_unparsable(template: &str) { + match command_line(template, URL) { + Err(Error::UnparsableCommand { command }) => assert_eq!(command, template), + other => panic!("{template:?} gave {other:?}, not UnparsableCommand"), + } +} + +#[test] +fn firefox_template_puts_the_url_after_its_flags() { + let (program, args) = parsed( + r#""C:\Program Files\Mozilla Firefox\firefox.exe" -osint -url "%1""#, + URL, + ); + assert_eq!( + program, + PathBuf::from(r"C:\Program Files\Mozilla Firefox\firefox.exe") + ); + assert_eq!(args, ["-osint", "-url", URL]); +} + +#[test] +fn chrome_template_passes_the_url_as_its_single_argument() { + let (program, args) = parsed( + r#""C:\Program Files\Google\Chrome\Application\chrome.exe" --single-argument %1"#, + URL, + ); + assert_eq!( + program, + PathBuf::from(r"C:\Program Files\Google\Chrome\Application\chrome.exe") + ); + assert_eq!(args, ["--single-argument", URL]); +} + +#[test] +fn edge_template_passes_the_url_as_its_single_argument() { + let (program, args) = parsed( + r#""C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --single-argument %1"#, + URL, + ); + assert_eq!( + program, + PathBuf::from(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe") + ); + assert_eq!(args, ["--single-argument", URL]); +} + +#[test] +fn unquoted_program_ends_at_the_first_space() { + let (program, args) = parsed(r"C:\Tools\browser.exe --new-tab %1", URL); + assert_eq!(program, PathBuf::from(r"C:\Tools\browser.exe")); + assert_eq!(args, ["--new-tab", URL]); +} + +#[test] +fn long_name_placeholders_are_replaced_too() { + for placeholder in ["%L", "%l"] { + let (_, args) = parsed(&format!("browser.exe {placeholder}"), URL); + assert_eq!(args, [URL], "{placeholder}"); + } +} + +#[test] +fn placeholder_inside_an_argument_is_replaced_in_place() { + let (_, args) = parsed("browser.exe --url=%1", URL); + assert_eq!(args, [format!("--url={URL}")]); +} + +#[test] +fn all_arguments_placeholder_is_dropped() { + let (_, args) = parsed("browser.exe %1 %*", URL); + assert_eq!(args, [URL]); +} + +#[test] +fn template_without_placeholder_gets_the_url_appended() { + let (program, args) = parsed(r#""C:\Apps\browser.exe" --new-window"#, URL); + assert_eq!(program, PathBuf::from(r"C:\Apps\browser.exe")); + assert_eq!(args, ["--new-window", URL]); +} + +#[test] +fn url_with_quotes_and_spaces_stays_one_argument() { + let url = r#"file:///C:/a b/x.html?q="1 2" -new -arg %L"#; + let (_, args) = parsed( + r#""C:\Program Files\Mozilla Firefox\firefox.exe" -osint -url "%1""#, + url, + ); + assert_eq!(args, ["-osint", "-url", url]); +} + +#[test] +fn escaped_quotes_and_backslash_runs_follow_windows_rules() { + let (_, args) = parsed(r#"browser.exe --name=a\"b "c:\dir\\" d\e %1"#, URL); + assert_eq!(args, [r#"--name=a"b"#, r"c:\dir\", r"d\e", URL]); +} + +#[test] +fn unterminated_quote_is_unparsable() { + assert_unparsable(r#""C:\Program Files\browser.exe --flag %1"#); + assert_unparsable(r#"browser.exe "%1"#); +} + +#[test] +fn empty_template_or_program_is_unparsable() { + assert_unparsable(""); + assert_unparsable(" "); + assert_unparsable(r#""" %1"#); +} diff --git a/crates/herdr-gpui/src/editor.rs b/crates/herdr-gpui/src/editor.rs index 2b54accc1..fb81e1441 100644 --- a/crates/herdr-gpui/src/editor.rs +++ b/crates/herdr-gpui/src/editor.rs @@ -236,7 +236,7 @@ impl HerdrWindow { cx: &mut Context, ) { if matches!(error, crate::Error::EditorPath) { - self.open_in_system_app(target.path.clone(), cx); + self.open_in_system_app(target.path.clone(), None, cx); } self.show_flash(crate::window::Flash::warning(error.to_string()), cx); } diff --git a/crates/herdr-gpui/src/error.rs b/crates/herdr-gpui/src/error.rs index 0275cfc68..b2a05022c 100644 --- a/crates/herdr-gpui/src/error.rs +++ b/crates/herdr-gpui/src/error.rs @@ -172,6 +172,8 @@ pub enum Error { #[cfg(feature = "daytona")] #[error(transparent)] Daytona(#[from] crate::daytona::Error), + #[error(transparent)] + DefaultBrowser(#[from] crate::default_browser::Error), #[error("Checkout lookup failed. Dismiss and reopen the menu.")] DeletionLookup, #[error("HEAD is detached. Check a branch out first.")] @@ -709,6 +711,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..d075a1410 100644 --- a/crates/herdr-gpui/src/main.rs +++ b/crates/herdr-gpui/src/main.rs @@ -61,6 +61,7 @@ mod crash; mod daemon; #[cfg(feature = "daytona")] mod daytona; +mod default_browser; mod devices_overview; mod diagnostics; mod dialog_input; @@ -125,6 +126,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..ba9e7b879 100644 --- a/crates/herdr-gpui/src/menu.rs +++ b/crates/herdr-gpui/src/menu.rs @@ -20,6 +20,8 @@ mod page; mod page_title; mod pr; mod pr_actions; +mod remote_file_dialogs; +mod remote_join_dialog; 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..78795279a 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,10 @@ impl HerdrWindow { | Page::VersionMismatch | Page::OpenCheckout | Page::OpenLink + | Page::RemoteConflict + | Page::RemoteJoin + | Page::RemoteMissing + | Page::RemoteUpload ) || self.cloud_batch_open(), |panel| { panel @@ -676,6 +681,14 @@ 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::RemoteJoin { + panel = panel.child(self.render_remote_join(cx)); + } else if page == Page::RemoteMissing { + panel = panel.child(self.render_remote_missing(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..59f28c369 100644 --- a/crates/herdr-gpui/src/menu/chrome/keys.rs +++ b/crates/herdr-gpui/src/menu/chrome/keys.rs @@ -128,6 +128,13 @@ impl HerdrWindow { self.host_menu_key(event, window, cx); return; } + // The path field owns typing and composition. + if self.menu.page == Some(Page::RemoteMissing) && self.remote_missing_field_key(event, cx) { + return; + } + if self.menu.page == Some(Page::RemoteJoin) && self.remote_join_field_key(event, cx) { + return; + } if matches!( self.menu.page, Some(Page::Tab | Page::RenameTab | Page::MoveTab) @@ -346,6 +353,18 @@ 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::RemoteJoin) => { + self.submit_remote_join(window, cx); + } + "enter" if self.menu.page == Some(Page::RemoteMissing) => { + self.submit_remote_missing(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..47017c111 100644 --- a/crates/herdr-gpui/src/menu/page.rs +++ b/crates/herdr-gpui/src/menu/page.rs @@ -73,6 +73,17 @@ 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, + /// An SSH path click whose path a raw wrap ran onto the next row; asks + /// which path to open before anything is fetched. + RemoteJoin, + /// A file an SSH path click asked for is not on the host; offers the + /// path to edit and open again. + RemoteMissing, + /// 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..833d50da8 100644 --- a/crates/herdr-gpui/src/menu/page_title.rs +++ b/crates/herdr-gpui/src/menu/page_title.rs @@ -74,6 +74,10 @@ impl Page { Self::PrComment => "Comment on pull request", Self::PrMerge => "Merge pull request", Self::PrReview => "Pull request", + Self::RemoteConflict => "Remote file changed", + Self::RemoteJoin => "Open remote file", + Self::RemoteMissing => "Remote file not found", + 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..ee44c754d --- /dev/null +++ b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs @@ -0,0 +1,398 @@ +//! 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; and the +//! one a click's file the host does not have asks: which path to open +//! instead. 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, Missing, Prompt, PromptKind, Resolution, basename}, + search_input::SearchInput, + window::RemotePath, +}; +use gpui::{prelude::*, *}; + +/// The not-on-host question: the click's missing file, and the field holding +/// the path to open instead. +pub(in crate::menu) struct MissingDialog { + missing: Missing, + input: Entity, +} + +impl HerdrWindow { + /// Shows the not-on-host question for `missing` unless another page holds + /// the window, its field focused and holding the path to edit. `true` + /// when it opened. + pub(crate) fn show_remote_missing( + &mut self, + missing: Missing, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.menu.page.is_some() || !self.open_menu(window, cx) { + return false; + } + let input = self.remote_path_field(&missing.remote.path, window, cx); + self.menu.remote_missing = Some(MissingDialog { missing, input }); + self.menu.page = Some(Page::RemoteMissing); + cx.notify(); + true + } + + /// A focused field for a path on the host, holding `text` all selected + /// so typing replaces it. + pub(in crate::menu) fn remote_path_field( + &self, + text: &str, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let input = cx.new(SearchInput::new); + input.update(cx, |input, cx| { + input.set_appearance(self.config.ui.clone(), self.theme.clone(), cx); + input.set_placeholder("Path on the host", cx); + input.set_text_selected(text, cx); + window.focus(&input.focus, cx); + }); + input + } + + /// Whether `event` belongs to the not-on-host question's field, which + /// owns typing and composition; the question takes only Escape and Enter. + pub(in crate::menu) fn remote_missing_field_key(&self, event: &KeyDownEvent, cx: &App) -> bool { + self.menu + .remote_missing + .as_ref() + .is_some_and(|dialog| dialog.input.read(cx).is_composing()) + || !matches!(event.keystroke.key.as_str(), "escape" | "enter") + } + + /// Opens the path in the field as a click opens one, closing the + /// question. An empty field, or a relative path with no working + /// directory to put it under, does nothing. + pub(in crate::menu) fn submit_remote_missing( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let Some((remote, query, cwd)) = self.remote_missing_choice(cx) else { + return; + }; + self.dismiss_menu(window, cx); + self.open_remote_file(remote, query, None, cwd, cx); + } + + fn remote_missing_choice( + &self, + cx: &App, + ) -> Option<(RemotePath, Option, Option)> { + let dialog = self.menu.remote_missing.as_ref()?; + let missing = &dialog.missing; + typed_choice( + dialog.input.read(cx), + &missing.remote, + missing.query.as_deref(), + missing.cwd.as_deref(), + ) + } + + /// The path the not-on-host question's field holds, while it is open. + #[cfg(test)] + pub(crate) fn remote_missing_text(&self, cx: &App) -> Option { + let dialog = self.menu.remote_missing.as_ref()?; + Some(dialog.input.read(cx).text().to_owned()) + } + + pub(in crate::menu) fn render_remote_missing(&self, cx: &mut Context) -> Div { + let Some(dialog) = &self.menu.remote_missing else { + return div(); + }; + let remote = &dialog.missing.remote; + div() + .debug_selector(|| "remote-missing".into()) + .child(title(format!("Not on {}", remote.target))) + .child( + div().px(px(8.)).child( + div() + .debug_selector(|| "remote-missing-path".into()) + .rounded(px(crate::config::corners::CONTROL)) + .bg(rgb(self.theme.active)) + .px(px(10.)) + .py(px(6.)) + .overflow_hidden() + .text_font(&self.config.terminal) + .child(remote.path.clone()), + ), + ) + .child(div().p(px(8.)).child(dialog.input.clone())) + .child( + buttons() + .child( + self.remote_file_button("remote-missing-open", "Open", true) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.submit_remote_missing(window, cx); + })), + ) + .child( + self.remote_file_button("remote-missing-cancel", "Cancel", false) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.dismiss_menu(window, cx); + })), + ), + ) + } + + /// 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, + )), + ) + } + + pub(in crate::menu) 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); + })) + } +} + +/// The file a path field names for the clicked file `clicked`, the query it +/// opens with, and the working directory it was resolved under. The click's +/// `query` and line stay only while the field still holds the clicked path. +/// An empty field, one mid-composition, or a relative path with no `cwd` to +/// put it under names none. +pub(super) fn typed_choice( + input: &SearchInput, + clicked: &RemotePath, + query: Option<&str>, + cwd: Option<&str>, +) -> Option<(RemotePath, Option, Option)> { + let text = input.text().trim(); + if input.is_composing() || text.is_empty() { + return None; + } + let cwd = cwd.map(str::to_owned); + if text == clicked.path { + return Some((clicked.clone(), query.map(str::to_owned), cwd)); + } + let remote = RemotePath::typed(&clicked.target, clicked.shell, text, cwd.as_deref())?; + Some((remote, None, cwd)) +} + +pub(super) fn title(text: String) -> Div { + div() + .p(px(8.)) + .font_weight(FontWeight::SEMIBOLD) + .child(text) +} + +pub(super) fn muted(color: u32, text: &'static str) -> Div { + div().p(px(8.)).text_color(rgb(color)).child(text) +} + +pub(super) fn buttons() -> Div { + div().flex().flex_wrap().gap(px(8.)).p(px(8.)) +} diff --git a/crates/herdr-gpui/src/menu/remote_join_dialog.rs b/crates/herdr-gpui/src/menu/remote_join_dialog.rs new file mode 100644 index 000000000..d15563814 --- /dev/null +++ b/crates/herdr-gpui/src/menu/remote_join_dialog.rs @@ -0,0 +1,154 @@ +//! The question a click on an SSH path asks before fetching when a raw wrap +//! ran the path onto the next row and the first row alone reads as another +//! path: the joined path to open or edit, the first row's part on request, +//! or nothing. It opens only when no other page holds the window. + +use super::{ + Page, + remote_file_dialogs::{buttons, muted, title, typed_choice}, +}; +use crate::{HerdrWindow, remote_files::RawJoin, search_input::SearchInput}; +use gpui::{prelude::*, *}; + +/// The line under the question's title. +const RAN_ON: &str = "This path ran onto a second line, which may be other output."; + +/// The raw-join question: the click it is about, and the field holding the +/// path to open. +pub(in crate::menu) struct JoinDialog { + join: RawJoin, + input: Entity, +} + +impl HerdrWindow { + /// Shows the raw-join question for `join` unless another page holds the + /// window, its field focused and holding the joined path. `true` when it + /// opened. + pub(crate) fn show_remote_join( + &mut self, + join: RawJoin, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.menu.page.is_some() || !self.open_menu(window, cx) { + return false; + } + let input = self.remote_path_field(&join.remote.path, window, cx); + self.menu.remote_join = Some(JoinDialog { join, input }); + self.menu.page = Some(Page::RemoteJoin); + cx.notify(); + true + } + + /// The click the open raw-join question is about. + pub(crate) fn shown_remote_join(&self) -> Option<&RawJoin> { + if self.menu.page != Some(Page::RemoteJoin) { + return None; + } + self.menu.remote_join.as_ref().map(|dialog| &dialog.join) + } + + /// Whether `event` belongs to the raw-join question's field, which owns + /// typing and composition; the question takes only Escape and Enter. + pub(in crate::menu) fn remote_join_field_key(&self, event: &KeyDownEvent, cx: &App) -> bool { + self.menu + .remote_join + .as_ref() + .is_some_and(|dialog| dialog.input.read(cx).is_composing()) + || !matches!(event.keystroke.key.as_str(), "escape" | "enter") + } + + /// Opens the path in the field as a click on a single row opens one, + /// closing the question. An empty field, or a relative path with no + /// working directory to put it under, does nothing. + pub(in crate::menu) fn submit_remote_join( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let Some(dialog) = &self.menu.remote_join else { + return; + }; + let join = &dialog.join; + let Some((remote, query, cwd)) = typed_choice( + dialog.input.read(cx), + &join.remote, + join.query.as_deref(), + join.cwd.as_deref(), + ) else { + return; + }; + self.dismiss_menu(window, cx); + self.open_remote_file(remote, query, None, cwd, cx); + } + + /// Puts the first row's own path in the field, all selected, and keeps + /// the field focused; nothing opens until the user does. + fn offer_first_line(&mut self, window: &mut Window, cx: &mut Context) { + let Some(dialog) = &self.menu.remote_join else { + return; + }; + let input = dialog.input.clone(); + let first = dialog.join.unjoined.path.clone(); + input.update(cx, |input, cx| { + input.set_text_selected(&first, cx); + window.focus(&input.focus, cx); + }); + cx.notify(); + } + + /// The path the raw-join question's field holds, while it is open. + #[cfg(test)] + pub(crate) fn remote_join_text(&self, cx: &App) -> Option { + let dialog = self.menu.remote_join.as_ref()?; + Some(dialog.input.read(cx).text().to_owned()) + } + + /// The raw-join question's title and the line under it, while it is + /// open. + #[cfg(test)] + pub(crate) fn remote_join_words(&self) -> Option<(String, &'static str)> { + let dialog = self.menu.remote_join.as_ref()?; + Some((join_title(&dialog.join), RAN_ON)) + } + + pub(in crate::menu) fn render_remote_join(&self, cx: &mut Context) -> Div { + let Some(dialog) = &self.menu.remote_join else { + return div(); + }; + div() + .debug_selector(|| "remote-join".into()) + .child(title(join_title(&dialog.join))) + .child(muted(self.theme.muted, RAN_ON)) + .child(div().p(px(8.)).child(dialog.input.clone())) + .child( + buttons() + .child( + self.remote_file_button("remote-join-open", "Open", true) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.submit_remote_join(window, cx); + })), + ) + .child( + self.remote_file_button("remote-join-first-line", "First line only", false) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.offer_first_line(window, cx); + })), + ) + .child( + self.remote_file_button("remote-join-cancel", "Cancel", false) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.dismiss_menu(window, cx); + })), + ), + ) + } +} + +/// The question's title, naming the host as the not-on-host question does. +fn join_title(join: &RawJoin) -> String { + format!("Open from {}?", join.remote.target) +} diff --git a/crates/herdr-gpui/src/menu/state.rs b/crates/herdr-gpui/src/menu/state.rs index e89ec0222..3e9d81598 100644 --- a/crates/herdr-gpui/src/menu/state.rs +++ b/crates/herdr-gpui/src/menu/state.rs @@ -68,6 +68,12 @@ 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 raw-joined click the which-path question is about, and its field. + pub(super) remote_join: Option, + /// The missing file the not-on-host question is about, and its field. + pub(super) remote_missing: 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 +332,9 @@ impl MenuState { session_edit: None, open_checkout: None, open_link: None, + remote_file: None, + remote_join: None, + remote_missing: None, #[cfg(feature = "cloud")] cloud_create: None, #[cfg(feature = "cloud")] @@ -402,6 +411,9 @@ impl MenuState { self.session_edit = None; self.open_checkout = None; self.open_link = None; + self.remote_file = None; + self.remote_join = None; + self.remote_missing = 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..7ae3c9e7c --- /dev/null +++ b/crates/herdr-gpui/src/remote_files.rs @@ -0,0 +1,865 @@ +//! 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 raw_join; +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 raw_join::RawJoin; +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 + )) + ) + } + + /// Whether the host has no file at the path a fetch asked for. + fn is_missing(&self) -> bool { + matches!( + self, + Self::Fetch(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Missing + )) + ) + } +} + +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, +} + +/// A click's file the host does not have, waiting for the window to have +/// no page open to ask for another path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Missing { + /// The host's file the click asked for. + pub(crate) remote: RemotePath, + /// The `?query` or `#fragment` the click's link printed after the path. + pub(crate) query: Option, + /// The working directory a relative path typed in its place is under. + pub(crate) cwd: Option, +} + +/// What a click asked for besides the host's file. +struct Click { + query: Option, + cwd: Option, +} + +/// 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, + /// Clicks' files the host does not have, waiting to be asked about. + missing: VecDeque, + /// The folder of the cache this window's copies are in. + owner: Owner, + prompts: VecDeque, + /// Clicks whose path a raw wrap ran onto the next row, waiting to ask + /// which path to open. + raw_joins: 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, with the link's query when it is a page, 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. +/// `query` is the `?query` or `#fragment` a click's link printed after the +/// path, which a page's copy opens with. Blocking. +fn fetch_copy( + io: &Io, + owner: Owner, + remote: &RemotePath, + keep_edits_since: Option, + query: Option<&str>, +) -> 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_url(&local, query); + 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. A page + /// opens with `query`, the `?query` or `#fragment` printed after its path. + /// `unjoined` is the path the first row reads alone, where a raw wrap + /// joined the next row's text to `remote`: such a click fetches nothing + /// and queues the question of which path to open. A file the host does + /// not have queues the question that offers its path to edit; a relative + /// path typed in either is under `cwd`. + pub(crate) fn open_remote_file( + &mut self, + remote: RemotePath, + query: Option, + unjoined: Option, + cwd: Option, + cx: &mut Context, + ) { + if let Some(unjoined) = unjoined { + let join = RawJoin { + remote, + query, + unjoined, + cwd, + }; + self.ask_raw_join(join, cx); + return; + } + 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(), query, 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 page_query = query.clone(); + let asked = remote.clone(); + let fetch = cx + .background_executor() + .spawn(async move { fetch_copy(&io, owner, &asked, synced, page_query.as_deref()) }); + let click = Click { query, cwd }; + 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(remote, click, fetched, cx) + }); + }) + .detach(); + } + + fn remote_file_fetched( + &mut self, + remote: RemotePath, + click: Click, + fetched: Result, + cx: &mut Context, + ) { + let key = (remote.target.clone(), remote.path.clone()); + 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(), click.query, cx); + } + } + Ok(Fetch::Written(done)) => { + let opened = done.opened.clone(); + self.remote_files.record(*done); + match opened { + Some(url) => self.open_file_url(url, cx), + None => tracing::warn!("Fetched remote file copy could not be opened"), + } + } + // The poll opens the question once no page holds the window. + Err(error) if error.is_missing() => { + tracing::info!(target = %remote.target, "Remote file not on the host; asking for another path"); + self.remote_files.missing.push_back(Missing { + remote, + query: click.query, + cwd: click.cwd, + }); + cx.notify(); + } + 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/raw_join.rs b/crates/herdr-gpui/src/remote_files/raw_join.rs new file mode 100644 index 000000000..6e19f67c4 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/raw_join.rs @@ -0,0 +1,61 @@ +//! The question a click on an SSH path asks when a raw wrap ran the path +//! onto the next row and the first row alone reads as another path. It is +//! asked before anything is fetched, so the joined reading never opens a +//! different file on the host without the user's say. + +use crate::window::{HerdrWindow, RemotePath}; +use gpui::{Context, Window}; + +/// A raw-joined click, waiting for the window to have no page open to ask +/// which path it meant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RawJoin { + /// The joined path the click read, with its line. + pub(crate) remote: RemotePath, + /// The `?query` or `#fragment` the click's link printed after the path. + pub(crate) query: Option, + /// The path the first row reads alone. + pub(crate) unjoined: RemotePath, + /// The working directory a relative path typed in its place is under. + pub(crate) cwd: Option, +} + +impl RawJoin { + /// Whether this question is about the same joined host path as `other`. + fn asks_about(&self, other: &RemotePath) -> bool { + self.remote.target == other.target && self.remote.path == other.path + } +} + +impl HerdrWindow { + /// Queues the question for `join` unless one about the same joined path + /// is already waiting or open; the poll opens it once no page holds the + /// window. + pub(super) fn ask_raw_join(&mut self, join: RawJoin, cx: &mut Context) { + let pending = |asked: &RawJoin| asked.asks_about(&join.remote); + if self.remote_files.raw_joins.iter().any(pending) + || self.shown_remote_join().is_some_and(pending) + { + return; + } + tracing::info!(target = %join.remote.target, "Remote path ran onto the next row; asking which to open"); + self.remote_files.raw_joins.push_back(join); + cx.notify(); + } + + /// Opens the first waiting raw-join question. `true` when one was + /// waiting, opened or not, so no question queued after it opens first. + pub(super) fn show_next_raw_join( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(join) = self.remote_files.raw_joins.front().cloned() else { + return false; + }; + if self.show_remote_join(join, window, cx) { + self.remote_files.raw_joins.pop_front(); + } + true + } +} 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..00b7b7c55 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests.rs @@ -0,0 +1,466 @@ +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 missing_dialog; +mod queries; +mod raw_join; +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, 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) +} + +fn posix(path: &str) -> RemotePath { + RemotePath { + target: "me@devbox".into(), + shell: RemoteShell::Posix, + path: path.into(), + line: None, + } +} + +/// A window whose host has none of the files asked for. +fn on_empty_host<'a>( + host: &FakeHost, + cx: &'a mut TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + let (view, cx) = window(host, cx); + view.update(cx, |view, _| host.serve(&mut view.remote_files, missing)); + (view, cx) +} + +/// A click on `remote`, its fetch finished, and the poll that opens a +/// waiting question. +fn click( + view: &Entity, + cx: &mut VisualTestContext, + remote: RemotePath, + unjoined: Option, + cwd: Option<&str>, +) { + let cwd = cwd.map(str::to_owned); + view.update(cx, |view, cx| { + view.open_remote_file(remote, None, unjoined, cwd, cx) + }); + poll(view, cx); +} + +fn poll(view: &Entity, cx: &mut VisualTestContext) { + cx.run_until_parked(); + view.update_in(cx, |view, window, cx| view.poll_remote_files(window, cx)); + cx.run_until_parked(); +} + +fn page(view: &Entity, cx: &mut VisualTestContext) -> Option { + view.read_with(cx, |view, _| view.menu.page) +} + +/// The path the not-on-host question's field holds. +fn field(view: &Entity, cx: &mut VisualTestContext) -> Option { + view.read_with(cx, |view, cx| view.remote_missing_text(cx)) +} + +/// Types `text` over the field's selected path, and presses Enter. +fn open(view: &Entity, cx: &mut VisualTestContext, text: &str) { + cx.simulate_input(text); + cx.simulate_keystrokes("enter"); + poll(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..1f9a973cb --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/answers.rs @@ -0,0 +1,93 @@ +//! 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), None, None, None, 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), None, None, None, 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..53ee6c8f0 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs @@ -0,0 +1,144 @@ +//! 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), None, None, None, 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..af66bb7fe --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs @@ -0,0 +1,232 @@ +//! 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), None) +} + +/// 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), None, None, None, 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..64f4f38fa --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/fetches.rs @@ -0,0 +1,134 @@ +//! 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), None, None, None, cx); + view.open_remote_file(remote(PATH), None, None, None, 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), None, None, None, 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), None, None, None, cx) + }); + cx.run_until_parked(); + view.update(cx, |view, cx| { + view.open_remote_file(remote(PATH), None, None, None, 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/missing_dialog.rs b/crates/herdr-gpui/src/remote_files/tests/missing_dialog.rs new file mode 100644 index 000000000..14485f4cd --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/missing_dialog.rs @@ -0,0 +1,184 @@ +//! A click's file the host does not have: the question that offers its path +//! to edit, and what its answers open. + +use super::*; +use crate::menu::Page; + +const PATH: &str = "/home/me/gone.md"; +const CWD: &str = "/home/me/src"; +const PAGE: &str = "/srv/www/gone.html"; + +/// A host that is reached but cuts the transfer short. +fn cut_short() -> herdr_client::Result<&'static [u8]> { + Err(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Incomplete, + )) +} + +#[gpui::test] +fn a_missing_file_asks_for_a_path_holding_the_clicked_one(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_empty_host(&host, cx); + click(&view, cx, posix(PATH), None, Some(CWD)); + assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); + assert_eq!(field(&view, cx).as_deref(), Some(PATH)); + assert_eq!(host.asked(), vec![posix(PATH)]); + assert!(cx.opened_url().is_none()); + view.read_with(cx, |view, _| { + let (flash, _) = view.flash.as_ref().unwrap(); + assert_eq!( + flash.text.as_ref(), + "Fetching gone.md from me@devbox…", + "no failure flash" + ); + }); +} + +#[gpui::test] +fn open_fetches_the_edited_path_under_the_click_s_directory(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_empty_host(&host, cx); + click(&view, cx, posix(PATH), None, Some(CWD)); + open(&view, cx, " docs/other.md "); + assert_eq!(host.asked()[1], posix("/home/me/src/docs/other.md")); + // Not there either: asked again, offering the path as the host reads it. + assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); + assert_eq!( + field(&view, cx).as_deref(), + Some("/home/me/src/docs/other.md") + ); +} + +#[gpui::test] +fn a_path_opened_from_the_question_that_is_missing_asks_again(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_empty_host(&host, cx); + click(&view, cx, posix(PATH), None, Some(CWD)); + open(&view, cx, "/srv/typed.md"); + assert_eq!(host.asked().len(), 2); + assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); + assert_eq!(field(&view, cx).as_deref(), Some("/srv/typed.md")); +} + +#[gpui::test] +fn escape_closes_the_question_without_a_fetch(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_empty_host(&host, cx); + click(&view, cx, posix(PATH), None, Some(CWD)); + cx.simulate_keystrokes("escape"); + poll(&view, cx); + assert_eq!(page(&view, cx), None); + assert_eq!(field(&view, cx), None); + assert_eq!(host.asked().len(), 1); +} + +#[gpui::test] +fn an_edited_home_path_keeps_the_windows_host_s_shell(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_empty_host(&host, cx); + click(&view, cx, remote(r"C:\dev\notes.md"), None, None); + assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); + open(&view, cx, "~/notes.md"); + assert_eq!(host.asked()[1], remote(r"~\notes.md")); +} + +#[gpui::test] +fn an_edited_posix_form_path_is_read_in_the_windows_host_s_shell(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_empty_host(&host, cx); + click(&view, cx, remote(r"C:\dev\notes.md"), None, None); + open(&view, cx, "/srv/notes.md"); + assert_eq!(host.asked()[1], remote(r"\srv\notes.md")); +} + +#[gpui::test] +fn open_with_an_empty_field_does_nothing(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_empty_host(&host, cx); + click(&view, cx, posix(PATH), None, Some(CWD)); + open(&view, cx, " "); + assert_eq!(host.asked().len(), 1); + assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); +} + +#[gpui::test] +fn open_of_a_relative_path_with_no_directory_does_nothing(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_empty_host(&host, cx); + click(&view, cx, posix(PATH), None, None); + open(&view, cx, "other.md"); + assert_eq!(host.asked().len(), 1); + assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); + assert_eq!(field(&view, cx).as_deref(), Some("other.md")); +} + +#[gpui::test] +fn any_other_fetch_failure_says_why_and_asks_nothing(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, _| host.serve(&mut view.remote_files, cut_short)); + click(&view, cx, posix(PATH), None, Some(CWD)); + assert_eq!(page(&view, cx), None); + view.read_with(cx, |view, _| { + let (flash, _) = view.flash.as_ref().unwrap(); + assert!( + flash.text.starts_with("Could not fetch the file: "), + "{}", + flash.text + ); + }); +} + +/// A window whose host has no page until the question is up, recording the +/// addresses the default browser is asked to open. +fn page_window<'a>( + host: &FakeHost, + cx: &'a mut TestAppContext, +) -> ( + Entity, + &'a mut VisualTestContext, + Arc>>, +) { + let (view, cx) = on_empty_host(host, cx); + let opened = Arc::new(Mutex::new(Vec::new())); + let record = opened.clone(); + view.update(cx, |view, cx| { + view.default_browser = Arc::new(move |url: &url::Url| { + record.lock().unwrap().push(url.to_string()); + Ok(()) + }); + let clicked = RemotePath { + line: Some(3), + ..posix(PAGE) + }; + view.open_remote_file(clicked, Some("?a=1#top".into()), None, Some(CWD.into()), cx) + }); + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); + host.change_file(NOTES); + (view, cx, opened) +} + +#[gpui::test] +fn open_of_the_clicked_path_keeps_its_query_and_line(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx, opened) = page_window(&host, cx); + cx.simulate_keystrokes("enter"); + poll(&view, cx); + assert_eq!(host.asked()[1].line, Some(3)); + let opened = opened.lock().unwrap().clone(); + assert_eq!(opened.len(), 1); + assert!(opened[0].ends_with("gone.html?a=1#top"), "{opened:?}"); +} + +#[gpui::test] +fn open_of_an_edited_path_drops_the_click_s_query_and_line(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx, opened) = page_window(&host, cx); + open(&view, cx, "/srv/www/other.html"); + assert_eq!(host.asked()[1], posix("/srv/www/other.html")); + // With no query, the copy opens in the system's application. + assert!(opened.lock().unwrap().is_empty()); + let url = cx.opened_url().unwrap(); + assert!(url.ends_with("/other.html"), "{url}"); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/queries.rs b/crates/herdr-gpui/src/remote_files/tests/queries.rs new file mode 100644 index 000000000..2de1ab965 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/queries.rs @@ -0,0 +1,62 @@ +//! A host's page fetched by a click on its path printed with a query: the +//! copy opens in the default browser with it, however the click finds it. + +use super::*; + +const PAGE: &str = "/srv/www/page.html"; +const EDITED: &[u8] = b"

edited

\n"; + +fn key() -> Key { + ("me@devbox".to_owned(), PAGE.to_owned()) +} + +/// A click on `PAGE` printed with `?a=1#top`, and its work finished. +fn click(view: &Entity, cx: &mut VisualTestContext) { + view.update(cx, |view, cx| { + view.open_remote_file(remote(PAGE), Some("?a=1#top".into()), None, None, cx) + }); + cx.run_until_parked(); +} + +#[gpui::test] +fn a_fetched_page_opens_in_the_default_browser_with_its_query(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + let asked = Arc::new(Mutex::new(Vec::new())); + let record = asked.clone(); + view.update(cx, |view, _| { + view.default_browser = Arc::new(move |url: &url::Url| { + record.lock().unwrap().push(url.to_string()); + Ok(()) + }); + }); + click(&view, cx); + + // The host is asked for the path alone, and the copy keeps it as its key. + assert_eq!(host.asked(), vec![remote(PAGE)]); + let copy = view.read_with(cx, |view, _| view.remote_files.copies[&key()].local.clone()); + let page = crate::window::opened(©).unwrap(); + let expected = format!("{}?a=1#top", url::Url::from_file_path(page).unwrap()); + assert_eq!(*asked.lock().unwrap(), vec![expected.clone()]); + + // Saved before the next click's fetch lands, the copy keeps its edits, + // and that copy opens with the query. + std::fs::write(©, EDITED).unwrap(); + click(&view, cx); + assert_eq!(host.asked().len(), 2); + assert_eq!(std::fs::read(©).unwrap(), EDITED); + assert_eq!(asked.lock().unwrap().len(), 2); + assert_eq!(asked.lock().unwrap()[1], expected); + + // A copy with edits the host lacks is not fetched again; a click opens + // the copy it has, with the query. + view.update(cx, |view, _| { + view.remote_files.copies.get_mut(&key()).unwrap().state = SyncState::Edited { + sha256: Sha256::digest(EDITED).into(), + }; + }); + click(&view, cx); + assert_eq!(host.asked().len(), 2, "not fetched again"); + assert_eq!(*asked.lock().unwrap(), vec![expected; 3]); + assert!(cx.opened_url().is_none()); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/raw_join.rs b/crates/herdr-gpui/src/remote_files/tests/raw_join.rs new file mode 100644 index 000000000..0d5c841d7 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/raw_join.rs @@ -0,0 +1,276 @@ +//! A click whose SSH path a raw wrap ran onto the next row, where the first +//! row alone reads as another path: the question asked before anything is +//! fetched, and what its answers open. + +use super::*; +use crate::menu::Page; +use gpui::Modifiers; + +const CWD: &str = "/home/me/src"; +const FIRST: &str = "/home/me/src/notes.md"; +const JOINED: &str = "/home/me/src/notes.mddone"; + +/// A click on [`JOINED`], whose first row alone reads [`FIRST`]. +fn raw_click(view: &Entity, cx: &mut VisualTestContext) { + click(view, cx, posix(JOINED), Some(posix(FIRST)), Some(CWD)); +} + +/// The path the raw-join question's field holds. +fn join_field(view: &Entity, cx: &mut VisualTestContext) -> Option { + view.read_with(cx, |view, cx| view.remote_join_text(cx)) +} + +fn draw(cx: &mut VisualTestContext) { + cx.update(|window, cx| crate::sidebar::layout_tests::full_draw(window, cx).clear(cx)); +} + +/// Clicks the button whose element is `id`. +fn press(cx: &mut VisualTestContext, id: &'static str) { + draw(cx); + let button = cx.debug_bounds(id).unwrap().center(); + cx.simulate_click(button, Modifiers::default()); + cx.run_until_parked(); +} + +#[gpui::test] +fn a_raw_joined_click_asks_which_path_before_fetching(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + raw_click(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteJoin)); + assert_eq!(Page::RemoteJoin.accessible_name(), "Open remote file"); + assert_eq!(join_field(&view, cx).as_deref(), Some(JOINED)); + assert_eq!( + view.read_with(cx, |view, _| view.remote_join_words()), + Some(( + "Open from me@devbox?".to_owned(), + "This path ran onto a second line, which may be other output." + )) + ); + draw(cx); + for id in [ + "remote-join-open", + "remote-join-first-line", + "remote-join-cancel", + ] { + assert!(cx.debug_bounds(id).is_some(), "{id}"); + } + assert!(host.asked().is_empty()); + assert!(cx.opened_url().is_none()); +} + +#[gpui::test] +fn open_fetches_the_joined_path_with_the_click_s_query_and_line(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + let opened = Arc::new(Mutex::new(Vec::new())); + let record = opened.clone(); + let joined = RemotePath { + line: Some(3), + ..posix("/srv/www/site/index.html") + }; + let clicked = joined.clone(); + view.update(cx, |view, cx| { + view.default_browser = Arc::new(move |url: &url::Url| { + record.lock().unwrap().push(url.to_string()); + Ok(()) + }); + let first = Some(posix("/srv/www/site")); + view.open_remote_file( + clicked, + Some("?a=1#top".into()), + first, + Some(CWD.into()), + cx, + ) + }); + poll(&view, cx); + assert!(host.asked().is_empty()); + cx.simulate_keystrokes("enter"); + poll(&view, cx); + assert_eq!(host.asked(), vec![joined]); + assert_eq!(page(&view, cx), None); + let opened = opened.lock().unwrap().clone(); + assert_eq!(opened.len(), 1); + assert!(opened[0].ends_with("index.html?a=1#top"), "{opened:?}"); +} + +#[gpui::test] +fn first_line_only_offers_the_first_row_s_part_and_open_fetches_it(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + raw_click(&view, cx); + press(cx, "remote-join-first-line"); + assert_eq!(join_field(&view, cx).as_deref(), Some(FIRST)); + assert_eq!(page(&view, cx), Some(Page::RemoteJoin)); + assert!(host.asked().is_empty()); + cx.simulate_keystrokes("enter"); + poll(&view, cx); + assert_eq!(host.asked(), vec![posix(FIRST)]); + assert_eq!(page(&view, cx), None); +} + +#[gpui::test] +fn typing_after_first_line_only_replaces_the_offered_path(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + raw_click(&view, cx); + press(cx, "remote-join-first-line"); + open(&view, cx, "/srv/typed.md"); + assert_eq!(host.asked(), vec![posix("/srv/typed.md")]); +} + +#[gpui::test] +fn an_edited_home_path_keeps_the_windows_host_s_shell(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + let (joined, first) = (r"C:\dev\notes.mddone", r"C:\dev\notes.md"); + click(&view, cx, remote(joined), Some(remote(first)), None); + assert_eq!(page(&view, cx), Some(Page::RemoteJoin)); + open(&view, cx, "~/notes.md"); + assert_eq!(host.asked(), vec![remote(r"~\notes.md")]); +} + +#[gpui::test] +fn cancel_fetches_nothing_and_a_later_click_asks_again(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + raw_click(&view, cx); + press(cx, "remote-join-cancel"); + poll(&view, cx); + assert_eq!(page(&view, cx), None); + assert_eq!(join_field(&view, cx), None); + raw_click(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteJoin)); + assert!(host.asked().is_empty()); +} + +#[gpui::test] +fn escape_fetches_nothing_and_a_later_click_asks_again(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + raw_click(&view, cx); + cx.simulate_keystrokes("escape"); + poll(&view, cx); + assert_eq!(page(&view, cx), None); + raw_click(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteJoin)); + assert!(host.asked().is_empty()); +} + +#[gpui::test] +fn a_second_click_while_the_question_is_open_asks_nothing_more(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + raw_click(&view, cx); + raw_click(&view, cx); + cx.simulate_keystrokes("escape"); + poll(&view, cx); + assert_eq!(page(&view, cx), None); + assert!(host.asked().is_empty()); +} + +#[gpui::test] +fn clicks_while_another_page_is_open_wait_as_one_question(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update_in(cx, |view, window, cx| assert!(view.open_menu(window, cx))); + raw_click(&view, cx); + raw_click(&view, cx); + assert_eq!(page(&view, cx), Some(Page::Menu)); + view.update_in(cx, |view, window, cx| view.dismiss_menu(window, cx)); + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteJoin)); + cx.simulate_keystrokes("escape"); + poll(&view, cx); + assert_eq!(page(&view, cx), None); + assert!(host.asked().is_empty()); +} + +/// Sets the state of the copy of [`JOINED`] or another fetched path. +fn set_state(view: &Entity, cx: &mut VisualTestContext, path: &str, state: SyncState) { + view.update(cx, |view, _| { + let key = ("me@devbox".to_owned(), path.to_owned()); + view.remote_files.copies.get_mut(&key).unwrap().state = state; + }); +} + +#[gpui::test] +fn the_question_waits_behind_an_upload_and_opens_before_a_missing_file(cx: &mut TestAppContext) { + const SAVED: &str = "/home/me/saved.md"; + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + click(&view, cx, posix(SAVED), None, Some(CWD)); + let sha256 = Sha256::digest(b"# Saved\n").into(); + set_state(&view, cx, SAVED, SyncState::Asking { sha256 }); + view.update(cx, |view, _| { + view.remote_files.missing.push_back(Missing { + remote: posix("/home/me/gone.md"), + query: None, + cwd: None, + }); + }); + raw_click(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteUpload)); + cx.simulate_keystrokes("escape"); + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteJoin)); + cx.simulate_keystrokes("escape"); + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); + assert_eq!(host.asked(), vec![posix(SAVED)]); +} + +#[gpui::test] +fn open_lets_a_later_identical_click_ask_again(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + raw_click(&view, cx); + cx.simulate_keystrokes("enter"); + poll(&view, cx); + assert_eq!(host.asked(), vec![posix(JOINED)]); + assert_eq!(page(&view, cx), None); + raw_click(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteJoin)); + assert_eq!(host.asked().len(), 1); +} + +#[gpui::test] +fn a_raw_joined_click_on_a_copy_with_unsynced_edits_asks_before_reopening_it( + cx: &mut TestAppContext, +) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + click(&view, cx, posix(JOINED), None, Some(CWD)); + let sha256 = Sha256::digest(b"# Notes, edited\n").into(); + set_state(&view, cx, JOINED, SyncState::Edited { sha256 }); + raw_click(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteJoin)); + assert_eq!(host.asked().len(), 1); +} + +#[gpui::test] +fn a_raw_joined_click_on_a_fetched_copy_asks_before_opening_it(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + click(&view, cx, posix(JOINED), None, Some(CWD)); + assert_eq!(host.asked(), vec![posix(JOINED)]); + assert!(view.read_with(cx, |view, _| { + view.remote_files.copy("me@devbox", JOINED).is_some() + })); + raw_click(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteJoin)); + assert_eq!(host.asked().len(), 1); +} + +#[gpui::test] +fn open_of_a_joined_path_the_host_lacks_asks_for_a_path_holding_it(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_empty_host(&host, cx); + raw_click(&view, cx); + cx.simulate_keystrokes("enter"); + poll(&view, cx); + assert_eq!(host.asked(), vec![posix(JOINED)]); + assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); + assert_eq!(field(&view, cx).as_deref(), Some(JOINED)); +} 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..6b0f26ab4 --- /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), None)); + 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, None).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..cbc62d37f --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/uploads.rs @@ -0,0 +1,320 @@ +//! 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 { + if self.show_next_raw_join(window, cx) { + return; + } + if let Some(missing) = self.remote_files.missing.front().cloned() + && self.show_remote_missing(missing, window, cx) + { + self.remote_files.missing.pop_front(); + } + 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, 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..7c813a7b4 100644 --- a/crates/herdr-gpui/src/sidebar/layout_tests.rs +++ b/crates/herdr-gpui/src/sidebar/layout_tests.rs @@ -544,6 +544,7 @@ pub(crate) fn fixture_window(window: &mut Window, cx: &mut Context) updater: crate::updater::Updater::default(), update_preview: None, daemon_text: Default::default(), + default_browser: crate::window::start_browser(), app_update_notes: Default::default(), app_update_notes_scroll: gpui::ScrollHandle::new(), removal: None, @@ -617,6 +618,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/terminal.rs b/crates/herdr-gpui/src/terminal.rs index c79f359cf..c25ac5c36 100644 --- a/crates/herdr-gpui/src/terminal.rs +++ b/crates/herdr-gpui/src/terminal.rs @@ -4,8 +4,8 @@ mod selection; pub(crate) mod splits; pub(crate) use accessibility::Transcript; pub(crate) use links::{ - ExternalUrl, PaneLink, RowLink, RowTarget, escape_into, escaped_len, link_at, - pane_hyperlink_at, pane_link_at, + ExternalUrl, LinkReading, PaneLink, RowLink, RowTarget, escape_into, escaped_len, + pane_hyperlink_at, }; pub(crate) use selection::{MAX_SELECTION_BYTES, Selection}; diff --git a/crates/herdr-gpui/src/terminal/links.rs b/crates/herdr-gpui/src/terminal/links.rs index 8eb46f750..2be30b778 100644 --- a/crates/herdr-gpui/src/terminal/links.rs +++ b/crates/herdr-gpui/src/terminal/links.rs @@ -1,16 +1,21 @@ //! Resolve user-selected links within the painted pane or popup only: web //! addresses, the local file paths a pane prints or names, which open only //! on the machine that runs the pane, and the other addresses with a defined -//! scheme. Reading a row decides nothing about the disk: whether a path is -//! there is asked once the link modifier is held over it, and every link -//! opens only on a link-modifier click. -use super::{HIDDEN, InputTarget, popup_origin, selection::shown, wheel_target}; +//! scheme. Printed text is read across the rows it wraps onto, by the +//! terminal or by an application's word wrap, and a link that reaches the +//! pane's edge with nothing continuing it ends there; see `wrap`. Reading +//! text decides nothing about the disk: whether a path is there is asked +//! once the link modifier is held over it, and every link opens only on a +//! link-modifier click. +use super::{HIDDEN, InputTarget, popup_origin, wheel_target}; use herdr_client::protocol::{CellData, FrameData, PaneSurfaceFrame}; use std::ops::Range; mod external; mod path; +mod wrap; pub(crate) use path::{escape_into, escaped_len}; +use wrap::Bounds; pub(crate) use external::ExternalUrl; @@ -29,21 +34,27 @@ const MAX_PATH_BYTES: usize = 4096; /// file a `file://` address or a target without a scheme names, or an /// address with another scheme. Nothing here asks whether the file exists. fn explicit_target(destination: &str) -> RowTarget { - let named = |path: String| RowTarget::Path { + let named = |path: String, query: Option| RowTarget::Path { path, - line: None, + line: query.as_deref().and_then(path::fragment_line), + query, explicit: true, + unjoined: None, }; let url = match url::Url::parse(destination) { Ok(url) => url, // No scheme: a path as a program would print it, with `~/` and - // relative paths left for the click to resolve. + // relative paths left for the click to resolve, and a page's query + // split off as printed text's is. Err(url::ParseError::RelativeUrlWithoutBase) - if !destination.is_empty() - && destination.len() <= MAX_PATH_BYTES - && !destination.chars().any(char::is_control) => + if !destination.is_empty() && !destination.chars().any(char::is_control) => { - return named(destination.to_owned()); + return match path::split_suffix(destination, path::drive_path(destination)) { + Some((path, query)) if path.len() <= MAX_PATH_BYTES => { + named(path.to_owned(), query.map(str::to_owned)) + } + _ => RowTarget::Malformed, + }; } Err(_) => return RowTarget::Malformed, }; @@ -60,10 +71,32 @@ fn explicit_target(destination: &str) -> RowTarget { { return RowTarget::OtherHost(host.to_owned()); } + let query = file_suffix(&url); + if query + .as_ref() + .is_some_and(|query| query.len() > path::MAX_SUFFIX_BYTES) + { + return RowTarget::Malformed; + } url.to_file_path() .ok() .and_then(|path| path.into_os_string().into_string().ok()) - .map_or(RowTarget::Malformed, named) + .map_or(RowTarget::Malformed, |path| named(path, query)) +} + +/// The `?query` and `#fragment` a `file://` address ends in, as the address +/// serializes them, already percent-encoded. An empty one names nothing. +fn file_suffix(url: &url::Url) -> Option { + let mut suffix = String::new(); + if let Some(query) = url.query().filter(|query| !query.is_empty()) { + suffix.push('?'); + suffix.push_str(query); + } + if let Some(fragment) = url.fragment().filter(|fragment| !fragment.is_empty()) { + suffix.push('#'); + suffix.push_str(fragment); + } + (!suffix.is_empty()).then_some(suffix) } /// What an address read from plain text points at. Only the defined schemes @@ -75,28 +108,40 @@ fn plain_address(text: &str) -> Option { } match explicit_target(text) { RowTarget::External(url) if url.trusted() => Some(RowTarget::External(url)), - RowTarget::Path { path, .. } if text.starts_with("file://") => Some(RowTarget::Path { + RowTarget::Path { + path, line, query, .. + } if text.starts_with("file://") => Some(RowTarget::Path { path, - line: None, + line, + query, explicit: false, + unjoined: None, }), _ => None, } } -/// What a row-local link points at. +/// What a link read from the terminal points at. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum RowTarget { /// A web address that passed `WebUrl` validation. Web(String), /// A local path as the pane printed it, with `~/` and relative paths left /// for the click to resolve, or the path an explicit hyperlink names, and - /// the line a `:line` suffix named. An `explicit` one is a link whether - /// or not its file exists; printed text is one only when it does. + /// the line a `:line` suffix or a `#L10` fragment named. An `explicit` + /// one is a link whether or not its file exists; printed text is one only + /// when it does. Path { path: String, line: Option, + /// The `?query` or `#fragment` a page's address ends in, with its + /// `?` or `#`, which a page opened in the default browser keeps. + query: Option, explicit: bool, + /// The path the first row alone reads, when the terminal's own wrap + /// joined the next row's text to it: a word that merely ended at the + /// pane's last column reads the same way. + unjoined: Option, }, /// An explicit hyperlink to a file on another host, by that host's name. /// It is never opened here; a click says so. @@ -109,95 +154,227 @@ pub(crate) enum RowTarget { External(ExternalUrl), } -/// A link read from one row, and the frame columns it covers. +/// A link and the frame cells it covers: one `(row, columns)` per row it +/// spans, top to bottom, with half-open frame columns. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RowLink { pub(crate) target: RowTarget, - pub(crate) row: u16, - pub(crate) columns: Range, + pub(crate) rows: Vec<(u16, Range)>, } -/// A row-local link in a pane, which a popup never is. +impl RowLink { + /// Whether this link covers every cell of `other`, given in the same + /// shape, and more besides. + pub(crate) fn strictly_contains(&self, other: &[(u16, Range)]) -> bool { + let cells = |rows: &[(u16, Range)]| { + rows.iter() + .map(|(_, columns)| usize::from(columns.end.saturating_sub(columns.start))) + .sum::() + }; + other.iter().all(|(row, columns)| { + self.rows.iter().any(|(own, covered)| { + own == row && covered.start <= columns.start && columns.end <= covered.end + }) + }) && cells(&self.rows) > cells(other) + } +} + +/// A link in a pane, which a popup never is. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PaneLink { pub(crate) pane_id: String, pub(crate) link: RowLink, } -/// The web address under a point, in a pane or a popup. -pub(crate) fn link_at( - surface: &PaneSurfaceFrame, - x: f32, - y: f32, - cell_width: f32, - cell_height: f32, -) -> Option { - match located(surface, x, y, cell_width, cell_height)?.1.target { - RowTarget::Web(url) => Some(url), - RowTarget::Path { .. } - | RowTarget::External(_) - | RowTarget::OtherHost(_) - | RowTarget::Malformed => None, - } +/// How plain text is read across rows. A daemon offering +/// `pane.link.resolve` reads an address that wraps raw onto the next row +/// from its own terminal state, so a client of one leaves that address to +/// it; paths, which the daemon does not read, join either way. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LinkReading { + /// Whether an address joins a raw terminal wrap. + pub(crate) raw_url_wraps: bool, + /// Whether the pane's host writes Windows paths, so a backslash in a + /// relative path separates folders when a `?query` or `#fragment` is + /// split off. + pub(crate) windows_paths: bool, } -/// The link of either kind under a point in a pane, in surface frame cells. -pub(crate) fn pane_link_at( - surface: &PaneSurfaceFrame, - x: f32, - y: f32, - cell_width: f32, - cell_height: f32, -) -> Option { - let (pane_id, link) = located(surface, x, y, cell_width, cell_height)?; - Some(PaneLink { - pane_id: pane_id?, - link, - }) -} +impl LinkReading { + /// The web address under a point, in a pane or a popup. + pub(crate) fn link_at( + self, + surface: &PaneSurfaceFrame, + x: f32, + y: f32, + cell_width: f32, + cell_height: f32, + ) -> Option { + match self + .located(surface, x, y, cell_width, cell_height)? + .1 + .target + { + RowTarget::Web(url) => Some(url), + RowTarget::Path { .. } + | RowTarget::External(_) + | RowTarget::OtherHost(_) + | RowTarget::Malformed => None, + } + } -fn located( - surface: &PaneSurfaceFrame, - x: f32, - y: f32, - cell_width: f32, - cell_height: f32, -) -> Option<(Option, RowLink)> { - // Share popup isolation, pane bounds, and invalid-geometry handling with input. - let target = wheel_target(surface, x, y, cell_width, cell_height)?; - let (frame, x, y, start, end, pane_id) = match target.target { - InputTarget::Popup(_) => { - let popup = surface.popup.as_ref()?; - let origin = popup_origin(&surface.frame, &popup.frame, cell_width, cell_height); - ( - &popup.frame, - x - f32::from(origin.x), - y - f32::from(origin.y), - 0, - popup.frame.width, - None, - ) + /// The link of either kind under a point in a pane, in surface frame cells. + pub(crate) fn pane_link_at( + self, + surface: &PaneSurfaceFrame, + x: f32, + y: f32, + cell_width: f32, + cell_height: f32, + ) -> Option { + let (pane_id, link) = self.located(surface, x, y, cell_width, cell_height)?; + Some(PaneLink { + pane_id: pane_id?, + link, + }) + } + + fn located( + self, + surface: &PaneSurfaceFrame, + x: f32, + y: f32, + cell_width: f32, + cell_height: f32, + ) -> Option<(Option, RowLink)> { + // Share popup isolation, pane bounds, and invalid-geometry handling with input. + let target = wheel_target(surface, x, y, cell_width, cell_height)?; + let (frame, x, y, bounds, pane_id) = match target.target { + InputTarget::Popup(_) => { + let popup = surface.popup.as_ref()?; + let origin = popup_origin(&surface.frame, &popup.frame, cell_width, cell_height); + let bounds = Bounds { + columns: 0..popup.frame.width, + rows: 0..popup.frame.height, + }; + ( + &popup.frame, + x - f32::from(origin.x), + y - f32::from(origin.y), + bounds, + None, + ) + } + InputTarget::Pane(id) => { + let pane = surface.panes.iter().find(|pane| pane.pane_id == id)?; + let rect = pane.inner_rect; + let bounds = Bounds { + columns: rect.x..rect.x.saturating_add(rect.width), + rows: rect.y..rect.y.saturating_add(rect.height), + }; + (&surface.frame, x, y, bounds, Some(id)) + } + }; + let link = self.frame_link( + frame, + (x / cell_width).floor() as u16, + (y / cell_height).floor() as u16, + &bounds, + )?; + Some((pane_id, link)) + } + + fn frame_link( + self, + frame: &FrameData, + column: u16, + row: u16, + bounds: &Bounds, + ) -> Option { + if !bounds.rows.contains(&row) { + return None; } - InputTarget::Pane(id) => { - let pane = surface.panes.iter().find(|pane| pane.pane_id == id)?; - ( - &surface.frame, - x, - y, - pane.inner_rect.x, - pane.inner_rect.x.saturating_add(pane.inner_rect.width), - Some(id), - ) + let (start, end) = (bounds.columns.start, bounds.columns.end); + let (cells, selected, source) = link_cell(frame, column, row, start, end)?; + // Cell indexes fit the row, whose width is a `u16`. + let at = |index: usize| start + index as u16; + // An explicit link is authoritative, even if its destination is disallowed. + if let Some(index) = cells[selected].hyperlink.or(cells[source].hyperlink) { + let destination = frame.hyperlinks.get(index as usize)?; + let target = match web_url(destination) { + Some(url) => RowTarget::Web(url), + None => explicit_target(destination), + }; + let on_link = |i: &usize| cells[*i].skip || cells[*i].hyperlink == Some(index); + let first = (0..=source) + .rev() + .take_while(on_link) + .last() + .unwrap_or(source); + let last = (selected..cells.len()) + .take_while(on_link) + .last() + .unwrap_or(selected); + let rows = wrap::hyperlink_rows(frame, bounds, row, at(first)..at(last + 1), |cell| { + cell.hyperlink == Some(index) + }); + return Some(RowLink { target, rows }); } - }; - let link = frame_link( - frame, - (x / cell_width).floor() as u16, - (y / cell_height).floor() as u16, - start, - end, - )?; - Some((pane_id, link)) + self.plain_link(frame, bounds, row, at(source)) + } + + /// The plain address or path whose text covers the cell at `column`, + /// read across the rows it wraps onto. + fn plain_link( + self, + frame: &FrameData, + bounds: &Bounds, + row: u16, + column: u16, + ) -> Option { + let joined = wrap::read(frame, bounds, row, column)?; + let text = joined.text.as_str(); + let hit = joined.byte_at(row, column)?; + let (range, open, target) = match plain_url(text, hit) { + Some((range, open)) => { + // The daemon reads an address the terminal wrapped itself. + if !self.raw_url_wraps && joined.crosses_raw_join(&range) { + return None; + } + let target = plain_address(&text[range.clone()])?; + (range, open, target) + } + None => { + let found = path::plain_path(text, hit, self.windows_paths)?; + // Read the first row's part as the whole path is read, so its + // suffix and escapes go the same way. + let unjoined = joined + .first_raw_join(&found.range) + .and_then(|join| { + path::plain_path(&text[..join], found.range.start, self.windows_paths) + }) + .map(|first| first.path.into_owned()) + .filter(|first| found.path != first.as_str()); + let target = RowTarget::Path { + path: found.path.into_owned(), + line: found.line, + query: found.query.map(str::to_owned), + explicit: false, + unjoined, + }; + (found.range, found.open, target) + } + }; + // Text running to the end of the pane's bottom row may continue + // off-screen, and text cut off with an ellipsis is not all there. + if (open && joined.ends_at_bottom) || joined.cut_off(&range) { + return None; + } + Some(RowLink { + target, + rows: joined.segments(&range)?, + }) + } } /// Whether an explicit hyperlink covers the pane cell under a point, whatever @@ -260,102 +437,6 @@ fn link_cell( (cells.get(source)?.modifier & HIDDEN == 0).then_some((cells, selected, source)) } -fn frame_link(frame: &FrameData, column: u16, row: u16, start: u16, end: u16) -> Option { - let (cells, selected, source) = link_cell(frame, column, row, start, end)?; - let cell = &cells[source]; - // Cell indexes fit the row, whose width is a `u16`. - let columns = |cells: Range| start + cells.start as u16..start + cells.end as u16; - // An explicit link is authoritative, even if its destination is disallowed. - if let Some(index) = cells[selected].hyperlink.or(cell.hyperlink) { - let destination = frame.hyperlinks.get(index as usize)?; - let target = match web_url(destination) { - Some(url) => RowTarget::Web(url), - None => explicit_target(destination), - }; - let on_link = |i: &usize| cells[*i].skip || cells[*i].hyperlink == Some(index); - let first = (0..=source) - .rev() - .take_while(on_link) - .last() - .unwrap_or(source); - let last = (selected..cells.len()) - .take_while(on_link) - .last() - .unwrap_or(selected); - return Some(RowLink { - target, - row, - columns: columns(first..last + 1), - }); - } - // Byte offsets into the row's text, so a link maps back to its cells. - let mut text = String::new(); - let mut starts = Vec::with_capacity(cells.len()); - for cell in cells { - starts.push(text.len()); - if cell.skip { - continue; - } - let symbol = shown(cell); - if text.len() + symbol.len() > MAX_ROW_BYTES { - return None; - } - text.push_str(symbol); - } - let hit = starts[source]; - // Plain links are row-local: the surface doesn't distinguish soft wraps - // from separate lines, so joining rows could silently change the - // destination. A daemon offering `pane.link.resolve` reads wrapped URLs - // from its own terminal state instead; see `crate::links`. - let (range, target) = match plain_url(&text, hit) { - Some((_, true)) => return None, - Some((range, false)) => { - let target = plain_address(&text[range.clone()])?; - (range, target) - } - None => { - let found = path::plain_path(&text, hit)?; - // A path starting the row may be the tail of whatever filled the - // row above, such as a wrapped URL. - if found.open || (found.range.start == 0 && row_full(frame, row, end)) { - return None; - } - let target = RowTarget::Path { - path: found.path.into_owned(), - line: found.line, - explicit: false, - }; - (found.range, target) - } - }; - let first = (0..cells.len()).find(|&i| !cells[i].skip && starts[i] >= range.start)?; - let last = (0..cells.len()) - .rev() - .find(|&i| !cells[i].skip && starts[i] < range.end)?; - // A wide character's continuation cells belong to the link too. - let end = (last + 1..cells.len()) - .find(|&i| !cells[i].skip) - .unwrap_or(cells.len()); - Some(RowLink { - target, - row, - columns: columns(first..end), - }) -} - -/// Whether the row above `row` prints up to the pane's right edge, `end`, -/// where its text may wrap onto `row`. -fn row_full(frame: &FrameData, row: u16, end: u16) -> bool { - let Some(above) = row.checked_sub(1) else { - return false; - }; - let index = usize::from(above) * usize::from(frame.width) + usize::from(end) - 1; - frame - .cells - .get(index) - .is_some_and(|cell| cell.skip || shown(cell) != " ") -} - /// The schemes read from plain text. Any other is plain text. const PLAIN_SCHEMES: [&str; 5] = ["http://", "https://", "file://", "mailto:", "vscode:"]; @@ -381,10 +462,14 @@ fn address_starts(text: &str) -> Vec<(usize, &'static str)> { starts } -/// The byte range of the plain address in one row's `text` that covers the -/// byte at `hit`, trimmed of the prose punctuation around it, and whether the -/// address runs to the end of the row, where it may continue off-screen or on -/// the next row. +/// The symbols terminal applications draw around text as decoration, such +/// as bullets, tool-result corners, prompts, and the ellipsis that marks +/// shortened text. An address never holds one. +const DECORATIONS: [char; 7] = ['●', '⏺', '⎿', '❯', '›', '•', '…']; + +/// The byte range of the plain address in `text` that covers the byte at +/// `hit`, trimmed of the prose punctuation around it, and whether the +/// address runs to the end of the text, where it may continue off-screen. pub(super) fn plain_url(text: &str, hit: usize) -> Option<(Range, bool)> { for (start, scheme) in address_starts(text) { if start > hit { @@ -393,11 +478,15 @@ pub(super) fn plain_url(text: &str, hit: usize) -> Option<(Range, bool)> let tail = &text[start..]; let end = tail .find(|c: char| { - c.is_whitespace() || c.is_control() || matches!(c, '<' | '>' | '"' | '\'' | '`') + c.is_whitespace() + || c.is_control() + || matches!(c, '<' | '>' | '"' | '\'' | '`') + || wrap::frame_char(c) + || DECORATIONS.contains(&c) }) .unwrap_or(tail.len()); - // Check the original token: punctuation at the edge may be part of a - // destination continuing off-screen or on the next row. + // Check the original token: punctuation at the end may be part of a + // destination continuing off-screen. let open = end == tail.len(); let mut candidate = tail[..end].trim_end_matches(['.', ',', ';', ':', '!', '?']); for (open, close) in [('(', ')'), ('[', ']'), ('{', '}')] { diff --git a/crates/herdr-gpui/src/terminal/links/path.rs b/crates/herdr-gpui/src/terminal/links/path.rs index 7e1bb9cd0..ceffa723b 100644 --- a/crates/herdr-gpui/src/terminal/links/path.rs +++ b/crates/herdr-gpui/src/terminal/links/path.rs @@ -12,15 +12,18 @@ const MAX_PATH_BYTES: usize = 4096; /// A file path read from a row. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct PlainPath<'a> { - /// The bytes of the row it covers, a `:line` or `:line:column` suffix - /// included. + /// The bytes of the row it covers, a `:line` or `:line:column` suffix and + /// a `?query` or `#fragment` included. pub(super) range: Range, /// The path without that suffix, with shell escapes such as `\ ` removed. pub(super) path: Cow<'a, str>, - /// The line the suffix names. + /// The line the suffix names, or else a `#L10` fragment. pub(super) line: Option, - /// Whether the path runs to the end of the row, where it may continue on - /// the next one. + /// The `?query` or `#fragment` a page's address ends in, with its `?` or + /// `#`; the range covers it too. + pub(super) query: Option<&'a str>, + /// Whether the path runs to the end of the text read, where it may + /// continue off-screen. pub(super) open: bool, } @@ -28,7 +31,9 @@ pub(super) struct PlainPath<'a> { /// after a backslash belongs to the path, as in the `/tmp/a\ b` a file drop /// or a shell's completion prints, except in a word that starts with a /// drive, such as `C:\Users\me`, where every backslash is part of the path. -pub(super) fn plain_path(text: &str, hit: usize) -> Option> { +/// `windows_paths` says the pane's host writes Windows paths, whose +/// backslashes separate folders when a `?query` or `#fragment` is split off. +pub(super) fn plain_path(text: &str, hit: usize, windows_paths: bool) -> Option> { let mut start = 0; let mut end = text.len(); let mut drive = drive_path(text); @@ -56,7 +61,9 @@ pub(super) fn plain_path(text: &str, hit: usize) -> Option> { if hit < start || hit >= start + token.len() { return None; } - let (located, line) = without_location(token); + let (named, query) = split_suffix(token, drive || windows_paths)?; + let (located, line) = without_location(named); + let line = line.or_else(|| query.and_then(fragment_line)); let (path, separators): (_, &[char]) = if drive { // A Windows name holds no colon but the drive's. if located.matches(':').count() > 1 { @@ -73,13 +80,121 @@ pub(super) fn plain_path(text: &str, hit: usize) -> Option> { range: start..start + token.len(), path, line, + query, open: end == text.len(), }) } +/// `token` split into the path it names and the `?query` or `#fragment` a +/// page's address ends in, as in `index.html?sheet=1`, the suffix keeping its +/// leading `?` or `#`. Only an unescaped mark after a folder, not starting a +/// name, with something after it splits: a `?` before `key=` or after a name +/// with an extension, and a `#` before an anchor or a `L10` line after a +/// name with an extension, or a `page=3` after a PDF's. So `notes#1`, +/// `~/.emacs.d/#init.el#`, and `/tmp/a?b` stay whole. `drive` says whether +/// the token is a Windows path, whose backslashes separate folders rather +/// than escape. `None` when the suffix is longer than [`MAX_SUFFIX_BYTES`]. +pub(super) fn split_suffix(token: &str, drive: bool) -> Option<(&str, Option<&str>)> { + let separator = |c: char| c == '/' || (drive && c == '\\'); + let mut after_folder = false; + let mut previous = None; + let mut chars = token.char_indices(); + while let Some((index, c)) = chars.next() { + if c == '\\' && !drive { + // The escaped character is part of the name. + previous = chars.next().map(|(_, escaped)| escaped); + continue; + } + if matches!(c, '?' | '#') && after_folder && previous.is_some_and(|p| !separator(p)) { + let (path, suffix) = token.split_at(index); + if page_suffix(path, &suffix[1..], c == '?', drive) { + return (suffix.len() <= MAX_SUFFIX_BYTES).then_some((path, Some(suffix))); + } + } + after_folder |= separator(c); + previous = Some(c); + } + Some((token, None)) +} + +/// The longest `?query` or `#fragment` read after a path, so a page's whole +/// address stays within the 8192 bytes a web address may have. +pub(super) const MAX_SUFFIX_BYTES: usize = 4096; + +/// Whether `rest`, after the `?` (`query`) or `#` that follows `path`, reads +/// as a page's query or fragment. +fn page_suffix(path: &str, rest: &str, query: bool, drive: bool) -> bool { + if rest.is_empty() { + return false; + } + let separators: &[char] = if drive { &['/', '\\'] } else { &['/'] }; + let name = without_location(path) + .0 + .rsplit(separators) + .next() + .unwrap_or_default(); + let extension = name + .rsplit_once('.') + .map(|(_, extension)| extension) + .filter(|extension| { + (1..=10).contains(&extension.len()) + && extension.bytes().all(|b| b.is_ascii_alphanumeric()) + }); + if query { + extension.is_some() || starts_like_query(rest) + } else { + extension.is_some_and(|extension| { + anchor(rest) || (extension.eq_ignore_ascii_case("pdf") && pdf_page(rest)) + }) + } +} + +/// Whether `fragment` names a PDF's page, as in `page=3`. +fn pdf_page(fragment: &str) -> bool { + fragment + .strip_prefix("page=") + .is_some_and(|page| !page.is_empty() && page.bytes().all(|b| b.is_ascii_digit())) +} + +/// Whether `rest` starts with a `key=`. +fn starts_like_query(rest: &str) -> bool { + let key = rest + .bytes() + .take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'~' | b'-')) + .count(); + key > 0 && rest.as_bytes().get(key) == Some(&b'=') +} + +/// Whether `fragment` is an anchor such as `intro`, which a line such as +/// `L10` or `L10-L20` is too. +fn anchor(fragment: &str) -> bool { + let mut bytes = fragment.bytes(); + bytes.next().is_some_and(|b| b.is_ascii_alphabetic()) + && bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')) +} + +/// The line a `#L10` or `#L10-L20` fragment at the end of `suffix` names: +/// the first of the range. +pub(super) fn fragment_line(suffix: &str) -> Option { + let (_, fragment) = suffix.split_once('#')?; + let lines = fragment.strip_prefix('L')?; + let number = |digits: &str| { + (!digits.is_empty() && digits.len() <= 9 && digits.bytes().all(|b| b.is_ascii_digit())) + .then(|| digits.parse().ok()) + .flatten() + }; + match lines.split_once('-') { + Some((first, last)) => { + number(last.strip_prefix('L').unwrap_or(last))?; + number(first) + } + None => number(lines), + } +} + /// Whether `word` starts with a drive and the separator after it, as in /// `C:\` or `C:/`: a Windows path, whose backslashes separate folders. -fn drive_path(word: &str) -> bool { +pub(super) fn drive_path(word: &str) -> bool { matches!(word.as_bytes(), [letter, b':', b'\\' | b'/', ..] if letter.is_ascii_alphabetic()) } diff --git a/crates/herdr-gpui/src/terminal/links/tests.rs b/crates/herdr-gpui/src/terminal/links/tests.rs index 130df8b93..acc33601c 100644 --- a/crates/herdr-gpui/src/terminal/links/tests.rs +++ b/crates/herdr-gpui/src/terminal/links/tests.rs @@ -24,6 +24,49 @@ fn frame(text: &str, width: u16, height: u16) -> FrameData { } } +/// A frame of `lines`, one row each, padded with blanks to `width`. +fn rows(lines: &[&str], width: u16) -> FrameData { + let text: String = lines + .iter() + .map(|line| { + let padding = usize::from(width) - line.chars().count(); + format!("{line}{}", " ".repeat(padding)) + }) + .collect(); + frame(&text, width, lines.len() as u16) +} + +/// Reads as a client whose daemon does not resolve links, so an address +/// joins a raw wrap too. +const JOINED: LinkReading = LinkReading { + raw_url_wraps: true, + windows_paths: false, +}; + +fn link_at(surface: &PaneSurfaceFrame, x: f32, y: f32, w: f32, h: f32) -> Option { + JOINED.link_at(surface, x, y, w, h) +} + +fn pane_link_at(surface: &PaneSurfaceFrame, x: f32, y: f32, w: f32, h: f32) -> Option { + JOINED.pane_link_at(surface, x, y, w, h) +} + +/// What `frame` reads at a cell, with the pane between columns `start` +/// and `end` and over every row. +fn frame_link(frame: &FrameData, column: u16, row: u16, start: u16, end: u16) -> Option { + let bounds = Bounds { + columns: start..end, + rows: 0..frame.height, + }; + JOINED.frame_link(frame, column, row, &bounds) +} + +/// The columns of a link that lies on one row. +fn columns(link: &RowLink) -> Range { + assert_eq!(link.rows.len(), 1, "{link:?}"); + link.rows[0].1.clone() +} + fn surface(text: &str) -> PaneSurfaceFrame { let rect = SurfaceRect { x: 0, @@ -157,27 +200,51 @@ fn geometry_is_bounded_and_popup_blocks_underlying_links() { } #[test] -fn plain_links_do_not_cross_panes_or_guess_wrapped_destinations() { +fn plain_links_do_not_cross_panes_and_read_wrapped_destinations_whole() { + // The pane ends where its cells do: what lies beyond is another pane's, + // and a blank row below says the address ends at the edge. let mut s = surface("https://example.com"); s.panes[0].inner_rect.width = 12; - assert!(link_at(&s, 1., 1., 10., 20.).is_none()); + assert_eq!( + link_at(&s, 1., 1., 10., 20.).as_deref(), + Some("https://exam/") + ); + // Filling the pane's bottom row, it may continue off-screen. let wrapped = frame("https://example.com/long", 12, 2); assert!(frame_link(&wrapped, 0, 0, 0, 12).is_none()); + let wrapped = frame("https://example.com/long", 12, 3); + assert_eq!( + frame_link(&wrapped, 0, 0, 0, 12), + Some(RowLink { + target: RowTarget::Web("https://example.com/long".into()), + rows: vec![(0, 0..12), (1, 0..12)], + }) + ); for punctuation in ['.', ',', ';', ':', '!', '?', ')', ']', '}'] { let text = format!("https://example{punctuation}com/path"); let wrapped = frame(&text, 16, 2); - assert!(frame_link(&wrapped, 0, 0, 0, 16).is_none(), "{text}"); + assert_eq!( + frame_link(&wrapped, 0, 0, 0, 16), + web_url(&text).map(|url| RowLink { + target: RowTarget::Web(url), + rows: vec![(0, 0..16), (1, 0..8)], + }), + "{text}" + ); let mut s = surface(&text); s.panes[0].inner_rect.width = 16; - assert!(link_at(&s, 1., 1., 10., 20.).is_none(), "{text}"); + assert_eq!( + link_at(&s, 1., 1., 10., 20.).as_deref(), + Some("https://example/"), + "{text}" + ); } let unicode = frame("界 https://example.com ", 40, 1); assert_eq!( frame_link(&unicode, 3, 0, 0, 40), Some(RowLink { target: RowTarget::Web("https://example.com/".into()), - row: 0, - columns: 2..21, + rows: vec![(0, 2..21)], }) ); } @@ -294,3 +361,5 @@ mod drive_paths; mod escaped_paths; mod external; mod paths; +mod queries; +mod wraps; diff --git a/crates/herdr-gpui/src/terminal/links/tests/drive_paths.rs b/crates/herdr-gpui/src/terminal/links/tests/drive_paths.rs index 27d4c3f33..2865a8509 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/drive_paths.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/drive_paths.rs @@ -1,12 +1,14 @@ use super::*; fn read(text: &str, column: u16) -> Option<(String, Option, Range)> { - frame_link(&frame(text, 60, 1), column, 0, 0, 60).and_then(|link| match link.target { - RowTarget::Path { path, line, .. } => Some((path, line, link.columns)), - RowTarget::Web(_) - | RowTarget::External(_) - | RowTarget::OtherHost(_) - | RowTarget::Malformed => None, + frame_link(&frame(text, 60, 1), column, 0, 0, 60).and_then(|link| { + match (columns(&link), link.target) { + (columns, RowTarget::Path { path, line, .. }) => Some((path, line, columns)), + (_, RowTarget::Web(_)) + | (_, RowTarget::External(_)) + | (_, RowTarget::OtherHost(_)) + | (_, RowTarget::Malformed) => None, + } }) } diff --git a/crates/herdr-gpui/src/terminal/links/tests/escaped_paths.rs b/crates/herdr-gpui/src/terminal/links/tests/escaped_paths.rs index c2daede8c..3550a652f 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/escaped_paths.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/escaped_paths.rs @@ -1,12 +1,14 @@ use super::*; fn located(text: &str, column: u16) -> Option<(String, Option, Range)> { - frame_link(&frame(text, 60, 1), column, 0, 0, 60).and_then(|link| match link.target { - RowTarget::Path { path, line, .. } => Some((path, line, link.columns)), - RowTarget::Web(_) - | RowTarget::External(_) - | RowTarget::OtherHost(_) - | RowTarget::Malformed => None, + frame_link(&frame(text, 60, 1), column, 0, 0, 60).and_then(|link| { + match (columns(&link), link.target) { + (columns, RowTarget::Path { path, line, .. }) => Some((path, line, columns)), + (_, RowTarget::Web(_)) + | (_, RowTarget::External(_)) + | (_, RowTarget::OtherHost(_)) + | (_, RowTarget::Malformed) => None, + } }) } @@ -43,11 +45,13 @@ fn every_cell_of_a_pasted_path_links_to_the_original_name() { RowTarget::Path { path: name.into(), line: None, + query: None, explicit: false, + unjoined: None, }, "{text} at {column}" ); - assert_eq!(found.columns, link, "{text}"); + assert_eq!(found.rows, vec![(0, link.clone())], "{text}"); } assert!(read(link.end).is_none(), "{text}"); } diff --git a/crates/herdr-gpui/src/terminal/links/tests/external.rs b/crates/herdr-gpui/src/terminal/links/tests/external.rs index d103b5fc8..03e9cce85 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/external.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/external.rs @@ -127,7 +127,9 @@ fn a_plain_file_address_is_a_path_to_look_for() { Some(RowTarget::Path { path: "/tmp/a b.rs".into(), line: None, + query: None, explicit: false, + unjoined: None, }) ); // Another host's file is nothing this machine can look for. diff --git a/crates/herdr-gpui/src/terminal/links/tests/paths.rs b/crates/herdr-gpui/src/terminal/links/tests/paths.rs index e456baa10..1056cf622 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/paths.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/paths.rs @@ -10,12 +10,12 @@ fn path(text: &str, column: u16) -> Option<(String, Range)> { } fn located(text: &str, column: u16) -> Option<(String, Option, Range)> { - read(text, column).and_then(|link| match link.target { - RowTarget::Path { path, line, .. } => Some((path, line, link.columns)), - RowTarget::Web(_) - | RowTarget::External(_) - | RowTarget::OtherHost(_) - | RowTarget::Malformed => None, + read(text, column).and_then(|link| match (columns(&link), link.target) { + (columns, RowTarget::Path { path, line, .. }) => Some((path, line, columns)), + (_, RowTarget::Web(_)) + | (_, RowTarget::External(_)) + | (_, RowTarget::OtherHost(_)) + | (_, RowTarget::Malformed) => None, }) } @@ -23,7 +23,9 @@ fn bare(path: &str) -> RowTarget { RowTarget::Path { path: path.into(), line: None, + query: None, explicit: false, + unjoined: None, } } @@ -109,19 +111,28 @@ fn paths_are_pane_links_only_and_never_web_links() { RowTarget::Path { path: "src/lib.rs".into(), line: Some(4), + query: None, explicit: false, + unjoined: None, } ); - assert_eq!((link.link.row, link.link.columns), (0, 5..17)); + assert_eq!(link.link.rows, vec![(0, 5..17)]); } #[test] fn paths_that_may_wrap_are_not_guessed() { - // Running to the right edge, the path may continue on the next row. + // Running to the right edge of the bottom row, the path may continue + // off-screen. assert!(frame_link(&frame("ab src/long/path", 16, 1), 5, 0, 0, 16).is_none()); - // Starting a row under a full one, it may be the tail of that row. + // Starting a row under a full one, it is the tail of that row. let wrapped = frame("https://example.com/src/a.rs", 20, 2); - assert!(frame_link(&wrapped, 2, 1, 0, 20).is_none()); + assert_eq!( + frame_link(&wrapped, 2, 1, 0, 20), + Some(RowLink { + target: RowTarget::Web("https://example.com/src/a.rs".into()), + rows: vec![(0, 0..20), (1, 0..8)], + }) + ); let mut separate = frame("first line ends here", 20, 2); separate.cells[19].symbol = " ".into(); for (cell, symbol) in separate.cells[20..].iter_mut().zip("src/a.rs".chars()) { @@ -145,7 +156,7 @@ fn wide_characters_before_a_path_keep_its_columns() { ); wide.cells.pop(); assert_eq!( - frame_link(&wide, 4, 0, 0, 20).map(|link| link.columns), + frame_link(&wide, 4, 0, 0, 20).map(|link| columns(&link)), Some(3..11) ); } @@ -157,7 +168,9 @@ fn a_hyperlink_names_a_path_by_a_file_address_or_without_a_scheme() { let named = |path: &str| RowTarget::Path { path: path.into(), line: None, + query: None, explicit: true, + unjoined: None, }; let mut s = surface("report"); s.frame.cells[0].hyperlink = Some(0); diff --git a/crates/herdr-gpui/src/terminal/links/tests/queries.rs b/crates/herdr-gpui/src/terminal/links/tests/queries.rs new file mode 100644 index 000000000..4af2d8d9f --- /dev/null +++ b/crates/herdr-gpui/src/terminal/links/tests/queries.rs @@ -0,0 +1,281 @@ +//! Paths that end in a page's `?query` or `#fragment`, as a mock page's +//! address does: the path part is what names the file, and the link covers +//! the whole word. +use super::*; + +/// The path, line, query, and columns a one-row frame reads at `column`. +type Read = (String, Option, Option, Range); + +fn read(text: &str, column: u16) -> Option { + let link = frame_link(&frame(text, 60, 1), column, 0, 0, 60)?; + let columns = columns(&link); + match link.target { + RowTarget::Path { + path, line, query, .. + } => Some((path, line, query, columns)), + RowTarget::Web(_) + | RowTarget::External(_) + | RowTarget::OtherHost(_) + | RowTarget::Malformed => None, + } +} + +fn split(path: &str, line: Option, query: Option<&str>, columns: Range) -> Option { + Some((path.into(), line, query.map(Into::into), columns)) +} + +#[test] +fn a_page_s_query_or_fragment_is_split_from_its_path_and_underlined_with_it() { + for (text, column, expected) in [ + ( + r"open X:\a\index.html?sheet=1 now", + 6, + split(r"X:\a\index.html", None, Some("?sheet=1"), 5..28), + ), + ( + "/srv/query?s=25 ok", + 1, + split("/srv/query", None, Some("?s=25"), 0..15), + ), + ( + "src/main.rs#L10 ok", + 1, + split("src/main.rs", Some(10), Some("#L10"), 0..15), + ), + ( + "src/main.rs#L10-L20 ok", + 1, + split("src/main.rs", Some(10), Some("#L10-L20"), 0..19), + ), + // A printed location wins over the fragment's line. + ( + "src/main.rs:7#L10 ok", + 1, + split("src/main.rs", Some(7), Some("#L10"), 0..17), + ), + // A colon in the query is no scheme's, host's, or drive's. + ( + r"X:\a\b.html?t=12:30 ok", + 1, + split(r"X:\a\b.html", None, Some("?t=12:30"), 0..19), + ), + ( + "/a/b.html?x=1#top ok", + 1, + split("/a/b.html", None, Some("?x=1#top"), 0..17), + ), + ( + "see docs/intro.md#usage ok", + 5, + split("docs/intro.md", None, Some("#usage"), 4..23), + ), + // A PDF's page is a fragment too. + ( + "see docs/guide.PDF#page=3 ok", + 5, + split("docs/guide.PDF", None, Some("#page=3"), 4..25), + ), + ] { + assert_eq!(read(text, column), expected, "{text}"); + } +} + +#[test] +fn a_question_mark_closing_a_sentence_is_no_query() { + assert_eq!( + read("see index.html?. ok", 5), + split("index.html", None, None, 4..14) + ); +} + +#[test] +fn marks_that_start_no_page_s_suffix_keep_the_path_whole() { + for (text, expected) in [ + (r"C:\repo\.tmp\#notes!.md", Some(r"C:\repo\.tmp\#notes!.md")), + (r"/tmp/a\?b", Some("/tmp/a?b")), + ("/tmp/a?b", Some("/tmp/a?b")), + ("~/.emacs.d/#init.el#", Some("~/.emacs.d/#init.el#")), + ("notes#1", Some("notes#1")), + ("src/a.rs#", Some("src/a.rs#")), + ("src/a.rs#1x", Some("src/a.rs#1x")), + // Only a PDF has pages. + ("docs/a.md#page=3", Some("docs/a.md#page=3")), + ("docs/a.pdf#page=", Some("docs/a.pdf#page=")), + // Rust's verbatim prefix is never split; its drive's colon then + // makes it no POSIX path at all. + (r"\\?\C:\x\a.rs", None), + ] { + let text = format!("{text} ok"); + let found = read(&text, 1); + assert_eq!( + found + .as_ref() + .map(|(path, _, query, _)| (path.as_str(), query.clone())), + expected.map(|path| (path, None)), + "{text}" + ); + } +} + +#[test] +fn a_query_does_not_hide_what_refuses_the_path_or_bound_its_length() { + // The path part still names an `scp` host. + assert_eq!(read("scp host:/p?x=1 now", 6), None); + let long = format!("/a/b.html?x={} ok", "a".repeat(5000)); + assert_eq!(path::plain_path(&long, 1, false), None); + let within = format!("/a/b.html?x={} ok", "a".repeat(4000)); + let found = path::plain_path(&within, 1, false).unwrap(); + assert_eq!(found.path, "/a/b.html"); + assert_eq!(found.query.map(str::len), Some(4003)); +} + +#[test] +fn a_query_wrapped_raw_onto_the_next_row_joins_its_path() { + let wrapped = rows(&["see /tmp/m/index.html?sh", "eet=1 ok"], 24); + let link = frame_link(&wrapped, 6, 0, 0, 24).unwrap(); + assert_eq!( + link.target, + RowTarget::Path { + path: "/tmp/m/index.html".into(), + line: None, + query: Some("?sheet=1".into()), + explicit: false, + unjoined: None, + } + ); + assert_eq!(link.rows, vec![(0, 4..24), (1, 0..5)]); +} + +/// What a one-row frame reads at `column` on a pane whose host writes +/// Windows paths when `windows_paths` is set. +fn read_on(text: &str, column: u16, windows_paths: bool) -> Option { + let reading = LinkReading { + raw_url_wraps: true, + windows_paths, + }; + let bounds = Bounds { + columns: 0..60, + rows: 0..1, + }; + let link = reading.frame_link(&frame(text, 60, 1), column, 0, &bounds)?; + let columns = columns(&link); + match link.target { + RowTarget::Path { + path, line, query, .. + } => Some((path, line, query, columns)), + RowTarget::Web(_) + | RowTarget::External(_) + | RowTarget::OtherHost(_) + | RowTarget::Malformed => None, + } +} + +#[test] +fn a_relative_windows_path_splits_its_query_on_a_windows_host() { + assert_eq!( + read_on(r"docs\index.html?x=1 ok", 1, true), + split(r"docs\index.html", None, Some("?x=1"), 0..19) + ); + assert_eq!( + read_on(r"docs\guide.md#L10 ok", 1, true), + split(r"docs\guide.md", Some(10), Some("#L10"), 0..17) + ); +} + +#[test] +fn a_backslash_escapes_as_before_on_a_posix_host() { + assert_eq!( + read_on(r"/tmp/a\?b ok", 1, false), + split("/tmp/a?b", None, None, 0..9) + ); + assert_eq!( + read_on(r"docs\index.html?x=1 ok", 1, false), + split(r"docs\index.html?x=1", None, None, 0..19) + ); +} + +/// A `file://` address of a page, and the path it names here. +#[cfg(unix)] +const PAGE: (&str, &str) = ("file:///tmp/m/index.html?sheet=1#top", "/tmp/m/index.html"); +#[cfg(windows)] +const PAGE: (&str, &str) = ("file:///X:/m/index.html?sheet=1#top", r"X:\m\index.html"); + +#[test] +fn a_file_address_keeps_its_query_and_fragment_in_text_and_hyperlinks() { + let (address, path) = PAGE; + let page = |explicit| RowTarget::Path { + path: path.into(), + line: None, + query: Some("?sheet=1#top".into()), + explicit, + unjoined: None, + }; + let text = format!("open {address} ok"); + let plain = frame_link(&frame(&text, 60, 1), 6, 0, 0, 60).unwrap(); + assert_eq!(plain.target, page(false)); + assert_eq!(plain.rows, vec![(0, 5..5 + address.len() as u16)]); + + let mut s = surface("report"); + s.frame.cells[0].hyperlink = Some(0); + let target = + |s: &PaneSurfaceFrame| pane_link_at(s, 1., 1., 10., 20.).map(|link| link.link.target); + s.frame.hyperlinks = vec![address.into()]; + assert_eq!(target(&s), Some(page(true))); + // Without a scheme, the target is split as printed text is. + s.frame.hyperlinks = vec!["docs/a.html?x=1".into()]; + assert_eq!( + target(&s), + Some(RowTarget::Path { + path: "docs/a.html".into(), + line: None, + query: Some("?x=1".into()), + explicit: true, + unjoined: None, + }) + ); + // A query longer than an address may carry is refused. + let (base, _) = address.split_once('?').unwrap(); + s.frame.hyperlinks = vec![format!("{base}?x={}", "a".repeat(5000))]; + assert_eq!(target(&s), Some(RowTarget::Malformed)); + s.frame.hyperlinks = vec![format!("docs/a.html?x={}", "a".repeat(5000))]; + assert_eq!(target(&s), Some(RowTarget::Malformed)); +} + +/// A `file://` address of a source line, and the path it names here. +#[cfg(unix)] +const SOURCE: (&str, &str) = ("file:///tmp/m/main.rs#L10", "/tmp/m/main.rs"); +#[cfg(windows)] +const SOURCE: (&str, &str) = ("file:///X:/m/main.rs#L10", r"X:\m\main.rs"); + +#[test] +fn a_file_address_or_hyperlink_takes_its_line_from_the_fragment() { + let (address, path) = SOURCE; + let source = |explicit| RowTarget::Path { + path: path.into(), + line: Some(10), + query: Some("#L10".into()), + explicit, + unjoined: None, + }; + let text = format!("open {address} ok"); + let plain = frame_link(&frame(&text, 60, 1), 6, 0, 0, 60).unwrap(); + assert_eq!(plain.target, source(false)); + + let mut s = surface("report"); + s.frame.cells[0].hyperlink = Some(0); + let target = + |s: &PaneSurfaceFrame| pane_link_at(s, 1., 1., 10., 20.).map(|link| link.link.target); + s.frame.hyperlinks = vec![address.into()]; + assert_eq!(target(&s), Some(source(true))); + s.frame.hyperlinks = vec!["src/main.rs#L10-L20".into()]; + assert_eq!( + target(&s), + Some(RowTarget::Path { + path: "src/main.rs".into(), + line: Some(10), + query: Some("#L10-L20".into()), + explicit: true, + unjoined: None, + }) + ); +} diff --git a/crates/herdr-gpui/src/terminal/links/tests/wraps.rs b/crates/herdr-gpui/src/terminal/links/tests/wraps.rs new file mode 100644 index 000000000..d89b24f38 --- /dev/null +++ b/crates/herdr-gpui/src/terminal/links/tests/wraps.rs @@ -0,0 +1,373 @@ +//! Links that run to a row's edge: complete there, or continued on the rows +//! below by a terminal's own wrap or an application's word wrap. +use super::*; + +/// Reads as a client whose daemon resolves links, which leaves an address +/// wrapping raw onto the next row to it. +const RESOLVED: LinkReading = LinkReading { + raw_url_wraps: false, + windows_paths: false, +}; + +type Rows = Vec<(u16, Range)>; + +/// What `frame` reads at a cell, over the whole frame. +fn read(frame: &FrameData, column: u16, row: u16) -> Option<(RowTarget, Rows)> { + frame_link(frame, column, row, 0, frame.width).map(|link| (link.target, link.rows)) +} + +fn read_resolved(frame: &FrameData, column: u16, row: u16) -> Option<(RowTarget, Rows)> { + let bounds = Bounds { + columns: 0..frame.width, + rows: 0..frame.height, + }; + RESOLVED + .frame_link(frame, column, row, &bounds) + .map(|link| (link.target, link.rows)) +} + +fn web(url: &str, rows: &[(u16, Range)]) -> Option<(RowTarget, Rows)> { + Some((RowTarget::Web(url.into()), rows.to_vec())) +} + +fn file(path: &str, line: Option, rows: &[(u16, Range)]) -> Option<(RowTarget, Rows)> { + let target = RowTarget::Path { + path: path.into(), + line, + query: None, + explicit: false, + unjoined: None, + }; + Some((target, rows.to_vec())) +} + +/// A path a raw wrap joined, with the path its first row reads alone. +fn joined_file( + path: &str, + line: Option, + first: &str, + rows: &[(u16, Range)], +) -> Option<(RowTarget, Rows)> { + let mut found = file(path, line, rows); + if let Some((RowTarget::Path { unjoined, .. }, _)) = &mut found { + *unjoined = Some(first.into()); + } + found +} + +/// The path the first row of the path read at a cell reads alone. +fn unjoined(frame: &FrameData, column: u16, row: u16) -> Option { + match read(frame, column, row) { + Some((RowTarget::Path { unjoined, .. }, _)) => unjoined, + other => panic!("no path: {other:?}"), + } +} + +#[test] +fn a_raw_wrap_joins_an_address_read_from_either_row() { + let wrapped = rows(&["see https://exa.com/", "abc/def next"], 20); + let expected = web("https://exa.com/abc/def", &[(0, 4..20), (1, 0..7)]); + assert_eq!(read(&wrapped, 5, 0), expected); + assert_eq!(read(&wrapped, 2, 1), expected); +} + +#[test] +fn a_raw_wrap_joins_a_path_and_its_location() { + let wrapped = rows(&["build src/very/long/", "file.rs:12 ok"], 20); + let expected = joined_file( + "src/very/long/file.rs", + Some(12), + "src/very/long/", + &[(0, 6..20), (1, 0..10)], + ); + assert_eq!(read(&wrapped, 8, 0), expected); + // A daemon that resolves links reads no paths, so they join anyway. + assert_eq!(read_resolved(&wrapped, 8, 0), expected); +} + +#[test] +fn a_row_starting_with_a_blank_does_not_continue_the_one_above() { + let separate = rows(&["see https://exa.com/", " abc/def next"], 20); + assert_eq!( + read(&separate, 5, 0), + web("https://exa.com/", &[(0, 4..20)]) + ); +} + +#[test] +fn a_link_at_the_edge_over_a_blank_row_is_complete_unless_it_ends_the_pane() { + for (text, expected) in [ + ( + "see https://exa.com/", + web("https://exa.com/", &[(0, 4..20)]), + ), + ( + "build src/very/long/", + file("src/very/long/", None, &[(0, 6..20)]), + ), + ] { + assert_eq!(read(&rows(&[text, ""], 20), 8, 0), expected, "{text}"); + // On the pane's bottom row it may continue off-screen. + assert_eq!(read(&rows(&[text], 20), 8, 0), None, "{text}"); + } +} + +#[test] +fn a_wide_glyph_that_did_not_fit_continues_on_the_next_row() { + let mut wrapped = rows(&["ab src/aaaaaaaaaaaa", "界 .rs ok"], 20); + wrapped.cells[21].skip = true; + assert_eq!( + read(&wrapped, 5, 0), + joined_file( + "src/aaaaaaaaaaaa界.rs", + None, + "src/aaaaaaaaaaaa", + &[(0, 3..19), (1, 0..5)] + ) + ); +} + +#[test] +fn a_path_a_raw_wrap_joined_keeps_what_its_first_row_reads() { + // `abc.txt` may have ended at the pane's edge, over a line of its own. + let wrapped = rows(&["/srv/project/abc.txt", "other.txt ok"], 20); + assert_eq!( + read(&wrapped, 3, 0), + joined_file( + "/srv/project/abc.txtother.txt", + None, + "/srv/project/abc.txt", + &[(0, 0..20), (1, 0..9)] + ) + ); + assert_eq!( + unjoined(&wrapped, 3, 1), + Some("/srv/project/abc.txt".into()) + ); +} + +#[test] +fn a_path_joined_only_inside_its_location_reads_the_same_on_its_first_row() { + let wrapped = rows(&["see /srv/proj/a.rs:1", "2 ok"], 20); + assert_eq!( + read(&wrapped, 6, 0), + file("/srv/proj/a.rs", Some(12), &[(0, 4..20), (1, 0..1)]) + ); +} + +#[test] +fn a_path_an_application_wrapped_or_not_joined_has_no_first_row_reading() { + let result = rows(&[" ⎿ /home/u/pj/src/", " deep/file.rs"], 20); + assert_eq!(unjoined(&result, 7, 0), None); + let apart = rows(&["see /srv/a.txt", "other.txt ok"], 20); + assert_eq!(unjoined(&apart, 6, 0), None); +} + +#[test] +fn a_raw_wrapped_address_is_left_to_a_daemon_that_resolves_links() { + let wrapped = rows(&["see https://exa.com/", "abc/def next"], 20); + assert_eq!(read_resolved(&wrapped, 5, 0), None); + assert_eq!(read_resolved(&wrapped, 2, 1), None); +} + +#[test] +fn an_address_wrapping_past_the_row_cap_is_no_link() { + let mut lines = vec!["https://e.co/aaaaaaa".to_owned()]; + lines.extend((0..199).map(|_| "a".repeat(20))); + let lines: Vec<&str> = lines.iter().map(String::as_str).collect(); + let long = rows(&lines, 20); + assert_eq!(read(&long, 2, 0), None); + assert_eq!(read(&long, 2, 100), None); +} + +#[test] +fn a_popup_and_a_pane_read_only_their_own_rows() { + let mut s = surface(""); + let popup = |lines: &[&str]| { + Some(Box::new(ClientShellPopupSurface { + terminal_id: "popup".into(), + title: String::new(), + width: None, + height: None, + frame: rows(lines, 20), + mouse_reporting: false, + sgr_pixel_mouse: false, + pixel_width: 200, + pixel_height: 40, + })) + }; + s.popup = popup(&["see https://exa.com/", "abc/def next"]); + assert_eq!( + link_at(&s, 351., 21., 10., 20.).as_deref(), + Some("https://exa.com/abc/def") + ); + assert_eq!( + link_at(&s, 311., 41., 10., 20.).as_deref(), + Some("https://exa.com/abc/def") + ); + // The popup's last row is its bottom, whatever lies below it. + s.popup = popup(&["xx", "see https://exa.com/"]); + assert!(link_at(&s, 351., 41., 10., 20.).is_none()); + + // A row above the pane, such as a tab bar, is never its text. + let mut s = surface(&format!("{}{}src/a.rs ok", "a".repeat(20), " ".repeat(60))); + s.panes[0].inner_rect = SurfaceRect { + x: 0, + y: 1, + width: 20, + height: 3, + }; + let link = pane_link_at(&s, 21., 21., 10., 20.).unwrap(); + assert_eq!( + Some((link.link.target, link.link.rows)), + file("src/a.rs", None, &[(1, 0..8)]) + ); +} + +#[test] +fn a_word_wrapped_reply_continues_under_the_text_after_its_marker() { + let reply = rows(&["● see https://ex.co/", " aaaaaaaa/b.html ok"], 20); + let expected = web("https://ex.co/aaaaaaaa/b.html", &[(0, 6..20), (1, 2..17)]); + assert_eq!(read(&reply, 8, 0), expected); + assert_eq!(read(&reply, 4, 1), expected); +} + +#[test] +fn a_row_indented_past_the_text_is_a_new_line_and_the_link_is_complete() { + let indented = rows(&["● see https://ex.co/", " more text"], 20); + assert_eq!(read(&indented, 8, 0), web("https://ex.co/", &[(0, 6..20)])); +} + +#[test] +fn a_word_that_fits_the_next_row_whole_was_not_broken_there() { + let moved = rows(&["● see https://ex.co/", " more text"], 20); + assert_eq!(read(&moved, 8, 0), web("https://ex.co/", &[(0, 6..20)])); +} + +#[test] +fn a_word_wrap_inside_a_box_joins_between_its_walls() { + let boxed = rows( + &[ + "╭──────────────────╮", + "│ see https://ex.c │", + "│ o/aaaaaaaaa/b ok │", + "╰──────────────────╯", + ], + 20, + ); + let expected = web("https://ex.co/aaaaaaaaa/b", &[(1, 6..18), (2, 2..15)]); + assert_eq!(read(&boxed, 8, 1), expected); + assert_eq!(read(&boxed, 3, 2), expected); +} + +#[test] +fn a_tool_result_continues_under_the_text_after_its_marker() { + let result = rows(&[" ⎿ /home/u/pj/src/", " deep/file.rs"], 20); + assert_eq!( + read(&result, 7, 0), + file( + "/home/u/pj/src/deep/file.rs", + None, + &[(0, 5..20), (1, 5..17)] + ) + ); +} + +#[test] +fn a_word_ending_before_the_blocks_wrap_column_does_not_join() { + let block = rows( + &[ + " words that go far ok", + " see https://e.co/a", + " bcd/e next", + ], + 22, + ); + assert_eq!(read(&block, 8, 1), web("https://e.co/a", &[(1, 6..20)])); +} + +#[test] +fn a_table_cell_joins_only_when_the_rest_of_the_next_record_is_blank() { + let record = rows(&["│ https://a.co/x │ y │", "│ next │ z │"], 22); + assert_eq!(read(&record, 4, 0), web("https://a.co/x", &[(0, 2..16)])); + let wrapped = rows(&["│ https://a.co/x │ y │", "│ ext/b │ │"], 22); + assert_eq!( + read(&wrapped, 4, 0), + web("https://a.co/xext/b", &[(0, 2..16), (1, 2..7)]) + ); +} + +#[test] +fn side_by_side_panels_wrap_each_at_its_own_walls() { + let panels = rows( + &[ + "│ src/ab │ │ see https://e.c │", + "│ cd.rs │ │ o/xxxxxx ok │", + ], + 30, + ); + assert_eq!( + read(&panels, 3, 0), + file("src/abcd.rs", None, &[(0, 2..8), (1, 2..7)]) + ); + assert_eq!( + read(&panels, 20, 0), + web("https://e.co/xxxxxx", &[(0, 17..28), (1, 13..21)]) + ); +} + +#[test] +fn parentheses_balance_across_a_wrap() { + let wrapped = rows(&["see https://e.co/F_(", "bar) ok"], 20); + assert_eq!( + read(&wrapped, 6, 0), + web("https://e.co/F_(bar)", &[(0, 4..20), (1, 0..4)]) + ); +} + +#[test] +fn a_numbered_row_continuing_under_its_text_joins_as_after_a_marker() { + let numbered = rows(&[" 12 foo https://exa", " mple.com/x"], 20); + assert_eq!( + read(&numbered, 10, 0), + web("https://example.com/x", &[(0, 9..20), (1, 5..15)]) + ); +} + +#[test] +fn text_cut_off_with_an_ellipsis_is_no_link() { + let cut = rows(&["see https://e.co/ab…", "", "see src/a/very/lo… ok"], 21); + assert_eq!(read(&cut, 6, 0), None); + assert_eq!(read(&cut, 6, 2), None); +} + +#[test] +fn an_explicit_hyperlink_continues_on_the_next_row() { + let mut linked = rows(&["see: aaaaaaaaaaaaaaa", "bbbbb after", ""], 20); + linked.hyperlinks = vec!["https://example.com/doc".into()]; + // The last 15 cells of the first row and the first 5 of the second. + for index in 5..25 { + linked.cells[index].hyperlink = Some(0); + } + let expected = web("https://example.com/doc", &[(0, 5..20), (1, 0..5)]); + assert_eq!(read(&linked, 6, 0), expected); + assert_eq!(read(&linked, 2, 1), expected); +} + +#[test] +fn a_link_strictly_contains_only_what_it_covers_and_more() { + let local = |rows: &[(u16, Range)]| RowLink { + target: RowTarget::Web("https://ex.co/".into()), + rows: rows.to_vec(), + }; + let wrapped = local(&[(0, 6..20), (1, 2..17)]); + // The same cells are not more of them. + assert!(!wrapped.strictly_contains(&[(0, 6..20), (1, 2..17)])); + // Another row, or more of the same row, is. + assert!(wrapped.strictly_contains(&[(0, 6..20)])); + assert!(local(&[(0, 2..20)]).strictly_contains(&[(0, 6..20)])); + // Cells on a row the link does not reach, or past its columns, are not + // covered. + assert!(!local(&[(0, 6..20)]).strictly_contains(&[(1, 6..10)])); + assert!(!wrapped.strictly_contains(&[(0, 4..12)])); +} diff --git a/crates/herdr-gpui/src/terminal/links/wrap.rs b/crates/herdr-gpui/src/terminal/links/wrap.rs new file mode 100644 index 000000000..db36f614a --- /dev/null +++ b/crates/herdr-gpui/src/terminal/links/wrap.rs @@ -0,0 +1,532 @@ +//! Where a word read from printed text continues on the rows around it. +//! +//! Two wraps continue a word: the terminal's own, which breaks a row at the +//! pane's last column and goes on at the next row's first, and an +//! application's word wrap, as Ink's `wrap-ansi` draws it, which breaks a +//! word too long for its block at the block's wrap column and goes on under +//! the row's text. A block lies between walls: the borders of a box or a +//! table cell, or the pane's own edges. Only the rows of the word under the +//! pointer are read; the link readers then tokenize the joined text as they +//! would one row. + +use super::super::selection::shown; +use super::{MAX_PATH_BYTES, MAX_ROW_BYTES}; +use herdr_client::protocol::{CellData, FrameData}; +use std::ops::Range; +use unicode_width::UnicodeWidthStr; + +/// The most rows one link spans; a longer one is no link. +const MAX_ROWS: usize = 16; +/// The most bytes of text the rows of one link hold. +const MAX_JOINED_BYTES: usize = 2 * MAX_PATH_BYTES; +/// How many rows each way are scanned for a block's wrap column. +const BLOCK_SCAN: u16 = 16; +/// The most blank cells between a block's wrap column and its right wall. +const MAX_PADDING: u16 = 2; +/// The most cells a list marker or line number before a row's text takes. +const MAX_MARKER: u16 = 3; + +/// The frame cells links are read from: a pane's inner rectangle, or a +/// whole popup. Nothing outside them is ever read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Bounds { + pub(super) columns: Range, + pub(super) rows: Range, +} + +/// Whether `c` draws a frame: box drawing, block elements, or `|`. +pub(super) fn frame_char(c: char) -> bool { + ('\u{2500}'..='\u{259f}').contains(&c) || c == '|' +} + +/// How a row continues onto the one below it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Join { + /// The terminal's wrap, read exactly as drawn. + Raw, + /// An application's word wrap, continued under the row's text. + App, +} + +/// One row of a run: the columns of it read, and how the row below +/// continues it, if it does. +#[derive(Debug)] +struct Piece { + row: u16, + columns: Range, + below: Option, +} + +/// A read cell: where its text starts in the joined text, and where it is. +#[derive(Debug, Clone, Copy)] +struct Placed { + byte: usize, + row: u16, + column: u16, + continuation: bool, +} + +/// The text of the rows a word spans, read as one, and the cells it came +/// from. +#[derive(Debug)] +pub(super) struct Joined { + pub(super) text: String, + cells: Vec, + /// Where each row a raw wrap continues starts in `text`. + raw_joins: Vec, + /// Whether the last row read is the bottom of the bounds, where the text + /// may continue off-screen. + pub(super) ends_at_bottom: bool, +} + +impl Joined { + /// Where the cell at `row` and `column` starts in the text. + pub(super) fn byte_at(&self, row: u16, column: u16) -> Option { + self.cells + .iter() + .find(|cell| cell.row == row && cell.column == column) + .map(|cell| cell.byte) + } + + /// Whether the text in `range` runs across a raw wrap. + pub(super) fn crosses_raw_join(&self, range: &Range) -> bool { + self.first_raw_join(range).is_some() + } + + /// Where the first raw wrap inside `range` starts its row in the text. + pub(super) fn first_raw_join(&self, range: &Range) -> Option { + self.raw_joins + .iter() + .copied() + .find(|&join| range.start < join && join < range.end) + } + + /// Whether the word holding `range` is cut off with an ellipsis, the way + /// an application shortens text that does not fit. + pub(super) fn cut_off(&self, range: &Range) -> bool { + self.text[range.start..] + .split(char::is_whitespace) + .next() + .is_some_and(|word| word.contains('…')) + } + + /// The frame cells the text in `range` covers, one `(row, columns)` per + /// row, a wide glyph's continuation cells included. + pub(super) fn segments(&self, range: &Range) -> Option)>> { + let first = self + .cells + .iter() + .position(|cell| !cell.continuation && cell.byte >= range.start)?; + let last = self + .cells + .iter() + .rposition(|cell| !cell.continuation && cell.byte < range.end)?; + let row = self.cells[last].row; + let mut end = last + 1; + while self + .cells + .get(end) + .is_some_and(|cell| cell.continuation && cell.row == row) + { + end += 1; + } + let mut rows: Vec<(u16, Range)> = Vec::new(); + for cell in self.cells.get(first..end)? { + match rows.last_mut() { + Some((row, columns)) if *row == cell.row => columns.end = cell.column + 1, + _ => rows.push((cell.row, cell.column..cell.column + 1)), + } + } + Some(rows) + } +} + +/// The rows the word at `column` of `row` spans, joined; `None` past the +/// row or byte cap, which is never read as a link cut short. +pub(super) fn read(frame: &FrameData, bounds: &Bounds, row: u16, column: u16) -> Option { + let grid = Grid { frame, bounds }; + let mut pieces = vec![Piece { + row, + columns: bounds.columns.clone(), + below: None, + }]; + if grid.content(row, column) { + let word = grid.word(row, column); + grid.walk_down(&mut pieces, word.clone())?; + grid.walk_up(&mut pieces, word)?; + } + grid.joined(&pieces) +} + +/// The rows an explicit hyperlink covers, starting from its `columns` on +/// `row`: it goes on at the next row's first cell when that carries the same +/// link, and back onto the row above when it starts a row. `same` says +/// whether a cell carries the link. +pub(super) fn hyperlink_rows( + frame: &FrameData, + bounds: &Bounds, + row: u16, + columns: Range, + same: impl Fn(&CellData) -> bool, +) -> Vec<(u16, Range)> { + let grid = Grid { frame, bounds }; + let (start, end) = (bounds.columns.start, bounds.columns.end); + let carries = |row: u16, column: u16| grid.cell(row, column).is_some_and(&same); + let on_link = |row: u16, column: u16| { + grid.cell(row, column) + .is_some_and(|cell| cell.skip || same(cell)) + }; + let mut rows = vec![(row, columns)]; + while let Some((last, covered)) = rows.last() + && covered.end == end + && let Some(next) = last.checked_add(1) + && carries(next, start) + { + let stop = (start..end).find(|&c| !on_link(next, c)).unwrap_or(end); + rows.push((next, start..stop)); + } + while let Some((first, covered)) = rows.first() + && covered.start == start + && let Some(above) = first.checked_sub(1) + && carries(above, end - 1) + { + let from = (start..end) + .rev() + .find(|&c| !on_link(above, c)) + .map_or(start, |c| c + 1); + rows.insert(0, (above, from..end)); + } + rows +} + +/// The walls around a word: the nearest frame characters beside it that +/// the row it may join also draws in the same column. `None` is the pane's +/// edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Walls { + left: Option, + right: Option, +} + +struct Grid<'a> { + frame: &'a FrameData, + bounds: &'a Bounds, +} + +impl Grid<'_> { + fn cell(&self, row: u16, column: u16) -> Option<&CellData> { + if !self.bounds.rows.contains(&row) + || !self.bounds.columns.contains(&column) + || column >= self.frame.width + { + return None; + } + let index = usize::from(row) * usize::from(self.frame.width) + usize::from(column); + self.frame.cells.get(index) + } + + fn blank(&self, row: u16, column: u16) -> bool { + self.cell(row, column) + .is_none_or(|cell| !cell.skip && shown(cell).trim().is_empty()) + } + + fn frame_at(&self, row: u16, column: u16) -> bool { + self.cell(row, column).is_some_and(|cell| { + let mut chars = shown(cell).chars(); + !cell.skip && chars.next().is_some_and(frame_char) && chars.next().is_none() + }) + } + + fn content(&self, row: u16, column: u16) -> bool { + !self.blank(row, column) && !self.frame_at(row, column) + } + + /// The run of text cells around `column`, bounded by blanks and frames. + fn word(&self, row: u16, column: u16) -> Range { + let mut start = column; + while start > self.bounds.columns.start && self.content(row, start - 1) { + start -= 1; + } + let mut end = column + 1; + while end < self.bounds.columns.end && self.content(row, end) { + end += 1; + } + start..end + } + + fn walls(&self, row: u16, word: &Range, other: u16) -> Walls { + let wall = |column: &u16| self.frame_at(row, *column) && self.frame_at(other, *column); + Walls { + left: (self.bounds.columns.start..word.start).rev().find(wall), + right: (word.end..self.bounds.columns.end).find(wall), + } + } + + /// The columns between `walls`. + fn inner(&self, walls: Walls) -> Range { + walls + .left + .map_or(self.bounds.columns.start, |left| left + 1) + ..walls.right.unwrap_or(self.bounds.columns.end) + } + + fn content_start(&self, row: u16, inner: &Range) -> Option { + inner.clone().find(|&column| self.content(row, column)) + } + + fn content_end(&self, row: u16, inner: &Range) -> Option { + inner + .clone() + .rev() + .find(|&column| self.content(row, column)) + } + + /// How the row below continues `word` on `row`, and the column the + /// continuation starts at. + fn join_down(&self, row: u16, word: &Range) -> Option<(Join, u16)> { + let next = row.checked_add(1)?; + if !self.bounds.rows.contains(&next) { + return None; + } + self.raw_join(row, word) + .map(|start| (Join::Raw, start)) + .or_else(|| self.app_join(row, word).map(|start| (Join::App, start))) + } + + /// The terminal's wrap: no walls, the word reaches the last column, or + /// stops one short where a wide glyph did not fit, and the next row's + /// first cell holds text. + fn raw_join(&self, row: u16, word: &Range) -> Option { + let (start, end) = (self.bounds.columns.start, self.bounds.columns.end); + let next = row + 1; + let unwalled = self.walls(row, word, next) + == Walls { + left: None, + right: None, + }; + let wide_next = || { + self.cell(next, start + 1).is_some_and(|cell| cell.skip) + || self + .cell(next, start) + .is_some_and(|cell| shown(cell).width() > 1) + }; + let at_edge = + word.end == end || (word.end + 1 == end && self.blank(row, end - 1) && wide_next()); + (unwalled && at_edge && self.content(next, start)).then_some(start) + } + + /// An application's word wrap: the next row, in the same block, starts + /// under this row's text, this word ends at the block's wrap column, and + /// `wrap-ansi` would have started the word on this row. + fn app_join(&self, row: u16, word: &Range) -> Option { + let next = row + 1; + let walls = self.walls(row, word, next); + let inner = self.inner(walls); + let next_start = self.content_start(next, &inner)?; + if !self.continues_under(row, next, &inner, next_start) { + return None; + } + let wrap = self.wrap_column(row, walls, &inner); + if word.end - 1 != wrap || inner.end - wrap - 1 > MAX_PADDING { + return None; + } + if self.in_table(row, walls) && !self.outside_blank(next, walls) { + return None; + } + let width = (wrap + 1).checked_sub(next_start)?; + let length = self.word_length(row, word, wrap, &inner); + breaks_here(word.end - word.start, length, width).then_some(next_start) + } + + /// Whether `next` starts at `row`'s text, or at the text after the + /// marker that starts `row`, with nothing but blanks, or the frames + /// `row` draws too, before it. + fn continues_under(&self, row: u16, next: u16, inner: &Range, next_start: u16) -> bool { + let Some(start) = self.content_start(row, inner) else { + return false; + }; + let aligned = + next_start == start || self.after_marker(row, start, inner) == Some(next_start); + aligned + && (inner.start..next_start).all(|column| { + self.blank(next, column) + || (self.cell(next, column).map(|cell| &cell.symbol) + == self.cell(row, column).map(|cell| &cell.symbol)) + }) + } + + /// Where the text after `row`'s first word starts, when that word is a + /// marker: a short run without letters, such as `●`, `-`, or `12`. + fn after_marker(&self, row: u16, start: u16, inner: &Range) -> Option { + let first = self.word(row, start); + let marker = first.end - first.start <= MAX_MARKER + && first.clone().all(|column| { + self.cell(row, column) + .is_some_and(|cell| !shown(cell).chars().any(char::is_alphabetic)) + }); + marker + .then(|| (first.end..inner.end).find(|&column| self.content(row, column))) + .flatten() + } + + /// The block's wrap column: the furthest any text reaches between the + /// same walls on the rows around `row`. + fn wrap_column(&self, row: u16, walls: Walls, inner: &Range) -> u16 { + let same = |other: &u16| { + walls.left.is_none_or(|left| self.frame_at(*other, left)) + && walls.right.is_none_or(|right| self.frame_at(*other, right)) + }; + let rows = &self.bounds.rows; + let above = (rows.start.max(row.saturating_sub(BLOCK_SCAN))..row) + .rev() + .take_while(same); + let below = (row..rows.end.min(row.saturating_add(BLOCK_SCAN + 1))).take_while(same); + above + .chain(below) + .filter_map(|other| self.content_end(other, inner)) + .max() + .unwrap_or(inner.start) + } + + /// Whether the word's walls are a table's: a neighbouring cell beyond + /// either wall holds text on `row`. + fn in_table(&self, row: u16, walls: Walls) -> bool { + let open = |column: &u16| !self.frame_at(row, *column); + let filled = |column: u16| self.content(row, column); + let (start, end) = (self.bounds.columns.start, self.bounds.columns.end); + let beyond_right = walls + .right + .is_some_and(|right| (right + 1..end).take_while(open).any(filled)); + let beyond_left = walls + .left + .is_some_and(|left| (start..left).rev().take_while(open).any(filled)); + beyond_right || beyond_left + } + + /// Whether `row` holds no text outside `walls`. + fn outside_blank(&self, row: u16, walls: Walls) -> bool { + let (start, end) = (self.bounds.columns.start, self.bounds.columns.end); + let left = start..walls.left.unwrap_or(start); + let right = walls.right.map_or(end, |right| right + 1)..end; + left.chain(right).all(|column| !self.content(row, column)) + } + + /// The cells of the whole word that starts with `word` on `row`, down + /// to the first row where it stops before the wrap column. + fn word_length(&self, row: u16, word: &Range, wrap: u16, inner: &Range) -> u16 { + let mut length = word.end - word.start; + let mut other = row; + for _ in 0..MAX_ROWS { + other = other.saturating_add(1); + let Some(start) = self.content_start(other, inner) else { + break; + }; + let next = self.word(other, start); + length = length.saturating_add(next.end - next.start); + if next.end - 1 != wrap { + break; + } + } + length + } + + /// Adds the rows below that continue `word`, the last piece's word. + fn walk_down(&self, pieces: &mut Vec, mut word: Range) -> Option<()> { + while let Some(last) = pieces.last_mut() + && let Some((join, start)) = self.join_down(last.row, &word) + { + last.columns.end = word.end; + last.below = Some(join); + let next = last.row + 1; + pieces.push(Piece { + row: next, + columns: start..self.bounds.columns.end, + below: None, + }); + if pieces.len() > MAX_ROWS { + return None; + } + word = self.word(next, start); + } + Some(()) + } + + /// Adds the rows above whose last word `word`, the first piece's, + /// continues. + fn walk_up(&self, pieces: &mut Vec, mut word: Range) -> Option<()> { + while let Some(first) = pieces.first() + && let Some(above) = first.row.checked_sub(1) + && self.bounds.rows.contains(&above) + && let Some(end) = + self.content_end(above, &self.inner(self.walls(first.row, &word, above))) + { + let candidate = self.word(above, end); + match self.join_down(above, &candidate) { + Some((join, start)) if start == word.start => { + pieces[0].columns.start = start; + pieces.insert( + 0, + Piece { + row: above, + columns: self.bounds.columns.start..candidate.end, + below: Some(join), + }, + ); + } + _ => break, + } + if pieces.len() > MAX_ROWS { + return None; + } + word = candidate; + } + Some(()) + } + + fn joined(&self, pieces: &[Piece]) -> Option { + let mut joined = Joined { + text: String::new(), + cells: Vec::new(), + raw_joins: Vec::new(), + ends_at_bottom: pieces + .last() + .is_some_and(|piece| piece.row + 1 == self.bounds.rows.end), + }; + let mut above = None; + for piece in pieces { + if above == Some(Join::Raw) { + joined.raw_joins.push(joined.text.len()); + } + above = piece.below; + let row_start = joined.text.len(); + for column in piece.columns.clone() { + let cell = self.cell(piece.row, column)?; + joined.cells.push(Placed { + byte: joined.text.len(), + row: piece.row, + column, + continuation: cell.skip, + }); + if cell.skip { + continue; + } + let symbol = shown(cell); + if joined.text.len() - row_start + symbol.len() > MAX_ROW_BYTES { + return None; + } + joined.text.push_str(symbol); + } + } + (pieces.len() == 1 || joined.text.len() <= MAX_JOINED_BYTES).then_some(joined) + } +} + +/// Whether `wrap-ansi` breaks a word of `length` cells, too long for rows of +/// `width`, starting it in the `first` cells left on the current row rather +/// than on a row of its own: when that costs no extra row. +fn breaks_here(first: u16, length: u16, width: u16) -> bool { + if width == 0 || first > width || length <= width { + return false; + } + first == width || (length - 1) / width > (length - first - 1) / width +} diff --git a/crates/herdr-gpui/src/window.rs b/crates/herdr-gpui/src/window.rs index f92760037..9ebff7167 100644 --- a/crates/herdr-gpui/src/window.rs +++ b/crates/herdr-gpui/src/window.rs @@ -20,6 +20,9 @@ mod device_themes; mod double_shift; mod file_drop; mod file_links; +#[cfg(test)] +pub(crate) use file_links::opened; +pub(crate) use file_links::{opened_url, start_browser}; mod find; mod flash; pub(crate) use commands::run_window_command; @@ -40,6 +43,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; @@ -163,6 +168,9 @@ pub(crate) struct HerdrWindow { pub(crate) copy_mode: Option, /// Prepared daemon announcement and release notes text. pub(crate) daemon_text: crate::release_notes::DaemonText, + /// Starts the default browser on a page with a query or fragment, which + /// the system opener would drop. + pub(crate) default_browser: file_links::StartBrowser, /// Notes waiting for their agents to be ready for them. pub(crate) deliveries: crate::agent_notes::Deliveries, /// A press on a device's header that may lift it for reordering. @@ -277,6 +285,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 +562,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..d9b492ac9 100644 --- a/crates/herdr-gpui/src/window/construct.rs +++ b/crates/herdr-gpui/src/window/construct.rs @@ -165,6 +165,7 @@ impl HerdrWindow { configured_terminal_size, copy_mode: None, daemon_text: Default::default(), + default_browser: super::start_browser(), deliveries: Default::default(), device_drag: None, device_filter: None, @@ -239,6 +240,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 +307,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..0ae505302 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 @@ -15,8 +16,9 @@ use super::HerdrWindow; use crate::editor::EditorTarget; -use crate::terminal::{PaneLink, RowTarget, pane_link_at}; +use crate::terminal::{PaneLink, RowTarget}; use gpui::{Context, Pixels, Point}; +use herdr_client::ConnectTarget; use std::{ fs::Metadata, path::{Component, Path, PathBuf, Prefix}, @@ -33,18 +35,36 @@ const DOCUMENTS: [&str; 52] = [ "zig", ]; +/// Extensions of the pages a link's `?query` or `#fragment` is kept for, by +/// opening them in the default browser: web pages and PDFs. A file with no +/// extension is one too. +const PAGES: [&str; 5] = [ + // keep-sorted start + "htm", "html", "pdf", "svg", "xhtml", + // keep-sorted end +]; + /// A path a pane printed, the line printed after it, and the pane and the /// directory it was in. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct FileLink { pub(crate) path: String, pub(crate) line: Option, + /// The `?query` or `#fragment` printed after the path, with its `?` or + /// `#`. A page that opens itself opens in the default browser with it. + pub(crate) query: Option, pub(crate) pane_id: String, pub(crate) cwd: Option, /// Whether an explicit hyperlink named the path. Such a path is a link /// 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, + /// The path the first row alone reads, when a raw wrap joined the next + /// row's text to it. A local path ignores it. + pub(crate) unjoined: Option, } /// Why a path has no real one. @@ -156,8 +176,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 @@ -182,6 +202,86 @@ fn opened(path: &Path) -> Option { .map(Path::to_owned) } +/// The address the system opens for a click on `path`: the page itself with +/// `query` when [`page_url`] keeps it, and otherwise what [`opened`] +/// decides, without it. Reads the disk. +pub(crate) fn opened_url(path: &Path, query: Option<&str>) -> Option { + page_url(path, query).or_else(|| url::Url::from_file_path(opened(path)?).ok()) +} + +/// The address of the file at `path` with `query`, for the default browser, +/// when there is a query and the file is a page: a regular file that is a +/// web page, a PDF, or has no extension. A folder never takes a query. +/// Reads the disk. +fn page_url(path: &Path, query: Option<&str>) -> Option { + let query = query?; + let real = real_path(path).ok()?; + if !std::fs::metadata(&real).is_ok_and(|metadata| metadata.is_file()) { + return None; + } + let page = real.extension().is_none_or(|extension| { + PAGES.contains(&extension.to_string_lossy().to_ascii_lowercase().as_str()) + }); + if !page { + return None; + } + let url = url::Url::from_file_path(&real).ok()?; + Some(with_query(url, query)) +} + +/// `url` with the `?query` and `#fragment` of `suffix`, which holds them as +/// [`FileLink::query`] does. +fn with_query(mut url: url::Url, suffix: &str) -> url::Url { + let (query, fragment) = match suffix.strip_prefix('#') { + Some(fragment) => (None, Some(fragment)), + None => { + let rest = suffix.strip_prefix('?').unwrap_or(suffix); + match rest.split_once('#') { + Some((query, fragment)) => (Some(query), Some(fragment)), + None => (Some(rest), None), + } + } + }; + url.set_query(query); + url.set_fragment(fragment); + url +} + +/// Starts the default browser on an address: [`crate::default_browser::open`] +/// in the app, and a fake in tests. Blocking. +pub(crate) type StartBrowser = + std::sync::Arc crate::default_browser::Result<()> + Send + Sync>; + +/// Whether `error` says this platform cannot start its default browser with +/// a URL at all, which every page click there meets: not worth a warning. +#[cfg(not(windows))] +fn unsupported(error: &crate::default_browser::Error) -> bool { + matches!(error, crate::default_browser::Error::Unsupported) +} + +/// Windows always can; any failure there is worth a warning. +#[cfg(windows)] +fn unsupported(_: &crate::default_browser::Error) -> bool { + false +} + +/// The default browser a new window starts pages in. +#[cfg(not(test))] +pub(crate) fn start_browser() -> StartBrowser { + std::sync::Arc::new(crate::default_browser::open) +} + +/// A test window never starts a real browser: this one always fails, so the +/// system opener answers unless a test sets its own. +#[cfg(test)] +pub(crate) fn start_browser() -> StartBrowser { + std::sync::Arc::new(|_: &url::Url| { + Err(crate::default_browser::Error::UnparsableCommand { + command: "test window".into(), + }) + }) +} + impl FileLink { /// Whether the path is there for a click to open. This reads the disk, /// so it runs off the UI thread. @@ -208,7 +308,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() @@ -217,15 +318,23 @@ impl HerdrWindow { return None; } let (x, y) = self.drawn_position(position)?; - let link = pane_link_at( + let link = self.link_reading((x, y)).pane_link_at( self.live.surface.as_deref()?, x, y, 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. @@ -240,7 +349,9 @@ impl HerdrWindow { let RowTarget::Path { path, line, + query, explicit, + unjoined, } = link.target else { return None; @@ -252,23 +363,66 @@ impl HerdrWindow { Some(FileLink { path, line, + query, pane_id, cwd, explicit, + remote: self.ssh_target(), + unjoined, }) } /// Opens `path` with the system's default application under the same /// rule as a clicked path: a document itself, and otherwise the folder - /// holding it, so an executable or a bundle is never launched. Checked - /// on the background executor. - pub(crate) fn open_in_system_app(&self, path: PathBuf, cx: &mut Context) { + /// holding it, so an executable or a bundle is never launched. A page + /// opens with `query`, the `?query` or `#fragment` a link printed after + /// it; see [`page_url`] and [`Self::open_file_url`]. Checked on the + /// background executor. + pub(crate) fn open_in_system_app( + &self, + path: PathBuf, + query: Option, + cx: &mut Context, + ) { let found = cx .background_executor() - .spawn(async move { url::Url::from_file_path(opened(&path)?).ok() }); - cx.spawn(async move |_, cx| { + .spawn(async move { opened_url(&path, query.as_deref()) }); + cx.spawn(async move |this, cx| { if let Some(url) = found.await { - cx.update(|cx| cx.open_url(url.as_str())); + // The window may have closed meanwhile; nothing opens then. + let _ = this.update(cx, |this, cx| this.open_file_url(url, cx)); + } + }) + .detach(); + } + + /// Opens the `file://` address `url` with the system's opener, which + /// drops a query and a fragment. One that has either opens in the + /// default browser, started on the background executor, which keeps + /// them and never runs the file. When it cannot be started, the system + /// opener opens what a click on the file opens without them, by + /// [`opened`]: the page itself when it is a document, and otherwise the + /// folder holding it, so an executable page is never launched. + pub(crate) fn open_file_url(&self, url: url::Url, cx: &mut Context) { + if url.query().is_none() && url.fragment().is_none() { + cx.open_url(url.as_str()); + return; + } + let browser = self.default_browser.clone(); + let fallback = cx.background_executor().spawn(async move { + let error = browser(&url).err()?; + if unsupported(&error) { + tracing::debug!(%error, "default browser not started; opening without the query"); + } else { + tracing::warn!(%error, "default browser not started; opening without the query"); + } + let path = url.to_file_path().ok()?; + url::Url::from_file_path(opened(&path)?).ok() + }); + cx.spawn(async move |this, cx| { + if let Some(url) = fallback.await { + // The window may have closed meanwhile; nothing opens then. + let _ = this.update(cx, |_, cx| cx.open_url(url.as_str())); } }) .detach(); @@ -295,6 +449,11 @@ impl HerdrWindow { Err(Unresolved::Missing) if link.explicit => return Some(Opened::Missing(path)), Err(_) => return None, }; + // A page with a query opens in the default browser with it, + // wherever its kind would otherwise open. + if let Some(url) = page_url(&path, link.query.as_deref()) { + return Some(Opened::System(url)); + } if in_editor && std::fs::metadata(&real) .is_ok_and(|metadata| EditorTarget::editable(&real, &metadata)) @@ -320,7 +479,7 @@ impl HerdrWindow { // asked for then. let _ = this.update(cx, |this, cx| match opened { Opened::Editor(target) => this.open_in_editor(&target, Some(&pane), cx), - Opened::System(url) => cx.open_url(url.as_str()), + Opened::System(url) => this.open_file_url(url, cx), Opened::Missing(path) => this.show_flash( super::flash::Flash::warning( crate::Error::LinkedFileMissing { path }.to_string(), diff --git a/crates/herdr-gpui/src/window/file_links/tests.rs b/crates/herdr-gpui/src/window/file_links/tests.rs index 0ca19b2dd..641640245 100644 --- a/crates/herdr-gpui/src/window/file_links/tests.rs +++ b/crates/herdr-gpui/src/window/file_links/tests.rs @@ -10,6 +10,10 @@ use herdr_client::{ }; use std::sync::Arc; +mod queries; +mod remote; +mod remote_saves; + #[test] fn paths_resolve_under_home_or_the_pane_directory() { let home = Path::new("home"); @@ -17,9 +21,12 @@ fn paths_resolve_under_home_or_the_pane_directory() { let link = |path: &str, cwd: Option<&Path>| FileLink { path: path.into(), line: None, + query: None, pane_id: "pane".into(), cwd: cwd.map(|cwd| cwd.to_str().unwrap().into()), explicit: false, + remote: None, + unjoined: None, }; assert_eq!( link("~/notes/a.md", None).resolve(Some(home)), @@ -238,7 +245,7 @@ fn a_link_modifier_click_opens_a_printed_file_that_exists(cx: &mut gpui::TestApp cx.run_until_parked(); view.read_with(cx, |view, _| { let link = view.hovered_local_link().unwrap(); - assert_eq!((link.row, link.columns.clone()), (0, 4..20)); + assert_eq!(link.rows, vec![(0, 4..20)]); assert!(view.terminal_link_hovered(found, Modifiers::secondary_key())); assert!(view.link_modifier_held(found, Modifiers::secondary_key())); }); @@ -280,22 +287,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/queries.rs b/crates/herdr-gpui/src/window/file_links/tests/queries.rs new file mode 100644 index 000000000..f5dc4506e --- /dev/null +++ b/crates/herdr-gpui/src/window/file_links/tests/queries.rs @@ -0,0 +1,181 @@ +//! Paths a pane printed with a `?query` or `#fragment`: found on disk by +//! their path, and a page among them opened in the default browser with the +//! rest. Every other file opens as it would without it. +use super::*; +use std::sync::Mutex; + +/// The addresses a fake default browser was started on. +type Asked = Arc>>; + +/// Has `view` start a fake default browser, which records each address it +/// is started on and starts when `starts`. +fn fake_browser(view: &Entity, cx: &mut VisualTestContext, starts: bool) -> Asked { + let asked = Asked::default(); + let record = asked.clone(); + view.update(cx, |view, _| { + view.default_browser = Arc::new(move |url: &url::Url| { + record.lock().unwrap().push(url.to_string()); + if starts { + Ok(()) + } else { + Err(crate::default_browser::Error::UnparsableCommand { + command: "fake".into(), + }) + } + }); + }); + asked +} + +/// A folder holding `files`, each a path under it, which the pane runs in. +fn folder(files: &[&str]) -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + for file in files { + let path = root.path().join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "x").unwrap(); + } + root +} + +/// The address the system opens for `path` under `root`, symlinks resolved. +fn address(root: &tempfile::TempDir, path: &str) -> String { + let real = real_path(&root.path().join(path)).unwrap(); + url::Url::from_file_path(real).unwrap().to_string() +} + +/// Opens in the system's default application rather than the editor. +fn system() -> Modifiers { + Modifiers { + alt: true, + ..Modifiers::secondary_key() + } +} + +#[gpui::test] +fn a_printed_page_with_a_query_opens_in_the_default_browser_with_it(cx: &mut gpui::TestAppContext) { + let root = folder(&["m/index.html"]); + let (view, cx) = window( + "open m/index.html?sheet=1 or m/missing.html?x=1 now", + root.path(), + cx, + ); + let page = at(&view, cx, 6); + let missing = at(&view, cx, 32); + + // Found by its path, the page is underlined with its query. + cx.simulate_mouse_move(page, None, Modifiers::secondary_key()); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + assert_eq!(view.hovered_local_link().unwrap().rows, vec![(0, 5..25)]); + }); + // A page that is not there stays plain text, query or not. + cx.simulate_mouse_move(missing, None, Modifiers::secondary_key()); + cx.run_until_parked(); + view.read_with(cx, |view, _| assert!(view.hovered_local_link().is_none())); + + let bare = address(&root, "m/index.html"); + let expected = format!("{bare}?sheet=1"); + let asked = fake_browser(&view, cx, true); + modifier_click(cx, page, system()); + assert_eq!(*asked.lock().unwrap(), vec![expected.clone()]); + assert!(cx.opened_url().is_none()); + + // A browser that cannot be started leaves the page to the system's + // opener, without the query it would drop anyway. + let asked = fake_browser(&view, cx, false); + modifier_click(cx, page, system()); + assert_eq!(*asked.lock().unwrap(), vec![expected]); + assert_eq!(cx.opened_url(), Some(bare)); +} + +#[gpui::test] +fn a_file_without_an_extension_and_a_pdf_page_open_in_the_browser(cx: &mut gpui::TestAppContext) { + let root = folder(&["srv/query", "m/guide.pdf"]); + let (view, cx) = window( + "see srv/query?x=1 and m/guide.pdf#page=3 ok", + root.path(), + cx, + ); + let asked = fake_browser(&view, cx, true); + // Even where a file would open in the editor, a page with a query + // opens in the browser. + let position = at(&view, cx, 6); + modifier_click(cx, position, Modifiers::secondary_key()); + let position = at(&view, cx, 24); + modifier_click(cx, position, Modifiers::secondary_key()); + assert_eq!( + *asked.lock().unwrap(), + vec![ + format!("{}?x=1", address(&root, "srv/query")), + format!("{}#page=3", address(&root, "m/guide.pdf")), + ] + ); + assert!(cx.opened_url().is_none()); +} + +#[gpui::test] +fn a_file_that_is_no_page_opens_as_it_would_without_the_query(cx: &mut gpui::TestAppContext) { + let root = folder(&["m/tool.py"]); + let (view, cx) = window("run m/tool.py?x=1 now", root.path(), cx); + let asked = fake_browser(&view, cx, true); + // A script opens the folder holding it, which takes no query. + let position = at(&view, cx, 6); + modifier_click(cx, position, system()); + assert!(asked.lock().unwrap().is_empty()); + let opened = cx.opened_url().unwrap(); + assert_eq!(opened, address(&root, "m")); + assert!(!opened.contains('?'), "{opened}"); +} + +#[gpui::test] +fn a_line_fragment_names_the_line_and_never_reaches_the_browser(cx: &mut gpui::TestAppContext) { + let root = folder(&["docs/notes.txt", "src/main.rs"]); + let (view, cx) = window( + "see docs/notes.txt#L3 or src/main.rs#L10 ok", + root.path(), + cx, + ); + let notes = at(&view, cx, 6); + let main = at(&view, cx, 28); + view.read_with(cx, |view, _| { + let link = view.file_link_at(notes).unwrap(); + assert_eq!(link.path, "docs/notes.txt"); + assert_eq!(link.line, Some(3)); + assert_eq!(link.query.as_deref(), Some("#L3")); + let link = view.file_link_at(main).unwrap(); + assert_eq!(link.path, "src/main.rs"); + assert_eq!(link.line, Some(10)); + }); + + // In the editor or the system's application, these files open as they + // would without the fragment, never in the browser. + let asked = fake_browser(&view, cx, true); + for (position, path) in [(notes, "docs/notes.txt"), (main, "src/main.rs")] { + modifier_click(cx, position, Modifiers::secondary_key()); + modifier_click(cx, position, system()); + assert!(asked.lock().unwrap().is_empty(), "{path}"); + assert_eq!(cx.opened_url(), Some(address(&root, path)), "{path}"); + } +} + +/// The browser never runs a page, but the system opener may: an executable +/// one it is left falls back to the folder holding it, as a click on it +/// without a query does. +#[cfg(unix)] +#[gpui::test] +fn an_executable_page_the_browser_cannot_open_opens_its_folder(cx: &mut gpui::TestAppContext) { + use std::os::unix::fs::PermissionsExt; + let root = folder(&["m/tool"]); + let tool = root.path().join("m/tool"); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + let (view, cx) = window("run m/tool?x=1 now", root.path(), cx); + let asked = fake_browser(&view, cx, false); + let position = at(&view, cx, 6); + modifier_click(cx, position, system()); + assert_eq!( + *asked.lock().unwrap(), + vec![format!("{}?x=1", address(&root, "m/tool"))] + ); + assert_eq!(cx.opened_url(), Some(address(&root, "m"))); +} 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..f18398f2e --- /dev/null +++ b/crates/herdr-gpui/src/window/file_links/tests/remote.rs @@ -0,0 +1,110 @@ +//! 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_missing_file_asks_for_another_path_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()); + view.update_in(cx, |view, window, cx| view.poll_remote_files(window, cx)); + assert_eq!(host.asked().len(), 1); + assert!(cx.opened_url().is_none()); + view.read_with(cx, |view, _| { + assert_eq!(view.menu.page, Some(crate::menu::Page::RemoteMissing)); + let (flash, _) = view.flash.as_ref().unwrap(); + assert_ne!(flash.tone, Tone::Warning, "no failure flash"); + assert_eq!(flash.text.as_ref(), "Fetching gone.md from me@devbox…"); + 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/input.rs b/crates/herdr-gpui/src/window/input.rs index 6e9e0a5a3..7d513c279 100644 --- a/crates/herdr-gpui/src/window/input.rs +++ b/crates/herdr-gpui/src/window/input.rs @@ -166,7 +166,7 @@ impl HerdrWindow { return None; } let (x, y) = self.drawn_position(position)?; - crate::terminal::link_at( + self.link_reading((x, y)).link_at( self.live.surface.as_deref()?, x, y, diff --git a/crates/herdr-gpui/src/window/links.rs b/crates/herdr-gpui/src/window/links.rs index a21a3b0c6..83c7b0414 100644 --- a/crates/herdr-gpui/src/window/links.rs +++ b/crates/herdr-gpui/src/window/links.rs @@ -10,14 +10,17 @@ //! daemon first when it offers activation, which lets a plugin link handler //! claim it, `file://` links included; otherwise, or when nothing claims it, //! this client opens the address or file itself. A handler that fails spends -//! the click. Daemons without these methods keep the row-local detector. +//! the click. Daemons without these methods keep the local detector, which +//! also reads a link that wraps onto the next rows whole. Where the local +//! reading strictly contains the daemon's, as at an application's word wrap, +//! the local one is underlined and opened. -use super::{HerdrWindow, file_links::FileLink}; +use super::{HerdrWindow, RemotePath, file_links::FileLink, remote_paths}; use crate::terminal::PaneLink; use crate::{ browser::WebUrl, links::{Activation, LinkCell, LinkInbox, LinkRequest, ResolvedLink}, - terminal::{ExternalUrl, RowLink, RowTarget}, + terminal::{ExternalUrl, InputTarget, LinkReading, RowLink, RowTarget, wheel_target}, }; use gpui::{Context, Modifiers, Pixels, Point, Task, Window}; use std::{ @@ -86,6 +89,10 @@ struct PendingActivation { /// What the row-local detector read at the click, opened when the /// daemon neither handles nor reads a web address there. fallback: Option, + /// Whether the fallback wins over the address the daemon declines with: + /// the local reading strictly contains the daemon's link, which it + /// reads cut off at an application's word wrap. + local_first: bool, in_tab: bool, } @@ -200,12 +207,92 @@ impl HerdrWindow { .then_some(link) } - /// The verified row-local link under the pointer while the link modifier + /// The verified local link under the pointer while the link modifier /// is held. pub(crate) fn hovered_local_link(&self) -> Option<&RowLink> { self.links.local.as_ref() } + /// How this client reads plain text on the selected daemon, at the + /// `drawn` surface point it is about to read. + pub(crate) fn link_reading(&self, drawn: (f32, f32)) -> LinkReading { + LinkReading { + raw_url_wraps: !self.live.supports_link_resolve, + windows_paths: self.windows_paths_at(drawn), + } + } + + /// Whether the pane at the `drawn` surface point runs on a host that + /// writes Windows paths, judged by its working directory as a file link + /// resolves against it; by this machine when no directory is known. + fn windows_paths_at(&self, (x, y): (f32, f32)) -> bool { + let pane_id = self.live.surface.as_deref().and_then(|surface| { + let hit = wheel_target( + surface, + x, + y, + self.cell_width, + self.config.terminal.line_height(), + )?; + match hit.target { + InputTarget::Pane(id) => Some(id), + InputTarget::Popup(_) => None, + } + }); + let cwd = pane_id.and_then(|pane_id| { + let snapshot = self.live.snapshot.as_ref()?; + let pane = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id)?; + pane.foreground_cwd.clone().or_else(|| pane.cwd.clone()) + }); + match cwd.as_deref() { + Some(cwd) if remote_paths::windows(cwd) => true, + Some(cwd) if cwd.starts_with('/') => false, + _ => cfg!(windows), + } + } + + /// The cells to underline: the daemon's resolved link while it reads + /// the content on screen, unless the local reading strictly contains + /// it, as it does for an application's word wrap, which reads to the + /// daemon as separate lines; otherwise the local reading. + pub(crate) fn underlined_link_rows(&self) -> Vec<(u16, std::ops::Range)> { + let daemon: Option> = self + .live + .surface + .as_deref() + .zip(self.hovered_daemon_link()) + .filter(|(surface, link)| link.cell.current(surface)) + .map(|(_, link)| link.frame_rows().collect()); + let local = self.hovered_local_link(); + match (daemon, local) { + (Some(daemon), Some(local)) if local.strictly_contains(&daemon) => local.rows.clone(), + (Some(daemon), _) => daemon, + (None, Some(local)) => local.rows.clone(), + (None, None) => Vec::new(), + } + } + + /// Whether the local reading at `position` strictly contains the + /// daemon's resolved link there, so a click opens the local target when + /// no handler claims it. A click before the hover's answer arrives has + /// no resolved link to compare: then a local reading spanning more than + /// one row wins. It is an application's word wrap or a raw wrap, and a + /// daemon offering `pane.link.resolve` is left every raw-wrapped + /// address, so a multi-row address read here is one the daemon reads as + /// separate lines. A handler still claims the click first. + fn local_contains_daemon(&self, position: Point) -> bool { + let Some(local) = self.local_link_at(position) else { + return false; + }; + match self.hovered_daemon_link() { + Some(daemon) => { + let rows: Vec<_> = daemon.frame_rows().collect(); + local.link.strictly_contains(&rows) + } + None => local.link.rows.len() > 1, + } + } + /// The cell at `position` when it lies on the hovered resolved link. pub(crate) fn daemon_link_at(&self, position: Point) -> Option { let cell = self.link_cell_at(position)?; @@ -216,6 +303,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 +340,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) }) } @@ -518,13 +615,18 @@ impl HerdrWindow { if self.links.activating.is_some() { return true; } + let local_first = self.local_contains_daemon(release.position); let connection = &self.endpoints[self.selected_endpoint].connection; match connection.request_link(LinkRequest::Activate, cell) { Ok(id) => { self.links.activating = Some(InFlight { id, inbox: connection.links.clone(), - context: PendingActivation { fallback, in_tab }, + context: PendingActivation { + fallback, + local_first, + in_tab, + }, }); return true; } @@ -538,7 +640,11 @@ impl HerdrWindow { fn finish_activation( &mut self, activation: Activation, - PendingActivation { fallback, in_tab }: PendingActivation, + PendingActivation { + fallback, + local_first, + in_tab, + }: PendingActivation, window: &mut Window, cx: &mut Context, ) { @@ -549,7 +655,13 @@ impl HerdrWindow { cx.notify(); } Activation::Declined(url) => { - self.open_link(url.map(Fallback::Web).or(fallback), in_tab, window, cx); + let declined = url.map(Fallback::Web); + let link = if local_first { + fallback.or(declined) + } else { + declined.or(fallback) + }; + self.open_link(link, in_tab, window, cx); } } } @@ -563,6 +675,14 @@ 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) { + let unjoined = RemotePath::unjoined(&link); + self.open_remote_file(remote, link.query, unjoined, link.cwd, 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.rs b/crates/herdr-gpui/src/window/links/tests.rs index 13e69d001..c13b8b240 100644 --- a/crates/herdr-gpui/src/window/links/tests.rs +++ b/crates/herdr-gpui/src/window/links/tests.rs @@ -13,6 +13,8 @@ mod external; #[cfg(unix)] mod file_checks; mod file_handlers; +mod windows_paths; +mod wraps; const URL: &str = "https://example.com/wrapped/path"; @@ -259,20 +261,20 @@ fn a_daemon_without_link_methods_keeps_the_local_detector(cx: &mut gpui::TestApp view.read_with(cx, |view, _| { assert!(view.links.hover.is_none() && view.links.resolving.is_none()); }); - // The row-local reading does not guess where a wrapped address - // ends, so neither row is a link and nothing opens. + // The local reading joins the terminal's wrap, so either row is the + // whole address and a click on it opens that. let continuation = at(&view, cx, 2, 1); for position in [link, continuation] { view.read_with(cx, |view, _| { - assert!(!view.terminal_link_hovered(position, Modifiers::secondary_key())); + assert!(view.terminal_link_hovered(position, Modifiers::secondary_key())); assert!( view.terminal_link_press(position, Modifiers::default()) .is_none() ); }); cx.simulate_click(position, Modifiers::secondary_key()); + assert_eq!(cx.opened_url().as_deref(), Some(URL)); } - assert!(cx.opened_url().is_none()); } #[gpui::test] @@ -291,6 +293,7 @@ fn a_replaced_connection_retires_link_requests_unanswered(cx: &mut gpui::TestApp inbox: Arc::default(), context: PendingActivation { fallback: WebUrl::try_from(URL).ok().map(Fallback::Web), + local_first: false, in_tab: false, }, }); 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/links/tests/windows_paths.rs b/crates/herdr-gpui/src/window/links/tests/windows_paths.rs new file mode 100644 index 000000000..ccfc073ac --- /dev/null +++ b/crates/herdr-gpui/src/window/links/tests/windows_paths.rs @@ -0,0 +1,50 @@ +//! Whether a pane's printed relative path is read with Windows separators, +//! as its working directory says its host writes them. +use super::*; + +/// Printed alone, so it ends inside the fixture's 20-column row. +const PRINTED: &str = r"docs\index.html?x=1"; + +/// The path and query `file_link_at` reads at the start of `PRINTED` in a +/// pane whose foreground directory is `cwd`. +fn read_in(cwd: &str, cx: &mut gpui::TestAppContext) -> Option<(String, Option)> { + let peer = MockPeer::new(); + let (view, cx) = window(&peer, false, cx); + view.update(cx, |view, _| { + let mut snapshot = snapshot(); + let pane = snapshot + .panes + .iter_mut() + .find(|pane| pane.pane_id == "w1:p1") + .unwrap(); + pane.foreground_cwd = Some(cwd.into()); + view.live.snapshot = Some(Arc::new(snapshot)); + let mut surface = (*wrapped(2)).clone(); + let mut symbols = PRINTED.chars().chain(std::iter::repeat(' ')); + for cell in &mut surface.frame.cells { + cell.symbol = symbols.next().unwrap().to_string(); + } + view.live.surface = Some(Arc::new(surface)); + }); + let position = at(&view, cx, 1, 0); + view.read_with(cx, |view, _| { + view.file_link_at(position) + .map(|link| (link.path, link.query)) + }) +} + +#[gpui::test] +fn a_pane_in_a_windows_folder_splits_a_relative_path_s_query(cx: &mut gpui::TestAppContext) { + assert_eq!( + read_in(r"C:\dev", cx), + Some((r"docs\index.html".into(), Some("?x=1".into()))) + ); +} + +#[gpui::test] +fn a_pane_in_a_posix_folder_keeps_the_backslash_an_escape(cx: &mut gpui::TestAppContext) { + assert_eq!( + read_in("/srv/app", cx), + Some((r"docs\index.html?x=1".into(), None)) + ); +} diff --git a/crates/herdr-gpui/src/window/links/tests/wraps.rs b/crates/herdr-gpui/src/window/links/tests/wraps.rs new file mode 100644 index 000000000..83418d707 --- /dev/null +++ b/crates/herdr-gpui/src/window/links/tests/wraps.rs @@ -0,0 +1,144 @@ +use super::*; +use crate::links::LinkRegion; + +/// An application's word wrap of an address the daemon reads only to the +/// end of its first row. +const REPLY: [&str; 2] = ["● see https://ex.co/", " aaaaaaaa/b.html ok"]; +const LOCAL: &str = "https://ex.co/aaaaaaaa/b.html"; +const DAEMON: &str = "https://ex.co/"; + +/// The pane of `wrapped` showing `lines` instead. +fn showing(lines: &[&str]) -> Arc { + let mut surface = (*wrapped(2)).clone(); + let width = usize::from(surface.frame.width); + for (row, cells) in surface.frame.cells.chunks_mut(width).enumerate() { + let line = lines.get(row).copied().unwrap_or_default(); + let mut symbols = line.chars().chain(std::iter::repeat(' ')); + for cell in cells { + cell.symbol = symbols.next().unwrap().to_string(); + } + } + Arc::new(surface) +} + +/// A window showing `lines`, hovered with the link modifier on the address +/// on the first row, which the daemon resolved to `regions`. +fn hovered<'a>( + peer: &MockPeer, + lines: &[&str], + regions: Vec, + cx: &'a mut gpui::TestAppContext, +) -> ( + Entity, + &'a mut VisualTestContext, + Point, +) { + let (view, cx) = window(peer, true, cx); + view.update(cx, |view, _| view.live.surface = Some(showing(lines))); + let position = at(&view, cx, 8, 0); + cx.simulate_mouse_move(position, None, Modifiers::secondary_key()); + view.update(cx, |view, _| { + let cell = view.links.hover.clone().unwrap(); + view.links.resolved = Some(ResolvedLink { cell, regions }); + assert!(view.hovered_daemon_link().is_some()); + }); + (view, cx, position) +} + +fn region(row: u16, start_col: u16, end_col: u16) -> LinkRegion { + LinkRegion { + row, + start_col, + end_col, + } +} + +#[gpui::test] +fn a_wrapped_link_hovered_on_its_second_row_is_underlined_on_both(cx: &mut gpui::TestAppContext) { + let peer = MockPeer::new(); + let (view, cx) = window(&peer, false, cx); + let continuation = at(&view, cx, 2, 1); + cx.simulate_mouse_move(continuation, None, Modifiers::secondary_key()); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + let link = view.hovered_local_link().unwrap(); + assert_eq!(link.target, RowTarget::Web(URL.into())); + assert_eq!(link.rows, vec![(0, 0..20), (1, 0..12)]); + assert_eq!(view.underlined_link_rows(), link.rows); + }); +} + +#[gpui::test] +fn a_local_reading_that_strictly_contains_the_daemons_is_underlined(cx: &mut gpui::TestAppContext) { + let peer = MockPeer::advertising(&["pane.link.resolve", "pane.link.activate"]); + let (view, cx, _) = hovered(&peer, &REPLY, vec![region(0, 6, 19)], cx); + view.read_with(cx, |view, _| { + assert_eq!(view.underlined_link_rows(), vec![(0, 6..20), (1, 2..17)]); + }); +} + +#[gpui::test] +fn the_daemons_link_is_underlined_when_the_local_one_does_not_contain_it( + cx: &mut gpui::TestAppContext, +) { + let peer = MockPeer::advertising(&["pane.link.resolve", "pane.link.activate"]); + let regions = vec![region(0, 6, 19), region(1, 0, 3)]; + let (view, cx, _) = hovered(&peer, &[REPLY[0]], regions, cx); + view.read_with(cx, |view, _| { + assert_eq!(view.hovered_local_link().unwrap().rows, vec![(0, 6..20)]); + assert_eq!(view.underlined_link_rows(), vec![(0, 6..20), (1, 0..4)]); + }); +} + +/// Clicks the hovered address, and has the daemon decline it with +/// `declined`. +fn decline( + peer: &mut MockPeer, + view: &Entity, + cx: &mut VisualTestContext, + position: Point, + declined: &str, +) { + cx.simulate_click(position, Modifiers::secondary_key()); + let request = peer.request(); + assert_eq!(request["method"], "pane.link.activate"); + answer( + peer, + view, + cx, + &request, + json!({"type": "pane_link_activated", "url": declined, "handled": false}), + ); +} + +#[gpui::test] +fn a_declined_click_opens_the_local_link_that_contains_the_daemons(cx: &mut gpui::TestAppContext) { + let mut peer = MockPeer::advertising(&["pane.link.resolve", "pane.link.activate"]); + let (view, cx, position) = hovered(&peer, &REPLY, vec![region(0, 6, 19)], cx); + decline(&mut peer, &view, cx, position, DAEMON); + assert_eq!(cx.opened_url().as_deref(), Some(LOCAL)); +} + +#[gpui::test] +fn a_click_before_the_hover_reply_opens_the_whole_wrapped_link(cx: &mut gpui::TestAppContext) { + let mut peer = MockPeer::advertising(&["pane.link.resolve", "pane.link.activate"]); + let (view, cx) = window(&peer, true, cx); + view.update(cx, |view, _| view.live.surface = Some(showing(&REPLY))); + let position = at(&view, cx, 8, 0); + // The click comes within the hover's delay, before anything is resolved. + cx.simulate_mouse_move(position, None, Modifiers::secondary_key()); + view.read_with(cx, |view, _| assert!(view.hovered_daemon_link().is_none())); + decline(&mut peer, &view, cx, position, DAEMON); + assert_eq!(cx.opened_url().as_deref(), Some(LOCAL)); +} + +#[gpui::test] +fn a_declined_click_opens_the_daemons_address_otherwise(cx: &mut gpui::TestAppContext) { + let mut peer = MockPeer::advertising(&["pane.link.resolve", "pane.link.activate"]); + let regions = vec![region(0, 6, 19), region(1, 0, 3)]; + let (view, cx, position) = hovered(&peer, &[REPLY[0]], regions, cx); + // The local reading there is `https://ex.co/`, which does not contain + // the daemon's link, so the daemon's address wins. + decline(&mut peer, &view, cx, position, "https://ex.co/other"); + assert_eq!(cx.opened_url().as_deref(), Some("https://ex.co/other")); +} 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..f6daccd5a --- /dev/null +++ b/crates/herdr-gpui/src/window/remote_paths.rs @@ -0,0 +1,218 @@ +//! 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 the first row of `link` names alone, when a raw wrap + /// joined the next row's text to it, resolved as [`Self::from_link`] + /// resolves the joined path. The line printed belongs to the joined text. + pub(crate) fn unjoined(link: &FileLink) -> Option { + let target = link.remote.clone()?; + let (shell, path) = resolve(link.unjoined.as_deref()?, link.cwd.as_deref())?; + Some(Self { + target, + shell, + path, + line: None, + }) + } + + /// The host's file a path typed for `target`, whose host reads paths in + /// `shell`, names. Every typed path is read in `shell`, a relative one + /// under `cwd`. `None` when it names none, as a relative path does with + /// no `cwd` absolute in `shell`'s form. + pub(crate) fn typed( + target: &str, + shell: RemoteShell, + path: &str, + cwd: Option<&str>, + ) -> Option { + let (shell, path) = resolve_typed(shell, path, cwd)?; + Some(Self { + target: target.to_owned(), + shell, + path, + line: None, + }) + } + + /// 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('~') { + let powershell = cwd.map_or(rest.starts_with('\\'), windows); + let shell = if powershell { + RemoteShell::PowerShell + } else { + RemoteShell::Posix + }; + return home(shell, path); + } + let shell = if cwd.is_some_and(windows) { + RemoteShell::PowerShell + } else { + RemoteShell::Posix + }; + under(shell, path, cwd?) +} + +/// The shell and path for `path` as typed for a host that reads paths in +/// `shell`. PowerShell reads every absolute form, with backslashes so it is +/// keyed as a clicked PowerShell path is; a path rooted without a drive +/// takes the drive of a drive `cwd`. A POSIX host reads no drive or UNC +/// path, so one names no file there. +fn resolve_typed( + shell: RemoteShell, + path: &str, + cwd: Option<&str>, +) -> Option<(RemoteShell, String)> { + if path.starts_with('~') { + return home(shell, path); + } + match shell { + RemoteShell::PowerShell => { + let path = file_url_drive(path); + let cwd = cwd.map(file_url_drive); + if windows(path) { + return Some((shell, backslashes(path))); + } + if path.starts_with(['/', '\\']) { + let drive = cwd.and_then(drive).unwrap_or_default(); + return Some((shell, format!("{drive}{}", backslashes(path)))); + } + under(shell, path, cwd?) + } + RemoteShell::Posix => { + if windows(path) { + return None; + } + if path.starts_with('/') { + return Some((shell, path.to_owned())); + } + under(shell, path, cwd?) + } + } +} + +/// The `X:` drive a drive path such as `X:\dev` starts with. +fn drive(path: &str) -> Option<&str> { + let drive = path.get(..2)?; + (windows(path) && !path.starts_with('\\')).then_some(drive) +} + +/// `path`, which starts with `~`, in `shell`'s form: a POSIX host expands +/// only a leading `~/`, so `~\` is read as `~/` there. `~user` names +/// another account's home, which is not resolved. +fn home(shell: RemoteShell, path: &str) -> Option<(RemoteShell, String)> { + let rest = path.strip_prefix('~')?; + if !(rest.is_empty() || rest.starts_with(['/', '\\'])) { + return None; + } + Some(match shell { + RemoteShell::PowerShell => (shell, backslashes(path)), + RemoteShell::Posix if rest.starts_with('\\') => (shell, path.replace('\\', "/")), + RemoteShell::Posix => (shell, path.to_owned()), + }) +} + +/// The relative `path` under `cwd`, joined in `shell`'s form. `None` when +/// `path` is empty or relative to the root of a drive no cwd names, or when +/// `cwd` is not absolute in `shell`'s form. +fn under(shell: RemoteShell, path: &str, cwd: &str) -> Option<(RemoteShell, String)> { + if path.is_empty() || path.starts_with('\\') { + return None; + } + let relative = without_current(path); + match shell { + RemoteShell::PowerShell if windows(cwd) => { + let cwd = backslashes(cwd); + let folder = cwd.trim_end_matches('\\'); + Some((shell, format!("{folder}\\{}", backslashes(relative)))) + } + RemoteShell::Posix if cwd.starts_with('/') => { + let folder = cwd.trim_end_matches('/'); + Some((shell, format!("{folder}/{relative}"))) + } + _ => None, + } +} + +/// Whether `path` starts with a drive and a separator, as in `C:\` or `c:/`, +/// or is a UNC path such as `\\server\share`. +pub(super) 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..ce12fa8bb --- /dev/null +++ b/crates/herdr-gpui/src/window/remote_paths/tests.rs @@ -0,0 +1,297 @@ +use super::*; + +fn link(path: &str, cwd: Option<&str>) -> FileLink { + FileLink { + path: path.into(), + line: None, + query: None, + pane_id: "pane".into(), + cwd: cwd.map(Into::into), + explicit: false, + remote: Some("me@devbox".into()), + unjoined: None, + } +} + +/// 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_query_printed_after_the_path_is_not_the_host_s() { + let page = link("/srv/www/index.html", None); + let with_query = FileLink { + query: Some("?a=1".into()), + ..page.clone() + }; + assert_eq!( + RemotePath::from_link(&with_query), + RemotePath::from_link(&page) + ); + assert_eq!( + asked("/srv/www/index.html", None), + posix("/srv/www/index.html") + ); +} + +#[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")); +} + +/// The shell and path a path typed for a host reading `shell` is asked as. +fn typed(shell: RemoteShell, path: &str, cwd: Option<&str>) -> Option<(RemoteShell, String)> { + RemotePath::typed("me@devbox", shell, path, cwd).map(|remote| (remote.shell, remote.path)) +} + +#[test] +fn a_typed_home_path_is_read_in_the_host_s_shell() { + use RemoteShell::{Posix, PowerShell}; + assert_eq!( + typed(PowerShell, "~/notes.md", None), + powershell(r"~\notes.md") + ); + assert_eq!(typed(PowerShell, "~", None), powershell("~")); + assert_eq!(typed(Posix, "~/notes.md", None), posix("~/notes.md")); + // A POSIX host expands only a leading `~/`. + assert_eq!(typed(Posix, r"~\notes.md", None), posix("~/notes.md")); + assert_eq!( + typed(Posix, r"~\docs\notes.md", None), + posix("~/docs/notes.md") + ); + // The host's shell wins over a cwd of the other form. + assert_eq!( + typed(PowerShell, "~/notes.md", Some("/home/me")), + powershell(r"~\notes.md") + ); + assert_eq!(typed(PowerShell, "~other/notes.md", None), None); +} + +#[test] +fn a_typed_relative_path_joins_a_cwd_in_the_host_s_form() { + use RemoteShell::{Posix, PowerShell}; + assert_eq!( + typed(PowerShell, "src/main.rs", Some("C:/dev/app")), + powershell(r"C:\dev\app\src\main.rs") + ); + assert_eq!( + typed(Posix, "./src/main.rs", Some("/home/me/app/")), + posix("/home/me/app/src/main.rs") + ); + // No cwd, or one not absolute in the host's form, names no file. + assert_eq!(typed(PowerShell, "notes.md", None), None); + assert_eq!(typed(Posix, "notes.md", None), None); + assert_eq!(typed(PowerShell, "notes.md", Some("/home/me")), None); + assert_eq!(typed(Posix, "notes.md", Some(r"C:\dev")), None); + assert_eq!(typed(Posix, "", Some("/home/me")), None); + // A POSIX cwd that only looks like a `file://` drive path is joined as + // it reads. + assert_eq!( + typed(Posix, "a.md", Some("/C:/Users/me")), + posix("/C:/Users/me/a.md") + ); + assert_eq!( + typed(PowerShell, "a.md", Some("/C:/Users/me")), + powershell(r"C:\Users\me\a.md") + ); +} + +#[test] +fn a_typed_drive_or_unc_path_names_no_file_on_a_posix_host() { + use RemoteShell::Posix; + assert_eq!(typed(Posix, "c:/dev/notes.md", None), None); + assert_eq!(typed(Posix, r"C:\dev\notes.md", Some("/home/me")), None); + assert_eq!( + typed(Posix, r"\\srv\share\notes.md", Some("/home/me")), + None + ); +} + +#[test] +fn a_typed_rooted_path_takes_the_drive_of_a_windows_cwd() { + use RemoteShell::PowerShell; + assert_eq!( + typed(PowerShell, "/proj/notes.md", Some(r"D:\proj")), + powershell(r"D:\proj\notes.md") + ); + assert_eq!( + typed(PowerShell, r"\proj\notes.md", Some("d:/work")), + powershell(r"d:\proj\notes.md") + ); + assert_eq!( + typed(PowerShell, "/proj/notes.md", Some("/D:/work")), + powershell(r"D:\proj\notes.md") + ); + // With no drive to take, the session's current drive reads it. + assert_eq!( + typed(PowerShell, "/proj/notes.md", None), + powershell(r"\proj\notes.md") + ); + assert_eq!( + typed(PowerShell, "/proj/notes.md", Some(r"\\srv\share")), + powershell(r"\proj\notes.md") + ); +} + +#[test] +fn a_typed_absolute_path_is_read_in_the_host_s_shell() { + use RemoteShell::{Posix, PowerShell}; + assert_eq!(typed(Posix, "/srv/x.md", None), posix("/srv/x.md")); + assert_eq!( + typed(PowerShell, "/srv/x.md", Some("/home/me")), + powershell(r"\srv\x.md") + ); + assert_eq!( + typed(PowerShell, r"\dev\x.md", None), + powershell(r"\dev\x.md") + ); + assert_eq!( + typed(PowerShell, "c:/dev/notes.md", None), + powershell(r"c:\dev\notes.md") + ); + // A `file://` drive path loses its leading slash, as a printed one does. + assert_eq!( + typed(PowerShell, "/C:/Users/me/a.md", None), + powershell(r"C:\Users\me\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/crates/herdr-gpui/src/window/render.rs b/crates/herdr-gpui/src/window/render.rs index 23f420d28..126f93eab 100644 --- a/crates/herdr-gpui/src/window/render.rs +++ b/crates/herdr-gpui/src/window/render.rs @@ -180,18 +180,9 @@ impl Render for HerdrWindow { ); // Like the highlight, the link underline paints with the frame that // owns its cells, and only while that frame shows the content the - // daemon resolved it from. Without one, the row-local reading under - // the pointer is underlined instead. - let link_rows: Vec<_> = surface - .as_deref() - .zip(self.hovered_daemon_link()) - .filter(|(surface, link)| link.cell.current(surface)) - .map(|(_, link)| link.frame_rows().collect()) - .or_else(|| { - let link = self.hovered_local_link()?; - Some(vec![(link.row, link.columns.clone())]) - }) - .unwrap_or_default(); + // daemon resolved it from. Without one, or when the local reading + // strictly contains it, the local reading is underlined instead. + let link_rows = self.underlined_link_rows(); // Search matches, mapped onto the frame on screen, tint below the // selection, which reads as chosen over them. A popup covers the // panes, so their matches stay under it. 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