Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7a808df
chore(client): fetch and push one file on an SSH host
leftos Oct 17, 2026
9bc3af9
feat: open file paths an SSH pane prints by fetching a copy
leftos Oct 17, 2026
bb6ddcd
feat: upload saves to a fetched SSH file back to its host
leftos Oct 17, 2026
a835c7f
feat: list fetched SSH file copies with `herdr-gpui remote-files`
leftos Oct 17, 2026
5945c64
docs: say a saved SSH host may run Herdr on Windows
leftos Oct 17, 2026
3f3634e
fix: underline a link that wraps onto the next row as one link
leftos Oct 18, 2026
dfdfcd2
feat: open file paths printed with a ?query or #fragment
leftos Oct 19, 2026
738f5b2
fix: open quick-clicked wraps, relative Windows queries and file:// l…
leftos Oct 19, 2026
56a5f78
fix: keep saves through a repeat SSH fetch and backslashes in names
leftos Oct 19, 2026
6a31f50
fix(security): never write an SSH upload through a planted link
leftos Oct 19, 2026
34e3244
Merge branch 'feat/remote-file-links' into feat/link-wraps-and-queries
leftos Oct 19, 2026
6946bc9
fix(security): never carry set-user-ID bits to a new owner
leftos Oct 19, 2026
938df4a
Merge branch 'feat/remote-file-links' into feat/link-wraps-and-queries
leftos Oct 19, 2026
9de0658
Merge remote-tracking branch 'upstream/main' into feat/remote-file-links
leftos Oct 20, 2026
e4bfacb
Merge branch 'feat/remote-file-links' into feat/link-wraps-and-queries
leftos Oct 20, 2026
1adaebf
Merge remote-tracking branch 'upstream/main' into feat/remote-file-links
leftos Oct 22, 2026
5c9fc8e
Merge branch 'feat/remote-file-links' into feat/link-wraps-and-queries
leftos Oct 22, 2026
7be7074
fix: ask for another path when a clicked SSH file is not on the host
leftos Oct 22, 2026
03f9600
docs: describe the dialog for a missing SSH path
leftos Oct 22, 2026
a2c83a0
fix: never upload an SSH copy that mixes two saves
penso Oct 22, 2026
1e4f493
fix: open and upload SSH files whose names are as long as names can be
penso Oct 22, 2026
6995251
fix: never remove an SSH copy named like its temporary files
penso Oct 22, 2026
3781923
test: wait for the PowerShell push's renamed temporary file
penso Oct 22, 2026
7a2d917
fix: upload SSH saves into set-group-ID folders on Linux
leftos Oct 22, 2026
8b64a58
feat: ask before fetching an SSH path that ran onto a second line
leftos Oct 22, 2026
03d7e69
Merge remote-tracking branch 'origin/feat/remote-file-links' into fea…
leftos Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ objc = "0.2"
# The Secret Service client GPUI's Linux platform links, with its async-io backend.
oo7 = { default-features = false, features = ["async-std", "native_crypto"], version = "0.6" }
objc2 = "0.6.4"
objc2-app-kit = { default-features = false, features = ["NSApplication", "NSColor", "NSDockTile", "NSDragging", "NSEvent", "NSGraphics", "NSGraphicsContext", "NSImage", "NSImageRep", "NSPasteboard", "NSPasteboardItem", "NSResponder", "NSTextInputContext", "NSTrackingArea", "NSView", "NSWindow", "block2", "objc2-core-foundation", "std"], version = "0.3.2" }
objc2-app-kit = { default-features = false, features = ["NSApplication", "NSColor", "NSDockTile", "NSDragging", "NSEvent", "NSGraphics", "NSGraphicsContext", "NSImage", "NSImageRep", "NSPasteboard", "NSPasteboardItem", "NSResponder", "NSTextInputContext", "NSTrackingArea", "NSView", "NSWindow", "NSWorkspace", "block2", "objc2-core-foundation", "std"], version = "0.3.2" }
objc2-foundation = { default-features = false, features = ["NSArray", "NSBundle", "NSData", "NSError", "NSProcessInfo", "NSString", "NSURL", "std"], version = "0.3.2" }
# The WebKit snapshot behind page annotation screenshots and the popups pages
# open, at the versions wry uses.
Expand Down
1 change: 1 addition & 0 deletions crates/herdr-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ crossbeam-channel.workspace = true
herdr-protocol.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
thiserror.workspace = true
tracing.workspace = true
Expand Down
50 changes: 50 additions & 0 deletions crates/herdr-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,56 @@ arbitrary remote paths. The remote account and temporary-directory ancestors
must remain trusted; shell-based cleanup cannot eliminate concurrent path
replacement races by that account.

### One File In Place

```text
fetch_remote_file(file: RemoteFile, out: impl Write, max_bytes: u64,
cancelled: &AtomicBool) -> Result<Fetched>
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),
Expand Down
2 changes: 2 additions & 0 deletions crates/herdr-client/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ pub enum Error {
source: Box<Error>,
cleanup: Box<Error>,
},
#[error(transparent)]
RemoteFile(#[from] crate::remote_file::Error),
#[error("host scripts require a Linux or macOS client")]
ScriptUnsupported,
#[error("could not start host script")]
Expand Down
5 changes: 5 additions & 0 deletions crates/herdr-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mod method;
mod options;
mod process;
mod queue;
mod remote_file;
mod remote_host;
mod script;
pub mod scrollback;
Expand Down Expand Up @@ -68,6 +69,10 @@ pub use options::ConnectOptions;
#[cfg(windows)]
pub use process::CREATE_NO_WINDOW;
pub use process::new_command;
pub use remote_file::{
Access as RemoteFileAccess, Error as RemoteFileError, Expect, Fetched, MAX_REMOTE_FILE_BYTES,
RemoteFile, RemoteShell, fetch_remote_file, push_remote_file,
};
pub use remote_host::RemoteHost;
pub use script::{ScriptHost, ScriptLimits, run_script, run_script_command, shell_quote};
pub use sessions::{
Expand Down
Loading