From 7a808df0360a23e672847170b78bc781d8b3f976 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Fri, 16 Oct 2026 22:13:35 -0700 Subject: [PATCH 01/19] chore(client): fetch and push one file on an SSH host Add herdr_client::remote_file, which copies a file off an SSH host and writes it back over the endpoint's noninteractive SSH trust, for the upcoming remote file links in the GUI. POSIX hosts run /bin/sh scripts; Windows OpenSSH hosts run Windows PowerShell 5.1+ scripts through -EncodedCommand. The path travels quoted in the script; file bytes travel only on stdout and stdin. Both shells share one exit-code contract for missing, not-a-file, too-large, symlink, no-hasher, and conflict refusals. A push writes a private temporary file beside the target and renames it over the target only while the host's file still matches the expected SHA-256 (or is absent, or anything). An existing POSIX file keeps its permission bits; a new one gets the login umask's default. Tests run the POSIX scripts through a local /bin/sh and, opt-in, the PowerShell scripts through a local powershell.exe and pwsh. --- Cargo.lock | 1 + crates/herdr-client/Cargo.toml | 1 + crates/herdr-client/src/error.rs | 2 + crates/herdr-client/src/lib.rs | 5 + crates/herdr-client/src/remote_file.rs | 277 +++++++++++++++++ crates/herdr-client/src/remote_file/posix.rs | 127 ++++++++ .../src/remote_file/powershell.rs | 149 ++++++++++ crates/herdr-client/src/remote_file/tests.rs | 65 ++++ .../src/remote_file/tests/posix_exec.rs | 278 ++++++++++++++++++ .../src/remote_file/tests/powershell_exec.rs | 220 ++++++++++++++ .../src/remote_file/tests/scripts.rs | 277 +++++++++++++++++ crates/herdr-client/src/ssh.rs | 9 + crates/herdr-client/src/ssh/powershell.rs | 2 +- 13 files changed, 1412 insertions(+), 1 deletion(-) create mode 100644 crates/herdr-client/src/remote_file.rs create mode 100644 crates/herdr-client/src/remote_file/posix.rs create mode 100644 crates/herdr-client/src/remote_file/powershell.rs create mode 100644 crates/herdr-client/src/remote_file/tests.rs create mode 100644 crates/herdr-client/src/remote_file/tests/posix_exec.rs create mode 100644 crates/herdr-client/src/remote_file/tests/powershell_exec.rs create mode 100644 crates/herdr-client/src/remote_file/tests/scripts.rs diff --git a/Cargo.lock b/Cargo.lock index d0e603306..935426afc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3534,6 +3534,7 @@ dependencies = [ "rustix", "serde", "serde_json", + "sha2 0.11.0", "tempfile", "thiserror 2.0.21", "tracing", diff --git a/crates/herdr-client/Cargo.toml b/crates/herdr-client/Cargo.toml index eaf84f64b..bcc8e05d2 100644 --- a/crates/herdr-client/Cargo.toml +++ b/crates/herdr-client/Cargo.toml @@ -24,6 +24,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/src/error.rs b/crates/herdr-client/src/error.rs index 0984b047f..932138fbf 100644 --- a/crates/herdr-client/src/error.rs +++ b/crates/herdr-client/src/error.rs @@ -248,6 +248,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 06c2233d8..83c590166 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::{ + Error as RemoteFileError, Expect, Fetched, MAX_REMOTE_FILE_BYTES, 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..e5c6b3ef4 --- /dev/null +++ b/crates/herdr-client/src/remote_file.rs @@ -0,0 +1,277 @@ +//! 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 private temporary file beside the target and renames it +//! over the target only after checking [`Expect`] against the host's file. +//! 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 a new file gets the mode +//! the host's umask gives one; either way the file becomes the SSH user's. A +//! POSIX host whose `stat` cannot report a mode gets the file overwritten in +//! place instead, which keeps its owner but is not atomic. + +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 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, +} + +/// 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("the host's file changed since it was fetched")] + Conflict, + #[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("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 +} + +/// Stream `path` on `target` 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: +/// target: A `[user@]host` SSH target. +/// shell: The shell the host runs scripts in. +/// path: The host's absolute path, or one starting `~/` (also `~\` for +/// PowerShell) for the login user's home folder. +/// 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( + target: &str, + shell: RemoteShell, + path: &str, + out: impl Write + Send, + max_bytes: u64, + cancelled: &AtomicBool, +) -> Result { + let script = match shell { + RemoteShell::Posix => posix::fetch_script(path, max_bytes), + RemoteShell::PowerShell => powershell::fetch_script(path, max_bytes), + }; + let command = remote_command(target, shell, &script)?; + Ok(fetch_with(command, out, max_bytes, cancelled)?) +} + +/// Replace `path` on `target` 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: +/// target: A `[user@]host` SSH target. +/// shell: The shell the host runs scripts in. +/// path: As for [`fetch_remote_file`]. Its folder must exist. +/// expect: What the host's file must be just before the rename. +/// input: The new contents. The caller bounds its length. +/// 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, or a failed +/// script, or a target or platform error from [`crate::Error`]. +pub fn push_remote_file( + target: &str, + shell: RemoteShell, + path: &str, + expect: Expect, + input: impl Read + Send, + cancelled: &AtomicBool, +) -> Result<()> { + let script = match shell { + RemoteShell::Posix => posix::push_script(path, expect), + RemoteShell::PowerShell => powershell::push_script(path, expect), + }; + let command = remote_command(target, 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, + _ => 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..9aa223db1 --- /dev/null +++ b/crates/herdr-client/src/remote_file/posix.rs @@ -0,0 +1,127 @@ +//! The `/bin/sh` scripts. They use only POSIX shell, `wc`, `cat`, `mktemp`, +//! `mv`, and `rm`, plus a SHA-256 tool when a push expects a hash. + +use super::{ + CONFLICT_EXIT, Expect, MISSING_EXIT, NO_HASHER_EXIT, NOT_A_FILE_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 { + if path == "~" { + return r#"path="$HOME""#.to_owned(); + } + match path.strip_prefix("~/") { + Some(rest) => format!(r#"path="$HOME"/{}"#, shell_quote(rest)), + None => format!("path={}", 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. A push runs it +/// before and after the copy, so a path that changed kind meanwhile is still +/// refused. +fn refuse_unsafe() -> String { + format!( + r#"if [ -L "$path" ]; then exit {SYMLINK_EXIT}; fi +if [ -e "$path" ] && [ ! -f "$path" ]; then exit {NOT_A_FILE_EXIT}; fi"# + ) +} + +/// 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 "$path" ]; then exit {CONFLICT_EXIT}; fi +sum=$($hasher < "$path") || exit 1 +if [ "${{sum%% *}}" != '{hex}' ]; then exit {CONFLICT_EXIT}; fi"#, + hex = hex(&digest), + ), + Expect::Absent => { + format!(r#"if [ -e "$path" ] || [ -L "$path" ]; then exit {CONFLICT_EXIT}; fi"#) + } + Expect::Any => String::new(), + } +} + +/// Gives the copy the target's permission bits, read with GNU `stat -c` or +/// BSD `stat -f`, or a new file the mode the caller's umask gives one. When no +/// `stat` reads the target's mode, the copy is written over the target in +/// place instead: that keeps the mode, owner, and hard links, but it is not +/// atomic, so a reader can see a partly written file and a failure partway +/// leaves one. +const KEEP_MODE: &str = r#"if [ -e "$path" ]; then +mode=$(stat -c %a -- "$path" 2>/dev/null) || mode=$(stat -f %Lp -- "$path" 2>/dev/null) || mode= +case $mode in ''|*[!0-7]*) mode= ;; esac +if [ -z "$mode" ]; then +cat < "$tmp" > "$path" || exit 1 +exit 0 +fi +chmod -- "$mode" "$tmp" || exit 1 +else +chmod -- "$(printf '%o' $(( 0666 & ~0$old_umask )))" "$tmp" || exit 1 +fi"#; + +/// Copies stdin to a private temporary file beside the target, checks +/// `expect`, gives the copy the target's permission bits (see [`KEEP_MODE`]), +/// and renames it over the target, so the file becomes the SSH user's. The +/// trap removes the copy on every exit that did not rename it. +pub(super) fn push_script(path: &str, expect: Expect) -> String { + let hasher = match expect { + Expect::Sha256(_) => choose_hasher(), + Expect::Absent | Expect::Any => String::new(), + }; + format!( + r#"old_umask=$(umask) +umask 077 +{assign} +{refuse} +{hasher} +case $path in +*/*) dir=${{path%/*}} ;; +*) dir=. ;; +esac +if [ -z "$dir" ]; then dir=/; fi +name=${{path##*/}} +tmp=$(mktemp "$dir/.$name.XXXXXX") || exit 1 +trap 'rm -f -- "$tmp"' EXIT +cat > "$tmp" || exit 1 +{refuse} +{check} +{KEEP_MODE} +mv -f -- "$tmp" "$path" || exit 1 +trap - EXIT"#, + assign = path_assignment(path), + refuse = refuse_unsafe(), + check = expect_check(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..413969be6 --- /dev/null +++ b/crates/herdr-client/src/remote_file/powershell.rs @@ -0,0 +1,149 @@ +//! 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::{ + CONFLICT_EXIT, Expect, MISSING_EXIT, NOT_A_FILE_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 hidden-named temporary file beside the target, checks +/// `expect`, and replaces the target with it. `finally` removes the copy on +/// every exit that did not rename it. `[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) -> String { + format!( + r#"{PREAMBLE} +$path = [System.IO.Path]::GetFullPath({path}) +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 = '.' + [System.IO.Path]::GetFileName($path) + '.' + [System.IO.Path]::GetRandomFileName() +$temporary = [System.IO.Path]::Combine($folder, $name) +$copy = [System.IO.File]::Open($temporary, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) +$renamed = $false +try {{ + try {{ + $stdin = [Console]::OpenStandardInput() + $stdin.CopyTo($copy) + }} finally {{ + $copy.Dispose() + }} + 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..9181efc2d --- /dev/null +++ b/crates/herdr-client/src/remote_file/tests.rs @@ -0,0 +1,65 @@ +use super::*; +use std::path::Path; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod posix_exec; +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() +} 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..74f5d4b6a --- /dev/null +++ b/crates/herdr-client/src/remote_file/tests/posix_exec.rs @@ -0,0 +1,278 @@ +//! 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}; + +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, +} + +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> { + let command = self.command(&posix::push_script(path, expect)); + push_with(command, input, &AtomicBool::new(false)) + } +} + +#[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"]); +} + +/// 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 { + let found = ["/bin", "/usr/bin"] + .iter() + .map(|folder| Path::new(folder).join(tool)) + .find(|candidate| candidate.exists()) + .unwrap(); + symlink(found, bin.path().join(tool)).unwrap(); + } + bin +} + +#[test] +fn push_without_stat_overwrites_in_place_and_keeps_the_mode() { + let mut fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", 0o755); + let bin = tool_folder(&["cat", "chmod", "mktemp", "mv", "rm"]); + fixture.path_env = Some(bin.path().to_owned()); + let content = payload(70_000); + fixture + .push(&fixture.text("file"), Expect::Any, &content) + .unwrap(); + assert!(std::fs::read(fixture.path("file")).unwrap() == content); + 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) + )); +} 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..ff988f558 --- /dev/null +++ b/crates/herdr-client/src/remote_file/tests/powershell_exec.rs @@ -0,0 +1,220 @@ +//! 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 { + run( + self.shell, + &powershell::push_script(path, expect), + 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); + + 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); + let mut expected = before.clone(); + expected.extend(["folder".to_owned(), format!("{HOSTILE_NAME}.new")]); + expected.sort(); + assert_eq!(fixture.files(), expected); +} + +#[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..eefc64a38 --- /dev/null +++ b/crates/herdr-client/src/remote_file/tests/scripts.rs @@ -0,0 +1,277 @@ +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); + 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])); + assert!(push.contains("$hasher < \"$path\"")); + assert!(push.contains("mv -f -- \"$tmp\" \"$path\"")); +} + +#[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)); + assert!(script.contains("sha256sum")); + assert!(script.contains(&format!("!= '{}'", "ab".repeat(32)))); + for expect in [Expect::Absent, Expect::Any] { + assert!(!posix::push_script("/f", expect).contains("sha256sum")); + } +} + +#[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); + 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])); + assert!(script.contains("SHA256]::Create().ComputeHash($current)")); + assert!(script.contains(&format!("-ne '{}'", "0f".repeat(32)))); + let absent = powershell::push_script("C:\\f", Expect::Absent); + assert!(absent.contains("Get-HerdrAttributes $path)) { exit 75 }")); + assert!(!absent.contains("ComputeHash")); + let any = powershell::push_script("C:\\f", Expect::Any); + 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); + 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(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"); +} + +#[test] +fn public_functions_refuse_an_invalid_target() { + let cancelled = AtomicBool::new(false); + let fetched = fetch_remote_file( + "-oProxyCommand=x", + RemoteShell::Posix, + "/f", + Vec::new(), + 1, + &cancelled, + ); + assert!(matches!(fetched, Err(crate::Error::InvalidSshTarget))); + let pushed = push_remote_file( + "-oProxyCommand=x", + RemoteShell::PowerShell, + "C:\\f", + Expect::Any, + &[][..], + &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 791f9949f..2970fb66b 100644 --- a/crates/herdr-client/src/ssh.rs +++ b/crates/herdr-client/src/ssh.rs @@ -512,6 +512,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) From 9bc3af953239a7a99eeeb644e3c5a1ae5cf4f5b4 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Fri, 16 Oct 2026 22:47:20 -0700 Subject: [PATCH 02/19] feat: open file paths an SSH pane prints by fetching a copy A link-modifier click on a path printed in a pane on an SSH device did nothing, since this machine cannot open that host's paths. Such paths are now links by their shape: a click fetches the file over the device's SSH trust into a per-launch cache and opens the copy in the system's application, or the folder holding it when it is not a known document, under the same rule as local paths. Windows OpenSSH hosts are recognised by a drive or UNC path, or a Windows working directory for a relative path, and use PowerShell; other hosts use /bin/sh. Relative paths join the pane's cwd with the host's separator, and `~` is left for the host to expand. Hover never contacts the host. Plugin link handlers keep the first claim. WSL and cloud panes are unchanged, as are SSH panes on Windows clients, which have no SSH children. The cache is removed at quit, and folders left by earlier launches are swept on first use. --- crates/herdr-gpui/src/error.rs | 2 + crates/herdr-gpui/src/main.rs | 1 + crates/herdr-gpui/src/remote_files.rs | 254 ++++++++++++++++ crates/herdr-gpui/src/remote_files/cache.rs | 281 ++++++++++++++++++ crates/herdr-gpui/src/remote_files/tests.rs | 259 ++++++++++++++++ crates/herdr-gpui/src/sidebar/layout_tests.rs | 1 + crates/herdr-gpui/src/window.rs | 5 + crates/herdr-gpui/src/window/construct.rs | 8 +- crates/herdr-gpui/src/window/file_links.rs | 27 +- .../herdr-gpui/src/window/file_links/tests.rs | 29 +- .../src/window/file_links/tests/remote.rs | 111 +++++++ crates/herdr-gpui/src/window/links.rs | 21 +- .../src/window/links/tests/file_handlers.rs | 37 ++- crates/herdr-gpui/src/window/remote_paths.rs | 111 +++++++ .../src/window/remote_paths/tests.rs | 161 ++++++++++ 15 files changed, 1284 insertions(+), 24 deletions(-) create mode 100644 crates/herdr-gpui/src/remote_files.rs create mode 100644 crates/herdr-gpui/src/remote_files/cache.rs create mode 100644 crates/herdr-gpui/src/remote_files/tests.rs create mode 100644 crates/herdr-gpui/src/window/file_links/tests/remote.rs create mode 100644 crates/herdr-gpui/src/window/remote_paths.rs create mode 100644 crates/herdr-gpui/src/window/remote_paths/tests.rs diff --git a/crates/herdr-gpui/src/error.rs b/crates/herdr-gpui/src/error.rs index e8a83a3a6..f686daf01 100644 --- a/crates/herdr-gpui/src/error.rs +++ b/crates/herdr-gpui/src/error.rs @@ -718,6 +718,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 5c5490cc1..5ea55dd84 100644 --- a/crates/herdr-gpui/src/main.rs +++ b/crates/herdr-gpui/src/main.rs @@ -109,6 +109,7 @@ mod pull_request; mod railway; mod release_notes; mod remote_browser; +mod remote_files; mod reorder; mod repo_items; mod review; diff --git a/crates/herdr-gpui/src/remote_files.rs b/crates/herdr-gpui/src/remote_files.rs new file mode 100644 index 000000000..f3dabf3ee --- /dev/null +++ b/crates/herdr-gpui/src/remote_files.rs @@ -0,0 +1,254 @@ +//! 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. +//! +//! Every fetch and every disk access runs on the background executor; the +//! UI thread only starts a fetch and applies its result. + +mod cache; + +use crate::window::{Flash, HerdrWindow, RemotePath}; +use gpui::{App, Context, Task}; +use herdr_client::{Fetched, MAX_REMOTE_FILE_BYTES, RemoteShell}; +use std::{ + collections::HashMap, + io::Write, + path::{Path, PathBuf}, + sync::{Arc, atomic::AtomicBool}, + time::SystemTime, +}; + +pub(crate) use cache::Cache; + +/// Why a remote file was not fetched or opened. +#[derive(Debug, thiserror::Error)] +pub enum Error { + // keep-sorted start + #[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("No state folder for the remote file cache: set XDG_STATE_HOME, HOME, or LOCALAPPDATA")] + NoStateDir, + // keep-sorted end +} + +pub(crate) type Result = std::result::Result; + +/// 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, +>; + +/// The cache copies are written to. Blocking. +pub(crate) type CacheSource = Arc Result> + Send + Sync>; + +/// The I/O a fetch does: SSH and the process's cache in the app, and fakes +/// in tests. +#[derive(Clone)] +pub(crate) struct Io { + cache: CacheSource, + fetch: Fetcher, +} + +impl Default for Io { + fn default() -> Self { + Self { + cache: Arc::new(cache::shared), + fetch: Arc::new(|remote, out| { + // The script's idle timeout ends a transfer that stalls. + herdr_client::fetch_remote_file( + &remote.target, + remote.shell, + &remote.path, + out, + MAX_REMOTE_FILE_BYTES, + &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, as it was right after it was written. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow( + dead_code, + reason = "what the copy was fetched as, to tell local edits from the host's bytes; only `local` is read so far" +)] +pub(crate) struct CachedCopy { + pub(crate) local: PathBuf, + pub(crate) sha256: [u8; 32], + pub(crate) shell: RemoteShell, + pub(crate) modified: SystemTime, + pub(crate) len: u64, +} + +/// 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<(String, String), CachedCopy>, + io: Io, +} + +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())) + } + + /// Whether a click on `remote` fetches it again rather than reopening + /// the copy already here. It always does: the host holds the file, and + /// its bytes may be newer than the copy's. + pub(crate) fn fetch_again(&self, _remote: &RemotePath) -> bool { + true + } + + fn record(&mut self, done: Done) { + self.cache = Some(done.cache); + self.copies.insert(done.key, done.copy); + } + + /// 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(); + 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"); + } + }) + } +} + +/// A finished fetch: the copy written, and what a click opens for it. +struct Done { + key: (String, String), + copy: CachedCopy, + cache: Arc, + /// The copy, or the folder holding it when it is not a document. + opened: Option, +} + +/// Fetches `remote` into its copy in the cache: written beside it first and +/// renamed over it once whole, so a failed fetch leaves any older copy as +/// it was. Blocking. +fn fetch_copy(io: &Io, remote: &RemotePath) -> Result { + let cache = (io.cache)()?; + let local = cache.copy_path(&remote.target, &remote.path); + if let Some(folder) = local.parent() { + cache::create_private_dirs(folder)?; + } + let mut name = local.file_name().unwrap_or_default().to_owned(); + name.push(".part"); + let part = local.with_file_name(name); + let fetched = write_part(io, remote, &part) + .and_then(|fetched| { + std::fs::rename(&part, &local) + .map(|()| fetched) + .map_err(|source| Error::Cache { + path: local.clone(), + source, + }) + }) + .inspect_err(|_| remove_part(&part))?; + let metadata = + std::fs::metadata(&local).and_then(|metadata| Ok((metadata.modified()?, metadata.len()))); + let (modified, len) = metadata.map_err(|source| Error::Cache { + path: local.clone(), + source, + })?; + let opened = crate::window::opened(&local).and_then(|path| url::Url::from_file_path(path).ok()); + Ok(Done { + key: (remote.target.clone(), remote.path.clone()), + copy: CachedCopy { + local, + sha256: fetched.sha256, + shell: remote.shell, + modified, + len, + }, + cache, + opened, + }) +} + +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. + pub(crate) fn open_remote_file(&mut self, remote: RemotePath, cx: &mut Context) { + if !self.remote_files.fetch_again(&remote) { + if let Some(copy) = self.remote_files.copy(&remote.target, &remote.path) { + self.open_in_system_app(copy.local.clone(), cx); + } + return; + } + let name = cache::basename(&remote.path); + self.show_flash( + Flash::success(format!("Fetching {name} from {}…", remote.target)), + cx, + ); + let io = self.remote_files.io.clone(); + let fetch = cx + .background_executor() + .spawn(async move { fetch_copy(&io, &remote) }); + 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(fetched, cx)); + }) + .detach(); + } + + fn remote_file_fetched(&mut self, fetched: Result, cx: &mut Context) { + match fetched { + Ok(done) => { + let opened = done.opened.clone(); + self.remote_files.record(done); + match opened { + Some(url) => cx.open_url(url.as_str()), + None => tracing::warn!("Fetched remote file copy could not be opened"), + } + } + Err(error) => { + tracing::warn!(%error, "Remote file not fetched"); + self.show_flash(Flash::warning(crate::Error::from(error).to_string()), cx); + } + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +pub(crate) mod tests; diff --git a/crates/herdr-gpui/src/remote_files/cache.rs b/crates/herdr-gpui/src/remote_files/cache.rs new file mode 100644 index 000000000..a45f1d20c --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/cache.rs @@ -0,0 +1,281 @@ +//! 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, Result}; +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 the copy of `path` on `target` is kept: a folder 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. + pub(crate) fn copy_path(&self, target: &str, path: &str) -> PathBuf { + self.launch + .join(short_hash(target)) + .join(short_hash(path)) + .join(local_name(basename(path), 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 either separator, or `file` +/// when that is no plain file name. +pub(crate) fn basename(path: &str) -> &str { + let name = path.rsplit(['/', '\\']).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") +} + +/// 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 or truncates the file at `path`, readable and writable only by +/// the user and never executable. +pub(super) fn create_private_file(path: &Path) -> Result { + private_options() + .create(true) + .truncate(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/tests.rs b/crates/herdr-gpui/src/remote_files/tests.rs new file mode 100644 index 000000000..522482dc1 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests.rs @@ -0,0 +1,259 @@ +use super::*; +use sha2::{Digest, Sha256}; +use std::sync::Mutex; + +/// 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, + )) +} + +/// An SSH host whose answer a test chooses, the fetches asked of it, and a +/// cache of its own in a temporary folder. +pub(crate) struct FakeHost { + asked: Arc>>, + cache: 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, + _root: root, + } + } + + /// I/O that answers every fetch with `reply`. + fn io(&self, reply: fn() -> herdr_client::Result<&'static [u8]>) -> Io { + let asked = self.asked.clone(); + let cache = self.cache.clone(); + Io { + cache: Arc::new(move || Ok(cache.clone())), + fetch: Arc::new(move |remote, out| { + asked.lock().unwrap().push(remote.clone()); + let bytes = reply()?; + out.write_all(bytes).unwrap(); + Ok(Fetched { + bytes: bytes.len() as u64, + sha256: Sha256::digest(bytes).into(), + }) + }), + } + } + + /// 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, + } +} + +#[test] +fn a_copy_is_named_after_the_host_s_file() { + assert_eq!(cache::basename(r"C:\dev\notes.md"), "notes.md"); + assert_eq!(cache::basename("/home/me/src/main.rs"), "main.rs"); + assert_eq!(cache::basename(r"~\a/b\Makefile"), "Makefile"); + assert_eq!(cache::basename("README"), "README"); + for odd in [ + "/home/me/", + r"C:\dev\", + "/a/.", + "/a/..", + "", + "/a/b\u{7}c", + "~", + ] { + let expected = if odd == "~" { "~" } else { "file" }; + assert_eq!(cache::basename(odd), expected, "{odd:?}"); + } +} + +#[test] +fn copies_are_kept_apart_by_host_and_path() { + let host = FakeHost::new(); + let cache = &host.cache; + let copy = cache.copy_path("me@devbox", "/a/notes.md"); + assert_eq!(copy, cache.copy_path("me@devbox", "/a/notes.md"), "stable"); + assert!(copy.starts_with(cache.launch())); + assert_eq!(copy.file_name().unwrap(), "notes.md"); + let others = [ + cache.copy_path("me@other", "/a/notes.md"), + cache.copy_path("me@devbox", "/b/notes.md"), + ]; + for other in &others { + assert_ne!(©, other); + assert_eq!(other.file_name().unwrap(), "notes.md"); + } + // Two folders of hex digits under the launch folder, then the file. + let relative = copy.strip_prefix(cache.launch()).unwrap(); + let folders: Vec<_> = relative.iter().take(2).collect(); + 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 done = fetch_copy(&host.io(notes), &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.sha256, <[u8; 32]>::from(Sha256::digest(NOTES))); + assert_eq!(done.copy.len, NOTES.len() as u64); + assert_eq!(done.copy.shell, RemoteShell::PowerShell); + assert_eq!( + done.copy.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_copy(&host.io(newer), &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_copy(&host.io(notes), &remote("/home/me/run.sh")).unwrap(); + let opened = done.opened.unwrap().to_file_path().unwrap(); + assert!(opened.is_dir(), "{}", opened.display()); + assert!(done.copy.local.starts_with(&opened)); +} + +#[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 local = fetch_copy(&host.io(notes), &remote(path)) + .unwrap() + .copy + .local; + + let Err(error) = fetch_copy(&host.io(missing), &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/sidebar/layout_tests.rs b/crates/herdr-gpui/src/sidebar/layout_tests.rs index f970d05c9..8a5dff8e5 100644 --- a/crates/herdr-gpui/src/sidebar/layout_tests.rs +++ b/crates/herdr-gpui/src/sidebar/layout_tests.rs @@ -530,6 +530,7 @@ pub(crate) fn fixture_window(window: &mut Window, cx: &mut Context) checkpoints: Default::default(), port_forwards: Default::default(), remote_browser: Vec::new(), + remote_files: Default::default(), #[cfg(feature = "cloud")] cloud_jobs: Default::default(), #[cfg(feature = "cloud")] diff --git a/crates/herdr-gpui/src/window.rs b/crates/herdr-gpui/src/window.rs index 703de531c..a98f17d66 100644 --- a/crates/herdr-gpui/src/window.rs +++ b/crates/herdr-gpui/src/window.rs @@ -15,6 +15,7 @@ mod device_themes; mod double_shift; mod file_drop; mod file_links; +pub(crate) use file_links::opened; mod find; mod flash; pub(crate) use commands::run_window_command; @@ -34,6 +35,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 selection; @@ -255,6 +258,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 diff --git a/crates/herdr-gpui/src/window/construct.rs b/crates/herdr-gpui/src/window/construct.rs index 6e07da410..b2a8e24dc 100644 --- a/crates/herdr-gpui/src/window/construct.rs +++ b/crates/herdr-gpui/src/window/construct.rs @@ -220,6 +220,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, @@ -284,11 +285,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(); Self::close_orchestrator_windows_on_release(cx); diff --git a/crates/herdr-gpui/src/window/file_links.rs b/crates/herdr-gpui/src/window/file_links.rs index 05e81d873..4764853d0 100644 --- a/crates/herdr-gpui/src/window/file_links.rs +++ b/crates/herdr-gpui/src/window/file_links.rs @@ -4,7 +4,8 @@ //! exists off the UI thread, so a word that only looks like a path opens //! nothing. A hyperlink's path is a link without that check, so a click on //! one that is not there says so. A pane on an SSH host prints that host's -//! paths, which this machine cannot open, so none are read there. +//! paths, which a click fetches from there (see `remote_files`), so they +//! are links by their shape alone. A WSL or cloud pane's paths are not read. //! //! Terminal output is untrusted, and a hyperlink's text need not match its //! target, so a click never launches anything. Only a plain folder or a @@ -17,6 +18,7 @@ use super::HerdrWindow; use crate::editor::EditorTarget; use crate::terminal::{PaneLink, RowTarget, pane_link_at}; use gpui::{Context, Pixels, Point}; +use herdr_client::ConnectTarget; use std::{ fs::Metadata, path::{Component, Path, PathBuf, Prefix}, @@ -45,6 +47,9 @@ pub(crate) struct FileLink { /// whether or not its file is there, so a click on a missing one says /// so; a path read from printed text is a link only when it exists. pub(crate) explicit: bool, + /// The SSH target of the host the pane runs on, whose file the path + /// names; `None` for a pane on this machine. + pub(crate) remote: Option, } /// Why a path has no real one. @@ -156,8 +161,8 @@ fn real_path(path: &Path) -> Result { /// What a click on `path` opens, once symlinks are resolved: a plain folder /// or a document itself, and otherwise the nearest folder holding it that /// is not itself a bundle the system would launch. `None` when the path -/// does not exist or leads to another machine. -fn opened(path: &Path) -> Option { +/// does not exist or leads to another machine. Reads the disk. +pub(crate) fn opened(path: &Path) -> Option { let real = real_path(path).ok()?; let metadata = std::fs::metadata(&real).ok()?; let extension = real @@ -208,7 +213,8 @@ fn executable(_: &Metadata) -> bool { impl HerdrWindow { /// The row-local link of either kind under `position` in a pane. Paths - /// count only on a pane this machine runs. + /// count only on a pane this machine runs, or on an SSH host when this + /// client can reach its files. pub(crate) fn local_link_at(&self, position: Point) -> Option { if self.menu.page.is_some() || !self.live.surface_ready() @@ -224,8 +230,16 @@ impl HerdrWindow { self.cell_width, self.config.terminal.line_height(), )?; - (!matches!(link.link.target, RowTarget::Path { .. }) || !self.selected_is_remote()) - .then_some(link) + let paths = !self.selected_is_remote() || self.ssh_target().is_some(); + (!matches!(link.link.target, RowTarget::Path { .. }) || paths).then_some(link) + } + + /// The SSH target the selected endpoint runs on, if it runs on one. + fn ssh_target(&self) -> Option { + match &self.endpoints[self.selected_endpoint].connection.target { + ConnectTarget::Ssh { target, .. } => Some(target.clone()), + _ => None, + } } /// The file path under `position`, with the directory it is relative to. @@ -255,6 +269,7 @@ impl HerdrWindow { pane_id, cwd, explicit, + remote: self.ssh_target(), }) } diff --git a/crates/herdr-gpui/src/window/file_links/tests.rs b/crates/herdr-gpui/src/window/file_links/tests.rs index 0ca19b2dd..1e2eb29c1 100644 --- a/crates/herdr-gpui/src/window/file_links/tests.rs +++ b/crates/herdr-gpui/src/window/file_links/tests.rs @@ -10,6 +10,8 @@ use herdr_client::{ }; use std::sync::Arc; +mod remote; + #[test] fn paths_resolve_under_home_or_the_pane_directory() { let home = Path::new("home"); @@ -20,6 +22,7 @@ fn paths_resolve_under_home_or_the_pane_directory() { pane_id: "pane".into(), cwd: cwd.map(|cwd| cwd.to_str().unwrap().into()), explicit: false, + remote: None, }; assert_eq!( link("~/notes/a.md", None).resolve(Some(home)), @@ -280,22 +283,36 @@ fn a_link_modifier_click_opens_a_printed_file_that_exists(cx: &mut gpui::TestApp assert_eq!(cx.opened_url().as_deref(), Some(expected.as_str())); } +/// A pane on an SSH host prints that host's paths, which a click fetches from +/// there, so they are links by their shape alone, shown without a check. A +/// WSL pane's are not links at all. #[gpui::test] -fn a_pane_on_an_ssh_host_has_no_file_links(cx: &mut gpui::TestAppContext) { +fn an_ssh_pane_s_paths_are_links_and_a_wsl_pane_s_are_not(cx: &mut gpui::TestAppContext) { let root = tempfile::tempdir().unwrap(); - std::fs::write(root.path().join("a.txt"), "a").unwrap(); - let (view, cx) = window("open ./a.txt", root.path(), cx); + let (view, cx) = window(r"open C:\dev\notes.md", root.path(), cx); let position = at(&view, cx, 7); view.update(cx, |view, _| { - assert!(view.file_link_at(position).is_some()); view.endpoints[view.selected_endpoint].connection.target = ConnectTarget::Ssh { - target: "unused".into(), + target: "me@devbox".into(), + session: "default".into(), + }; + }); + cx.simulate_mouse_move(position, None, Modifiers::secondary_key()); + view.read_with(cx, |view, _| { + let link = view.file_link_at(position); + assert!(link.is_some(), "{link:?}"); + assert!(view.hovered_local_link().is_some()); + }); + + view.update(cx, |view, _| { + view.endpoints[view.selected_endpoint].connection.target = ConnectTarget::Wsl { + distro: "Ubuntu".into(), session: "default".into(), }; assert!(view.file_link_at(position).is_none()); assert!(view.local_link_at(position).is_none()); - assert!(!view.terminal_link_hovered(position, Modifiers::secondary_key())); }); modifier_click(cx, position, Modifiers::secondary_key()); assert!(cx.opened_url().is_none()); + view.read_with(cx, |view, _| assert!(view.flash.is_none())); } diff --git a/crates/herdr-gpui/src/window/file_links/tests/remote.rs b/crates/herdr-gpui/src/window/file_links/tests/remote.rs new file mode 100644 index 000000000..76257e02b --- /dev/null +++ b/crates/herdr-gpui/src/window/file_links/tests/remote.rs @@ -0,0 +1,111 @@ +//! Clicks on paths a pane on an SSH host printed, fetched from a fake host. + +use super::*; +use crate::{ + remote_files::tests::{FakeHost, NOTES, missing, notes}, + window::{RemotePath, flash::Tone}, +}; +use herdr_client::RemoteShell; + +const TARGET: &str = "me@devbox"; + +/// A window whose SSH pane prints `text` in `cwd`, fetching from `host`. +fn on_host<'a>( + host: &FakeHost, + reply: fn() -> herdr_client::Result<&'static [u8]>, + text: &str, + cwd: &str, + cx: &'a mut gpui::TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + let (view, cx) = window(text, Path::new(cwd), cx); + view.update(cx, |view, _| { + view.endpoints[view.selected_endpoint].connection.target = ConnectTarget::Ssh { + target: TARGET.into(), + session: "default".into(), + }; + host.serve(&mut view.remote_files, reply); + }); + (view, cx) +} + +fn asked(shell: RemoteShell, path: &str, line: Option) -> Vec { + vec![RemotePath { + target: TARGET.into(), + shell, + path: path.into(), + line, + }] +} + +#[gpui::test] +fn a_click_on_a_printed_drive_path_fetches_it_and_opens_the_copy(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_host(&host, notes, r"see C:\dev\notes.md:3 now", r"C:\dev", cx); + let path_cell = at(&view, cx, 8); + modifier_click(cx, path_cell, Modifiers::secondary_key()); + + let path = r"C:\dev\notes.md"; + assert_eq!(host.asked(), asked(RemoteShell::PowerShell, path, Some(3))); + let opened = cx.opened_url().unwrap(); + assert!( + opened.starts_with("file://") && opened.ends_with("/notes.md"), + "{opened}" + ); + view.read_with(cx, |view, _| { + let copy = view.remote_files.copy(TARGET, path).unwrap(); + assert_eq!(std::fs::read(©.local).unwrap(), NOTES); + assert_eq!(copy.shell, RemoteShell::PowerShell); + let real = real_path(©.local).unwrap(); + assert_eq!(opened, url::Url::from_file_path(real).unwrap().as_str()); + let (flash, _) = view.flash.as_ref().unwrap(); + assert_eq!(flash.text.as_ref(), "Fetching notes.md from me@devbox…"); + }); + + // Another click fetches the host's file again into the same copy. + let path_cell = at(&view, cx, 8); + modifier_click(cx, path_cell, Modifiers::secondary_key()); + assert_eq!(host.asked().len(), 2); + assert!(cx.opened_url().unwrap().ends_with("/notes.md")); +} + +#[gpui::test] +fn a_relative_path_is_asked_for_under_the_pane_s_windows_directory(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_host(&host, notes, r"open src\main.rs", r"C:\dev\app", cx); + let path_cell = at(&view, cx, 7); + modifier_click(cx, path_cell, Modifiers::secondary_key()); + assert_eq!( + host.asked(), + asked(RemoteShell::PowerShell, r"C:\dev\app\src\main.rs", None) + ); + assert!(cx.opened_url().unwrap().ends_with("/main.rs")); +} + +#[gpui::test] +fn a_home_path_on_a_posix_host_is_left_for_it_to_expand(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_host(&host, notes, "open ~/x.md", "/home/me", cx); + let path_cell = at(&view, cx, 7); + modifier_click(cx, path_cell, Modifiers::secondary_key()); + assert_eq!(host.asked(), asked(RemoteShell::Posix, "~/x.md", None)); + assert!(cx.opened_url().unwrap().ends_with("/x.md")); +} + +#[gpui::test] +fn a_failed_fetch_says_why_and_opens_nothing(cx: &mut gpui::TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_host(&host, missing, "open /home/me/gone.md", "/home/me", cx); + let path_cell = at(&view, cx, 7); + modifier_click(cx, path_cell, Modifiers::secondary_key()); + assert_eq!(host.asked().len(), 1); + assert!(cx.opened_url().is_none()); + view.read_with(cx, |view, _| { + let (flash, _) = view.flash.as_ref().unwrap(); + assert_eq!(flash.tone, Tone::Warning); + assert_eq!( + flash.text.as_ref(), + "Could not fetch the file: the file is not on the host" + ); + assert!(view.remote_files.copy(TARGET, "/home/me/gone.md").is_none()); + }); +} diff --git a/crates/herdr-gpui/src/window/links.rs b/crates/herdr-gpui/src/window/links.rs index a21a3b0c6..42ee0aec7 100644 --- a/crates/herdr-gpui/src/window/links.rs +++ b/crates/herdr-gpui/src/window/links.rs @@ -12,7 +12,7 @@ //! this client opens the address or file itself. A handler that fails spends //! the click. Daemons without these methods keep the row-local detector. -use super::{HerdrWindow, file_links::FileLink}; +use super::{HerdrWindow, RemotePath, file_links::FileLink}; use crate::terminal::PaneLink; use crate::{ browser::WebUrl, @@ -216,6 +216,11 @@ impl HerdrWindow { /// first question about a path starts a check on the background /// executor, whose answer is applied to wherever the pointer is then. fn file_found(&mut self, link: FileLink, cx: &mut Context) -> bool { + // A host's file counts by its shape: asking the host on every hover + // would cost an SSH round trip each time. + if link.remote.is_some() { + return RemotePath::from_link(&link).is_some(); + } if let Some((checked, found)) = &self.links.checked && *checked == link { @@ -248,16 +253,21 @@ impl HerdrWindow { /// disk again, and opens nothing for a printed path that is not there. fn clickable_file(&self, link: PaneLink) -> Option { let file = self.file_link(link)?; + if file.remote.is_some() { + return RemotePath::from_link(&file).map(|_| file); + } let missing = matches!(&self.links.checked, Some((checked, Some(false))) if *checked == file); (file.explicit || !missing).then_some(file) } /// Whether a press is on a link known to be one: anything but a printed - /// path whose check has not found it yet, which may name nothing. + /// path whose check has not found it yet, which may name nothing. A + /// host's file is one by its shape, as for a hover. pub(crate) fn verified_press(&self, press: &PressedLink) -> bool { press.file.as_ref().is_none_or(|file| { file.explicit + || file.remote.is_some() || matches!(&self.links.checked, Some((checked, Some(true))) if checked == file) }) } @@ -563,6 +573,13 @@ impl HerdrWindow { ) { match link { Some(Fallback::Web(url)) => self.open_web_link(url, in_tab, window, cx), + // The terminal editor never opens a pane on another host, so a + // host's file always opens in the system's application. + Some(Fallback::File { link, .. }) if link.remote.is_some() => { + if let Some(remote) = RemotePath::from_link(&link) { + self.open_remote_file(remote, cx); + } + } Some(Fallback::File { link, in_editor }) => self.open_file_link(link, in_editor, cx), Some(Fallback::External(url)) if url.trusted() => cx.open_url(url.as_str()), Some(Fallback::External(url)) => self.ask_to_open_link(url, window, cx), diff --git a/crates/herdr-gpui/src/window/links/tests/file_handlers.rs b/crates/herdr-gpui/src/window/links/tests/file_handlers.rs index b659b8ee6..db005f9a5 100644 --- a/crates/herdr-gpui/src/window/links/tests/file_handlers.rs +++ b/crates/herdr-gpui/src/window/links/tests/file_handlers.rs @@ -197,26 +197,32 @@ fn a_resolved_file_hyperlink_keeps_its_local_fallback(cx: &mut gpui::TestAppCont view.read_with(cx, |view, _| assert!(view.links.activating.is_none())); } -/// A pane on an SSH host prints that host's `file://` links, which never open -/// here, yet a quick click, before any hover resolved it, still reaches the -/// daemon so a handler on that host can claim it. Declined, nothing opens. +/// A pane on an SSH host prints that host's `file://` links. A quick click, +/// before any hover resolved one, still reaches the daemon first so a handler +/// on that host can claim it. Declined, the file is fetched from the host and +/// its copy opens here. A Windows client reads no path from a `file://` URL +/// naming a POSIX path, so there the link is no file and opens nothing. #[gpui::test] fn a_remote_file_hyperlink_still_reaches_plugin_handlers(cx: &mut gpui::TestAppContext) { + use crate::remote_files::tests::{FakeHost, notes}; + let opens = !cfg!(windows); + let host = FakeHost::new(); let mut peer = MockPeer::advertising(&["pane.link.resolve", "pane.link.activate"]); let (view, cx) = window(&peer, true, cx); - let url = "file://devbox/home/dev/notes.md"; + let url = "file:///home/dev/notes.md"; view.update(cx, |view, _| { view.live.surface = Some(hyperlinked(url)); let selected = view.selected_endpoint; view.endpoints[selected].connection.target = herdr_client::ConnectTarget::Ssh { - target: "devbox.invalid".into(), + target: "dev@devbox.invalid".into(), session: "default".into(), }; + host.serve(&mut view.remote_files, notes); assert!(view.selected_is_remote()); }); let link = at(&view, cx, 3, 0); view.read_with(cx, |view, _| { - assert!(view.file_link_at(link).is_none(), "not this machine's file"); + assert_eq!(view.file_link_at(link).is_some(), opens, "the host's file"); assert!(view.hovered_daemon_link().is_none()); }); @@ -224,6 +230,7 @@ fn a_remote_file_hyperlink_still_reaches_plugin_handlers(cx: &mut gpui::TestAppC cx.simulate_click(link, Modifiers::secondary_key()); let request = activation(&mut peer); assert_eq!(request["params"]["col"], 3); + assert!(host.asked().is_empty(), "the daemon answers first"); reply( &mut peer, &view, @@ -231,7 +238,23 @@ fn a_remote_file_hyperlink_still_reaches_plugin_handlers(cx: &mut gpui::TestAppC &request, json!({"result": {"type": "pane_link_activated", "url": url, "handled": handled}}), ); - assert!(cx.opened_url().is_none(), "handled={handled}"); + if handled || !opens { + assert!(cx.opened_url().is_none(), "handled={handled}"); + assert!(host.asked().is_empty(), "handled={handled}"); + } + } + if opens { + assert_eq!( + host.asked(), + vec![RemotePath { + target: "dev@devbox.invalid".into(), + shell: herdr_client::RemoteShell::Posix, + path: "/home/dev/notes.md".into(), + line: None, + }] + ); + let opened = cx.opened_url().unwrap(); + assert!(opened.ends_with("/notes.md"), "{opened}"); } // Without the modifier the click stays the pane's, as on any hyperlink // this client cannot open. diff --git a/crates/herdr-gpui/src/window/remote_paths.rs b/crates/herdr-gpui/src/window/remote_paths.rs new file mode 100644 index 000000000..9d08fac8b --- /dev/null +++ b/crates/herdr-gpui/src/window/remote_paths.rs @@ -0,0 +1,111 @@ +//! 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 shell and the absolute or `~` path for `path` as printed under `cwd`. +fn resolve(path: &str, cwd: Option<&str>) -> Option<(RemoteShell, String)> { + let path = file_url_drive(path); + if windows(path) { + return Some((RemoteShell::PowerShell, backslashes(path))); + } + if path.starts_with('/') { + return Some((RemoteShell::Posix, path.to_owned())); + } + let cwd = cwd.map(file_url_drive); + if let Some(rest) = path.strip_prefix('~') { + // `~user` names another account's home, which is not resolved. + if !(rest.is_empty() || rest.starts_with(['/', '\\'])) { + return None; + } + let powershell = cwd.map_or(rest.starts_with('\\'), windows); + return Some(if powershell { + (RemoteShell::PowerShell, backslashes(path)) + } else { + (RemoteShell::Posix, path.to_owned()) + }); + } + // Empty, or relative to the root of a drive no cwd names. + if path.is_empty() || path.starts_with('\\') { + return None; + } + let relative = without_current(path); + let cwd = cwd?; + if windows(cwd) { + let cwd = backslashes(cwd); + let folder = cwd.trim_end_matches('\\'); + Some(( + RemoteShell::PowerShell, + format!("{folder}\\{}", backslashes(relative)), + )) + } else if cwd.starts_with('/') { + let folder = cwd.trim_end_matches('/'); + Some((RemoteShell::Posix, format!("{folder}/{relative}"))) + } else { + None + } +} + +/// Whether `path` starts with a drive and a separator, as in `C:\` or `c:/`, +/// or is a UNC path such as `\\server\share`. +fn windows(path: &str) -> bool { + path.starts_with(r"\\") + || matches!(path.as_bytes(), [letter, b':', b'\\' | b'/', ..] if letter.is_ascii_alphabetic()) +} + +/// A `file://` address's drive path, which reads `/C:/…` on a Unix client, +/// as the drive path `C:/…`; any other path unchanged. +fn file_url_drive(path: &str) -> &str { + match path.strip_prefix('/') { + Some(rest) if windows(rest) && !rest.starts_with('\\') => rest, + _ => path, + } +} + +/// `path` without the `./` or `.\` that only says it is relative. +fn without_current(mut path: &str) -> &str { + while let Some(rest) = path.strip_prefix("./").or_else(|| path.strip_prefix(r".\")) { + path = rest; + } + path +} + +fn backslashes(path: &str) -> String { + path.replace('/', "\\") +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests; diff --git a/crates/herdr-gpui/src/window/remote_paths/tests.rs b/crates/herdr-gpui/src/window/remote_paths/tests.rs new file mode 100644 index 000000000..544d5911e --- /dev/null +++ b/crates/herdr-gpui/src/window/remote_paths/tests.rs @@ -0,0 +1,161 @@ +use super::*; + +fn link(path: &str, cwd: Option<&str>) -> FileLink { + FileLink { + path: path.into(), + line: None, + pane_id: "pane".into(), + cwd: cwd.map(Into::into), + explicit: false, + remote: Some("me@devbox".into()), + } +} + +/// The shell and path the host is asked for, or `None` when there is none. +fn asked(path: &str, cwd: Option<&str>) -> Option<(RemoteShell, String)> { + RemotePath::from_link(&link(path, cwd)).map(|remote| (remote.shell, remote.path)) +} + +fn powershell(path: &str) -> Option<(RemoteShell, String)> { + Some((RemoteShell::PowerShell, path.into())) +} + +fn posix(path: &str) -> Option<(RemoteShell, String)> { + Some((RemoteShell::Posix, path.into())) +} + +#[test] +fn a_local_pane_has_no_remote_path() { + let local = FileLink { + remote: None, + ..link("/home/me/a.md", None) + }; + assert_eq!(RemotePath::from_link(&local), None); +} + +#[test] +fn a_drive_path_is_read_in_powershell_with_backslashes() { + assert_eq!( + asked(r"C:\dev\notes.md", None), + powershell(r"C:\dev\notes.md") + ); + assert_eq!( + asked("c:/dev/notes.md", None), + powershell(r"c:\dev\notes.md") + ); + assert_eq!( + asked(r"D:/dev\sub/x.rs", Some("/home/me")), + powershell(r"D:\dev\sub\x.rs") + ); +} + +#[test] +fn a_drive_path_keeps_its_target_and_line() { + let remote = RemotePath::from_link(&FileLink { + line: Some(42), + ..link(r"C:\dev\notes.md", None) + }) + .unwrap(); + assert_eq!( + remote, + RemotePath { + target: "me@devbox".into(), + shell: RemoteShell::PowerShell, + path: r"C:\dev\notes.md".into(), + line: Some(42), + } + ); +} + +#[test] +fn a_file_url_drive_path_loses_its_leading_slash() { + assert_eq!( + asked("/C:/Users/me/a.md", None), + powershell(r"C:\Users\me\a.md") + ); + // A relative path under such a cwd joins it as a drive path too. + assert_eq!( + asked("a.md", Some("/C:/Users/me")), + powershell(r"C:\Users\me\a.md") + ); +} + +#[test] +fn a_unc_path_is_read_in_powershell() { + assert_eq!( + asked(r"\\server\share\a.md", None), + powershell(r"\\server\share\a.md") + ); + assert_eq!( + asked("a.md", Some(r"\\server\share")), + powershell(r"\\server\share\a.md") + ); +} + +#[test] +fn a_posix_absolute_path_is_kept_as_is() { + assert_eq!(asked("/home/me/x.md", None), posix("/home/me/x.md")); + assert_eq!( + asked("/srv/a b/../x.md", Some(r"C:\dev")), + posix("/srv/a b/../x.md") + ); +} + +#[test] +fn a_home_path_is_left_for_the_host_to_expand() { + assert_eq!(asked("~/x.md", Some("/home/me")), posix("~/x.md")); + assert_eq!(asked("~/x.md", None), posix("~/x.md")); + assert_eq!(asked("~", None), posix("~")); + // A Windows host reads it in PowerShell, by the cwd or the separator. + assert_eq!(asked("~/x.md", Some(r"C:\Users\me")), powershell(r"~\x.md")); + assert_eq!(asked(r"~\x.md", None), powershell(r"~\x.md")); + // Another account's home is not resolved. + assert_eq!(asked("~other/x.md", Some("/home/me")), None); +} + +#[test] +fn a_relative_path_joins_a_posix_cwd_with_slashes() { + assert_eq!( + asked("src/main.rs", Some("/home/me/app")), + posix("/home/me/app/src/main.rs") + ); + assert_eq!( + asked("./src/main.rs", Some("/home/me/app/")), + posix("/home/me/app/src/main.rs") + ); + assert_eq!( + asked("../x.md", Some("/home/me/app")), + posix("/home/me/app/../x.md") + ); + assert_eq!(asked("a.md", Some("/")), posix("/a.md")); +} + +#[test] +fn a_relative_path_joins_a_windows_cwd_with_backslashes() { + assert_eq!( + asked(r"src\main.rs", Some(r"C:\dev\app")), + powershell(r"C:\dev\app\src\main.rs") + ); + assert_eq!( + asked("src/main.rs", Some("C:/dev/app/")), + powershell(r"C:\dev\app\src\main.rs") + ); + assert_eq!( + asked(r".\src\main.rs", Some(r"C:\dev\app")), + powershell(r"C:\dev\app\src\main.rs") + ); + assert_eq!( + asked(r"..\x.md", Some(r"C:\dev\app")), + powershell(r"C:\dev\app\..\x.md") + ); + assert_eq!(asked("a.md", Some(r"C:\")), powershell(r"C:\a.md")); +} + +#[test] +fn a_relative_path_without_an_absolute_cwd_has_no_remote_path() { + assert_eq!(asked("src/main.rs", None), None); + assert_eq!(asked("src/main.rs", Some("relative/dir")), None); + assert_eq!(asked("src/main.rs", Some("C:relative")), None); + assert_eq!(asked("", Some("/home/me")), None); + assert_eq!(asked(r"\dev\x.md", Some(r"C:\dev")), None); +} From bb6ddcd022fa70209f9004266b6128ca72d10056 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Fri, 16 Oct 2026 23:49:12 -0700 Subject: [PATCH 03/19] feat: upload saves to a fetched SSH file back to its host A copy fetched by clicking a path in an SSH pane is now watched for saves. The first save asks whether to upload it: Upload, Upload future saves without asking, or Not now. Later saves follow that answer, and an automatic upload reports itself in a flash. Every upload sends the hash the host's file had when it was last synced. When the file changed on the host since then, nothing is written and a dialog offers Keep both (uploads beside it as name.conflict.ext), Overwrite anyway, or Re-fetch. Copies over 64 MB are not uploaded. The client's push now carries the exact byte count, and the host refuses to replace its file with a temp file that holds fewer bytes, so a dropped connection or cancelled upload leaves it unchanged. A click that fetches a copy again keeps a save made while the fetch ran. The host, shell, and path of a transfer travel as one RemoteFile. Watching runs off the UI thread at most once a second; a change counts once two runs agree on it, which lets an editor's atomic save settle. Prompts wait for any open dialog instead of replacing it. Saving a copy back to its synced bytes drops its question and closes its dialog, and a failed Re-fetch offers the conflict again. An upload never makes the file readable by anyone who could not read it before. A POSIX host gives the temp file the target's group and mode before the rename, and writes in place instead when the group cannot be kept with the same reach or an ACL shows (`ls` `+` or `@`, `getfacl`). Windows PowerShell 5.1 and 7 create the temp file with the target's DACL. Keep both takes the original file's access. When access cannot be kept and nothing can be written safely, the host exits with its own code and the user is told so. A macOS host's inheritable folder ACL can still read the temp file during the transfer. Each window keeps its copies in its own cache folder, so a fetch in one window never replaces another's unsent edits. A click while its file is still being fetched starts no second fetch, and each fetch writes its own part file. --- crates/herdr-client/src/lib.rs | 4 +- crates/herdr-client/src/remote_file.rs | 128 +++-- crates/herdr-client/src/remote_file/posix.rs | 112 +++- .../src/remote_file/powershell.rs | 57 +- .../src/remote_file/tests/posix_exec.rs | 331 +++++++++++- .../src/remote_file/tests/powershell_exec.rs | 195 ++++++- .../src/remote_file/tests/scripts.rs | 120 ++++- crates/herdr-gpui/src/menu.rs | 1 + crates/herdr-gpui/src/menu/chrome.rs | 12 +- crates/herdr-gpui/src/menu/chrome/keys.rs | 6 + crates/herdr-gpui/src/menu/page.rs | 5 + .../src/menu/remote_file_dialogs.rs | 228 ++++++++ crates/herdr-gpui/src/menu/state.rs | 4 + crates/herdr-gpui/src/remote_files.rs | 488 +++++++++++++++--- crates/herdr-gpui/src/remote_files/cache.rs | 30 +- crates/herdr-gpui/src/remote_files/push.rs | 93 ++++ crates/herdr-gpui/src/remote_files/sync.rs | 184 +++++++ crates/herdr-gpui/src/remote_files/tests.rs | 182 ++++++- .../src/remote_files/tests/answers.rs | 89 ++++ .../remote_files/tests/dismissed_conflict.rs | 142 +++++ .../src/remote_files/tests/fetch_meta.rs | 39 ++ .../src/remote_files/tests/fetches.rs | 128 +++++ .../src/remote_files/tests/saves.rs | 475 +++++++++++++++++ .../src/remote_files/tests/windows_names.rs | 71 +++ crates/herdr-gpui/src/remote_files/uploads.rs | 307 +++++++++++ crates/herdr-gpui/src/remote_files/watch.rs | 131 +++++ crates/herdr-gpui/src/window.rs | 1 + .../herdr-gpui/src/window/file_links/tests.rs | 1 + .../src/window/file_links/tests/remote.rs | 4 +- .../window/file_links/tests/remote_saves.rs | 353 +++++++++++++ crates/herdr-gpui/src/window/remote_paths.rs | 9 + 31 files changed, 3732 insertions(+), 198 deletions(-) create mode 100644 crates/herdr-gpui/src/menu/remote_file_dialogs.rs create mode 100644 crates/herdr-gpui/src/remote_files/push.rs create mode 100644 crates/herdr-gpui/src/remote_files/sync.rs create mode 100644 crates/herdr-gpui/src/remote_files/tests/answers.rs create mode 100644 crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs create mode 100644 crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs create mode 100644 crates/herdr-gpui/src/remote_files/tests/fetches.rs create mode 100644 crates/herdr-gpui/src/remote_files/tests/saves.rs create mode 100644 crates/herdr-gpui/src/remote_files/tests/windows_names.rs create mode 100644 crates/herdr-gpui/src/remote_files/uploads.rs create mode 100644 crates/herdr-gpui/src/remote_files/watch.rs create mode 100644 crates/herdr-gpui/src/window/file_links/tests/remote_saves.rs diff --git a/crates/herdr-client/src/lib.rs b/crates/herdr-client/src/lib.rs index 83c590166..9ba10e699 100644 --- a/crates/herdr-client/src/lib.rs +++ b/crates/herdr-client/src/lib.rs @@ -70,8 +70,8 @@ pub use options::ConnectOptions; pub use process::CREATE_NO_WINDOW; pub use process::new_command; pub use remote_file::{ - Error as RemoteFileError, Expect, Fetched, MAX_REMOTE_FILE_BYTES, RemoteShell, - fetch_remote_file, push_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}; diff --git a/crates/herdr-client/src/remote_file.rs b/crates/herdr-client/src/remote_file.rs index e5c6b3ef4..992fb6c4c 100644 --- a/crates/herdr-client/src/remote_file.rs +++ b/crates/herdr-client/src/remote_file.rs @@ -12,15 +12,38 @@ //! variants. Any other failure, including an SSH failure, is //! [`Error::Script`]. //! -//! A push writes a private temporary file beside the target and renames it -//! over the target only after checking [`Expect`] against the host's file. +//! 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 a new file gets the mode -//! the host's umask gives one; either way the file becomes the SSH user's. A -//! POSIX host whose `stat` cannot report a mode gets the file overwritten in -//! place instead, which keeps its owner but is not atomic. +//! An existing file keeps its permission bits and, on a POSIX host, its +//! group, and becomes the SSH user's. 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. +//! +//! 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}; @@ -55,6 +78,13 @@ const NOT_A_FILE_EXIT: i32 = 67; 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; /// The host's file no longer matches what the push expected. const CONFLICT_EXIT: i32 = 75; @@ -82,6 +112,17 @@ pub enum Expect { 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 { @@ -95,8 +136,12 @@ pub struct Fetched { #[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)")] @@ -113,16 +158,25 @@ pub enum Error { // keep-sorted end } -/// Stream `path` on `target` to `out`, refusing a missing path, a folder or -/// device, and a file larger than `max_bytes`. Symbolic links are followed. +/// 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: -/// target: A `[user@]host` SSH target. -/// shell: The shell the host runs scripts in. -/// path: The host's absolute path, or one starting `~/` (also `~\` for -/// PowerShell) for the login user's home folder. +/// 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. @@ -134,52 +188,56 @@ pub enum Error { /// [`crate::Error::RemoteFile`] for a refusal or a failed script, or a /// target or platform error from [`crate::Error`]. pub fn fetch_remote_file( - target: &str, - shell: RemoteShell, - path: &str, + file: RemoteFile<'_>, out: impl Write + Send, max_bytes: u64, cancelled: &AtomicBool, ) -> Result { - let script = match shell { - RemoteShell::Posix => posix::fetch_script(path, max_bytes), - RemoteShell::PowerShell => powershell::fetch_script(path, max_bytes), + 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(target, shell, &script)?; + let command = remote_command(file.target, file.shell, &script)?; Ok(fetch_with(command, out, max_bytes, cancelled)?) } -/// Replace `path` on `target` with `input`, only while the host's file still -/// meets `expect`. A symbolic link or non-regular file at the path is never +/// 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: -/// target: A `[user@]host` SSH target. -/// shell: The shell the host runs scripts in. -/// path: As for [`fetch_remote_file`]. Its folder must exist. +/// 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. -/// input: The new contents. The caller bounds its length. +/// 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, or a failed -/// script, or a target or platform error from [`crate::Error`]. +/// [`crate::Error::RemoteFile`] for a refusal, a conflict, a transfer +/// that was not `len` bytes ([`Error::Incomplete`]), access it cannot +/// keep ([`Error::Access`]), or a failed script, or a target or platform +/// error from [`crate::Error`]. pub fn push_remote_file( - target: &str, - shell: RemoteShell, - path: &str, + file: RemoteFile<'_>, expect: Expect, + access: Access<'_>, + len: u64, input: impl Read + Send, cancelled: &AtomicBool, ) -> Result<()> { - let script = match shell { - RemoteShell::Posix => posix::push_script(path, expect), - RemoteShell::PowerShell => powershell::push_script(path, expect), + 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(target, shell, &script)?; + let command = remote_command(file.target, file.shell, &script)?; Ok(push_with(command, input, cancelled)?) } @@ -249,6 +307,8 @@ fn classify(error: crate::Error, limit: Option) -> Error { (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(SHORT_EXIT), _) => Error::Incomplete, _ => Error::Script(Box::new(error)), } } diff --git a/crates/herdr-client/src/remote_file/posix.rs b/crates/herdr-client/src/remote_file/posix.rs index 9aa223db1..76ff0f68d 100644 --- a/crates/herdr-client/src/remote_file/posix.rs +++ b/crates/herdr-client/src/remote_file/posix.rs @@ -1,20 +1,28 @@ //! The `/bin/sh` scripts. They use only POSIX shell, `wc`, `cat`, `mktemp`, -//! `mv`, and `rm`, plus a SHA-256 tool when a push expects a hash. +//! `mv`, and `rm`, plus a SHA-256 tool when a push expects a hash, and `stat`, +//! `ls`, `getfacl` where there is one, `chgrp`, and `chmod` to keep a file's +//! access. use super::{ - CONFLICT_EXIT, Expect, MISSING_EXIT, NO_HASHER_EXIT, NOT_A_FILE_EXIT, SYMLINK_EXIT, - TOO_LARGE_EXIT, hex, + ACCESS_EXIT, Access, CONFLICT_EXIT, Expect, MISSING_EXIT, NO_HASHER_EXIT, NOT_A_FILE_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 r#"path="$HOME""#.to_owned(); + return format!(r#"{name}="$HOME""#); } match path.strip_prefix("~/") { - Some(rest) => format!(r#"path="$HOME"/{}"#, shell_quote(rest)), - None => format!("path={}", shell_quote(path)), + Some(rest) => format!(r#"{name}="$HOME"/{}"#, shell_quote(rest)), + None => format!("{name}={}", shell_quote(path)), } } @@ -73,37 +81,87 @@ if [ "${{sum%% *}}" != '{hex}' ]; then exit {CONFLICT_EXIT}; fi"#, } } -/// Gives the copy the target's permission bits, read with GNU `stat -c` or -/// BSD `stat -f`, or a new file the mode the caller's umask gives one. When no -/// `stat` reads the target's mode, the copy is written over the target in -/// place instead: that keeps the mode, owner, and hard links, but it is not -/// atomic, so a reader can see a partly written file and a failure partway -/// leaves one. -const KEEP_MODE: &str = r#"if [ -e "$path" ]; then -mode=$(stat -c %a -- "$path" 2>/dev/null) || mode=$(stat -f %Lp -- "$path" 2>/dev/null) || mode= +/// 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. +/// +/// 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; a target the SSH +/// user cannot write is refused with [`ACCESS_EXIT`] 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() -> String { + format!( + r#"if [ -e "$path" ]; then like=$path; 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 -if [ -z "$mode" ]; then -cat < "$tmp" > "$path" || exit 1 -exit 0 +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 -- "$tmp" 2>/dev/null) || tmp_group=$(stat -f %g -- "$tmp" 2>/dev/null) || tmp_group= +if [ -n "$mode" ] && [ -n "$group" ]; then keep=1; fi +if [ -L "$like" ] || [ ! -f "$like" ]; then keep=; fi +for listed in "$like" "$tmp"; 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" "$tmp" 2>/dev/null; then +g=$(( (0$mode >> 3) & 7 )) +o=$(( 0$mode & 7 )) +if [ "$g" -ne "$o" ]; then keep=; fi +fi fi +if [ -n "$keep" ]; then chmod -- "$mode" "$tmp" || exit 1 -else +elif [ -e "$path" ]; then +if [ ! -w "$path" ]; then exit {ACCESS_EXIT}; fi +cat < "$tmp" > "$path" || exit 1 +exit 0 +elif [ -z "$like" ]; then chmod -- "$(printf '%o' $(( 0666 & ~0$old_umask )))" "$tmp" || exit 1 -fi"#; +fi"# + ) +} -/// Copies stdin to a private temporary file beside the target, checks -/// `expect`, gives the copy the target's permission bits (see [`KEEP_MODE`]), -/// and renames it over the target, so the file becomes the SSH user's. The -/// trap removes the copy on every exit that did not rename it. -pub(super) fn push_script(path: &str, expect: Expect) -> String { +/// Copies stdin to a private temporary file beside the target, refuses it +/// 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. When that access cannot be +/// kept, it writes over an existing target in place instead (see +/// [`keep_access`]). The trap removes the copy on every exit that did not +/// rename it. +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), + }; format!( r#"old_umask=$(umask) umask 077 {assign} +{source} {refuse} {hasher} case $path in @@ -115,13 +173,17 @@ name=${{path##*/}} tmp=$(mktemp "$dir/.$name.XXXXXX") || exit 1 trap 'rm -f -- "$tmp"' EXIT cat > "$tmp" || exit 1 +size=$(wc -c < "$tmp") || exit 1 +case $size in ''|*[!0-9\ ]*) exit 1 ;; esac +if [ $size -ne {len} ]; then exit {SHORT_EXIT}; fi {refuse} {check} -{KEEP_MODE} +{keep} mv -f -- "$tmp" "$path" || exit 1 trap - EXIT"#, assign = path_assignment(path), refuse = refuse_unsafe(), check = expect_check(expect), + keep = keep_access(), ) } diff --git a/crates/herdr-client/src/remote_file/powershell.rs b/crates/herdr-client/src/remote_file/powershell.rs index 413969be6..722f168a2 100644 --- a/crates/herdr-client/src/remote_file/powershell.rs +++ b/crates/herdr-client/src/remote_file/powershell.rs @@ -3,7 +3,8 @@ //! streams: PowerShell's own pipeline would re-encode them as text. use super::{ - CONFLICT_EXIT, Expect, MISSING_EXIT, NOT_A_FILE_EXIT, SYMLINK_EXIT, TOO_LARGE_EXIT, hex, + 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 @@ -96,14 +97,35 @@ fn expect_check(expect: Expect) -> String { } } -/// Copies stdin to a hidden-named temporary file beside the target, checks -/// `expect`, and replaces the target with it. `finally` removes the copy on -/// every exit that did not rename it. `[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) -> String { +/// 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) @@ -122,15 +144,36 @@ Assert-HerdrReplaceable $folder = [System.IO.Path]::GetDirectoryName($path) $name = '.' + [System.IO.Path]::GetFileName($path) + '.' + [System.IO.Path]::GetRandomFileName() $temporary = [System.IO.Path]::Combine($folder, $name) -$copy = [System.IO.File]::Open($temporary, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) +$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)) {{ diff --git a/crates/herdr-client/src/remote_file/tests/posix_exec.rs b/crates/herdr-client/src/remote_file/tests/posix_exec.rs index 74f5d4b6a..291560bb2 100644 --- a/crates/herdr-client/src/remote_file/tests/posix_exec.rs +++ b/crates/herdr-client/src/remote_file/tests/posix_exec.rs @@ -60,9 +60,46 @@ impl Fixture { } fn push(&self, path: &str, expect: Expect, input: &[u8]) -> std::result::Result<(), Error> { - let command = self.command(&posix::push_script(path, expect)); + 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 { + let bin = tempfile::tempdir().unwrap(); + let chgrp = bin.path().join("chgrp"); + std::fs::write(&chgrp, "#!/bin/sh\nexit 1\n").unwrap(); + std::fs::set_permissions(&chgrp, std::fs::Permissions::from_mode(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 + } } #[test] @@ -189,6 +226,296 @@ fn push_keeps_an_existing_files_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"]); +} + +#[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}"); + } +} + +#[test] +#[ignore = "needs setfacl and an ACL-capable filesystem"] +fn push_under_a_folders_default_acl_overwrites_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 home = fixture.home.path().to_str().unwrap(); + output_of("setfacl", &["-d", "-m", "u:nobody:r", home]); + let file = fixture.text("file"); + let before = std::fs::metadata(&file).unwrap(); + fixture.push(&file, Expect::Any, b"new").unwrap(); + let after = std::fs::metadata(&file).unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), b"new"); + assert_eq!(after.ino(), before.ino(), "the file was written in place"); + let acl = output_of("getfacl", &["--omit-header", &file]); + assert!(!acl.contains("nobody"), "{acl}"); + 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(&["cat", "chmod", "mktemp", "mv", "rm", "wc"]); + 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"]); +} + /// 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(); @@ -208,7 +535,7 @@ fn push_without_stat_overwrites_in_place_and_keeps_the_mode() { let mut fixture = Fixture::new(); std::fs::write(fixture.path("file"), b"old").unwrap(); fixture.set_mode("file", 0o755); - let bin = tool_folder(&["cat", "chmod", "mktemp", "mv", "rm"]); + let bin = tool_folder(&["cat", "chmod", "mktemp", "mv", "rm", "wc"]); fixture.path_env = Some(bin.path().to_owned()); let content = payload(70_000); fixture diff --git a/crates/herdr-client/src/remote_file/tests/powershell_exec.rs b/crates/herdr-client/src/remote_file/tests/powershell_exec.rs index ff988f558..042bac153 100644 --- a/crates/herdr-client/src/remote_file/tests/powershell_exec.rs +++ b/crates/herdr-client/src/remote_file/tests/powershell_exec.rs @@ -117,13 +117,29 @@ impl Fixture { } 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), + &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) { @@ -164,6 +180,20 @@ fn pushes(fixture: &Fixture) { 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); @@ -190,12 +220,173 @@ fn pushes(fixture: &Fixture) { ); 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")]); + 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(), ".secret.txt.", &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() { diff --git a/crates/herdr-client/src/remote_file/tests/scripts.rs b/crates/herdr-client/src/remote_file/tests/scripts.rs index eefc64a38..99e14965b 100644 --- a/crates/herdr-client/src/remote_file/tests/scripts.rs +++ b/crates/herdr-client/src/remote_file/tests/scripts.rs @@ -19,7 +19,7 @@ fn posix_scripts_assign_the_path_as_one_quoted_word() { "{path}" ); for expect in [Expect::Any, Expect::Absent, Expect::Sha256([0; 32])] { - let script = posix::push_script(path, expect); + let script = posix::push_script(path, expect, Access::Keep, 0); assert!(script.contains(&format!("\n{assignment}")), "{path}"); } } @@ -42,7 +42,7 @@ 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])); + let push = posix::push_script("-x", Expect::Sha256([0; 32]), Access::Keep, 0); assert!(push.contains("$hasher < \"$path\"")); assert!(push.contains("mv -f -- \"$tmp\" \"$path\"")); } @@ -50,14 +50,78 @@ fn posix_scripts_never_pass_the_path_where_it_could_be_an_option() { #[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)); + 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).contains("sha256sum")); + 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 < \"$path\"").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 checked = script + .find("if [ ! -w \"$path\" ]; then exit 71; fi") + .unwrap(); + assert!(checked < script.find("cat < \"$tmp\" > \"$path\"").unwrap()); + 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))); @@ -69,7 +133,7 @@ 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); + 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 { @@ -93,13 +157,13 @@ fn powershell_scripts_expand_only_a_leading_home() { #[test] fn powershell_push_checks_each_expectation() { - let script = powershell::push_script("C:\\f", Expect::Sha256([0x0F; 32])); + 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); + 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); + 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")); @@ -112,7 +176,7 @@ fn powershell_push_checks_each_expectation() { #[test] fn encoded_scripts_decode_back_to_the_script() { - let script = powershell::push_script("C:\\\u{e9}\u{1F600}'", Expect::Absent); + let script = powershell::push_script("C:\\\u{e9}\u{1F600}'", Expect::Absent, Access::Keep, 0); assert_eq!(decode(&encode(&script)), script); } @@ -177,6 +241,8 @@ fn exit_codes_map_to_refusals() { } )); 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(75), None), Error::Conflict)); assert!(matches!( classify(crate::Error::ScriptOutputLimit, Some(7)), @@ -227,28 +293,32 @@ fn messages_are_short_and_plain() { "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" + ); +} + +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 fetched = fetch_remote_file( - "-oProxyCommand=x", - RemoteShell::Posix, - "/f", - Vec::new(), - 1, - &cancelled, - ); + 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 pushed = push_remote_file( - "-oProxyCommand=x", - RemoteShell::PowerShell, - "C:\\f", - Expect::Any, - &[][..], - &cancelled, - ); + 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))); } diff --git a/crates/herdr-gpui/src/menu.rs b/crates/herdr-gpui/src/menu.rs index a49570f77..c3296d49e 100644 --- a/crates/herdr-gpui/src/menu.rs +++ b/crates/herdr-gpui/src/menu.rs @@ -14,6 +14,7 @@ mod open_link; mod page; mod pr; mod pr_actions; +mod remote_file_dialogs; mod sessions; mod settings; mod shortcut_editor; diff --git a/crates/herdr-gpui/src/menu/chrome.rs b/crates/herdr-gpui/src/menu/chrome.rs index 53973d90a..0b2b94404 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 @@ -509,7 +510,12 @@ impl HerdrWindow { .when( matches!( page, - Page::Install | Page::AgentSkill | Page::VersionMismatch | Page::OpenLink + Page::Install + | Page::AgentSkill + | Page::VersionMismatch + | Page::OpenLink + | Page::RemoteConflict + | Page::RemoteUpload ), |panel| { panel @@ -669,6 +675,10 @@ impl HerdrWindow { panel = panel.child(self.render_worktree_script(cx)); } else if page == Page::OpenLink { panel = panel.child(self.render_open_link(cx)); + } else if page == Page::RemoteUpload { + panel = panel.child(self.render_remote_upload(cx)); + } else if page == Page::RemoteConflict { + panel = panel.child(self.render_remote_conflict(cx)); } else if page == Page::Install { panel = panel .child(div().p(px(8.)).child("Herdr must be installed")) diff --git a/crates/herdr-gpui/src/menu/chrome/keys.rs b/crates/herdr-gpui/src/menu/chrome/keys.rs index 6d4fea66c..554062be6 100644 --- a/crates/herdr-gpui/src/menu/chrome/keys.rs +++ b/crates/herdr-gpui/src/menu/chrome/keys.rs @@ -335,6 +335,12 @@ impl HerdrWindow { "enter" if self.menu.page == Some(Page::OpenLink) => { self.confirm_open_link(window, cx); } + "enter" if self.menu.page == Some(Page::RemoteUpload) => { + self.confirm_remote_upload(crate::remote_files::Answer::Upload, window, cx); + } + "enter" if self.menu.page == Some(Page::RemoteConflict) => { + self.confirm_remote_conflict(crate::remote_files::Resolution::KeepBoth, window, cx); + } "enter" if self.menu.page == Some(Page::VersionMismatch) => { self.act_on_version_mismatch(window, cx); } diff --git a/crates/herdr-gpui/src/menu/page.rs b/crates/herdr-gpui/src/menu/page.rs index 9585276bf..8543ae569 100644 --- a/crates/herdr-gpui/src/menu/page.rs +++ b/crates/herdr-gpui/src/menu/page.rs @@ -65,6 +65,11 @@ pub(crate) enum Page { PrMerge, /// The open pull request's checks and review conversation. PrReview, + /// A remote file's host changed since its copy was synced, so a save + /// was not uploaded. + RemoteConflict, + /// Asks before a save to a remote file's copy is uploaded. + RemoteUpload, RemoveDevice, /// Confirming a saved WSL distribution should be forgotten. RemoveWsl, diff --git a/crates/herdr-gpui/src/menu/remote_file_dialogs.rs b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs new file mode 100644 index 000000000..2f22d56be --- /dev/null +++ b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs @@ -0,0 +1,228 @@ +//! The questions a save to a remote file's copy asks: whether to upload it, +//! and what to do when the host's file changed since it was synced. They +//! open only when no other page holds the window, and wait until then. + +use super::Page; +use crate::{ + HerdrWindow, + fonts::StyledFont, + remote_files::{Answer, Prompt, PromptKind, Resolution, basename}, +}; +use gpui::{prelude::*, *}; + +impl HerdrWindow { + /// The question the open page asks, if it is one of these. + pub(crate) fn shown_remote_file_prompt(&self) -> Option { + let kind = match self.menu.page? { + Page::RemoteUpload => PromptKind::Upload, + Page::RemoteConflict => PromptKind::Conflict, + _ => return None, + }; + Some(Prompt { + key: self.menu.remote_file.clone()?, + kind, + }) + } + + /// Shows `prompt` unless another page holds the window. `true` when it + /// opened. + pub(crate) fn show_remote_file_prompt( + &mut self, + prompt: &Prompt, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.menu.page.is_some() || !self.open_menu(window, cx) { + return false; + } + self.menu.remote_file = Some(prompt.key.clone()); + self.menu.page = Some(match prompt.kind { + PromptKind::Upload => Page::RemoteUpload, + PromptKind::Conflict => Page::RemoteConflict, + }); + true + } + + /// Closes the open question without answering it: its copy no longer + /// waits for the answer. The copy is forgotten first, so closing is not + /// taken as a "Not now". + pub(crate) fn close_remote_file_prompt(&mut self, window: &mut Window, cx: &mut Context) { + self.menu.remote_file = None; + self.dismiss_menu(window, cx); + } + + /// Closing the upload question without an answer is a "Not now". + pub(in crate::menu) fn decline_remote_upload_on_dismiss(&mut self, cx: &mut Context) { + if self.menu.page == Some(Page::RemoteUpload) + && let Some(key) = self.menu.remote_file.take() + { + self.answer_remote_upload(key, Answer::NotNow, cx); + } + } + + pub(in crate::menu) fn confirm_remote_upload( + &mut self, + answer: Answer, + window: &mut Window, + cx: &mut Context, + ) { + let key = self.menu.remote_file.take(); + self.dismiss_menu(window, cx); + if let Some(key) = key { + self.answer_remote_upload(key, answer, cx); + } + } + + pub(in crate::menu) fn confirm_remote_conflict( + &mut self, + resolution: Resolution, + window: &mut Window, + cx: &mut Context, + ) { + let key = self.menu.remote_file.take(); + self.dismiss_menu(window, cx); + if let Some(key) = key { + self.resolve_remote_conflict(key, resolution, cx); + } + } + + pub(in crate::menu) fn render_remote_upload(&self, cx: &mut Context) -> Div { + let Some((target, path)) = &self.menu.remote_file else { + return div(); + }; + let theme = &self.theme; + div() + .debug_selector(|| "remote-upload".into()) + .child(title(format!("Upload your changes to {}?", basename(path)))) + .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(); + }; + div() + .debug_selector(|| "remote-conflict".into()) + .child(title(format!("{} changed on {target}", basename(path)))) + .child(muted( + self.theme.muted, + "It changed since it was fetched, so your save was not uploaded.", + )) + .child( + buttons() + .child(self.conflict_button( + "remote-conflict-keep-both", + "Keep both", + Resolution::KeepBoth, + cx, + )) + .child(self.conflict_button( + "remote-conflict-overwrite", + "Overwrite anyway", + Resolution::Overwrite, + cx, + )) + .child(self.conflict_button( + "remote-conflict-refetch", + "Re-fetch", + Resolution::Refetch, + cx, + )), + ) + } + + fn remote_file_button( + &self, + id: &'static str, + label: &'static str, + primary: bool, + ) -> Stateful
{ + let active = self.theme.active; + div() + .id(id) + .debug_selector(move || id.into()) + .p(px(8.)) + .rounded(px(crate::config::corners::CONTROL)) + .when(primary, |button| button.bg(rgb(active))) + .when(!primary, |button| button.hover(|s| s.bg(rgb(active)))) + .cursor_pointer() + .child(label) + } + + fn upload_button( + &self, + id: &'static str, + label: &'static str, + answer: Answer, + cx: &mut Context, + ) -> Stateful
{ + self.remote_file_button(id, label, answer == Answer::Upload) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.confirm_remote_upload(answer, window, cx); + })) + } + + fn conflict_button( + &self, + id: &'static str, + label: &'static str, + resolution: Resolution, + cx: &mut Context, + ) -> Stateful
{ + self.remote_file_button(id, label, resolution == Resolution::KeepBoth) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.confirm_remote_conflict(resolution, window, cx); + })) + } +} + +fn title(text: String) -> Div { + div() + .p(px(8.)) + .font_weight(FontWeight::SEMIBOLD) + .child(text) +} + +fn muted(color: u32, text: &'static str) -> Div { + div().p(px(8.)).text_color(rgb(color)).child(text) +} + +fn buttons() -> Div { + div().flex().flex_wrap().gap(px(8.)).p(px(8.)) +} diff --git a/crates/herdr-gpui/src/menu/state.rs b/crates/herdr-gpui/src/menu/state.rs index 98fea1476..75ad0e5f2 100644 --- a/crates/herdr-gpui/src/menu/state.rs +++ b/crates/herdr-gpui/src/menu/state.rs @@ -66,6 +66,8 @@ pub(crate) struct MenuState { pub(super) session_edit: 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, #[cfg(feature = "coder")] pub(super) coder: Option, pub(super) devices_scroll: ScrollHandle, @@ -312,6 +314,7 @@ impl MenuState { wsl_remove: None, session_edit: None, open_link: None, + remote_file: None, #[cfg(feature = "coder")] coder: None, devices_scroll: ScrollHandle::new(), @@ -382,6 +385,7 @@ impl MenuState { self.wsl_remove = None; self.session_edit = None; self.open_link = None; + self.remote_file = None; #[cfg(feature = "coder")] { self.coder = None; diff --git a/crates/herdr-gpui/src/remote_files.rs b/crates/herdr-gpui/src/remote_files.rs index f3dabf3ee..0435ea077 100644 --- a/crates/herdr-gpui/src/remote_files.rs +++ b/crates/herdr-gpui/src/remote_files.rs @@ -5,28 +5,47 @@ //! 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. //! -//! Every fetch and every disk access runs on the background executor; the -//! UI thread only starts a fetch and applies its result. +//! While the window holds copies it watches them for saves (`watch`), and +//! sends a save back to the host under the rules in `sync`: it asks first, +//! unless the user chose to upload that copy's saves without asking, and a +//! host's file that changed since it was synced is never replaced without +//! the user's say. +//! +//! Every fetch, push, and disk access runs on the background executor; the +//! UI thread only starts them and applies their results. mod cache; +mod push; +mod sync; +mod uploads; +mod watch; use crate::window::{Flash, HerdrWindow, RemotePath}; use gpui::{App, Context, Task}; -use herdr_client::{Fetched, MAX_REMOTE_FILE_BYTES, RemoteShell}; +use herdr_client::{Expect, Fetched, MAX_REMOTE_FILE_BYTES, RemoteFileAccess, RemoteShell}; use std::{ - collections::HashMap, - io::Write, + collections::{HashMap, HashSet, VecDeque}, + io::{Read, Write}, path::{Path, PathBuf}, - sync::{Arc, atomic::AtomicBool}, - time::SystemTime, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Instant, }; -pub(crate) use cache::Cache; +pub(crate) use cache::{Cache, basename}; +pub(crate) use sync::{Answer, Policy, Resolution, SyncState}; + +/// Bytes in the megabyte that [`Error::TooLarge`] reports. +const MB: u64 = 1024 * 1024; -/// Why a remote file was not fetched or opened. +/// 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, @@ -35,44 +54,96 @@ pub enum 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("Not uploaded: the copy is larger than {} MB", .limit.div_ceil(MB))] + TooLarge { limit: u64 }, // keep-sorted end } +impl Error { + /// Whether the host refused a push because its file was not what the + /// push expected. + fn is_conflict(&self) -> bool { + matches!( + self, + Self::Push(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Conflict + )) + ) + } +} + pub(crate) type Result = std::result::Result; +/// A copy's SSH target and host path. +pub(crate) type Key = (String, String); + /// Streams the host's file `RemotePath` names into the writer. Blocking. pub(crate) type Fetcher = Arc< dyn Fn(&RemotePath, &mut (dyn Write + Send)) -> herdr_client::Result + Send + Sync, >; +/// Replaces the host's file `RemotePath` names with the reader's bytes, of +/// which there are exactly the given count, while it meets the expectation, +/// giving a new file the access named. Blocking. +pub(crate) type Pusher = Arc< + dyn Fn( + &RemotePath, + Expect, + RemoteFileAccess<'_>, + u64, + &mut (dyn Read + Send), + ) -> herdr_client::Result<()> + + Send + + Sync, +>; + /// The cache copies are written to. Blocking. pub(crate) type CacheSource = Arc Result> + Send + Sync>; -/// The I/O a fetch does: SSH and the process's cache in the app, and fakes -/// in tests. +/// 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 as soon as a fetch's copy is renamed into place, so a test can + /// save to it then. + #[cfg(test)] + renamed: Arc, } impl Default for Io { fn default() -> Self { Self { + #[cfg(test)] + renamed: Arc::new(|_| {}), cache: Arc::new(cache::shared), + // The scripts' idle timeout ends a transfer that stalls. fetch: Arc::new(|remote, out| { - // The script's idle timeout ends a transfer that stalls. herdr_client::fetch_remote_file( - &remote.target, - remote.shell, - &remote.path, + 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), + ) + }), } } } @@ -83,18 +154,55 @@ impl std::fmt::Debug for Io { } } -/// A local copy of a host's file, as it was right after it was written. +/// A local copy of a host's file, and where it stands against the host. #[derive(Debug, Clone, PartialEq, Eq)] -#[allow( - dead_code, - reason = "what the copy was fetched as, to tell local edits from the host's bytes; only `local` is read so far" -)] pub(crate) struct CachedCopy { pub(crate) local: PathBuf, - pub(crate) sha256: [u8; 32], + pub(crate) policy: Policy, pub(crate) shell: RemoteShell, - pub(crate) modified: SystemTime, - pub(crate) len: u64, + pub(crate) state: SyncState, + /// The copy's metadata when it last matched the host. + pub(crate) synced_meta: watch::Meta, + /// The host's bytes as last fetched or pushed. + pub(crate) synced_sha256: sync::Sha256, + watch: watch::Watch, +} + +/// One copy as `RemoteFiles::list` shows it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Listed<'a> { + pub(crate) target: &'a str, + pub(crate) remote_path: &'a str, + pub(crate) local: &'a Path, + pub(crate) state: SyncState, + pub(crate) policy: Policy, +} + +/// Which question a copy waits to ask. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptKind { + Upload, + Conflict, +} + +/// A question waiting for the window to have no page open. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Prompt { + pub(crate) key: Key, + pub(crate) kind: PromptKind, +} + +/// The window whose copies a folder of the cache holds. Each window keeps +/// its own copies, so a fetch in one never writes over another's edits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Owner(u64); + +impl Default for Owner { + /// An owner no other window of this process has. + fn default() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + Self(NEXT.fetch_add(1, Ordering::Relaxed)) + } } /// The copies one window fetched, by SSH target and host path. @@ -102,8 +210,17 @@ pub(crate) struct CachedCopy { pub(crate) struct RemoteFiles { /// The cache the copies are in, once one was written. cache: Option>, - copies: HashMap<(String, String), CachedCopy>, + copies: HashMap, + /// The copies a click's fetch is writing. + fetching: HashSet, io: Io, + /// When the last watch run started. + last_watch: Option, + /// The folder of the cache this window's copies are in. + owner: Owner, + prompts: VecDeque, + /// Whether a watch run is underway. + watching: bool, } impl RemoteFiles { @@ -112,22 +229,181 @@ impl RemoteFiles { self.copies.get(&(target.to_owned(), path.to_owned())) } + /// Every copy, with where it stands against its host. + #[cfg_attr( + not(test), + expect(dead_code, reason = "read by the remote-files list command") + )] + 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. It always does: the host holds the file, and - /// its bytes may be newer than the copy's. - pub(crate) fn fetch_again(&self, _remote: &RemotePath) -> bool { - true + /// 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); - self.copies.insert(done.key, done.copy); + 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. + fn probes(&self) -> Vec { + self.copies + .iter() + .filter(|(_, copy)| !transferring(copy.state)) + .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 { + 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 @@ -137,58 +413,122 @@ impl RemoteFiles { } }) } + + /// 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: (String, String), + key: Key, copy: CachedCopy, cache: Arc, /// The copy, or the folder holding it when it is not a document. opened: Option, } -/// Fetches `remote` into its copy in the cache: written beside it first and -/// renamed over it once whole, so a failed fetch leaves any older copy as -/// it was. Blocking. -fn fetch_copy(io: &Io, remote: &RemotePath) -> Result { +/// 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 renamed over it 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. Blocking. +fn fetch_copy( + io: &Io, + owner: Owner, + remote: &RemotePath, + keep_edits_since: Option, +) -> Result { let cache = (io.cache)()?; - let local = cache.copy_path(&remote.target, &remote.path); + let local = cache.copy_path(owner, &remote.target, &remote.path); if let Some(folder) = local.parent() { cache::create_private_dirs(folder)?; } - let mut name = local.file_name().unwrap_or_default().to_owned(); - name.push(".part"); - let part = local.with_file_name(name); - let fetched = write_part(io, remote, &part) - .and_then(|fetched| { - std::fs::rename(&part, &local) - .map(|()| fetched) - .map_err(|source| Error::Cache { - path: local.clone(), - source, - }) + 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); + } + // 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 metadata = - std::fs::metadata(&local).and_then(|metadata| Ok((metadata.modified()?, metadata.len()))); - let (modified, len) = metadata.map_err(|source| Error::Cache { - path: local.clone(), - source, - })?; + std::fs::rename(&part, &local) + .map_err(|source| Error::Cache { + path: local.clone(), + source, + }) + .inspect_err(|_| remove_part(&part))?; + #[cfg(test)] + (io.renamed)(&local); let opened = crate::window::opened(&local).and_then(|path| url::Url::from_file_path(path).ok()); - Ok(Done { + Ok(Fetch::Written(Box::new(Done { key: (remote.target.clone(), remote.path.clone()), copy: CachedCopy { local, - sha256: fetched.sha256, + policy: Policy::default(), shell: remote.shell, - modified, - len, + 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, + }), + } } fn write_part(io: &Io, remote: &RemotePath, part: &Path) -> Result { @@ -207,35 +547,57 @@ fn remove_part(part: &Path) { impl HerdrWindow { /// Fetches the host's file `remote` names into the cache and opens the /// copy with the system's default application, or says why it could not. + /// A copy in conflict is reopened, and its question asked again. pub(crate) fn open_remote_file(&mut self, remote: RemotePath, cx: &mut Context) { if !self.remote_files.fetch_again(&remote) { + let shown = self.shown_remote_file_prompt(); + let key = (remote.target.clone(), remote.path.clone()); + self.remote_files.reask_conflict(key, shown.as_ref()); if let Some(copy) = self.remote_files.copy(&remote.target, &remote.path) { self.open_in_system_app(copy.local.clone(), cx); } return; } - let name = cache::basename(&remote.path); + 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); self.show_flash( Flash::success(format!("Fetching {name} from {}…", remote.target)), cx, ); let io = self.remote_files.io.clone(); + let owner = self.remote_files.owner; + let synced = self + .remote_files + .copy(&remote.target, &remote.path) + .map(|copy| copy.synced_meta); let fetch = cx .background_executor() - .spawn(async move { fetch_copy(&io, &remote) }); + .spawn(async move { fetch_copy(&io, owner, &remote, synced) }); cx.spawn(async move |this, cx| { let fetched = fetch.await; // The window may have closed meanwhile; nothing opens then. - let _ = this.update(cx, |this, cx| this.remote_file_fetched(fetched, cx)); + let _ = this.update(cx, |this, cx| this.remote_file_fetched(&key, fetched, cx)); }) .detach(); } - fn remote_file_fetched(&mut self, fetched: Result, cx: &mut Context) { + fn remote_file_fetched(&mut self, key: &Key, fetched: Result, cx: &mut Context) { + self.remote_files.fetching.remove(key); match fetched { - Ok(done) => { + // Saved while the fetch ran: the copy with the save opens, and + // the watch takes the save from there. + Ok(Fetch::KeptEdits) => { + if let Some(copy) = self.remote_files.copies.get(key) { + self.open_in_system_app(copy.local.clone(), cx); + } + } + Ok(Fetch::Written(done)) => { let opened = done.opened.clone(); - self.remote_files.record(done); + self.remote_files.record(*done); match opened { Some(url) => cx.open_url(url.as_str()), None => tracing::warn!("Fetched remote file copy could not be opened"), diff --git a/crates/herdr-gpui/src/remote_files/cache.rs b/crates/herdr-gpui/src/remote_files/cache.rs index a45f1d20c..2d982be56 100644 --- a/crates/herdr-gpui/src/remote_files/cache.rs +++ b/crates/herdr-gpui/src/remote_files/cache.rs @@ -4,7 +4,7 @@ //! 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, Result}; +use super::{Error, Owner, Result}; use sha2::{Digest, Sha256}; use std::{ borrow::Cow, @@ -80,11 +80,13 @@ impl Cache { }) } - /// Where the copy of `path` on `target` is kept: a folder 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. - pub(crate) fn copy_path(&self, target: &str, path: &str) -> PathBuf { + /// 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. + pub(crate) fn copy_path(&self, owner: Owner, target: &str, path: &str) -> PathBuf { self.launch + .join(format!("w{}", owner.0)) .join(short_hash(target)) .join(short_hash(path)) .join(local_name(basename(path), cfg!(windows)).as_ref()) @@ -188,6 +190,17 @@ fn windows_device(name: &str) -> bool { || 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 { + static SERIAL: AtomicU64 = AtomicU64::new(0); + let serial = SERIAL.fetch_add(1, Ordering::Relaxed); + let mut name = local.file_name().unwrap_or_default().to_owned(); + name.push(format!(".{serial}.part")); + local.with_file_name(name) +} + /// Creates `path` and any missing parents, private to the user. pub(super) fn create_private_dirs(path: &Path) -> Result<()> { let mut builder = DirBuilder::new(); @@ -204,12 +217,11 @@ pub(super) fn create_private_dirs(path: &Path) -> Result<()> { }) } -/// Creates or truncates the file at `path`, readable and writable only by -/// the user and never executable. +/// 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(true) - .truncate(true) + .create_new(true) .open(path) .map_err(|source| Error::Cache { path: path.to_owned(), 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..a065ab462 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/push.rs @@ -0,0 +1,93 @@ +//! Sending a copy back to its host. The push sends exactly the length the +//! copy had when it was stat'ed, 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, sync::Sha256, watch::Meta}; +use crate::window::RemotePath; +use herdr_client::{Expect, RemoteFileAccess}; +use sha2::{Digest, Sha256 as Hasher}; +use std::{ + io::{self, Read}, + path::Path, +}; + +/// 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. A copy that grows +/// while it is read sends only the length it had when stat'ed, and the +/// watch sees the rest as a later save; one that shrinks fails the push. 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 cache = |source| Error::Cache { + path: local.to_owned(), + source, + }; + let file = std::fs::File::open(local).map_err(cache)?; + let meta = file + .metadata() + .and_then(|metadata| Meta::of(&metadata)) + .map_err(cache)?; + if meta.len > limit { + return Err(Error::TooLarge { limit }); + } + let mut reader = Hashing::new(file.take(meta.len)); + let pushed = (io.push)(remote, expect, access, meta.len, &mut reader); + let (sent, sha256) = reader.finish(); + pushed.map_err(|error| match error { + herdr_client::Error::RemoteFile(herdr_client::RemoteFileError::Access) => Error::Access, + error => Error::Push(error), + })?; + // The host refuses a short transfer; this holds a push that ignored it. + if sent != meta.len { + return Err(Error::Push(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Incomplete, + ))); + } + Ok(Pushed { meta, sha256 }) +} 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 index 522482dc1..e4cd5787e 100644 --- a/crates/herdr-gpui/src/remote_files/tests.rs +++ b/crates/herdr-gpui/src/remote_files/tests.rs @@ -1,7 +1,15 @@ use super::*; +use gpui::{Entity, TestAppContext, VisualTestContext}; use sha2::{Digest, Sha256}; use std::sync::Mutex; +mod answers; +mod dismissed_conflict; +mod fetch_meta; +mod fetches; +mod saves; +mod windows_names; + /// The bytes [`notes`] serves. pub(crate) const NOTES: &[u8] = b"# Notes\n"; @@ -17,11 +25,51 @@ pub(crate) fn missing() -> herdr_client::Result<&'static [u8]> { )) } -/// An SSH host whose answer a test chooses, the fetches asked of it, and a -/// cache of its own in a temporary folder. +/// A host whose file changed since it was fetched. +pub(crate) fn conflict() -> herdr_client::Result<()> { + Err(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Conflict, + )) +} + +/// A push the host received less of than it was told to expect. +pub(crate) fn cut_off() -> herdr_client::Result<()> { + Err(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Incomplete, + )) +} + +/// A fetch into a cache that keeps no edits, which always writes the copy. +fn fetch_fresh(io: &Io, owner: Owner, remote: &RemotePath) -> Result { + fetch_copy(io, owner, remote, None).map(|fetch| match fetch { + Fetch::Written(done) => *done, + Fetch::KeptEdits => panic!("a fetch keeping no edits kept some"), + }) +} + +/// How the fake host answers one push. +type PushReply = fn() -> herdr_client::Result<()>; + +/// A push asked of the fake host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Received { + pub(crate) path: String, + pub(crate) expect: Expect, + /// The host file whose access a new file was to take, if any. + pub(crate) like: Option, + pub(crate) bytes: Vec, +} + +/// An SSH host whose answers a test chooses, the fetches and pushes asked +/// of it, and a cache of its own in a temporary folder. pub(crate) struct FakeHost { asked: Arc>>, cache: Arc, + /// Bytes every fetch serves in place of its reply, once set. + file: Arc>>>, + /// Answers for the next pushes, in order; a push past them succeeds. + push_replies: Arc>>, + received: Arc>>, _root: tempfile::TempDir, } @@ -32,28 +80,74 @@ impl FakeHost { 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`. - fn io(&self, reply: fn() -> herdr_client::Result<&'static [u8]>) -> Io { + 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 { + renamed: Arc::new(|_| {}), cache: Arc::new(move || Ok(cache.clone())), fetch: Arc::new(move |remote, out| { asked.lock().unwrap().push(remote.clone()); - let bytes = reply()?; - out.write_all(bytes).unwrap(); + 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(), + 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, @@ -81,12 +175,22 @@ fn remote(path: &str) -> RemotePath { } } +/// A window fetching from `host`. +fn window<'a>( + host: &FakeHost, + cx: &'a mut TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + let (view, cx) = cx.add_window_view(crate::sidebar::layout_tests::fixture_window); + view.update(cx, |view, _| host.serve(&mut view.remote_files, notes)); + (view, cx) +} + #[test] fn a_copy_is_named_after_the_host_s_file() { - assert_eq!(cache::basename(r"C:\dev\notes.md"), "notes.md"); - assert_eq!(cache::basename("/home/me/src/main.rs"), "main.rs"); - assert_eq!(cache::basename(r"~\a/b\Makefile"), "Makefile"); - assert_eq!(cache::basename("README"), "README"); + assert_eq!(basename(r"C:\dev\notes.md"), "notes.md"); + assert_eq!(basename("/home/me/src/main.rs"), "main.rs"); + assert_eq!(basename(r"~\a/b\Makefile"), "Makefile"); + assert_eq!(basename("README"), "README"); for odd in [ "/home/me/", r"C:\dev\", @@ -97,7 +201,7 @@ fn a_copy_is_named_after_the_host_s_file() { "~", ] { let expected = if odd == "~" { "~" } else { "file" }; - assert_eq!(cache::basename(odd), expected, "{odd:?}"); + assert_eq!(basename(odd), expected, "{odd:?}"); } } @@ -105,21 +209,31 @@ fn a_copy_is_named_after_the_host_s_file() { fn copies_are_kept_apart_by_host_and_path() { let host = FakeHost::new(); let cache = &host.cache; - let copy = cache.copy_path("me@devbox", "/a/notes.md"); - assert_eq!(copy, cache.copy_path("me@devbox", "/a/notes.md"), "stable"); + let (owner, another) = (Owner::default(), Owner::default()); + let copy = cache.copy_path(owner, "me@devbox", "/a/notes.md"); + assert_eq!( + copy, + cache.copy_path(owner, "me@devbox", "/a/notes.md"), + "stable" + ); assert!(copy.starts_with(cache.launch())); assert_eq!(copy.file_name().unwrap(), "notes.md"); let others = [ - cache.copy_path("me@other", "/a/notes.md"), - cache.copy_path("me@devbox", "/b/notes.md"), + cache.copy_path(owner, "me@other", "/a/notes.md"), + cache.copy_path(owner, "me@devbox", "/b/notes.md"), + cache.copy_path(another, "me@devbox", "/a/notes.md"), ]; for other in &others { assert_ne!(©, other); assert_eq!(other.file_name().unwrap(), "notes.md"); } - // Two folders of hex digits under the launch folder, then the file. + // 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 folders: Vec<_> = relative.iter().take(2).collect(); + 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}"); @@ -169,14 +283,18 @@ fn a_launch_removes_unlocked_launch_folders_and_keeps_locked_ones() { #[test] fn a_fetch_writes_the_copy_privately_and_a_new_fetch_replaces_it() { let host = FakeHost::new(); - let done = fetch_copy(&host.io(notes), &remote(r"C:\dev\notes.md")).unwrap(); + 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.sha256, <[u8; 32]>::from(Sha256::digest(NOTES))); - assert_eq!(done.copy.len, NOTES.len() as u64); + 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.modified, + 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())); @@ -200,7 +318,7 @@ fn a_fetch_writes_the_copy_privately_and_a_new_fetch_replaces_it() { fn newer() -> herdr_client::Result<&'static [u8]> { Ok(b"# Newer\n") } - let again = fetch_copy(&host.io(newer), &remote(r"C:\dev\notes.md")).unwrap(); + 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"); } @@ -208,22 +326,34 @@ fn a_fetch_writes_the_copy_privately_and_a_new_fetch_replaces_it() { #[test] fn a_script_opens_the_folder_holding_its_copy() { let host = FakeHost::new(); - let done = fetch_copy(&host.io(notes), &remote("/home/me/run.sh")).unwrap(); + 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()); - assert!(done.copy.local.starts_with(&opened)); + // `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 local = fetch_copy(&host.io(notes), &remote(path)) + let owner = Owner::default(); + let local = fetch_fresh(&host.io(notes), owner, &remote(path)) .unwrap() .copy .local; - let Err(error) = fetch_copy(&host.io(missing), &remote(path)) else { + let Err(error) = fetch_fresh(&host.io(missing), owner, &remote(path)) else { panic!("a missing file was fetched"); }; assert!(matches!( diff --git a/crates/herdr-gpui/src/remote_files/tests/answers.rs b/crates/herdr-gpui/src/remote_files/tests/answers.rs new file mode 100644 index 000000000..df888642d --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/answers.rs @@ -0,0 +1,89 @@ +//! Answers to a copy's questions that arrive after the copy moved on. + +use super::*; +use std::time::Duration; + +const PATH: &str = "/home/me/notes.md"; + +/// One poll of the window, with a watch run due, and its work finished. +fn poll(view: &Entity, cx: &mut VisualTestContext) { + view.update_in(cx, |view, window, cx| { + view.remote_files.last_watch = None; + view.poll_remote_files(window, cx); + }); + cx.run_until_parked(); +} + +#[gpui::test] +fn saving_the_synced_bytes_back_closes_the_open_upload_question(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + let key = ("me@devbox".to_owned(), PATH.to_owned()); + let (local, synced) = view.read_with(cx, |view, _| { + let copy = &view.remote_files.copies[&key]; + (copy.local.clone(), copy.synced_meta) + }); + std::fs::write(&local, b"# Notes, edited\n").unwrap(); + // Two runs settle the save; the next poll opens its question. + for _ in 0..3 { + poll(&view, cx); + } + let page = |cx: &mut VisualTestContext| view.read_with(cx, |view, _| view.menu.page); + assert_eq!(page(cx), Some(crate::menu::Page::RemoteUpload)); + + // The user saves the synced bytes back while the question is open. + std::fs::write(&local, NOTES).unwrap(); + let file = std::fs::File::options().write(true).open(&local).unwrap(); + file.set_modified(synced.modified + Duration::from_secs(5)) + .unwrap(); + drop(file); + for _ in 0..2 { + poll(&view, cx); + } + view.read_with(cx, |view, _| { + assert_eq!(view.remote_files.copies[&key].state, SyncState::Synced); + assert_eq!(view.shown_remote_file_prompt(), None); + }); + assert_eq!(page(cx), None, "the question about the edits closed"); + assert!(host.received().is_empty(), "{:?}", host.received()); +} + +#[gpui::test] +fn overwrite_after_the_host_s_bytes_were_saved_back_pushes_nothing(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + let key = ("me@devbox".to_owned(), PATH.to_owned()); + view.update(cx, |view, cx| { + let files = &mut view.remote_files; + let copy = files.copies.get_mut(&key).unwrap(); + // The host refused a save's upload: its file changed since the sync. + copy.state = SyncState::Conflict { + sha256: Sha256::digest(b"# Notes, edited\n").into(), + }; + // Then the user saves the bytes the copy was synced with back. + std::fs::write(©.local, NOTES).unwrap(); + let file = std::fs::File::options() + .write(true) + .open(©.local) + .unwrap(); + file.set_modified(copy.synced_meta.modified + Duration::from_secs(5)) + .unwrap(); + drop(file); + // Two watch runs settle that save. + for _ in 0..2 { + let seen = watch::scan(files.probes()); + files.apply_scan(seen); + } + // The conflict question, still open, is answered after that. + view.resolve_remote_conflict(key.clone(), Resolution::Overwrite, cx); + }); + cx.run_until_parked(); + assert!(host.received().is_empty(), "{:?}", host.received()); + view.read_with(cx, |view, _| { + assert_eq!(view.remote_files.copies[&key].state, SyncState::Synced); + }); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs b/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs new file mode 100644 index 000000000..d0d860c0b --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs @@ -0,0 +1,142 @@ +//! A conflict question closed without an answer, which a click on the +//! copy's path brings back. + +use super::*; +use crate::menu::Page; + +const PATH: &str = "/home/me/notes.md"; +const EDITED: &[u8] = b"# Notes, edited\n"; + +fn key() -> Key { + ("me@devbox".to_owned(), PATH.to_owned()) +} + +/// One poll of the window, and its work finished. +fn poll(view: &Entity, cx: &mut VisualTestContext) { + view.update_in(cx, |view, window, cx| view.poll_remote_files(window, cx)); + cx.run_until_parked(); +} + +/// A link click on `PATH`, and its work finished. +fn click(view: &Entity, cx: &mut VisualTestContext) { + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); +} + +fn page(view: &Entity, cx: &mut VisualTestContext) -> Option { + view.read_with(cx, |view, _| view.menu.page) +} + +/// The questions waiting for the window, by kind. +fn waiting(view: &Entity, cx: &mut VisualTestContext) -> Vec { + view.read_with(cx, |view, _| { + view.remote_files + .prompts + .iter() + .map(|prompt| prompt.kind) + .collect() + }) +} + +fn set_state(view: &Entity, cx: &mut VisualTestContext, state: SyncState) { + view.update(cx, |view, _| { + view.remote_files.copies.get_mut(&key()).unwrap().state = state; + }); +} + +/// A window whose copy of `PATH` is in conflict, its question closed with +/// Escape. +fn dismissed<'a>( + host: &FakeHost, + cx: &'a mut TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + let (view, cx) = window(host, cx); + click(&view, cx); + set_state( + &view, + cx, + SyncState::Conflict { + sha256: Sha256::digest(EDITED).into(), + }, + ); + view.update(cx, |view, _| { + view.remote_files.enqueue(Prompt { + key: key(), + kind: PromptKind::Conflict, + }); + }); + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteConflict)); + cx.simulate_keystrokes("escape"); + assert_eq!(page(&view, cx), None); + poll(&view, cx); + assert_eq!(page(&view, cx), None, "nothing brings it back by itself"); + assert_eq!(waiting(&view, cx), []); + (view, cx) +} + +#[gpui::test] +fn a_click_on_a_conflicted_copy_s_path_asks_its_closed_question_again(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = dismissed(&host, cx); + // Something else opened since, so the copy's own opening shows. + cx.update(|_, cx| cx.open_url("about:blank")); + + click(&view, cx); + assert_eq!(host.asked().len(), 1, "not fetched again"); + assert!(cx.opened_url().unwrap().ends_with("/notes.md")); + assert_eq!(waiting(&view, cx), [PromptKind::Conflict]); + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteConflict)); + assert_eq!( + view.read_with(cx, |view, _| view.shown_remote_file_prompt()), + Some(Prompt { + key: key(), + kind: PromptKind::Conflict, + }) + ); +} + +#[gpui::test] +fn two_clicks_on_a_conflicted_copy_s_path_queue_its_question_once(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = dismissed(&host, cx); + click(&view, cx); + click(&view, cx); + assert_eq!(waiting(&view, cx), [PromptKind::Conflict]); + + // Nor does a click while the question is open queue it behind itself. + poll(&view, cx); + assert_eq!(page(&view, cx), Some(Page::RemoteConflict)); + click(&view, cx); + assert_eq!(waiting(&view, cx), []); +} + +#[gpui::test] +fn a_click_on_a_copy_not_in_conflict_queues_no_question(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + click(&view, cx); + // A synced copy is fetched again. + click(&view, cx); + assert_eq!(host.asked().len(), 2); + assert_eq!(waiting(&view, cx), []); + + // One asking to be uploaded, whose question waits, is reopened as it is. + set_state( + &view, + cx, + SyncState::Asking { + sha256: Sha256::digest(EDITED).into(), + }, + ); + view.update(cx, |view, _| { + view.remote_files.enqueue(Prompt { + key: key(), + kind: PromptKind::Upload, + }); + }); + click(&view, cx); + assert_eq!(host.asked().len(), 2, "not fetched again"); + assert_eq!(waiting(&view, cx), [PromptKind::Upload]); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs b/crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs new file mode 100644 index 000000000..afa9b8b47 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs @@ -0,0 +1,39 @@ +//! 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.renamed = Arc::new(|local| 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/fetches.rs b/crates/herdr-gpui/src/remote_files/tests/fetches.rs new file mode 100644 index 000000000..bbf4cab2e --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/fetches.rs @@ -0,0 +1,128 @@ +//! Fetches that overlap: clicks made while a fetch runs, and fetches of one +//! host file by windows that share the process's cache. + +use super::*; + +const PATH: &str = "/home/me/notes.md"; + +#[gpui::test] +fn a_click_while_its_file_is_fetched_starts_no_second_fetch(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, cx| { + view.open_remote_file(remote(PATH), cx); + view.open_remote_file(remote(PATH), cx); + }); + cx.run_until_parked(); + assert_eq!(host.asked().len(), 1); + + // Once that fetch is done, a click fetches again. + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + assert_eq!(host.asked().len(), 2); +} + +#[gpui::test] +fn a_failed_click_fetch_lets_the_next_click_fetch_again(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, _| host.serve(&mut view.remote_files, missing)); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + assert_eq!(host.asked().len(), 2); +} + +#[test] +fn overlapping_fetches_of_one_copy_both_return_and_leave_it_whole() { + let host = FakeHost::new(); + let owner = Owner::default(); + let (started, on_start) = std::sync::mpsc::channel(); + let (release, on_release) = std::sync::mpsc::channel::<()>(); + let on_release = Mutex::new(on_release); + let io = host.io(notes); + let fetch = io.fetch.clone(); + let gated = Io { + fetch: Arc::new(move |remote, out| { + started.send(()).unwrap(); + on_release.lock().unwrap().recv().unwrap(); + fetch(remote, out) + }), + ..io + }; + let (first, second) = std::thread::scope(|scope| { + let first = scope.spawn(|| fetch_fresh(&gated, owner, &remote(PATH))); + let second = scope.spawn(|| fetch_fresh(&gated, owner, &remote(PATH))); + // Both fetches are writing before either may finish. + on_start.recv().unwrap(); + on_start.recv().unwrap(); + release.send(()).unwrap(); + release.send(()).unwrap(); + (first.join().unwrap(), second.join().unwrap()) + }); + let (first, second) = (first.unwrap(), second.unwrap()); + assert_eq!(first.copy.local, second.copy.local); + assert_eq!(std::fs::read(&first.copy.local).unwrap(), NOTES); + assert_eq!( + std::fs::read_dir(first.copy.local.parent().unwrap()) + .unwrap() + .count(), + 1, + "no .part left" + ); + assert_eq!(host.asked().len(), 2); +} + +/// Fetches `PATH` into `files`, as a click in its window does, returning +/// where the copy is. +fn fetch_into(files: &mut RemoteFiles) -> PathBuf { + let done = fetch_fresh(&files.io, files.owner, &remote(PATH)).unwrap(); + let local = done.copy.local.clone(); + files.record(done); + local +} + +#[test] +fn a_fetch_in_another_window_keeps_this_window_s_edits() { + const EDITED: &[u8] = b"# Notes, edited\n"; + let host = FakeHost::new(); + let (mut first, mut second) = (RemoteFiles::default(), RemoteFiles::default()); + host.serve(&mut first, notes); + host.serve(&mut second, notes); + let local = fetch_into(&mut first); + std::fs::write(&local, EDITED).unwrap(); + // Two watch runs settle the save, which then asks to be uploaded. + for _ in 0..2 { + let seen = watch::scan(first.probes()); + first.apply_scan(seen); + } + let key = ("me@devbox".to_owned(), PATH.to_owned()); + assert!( + matches!(first.copies[&key].state, SyncState::Asking { .. }), + "{:?}", + first.copies[&key].state + ); + + let theirs = fetch_into(&mut second); + assert_eq!(std::fs::read(&local).unwrap(), EDITED); + assert_ne!(theirs, local); + assert_eq!(std::fs::read(&theirs).unwrap(), NOTES); +} + +#[test] +fn two_fetches_of_one_copy_write_apart() { + let host = FakeHost::new(); + let local = host.cache.launch().join("notes.md"); + let first = cache::part_path(&local); + let second = cache::part_path(&local); + assert_ne!(first, second); + assert_eq!(first.parent(), local.parent(), "beside the copy"); + assert_eq!(second.parent(), local.parent(), "beside the copy"); + + let _written = cache::create_private_file(&first).unwrap(); + assert!( + cache::create_private_file(&first).is_err(), + "a part is never opened over another fetch's" + ); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/saves.rs b/crates/herdr-gpui/src/remote_files/tests/saves.rs new file mode 100644 index 000000000..ee0ba0232 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/saves.rs @@ -0,0 +1,475 @@ +//! 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 stat'ed 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_that_shrinks_during_its_push_is_cut_off() { + let host = FakeHost::new(); + let (_, _, local) = fetched(&host); + let io = saving_during_push(&host, local.clone(), |local| { + std::fs::write(local, b"#").unwrap(); + }); + let Err(error) = push_copy( + &io, + &remote(PATH), + &local, + (Expect::Any, RemoteFileAccess::Keep), + 1024, + ) else { + panic!("a cut-off push succeeded"); + }; + assert!( + matches!( + error, + Error::Push(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Incomplete + )) + ), + "{error:?}" + ); + assert_eq!( + crate::Error::from(error).to_string(), + "Could not upload the file: the upload was cut off; the host's file is unchanged" + ); +} + +#[test] +fn a_remote_save_made_while_a_click_fetches_is_kept() { + let host = FakeHost::new(); + let (mut files, key, local) = fetched(&host); + let synced = files.copies[&key].synced_meta; + let owner = files.owner; + let (started, on_start) = std::sync::mpsc::channel(); + let (release, on_release) = std::sync::mpsc::channel::<()>(); + let on_release = Mutex::new(on_release); + let io = host.io(notes); + let fetch = io.fetch.clone(); + let gated = Io { + fetch: Arc::new(move |remote, out| { + started.send(()).unwrap(); + on_release.lock().unwrap().recv().unwrap(); + fetch(remote, out) + }), + ..io + }; + let outcome = std::thread::scope(|scope| { + let fetching = scope.spawn(|| fetch_copy(&gated, owner, &remote(PATH), Some(synced))); + on_start.recv().unwrap(); + std::fs::write(&local, b"# Saved meanwhile\n").unwrap(); + release.send(()).unwrap(); + fetching.join().unwrap() + }); + assert!(matches!(outcome, Ok(Fetch::KeptEdits))); + assert_eq!(std::fs::read(&local).unwrap(), b"# Saved meanwhile\n"); + assert_eq!( + std::fs::read_dir(local.parent().unwrap()).unwrap().count(), + 1, + "no .part left" + ); + assert!(run(&mut files).is_empty()); + assert_eq!(run(&mut files), vec![(key, Action::Ask)]); + + // Unsaved since its sync, a copy takes the host's bytes. + let host = FakeHost::new(); + let (files, key, local) = fetched(&host); + host.change_file(b"# Newer\n"); + let synced = Some(files.copies[&key].synced_meta); + let written = fetch_copy(&files.io, files.owner, &remote(PATH), synced).unwrap(); + assert!(matches!(written, Fetch::Written(_))); + assert_eq!(std::fs::read(&local).unwrap(), b"# Newer\n"); +} + +#[test] +fn a_failed_remote_push_says_why() { + let error = Error::Push(conflict().unwrap_err()); + assert!(error.is_conflict()); + assert_eq!( + crate::Error::from(error).to_string(), + "Could not upload the file: the host's file changed since it was fetched" + ); + let missing = Error::Push(missing().unwrap_err()); + assert!(!missing.is_conflict()); +} + +#[test] +fn a_push_that_cannot_keep_the_file_s_access_says_nothing_changed() { + let host = FakeHost::new(); + let (files, _, local) = fetched(&host); + let io = Io { + push: Arc::new(|_, _, _, _, _| { + Err(herdr_client::Error::RemoteFile( + herdr_client::RemoteFileError::Access, + )) + }), + ..files.io.clone() + }; + let rule = (Expect::Any, RemoteFileAccess::Keep); + let Err(error) = push_copy(&io, &remote(PATH), &local, rule, 1024) else { + panic!("a push that could not keep the access succeeded"); + }; + assert!(matches!(error, Error::Access), "{error:?}"); + assert!(!error.is_conflict()); + assert_eq!( + crate::Error::from(error).to_string(), + "Could not keep the file's access; nothing was changed." + ); +} + +#[test] +fn 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..0ff50f999 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/windows_names.rs @@ -0,0 +1,71 @@ +//! 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 copy = cache.copy_path(Owner(1), "me@devbox", "/home/me/a:b.txt"); + 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"), "a:b.txt", "the host's name"); +} 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..5af90bda3 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/uploads.rs @@ -0,0 +1,307 @@ +//! The window's side of sending saves back: starting watch runs from its +//! poll tick, opening the questions they queue once no page is open, and +//! running the pushes and fetches the answers lead to. + +use super::{ + Answer, Error, Fetch, Key, MAX_REMOTE_FILE_BYTES, Prompt, PromptKind, Resolution, fetch_copy, + push::{Pushed, push_copy}, + sync::{self, Action, Event, Push}, + watch, +}; +use crate::window::{Flash, HerdrWindow, RemotePath}; +use gpui::{Context, Window}; +use herdr_client::{Expect, RemoteFileAccess}; +use std::time::{Duration, Instant}; + +/// The shortest time between two watch runs. +const WATCH_INTERVAL: Duration = Duration::from_secs(1); + +/// The highest `n` a kept-both copy's name tries. +const LAST_CONFLICT_NAME: u8 = 9; + +impl HerdrWindow { + /// Opens a waiting question when no page is open, and starts a watch run + /// when one is due. + pub(crate) fn poll_remote_files(&mut self, window: &mut Window, cx: &mut Context) { + self.show_next_remote_prompt(window, cx); + self.start_remote_watch(window, cx); + } + + /// Closes the open question when its copy left the state it asks about, + /// so no buttons stay up that would do nothing. + fn close_stale_remote_prompt(&mut self, window: &mut Window, cx: &mut Context) { + let Some(shown) = self.shown_remote_file_prompt() else { + return; + }; + if self.remote_files.applies(&shown) { + return; + } + self.close_remote_file_prompt(window, cx); + } + + fn show_next_remote_prompt(&mut self, window: &mut Window, cx: &mut Context) { + let shown = self.shown_remote_file_prompt(); + self.remote_files.requeue_unanswered(shown.as_ref()); + if self.menu.page.is_some() { + return; + } + let Some(prompt) = self.remote_files.next_prompt() else { + return; + }; + if self.show_remote_file_prompt(&prompt, window, cx) { + self.remote_files.prompts.pop_front(); + } + } + + fn start_remote_watch(&mut self, window: &mut Window, cx: &mut Context) { + let files = &mut self.remote_files; + let now = Instant::now(); + if files.watching + || files.copies.is_empty() + || files + .last_watch + .is_some_and(|last| now.duration_since(last) < WATCH_INTERVAL) + { + return; + } + files.last_watch = Some(now); + files.watching = true; + let probes = files.probes(); + let scan = cx + .background_executor() + .spawn(async move { watch::scan(probes) }); + cx.spawn_in(window, async move |this, cx| { + let seen = scan.await; + let _ = this.update_in(cx, |this, window, cx| { + this.remote_files_scanned(seen, window, cx); + }); + }) + .detach(); + } + + fn remote_files_scanned( + &mut self, + seen: Vec, + window: &mut Window, + cx: &mut Context, + ) { + self.remote_files.watching = false; + for (key, action) in self.remote_files.apply_scan(seen) { + self.act_on_remote_file(key, action, cx); + } + // A save back to the synced bytes ends the question about the edits. + self.close_stale_remote_prompt(window, cx); + } + + /// Answers the upload question about `key`'s copy. + pub(crate) fn answer_remote_upload( + &mut self, + key: Key, + answer: Answer, + cx: &mut Context, + ) { + let action = self.remote_files.handle(&key, Event::Answered(answer)); + self.act_on_remote_file(key, action, cx); + } + + /// Answers the conflict question about `key`'s copy. + pub(crate) fn resolve_remote_conflict( + &mut self, + key: Key, + resolution: Resolution, + cx: &mut Context, + ) { + let action = self.remote_files.handle(&key, Event::Resolved(resolution)); + self.act_on_remote_file(key, action, cx); + } + + fn act_on_remote_file(&mut self, key: Key, action: Action, cx: &mut Context) { + match action { + Action::Nothing => {} + Action::Ask => self.remote_files.enqueue(Prompt { + key, + kind: PromptKind::Upload, + }), + Action::ShowConflict => self.remote_files.enqueue(Prompt { + key, + kind: PromptKind::Conflict, + }), + Action::Push(push) => self.start_remote_push(key, push, cx), + Action::Refetch => self.start_remote_refetch(key, cx), + } + cx.notify(); + } + + /// The host file `push` goes to for `key`'s copy, what it expects there, + /// and the host file whose access a new file takes: a kept-both copy + /// takes the original's, so it lets no one read it who could not read + /// that. + fn push_target(&self, key: &Key, push: Push) -> Option<(RemotePath, Expect, Option)> { + let copy = self.remote_files.copies.get(key)?; + let (path, expect, like) = match push { + Push::Expected => (key.1.clone(), Expect::Sha256(copy.synced_sha256), None), + Push::Any => (key.1.clone(), Expect::Any, None), + Push::Beside(n) => ( + sync::conflict_path(&key.1, copy.shell, n), + Expect::Absent, + Some(key.1.clone()), + ), + }; + let remote = RemotePath { + target: key.0.clone(), + shell: copy.shell, + path, + line: None, + }; + Some((remote, expect, like)) + } + + fn start_remote_push(&mut self, key: Key, push: Push, cx: &mut Context) { + let (Some((remote, expect, like)), Some(copy)) = ( + self.push_target(&key, push), + self.remote_files.copies.get(&key), + ) else { + return; + }; + let local = copy.local.clone(); + let io = self.remote_files.io.clone(); + let task = cx.background_executor().spawn(async move { + let access = like + .as_deref() + .map_or(RemoteFileAccess::Keep, RemoteFileAccess::Like); + let pushed = push_copy( + &io, + &remote, + &local, + (expect, access), + MAX_REMOTE_FILE_BYTES, + ); + (remote, pushed) + }); + cx.spawn(async move |this, cx| { + let (remote, pushed) = task.await; + let _ = this.update(cx, |this, cx| { + this.remote_file_pushed(key, push, &remote, pushed, cx); + }); + }) + .detach(); + } + + fn remote_file_pushed( + &mut self, + key: Key, + push: Push, + remote: &RemotePath, + pushed: super::Result, + cx: &mut Context, + ) { + let error = match pushed { + Ok(pushed) => return self.remote_push_done(key, push, remote, pushed, cx), + Err(error) => error, + }; + let event = match push { + Push::Beside(n) if error.is_conflict() && n < LAST_CONFLICT_NAME => { + return self.start_remote_push(key, Push::Beside(n + 1), cx); + } + Push::Beside(_) if error.is_conflict() => { + let last = super::basename(&remote.path).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); + 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, + }; + let io = self.remote_files.io.clone(); + let owner = self.remote_files.owner; + let fetch = cx + .background_executor() + // The user chose to drop the copy's edits, so none are kept. + .spawn(async move { fetch_copy(&io, owner, &remote, None) }); + cx.spawn(async move |this, cx| { + let fetched = fetch.await; + let _ = this.update(cx, |this, cx| this.remote_file_refetched(key, fetched, cx)); + }) + .detach(); + } + + fn remote_file_refetched( + &mut self, + key: Key, + fetched: super::Result, + cx: &mut Context, + ) { + 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), + 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/window.rs b/crates/herdr-gpui/src/window.rs index a98f17d66..871e6147a 100644 --- a/crates/herdr-gpui/src/window.rs +++ b/crates/herdr-gpui/src/window.rs @@ -519,6 +519,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/file_links/tests.rs b/crates/herdr-gpui/src/window/file_links/tests.rs index 1e2eb29c1..d5d42d059 100644 --- a/crates/herdr-gpui/src/window/file_links/tests.rs +++ b/crates/herdr-gpui/src/window/file_links/tests.rs @@ -11,6 +11,7 @@ use herdr_client::{ use std::sync::Arc; mod remote; +mod remote_saves; #[test] fn paths_resolve_under_home_or_the_pane_directory() { diff --git a/crates/herdr-gpui/src/window/file_links/tests/remote.rs b/crates/herdr-gpui/src/window/file_links/tests/remote.rs index 76257e02b..da011f1a4 100644 --- a/crates/herdr-gpui/src/window/file_links/tests/remote.rs +++ b/crates/herdr-gpui/src/window/file_links/tests/remote.rs @@ -7,10 +7,10 @@ use crate::{ }; use herdr_client::RemoteShell; -const TARGET: &str = "me@devbox"; +pub(super) const TARGET: &str = "me@devbox"; /// A window whose SSH pane prints `text` in `cwd`, fetching from `host`. -fn on_host<'a>( +pub(super) fn on_host<'a>( host: &FakeHost, reply: fn() -> herdr_client::Result<&'static [u8]>, text: &str, 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/remote_paths.rs b/crates/herdr-gpui/src/window/remote_paths.rs index 9d08fac8b..462981f4e 100644 --- a/crates/herdr-gpui/src/window/remote_paths.rs +++ b/crates/herdr-gpui/src/window/remote_paths.rs @@ -33,6 +33,15 @@ impl RemotePath { line: link.line, }) } + + /// The host's file, as the transfer functions name it. + pub(crate) fn remote_file(&self) -> herdr_client::RemoteFile<'_> { + herdr_client::RemoteFile { + target: &self.target, + shell: self.shell, + path: &self.path, + } + } } /// The shell and the absolute or `~` path for `path` as printed under `cwd`. From a835c7fc01974c526b4644b4f05e08af1b8923eb Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Sat, 17 Oct 2026 00:06:03 -0700 Subject: [PATCH 04/19] feat: list fetched SSH file copies with `herdr-gpui remote-files` `herdr-gpui remote-files [list]` reports the copies of SSH hosts' files that link clicks fetched into the window, each with its host, host path, local path, sync state, and whether its saves upload without asking. It is read-only: uploads stay behind the app's dialog. The reply fits the socket's message budget and counts what it omits, as `notes list` does. Document remote file links in the Herdr UI README, the transfer API in the client README, the command in docs/cli.md, and the behaviour beside open_files_in in the example config. --- crates/herdr-client/README.md | 50 ++++++++ crates/herdr-gpui/README.md | 53 +++++++- crates/herdr-gpui/config-gpui.example.toml | 3 +- crates/herdr-gpui/src/cli/control.rs | 10 ++ crates/herdr-gpui/src/cli/control/hosts.rs | 11 +- crates/herdr-gpui/src/control/client.rs | 1 + crates/herdr-gpui/src/control/client/help.rs | 3 + crates/herdr-gpui/src/control/client/hosts.rs | 25 +++- .../src/control/client/hosts/tests.rs | 2 + .../client/hosts/tests/remote_files.rs | 105 ++++++++++++++++ .../src/control/client/hosts/text.rs | 41 ++++++- .../herdr-gpui/src/control/protocol/hosts.rs | 46 ++++++- .../src/control/protocol/hosts/tests.rs | 2 + .../protocol/hosts/tests/remote_files.rs | 79 ++++++++++++ crates/herdr-gpui/src/control/server/hosts.rs | 13 +- .../src/control/server/hosts/remote_files.rs | 58 +++++++++ .../src/control/server/hosts/tests.rs | 1 + .../server/hosts/tests/remote_files.rs | 115 ++++++++++++++++++ crates/herdr-gpui/src/remote_files.rs | 4 - docs/cli.md | 1 + 20 files changed, 607 insertions(+), 16 deletions(-) create mode 100644 crates/herdr-gpui/src/control/client/hosts/tests/remote_files.rs create mode 100644 crates/herdr-gpui/src/control/protocol/hosts/tests/remote_files.rs create mode 100644 crates/herdr-gpui/src/control/server/hosts/remote_files.rs create mode 100644 crates/herdr-gpui/src/control/server/hosts/tests/remote_files.rs diff --git a/crates/herdr-client/README.md b/crates/herdr-client/README.md index 2664246f7..29e046eb5 100644 --- a/crates/herdr-client/README.md +++ b/crates/herdr-client/README.md @@ -604,6 +604,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-gpui/README.md b/crates/herdr-gpui/README.md index 04150971f..aa28a28fd 100644 --- a/crates/herdr-gpui/README.md +++ b/crates/herdr-gpui/README.md @@ -2123,8 +2123,9 @@ its dashes turn solid once its target is verified: A plain URL is read from its row. One that reaches the pane's right edge may wrap onto the next row; a daemon that offers `pane.link.resolve` reads it -whole, and otherwise it needs explicit terminal hyperlink metadata. Paths are -not links in panes on SSH hosts, whose files live on that host. +whole, and otherwise it needs explicit terminal hyperlink metadata. Paths in +panes on SSH hosts name that host's files; see +[Files on SSH Hosts](#files-on-ssh-hosts). A file opens in your terminal editor at the printed line, in a new pane split to the right of the pane that printed it (see [Code Navigation](#code-navigation)). @@ -2144,6 +2145,54 @@ With a daemon that offers it, a link click, a file path or `file://` hyperlink included, first goes to the daemon so a plugin's `[[link_handlers]]` entry can claim it; see [Herdr Plugins](#herdr-plugins). +### Files on SSH Hosts + +A path printed in a pane on an SSH device is a link too, +such as `C:\dev\notes.md:42` from a Windows host, `src\main.rs` under a +Windows working directory, or `~/notes.md` and `src/main.rs` on a POSIX host. +The host is not asked whether the file exists, so the path is underlined by its +shape. Relative paths join the pane's working directory on the host, and `~` +is the login user's home there. + +The modifier click copies the file over the device's own SSH login into a +private cache folder and opens the copy, under the same rule as a local file: a +document opens in its default application, and anything else opens the folder +holding the copy. Windows OpenSSH hosts are reached through Windows PowerShell +5.1 or later, and other hosts through `/bin/sh`. Files up to 64 MB are fetched; +a folder, a larger file, or a missing one shows why nothing opened. + +The copy is watched for saves. The first save asks whether to upload it: +**Upload**, **Upload future saves without asking**, or **Not now**. Each +upload checks that the host's file is still the one fetched; when it changed +on the host meanwhile, nothing is written and a dialog offers **Keep both** +(uploads your copy beside it as `notes.conflict.md`), **Overwrite anyway**, or +**Re-fetch**, which replaces the copy and drops your edits. If the Re-fetch +fails, the dialog comes back. Saving the copy back to the bytes it was +fetched or last uploaded with drops the question and closes its dialog. An +upload replaces the host's file only once all of it arrived, and the file +does not become readable by anyone who could not read it before. An existing +file keeps its permissions and ACL. A **Keep both** copy gets the original +file's access; on a POSIX host it is private to you when that access cannot +be copied, and on a Windows host the upload is refused instead. When an +existing file's access cannot be kept, it is not changed and the upload says +so. On a macOS host, a folder ACL that others inherit can let them read the +upload while it transfers. Clicking the path again reopens a copy with +unuploaded edits, and fetches a synced one anew; a click while the file is +still being fetched does nothing more. + +Each window keeps its own copies, so the same host file opened in two windows +is two copies, and a fetch in one window never replaces the other's edits. +Copies last until the app quits, when the cache is removed; a cache an earlier +run left behind is removed on the next fetch. `herdr-gpui remote-files` lists +the calling window's copies and their state. WSL and cloud panes are +unchanged. A `file://` +hyperlink naming the host itself, as `ls --hyperlink` prints, still counts as +another host's file, and on Windows a `file://` hyperlink naming a POSIX path +is not a link. On Windows, a host file whose name Windows cannot store (one +with `<>:"/\|?*` or a control character, ending in a dot or space, or a device +name such as `CON`) is copied under a safe local name, such as `a_b.txt` for +`a:b.txt`; dialogs and uploads keep the host's name and path. + ## Herdr Plugins Herdr runs plugins; this client never reads plugin manifests, runs plugin diff --git a/crates/herdr-gpui/config-gpui.example.toml b/crates/herdr-gpui/config-gpui.example.toml index 8efa604cb..2b25f2cc5 100644 --- a/crates/herdr-gpui/config-gpui.example.toml +++ b/crates/herdr-gpui/config-gpui.example.toml @@ -121,7 +121,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 b07b14af5..8c6a50cbd 100644 --- a/crates/herdr-gpui/src/cli/control.rs +++ b/crates/herdr-gpui/src/cli/control.rs @@ -69,6 +69,8 @@ pub enum ControlCommand { Host(HostCommand), Cloud(CloudCommand), Ports(PortsCommand), + /// The copies of SSH hosts' files that link clicks fetched. + RemoteFiles, Processes { pane: Option, }, @@ -166,6 +168,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"), @@ -302,6 +308,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 3acda8182..7281c81cb 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`, and `ports`. +//! `pr`, `host`, `cloud`, `ports`, and `remote-files`. use super::{Args, CliError, unknown}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -78,6 +78,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 9bfff3db6..a021a8d67 100644 --- a/crates/herdr-gpui/src/control/client.rs +++ b/crates/herdr-gpui/src/control/client.rs @@ -364,6 +364,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 fc77b5c37..fd2d98ecd 100644 --- a/crates/herdr-gpui/src/control/client/help.rs +++ b/crates/herdr-gpui/src/control/client/help.rs @@ -103,6 +103,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 0b7e70952..fc7b7d119 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`, and `system`. +//! `pr`, `host`, `cloud`, `ports`, `remote-files`, `usage`, and `system`. mod cloud; mod text; @@ -12,7 +12,7 @@ use crate::{ cli::control::{CloudCommand, HostCommand, PortsCommand, PrCommand}, control::protocol::{ Request, Response, - hosts::{HostOp, Reply}, + hosts::{HostOp, RemoteCopy, Reply}, }, }; @@ -99,6 +99,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 5ff661a94..c231d059d 100644 --- a/crates/herdr-gpui/src/control/client/hosts/tests.rs +++ b/crates/herdr-gpui/src/control/client/hosts/tests.rs @@ -8,6 +8,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 891e47ae3..f20478c4a 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 3b97925dd..1a833a28c 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, and load. +//! forwarded ports, plan usage, load, 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. @@ -45,6 +46,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 { @@ -137,6 +141,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)] @@ -178,6 +216,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, + }, } #[cfg(test)] diff --git a/crates/herdr-gpui/src/control/protocol/hosts/tests.rs b/crates/herdr-gpui/src/control/protocol/hosts/tests.rs index 914a7e062..d09ea4bfb 100644 --- a/crates/herdr-gpui/src/control/protocol/hosts/tests.rs +++ b/crates/herdr-gpui/src/control/protocol/hosts/tests.rs @@ -2,6 +2,8 @@ use super::*; use crate::control::protocol::{Call, Caller, MAX_WAIT_SECONDS, Request, Response}; +mod remote_files; + #[test] fn host_requests_have_a_stable_wire_form() { let request = Request::Host(Call { 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 205080623..2cf315afe 100644 --- a/crates/herdr-gpui/src/control/server/hosts.rs +++ b/crates/herdr-gpui/src/control/server/hosts.rs @@ -1,9 +1,11 @@ -//! `pr`, `host`, `cloud add`, `ports`, `usage`, and `system`: 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`, and +//! `remote-files`: 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; #[cfg(test)] mod tests; @@ -56,6 +58,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 34ae28a6b..2eea03338 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 strict; mod wsl_forwards; 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/remote_files.rs b/crates/herdr-gpui/src/remote_files.rs index 0435ea077..0a3284aac 100644 --- a/crates/herdr-gpui/src/remote_files.rs +++ b/crates/herdr-gpui/src/remote_files.rs @@ -230,10 +230,6 @@ impl RemoteFiles { } /// Every copy, with where it stands against its host. - #[cfg_attr( - not(test), - expect(dead_code, reason = "read by the remote-files list command") - )] pub(crate) fn list(&self) -> impl Iterator> { self.copies.iter().map(|((target, path), copy)| Listed { target, diff --git a/docs/cli.md b/docs/cli.md index 03bdbdaec..e2afffd51 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -119,6 +119,7 @@ Every command accepts: | `cloud add` | Open the settings that add one | | `cloud setup ID` | Run the `[cloud]` setup script on a saved machine again, in a new tab once the window shows it. `ID` is the machine ID from `cloud list`, its host ID, or its label | | `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 From 5945c6477102f7efb5e48f19e3b939fa485841a4 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Sat, 17 Oct 2026 10:02:57 -0700 Subject: [PATCH 05/19] docs: say a saved SSH host may run Herdr on Windows The overview said SSH needs "an installed POSIX Herdr", but a macOS client connects to a Windows OpenSSH host running Herdr 0.9.4, and the Files on SSH Hosts section already describes Windows hosts. --- crates/herdr-gpui/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/herdr-gpui/README.md b/crates/herdr-gpui/README.md index aa28a28fd..b689674be 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, From 3f3634e78c0791f9c804f096c9a73647bfe9b8c6 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Sun, 18 Oct 2026 16:06:55 -0700 Subject: [PATCH 06/19] fix: underline a link that wraps onto the next row as one link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A URL or path that ran to the pane's right edge was never a link: the client gets no soft-wrap information, so the row reader refused any token reaching the last column in case it continued on the next row. The reader now decides. `terminal/links/wrap.rs` walks the hit word's rows and joins them in two cases: a raw terminal wrap (no walls, the word ends at the pane's last column and the next row starts at column 0), and an application's word wrap as Ink/wrap-ansi lays it out (same walls, the next row starting under the text or after a bullet marker, and a word too long for the wrap width). Walls are frame characters that line up on both rows, so boxes, table cells and side-by-side panels each wrap at their own border. The existing URL and path parsers run on the joined text, and the match maps back to one segment per row, which the painter already draws. A word that reaches the edge and does not continue is a complete link. Text that runs to the end of the pane's bottom row, or ends in `…`, is still refused, since it may be cut off. A raw-wrapped URL is left to a daemon that offers `pane.link.resolve`. When the local reading strictly contains the daemon's resolved link, as at an app's word wrap, the local one is underlined and opened if the daemon declines the click. OSC 8 hyperlinks that continue on the next row are underlined on both. --- crates/herdr-gpui/README.md | 13 +- crates/herdr-gpui/src/terminal.rs | 4 +- crates/herdr-gpui/src/terminal/links.rs | 396 +++++++------ crates/herdr-gpui/src/terminal/links/path.rs | 4 +- crates/herdr-gpui/src/terminal/links/tests.rs | 79 ++- .../src/terminal/links/tests/drive_paths.rs | 14 +- .../src/terminal/links/tests/escaped_paths.rs | 16 +- .../src/terminal/links/tests/paths.rs | 29 +- .../src/terminal/links/tests/wraps.rs | 302 ++++++++++ crates/herdr-gpui/src/terminal/links/wrap.rs | 526 ++++++++++++++++++ crates/herdr-gpui/src/window/file_links.rs | 4 +- .../herdr-gpui/src/window/file_links/tests.rs | 2 +- crates/herdr-gpui/src/window/input.rs | 2 +- crates/herdr-gpui/src/window/links.rs | 74 ++- crates/herdr-gpui/src/window/links/tests.rs | 10 +- .../src/window/links/tests/wraps.rs | 131 +++++ crates/herdr-gpui/src/window/render.rs | 15 +- 17 files changed, 1375 insertions(+), 246 deletions(-) create mode 100644 crates/herdr-gpui/src/terminal/links/tests/wraps.rs create mode 100644 crates/herdr-gpui/src/terminal/links/wrap.rs create mode 100644 crates/herdr-gpui/src/window/links/tests/wraps.rs diff --git a/crates/herdr-gpui/README.md b/crates/herdr-gpui/README.md index b689674be..fffd14734 100644 --- a/crates/herdr-gpui/README.md +++ b/crates/herdr-gpui/README.md @@ -2121,11 +2121,14 @@ 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 in -panes on SSH hosts name that host's files; see -[Files on SSH Hosts](#files-on-ssh-hosts). +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 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)). diff --git a/crates/herdr-gpui/src/terminal.rs b/crates/herdr-gpui/src/terminal.rs index 7fcc77a7e..b48b5a2f2 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..f95633dc2 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; @@ -84,7 +89,7 @@ fn plain_address(text: &str) -> Option { } } -/// 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. @@ -109,95 +114,212 @@ 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, } -/// 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)?; + let target = RowTarget::Path { + path: found.path.into_owned(), + line: found.line, + explicit: false, + }; + (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 +382,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 +407,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 +423,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..77528fde8 100644 --- a/crates/herdr-gpui/src/terminal/links/path.rs +++ b/crates/herdr-gpui/src/terminal/links/path.rs @@ -19,8 +19,8 @@ pub(super) struct PlainPath<'a> { pub(super) path: Cow<'a, str>, /// The line the suffix names. pub(super) line: Option, - /// Whether the path runs to the end of the row, where it may continue on - /// the next one. + /// Whether the path runs to the end of the text read, where it may + /// continue off-screen. pub(super) open: bool, } diff --git a/crates/herdr-gpui/src/terminal/links/tests.rs b/crates/herdr-gpui/src/terminal/links/tests.rs index 130df8b93..9820a4ce9 100644 --- a/crates/herdr-gpui/src/terminal/links/tests.rs +++ b/crates/herdr-gpui/src/terminal/links/tests.rs @@ -24,6 +24,48 @@ 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, +}; + +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 +199,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 +360,4 @@ mod drive_paths; mod escaped_paths; mod external; mod paths; +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..94cc710d0 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, + } }) } @@ -47,7 +49,7 @@ fn every_cell_of_a_pasted_path_links_to_the_original_name() { }, "{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/paths.rs b/crates/herdr-gpui/src/terminal/links/tests/paths.rs index e456baa10..6fdadba5e 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, }) } @@ -112,16 +112,23 @@ fn paths_are_pane_links_only_and_never_web_links() { explicit: false, } ); - 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 +152,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) ); } 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..0cf6d3b10 --- /dev/null +++ b/crates/herdr-gpui/src/terminal/links/tests/wraps.rs @@ -0,0 +1,302 @@ +//! 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, +}; + +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, + explicit: false, + }; + Some((target, rows.to_vec())) +} + +#[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 = file("src/very/long/file.rs", Some(12), &[(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), + file("src/aaaaaaaaaaaa界.rs", None, &[(0, 3..19), (1, 0..5)]) + ); +} + +#[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..f46c43cee --- /dev/null +++ b/crates/herdr-gpui/src/terminal/links/wrap.rs @@ -0,0 +1,526 @@ +//! 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.raw_joins + .iter() + .any(|&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/file_links.rs b/crates/herdr-gpui/src/window/file_links.rs index 4764853d0..b330fd776 100644 --- a/crates/herdr-gpui/src/window/file_links.rs +++ b/crates/herdr-gpui/src/window/file_links.rs @@ -16,7 +16,7 @@ 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::{ @@ -223,7 +223,7 @@ impl HerdrWindow { return None; } let (x, y) = self.drawn_position(position)?; - let link = pane_link_at( + let link = self.link_reading().pane_link_at( self.live.surface.as_deref()?, x, y, diff --git a/crates/herdr-gpui/src/window/file_links/tests.rs b/crates/herdr-gpui/src/window/file_links/tests.rs index d5d42d059..6b93d9824 100644 --- a/crates/herdr-gpui/src/window/file_links/tests.rs +++ b/crates/herdr-gpui/src/window/file_links/tests.rs @@ -242,7 +242,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())); }); diff --git a/crates/herdr-gpui/src/window/input.rs b/crates/herdr-gpui/src/window/input.rs index aef2756c5..e864f8453 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().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 42ee0aec7..76a6b8385 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, RemotePath, file_links::FileLink}; use crate::terminal::PaneLink; use crate::{ browser::WebUrl, links::{Activation, LinkCell, LinkInbox, LinkRequest, ResolvedLink}, - terminal::{ExternalUrl, RowLink, RowTarget}, + terminal::{ExternalUrl, LinkReading, RowLink, RowTarget}, }; 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,52 @@ 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. + pub(crate) fn link_reading(&self) -> LinkReading { + LinkReading { + raw_url_wraps: !self.live.supports_link_resolve, + } + } + + /// 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. + fn local_contains_daemon(&self, position: Point) -> bool { + let Some(daemon) = self.hovered_daemon_link() else { + return false; + }; + let rows: Vec<_> = daemon.frame_rows().collect(); + self.local_link_at(position) + .is_some_and(|local| local.link.strictly_contains(&rows)) + } + /// 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)?; @@ -528,13 +575,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; } @@ -548,7 +600,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, ) { @@ -559,7 +615,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); } } } diff --git a/crates/herdr-gpui/src/window/links/tests.rs b/crates/herdr-gpui/src/window/links/tests.rs index 13e69d001..78cbe347c 100644 --- a/crates/herdr-gpui/src/window/links/tests.rs +++ b/crates/herdr-gpui/src/window/links/tests.rs @@ -13,6 +13,7 @@ mod external; #[cfg(unix)] mod file_checks; mod file_handlers; +mod wraps; const URL: &str = "https://example.com/wrapped/path"; @@ -259,20 +260,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 +292,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/wraps.rs b/crates/herdr-gpui/src/window/links/tests/wraps.rs new file mode 100644 index 000000000..c3b33ad99 --- /dev/null +++ b/crates/herdr-gpui/src/window/links/tests/wraps.rs @@ -0,0 +1,131 @@ +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_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/render.rs b/crates/herdr-gpui/src/window/render.rs index 0fea25fc0..f2bd2a9cf 100644 --- a/crates/herdr-gpui/src/window/render.rs +++ b/crates/herdr-gpui/src/window/render.rs @@ -135,18 +135,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. From dfdfcd226b6c75c3cc38705834716d098d17128d Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Sun, 18 Oct 2026 17:42:49 -0700 Subject: [PATCH 07/19] feat: open file paths printed with a ?query or #fragment Mock pages are printed as `X:\...\index.html?sheet=1` or as `file:///.../index.html?sheet=1`. The disk check looked for a file literally named `index.html?sheet=1`, so the path was never a link, and `Url::to_file_path` dropped the query from `file://` links. A path containing a separator may now end in `?query` or `#fragment`. `plain_path` splits it off at the first unescaped mark after a separator. Guards keep real names whole: no split at the start of a component, `?` only before `key=` or after an extension, `#` only after an extension and before an anchor, `L[-L]`, or `page=` on a PDF, and never at an empty suffix. The disk check and SSH's shape check see the path part, the underline covers the whole token, and `#L10` sets the line when there is no `:line`. `file://` links keep their query and fragment, and a suffix is capped at 4096 bytes. Clicking a web page (html, htm, xhtml, svg), a PDF, or a file with no extension that has a suffix starts the default browser directly with the `file://` URL, suffix included, because the system openers drop it. This covers local panes and SSH copies (fetched, cached, and kept edits). - Windows reads the https/http UserChoice ProgId (UserChoiceLatest first) and its open command. - macOS asks NSWorkspace for the default browser and runs its bundle executable. Safari takes no URL argument, so it falls back. - Linux has no direct launch and always falls back. The URL is passed as one argument, never through a shell. If the browser cannot start, the click opens what it would open without a suffix, so a file with no extension that is executable opens its folder and is never run. Every other file opens as before, without the suffix. --- Cargo.lock | 1 + Cargo.toml | 4 +- crates/herdr-gpui/Cargo.toml | 1 + crates/herdr-gpui/README.md | 7 + crates/herdr-gpui/src/default_browser.rs | 225 ++++++++++++++++++ .../src/default_browser/launch_services.rs | 33 +++ .../src/default_browser/registry.rs | 49 ++++ .../herdr-gpui/src/default_browser/tests.rs | 119 +++++++++ crates/herdr-gpui/src/editor.rs | 2 +- crates/herdr-gpui/src/error.rs | 2 + crates/herdr-gpui/src/main.rs | 1 + crates/herdr-gpui/src/remote_files.rs | 41 +++- crates/herdr-gpui/src/remote_files/tests.rs | 3 +- .../src/remote_files/tests/answers.rs | 4 +- .../remote_files/tests/dismissed_conflict.rs | 2 +- .../src/remote_files/tests/fetches.rs | 10 +- .../src/remote_files/tests/queries.rs | 62 +++++ .../src/remote_files/tests/saves.rs | 4 +- crates/herdr-gpui/src/remote_files/uploads.rs | 2 +- crates/herdr-gpui/src/sidebar/layout_tests.rs | 1 + crates/herdr-gpui/src/terminal/links.rs | 63 +++-- crates/herdr-gpui/src/terminal/links/path.rs | 123 +++++++++- crates/herdr-gpui/src/terminal/links/tests.rs | 1 + .../src/terminal/links/tests/escaped_paths.rs | 1 + .../src/terminal/links/tests/external.rs | 1 + .../src/terminal/links/tests/paths.rs | 3 + .../src/terminal/links/tests/queries.rs | 191 +++++++++++++++ .../src/terminal/links/tests/wraps.rs | 1 + crates/herdr-gpui/src/window.rs | 5 + crates/herdr-gpui/src/window/construct.rs | 1 + crates/herdr-gpui/src/window/file_links.rs | 153 +++++++++++- .../herdr-gpui/src/window/file_links/tests.rs | 2 + .../src/window/file_links/tests/queries.rs | 181 ++++++++++++++ crates/herdr-gpui/src/window/links.rs | 2 +- .../src/window/remote_paths/tests.rs | 18 ++ 35 files changed, 1267 insertions(+), 52 deletions(-) create mode 100644 crates/herdr-gpui/src/default_browser.rs create mode 100644 crates/herdr-gpui/src/default_browser/launch_services.rs create mode 100644 crates/herdr-gpui/src/default_browser/registry.rs create mode 100644 crates/herdr-gpui/src/default_browser/tests.rs create mode 100644 crates/herdr-gpui/src/remote_files/tests/queries.rs create mode 100644 crates/herdr-gpui/src/terminal/links/tests/queries.rs create mode 100644 crates/herdr-gpui/src/window/file_links/tests/queries.rs diff --git a/Cargo.lock b/Cargo.lock index 935426afc..cdc1eedb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3614,6 +3614,7 @@ dependencies = [ "url", "uuid", "windows 0.62.2", + "windows-registry", "windows-sys 0.61.2", "winresource", "zed-font-kit", diff --git a/Cargo.toml b/Cargo.toml index 028b7bfba..dd560c442 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,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. @@ -114,6 +114,8 @@ uuid = { features = ["serde", "v4", "v5"], version = "1" } # Window shell properties behind the taskbar relaunch identity; the version # GPUI's Windows platform already links. windows = { features = ["Win32_Foundation", "Win32_Storage_EnhancedStorage", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Variant", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem"], version = "0.62.2" } +# Reads the default browser's registered command (`default_browser`). +windows-registry = "0.6.1" windows-sys = { features = ["Win32_Foundation", "Win32_System_Pipes"], version = "0.61.2" } # Embeds the Windows executable's icon and version information; a build # dependency, so it runs on the host. diff --git a/crates/herdr-gpui/Cargo.toml b/crates/herdr-gpui/Cargo.toml index 1cba50adb..76dad1f4e 100644 --- a/crates/herdr-gpui/Cargo.toml +++ b/crates/herdr-gpui/Cargo.toml @@ -114,6 +114,7 @@ clipboard-win.workspace = true interprocess.workspace = true raw-window-handle.workspace = true windows.workspace = true +windows-registry.workspace = true windows-sys = { features = [ "Wdk_System_SystemServices", "Win32_Graphics_Dwm", diff --git a/crates/herdr-gpui/README.md b/crates/herdr-gpui/README.md index fffd14734..5b229bc19 100644 --- a/crates/herdr-gpui/README.md +++ b/crates/herdr-gpui/README.md @@ -2130,6 +2130,13 @@ 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)). Folders, images, videos, and PDFs open with the system's default application diff --git a/crates/herdr-gpui/src/default_browser.rs b/crates/herdr-gpui/src/default_browser.rs new file mode 100644 index 000000000..faa02be8d --- /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::core::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..9d122ab64 --- /dev/null +++ b/crates/herdr-gpui/src/default_browser/registry.rs @@ -0,0 +1,49 @@ +//! 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::{Win32::Foundation::ERROR_FILE_NOT_FOUND, core::HRESULT}; +use windows_registry::{CLASSES_ROOT, CURRENT_USER, Key}; + +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 error.code() == HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0) => 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 f686daf01..bbb8ce322 100644 --- a/crates/herdr-gpui/src/error.rs +++ b/crates/herdr-gpui/src/error.rs @@ -174,6 +174,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.")] diff --git a/crates/herdr-gpui/src/main.rs b/crates/herdr-gpui/src/main.rs index 5ea55dd84..53b47b9ab 100644 --- a/crates/herdr-gpui/src/main.rs +++ b/crates/herdr-gpui/src/main.rs @@ -55,6 +55,7 @@ mod copy_mode; mod daemon; #[cfg(feature = "daytona")] mod daytona; +mod default_browser; mod devices_overview; mod diagnostics; mod dialog_input; diff --git a/crates/herdr-gpui/src/remote_files.rs b/crates/herdr-gpui/src/remote_files.rs index 0a3284aac..484e8d880 100644 --- a/crates/herdr-gpui/src/remote_files.rs +++ b/crates/herdr-gpui/src/remote_files.rs @@ -444,7 +444,8 @@ struct Done { key: Key, copy: CachedCopy, cache: Arc, - /// The copy, or the folder holding it when it is not a document. + /// 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, } @@ -459,12 +460,15 @@ enum Fetch { /// Fetches `remote` into `owner`'s copy of it in the cache: written beside /// it first and renamed over it 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. Blocking. +/// it was last synced, a copy saved since then is left as it is. `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); @@ -497,7 +501,7 @@ fn fetch_copy( .inspect_err(|_| remove_part(&part))?; #[cfg(test)] (io.renamed)(&local); - let opened = crate::window::opened(&local).and_then(|path| url::Url::from_file_path(path).ok()); + let opened = crate::window::opened_url(&local, query); Ok(Fetch::Written(Box::new(Done { key: (remote.target.clone(), remote.path.clone()), copy: CachedCopy { @@ -543,14 +547,20 @@ fn remove_part(part: &Path) { impl HerdrWindow { /// Fetches the host's file `remote` names into the cache and opens the /// copy with the system's default application, or says why it could not. - /// A copy in conflict is reopened, and its question asked again. - pub(crate) fn open_remote_file(&mut self, remote: RemotePath, cx: &mut Context) { + /// A copy in conflict is reopened, and its question asked again. A page + /// opens with `query`, the `?query` or `#fragment` printed after its path. + pub(crate) fn open_remote_file( + &mut self, + remote: RemotePath, + query: Option, + cx: &mut Context, + ) { if !self.remote_files.fetch_again(&remote) { let shown = self.shown_remote_file_prompt(); let key = (remote.target.clone(), remote.path.clone()); self.remote_files.reask_conflict(key, shown.as_ref()); if let Some(copy) = self.remote_files.copy(&remote.target, &remote.path) { - self.open_in_system_app(copy.local.clone(), cx); + self.open_in_system_app(copy.local.clone(), query, cx); } return; } @@ -570,32 +580,41 @@ impl HerdrWindow { .remote_files .copy(&remote.target, &remote.path) .map(|copy| copy.synced_meta); + let page_query = query.clone(); let fetch = cx .background_executor() - .spawn(async move { fetch_copy(&io, owner, &remote, synced) }); + .spawn(async move { fetch_copy(&io, owner, &remote, synced, page_query.as_deref()) }); cx.spawn(async move |this, cx| { let fetched = fetch.await; // The window may have closed meanwhile; nothing opens then. - let _ = this.update(cx, |this, cx| this.remote_file_fetched(&key, fetched, cx)); + let _ = this.update(cx, |this, cx| { + this.remote_file_fetched(&key, query, fetched, cx) + }); }) .detach(); } - fn remote_file_fetched(&mut self, key: &Key, fetched: Result, cx: &mut Context) { + fn remote_file_fetched( + &mut self, + key: &Key, + query: Option, + fetched: Result, + cx: &mut Context, + ) { self.remote_files.fetching.remove(key); match fetched { // Saved while the fetch ran: the copy with the save opens, and // the watch takes the save from there. Ok(Fetch::KeptEdits) => { if let Some(copy) = self.remote_files.copies.get(key) { - self.open_in_system_app(copy.local.clone(), cx); + self.open_in_system_app(copy.local.clone(), query, cx); } } Ok(Fetch::Written(done)) => { let opened = done.opened.clone(); self.remote_files.record(*done); match opened { - Some(url) => cx.open_url(url.as_str()), + Some(url) => self.open_file_url(url, cx), None => tracing::warn!("Fetched remote file copy could not be opened"), } } diff --git a/crates/herdr-gpui/src/remote_files/tests.rs b/crates/herdr-gpui/src/remote_files/tests.rs index e4cd5787e..8df59ccc3 100644 --- a/crates/herdr-gpui/src/remote_files/tests.rs +++ b/crates/herdr-gpui/src/remote_files/tests.rs @@ -7,6 +7,7 @@ mod answers; mod dismissed_conflict; mod fetch_meta; mod fetches; +mod queries; mod saves; mod windows_names; @@ -41,7 +42,7 @@ pub(crate) fn cut_off() -> herdr_client::Result<()> { /// A fetch into a cache that keeps no edits, which always writes the copy. fn fetch_fresh(io: &Io, owner: Owner, remote: &RemotePath) -> Result { - fetch_copy(io, owner, remote, None).map(|fetch| match fetch { + fetch_copy(io, owner, remote, None, None).map(|fetch| match fetch { Fetch::Written(done) => *done, Fetch::KeptEdits => panic!("a fetch keeping no edits kept some"), }) diff --git a/crates/herdr-gpui/src/remote_files/tests/answers.rs b/crates/herdr-gpui/src/remote_files/tests/answers.rs index df888642d..12dd92923 100644 --- a/crates/herdr-gpui/src/remote_files/tests/answers.rs +++ b/crates/herdr-gpui/src/remote_files/tests/answers.rs @@ -18,7 +18,7 @@ fn poll(view: &Entity, cx: &mut VisualTestContext) { fn saving_the_synced_bytes_back_closes_the_open_upload_question(cx: &mut TestAppContext) { let host = FakeHost::new(); let (view, cx) = window(&host, cx); - view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), None, cx)); cx.run_until_parked(); let key = ("me@devbox".to_owned(), PATH.to_owned()); let (local, synced) = view.read_with(cx, |view, _| { @@ -54,7 +54,7 @@ fn saving_the_synced_bytes_back_closes_the_open_upload_question(cx: &mut TestApp fn overwrite_after_the_host_s_bytes_were_saved_back_pushes_nothing(cx: &mut TestAppContext) { let host = FakeHost::new(); let (view, cx) = window(&host, cx); - view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), None, cx)); cx.run_until_parked(); let key = ("me@devbox".to_owned(), PATH.to_owned()); view.update(cx, |view, cx| { diff --git a/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs b/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs index d0d860c0b..f2bd99aec 100644 --- a/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs +++ b/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs @@ -19,7 +19,7 @@ fn poll(view: &Entity, cx: &mut VisualTestContext) { /// A link click on `PATH`, and its work finished. fn click(view: &Entity, cx: &mut VisualTestContext) { - view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), None, cx)); cx.run_until_parked(); } diff --git a/crates/herdr-gpui/src/remote_files/tests/fetches.rs b/crates/herdr-gpui/src/remote_files/tests/fetches.rs index bbf4cab2e..181c2af7f 100644 --- a/crates/herdr-gpui/src/remote_files/tests/fetches.rs +++ b/crates/herdr-gpui/src/remote_files/tests/fetches.rs @@ -10,14 +10,14 @@ fn a_click_while_its_file_is_fetched_starts_no_second_fetch(cx: &mut TestAppCont let host = FakeHost::new(); let (view, cx) = window(&host, cx); view.update(cx, |view, cx| { - view.open_remote_file(remote(PATH), cx); - view.open_remote_file(remote(PATH), cx); + view.open_remote_file(remote(PATH), None, cx); + view.open_remote_file(remote(PATH), 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), cx)); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), None, cx)); cx.run_until_parked(); assert_eq!(host.asked().len(), 2); } @@ -27,9 +27,9 @@ fn a_failed_click_fetch_lets_the_next_click_fetch_again(cx: &mut TestAppContext) let host = FakeHost::new(); let (view, cx) = window(&host, cx); view.update(cx, |view, _| host.serve(&mut view.remote_files, missing)); - view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), None, cx)); cx.run_until_parked(); - view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), None, cx)); cx.run_until_parked(); assert_eq!(host.asked().len(), 2); } 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..f67b48965 --- /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()), 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/saves.rs b/crates/herdr-gpui/src/remote_files/tests/saves.rs index ee0ba0232..eab55c395 100644 --- a/crates/herdr-gpui/src/remote_files/tests/saves.rs +++ b/crates/herdr-gpui/src/remote_files/tests/saves.rs @@ -375,7 +375,7 @@ fn a_remote_save_made_while_a_click_fetches_is_kept() { ..io }; let outcome = std::thread::scope(|scope| { - let fetching = scope.spawn(|| fetch_copy(&gated, owner, &remote(PATH), Some(synced))); + 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(); @@ -396,7 +396,7 @@ fn a_remote_save_made_while_a_click_fetches_is_kept() { let (files, key, local) = fetched(&host); host.change_file(b"# Newer\n"); let synced = Some(files.copies[&key].synced_meta); - let written = fetch_copy(&files.io, files.owner, &remote(PATH), synced).unwrap(); + 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"); } diff --git a/crates/herdr-gpui/src/remote_files/uploads.rs b/crates/herdr-gpui/src/remote_files/uploads.rs index 5af90bda3..af36c3444 100644 --- a/crates/herdr-gpui/src/remote_files/uploads.rs +++ b/crates/herdr-gpui/src/remote_files/uploads.rs @@ -267,7 +267,7 @@ impl HerdrWindow { 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) }); + .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)); diff --git a/crates/herdr-gpui/src/sidebar/layout_tests.rs b/crates/herdr-gpui/src/sidebar/layout_tests.rs index 8a5dff8e5..67878b0f6 100644 --- a/crates/herdr-gpui/src/sidebar/layout_tests.rs +++ b/crates/herdr-gpui/src/sidebar/layout_tests.rs @@ -470,6 +470,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, diff --git a/crates/herdr-gpui/src/terminal/links.rs b/crates/herdr-gpui/src/terminal/links.rs index f95633dc2..6d89f11a7 100644 --- a/crates/herdr-gpui/src/terminal/links.rs +++ b/crates/herdr-gpui/src/terminal/links.rs @@ -34,21 +34,26 @@ 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, + query, explicit: true, }; 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, }; @@ -65,10 +70,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 @@ -80,11 +107,14 @@ 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 { - path, - line: None, - explicit: false, - }), + RowTarget::Path { path, query, .. } if text.starts_with("file://") => { + Some(RowTarget::Path { + path, + line: None, + query, + explicit: false, + }) + } _ => None, } } @@ -96,11 +126,15 @@ pub(crate) enum RowTarget { 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, }, /// An explicit hyperlink to a file on another host, by that host's name. @@ -305,6 +339,7 @@ impl LinkReading { let target = RowTarget::Path { path: found.path.into_owned(), line: found.line, + query: found.query.map(str::to_owned), explicit: false, }; (found.range, found.open, target) diff --git a/crates/herdr-gpui/src/terminal/links/path.rs b/crates/herdr-gpui/src/terminal/links/path.rs index 77528fde8..567d86df4 100644 --- a/crates/herdr-gpui/src/terminal/links/path.rs +++ b/crates/herdr-gpui/src/terminal/links/path.rs @@ -12,13 +12,16 @@ 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, + /// 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, @@ -56,7 +59,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)?; + 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 +78,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. +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 9820a4ce9..7764bc8f2 100644 --- a/crates/herdr-gpui/src/terminal/links/tests.rs +++ b/crates/herdr-gpui/src/terminal/links/tests.rs @@ -360,4 +360,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/escaped_paths.rs b/crates/herdr-gpui/src/terminal/links/tests/escaped_paths.rs index 94cc710d0..2c7c6ac53 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/escaped_paths.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/escaped_paths.rs @@ -45,6 +45,7 @@ fn every_cell_of_a_pasted_path_links_to_the_original_name() { RowTarget::Path { path: name.into(), line: None, + query: None, explicit: false, }, "{text} at {column}" diff --git a/crates/herdr-gpui/src/terminal/links/tests/external.rs b/crates/herdr-gpui/src/terminal/links/tests/external.rs index d103b5fc8..f45e139b4 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/external.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/external.rs @@ -127,6 +127,7 @@ 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, }) ); diff --git a/crates/herdr-gpui/src/terminal/links/tests/paths.rs b/crates/herdr-gpui/src/terminal/links/tests/paths.rs index 6fdadba5e..242932885 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/paths.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/paths.rs @@ -23,6 +23,7 @@ fn bare(path: &str) -> RowTarget { RowTarget::Path { path: path.into(), line: None, + query: None, explicit: false, } } @@ -109,6 +110,7 @@ fn paths_are_pane_links_only_and_never_web_links() { RowTarget::Path { path: "src/lib.rs".into(), line: Some(4), + query: None, explicit: false, } ); @@ -164,6 +166,7 @@ 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, }; let mut s = surface("report"); 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..553c08495 --- /dev/null +++ b/crates/herdr-gpui/src/terminal/links/tests/queries.rs @@ -0,0 +1,191 @@ +//! 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), None); + let within = format!("/a/b.html?x={} ok", "a".repeat(4000)); + let found = path::plain_path(&within, 1).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, + } + ); + assert_eq!(link.rows, vec![(0, 4..24), (1, 0..5)]); +} + +/// 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, + }; + 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, + }) + ); + // 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)); +} diff --git a/crates/herdr-gpui/src/terminal/links/tests/wraps.rs b/crates/herdr-gpui/src/terminal/links/tests/wraps.rs index 0cf6d3b10..450b457f2 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/wraps.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/wraps.rs @@ -33,6 +33,7 @@ fn file(path: &str, line: Option, rows: &[(u16, Range)]) -> Option<(Ro let target = RowTarget::Path { path: path.into(), line, + query: None, explicit: false, }; Some((target, rows.to_vec())) diff --git a/crates/herdr-gpui/src/window.rs b/crates/herdr-gpui/src/window.rs index 871e6147a..451f441d4 100644 --- a/crates/herdr-gpui/src/window.rs +++ b/crates/herdr-gpui/src/window.rs @@ -15,7 +15,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; @@ -153,6 +155,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, /// Orchestrator views moved into windows of their own, by their old tab. diff --git a/crates/herdr-gpui/src/window/construct.rs b/crates/herdr-gpui/src/window/construct.rs index b2a8e24dc..aedbf9cea 100644 --- a/crates/herdr-gpui/src/window/construct.rs +++ b/crates/herdr-gpui/src/window/construct.rs @@ -151,6 +151,7 @@ impl HerdrWindow { configured_terminal_size, copy_mode: None, daemon_text: Default::default(), + default_browser: super::start_browser(), deliveries: Default::default(), detached_orchestrators: Default::default(), device_drag: None, diff --git a/crates/herdr-gpui/src/window/file_links.rs b/crates/herdr-gpui/src/window/file_links.rs index b330fd776..434ae873c 100644 --- a/crates/herdr-gpui/src/window/file_links.rs +++ b/crates/herdr-gpui/src/window/file_links.rs @@ -35,12 +35,24 @@ 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 @@ -187,6 +199,86 @@ pub(crate) 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. @@ -254,6 +346,7 @@ impl HerdrWindow { let RowTarget::Path { path, line, + query, explicit, } = link.target else { @@ -266,6 +359,7 @@ impl HerdrWindow { Some(FileLink { path, line, + query, pane_id, cwd, explicit, @@ -275,15 +369,55 @@ impl HerdrWindow { /// 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(); @@ -310,6 +444,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)) @@ -335,7 +474,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 6b93d9824..9e9c45012 100644 --- a/crates/herdr-gpui/src/window/file_links/tests.rs +++ b/crates/herdr-gpui/src/window/file_links/tests.rs @@ -10,6 +10,7 @@ use herdr_client::{ }; use std::sync::Arc; +mod queries; mod remote; mod remote_saves; @@ -20,6 +21,7 @@ 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, 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/links.rs b/crates/herdr-gpui/src/window/links.rs index 76a6b8385..0af783f9c 100644 --- a/crates/herdr-gpui/src/window/links.rs +++ b/crates/herdr-gpui/src/window/links.rs @@ -639,7 +639,7 @@ impl HerdrWindow { // host's file always opens in the system's application. Some(Fallback::File { link, .. }) if link.remote.is_some() => { if let Some(remote) = RemotePath::from_link(&link) { - self.open_remote_file(remote, cx); + self.open_remote_file(remote, link.query, cx); } } Some(Fallback::File { link, in_editor }) => self.open_file_link(link, in_editor, cx), diff --git a/crates/herdr-gpui/src/window/remote_paths/tests.rs b/crates/herdr-gpui/src/window/remote_paths/tests.rs index 544d5911e..f1cf00233 100644 --- a/crates/herdr-gpui/src/window/remote_paths/tests.rs +++ b/crates/herdr-gpui/src/window/remote_paths/tests.rs @@ -4,6 +4,7 @@ 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, @@ -24,6 +25,23 @@ 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 { From 738f5b2378d56729b42b9fd7fa68cea734338a22 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Sun, 18 Oct 2026 21:23:24 -0700 Subject: [PATCH 08/19] fix: open quick-clicked wraps, relative Windows queries and file:// lines Three link fixes from review: - A modifier-click on an address an application wrapped across rows, made before the hover's pane.link.resolve answer arrived, opened only the first row: with no resolved link to compare, the daemon's first-row reading won. A local reading that spans more than one row now wins when nothing is resolved yet; a handler still claims the click first. - A relative Windows path such as docs\index.html?x=1 never split off its query, because without a drive a backslash read as an escape. LinkReading now carries whether the pane's host writes Windows paths, judged from the pane's working directory (a drive or UNC path means Windows, a leading / means not), else from this machine. Only the suffix split uses it; POSIX escapes such as /tmp/a\ b are unchanged. - file://...#L10 links, and schemeless hyperlinks such as src/main.rs#L10-L20, now open at the line the fragment names, as a printed src/main.rs#L10 already did. --- crates/herdr-gpui/src/terminal/links.rs | 24 ++--- crates/herdr-gpui/src/terminal/links/path.rs | 8 +- crates/herdr-gpui/src/terminal/links/tests.rs | 1 + .../src/terminal/links/tests/queries.rs | 89 ++++++++++++++++++- .../src/terminal/links/tests/wraps.rs | 1 + crates/herdr-gpui/src/window/file_links.rs | 2 +- crates/herdr-gpui/src/window/input.rs | 2 +- crates/herdr-gpui/src/window/links.rs | 58 ++++++++++-- crates/herdr-gpui/src/window/links/tests.rs | 1 + .../src/window/links/tests/windows_paths.rs | 50 +++++++++++ .../src/window/links/tests/wraps.rs | 13 +++ crates/herdr-gpui/src/window/remote_paths.rs | 2 +- 12 files changed, 224 insertions(+), 27 deletions(-) create mode 100644 crates/herdr-gpui/src/window/links/tests/windows_paths.rs diff --git a/crates/herdr-gpui/src/terminal/links.rs b/crates/herdr-gpui/src/terminal/links.rs index 6d89f11a7..d0760e90e 100644 --- a/crates/herdr-gpui/src/terminal/links.rs +++ b/crates/herdr-gpui/src/terminal/links.rs @@ -36,7 +36,7 @@ const MAX_PATH_BYTES: usize = 4096; fn explicit_target(destination: &str) -> RowTarget { let named = |path: String, query: Option| RowTarget::Path { path, - line: None, + line: query.as_deref().and_then(path::fragment_line), query, explicit: true, }; @@ -107,14 +107,14 @@ fn plain_address(text: &str) -> Option { } match explicit_target(text) { RowTarget::External(url) if url.trusted() => Some(RowTarget::External(url)), - RowTarget::Path { path, query, .. } if text.starts_with("file://") => { - Some(RowTarget::Path { - path, - line: None, - query, - explicit: false, - }) - } + RowTarget::Path { + path, line, query, .. + } if text.starts_with("file://") => Some(RowTarget::Path { + path, + line, + query, + explicit: false, + }), _ => None, } } @@ -188,6 +188,10 @@ pub(crate) struct PaneLink { 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, } impl LinkReading { @@ -335,7 +339,7 @@ impl LinkReading { (range, open, target) } None => { - let found = path::plain_path(text, hit)?; + let found = path::plain_path(text, hit, self.windows_paths)?; let target = RowTarget::Path { path: found.path.into_owned(), line: found.line, diff --git a/crates/herdr-gpui/src/terminal/links/path.rs b/crates/herdr-gpui/src/terminal/links/path.rs index 567d86df4..ceffa723b 100644 --- a/crates/herdr-gpui/src/terminal/links/path.rs +++ b/crates/herdr-gpui/src/terminal/links/path.rs @@ -31,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); @@ -59,7 +61,7 @@ pub(super) fn plain_path(text: &str, hit: usize) -> Option> { if hit < start || hit >= start + token.len() { return None; } - let (named, query) = split_suffix(token, drive)?; + 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 { @@ -173,7 +175,7 @@ fn anchor(fragment: &str) -> bool { /// The line a `#L10` or `#L10-L20` fragment at the end of `suffix` names: /// the first of the range. -fn fragment_line(suffix: &str) -> Option { +pub(super) fn fragment_line(suffix: &str) -> Option { let (_, fragment) = suffix.split_once('#')?; let lines = fragment.strip_prefix('L')?; let number = |digits: &str| { diff --git a/crates/herdr-gpui/src/terminal/links/tests.rs b/crates/herdr-gpui/src/terminal/links/tests.rs index 7764bc8f2..acc33601c 100644 --- a/crates/herdr-gpui/src/terminal/links/tests.rs +++ b/crates/herdr-gpui/src/terminal/links/tests.rs @@ -40,6 +40,7 @@ fn rows(lines: &[&str], width: u16) -> FrameData { /// 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 { diff --git a/crates/herdr-gpui/src/terminal/links/tests/queries.rs b/crates/herdr-gpui/src/terminal/links/tests/queries.rs index 553c08495..bf78a6826 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/queries.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/queries.rs @@ -122,9 +122,9 @@ 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), None); + 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).unwrap(); + 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)); } @@ -145,6 +145,54 @@ fn a_query_wrapped_raw_onto_the_next_row_joins_its_path() { 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"); @@ -189,3 +237,40 @@ fn a_file_address_keeps_its_query_and_fragment_in_text_and_hyperlinks() { 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, + }; + 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, + }) + ); +} diff --git a/crates/herdr-gpui/src/terminal/links/tests/wraps.rs b/crates/herdr-gpui/src/terminal/links/tests/wraps.rs index 450b457f2..c9aa6d0e2 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/wraps.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/wraps.rs @@ -6,6 +6,7 @@ use super::*; /// wrapping raw onto the next row to it. const RESOLVED: LinkReading = LinkReading { raw_url_wraps: false, + windows_paths: false, }; type Rows = Vec<(u16, Range)>; diff --git a/crates/herdr-gpui/src/window/file_links.rs b/crates/herdr-gpui/src/window/file_links.rs index 434ae873c..7243ae3da 100644 --- a/crates/herdr-gpui/src/window/file_links.rs +++ b/crates/herdr-gpui/src/window/file_links.rs @@ -315,7 +315,7 @@ impl HerdrWindow { return None; } let (x, y) = self.drawn_position(position)?; - let link = self.link_reading().pane_link_at( + let link = self.link_reading((x, y)).pane_link_at( self.live.surface.as_deref()?, x, y, diff --git a/crates/herdr-gpui/src/window/input.rs b/crates/herdr-gpui/src/window/input.rs index e864f8453..6815e4a07 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)?; - self.link_reading().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 0af783f9c..c737c2aeb 100644 --- a/crates/herdr-gpui/src/window/links.rs +++ b/crates/herdr-gpui/src/window/links.rs @@ -15,12 +15,12 @@ //! reading strictly contains the daemon's, as at an application's word wrap, //! the local one is underlined and opened. -use super::{HerdrWindow, RemotePath, 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, LinkReading, RowLink, RowTarget}, + terminal::{ExternalUrl, InputTarget, LinkReading, RowLink, RowTarget, wheel_target}, }; use gpui::{Context, Modifiers, Pixels, Point, Task, Window}; use std::{ @@ -213,10 +213,41 @@ impl HerdrWindow { self.links.local.as_ref() } - /// How this client reads plain text on the selected daemon. - pub(crate) fn link_reading(&self) -> LinkReading { + /// 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), } } @@ -243,14 +274,23 @@ impl HerdrWindow { /// 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. + /// 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(daemon) = self.hovered_daemon_link() else { + let Some(local) = self.local_link_at(position) else { return false; }; - let rows: Vec<_> = daemon.frame_rows().collect(); - self.local_link_at(position) - .is_some_and(|local| local.link.strictly_contains(&rows)) + 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. diff --git a/crates/herdr-gpui/src/window/links/tests.rs b/crates/herdr-gpui/src/window/links/tests.rs index 78cbe347c..c13b8b240 100644 --- a/crates/herdr-gpui/src/window/links/tests.rs +++ b/crates/herdr-gpui/src/window/links/tests.rs @@ -13,6 +13,7 @@ mod external; #[cfg(unix)] mod file_checks; mod file_handlers; +mod windows_paths; mod wraps; const URL: &str = "https://example.com/wrapped/path"; 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 index c3b33ad99..83418d707 100644 --- a/crates/herdr-gpui/src/window/links/tests/wraps.rs +++ b/crates/herdr-gpui/src/window/links/tests/wraps.rs @@ -119,6 +119,19 @@ fn a_declined_click_opens_the_local_link_that_contains_the_daemons(cx: &mut gpui 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"]); diff --git a/crates/herdr-gpui/src/window/remote_paths.rs b/crates/herdr-gpui/src/window/remote_paths.rs index 462981f4e..e1bee58e6 100644 --- a/crates/herdr-gpui/src/window/remote_paths.rs +++ b/crates/herdr-gpui/src/window/remote_paths.rs @@ -89,7 +89,7 @@ fn resolve(path: &str, cwd: Option<&str>) -> Option<(RemoteShell, String)> { /// Whether `path` starts with a drive and a separator, as in `C:\` or `c:/`, /// or is a UNC path such as `\\server\share`. -fn windows(path: &str) -> bool { +pub(super) fn windows(path: &str) -> bool { path.starts_with(r"\\") || matches!(path.as_bytes(), [letter, b':', b'\\' | b'/', ..] if letter.is_ascii_alphabetic()) } From 56a5f7875a037bee739414cf9dc4639cd10ed27e Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Sun, 18 Oct 2026 21:47:01 -0700 Subject: [PATCH 09/19] fix: keep saves through a repeat SSH fetch and backslashes in names Two fixes from review of the SSH file copies: - A repeat fetch checked the copy for saves, then renamed the downloaded file over it, so a save landing between the two was overwritten and the copy marked synced. The copy now moves aside first, the download is hard-linked into its place, which fails rather than replace a save that took the place meanwhile, and the copy moved aside comes back unless its metadata proves it is the one last synced. A filesystem without hard links falls back to a rename. The watch run no longer probes a copy a fetch is writing, and a conflict's refetch now holds its copy in the in-flight set as a click's fetch does, so only one writer handles a copy at a time. - A copy's file name was split at both separators for every host, so /srv/foo\bar.txt on a POSIX host was cached and shown as bar.txt. It is now split at the host shell's own separators, and on Windows the backslash becomes an underscore as the local naming rules intend. --- .../src/menu/remote_file_dialogs.rs | 16 +- crates/herdr-gpui/src/remote_files.rs | 201 +++++++++++++-- crates/herdr-gpui/src/remote_files/cache.rs | 41 +++- crates/herdr-gpui/src/remote_files/tests.rs | 38 ++- .../src/remote_files/tests/fetch_meta.rs | 6 +- .../src/remote_files/tests/fetch_swap.rs | 230 ++++++++++++++++++ .../src/remote_files/tests/windows_names.rs | 23 +- crates/herdr-gpui/src/remote_files/uploads.rs | 11 +- 8 files changed, 520 insertions(+), 46 deletions(-) create mode 100644 crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs diff --git a/crates/herdr-gpui/src/menu/remote_file_dialogs.rs b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs index 2f22d56be..67e6ff021 100644 --- a/crates/herdr-gpui/src/menu/remote_file_dialogs.rs +++ b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs @@ -90,10 +90,16 @@ impl HerdrWindow { 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)))) + .child(title(format!( + "Upload your changes to {}?", + basename(path, copy.shell) + ))) .child( div().px(px(8.)).child( div() @@ -135,9 +141,15 @@ impl HerdrWindow { 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)))) + .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.", diff --git a/crates/herdr-gpui/src/remote_files.rs b/crates/herdr-gpui/src/remote_files.rs index 0a3284aac..6e9f2e172 100644 --- a/crates/herdr-gpui/src/remote_files.rs +++ b/crates/herdr-gpui/src/remote_files.rs @@ -113,17 +113,33 @@ pub(crate) struct Io { cache: CacheSource, fetch: Fetcher, push: Pusher, - /// Runs as soon as a fetch's copy is renamed into place, so a test can - /// save to it then. + /// 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)] - renamed: Arc, + 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)] - renamed: Arc::new(|_| {}), + step: Arc::new(|_, _| {}), cache: Arc::new(cache::shared), // The scripts' idle timeout ends a transfer that stalls. fetch: Arc::new(|remote, out| { @@ -211,7 +227,8 @@ pub(crate) struct RemoteFiles { /// The cache the copies are in, once one was written. cache: Option>, copies: HashMap, - /// The copies a click's fetch is writing. + /// 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. @@ -349,11 +366,11 @@ impl RemoteFiles { } /// The copies a watch run looks at: all but those a transfer is - /// rewriting or reading. + /// rewriting or reading, or a fetch is writing. fn probes(&self) -> Vec { self.copies .iter() - .filter(|(_, copy)| !transferring(copy.state)) + .filter(|(key, copy)| !transferring(copy.state) && !self.fetching.contains(*key)) .map(|(key, copy)| watch::Probe { key: key.clone(), local: copy.local.clone(), @@ -368,6 +385,10 @@ impl RemoteFiles { 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; }; @@ -457,9 +478,11 @@ enum Fetch { } /// Fetches `remote` into `owner`'s copy of it in the cache: written beside -/// it first and renamed over it once whole, so a failed fetch leaves any +/// 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. Blocking. +/// it was last synced, a copy saved since then is left as it is, however +/// late the save comes (`swap_in`); without it the copy is replaced. +/// Blocking. fn fetch_copy( io: &Io, owner: Owner, @@ -467,7 +490,7 @@ fn fetch_copy( keep_edits_since: Option, ) -> Result { let cache = (io.cache)()?; - let local = cache.copy_path(owner, &remote.target, &remote.path); + let local = cache.copy_path(owner, &remote.target, &remote.path, remote.shell); if let Some(folder) = local.parent() { cache::create_private_dirs(folder)?; } @@ -480,6 +503,8 @@ fn fetch_copy( 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) @@ -489,14 +514,13 @@ fn fetch_copy( source, }) .inspect_err(|_| remove_part(&part))?; - std::fs::rename(&part, &local) - .map_err(|source| Error::Cache { - path: local.clone(), - source, - }) - .inspect_err(|_| remove_part(&part))?; - #[cfg(test)] - (io.renamed)(&local); + let placed = match keep_edits_since { + Some(synced) => swap_in(io, &part, &local, synced), + None => replace(io, &part, &local), + }; + if placed.inspect_err(|_| remove_part(&part))? == Placed::KeptEdits { + return Ok(Fetch::KeptEdits); + } let opened = crate::window::opened(&local).and_then(|path| url::Url::from_file_path(path).ok()); Ok(Fetch::Written(Box::new(Done { key: (remote.target.clone(), remote.path.clone()), @@ -527,6 +551,145 @@ fn edited_since(local: &Path, synced: watch::Meta) -> Result { } } +/// 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) @@ -559,7 +722,7 @@ impl HerdrWindow { if !self.remote_files.fetching.insert(key.clone()) { return; } - let name = basename(&remote.path); + let name = basename(&remote.path, remote.shell); self.show_flash( Flash::success(format!("Fetching {name} from {}…", remote.target)), cx, diff --git a/crates/herdr-gpui/src/remote_files/cache.rs b/crates/herdr-gpui/src/remote_files/cache.rs index 2d982be56..396049043 100644 --- a/crates/herdr-gpui/src/remote_files/cache.rs +++ b/crates/herdr-gpui/src/remote_files/cache.rs @@ -5,6 +5,7 @@ //! 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, @@ -83,13 +84,19 @@ impl Cache { /// 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. - pub(crate) fn copy_path(&self, owner: Owner, target: &str, path: &str) -> PathBuf { + /// 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), cfg!(windows)).as_ref()) + .join(local_name(basename(path, shell), cfg!(windows)).as_ref()) } #[cfg(test)] @@ -131,10 +138,15 @@ pub(crate) fn shared() -> Result> { Ok(cache) } -/// The last segment of a host's path, split at either separator, or `file` -/// when that is no plain file name. -pub(crate) fn basename(path: &str) -> &str { - let name = path.rsplit(['/', '\\']).next().unwrap_or_default(); +/// 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 { @@ -194,10 +206,23 @@ fn windows_device(name: &str) -> bool { /// 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") +} + +/// `local`'s name with a serial number unique in this process and +/// `extension` added, in `local`'s folder. +fn beside(local: &Path, extension: &str) -> PathBuf { static SERIAL: AtomicU64 = AtomicU64::new(0); let serial = SERIAL.fetch_add(1, Ordering::Relaxed); let mut name = local.file_name().unwrap_or_default().to_owned(); - name.push(format!(".{serial}.part")); + name.push(format!(".{serial}.{extension}")); local.with_file_name(name) } diff --git a/crates/herdr-gpui/src/remote_files/tests.rs b/crates/herdr-gpui/src/remote_files/tests.rs index e4cd5787e..3d10fb6e5 100644 --- a/crates/herdr-gpui/src/remote_files/tests.rs +++ b/crates/herdr-gpui/src/remote_files/tests.rs @@ -6,6 +6,7 @@ use std::sync::Mutex; mod answers; mod dismissed_conflict; mod fetch_meta; +mod fetch_swap; mod fetches; mod saves; mod windows_names; @@ -95,7 +96,7 @@ impl FakeHost { let replies = self.push_replies.clone(); let received = self.received.clone(); Io { - renamed: Arc::new(|_| {}), + step: Arc::new(|_, _| {}), cache: Arc::new(move || Ok(cache.clone())), fetch: Arc::new(move |remote, out| { asked.lock().unwrap().push(remote.clone()); @@ -187,10 +188,12 @@ fn window<'a>( #[test] fn a_copy_is_named_after_the_host_s_file() { - assert_eq!(basename(r"C:\dev\notes.md"), "notes.md"); - assert_eq!(basename("/home/me/src/main.rs"), "main.rs"); - assert_eq!(basename(r"~\a/b\Makefile"), "Makefile"); - assert_eq!(basename("README"), "README"); + 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\", @@ -201,27 +204,40 @@ fn a_copy_is_named_after_the_host_s_file() { "~", ] { let expected = if odd == "~" { "~" } else { "file" }; - assert_eq!(basename(odd), expected, "{odd:?}"); + 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 copy = cache.copy_path(owner, "me@devbox", "/a/notes.md"); + 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"), + 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"), - cache.copy_path(owner, "me@devbox", "/b/notes.md"), - cache.copy_path(another, "me@devbox", "/a/notes.md"), + 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); diff --git a/crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs b/crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs index afa9b8b47..b3975a2dc 100644 --- a/crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs +++ b/crates/herdr-gpui/src/remote_files/tests/fetch_meta.rs @@ -13,7 +13,11 @@ 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.renamed = Arc::new(|local| std::fs::write(local, EDITED).unwrap()); + 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, diff --git a/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs b/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs new file mode 100644 index 000000000..c1699ddff --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs @@ -0,0 +1,230 @@ +//! A repeat fetch that keeps edits, against saves made at each step of +//! putting the host's bytes in place, and the watch runs and conflict +//! refetches that must leave a fetching copy alone. + +use super::*; + +const PATH: &str = "/home/me/notes.md"; +/// Longer than [`NOTES`] and [`NEWER`], so a save changes the copy's length +/// even when the clock has not moved on. +const EDITED: &[u8] = b"# Notes, saved while the fetch ran\n"; +/// What the host holds by the time the copy is fetched again. +const NEWER: &[u8] = b"# Notes, newer\n"; + +/// Window-less files holding a synced copy of `PATH`, fetched from `host`, +/// whose later fetches serve [`NEWER`]. +fn synced(host: &FakeHost) -> (RemoteFiles, Key, PathBuf) { + let mut files = RemoteFiles::default(); + host.serve(&mut files, notes); + let done = fetch_fresh(&files.io, files.owner, &remote(PATH)).unwrap(); + let (key, local) = (done.key.clone(), done.copy.local.clone()); + files.record(done); + host.change_file(NEWER); + (files, key, local) +} + +/// Fetches `PATH` again over `key`'s copy, keeping edits made since it was +/// synced, with `at` run at each step of putting it in place. +fn refetch( + files: &mut RemoteFiles, + key: &Key, + at: impl Fn(Step, &Path) + Send + Sync + 'static, +) -> Result { + files.io.step = Arc::new(at); + let synced = files.copies[key].synced_meta; + fetch_copy(&files.io, files.owner, &remote(PATH), Some(synced)) +} + +/// How many files the folder holding `local` has. +fn entries(local: &Path) -> usize { + std::fs::read_dir(local.parent().unwrap()).unwrap().count() +} + +/// One watch run, done in place. +fn run(files: &mut RemoteFiles) -> Vec<(Key, sync::Action)> { + let seen = watch::scan(files.probes()); + files.apply_scan(seen) +} + +#[test] +fn an_in_place_save_after_the_check_is_kept() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + let fetched = refetch(&mut files, &key, |step, local| { + if step == Step::Checked { + std::fs::write(local, EDITED).unwrap(); + } + }); + assert!(matches!(fetched, Ok(Fetch::KeptEdits))); + assert_eq!(std::fs::read(&local).unwrap(), EDITED); + assert_eq!(entries(&local), 1, "no part or kept file left"); +} + +#[test] +fn a_temp_and_rename_save_after_the_move_aside_is_kept() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + let fetched = refetch(&mut files, &key, |step, local| { + if step == Step::MovedAside { + let temp = local.with_file_name("notes.md.save"); + std::fs::write(&temp, EDITED).unwrap(); + std::fs::rename(&temp, local).unwrap(); + } + }); + assert!(matches!(fetched, Ok(Fetch::KeptEdits))); + assert_eq!(std::fs::read(&local).unwrap(), EDITED); + assert_eq!(entries(&local), 1, "the synced copy moved aside is gone"); +} + +#[test] +fn a_save_through_a_handle_opened_before_the_move_aside_is_kept() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + let handle = Mutex::new(None::); + let fetched = refetch(&mut files, &key, move |step, local| match step { + Step::Checked => { + let file = std::fs::OpenOptions::new().write(true).open(local).unwrap(); + *handle.lock().unwrap() = Some(file); + } + Step::MovedAside => { + let mut file = handle.lock().unwrap().take().unwrap(); + file.write_all(EDITED).unwrap(); + } + Step::Placed => {} + }); + assert!(matches!(fetched, Ok(Fetch::KeptEdits))); + assert_eq!(std::fs::read(&local).unwrap(), EDITED); + assert_eq!(entries(&local), 1); +} + +#[test] +fn a_save_after_the_copy_is_placed_asks_to_be_uploaded() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + let fetched = refetch(&mut files, &key, |step, local| { + if step == Step::Placed { + std::fs::write(local, EDITED).unwrap(); + } + }); + let Ok(Fetch::Written(done)) = fetched else { + panic!("the host's bytes were not written"); + }; + assert_eq!( + done.copy.synced_meta.len, + NEWER.len() as u64, + "the synced metadata is the downloaded bytes', not the save's" + ); + assert_eq!(entries(&local), 1); + files.record(*done); + + // Two watch runs settle the save, which then asks to be uploaded. + let mut actions = Vec::new(); + for _ in 0..2 { + actions.extend(run(&mut files)); + } + assert_eq!(actions, vec![(key.clone(), sync::Action::Ask)]); + assert_eq!( + files.copies[&key].state, + SyncState::Asking { + sha256: Sha256::digest(EDITED).into() + } + ); +} + +#[test] +fn a_copy_deleted_before_the_fetch_is_written() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + std::fs::remove_file(&local).unwrap(); + let fetched = refetch(&mut files, &key, |_, _| {}); + assert!(matches!(fetched, Ok(Fetch::Written(_)))); + assert_eq!(std::fs::read(&local).unwrap(), NEWER); + assert_eq!(entries(&local), 1); +} + +#[cfg(windows)] +#[test] +fn a_copy_held_open_without_delete_sharing_is_left_as_it_was() { + use std::os::windows::fs::OpenOptionsExt; + /// `FILE_SHARE_READ | FILE_SHARE_WRITE`, without `FILE_SHARE_DELETE`. + const SHARE_READ_WRITE: u32 = 0x1 | 0x2; + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + let held = std::fs::OpenOptions::new() + .read(true) + .share_mode(SHARE_READ_WRITE) + .open(&local) + .unwrap(); + let fetched = refetch(&mut files, &key, |_, _| {}); + assert!( + matches!(&fetched, Err(Error::Cache { .. })), + "{:?}", + fetched.err() + ); + drop(held); + assert_eq!(std::fs::read(&local).unwrap(), NOTES); + assert_eq!(entries(&local), 1, "no part left"); +} + +#[test] +fn a_copy_being_fetched_is_not_watched_until_its_fetch_ends() { + let host = FakeHost::new(); + let (mut files, key, local) = synced(&host); + std::fs::write(&local, EDITED).unwrap(); + // A run that began before the fetch reports after it started. + let seen = watch::scan(files.probes()); + files.fetching.insert(key.clone()); + assert_eq!(files.apply_scan(seen), vec![]); + assert_eq!(files.copies[&key].watch, watch::Watch::default()); + for _ in 0..2 { + assert_eq!(run(&mut files), vec![]); + } + assert_eq!(files.copies[&key].state, SyncState::Synced); + + files.fetching.remove(&key); + let mut actions = Vec::new(); + for _ in 0..2 { + actions.extend(run(&mut files)); + } + assert_eq!(actions, vec![(key, sync::Action::Ask)]); +} + +#[gpui::test] +fn a_conflict_refetch_holds_its_copy_while_it_runs(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = window(&host, cx); + view.update(cx, |view, cx| view.open_remote_file(remote(PATH), cx)); + cx.run_until_parked(); + let key: Key = ("me@devbox".into(), PATH.into()); + let conflict = SyncState::Conflict { + sha256: Sha256::digest(EDITED).into(), + }; + view.update(cx, |view, _| { + view.remote_files.copies.get_mut(&key).unwrap().state = conflict; + view.remote_files.io = host.io(missing); + }); + + // A refetch that fails lets go of the copy. + view.update(cx, |view, cx| { + view.resolve_remote_conflict(key.clone(), Resolution::Refetch, cx); + assert!(view.remote_files.fetching.contains(&key), "held"); + }); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + assert!(!view.remote_files.fetching.contains(&key), "released"); + assert_eq!(view.remote_files.copies[&key].state, conflict); + }); + + // As does one that succeeds. + view.update(cx, |view, cx| { + view.remote_files.io = host.io(notes); + view.resolve_remote_conflict(key.clone(), Resolution::Refetch, cx); + assert!(view.remote_files.fetching.contains(&key), "held"); + }); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + assert!(!view.remote_files.fetching.contains(&key), "released"); + assert_eq!(view.remote_files.copies[&key].state, SyncState::Synced); + }); + assert_eq!(host.asked().len(), 3); +} diff --git a/crates/herdr-gpui/src/remote_files/tests/windows_names.rs b/crates/herdr-gpui/src/remote_files/tests/windows_names.rs index 0ff50f999..ae61533a5 100644 --- a/crates/herdr-gpui/src/remote_files/tests/windows_names.rs +++ b/crates/herdr-gpui/src/remote_files/tests/windows_names.rs @@ -64,8 +64,27 @@ fn other_clients_keep_every_host_name() { fn the_renamed_copy_still_belongs_to_the_host_s_path() { let root = tempfile::tempdir().unwrap(); let cache = Cache::create(root.path()).unwrap(); - let copy = cache.copy_path(Owner(1), "me@devbox", "/home/me/a:b.txt"); + 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"), "a:b.txt", "the host's name"); + 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 index 5af90bda3..c3dce5dad 100644 --- a/crates/herdr-gpui/src/remote_files/uploads.rs +++ b/crates/herdr-gpui/src/remote_files/uploads.rs @@ -204,7 +204,7 @@ impl HerdrWindow { return self.start_remote_push(key, Push::Beside(n + 1), cx); } Push::Beside(_) if error.is_conflict() => { - let last = super::basename(&remote.path).to_owned(); + let last = super::basename(&remote.path, remote.shell).to_owned(); self.remote_push_failed(&Error::NamesTaken { last }, cx); Event::Conflicted } @@ -226,7 +226,7 @@ impl HerdrWindow { pushed: Pushed, cx: &mut Context, ) { - let name = super::basename(&remote.path); + let name = super::basename(&remote.path, remote.shell); let (event, text) = match push { Push::Beside(_) => ( Event::KeptBoth, @@ -262,6 +262,10 @@ impl HerdrWindow { 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 @@ -281,6 +285,7 @@ impl HerdrWindow { 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. @@ -293,7 +298,7 @@ impl HerdrWindow { }; let text = format!( "Your copy of {} now holds the version on {}", - super::basename(&key.1), + super::basename(&key.1, done.copy.shell), key.0 ); self.remote_files.record(done); From 6a31f50217d21062de3162170da147db38c8e392 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Sun, 18 Oct 2026 21:47:26 -0700 Subject: [PATCH 10/19] fix(security): never write an SSH upload through a planted link The POSIX push script checked the target for a symbolic link, then reopened it by name: for its temporary file, for chgrp and chmod, and for the in-place write it falls back to when a file's ACL cannot be kept. Another user who can rename entries in the folder could swap in a link between the check and the write, so the upload landed in a different file the SSH account can write. The script now: - enters the target's folder once with cd -P and names the target relative to it, so no later step re-resolves the folder's path; - builds the copy in a private mktemp -d folder inside it, checked to be the SSH user's drwx------ folder within the anchored one, with every file in it created by mktemp, so nothing that already exists is opened or replaced, and moves it over the target with mv -T (GNU) or mv -h (BSD) where mv takes one; - writes in place only in a folder no one else can replace files in (owned by the SSH user or root, no ACL, and not writable by group or others unless sticky with the target the user's or root's), and otherwise refuses with a new exit code, shown as "Other users can replace files in that folder on the host, so it was not written in place; nothing was changed."; - never writes in place for Keep both, and re-checks that the new name is still free just before the rename. A push now also needs id, ls and rmdir on the host. --- crates/herdr-client/src/remote_file.rs | 24 +- crates/herdr-client/src/remote_file/posix.rs | 231 ++++++++++++++---- .../src/remote_file/tests/posix_exec.rs | 211 ++++++++++++++-- .../src/remote_file/tests/scripts.rs | 31 ++- crates/herdr-gpui/src/remote_files.rs | 5 + crates/herdr-gpui/src/remote_files/push.rs | 3 + .../src/remote_files/tests/saves.rs | 25 ++ 7 files changed, 456 insertions(+), 74 deletions(-) diff --git a/crates/herdr-client/src/remote_file.rs b/crates/herdr-client/src/remote_file.rs index 992fb6c4c..f413e5d18 100644 --- a/crates/herdr-client/src/remote_file.rs +++ b/crates/herdr-client/src/remote_file.rs @@ -29,7 +29,14 @@ //! 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. +//! 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 @@ -85,6 +92,11 @@ const SHORT_EXIT: i32 = 70; /// 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; @@ -151,6 +163,11 @@ pub enum Error { #[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))] @@ -223,7 +240,9 @@ pub fn fetch_remote_file( /// Errors: /// [`crate::Error::RemoteFile`] for a refusal, a conflict, a transfer /// that was not `len` bytes ([`Error::Incomplete`]), access it cannot -/// keep ([`Error::Access`]), or a failed script, or a target or platform +/// 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<'_>, @@ -308,6 +327,7 @@ fn classify(error: crate::Error, limit: Option) -> Error { (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)), } diff --git a/crates/herdr-client/src/remote_file/posix.rs b/crates/herdr-client/src/remote_file/posix.rs index 76ff0f68d..b3f3449fb 100644 --- a/crates/herdr-client/src/remote_file/posix.rs +++ b/crates/herdr-client/src/remote_file/posix.rs @@ -1,11 +1,11 @@ //! The `/bin/sh` scripts. They use only POSIX shell, `wc`, `cat`, `mktemp`, -//! `mv`, and `rm`, plus a SHA-256 tool when a push expects a hash, and `stat`, -//! `ls`, `getfacl` where there is one, `chgrp`, and `chmod` to keep a file's -//! access. +//! `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, - SHORT_EXIT, SYMLINK_EXIT, TOO_LARGE_EXIT, hex, + SHARED_FOLDER_EXIT, SHORT_EXIT, SYMLINK_EXIT, TOO_LARGE_EXIT, hex, }; use crate::script::shell_quote; @@ -43,16 +43,110 @@ exec cat < "$path""#, ) } -/// Refuses a symbolic link or a non-regular file at the path. A push runs it -/// before and after the copy, so a path that changed kind meanwhile is still -/// refused. -fn refuse_unsafe() -> String { +/// 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 "$path" ]; then exit {SYMLINK_EXIT}; fi -if [ -e "$path" ] && [ ! -f "$path" ]; then exit {NOT_A_FILE_EXIT}; fi"# + 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. +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------*) ;; *) 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 { @@ -69,18 +163,21 @@ else exit {NO_HASHER_EXIT}; fi"# fn expect_check(expect: Expect) -> String { match expect { Expect::Sha256(digest) => format!( - r#"if [ ! -f "$path" ]; then exit {CONFLICT_EXIT}; fi -sum=$($hasher < "$path") || exit 1 + 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 => { - format!(r#"if [ -e "$path" ] || [ -L "$path" ]; then exit {CONFLICT_EXIT}; fi"#) - } + 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, @@ -99,53 +196,66 @@ if [ "${{sum%% *}}" != '{hex}' ]; then exit {CONFLICT_EXIT}; fi"#, /// /// 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; a target the SSH -/// user cannot write is refused with [`ACCESS_EXIT`] 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() -> String { +/// 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 "$path" ]; then like=$path; else like=$source; fi + 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 -- "$tmp" 2>/dev/null) || tmp_group=$(stat -f %g -- "$tmp" 2>/dev/null) || tmp_group= +tmp_group=$(stat -c %g -- "$copy" 2>/dev/null) || tmp_group=$(stat -f %g -- "$copy" 2>/dev/null) || tmp_group= if [ -n "$mode" ] && [ -n "$group" ]; then keep=1; fi if [ -L "$like" ] || [ ! -f "$like" ]; then keep=; fi -for listed in "$like" "$tmp"; do +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" "$tmp" 2>/dev/null; then +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=; fi fi fi if [ -n "$keep" ]; then -chmod -- "$mode" "$tmp" || exit 1 -elif [ -e "$path" ]; then -if [ ! -w "$path" ]; then exit {ACCESS_EXIT}; fi -cat < "$tmp" > "$path" || exit 1 +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 )))" "$tmp" || exit 1 +chmod -- "$(printf '%o' $(( 0666 & ~0$old_umask )))" "$copy" || exit 1 fi"# ) } -/// Copies stdin to a private temporary file beside the target, refuses it -/// 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. When that access cannot be -/// kept, it writes over an existing target in place instead (see -/// [`keep_access`]). The trap removes the copy on every exit that did not -/// rename it. +/// 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); @@ -157,12 +267,16 @@ pub(super) fn push_script(path: &str, expect: Expect, access: Access<'_>, len: u 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} +{refuse_path} {hasher} case $path in */*) dir=${{path%/*}} ;; @@ -170,20 +284,43 @@ case $path in esac if [ -z "$dir" ]; then dir=/; fi name=${{path##*/}} -tmp=$(mktemp "$dir/.$name.XXXXXX") || exit 1 -trap 'rm -f -- "$tmp"' EXIT -cat > "$tmp" || exit 1 -size=$(wc -c < "$tmp") || exit 1 +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 "./.$name.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} +{refuse_target} {check} {keep} -mv -f -- "$tmp" "$path" || exit 1 -trap - EXIT"#, +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 = refuse_unsafe(), + refuse_path = refuse_unsafe("path"), + refuse_target = refuse_unsafe("target"), check = expect_check(expect), - keep = keep_access(), + enter = enter_work_folder(), + keep = keep_access(expect), ) } diff --git a/crates/herdr-client/src/remote_file/tests/posix_exec.rs b/crates/herdr-client/src/remote_file/tests/posix_exec.rs index 291560bb2..b01bfaf50 100644 --- a/crates/herdr-client/src/remote_file/tests/posix_exec.rs +++ b/crates/herdr-client/src/remote_file/tests/posix_exec.rs @@ -90,10 +90,16 @@ impl Fixture { /// 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. + fn shim(&mut self, tool: &str, body: &str) -> tempfile::TempDir { let bin = tempfile::tempdir().unwrap(); - let chgrp = bin.path().join("chgrp"); - std::fs::write(&chgrp, "#!/bin/sh\nexit 1\n").unwrap(); - std::fs::set_permissions(&chgrp, std::fs::Permissions::from_mode(0o755)).unwrap(); + let shim = bin.path().join(tool); + std::fs::write(&shim, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(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)); @@ -421,23 +427,21 @@ fn push_like_a_link_or_folder_leaves_the_new_file_private() { } } +/// 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_default_acl_overwrites_in_place() { - use std::os::unix::fs::MetadataExt; +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 before = std::fs::metadata(&file).unwrap(); - fixture.push(&file, Expect::Any, b"new").unwrap(); - let after = std::fs::metadata(&file).unwrap(); - assert_eq!(std::fs::read(&file).unwrap(), b"new"); - assert_eq!(after.ino(), before.ino(), "the file was written in place"); - let acl = output_of("getfacl", &["--omit-header", &file]); - assert!(!acl.contains("nobody"), "{acl}"); + 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"]); } @@ -508,7 +512,7 @@ fn push_cut_off_short_of_its_length_replaces_nothing() { 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(&["cat", "chmod", "mktemp", "mv", "rm", "wc"]); + 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:?}"); @@ -516,32 +520,48 @@ fn push_without_stat_still_refuses_a_cut_off_transfer() { 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", +]; + +/// Where this machine keeps `tool`. +fn real_tool(tool: &str) -> std::path::PathBuf { + ["/bin", "/usr/bin"] + .iter() + .map(|folder| Path::new(folder).join(tool)) + .find(|candidate| candidate.exists()) + .unwrap() +} + /// 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 { - let found = ["/bin", "/usr/bin"] - .iter() - .map(|folder| Path::new(folder).join(tool)) - .find(|candidate| candidate.exists()) - .unwrap(); - symlink(found, bin.path().join(tool)).unwrap(); + 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); - let bin = tool_folder(&["cat", "chmod", "mktemp", "mv", "rm", "wc"]); + // 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"]); } @@ -603,3 +623,154 @@ fn push_of_unread_output_is_a_script_failure() { 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"]); +} diff --git a/crates/herdr-client/src/remote_file/tests/scripts.rs b/crates/herdr-client/src/remote_file/tests/scripts.rs index 99e14965b..e25ae3ba4 100644 --- a/crates/herdr-client/src/remote_file/tests/scripts.rs +++ b/crates/herdr-client/src/remote_file/tests/scripts.rs @@ -43,8 +43,9 @@ fn posix_scripts_never_pass_the_path_where_it_could_be_an_option() { 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 < \"$path\"")); - assert!(push.contains("mv -f -- \"$tmp\" \"$path\"")); + assert!(push.contains("$hasher < \"$target\"")); + assert!(push.contains("mv -f $no_follow -- \"$copy\" \"$target\"")); + assert!(push.contains("\ntarget=../$name\n")); } #[test] @@ -62,7 +63,7 @@ fn posix_push_consults_a_hasher_only_for_an_expected_hash() { 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 < \"$path\"").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) @@ -87,10 +88,24 @@ fn posix_push_names_where_a_new_files_access_comes_from() { #[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 \"$path\" ]; then exit 71; fi") + .find("if [ ! -w \"$target\" ]; then exit 71; fi") .unwrap(); - assert!(checked < script.find("cat < \"$tmp\" > \"$path\"").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" @@ -243,6 +258,7 @@ fn exit_codes_map_to_refusals() { 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)), @@ -301,6 +317,11 @@ fn messages_are_short_and_plain() { 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> { diff --git a/crates/herdr-gpui/src/remote_files.rs b/crates/herdr-gpui/src/remote_files.rs index 6e9f2e172..697b7c980 100644 --- a/crates/herdr-gpui/src/remote_files.rs +++ b/crates/herdr-gpui/src/remote_files.rs @@ -60,6 +60,11 @@ pub enum Error { 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 }, // keep-sorted end diff --git a/crates/herdr-gpui/src/remote_files/push.rs b/crates/herdr-gpui/src/remote_files/push.rs index a065ab462..22d04febc 100644 --- a/crates/herdr-gpui/src/remote_files/push.rs +++ b/crates/herdr-gpui/src/remote_files/push.rs @@ -81,6 +81,9 @@ pub(crate) fn push_copy( 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. diff --git a/crates/herdr-gpui/src/remote_files/tests/saves.rs b/crates/herdr-gpui/src/remote_files/tests/saves.rs index ee0ba0232..2b34b76fe 100644 --- a/crates/herdr-gpui/src/remote_files/tests/saves.rs +++ b/crates/herdr-gpui/src/remote_files/tests/saves.rs @@ -437,6 +437,31 @@ fn a_push_that_cannot_keep_the_file_s_access_says_nothing_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(); From 6946bc9cec8299a0dad294cb2bf6a76ffb757026 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Mon, 19 Oct 2026 11:51:27 -0700 Subject: [PATCH 11/19] fix(security): never carry set-user-ID bits to a new owner A POSIX push renames a copy owned by the SSH user over the target, so a target another account owned becomes the SSH user's. The copy kept the target's mode, set-user-ID bit included: as root, pushing over another account's 4755 file produced a root-owned setuid executable that runs as root for everyone. Keep the set-user-ID bit only when the copy's owner matches the file whose access it keeps; an unreadable owner on either side drops it. Drop the set-group-ID bit when chgrp fails and the copy keeps its own group. Sticky stays: on a regular file it names no owner or group. --- crates/herdr-client/src/remote_file.rs | 11 +- crates/herdr-client/src/remote_file/posix.rs | 18 ++- .../src/remote_file/tests/posix_exec.rs | 116 ++++++++++++++++++ 3 files changed, 140 insertions(+), 5 deletions(-) diff --git a/crates/herdr-client/src/remote_file.rs b/crates/herdr-client/src/remote_file.rs index f413e5d18..8b3513df9 100644 --- a/crates/herdr-client/src/remote_file.rs +++ b/crates/herdr-client/src/remote_file.rs @@ -23,10 +23,13 @@ //! 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. 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 +//! 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 diff --git a/crates/herdr-client/src/remote_file/posix.rs b/crates/herdr-client/src/remote_file/posix.rs index b3f3449fb..cb2b123f1 100644 --- a/crates/herdr-client/src/remote_file/posix.rs +++ b/crates/herdr-client/src/remote_file/posix.rs @@ -186,6 +186,12 @@ fn absent_check() -> String { /// 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` @@ -218,6 +224,9 @@ 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 @@ -228,10 +237,17 @@ 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=; fi +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} diff --git a/crates/herdr-client/src/remote_file/tests/posix_exec.rs b/crates/herdr-client/src/remote_file/tests/posix_exec.rs index b01bfaf50..9857d1542 100644 --- a/crates/herdr-client/src/remote_file/tests/posix_exec.rs +++ b/crates/herdr-client/src/remote_file/tests/posix_exec.rs @@ -289,6 +289,122 @@ fn push_keeps_an_existing_files_group() { 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; From 7be7074205d4eed847e9e31079bd64e9065558b2 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Thu, 22 Oct 2026 04:07:56 -0700 Subject: [PATCH 12/19] fix: ask for another path when a clicked SSH file is not on the host A path printed up to a pane's last column joins the next row's text, because the frame carries no soft-wrap flag. In an SSH pane, where a path counts by its shape alone, a click could then fetch a path that never existed, and a mistyped or moved path ended in a flash. A click whose file is not on the host now opens a dialog with the path in an editable field. When the click read across a raw wrap, the field holds the first row's part instead. Open fetches what the field holds, resolving a relative path under the click's working directory, and asks again if that path is missing too. The click's query, fragment or line are kept only when the field is unchanged. Cancel or Escape opens nothing. Other fetch errors still show their flash. --- crates/herdr-gpui/src/menu/chrome.rs | 3 + crates/herdr-gpui/src/menu/chrome/keys.rs | 7 + crates/herdr-gpui/src/menu/page.rs | 3 + crates/herdr-gpui/src/menu/page_title.rs | 1 + .../src/menu/remote_file_dialogs.rs | 137 ++++++++++- crates/herdr-gpui/src/menu/state.rs | 4 + crates/herdr-gpui/src/remote_files.rs | 76 +++++- crates/herdr-gpui/src/remote_files/tests.rs | 1 + .../src/remote_files/tests/answers.rs | 8 +- .../remote_files/tests/dismissed_conflict.rs | 4 +- .../src/remote_files/tests/fetch_swap.rs | 4 +- .../src/remote_files/tests/fetches.rs | 16 +- .../src/remote_files/tests/missing_dialog.rs | 232 ++++++++++++++++++ .../src/remote_files/tests/queries.rs | 2 +- crates/herdr-gpui/src/remote_files/uploads.rs | 5 + crates/herdr-gpui/src/terminal/links.rs | 16 ++ .../src/terminal/links/tests/escaped_paths.rs | 1 + .../src/terminal/links/tests/external.rs | 1 + .../src/terminal/links/tests/paths.rs | 3 + .../src/terminal/links/tests/queries.rs | 5 + .../src/terminal/links/tests/wraps.rs | 73 +++++- crates/herdr-gpui/src/terminal/links/wrap.rs | 8 +- crates/herdr-gpui/src/window/file_links.rs | 5 + .../herdr-gpui/src/window/file_links/tests.rs | 1 + .../src/window/file_links/tests/remote.rs | 11 +- crates/herdr-gpui/src/window/links.rs | 3 +- crates/herdr-gpui/src/window/remote_paths.rs | 27 ++ .../src/window/remote_paths/tests.rs | 1 + 28 files changed, 628 insertions(+), 30 deletions(-) create mode 100644 crates/herdr-gpui/src/remote_files/tests/missing_dialog.rs diff --git a/crates/herdr-gpui/src/menu/chrome.rs b/crates/herdr-gpui/src/menu/chrome.rs index 4ab5197e7..472d79b5f 100644 --- a/crates/herdr-gpui/src/menu/chrome.rs +++ b/crates/herdr-gpui/src/menu/chrome.rs @@ -510,6 +510,7 @@ impl HerdrWindow { | Page::OpenCheckout | Page::OpenLink | Page::RemoteConflict + | Page::RemoteMissing | Page::RemoteUpload ) || self.cloud_batch_open(), |panel| { @@ -683,6 +684,8 @@ impl HerdrWindow { 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::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 a6730e1c0..d6a658e81 100644 --- a/crates/herdr-gpui/src/menu/chrome/keys.rs +++ b/crates/herdr-gpui/src/menu/chrome/keys.rs @@ -128,6 +128,10 @@ 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 matches!( self.menu.page, Some(Page::Tab | Page::RenameTab | Page::MoveTab) @@ -352,6 +356,9 @@ impl HerdrWindow { "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::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 e07bbf87b..fb71a99db 100644 --- a/crates/herdr-gpui/src/menu/page.rs +++ b/crates/herdr-gpui/src/menu/page.rs @@ -76,6 +76,9 @@ pub(crate) enum Page { /// A remote file's host changed since its copy was synced, so a save /// was not uploaded. RemoteConflict, + /// 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, diff --git a/crates/herdr-gpui/src/menu/page_title.rs b/crates/herdr-gpui/src/menu/page_title.rs index f0a5e473a..caa322222 100644 --- a/crates/herdr-gpui/src/menu/page_title.rs +++ b/crates/herdr-gpui/src/menu/page_title.rs @@ -75,6 +75,7 @@ impl Page { Self::PrMerge => "Merge pull request", Self::PrReview => "Pull request", Self::RemoteConflict => "Remote file changed", + Self::RemoteMissing => "Remote file not found", Self::RemoteUpload => "Upload changes", Self::RemoveDevice => "Remove device", Self::RemoveWsl => "Remove WSL distribution", diff --git a/crates/herdr-gpui/src/menu/remote_file_dialogs.rs b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs index 67e6ff021..c6b5db8d8 100644 --- a/crates/herdr-gpui/src/menu/remote_file_dialogs.rs +++ b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs @@ -1,16 +1,147 @@ //! The questions a save to a remote file's copy asks: whether to upload it, -//! and what to do when the host's file changed since it was synced. They -//! open only when no other page holds the window, and wait until then. +//! 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, Prompt, PromptKind, Resolution, basename}, + 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 = 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(&missing.prefill, cx); + window.focus(&input.focus, cx); + }); + self.menu.remote_missing = Some(MissingDialog { missing, input }); + self.menu.page = Some(Page::RemoteMissing); + cx.notify(); + true + } + + /// 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); + } + + /// The file the field names, 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. + fn remote_missing_choice( + &self, + cx: &App, + ) -> Option<(RemotePath, Option, Option)> { + let dialog = self.menu.remote_missing.as_ref()?; + let input = dialog.input.read(cx); + let text = input.text().trim(); + if input.is_composing() || text.is_empty() { + return None; + } + let missing = &dialog.missing; + let cwd = missing.cwd.clone(); + if text == missing.remote.path { + return Some((missing.remote.clone(), missing.query.clone(), cwd)); + } + let remote = RemotePath::typed(&missing.remote.target, text, cwd.as_deref())?; + Some((remote, None, cwd)) + } + + /// 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? { diff --git a/crates/herdr-gpui/src/menu/state.rs b/crates/herdr-gpui/src/menu/state.rs index bcd606809..e2e31d1e9 100644 --- a/crates/herdr-gpui/src/menu/state.rs +++ b/crates/herdr-gpui/src/menu/state.rs @@ -70,6 +70,8 @@ pub(crate) struct MenuState { pub(super) open_link: Option, /// The copy the remote file questions are about. pub(super) remote_file: 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. @@ -329,6 +331,7 @@ impl MenuState { open_checkout: None, open_link: None, remote_file: None, + remote_missing: None, #[cfg(feature = "cloud")] cloud_create: None, #[cfg(feature = "cloud")] @@ -406,6 +409,7 @@ impl MenuState { self.open_checkout = None; self.open_link = None; self.remote_file = 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 index a7a62914b..d5aa28dec 100644 --- a/crates/herdr-gpui/src/remote_files.rs +++ b/crates/herdr-gpui/src/remote_files.rs @@ -81,6 +81,16 @@ impl Error { )) ) } + + /// 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; @@ -213,6 +223,29 @@ pub(crate) struct Prompt { 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, + /// The path the question offers to edit: the first row's own path when + /// a raw wrap joined the next row's text to the click's, else the + /// click's. + pub(crate) prefill: String, +} + +/// What a click asked for besides the host's file. +struct Click { + query: Option, + unjoined: 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)] @@ -238,6 +271,8 @@ pub(crate) struct RemoteFiles { 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, @@ -716,10 +751,16 @@ impl HerdrWindow { /// 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`. A file the host does not have + /// queues the question that offers that path, or `remote`'s, to edit; + /// a relative path typed there is under `cwd`. pub(crate) fn open_remote_file( &mut self, remote: RemotePath, query: Option, + unjoined: Option, + cwd: Option, cx: &mut Context, ) { if !self.remote_files.fetch_again(&remote) { @@ -748,14 +789,20 @@ impl HerdrWindow { .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, &remote, synced, page_query.as_deref()) }); + .spawn(async move { fetch_copy(&io, owner, &asked, synced, page_query.as_deref()) }); + let click = Click { + query, + unjoined, + 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(&key, query, fetched, cx) + this.remote_file_fetched(remote, click, fetched, cx) }); }) .detach(); @@ -763,18 +810,19 @@ impl HerdrWindow { fn remote_file_fetched( &mut self, - key: &Key, - query: Option, + remote: RemotePath, + click: Click, fetched: Result, cx: &mut Context, ) { - self.remote_files.fetching.remove(key); + 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(), query, cx); + 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)) => { @@ -785,6 +833,20 @@ impl HerdrWindow { 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"); + let prefill = click + .unjoined + .map_or_else(|| remote.path.clone(), |unjoined| unjoined.path); + self.remote_files.missing.push_back(Missing { + remote, + query: click.query, + cwd: click.cwd, + prefill, + }); + cx.notify(); + } Err(error) => { tracing::warn!(%error, "Remote file not fetched"); self.show_flash(Flash::warning(crate::Error::from(error).to_string()), cx); diff --git a/crates/herdr-gpui/src/remote_files/tests.rs b/crates/herdr-gpui/src/remote_files/tests.rs index a0409805f..68a373c64 100644 --- a/crates/herdr-gpui/src/remote_files/tests.rs +++ b/crates/herdr-gpui/src/remote_files/tests.rs @@ -8,6 +8,7 @@ mod dismissed_conflict; mod fetch_meta; mod fetch_swap; mod fetches; +mod missing_dialog; mod queries; mod saves; mod windows_names; diff --git a/crates/herdr-gpui/src/remote_files/tests/answers.rs b/crates/herdr-gpui/src/remote_files/tests/answers.rs index 12dd92923..1f9a973cb 100644 --- a/crates/herdr-gpui/src/remote_files/tests/answers.rs +++ b/crates/herdr-gpui/src/remote_files/tests/answers.rs @@ -18,7 +18,9 @@ fn poll(view: &Entity, cx: &mut VisualTestContext) { 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, 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, _| { @@ -54,7 +56,9 @@ fn saving_the_synced_bytes_back_closes_the_open_upload_question(cx: &mut TestApp 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, 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| { diff --git a/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs b/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs index f2bd99aec..53ee6c8f0 100644 --- a/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs +++ b/crates/herdr-gpui/src/remote_files/tests/dismissed_conflict.rs @@ -19,7 +19,9 @@ fn poll(view: &Entity, cx: &mut VisualTestContext) { /// 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, cx)); + view.update(cx, |view, cx| { + view.open_remote_file(remote(PATH), None, None, None, cx) + }); cx.run_until_parked(); } diff --git a/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs b/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs index 823dec5c7..af66bb7fe 100644 --- a/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs +++ b/crates/herdr-gpui/src/remote_files/tests/fetch_swap.rs @@ -193,7 +193,9 @@ fn a_copy_being_fetched_is_not_watched_until_its_fetch_ends() { 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, 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 { diff --git a/crates/herdr-gpui/src/remote_files/tests/fetches.rs b/crates/herdr-gpui/src/remote_files/tests/fetches.rs index 181c2af7f..64f4f38fa 100644 --- a/crates/herdr-gpui/src/remote_files/tests/fetches.rs +++ b/crates/herdr-gpui/src/remote_files/tests/fetches.rs @@ -10,14 +10,16 @@ fn a_click_while_its_file_is_fetched_starts_no_second_fetch(cx: &mut TestAppCont let host = FakeHost::new(); let (view, cx) = window(&host, cx); view.update(cx, |view, cx| { - view.open_remote_file(remote(PATH), None, cx); - view.open_remote_file(remote(PATH), None, 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, cx)); + 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); } @@ -27,9 +29,13 @@ 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, cx)); + 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, cx)); + 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); } 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..146db5c24 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/missing_dialog.rs @@ -0,0 +1,232 @@ +//! 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"; + +fn posix(path: &str) -> RemotePath { + RemotePath { + target: "me@devbox".into(), + shell: RemoteShell::Posix, + path: path.into(), + line: None, + } +} + +/// 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, + )) +} + +/// 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) +} + +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); +} + +#[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 a_missing_joined_path_offers_the_first_row_s_part(cx: &mut TestAppContext) { + let host = FakeHost::new(); + let (view, cx) = on_empty_host(&host, cx); + let joined = posix("/home/me/gone.mdnext-row.txt"); + click(&view, cx, joined.clone(), Some(posix(PATH)), Some(CWD)); + assert_eq!(host.asked(), vec![joined]); + assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); + assert_eq!(field(&view, cx).as_deref(), Some(PATH)); +} + +#[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 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 index f67b48965..2de1ab965 100644 --- a/crates/herdr-gpui/src/remote_files/tests/queries.rs +++ b/crates/herdr-gpui/src/remote_files/tests/queries.rs @@ -13,7 +13,7 @@ fn key() -> Key { /// 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()), cx) + view.open_remote_file(remote(PAGE), Some("?a=1#top".into()), None, None, cx) }); cx.run_until_parked(); } diff --git a/crates/herdr-gpui/src/remote_files/uploads.rs b/crates/herdr-gpui/src/remote_files/uploads.rs index bb462d347..ad3658432 100644 --- a/crates/herdr-gpui/src/remote_files/uploads.rs +++ b/crates/herdr-gpui/src/remote_files/uploads.rs @@ -46,6 +46,11 @@ impl HerdrWindow { return; } let Some(prompt) = self.remote_files.next_prompt() else { + 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) { diff --git a/crates/herdr-gpui/src/terminal/links.rs b/crates/herdr-gpui/src/terminal/links.rs index d0760e90e..2be30b778 100644 --- a/crates/herdr-gpui/src/terminal/links.rs +++ b/crates/herdr-gpui/src/terminal/links.rs @@ -39,6 +39,7 @@ fn explicit_target(destination: &str) -> RowTarget { line: query.as_deref().and_then(path::fragment_line), query, explicit: true, + unjoined: None, }; let url = match url::Url::parse(destination) { Ok(url) => url, @@ -114,6 +115,7 @@ fn plain_address(text: &str) -> Option { line, query, explicit: false, + unjoined: None, }), _ => None, } @@ -136,6 +138,10 @@ pub(crate) enum RowTarget { /// `?` 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. @@ -340,11 +346,21 @@ impl LinkReading { } 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) } 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 2c7c6ac53..3550a652f 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/escaped_paths.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/escaped_paths.rs @@ -47,6 +47,7 @@ fn every_cell_of_a_pasted_path_links_to_the_original_name() { line: None, query: None, explicit: false, + unjoined: None, }, "{text} at {column}" ); diff --git a/crates/herdr-gpui/src/terminal/links/tests/external.rs b/crates/herdr-gpui/src/terminal/links/tests/external.rs index f45e139b4..03e9cce85 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/external.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/external.rs @@ -129,6 +129,7 @@ fn a_plain_file_address_is_a_path_to_look_for() { 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 242932885..1056cf622 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/paths.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/paths.rs @@ -25,6 +25,7 @@ fn bare(path: &str) -> RowTarget { line: None, query: None, explicit: false, + unjoined: None, } } @@ -112,6 +113,7 @@ fn paths_are_pane_links_only_and_never_web_links() { line: Some(4), query: None, explicit: false, + unjoined: None, } ); assert_eq!(link.link.rows, vec![(0, 5..17)]); @@ -168,6 +170,7 @@ fn a_hyperlink_names_a_path_by_a_file_address_or_without_a_scheme() { 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 index bf78a6826..4af2d8d9f 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/queries.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/queries.rs @@ -140,6 +140,7 @@ fn a_query_wrapped_raw_onto_the_next_row_joins_its_path() { line: None, query: Some("?sheet=1".into()), explicit: false, + unjoined: None, } ); assert_eq!(link.rows, vec![(0, 4..24), (1, 0..5)]); @@ -207,6 +208,7 @@ fn a_file_address_keeps_its_query_and_fragment_in_text_and_hyperlinks() { 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(); @@ -228,6 +230,7 @@ fn a_file_address_keeps_its_query_and_fragment_in_text_and_hyperlinks() { line: None, query: Some("?x=1".into()), explicit: true, + unjoined: None, }) ); // A query longer than an address may carry is refused. @@ -252,6 +255,7 @@ fn a_file_address_or_hyperlink_takes_its_line_from_the_fragment() { 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(); @@ -271,6 +275,7 @@ fn a_file_address_or_hyperlink_takes_its_line_from_the_fragment() { 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 index c9aa6d0e2..d89b24f38 100644 --- a/crates/herdr-gpui/src/terminal/links/tests/wraps.rs +++ b/crates/herdr-gpui/src/terminal/links/tests/wraps.rs @@ -36,10 +36,33 @@ fn file(path: &str, line: Option, rows: &[(u16, Range)]) -> Option<(Ro 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); @@ -51,7 +74,12 @@ fn a_raw_wrap_joins_an_address_read_from_either_row() { #[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 = file("src/very/long/file.rs", Some(12), &[(0, 6..20), (1, 0..10)]); + 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); @@ -90,10 +118,51 @@ fn a_wide_glyph_that_did_not_fit_continues_on_the_next_row() { wrapped.cells[21].skip = true; assert_eq!( read(&wrapped, 5, 0), - file("src/aaaaaaaaaaaa界.rs", None, &[(0, 3..19), (1, 0..5)]) + 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); diff --git a/crates/herdr-gpui/src/terminal/links/wrap.rs b/crates/herdr-gpui/src/terminal/links/wrap.rs index f46c43cee..db36f614a 100644 --- a/crates/herdr-gpui/src/terminal/links/wrap.rs +++ b/crates/herdr-gpui/src/terminal/links/wrap.rs @@ -90,9 +90,15 @@ impl Joined { /// 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() - .any(|&join| range.start < join && join < range.end) + .copied() + .find(|&join| range.start < join && join < range.end) } /// Whether the word holding `range` is cut off with an ellipsis, the way diff --git a/crates/herdr-gpui/src/window/file_links.rs b/crates/herdr-gpui/src/window/file_links.rs index 7243ae3da..0ae505302 100644 --- a/crates/herdr-gpui/src/window/file_links.rs +++ b/crates/herdr-gpui/src/window/file_links.rs @@ -62,6 +62,9 @@ pub(crate) struct FileLink { /// 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. @@ -348,6 +351,7 @@ impl HerdrWindow { line, query, explicit, + unjoined, } = link.target else { return None; @@ -364,6 +368,7 @@ impl HerdrWindow { cwd, explicit, remote: self.ssh_target(), + unjoined, }) } diff --git a/crates/herdr-gpui/src/window/file_links/tests.rs b/crates/herdr-gpui/src/window/file_links/tests.rs index 9e9c45012..641640245 100644 --- a/crates/herdr-gpui/src/window/file_links/tests.rs +++ b/crates/herdr-gpui/src/window/file_links/tests.rs @@ -26,6 +26,7 @@ fn paths_resolve_under_home_or_the_pane_directory() { 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)), diff --git a/crates/herdr-gpui/src/window/file_links/tests/remote.rs b/crates/herdr-gpui/src/window/file_links/tests/remote.rs index da011f1a4..f18398f2e 100644 --- a/crates/herdr-gpui/src/window/file_links/tests/remote.rs +++ b/crates/herdr-gpui/src/window/file_links/tests/remote.rs @@ -92,20 +92,19 @@ fn a_home_path_on_a_posix_host_is_left_for_it_to_expand(cx: &mut gpui::TestAppCo } #[gpui::test] -fn a_failed_fetch_says_why_and_opens_nothing(cx: &mut gpui::TestAppContext) { +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_eq!(flash.tone, Tone::Warning); - assert_eq!( - flash.text.as_ref(), - "Could not fetch the file: the file is not on the host" - ); + 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/links.rs b/crates/herdr-gpui/src/window/links.rs index c737c2aeb..83c7b0414 100644 --- a/crates/herdr-gpui/src/window/links.rs +++ b/crates/herdr-gpui/src/window/links.rs @@ -679,7 +679,8 @@ impl HerdrWindow { // host's file always opens in the system's application. Some(Fallback::File { link, .. }) if link.remote.is_some() => { if let Some(remote) = RemotePath::from_link(&link) { - self.open_remote_file(remote, link.query, cx); + 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), diff --git a/crates/herdr-gpui/src/window/remote_paths.rs b/crates/herdr-gpui/src/window/remote_paths.rs index e1bee58e6..eb0a4239e 100644 --- a/crates/herdr-gpui/src/window/remote_paths.rs +++ b/crates/herdr-gpui/src/window/remote_paths.rs @@ -34,6 +34,33 @@ impl RemotePath { }) } + /// 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` names, resolved under `cwd` + /// as a printed path is. `None` when it names none, as a relative path + /// does with no absolute `cwd`. + pub(crate) fn typed(target: &str, path: &str, cwd: Option<&str>) -> Option { + let (shell, path) = resolve(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 { diff --git a/crates/herdr-gpui/src/window/remote_paths/tests.rs b/crates/herdr-gpui/src/window/remote_paths/tests.rs index f1cf00233..c5392e685 100644 --- a/crates/herdr-gpui/src/window/remote_paths/tests.rs +++ b/crates/herdr-gpui/src/window/remote_paths/tests.rs @@ -9,6 +9,7 @@ fn link(path: &str, cwd: Option<&str>) -> FileLink { cwd: cwd.map(Into::into), explicit: false, remote: Some("me@devbox".into()), + unjoined: None, } } From 03f96005a8bc47e443869e3cf57684537c00b1d9 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Thu, 22 Oct 2026 09:02:27 -0700 Subject: [PATCH 13/19] docs: describe the dialog for a missing SSH path The README still said a missing file on an SSH host only shows why nothing opened. It now describes the dialog with the editable path that 7be70742 added. --- crates/herdr-gpui/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/herdr-gpui/README.md b/crates/herdr-gpui/README.md index 413212b40..7e2c3b6b6 100644 --- a/crates/herdr-gpui/README.md +++ b/crates/herdr-gpui/README.md @@ -2308,7 +2308,14 @@ private cache folder and opens the copy, under the same rule as a local file: a document opens in its default application, and anything else opens the folder holding the copy. Windows OpenSSH hosts are reached through Windows PowerShell 5.1 or later, and other hosts through `/bin/sh`. Files up to 64 MB are fetched; -a folder, a larger file, or a missing one shows why nothing opened. +a folder or a larger file shows why nothing opened. + +When the host has no file at the clicked path, a dialog shows the path in an +editable field. If the click read a path across a raw wrap, the field holds the +first row's part instead. **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. The copy is watched for saves. The first save asks whether to upload it: **Upload**, **Upload future saves without asking**, or **Not now**. Each From a2c83a04d94ff64ff811722b1c1bcf7317d0e175 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Thu, 22 Oct 2026 11:03:14 -0700 Subject: [PATCH 14/19] fix: never upload an SSH copy that mixes two saves A push streamed straight from the editable copy. An editor rewriting a large copy in place during a slow upload could make the host receive the start of one save and the end of the next; the length still matched, so the host replaced its file and the app reported a save the user never made. Stage the copy in a private file beside it and send that instead. The copy's metadata is read before and after staging and the stage is redone when it changed, up to three times, after which the push fails as unsettled and the watch picks up the latest save. The staged file is removed when the push ends. --- crates/herdr-gpui/src/remote_files.rs | 2 + crates/herdr-gpui/src/remote_files/cache.rs | 6 + crates/herdr-gpui/src/remote_files/push.rs | 105 +++++++++++++----- .../src/remote_files/tests/saves.rs | 37 +++++- 4 files changed, 121 insertions(+), 29 deletions(-) diff --git a/crates/herdr-gpui/src/remote_files.rs b/crates/herdr-gpui/src/remote_files.rs index 697b7c980..2d4c3296e 100644 --- a/crates/herdr-gpui/src/remote_files.rs +++ b/crates/herdr-gpui/src/remote_files.rs @@ -67,6 +67,8 @@ pub enum Error { 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 } diff --git a/crates/herdr-gpui/src/remote_files/cache.rs b/crates/herdr-gpui/src/remote_files/cache.rs index 396049043..aa84c192c 100644 --- a/crates/herdr-gpui/src/remote_files/cache.rs +++ b/crates/herdr-gpui/src/remote_files/cache.rs @@ -216,6 +216,12 @@ 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") +} + /// `local`'s name with a serial number unique in this process and /// `extension` added, in `local`'s folder. fn beside(local: &Path, extension: &str) -> PathBuf { diff --git a/crates/herdr-gpui/src/remote_files/push.rs b/crates/herdr-gpui/src/remote_files/push.rs index 22d04febc..0fc075cea 100644 --- a/crates/herdr-gpui/src/remote_files/push.rs +++ b/crates/herdr-gpui/src/remote_files/push.rs @@ -1,18 +1,27 @@ -//! Sending a copy back to its host. The push sends exactly the length the -//! copy had when it was stat'ed, 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. +//! 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, sync::Sha256, watch::Meta}; +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, + 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, @@ -53,10 +62,10 @@ pub(crate) struct Pushed { } /// Pushes the copy at `local` to `remote` while the host's file meets -/// `expect`, refusing a copy larger than `limit` bytes. A copy that grows -/// while it is read sends only the length it had when stat'ed, and the -/// watch sees the rest as a later save; one that shrinks fails the push. A -/// new file on the host takes the access `access` names. Blocking. +/// `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, @@ -64,20 +73,11 @@ pub(crate) fn push_copy( (expect, access): (Expect, RemoteFileAccess<'_>), limit: u64, ) -> Result { - let cache = |source| Error::Cache { - path: local.to_owned(), - source, - }; - let file = std::fs::File::open(local).map_err(cache)?; - let meta = file - .metadata() - .and_then(|metadata| Meta::of(&metadata)) - .map_err(cache)?; - if meta.len > limit { - return Err(Error::TooLarge { limit }); - } - let mut reader = Hashing::new(file.take(meta.len)); - let pushed = (io.push)(remote, expect, access, meta.len, &mut reader); + 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, @@ -87,10 +87,61 @@ pub(crate) fn push_copy( error => Error::Push(error), })?; // The host refuses a short transfer; this holds a push that ignored it. - if sent != meta.len { + if sent != len { return Err(Error::Push(herdr_client::Error::RemoteFile( herdr_client::RemoteFileError::Incomplete, ))); } - Ok(Pushed { meta, sha256 }) + 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 staged = Self { + path: cache::staged_path(local), + meta: before, + }; + let mut file = cache::create_private_file(&staged.path)?; + 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/tests/saves.rs b/crates/herdr-gpui/src/remote_files/tests/saves.rs index 2b34b76fe..10fa0648c 100644 --- a/crates/herdr-gpui/src/remote_files/tests/saves.rs +++ b/crates/herdr-gpui/src/remote_files/tests/saves.rs @@ -319,18 +319,51 @@ fn a_remote_copy_that_grows_during_its_push_sends_the_length_it_had() { 1024, ) .unwrap(); - assert_eq!(host.received()[0].bytes, NOTES, "the stat'ed length only"); + 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_that_shrinks_during_its_push_is_cut_off() { +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), From 1e4f493e4dac72ebf392280b8f961de2ab39e2c3 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Thu, 22 Oct 2026 11:03:16 -0700 Subject: [PATCH 15/19] fix: open and upload SSH files whose names are as long as names can be Temporary files beside a fetched copy were named after the host's file plus a serial and extension, and the POSIX and PowerShell pushes named their work folder or temporary file after it too. A valid 250-byte name then needed a temporary name over the usual 255-byte limit, so the fetch or the upload failed. Name them by serial or random suffix alone. Each copy has a folder of its own, and each push's work folder or temporary file is made with mktemp or a random name, so the host's name adds nothing to their uniqueness. --- crates/herdr-client/src/remote_file/posix.rs | 2 +- .../src/remote_file/powershell.rs | 2 +- .../src/remote_file/tests/posix_exec.rs | 14 ++++++ crates/herdr-gpui/src/remote_files/cache.rs | 10 ++-- crates/herdr-gpui/src/remote_files/tests.rs | 1 + .../src/remote_files/tests/long_names.rs | 47 +++++++++++++++++++ 6 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 crates/herdr-gpui/src/remote_files/tests/long_names.rs diff --git a/crates/herdr-client/src/remote_file/posix.rs b/crates/herdr-client/src/remote_file/posix.rs index cb2b123f1..24b192aff 100644 --- a/crates/herdr-client/src/remote_file/posix.rs +++ b/crates/herdr-client/src/remote_file/posix.rs @@ -316,7 +316,7 @@ copy= probe= probe_to= trap cleanup EXIT -work=$(mktemp -d "./.$name.XXXXXX") || exit 1 +work=$(mktemp -d ./.herdr-push.XXXXXX) || exit 1 {enter} target=../$name copy=$(mktemp ./f.XXXXXX) || exit 1 diff --git a/crates/herdr-client/src/remote_file/powershell.rs b/crates/herdr-client/src/remote_file/powershell.rs index 722f168a2..1a7d6f7b3 100644 --- a/crates/herdr-client/src/remote_file/powershell.rs +++ b/crates/herdr-client/src/remote_file/powershell.rs @@ -142,7 +142,7 @@ function Assert-HerdrReplaceable {{ }} Assert-HerdrReplaceable $folder = [System.IO.Path]::GetDirectoryName($path) -$name = '.' + [System.IO.Path]::GetFileName($path) + '.' + [System.IO.Path]::GetRandomFileName() +$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 diff --git a/crates/herdr-client/src/remote_file/tests/posix_exec.rs b/crates/herdr-client/src/remote_file/tests/posix_exec.rs index 9857d1542..37b1fff33 100644 --- a/crates/herdr-client/src/remote_file/tests/posix_exec.rs +++ b/crates/herdr-client/src/remote_file/tests/posix_exec.rs @@ -890,3 +890,17 @@ fn push_absent_never_renames_over_a_file_that_appeared() { 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-gpui/src/remote_files/cache.rs b/crates/herdr-gpui/src/remote_files/cache.rs index aa84c192c..39f3919f7 100644 --- a/crates/herdr-gpui/src/remote_files/cache.rs +++ b/crates/herdr-gpui/src/remote_files/cache.rs @@ -222,14 +222,14 @@ pub(super) fn staged_path(local: &Path) -> PathBuf { beside(local, "push") } -/// `local`'s name with a serial number unique in this process and -/// `extension` added, in `local`'s folder. +/// 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 the short name cannot be mistaken for another's. fn beside(local: &Path, extension: &str) -> PathBuf { static SERIAL: AtomicU64 = AtomicU64::new(0); let serial = SERIAL.fetch_add(1, Ordering::Relaxed); - let mut name = local.file_name().unwrap_or_default().to_owned(); - name.push(format!(".{serial}.{extension}")); - local.with_file_name(name) + local.with_file_name(format!(".{serial}.{extension}")) } /// Creates `path` and any missing parents, private to the user. diff --git a/crates/herdr-gpui/src/remote_files/tests.rs b/crates/herdr-gpui/src/remote_files/tests.rs index 3d10fb6e5..2230e6a23 100644 --- a/crates/herdr-gpui/src/remote_files/tests.rs +++ b/crates/herdr-gpui/src/remote_files/tests.rs @@ -8,6 +8,7 @@ mod dismissed_conflict; mod fetch_meta; mod fetch_swap; mod fetches; +mod long_names; mod saves; mod windows_names; 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..688dac5e2 --- /dev/null +++ b/crates/herdr-gpui/src/remote_files/tests/long_names.rs @@ -0,0 +1,47 @@ +//! 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"); +} From 699525132fe4ba72a764dfe9c6edad8f2645f3d4 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Thu, 22 Oct 2026 11:16:47 -0700 Subject: [PATCH 16/19] fix: never remove an SSH copy named like its temporary files Temporary names beside a copy became `..` so a long name fits, but a host file named, say, `.1.push` then has a copy with that name. A stage or fetch drawing that serial failed to create its file because the copy was there, and its cleanup removed the copy, edits included. Skip any serial whose name is the copy's own; the folder holds that copy alone, so no other name can collide. Arm a stage's cleanup only once its file was created. --- crates/herdr-gpui/src/remote_files/cache.rs | 19 +++++++++++---- crates/herdr-gpui/src/remote_files/push.rs | 9 ++++--- .../src/remote_files/tests/long_names.rs | 24 +++++++++++++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/crates/herdr-gpui/src/remote_files/cache.rs b/crates/herdr-gpui/src/remote_files/cache.rs index 39f3919f7..7bc3ce12a 100644 --- a/crates/herdr-gpui/src/remote_files/cache.rs +++ b/crates/herdr-gpui/src/remote_files/cache.rs @@ -224,12 +224,23 @@ pub(super) fn staged_path(local: &Path) -> PathBuf { /// 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 the short name cannot be mistaken for another's. +/// 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); - let serial = SERIAL.fetch_add(1, Ordering::Relaxed); - local.with_file_name(format!(".{serial}.{extension}")) + 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. diff --git a/crates/herdr-gpui/src/remote_files/push.rs b/crates/herdr-gpui/src/remote_files/push.rs index 0fc075cea..1180cdefa 100644 --- a/crates/herdr-gpui/src/remote_files/push.rs +++ b/crates/herdr-gpui/src/remote_files/push.rs @@ -115,11 +115,10 @@ impl Staged { if before.len > limit { return Err(Error::TooLarge { limit }); } - let staged = Self { - path: cache::staged_path(local), - meta: before, - }; - let mut file = cache::create_private_file(&staged.path)?; + 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))?; diff --git a/crates/herdr-gpui/src/remote_files/tests/long_names.rs b/crates/herdr-gpui/src/remote_files/tests/long_names.rs index 688dac5e2..cfe71cf16 100644 --- a/crates/herdr-gpui/src/remote_files/tests/long_names.rs +++ b/crates/herdr-gpui/src/remote_files/tests/long_names.rs @@ -45,3 +45,27 @@ fn a_copy_with_the_longest_name_is_fetched_and_pushed() { .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" + ); +} From 378192318ea6656ed8b8ff371a5489fa793bf254 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Thu, 22 Oct 2026 11:16:48 -0700 Subject: [PATCH 17/19] test: wait for the PowerShell push's renamed temporary file --- crates/herdr-client/src/remote_file/tests/powershell_exec.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/herdr-client/src/remote_file/tests/powershell_exec.rs b/crates/herdr-client/src/remote_file/tests/powershell_exec.rs index 042bac153..46ffa3e9f 100644 --- a/crates/herdr-client/src/remote_file/tests/powershell_exec.rs +++ b/crates/herdr-client/src/remote_file/tests/powershell_exec.rs @@ -348,7 +348,7 @@ fn powershell_push_temporary_file_takes_the_targets_acl() { let mut input = child.stdin.take().unwrap(); input.write_all(b"new").unwrap(); input.flush().unwrap(); - let temporary = wait_for_temporary(home.path(), ".secret.txt.", &mut child); + 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); From 7a2d917ec383196dcd13f611f0599e1f9b21dcd3 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Thu, 22 Oct 2026 12:01:22 -0700 Subject: [PATCH 18/19] fix: upload SSH saves into set-group-ID folders on Linux A push makes its private work folder with `mktemp -d` beside the target. Linux gives a folder made inside a set-group-ID folder that bit too, so the work folder lists as `drwx--S---`, and the check that it is private refused it: every save into a set-group-ID folder, including the usual group-writable shared project folder, failed with the shared-folder error. The check now also accepts `drwx--S---`, which grants the group nothing, and still refuses `drwx--s---` and any group or other bit. The push tests' PATH shims and the upload test's fake ssh are now written through `test_executable::write`. Written from the test process, a sibling test's fork could hold the shim open for writing; exec then failed with ETXTBSY and dash quietly ran the real tool instead, so `push_never_writes_through_a_link_planted_at_its_temporary_name` failed under parallel load and others could pass without testing anything. `Fixture` moves to the parent test module so the new set-group-ID tests share it. --- crates/herdr-client/src/remote_file/posix.rs | 6 +- crates/herdr-client/src/remote_file/tests.rs | 124 ++++++++++++++++++ .../src/remote_file/tests/posix_exec.rs | 113 ---------------- .../src/remote_file/tests/posix_setgid.rs | 38 ++++++ crates/herdr-client/src/upload_tests.rs | 4 +- 5 files changed, 168 insertions(+), 117 deletions(-) create mode 100644 crates/herdr-client/src/remote_file/tests/posix_setgid.rs diff --git a/crates/herdr-client/src/remote_file/posix.rs b/crates/herdr-client/src/remote_file/posix.rs index 24b192aff..593ff4376 100644 --- a/crates/herdr-client/src/remote_file/posix.rs +++ b/crates/herdr-client/src/remote_file/posix.rs @@ -116,7 +116,9 @@ 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------` 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 @@ -125,7 +127,7 @@ work_listing=$(ls -ldn . 2>/dev/null) || work_listing= set -f set -- $work_listing set +f -case ${{1-}} in drwx------*) ;; *) exit {SHARED_FOLDER_EXIT} ;; esac +case ${{1-}} in drwx------*|drwx--S---*) ;; *) exit {SHARED_FOLDER_EXIT} ;; esac if [ "${{3-}}" != "$uid" ] || [ "$(inode ..)" != "$here" ]; then exit {SHARED_FOLDER_EXIT}; fi"# ) } diff --git a/crates/herdr-client/src/remote_file/tests.rs b/crates/herdr-client/src/remote_file/tests.rs index 9181efc2d..366eb46bb 100644 --- a/crates/herdr-client/src/remote_file/tests.rs +++ b/crates/herdr-client/src/remote_file/tests.rs @@ -1,8 +1,13 @@ 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; @@ -63,3 +68,122 @@ fn decode(encoded: &str) -> String { .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 index 37b1fff33..4f4d813a9 100644 --- a/crates/herdr-client/src/remote_file/tests/posix_exec.rs +++ b/crates/herdr-client/src/remote_file/tests/posix_exec.rs @@ -4,110 +4,6 @@ use super::*; use std::os::unix::fs::{PermissionsExt, symlink}; -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, -} - -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. - fn shim(&mut self, tool: &str, body: &str) -> tempfile::TempDir { - let bin = tempfile::tempdir().unwrap(); - let shim = bin.path().join(tool); - std::fs::write(&shim, format!("#!/bin/sh\n{body}")).unwrap(); - std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(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 - } -} - #[test] fn fetch_streams_a_binary_file_exactly() { let fixture = Fixture::new(); @@ -641,15 +537,6 @@ const WITHOUT_STAT: &[&str] = &[ "cat", "chmod", "id", "ls", "mktemp", "mv", "rm", "rmdir", "wc", ]; -/// Where this machine keeps `tool`. -fn real_tool(tool: &str) -> std::path::PathBuf { - ["/bin", "/usr/bin"] - .iter() - .map(|folder| Path::new(folder).join(tool)) - .find(|candidate| candidate.exists()) - .unwrap() -} - /// 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(); 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/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( From 8b64a580e645403a3404336368bd764c607854a4 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Thu, 22 Oct 2026 12:49:21 -0700 Subject: [PATCH 19/19] feat: ask before fetching an SSH path that ran onto a second line A path that reaches the terminal's right edge is joined with the next row when that row starts with text, but the next row may be other output. Over SSH the joined path was fetched straight away, so when it named a different file that exists, that file opened without a word; the "Not on {target}" dialog only helped when it was missing. When a click's path crossed such a join and its first line alone reads as a different path, the click now asks first, even when a copy is cached: "Open from {target}?" holds the joined path in an editable field, with Open, First line only (puts the first line's part in the field) and Cancel. The "Not on {target}" dialog now always offers the path that was asked for. A path typed into either dialog is read in the clicked host's own shell, which the click has confirmed, instead of being guessed from its form: on a Windows host `~/notes.md` is fetched through PowerShell as `~\notes.md`, and a rooted path takes the drive of the pane's working directory; on a POSIX host `~\` becomes `~/`, and a drive or UNC path names no file. --- crates/herdr-gpui/README.md | 26 +- crates/herdr-gpui/src/menu.rs | 1 + crates/herdr-gpui/src/menu/chrome.rs | 3 + crates/herdr-gpui/src/menu/chrome/keys.rs | 6 + crates/herdr-gpui/src/menu/page.rs | 3 + crates/herdr-gpui/src/menu/page_title.rs | 1 + .../src/menu/remote_file_dialogs.rs | 73 +++-- .../herdr-gpui/src/menu/remote_join_dialog.rs | 154 ++++++++++ crates/herdr-gpui/src/menu/state.rs | 4 + crates/herdr-gpui/src/remote_files.rs | 37 +-- .../herdr-gpui/src/remote_files/raw_join.rs | 61 ++++ crates/herdr-gpui/src/remote_files/tests.rs | 58 ++++ .../src/remote_files/tests/missing_dialog.rs | 86 ++---- .../src/remote_files/tests/raw_join.rs | 276 ++++++++++++++++++ crates/herdr-gpui/src/remote_files/uploads.rs | 3 + crates/herdr-gpui/src/window/remote_paths.rs | 125 ++++++-- .../src/window/remote_paths/tests.rs | 117 ++++++++ 17 files changed, 894 insertions(+), 140 deletions(-) create mode 100644 crates/herdr-gpui/src/menu/remote_join_dialog.rs create mode 100644 crates/herdr-gpui/src/remote_files/raw_join.rs create mode 100644 crates/herdr-gpui/src/remote_files/tests/raw_join.rs diff --git a/crates/herdr-gpui/README.md b/crates/herdr-gpui/README.md index 7e2c3b6b6..9c40c74e3 100644 --- a/crates/herdr-gpui/README.md +++ b/crates/herdr-gpui/README.md @@ -2310,12 +2310,26 @@ 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 the host has no file at the clicked path, a dialog shows the path in an -editable field. If the click read a path across a raw wrap, the field holds the -first row's part instead. **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. +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 diff --git a/crates/herdr-gpui/src/menu.rs b/crates/herdr-gpui/src/menu.rs index e4dbcbcc6..ba9e7b879 100644 --- a/crates/herdr-gpui/src/menu.rs +++ b/crates/herdr-gpui/src/menu.rs @@ -21,6 +21,7 @@ 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 472d79b5f..78795279a 100644 --- a/crates/herdr-gpui/src/menu/chrome.rs +++ b/crates/herdr-gpui/src/menu/chrome.rs @@ -510,6 +510,7 @@ impl HerdrWindow { | Page::OpenCheckout | Page::OpenLink | Page::RemoteConflict + | Page::RemoteJoin | Page::RemoteMissing | Page::RemoteUpload ) || self.cloud_batch_open(), @@ -684,6 +685,8 @@ impl HerdrWindow { 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 { diff --git a/crates/herdr-gpui/src/menu/chrome/keys.rs b/crates/herdr-gpui/src/menu/chrome/keys.rs index d6a658e81..59f28c369 100644 --- a/crates/herdr-gpui/src/menu/chrome/keys.rs +++ b/crates/herdr-gpui/src/menu/chrome/keys.rs @@ -132,6 +132,9 @@ impl HerdrWindow { 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) @@ -356,6 +359,9 @@ impl HerdrWindow { "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); } diff --git a/crates/herdr-gpui/src/menu/page.rs b/crates/herdr-gpui/src/menu/page.rs index fb71a99db..47017c111 100644 --- a/crates/herdr-gpui/src/menu/page.rs +++ b/crates/herdr-gpui/src/menu/page.rs @@ -76,6 +76,9 @@ pub(crate) enum Page { /// 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, diff --git a/crates/herdr-gpui/src/menu/page_title.rs b/crates/herdr-gpui/src/menu/page_title.rs index caa322222..833d50da8 100644 --- a/crates/herdr-gpui/src/menu/page_title.rs +++ b/crates/herdr-gpui/src/menu/page_title.rs @@ -75,6 +75,7 @@ impl Page { 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", diff --git a/crates/herdr-gpui/src/menu/remote_file_dialogs.rs b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs index c6b5db8d8..ee44c754d 100644 --- a/crates/herdr-gpui/src/menu/remote_file_dialogs.rs +++ b/crates/herdr-gpui/src/menu/remote_file_dialogs.rs @@ -34,17 +34,29 @@ impl HerdrWindow { 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(&missing.prefill, cx); + input.set_text_selected(text, cx); window.focus(&input.focus, cx); }); - self.menu.remote_missing = Some(MissingDialog { missing, input }); - self.menu.page = Some(Page::RemoteMissing); - cx.notify(); - true + input } /// Whether `event` belongs to the not-on-host question's field, which @@ -72,26 +84,18 @@ impl HerdrWindow { self.open_remote_file(remote, query, None, cwd, cx); } - /// The file the field names, 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. fn remote_missing_choice( &self, cx: &App, ) -> Option<(RemotePath, Option, Option)> { let dialog = self.menu.remote_missing.as_ref()?; - let input = dialog.input.read(cx); - let text = input.text().trim(); - if input.is_composing() || text.is_empty() { - return None; - } let missing = &dialog.missing; - let cwd = missing.cwd.clone(); - if text == missing.remote.path { - return Some((missing.remote.clone(), missing.query.clone(), cwd)); - } - let remote = RemotePath::typed(&missing.remote.target, text, cwd.as_deref())?; - Some((remote, None, cwd)) + 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. @@ -308,7 +312,7 @@ impl HerdrWindow { ) } - fn remote_file_button( + pub(in crate::menu) fn remote_file_button( &self, id: &'static str, label: &'static str, @@ -355,17 +359,40 @@ impl HerdrWindow { } } -fn title(text: String) -> Div { +/// 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) } -fn muted(color: u32, text: &'static str) -> Div { +pub(super) fn muted(color: u32, text: &'static str) -> Div { div().p(px(8.)).text_color(rgb(color)).child(text) } -fn buttons() -> Div { +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 e2e31d1e9..3e9d81598 100644 --- a/crates/herdr-gpui/src/menu/state.rs +++ b/crates/herdr-gpui/src/menu/state.rs @@ -70,6 +70,8 @@ pub(crate) struct MenuState { 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 @@ -331,6 +333,7 @@ impl MenuState { open_checkout: None, open_link: None, remote_file: None, + remote_join: None, remote_missing: None, #[cfg(feature = "cloud")] cloud_create: None, @@ -409,6 +412,7 @@ impl MenuState { self.open_checkout = None; self.open_link = None; self.remote_file = None; + self.remote_join = None; self.remote_missing = None; #[cfg(feature = "cloud")] { diff --git a/crates/herdr-gpui/src/remote_files.rs b/crates/herdr-gpui/src/remote_files.rs index d5aa28dec..2a509265e 100644 --- a/crates/herdr-gpui/src/remote_files.rs +++ b/crates/herdr-gpui/src/remote_files.rs @@ -16,6 +16,7 @@ mod cache; mod push; +mod raw_join; mod sync; mod uploads; mod watch; @@ -35,6 +36,7 @@ use std::{ }; 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. @@ -233,16 +235,11 @@ pub(crate) struct Missing { pub(crate) query: Option, /// The working directory a relative path typed in its place is under. pub(crate) cwd: Option, - /// The path the question offers to edit: the first row's own path when - /// a raw wrap joined the next row's text to the click's, else the - /// click's. - pub(crate) prefill: String, } /// What a click asked for besides the host's file. struct Click { query: Option, - unjoined: Option, cwd: Option, } @@ -276,6 +273,9 @@ pub(crate) struct RemoteFiles { /// 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, } @@ -752,9 +752,10 @@ impl HerdrWindow { /// 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`. A file the host does not have - /// queues the question that offers that path, or `remote`'s, to edit; - /// a relative path typed there is under `cwd`. + /// 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, @@ -763,6 +764,16 @@ impl HerdrWindow { 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()); @@ -793,11 +804,7 @@ impl HerdrWindow { let fetch = cx .background_executor() .spawn(async move { fetch_copy(&io, owner, &asked, synced, page_query.as_deref()) }); - let click = Click { - query, - unjoined, - cwd, - }; + let click = Click { query, cwd }; cx.spawn(async move |this, cx| { let fetched = fetch.await; // The window may have closed meanwhile; nothing opens then. @@ -836,14 +843,10 @@ impl HerdrWindow { // 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"); - let prefill = click - .unjoined - .map_or_else(|| remote.path.clone(), |unjoined| unjoined.path); self.remote_files.missing.push_back(Missing { remote, query: click.query, cwd: click.cwd, - prefill, }); cx.notify(); } 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/tests.rs b/crates/herdr-gpui/src/remote_files/tests.rs index 68a373c64..bc2d0ee11 100644 --- a/crates/herdr-gpui/src/remote_files/tests.rs +++ b/crates/herdr-gpui/src/remote_files/tests.rs @@ -10,6 +10,7 @@ mod fetch_swap; mod fetches; mod missing_dialog; mod queries; +mod raw_join; mod saves; mod windows_names; @@ -188,6 +189,63 @@ fn window<'a>( (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); diff --git a/crates/herdr-gpui/src/remote_files/tests/missing_dialog.rs b/crates/herdr-gpui/src/remote_files/tests/missing_dialog.rs index 146db5c24..14485f4cd 100644 --- a/crates/herdr-gpui/src/remote_files/tests/missing_dialog.rs +++ b/crates/herdr-gpui/src/remote_files/tests/missing_dialog.rs @@ -8,15 +8,6 @@ const PATH: &str = "/home/me/gone.md"; const CWD: &str = "/home/me/src"; const PAGE: &str = "/srv/www/gone.html"; -fn posix(path: &str) -> RemotePath { - RemotePath { - target: "me@devbox".into(), - shell: RemoteShell::Posix, - path: path.into(), - line: None, - } -} - /// A host that is reached but cuts the transfer short. fn cut_short() -> herdr_client::Result<&'static [u8]> { Err(herdr_client::Error::RemoteFile( @@ -24,53 +15,6 @@ fn cut_short() -> herdr_client::Result<&'static [u8]> { )) } -/// 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) -} - -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); -} - #[gpui::test] fn a_missing_file_asks_for_a_path_holding_the_clicked_one(cx: &mut TestAppContext) { let host = FakeHost::new(); @@ -90,17 +34,6 @@ fn a_missing_file_asks_for_a_path_holding_the_clicked_one(cx: &mut TestAppContex }); } -#[gpui::test] -fn a_missing_joined_path_offers_the_first_row_s_part(cx: &mut TestAppContext) { - let host = FakeHost::new(); - let (view, cx) = on_empty_host(&host, cx); - let joined = posix("/home/me/gone.mdnext-row.txt"); - click(&view, cx, joined.clone(), Some(posix(PATH)), Some(CWD)); - assert_eq!(host.asked(), vec![joined]); - assert_eq!(page(&view, cx), Some(Page::RemoteMissing)); - assert_eq!(field(&view, cx).as_deref(), Some(PATH)); -} - #[gpui::test] fn open_fetches_the_edited_path_under_the_click_s_directory(cx: &mut TestAppContext) { let host = FakeHost::new(); @@ -139,6 +72,25 @@ fn escape_closes_the_question_without_a_fetch(cx: &mut TestAppContext) { 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(); 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/uploads.rs b/crates/herdr-gpui/src/remote_files/uploads.rs index ad3658432..cbc62d37f 100644 --- a/crates/herdr-gpui/src/remote_files/uploads.rs +++ b/crates/herdr-gpui/src/remote_files/uploads.rs @@ -46,6 +46,9 @@ impl HerdrWindow { 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) { diff --git a/crates/herdr-gpui/src/window/remote_paths.rs b/crates/herdr-gpui/src/window/remote_paths.rs index eb0a4239e..f6daccd5a 100644 --- a/crates/herdr-gpui/src/window/remote_paths.rs +++ b/crates/herdr-gpui/src/window/remote_paths.rs @@ -48,11 +48,17 @@ impl RemotePath { }) } - /// The host's file a path typed for `target` names, resolved under `cwd` - /// as a printed path is. `None` when it names none, as a relative path - /// does with no absolute `cwd`. - pub(crate) fn typed(target: &str, path: &str, cwd: Option<&str>) -> Option { - let (shell, path) = resolve(path, cwd)?; + /// 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, @@ -82,35 +88,100 @@ fn resolve(path: &str, cwd: Option<&str>) -> Option<(RemoteShell, String)> { } let cwd = cwd.map(file_url_drive); if let Some(rest) = path.strip_prefix('~') { - // `~user` names another account's home, which is not resolved. - if !(rest.is_empty() || rest.starts_with(['/', '\\'])) { - return None; - } let powershell = cwd.map_or(rest.starts_with('\\'), windows); - return Some(if powershell { - (RemoteShell::PowerShell, backslashes(path)) + let shell = if powershell { + RemoteShell::PowerShell } else { - (RemoteShell::Posix, path.to_owned()) - }); + 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); } - // Empty, or relative to the root of a drive no cwd names. + 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); - let cwd = cwd?; - if windows(cwd) { - let cwd = backslashes(cwd); - let folder = cwd.trim_end_matches('\\'); - Some(( - RemoteShell::PowerShell, - format!("{folder}\\{}", backslashes(relative)), - )) - } else if cwd.starts_with('/') { - let folder = cwd.trim_end_matches('/'); - Some((RemoteShell::Posix, format!("{folder}/{relative}"))) - } else { - None + 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, } } diff --git a/crates/herdr-gpui/src/window/remote_paths/tests.rs b/crates/herdr-gpui/src/window/remote_paths/tests.rs index c5392e685..ce12fa8bb 100644 --- a/crates/herdr-gpui/src/window/remote_paths/tests.rs +++ b/crates/herdr-gpui/src/window/remote_paths/tests.rs @@ -170,6 +170,123 @@ fn a_relative_path_joins_a_windows_cwd_with_backslashes() { 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);