From 7a808df0360a23e672847170b78bc781d8b3f976 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Fri, 16 Oct 2026 22:13:35 -0700 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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 56a5f7875a037bee739414cf9dc4639cd10ed27e Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Sun, 18 Oct 2026 21:47:01 -0700 Subject: [PATCH 06/17] 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 07/17] 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 08/17] 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 a2c83a04d94ff64ff811722b1c1bcf7317d0e175 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Thu, 22 Oct 2026 11:03:14 -0700 Subject: [PATCH 09/17] 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 10/17] 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 11/17] 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 12/17] 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 13/17] 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 3798bf2341b00fd6c12365592d9a39fcd5954014 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Mon, 26 Oct 2026 11:48:02 -0700 Subject: [PATCH 14/17] fix: never remove a copy whose name matches a fetch's temporary file A fetched copy sits beside hidden temporary files named `..part`, `.kept` and `.push`. The name picker skipped only a byte-equal match of the copy's own name, so on a case-insensitive disk a host file named `.5.PART` was the same file as the temporary `.5.part`. Creating the part then failed, and the caller's cleanup removed the copy, edits included. Compare names ignoring ASCII case, and let `write_part` own its cleanup: it removes only a part it created, when the fetch into it fails, and never a file it found at that path. --- crates/herdr-gpui/src/remote_files.rs | 12 +++++-- crates/herdr-gpui/src/remote_files/cache.rs | 13 +++++-- .../src/remote_files/tests/long_names.rs | 35 +++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/crates/herdr-gpui/src/remote_files.rs b/crates/herdr-gpui/src/remote_files.rs index 2d4c3296e..1b786a7b4 100644 --- a/crates/herdr-gpui/src/remote_files.rs +++ b/crates/herdr-gpui/src/remote_files.rs @@ -502,7 +502,7 @@ fn fetch_copy( cache::create_private_dirs(folder)?; } let part = cache::part_path(&local); - let fetched = write_part(io, remote, &part).inspect_err(|_| remove_part(&part))?; + let fetched = write_part(io, remote, &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))? @@ -697,9 +697,17 @@ fn remove_kept(kept: &Path) { } } +/// Fetches `remote` into a new file at `part`. A file already at `part` is +/// left as it is, since it may be the copy itself; only a part this call +/// created is removed when the fetch fails. 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) + (io.fetch)(remote, &mut file) + .map_err(Error::Fetch) + .inspect_err(|_| { + drop(file); + remove_part(part); + }) } fn remove_part(part: &Path) { diff --git a/crates/herdr-gpui/src/remote_files/cache.rs b/crates/herdr-gpui/src/remote_files/cache.rs index 7bc3ce12a..838cfca8e 100644 --- a/crates/herdr-gpui/src/remote_files/cache.rs +++ b/crates/herdr-gpui/src/remote_files/cache.rs @@ -227,17 +227,24 @@ pub(super) fn staged_path(local: &Path) -> PathBuf { /// 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. +/// write or its cleanup then never touches the copy. Case is ignored in +/// that comparison, because the cache may sit on a disk where `.0.PART` and +/// `.0.part` are one file. fn beside(local: &Path, extension: &str) -> PathBuf { static SERIAL: AtomicU64 = AtomicU64::new(0); beside_from(local, extension, || SERIAL.fetch_add(1, Ordering::Relaxed)) } -/// [`beside`] with the serial numbers drawn from `next`. +/// [`beside`] with the serial numbers drawn from `next`: the first name +/// that differs from `local`'s by more than ASCII case. 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 { + let own = local + .file_name() + .zip(path.file_name()) + .is_some_and(|(own, candidate)| own.eq_ignore_ascii_case(candidate)); + if !own { return path; } } 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 cfe71cf16..2f25abaf9 100644 --- a/crates/herdr-gpui/src/remote_files/tests/long_names.rs +++ b/crates/herdr-gpui/src/remote_files/tests/long_names.rs @@ -69,3 +69,38 @@ fn a_temporary_name_is_never_the_copy_s_own() { "another extension is another name" ); } + +/// The cache may sit on a disk that ignores case, where `.5.PART` and +/// `.5.part` are one file: a temporary name differing from the copy's only +/// by case is the copy's own. +#[test] +fn a_temporary_name_never_differs_from_the_copy_s_own_only_by_case() { + for copy in [".5.PART", ".5.Part"] { + let local = Path::new("cache").join(copy); + let mut serials = 5..; + assert_eq!( + cache::beside_from(&local, "part", || serials.next().unwrap()), + Path::new("cache").join(".6.part"), + "{copy}" + ); + } +} + +/// A part that cannot be created because a file is already at its name +/// leaves that file as it was: it may be the copy itself. +#[test] +fn a_failed_create_leaves_the_file_already_there() { + let host = FakeHost::new(); + let root = tempfile::tempdir().unwrap(); + let there = root.path().join(".5.part"); + std::fs::write(&there, b"# Edits\n").unwrap(); + let Err(error) = write_part(&host.io(notes), &remote(r"C:\dev\.5.PART"), &there) else { + panic!("a part was created over a file already there"); + }; + assert!( + matches!(&error, Error::Cache { path, .. } if *path == there), + "{error:?}" + ); + assert_eq!(std::fs::read(&there).unwrap(), b"# Edits\n"); + assert!(host.asked().is_empty(), "the host is not asked"); +} From f9c8ebb52b989ed56ed21af64b085bdbf40890de Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Mon, 26 Oct 2026 11:56:32 -0700 Subject: [PATCH 15/17] fix: keep a file's access when uploading to macOS hosts that tag files macOS `ls -l` prints `@` after the mode for any extended attribute, and hides `+` behind it. The push read every `@` as a possible ACL and gave up keeping access, so on a host that tags files (`com.apple.provenance` marks every file a sandboxed process makes) an upload wrote over the file in place, and a new file stayed private (0600) instead of taking its source's mode. On `@`, refuse only when `ls -lde` lists an ACL entry or fails, as the folder check already does. `+` still refuses outright, and Linux, BusyBox and Git Bash `ls` never print `@`. --- crates/herdr-client/src/remote_file/posix.rs | 16 ++++++++--- .../src/remote_file/tests/posix_exec.rs | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/herdr-client/src/remote_file/posix.rs b/crates/herdr-client/src/remote_file/posix.rs index 593ff4376..79b95fbbc 100644 --- a/crates/herdr-client/src/remote_file/posix.rs +++ b/crates/herdr-client/src/remote_file/posix.rs @@ -197,9 +197,10 @@ fn absent_check() -> String { /// 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 +/// fails, `ls -ld` marks `$like` or the copy with `+` (an ACL), or with +/// macOS's `@` (extended attributes, which hide `+`) and `ls -lde` lists an +/// ACL entry after the first line or fails, 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, @@ -233,7 +234,14 @@ if [ -n "$mode" ] && [ -n "$group" ]; then keep=1; fi if [ -L "$like" ] || [ ! -f "$like" ]; then keep=; fi for listed in "$like" "$copy"; do listing=$(ls -ld -- "$listed" 2>/dev/null) || listing= -case $listing in ''|??????????[+@]*) keep= ;; esac +nl=' +' +case $listing in +''|??????????+*) keep= ;; +??????????@*) +acl_listing=$(ls -lde -- "$listed" 2>/dev/null) || acl_listing=$nl +case $acl_listing in *"$nl"*) keep= ;; esac ;; +esac if command -v getfacl >/dev/null 2>&1 && [ -n "$(getfacl -s -- "$listed" 2>/dev/null)" ]; then keep=; fi done if [ -n "$keep" ] && [ "$group" != "$tmp_group" ] && ! chgrp -- "$group" "$copy" 2>/dev/null; then 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 4f4d813a9..4338914b0 100644 --- a/crates/herdr-client/src/remote_file/tests/posix_exec.rs +++ b/crates/herdr-client/src/remote_file/tests/posix_exec.rs @@ -478,6 +478,34 @@ fn push_renames_over_a_file_whose_group_already_matches() { assert_eq!(entries(fixture.home.path()), ["file"]); } +/// macOS marks a file with `@` for any extended attribute (a provenance-sandboxed +/// process tags every file it makes); with no ACL in `ls -lde`, the push still renames. +#[test] +fn push_renames_over_a_file_whose_listing_shows_extended_attributes() { + use std::os::unix::fs::MetadataExt; + let mut fixture = Fixture::new(); + std::fs::write(fixture.path("file"), b"old").unwrap(); + fixture.set_mode("file", 0o640); + let body = format!( + "case \"$1\" in\n-ld|-lde) shift; '{ls}' -ld \"$@\" | sed 's/^\\(..........\\)[.+@]\\{{0,1\\}} /\\1@ /'; exit ;;\nesac\nexec '{ls}' \"$@\"\n", + ls = real_tool("ls").display(), + ); + let _bin = fixture.shim("ls", &body); + 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_ne!( + after.ino(), + before.ino(), + "the copy was renamed over the file" + ); + assert_eq!(fixture.mode("file"), 0o640); + 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() { From 697be6a62ac487660a7c5e64b34d0bc6f000b74a Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Mon, 26 Oct 2026 12:24:37 -0700 Subject: [PATCH 16/17] test: check the push's ACL guard for macOS's split `+` and `@` branches The script-text check still looked for the old `[+@]` pattern, which the push no longer uses: `+` refuses outright, and `@` asks `ls -lde` whether an ACL hides behind it. --- crates/herdr-client/src/remote_file/tests/scripts.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/herdr-client/src/remote_file/tests/scripts.rs b/crates/herdr-client/src/remote_file/tests/scripts.rs index 7f1249825..ee281c28d 100644 --- a/crates/herdr-client/src/remote_file/tests/scripts.rs +++ b/crates/herdr-client/src/remote_file/tests/scripts.rs @@ -106,9 +106,10 @@ fn posix_push_checks_it_can_write_in_place_before_writing() { 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("??????????+*) keep= ;;"), "an ACL refuses"); assert!( - script.contains("??????????[+@]*"), - "macOS marks an ACL with @ too" + script.contains("??????????@*)") && script.contains("ls -lde -- \"$listed\""), + "macOS hides an ACL behind @, so @ asks ls -lde" ); assert!( script.contains("getfacl -s -- \"$listed\""), From 4d657414a362e77f3de41484920a840651a70608 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Mon, 26 Oct 2026 14:14:27 -0700 Subject: [PATCH 17/17] test: name the pane's host in main's new endpoint-switch file link test Main's 16c08d0f builds a `FileLink` in `a_document_check_cannot_open_after_switching_endpoints`; this branch gives every file link the SSH target of its pane's host, `None` here. --- crates/herdr-gpui/src/window/file_links/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/herdr-gpui/src/window/file_links/tests.rs b/crates/herdr-gpui/src/window/file_links/tests.rs index 6935bf022..6b40daa51 100644 --- a/crates/herdr-gpui/src/window/file_links/tests.rs +++ b/crates/herdr-gpui/src/window/file_links/tests.rs @@ -134,6 +134,7 @@ fn a_document_check_cannot_open_after_switching_endpoints(cx: &mut gpui::TestApp pane_id: "p1".into(), cwd: Some(directory.path().to_str().unwrap().into()), explicit: true, + remote: None, }, false, cx,