Skip to content

feat: open wrapped links, and paths with a ?query or #fragment - #695

Draft
leftos wants to merge 15 commits into
penso:mainfrom
leftos:feat/link-wraps-and-queries
Draft

leftos wants to merge 15 commits into
penso:mainfrom
leftos:feat/link-wraps-and-queries

Conversation

@leftos

@leftos leftos commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Posted by Claude Code on behalf of @leftos.

Stacked on #627. Merge #627 first. This branch starts from feat/remote-file-links, so its first commits here are #627's, and #627's later fixes come in through a merge of that branch. This PR's own commits are 3f3634e, dfdfcd2 and 738f5b2.

Summary

Two link fixes in terminal panes.

A link that wraps is one link (fix: underline a link that wraps onto the next row as one link)

  • Before: a path or URL that reached the pane's right edge was never underlined or clickable. The client gets no soft-wrap flags, so a token touching the edge was refused in case it continued on the next row.
  • Now: the row reader works out whether the next row really continues the word. It joins rows in two cases:
    • raw terminal wraps: the word reaches the last column, and the next row starts at column 0;
    • app word-wraps, such as Claude Code's Ink output, which wraps at the block's wrap column, looking past │ … │ box walls, hanging indents and markers such as ● or ⎿.
  • The joined link is underlined and clicked as one link on every row, including a click made before the hover's pane.link.resolve answer arrives.
  • A link that ends at the edge with nothing after it is one complete link.
  • Walls are found per word, so side-by-side panels and table cells each wrap at their own wall.
  • The walk is capped at 16 rows, and it reads only the pane's own rect. That also fixes a reader that could look at the row above the pane.
  • plain_url now stops at frame characters, so │https://a.b/c│ no longer takes in the │.
  • RowLink now holds rows: Vec<(u16, Range<u16>)>, the shape the painter already takes.

Paths with ?query or #fragment (feat: open file paths printed with a ?query or #fragment)

  • A printed path such as X:\…\index.html?sheet=1, or its file:// form, was never a link: the disk check looked for a file literally named index.html?sheet=1, and Url::to_file_path dropped the query.

  • The split: a path that contains a folder separator may now end in ?query or #fragment. The file is looked for without it, and the underline covers the whole token.

  • Real file names stay whole. There is no split:

    • at the start of a path component (#notes!.md, #init.el#);
    • for ls's ? placeholder;
    • at the \\?\ prefix;
    • at an empty suffix.

    ? splits before a key= query or after an extension. # splits after an extension, before an anchor, L<n>[-L<m>], or page=<n> on a PDF.

  • Windows paths: a relative path such as docs\index.html?x=1 splits too when the pane's host writes Windows paths, judged from the pane's working directory, else from this machine.

  • Line numbers: #L10 sets the line when there is no :line, in printed paths, file:// links and schemeless hyperlinks. file:// links keep their query and fragment.

  • Opening: clicking a web page (html, htm, xhtml, svg), a PDF, or a file with no extension that has a suffix starts the default browser directly with the file:// URL. The system openers drop the query from file:// URLs. This works locally and for SSH copies: fetched, cached, and kept edits.

    • Windows: the https/http UserChoice ProgId and its open command.
    • macOS: NSWorkspace gives the default browser, and its bundle executable is run. Safari takes no URL argument, so it falls back.
    • Linux: always falls back.
  • The URL is passed as one argument, never through a shell, and the suffix is capped at 4096 bytes.

  • If the browser cannot start, the click opens what it would open without a suffix, so an executable with no extension opens its folder and is never run. Every other file (main.rs#L10, notes.md#usage) opens as before, without the suffix.

The crate README's Terminal Links section describes both.

Screenshots

Windows, the real native window, dark theme (no colour changes, so no light variant).

Wrapped links, hovered with Ctrl, one hover per frame:

  • a URL raw-wrapped over three rows;
  • a path ending exactly at the edge;
  • a URL inside a box;
  • a ● reply path under a hanging indent.

wrapped links

A path with ?sheet=1, and the file:// form beside it:

path with query

The same path raw-wrapped inside the file name, and inside the query:

wrapped path with query

QA

Ran:

  • just ci on Windows x64 at 34e3244. Every step passed except test-mockup-wait, which fails on every Windows machine until test: run the mockup wait tests on Windows #643 merges; it is the last step.
  • WSL (Ubuntu) at 34e3244: cargo clippy --workspace --all-targets with --all-features and with --no-default-features, -D warnings, both clean.
  • WSL at 34e3244: cargo test --workspace, default and --all-features, all passed.
  • just lint-windows at dfdfcd2: clean.
  • macOS arm64 (MacBook) at dfdfcd2: cargo clippy -p herdr-gpui --all-targets --all-features -D warnings clean, plus the link, file-link, remote-path, remote-file and default-browser tests (211 passed). This covers the NSWorkspace code and the Unix-only executable-fallback test. Not re-run at 34e3244.
  • Windows native at dfdfcd2, against a private daemon:
    • Ctrl-hover underlines the whole index.html?sheet=1 token, and both wrapped forms across their rows.
    • Ctrl-click on the plain path started Firefox with file:///X:/…/index.html?sheet=1, and on the file:// form with ?sheet=1#top; both were read from the started process's command line.
  • Windows native (wrapped links commit): a raw-wrapped URL, an edge path, a boxed URL and a reply path all hover as one link. A Ctrl-click on the third row of a wrapped localhost URL delivered the whole URL, with ?sheet=1, to the server.

For a reviewer:

  • macOS: with Firefox or Chrome as the default browser, Ctrl-click a printed …/index.html?x=1; the page opens with ?x=1. With Safari as the default, the page opens without it.
  • Linux: Ctrl-click the same; the page opens through the system opener, without the query.
  • An SSH pane: print a remote …/page.html?x=1, Ctrl-click it; the fetched copy opens in the browser with ?x=1.
  • Claude Code in a pane: ask it to print a long path inside a reply, so it wraps under the reply's indent; Ctrl-hover a continuation row and the whole path underlines.
  • Claude Code in a pane: Ctrl-click a long wrapped URL in a reply straight away, without resting on it first; the whole URL opens.
  • A Windows pane: in a folder holding docs\index.html, print docs\index.html?x=1 and Ctrl-click it; the page opens with ?x=1.
  • Print file:///<a source file>#L10 and Ctrl-click it; the editor opens at line 10.

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.
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.
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.
`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.
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.
A URL or path that ran to the pane's right edge was never a link: the
client gets no soft-wrap information, so the row reader refused any
token reaching the last column in case it continued on the next row.

The reader now decides. `terminal/links/wrap.rs` walks the hit word's
rows and joins them in two cases: a raw terminal wrap (no walls, the
word ends at the pane's last column and the next row starts at column
0), and an application's word wrap as Ink/wrap-ansi lays it out (same
walls, the next row starting under the text or after a bullet marker,
and a word too long for the wrap width). Walls are frame characters
that line up on both rows, so boxes, table cells and side-by-side
panels each wrap at their own border. The existing URL and path
parsers run on the joined text, and the match maps back to one
segment per row, which the painter already draws.

A word that reaches the edge and does not continue is a complete link.
Text that runs to the end of the pane's bottom row, or ends in `…`, is
still refused, since it may be cut off. A raw-wrapped URL is left to a
daemon that offers `pane.link.resolve`. When the local reading strictly
contains the daemon's resolved link, as at an app's word wrap, the
local one is underlined and opened if the daemon declines the click.
OSC 8 hyperlinks that continue on the next row are underlined on both.
Mock pages are printed as `X:\...\index.html?sheet=1` or as
`file:///.../index.html?sheet=1`. The disk check looked for a file
literally named `index.html?sheet=1`, so the path was never a link, and
`Url::to_file_path` dropped the query from `file://` links.

A path containing a separator may now end in `?query` or `#fragment`.
`plain_path` splits it off at the first unescaped mark after a
separator. Guards keep real names whole: no split at the start of a
component, `?` only before `key=` or after an extension, `#` only after
an extension and before an anchor, `L<n>[-L<m>]`, or `page=<n>` on a
PDF, and never at an empty suffix. The disk check and SSH's shape
check see the path part, the underline covers the whole token, and
`#L10` sets the line when there is no `:line`. `file://` links keep
their query and fragment, and a suffix is capped at 4096 bytes.

Clicking a web page (html, htm, xhtml, svg), a PDF, or a file with no
extension that has a suffix starts the default browser directly with
the `file://` URL, suffix included, because the system openers drop it.
This covers local panes and SSH copies (fetched, cached, and kept
edits).
- Windows reads the https/http UserChoice ProgId (UserChoiceLatest
  first) and its open command.
- macOS asks NSWorkspace for the default browser and runs its bundle
  executable. Safari takes no URL argument, so it falls back.
- Linux has no direct launch and always falls back.
The URL is passed as one argument, never through a shell. If the
browser cannot start, the click opens what it would open without a
suffix, so a file with no extension that is executable opens its folder
and is never run. Every other file opens as before, without the suffix.
@leftos

leftos commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 Posted by Claude Code on behalf of @leftos.

Local just ci checks at e4bfacb, which merges #627's updated branch (9de0658, itself merging main at 0ca50aa).

Windows 11 x86_64:

  • check-commits (upstream/main..HEAD): passed
  • format-check, check-file-size, check-sorted: passed
  • lint, all features and no default features: passed
  • test, default and all features: passed, 0 failed in all 46 test binaries (herdr-gpui 4342 and 4386)
  • test-mockup-wait: failed, the Windows-only test_wait.py failure (test: run the mockup wait tests on Windows #643); this PR touches nothing it covers.

Not run at e4bfacb: WSL (Linux) and macOS. At 34e3244, WSL clippy (all features and no default features) and cargo test --workspace passed; macOS is covered by this PR's macos-14 and macos-15 CI jobs.

The merge needed one fit to main: upstream moved windows-registry to 0.100, which returns windows-result errors, so default_browser reads a missing key from WIN32_ERROR and carries windows_result::Error. Cargo.toml and Cargo.lock take main's side.

The Windows native GUI jobs fail on main too (0ca50aa and later): the native_sidebar paint probe measures the child label at 179px or 127px against an expected 161px. This PR does not touch the sidebar, and its Linux and macOS jobs pass.

@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 1/5

[Medium impact] Adds remote file transfer and default browser detection.

Do not merge until remote uploads cannot follow replacement symlinks, repeat fetches preserve editor saves, and quick clicks open complete wrapped URLs.

Findings

  1. P1 Security Uploads can overwrite another file ▶
  2. P1 Quick clicks open shortened URLs ▶
  3. P1 Repeat fetches can erase saves ▶
  4. P2 Relative Windows queries fail ▶
  5. P2 File URLs lose line numbers ▶
  6. P2 Remote filenames get shortened ▶

Summary

Adds wrapped terminal links, path queries and fragments, direct browser opening, and SSH file copies with confirmed uploads.

  • SSH terminal paths open as local copies that can be edited and synced.
  • Terminal links stay whole when they wrap across rows.
  • Printed page paths keep their query or fragment when opened.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Modifier-click terminal link] --> B[Read joined rows and suffix]
  B --> C{Daemon handles click?}
  C -->|Yes| D[Handler opens link]
  C -->|No| E{Target}
  E -->|Web URL| F[Open browser]
  E -->|Local file| G[Check file and opening rules]
  E -->|SSH file| H[Fetch private local copy]
  H --> G
  G --> I[Open document or containing folder]
  G --> J[Start browser with query]
  H --> K[Watch editor saves]
  K --> L[Ask before uploading]
  L --> M[Check remote hash and upload]
Loading

Reviews (1) · Last reviewed commit: "feat: open file paths printed with a ?qu..." · Reviewed by Greptile

Comment on lines +132 to +134
elif [ -e "$path" ]; then
if [ ! -w "$path" ]; then exit {ACCESS_EXIT}; fi
cat < "$tmp" > "$path" || exit 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Uploads can overwrite another file

If another user can change the destination directory on a POSIX host, they can replace the destination with a symlink after its checks. When an ACL forces this in-place branch, both -w and > follow that link. The upload then overwrites another file writable by the SSH account.

Use a write that checks and opens the same file without following symlinks, or refuse this fallback.

How this was verified: The script checks the destination before keep_access, then opens it again through a shell redirection that follows symlinks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Posted by Claude Code on behalf of @leftos.

Confirmed, and the window was wider than the in-place branch: the rename branch also reopened its temporary file by name after mktemp, and chgrp/chmod followed links. Fixed in 6a31f50 on #627, merged here in 34e3244. The push script now enters the target's folder once with cd -P and names the target relative to it. It builds the copy in a private mktemp -d folder, checked to be the SSH user's drwx------ folder inside the anchored one, with every file in it created by mktemp. It moves the copy over the target with mv -T (GNU) or mv -h (BSD) where mv takes one. It 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). Anywhere else it refuses with its own exit code and error, "Other users can replace files in that folder on the host, so it was not written in place; nothing was changed." Keep both never writes in place. Tests in remote_file/tests/posix_exec.rs swap the target, the temporary file and the work folder for links through tool shims on PATH, and check that the victim file is unchanged.

Comment thread crates/herdr-gpui/src/window/links.rs Outdated
Comment on lines +248 to +249
let Some(daemon) = self.hovered_daemon_link() else {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Quick clicks open shortened URLs

A modifier-click before the hover reply arrives can open only the first row of an app-wrapped URL. local_contains_daemon returns false without that reply, and the activation saves that choice. When the daemon declines with its first-row URL, finish_activation chooses it over the complete local reading.

This can happen during the 40 ms hover delay or while waiting for an SSH reply. Prefer the complete app-wrapped reading without requiring a completed hover request, while still letting handlers claim the click.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Posted by Claude Code on behalf of @leftos.

Confirmed and fixed in 738f5b2. With no resolved link yet to compare, local_contains_daemon now lets a local reading that spans more than one row win. On a daemon offering pane.link.resolve, a raw-wrapped address is left to the daemon, so a multi-row local web reading is an application's word wrap that the daemon reads as separate lines. A handler still claims the click first. Test: a_click_before_the_hover_reply_opens_the_whole_wrapped_link.

Comment thread crates/herdr-gpui/src/remote_files.rs Outdated
source,
})
.inspect_err(|_| remove_part(&part))?;
std::fs::rename(&part, &local)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Repeat fetches can erase saves

fetch_copy can overwrite a save made after edited_since returns but before rename runs. An editor can finish its save in that gap; the rename replaces those edits with the downloaded file, and the returned copy is marked Synced. The watcher cannot recover the overwritten bytes.

Avoid replacing an editable copy based on a separate earlier check. Keep the current copy or fetch into a new copy when another application may be editing it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Posted by Claude Code on behalf of @leftos.

Confirmed and fixed in 56a5f78 on #627, merged here in 34e3244. When edits are kept, the copy now moves aside first, and the download is hard-linked into its place, which fails rather than replace a save that took the place meanwhile. The copy moved aside comes back over the download unless its metadata proves it is the one last synced. A filesystem without hard links falls back to a rename. The watch run also skips 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. Tests in remote_files/tests/fetch_swap.rs save in place, by temp-and-rename, and through a handle at each step of the swap.

Comment on lines +95 to +103
pub(super) fn split_suffix(token: &str, drive: bool) -> Option<(&str, Option<&str>)> {
let separator = |c: char| c == '/' || (drive && c == '\\');
let mut after_folder = false;
let mut previous = None;
let mut chars = token.char_indices();
while let Some((index, c)) = chars.next() {
if c == '\\' && !drive {
// The escaped character is part of the name.
previous = chars.next().map(|(_, escaped)| escaped);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Relative Windows queries fail

Queries are not split from relative Windows paths such as docs\index.html?x=1. Without a drive letter, drive is false, so this code treats backslashes as escapes rather than folder separators. In an SSH pane with C:\dev as its working directory, the fetch asks for C:\dev\docs\index.html?x=1 instead of the file without the query.

Pass the host's path style into suffix parsing and cover this relative-path case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Posted by Claude Code on behalf of @leftos.

Confirmed and fixed in 738f5b2. LinkReading now carries whether the pane's host writes Windows paths. It is judged from the pane's working directory (a drive or UNC path means Windows, a leading / means not), else from this machine. Only the suffix split uses it, so POSIX escapes such as /tmp/a\ b are unchanged. Tests: a_relative_windows_path_splits_its_query_on_a_windows_host and the window-level windows_paths.rs.

Comment on lines +73 to +83
let query = file_suffix(&url);
if query
.as_ref()
.is_some_and(|query| query.len() > path::MAX_SUFFIX_BYTES)
{
return RowTarget::Malformed;
}
url.to_file_path()
.ok()
.and_then(|path| path.into_os_string().into_string().ok())
.map_or(RowTarget::Malformed, named)
.map_or(RowTarget::Malformed, |path| named(path, query))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 File URLs lose line numbers

file:// links keep #L10 but never use it to set line. explicit_target creates every file target with line: None, and plain_address also resets it to None. Thus file:///tmp/main.rs#L10 opens at the default editor position, unlike src/main.rs#L10.

Apply fragment_line to file URLs too, and preserve that line when creating the plain target.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Posted by Claude Code on behalf of @leftos.

Confirmed and fixed in 738f5b2. file://…#L10 links, as plain text or as hyperlinks, and schemeless hyperlinks such as src/main.rs#L10-L20, now take their line from the fragment, as a printed src/main.rs#L10 already did. Test: a_file_address_or_hyperlink_takes_its_line_from_the_fragment.

Comment on lines +136 to +137
pub(crate) fn basename(path: &str) -> &str {
let name = path.rsplit(['/', '\\']).next().unwrap_or_default();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Remote filenames get shortened

basename treats backslashes as folder separators even for POSIX hosts. Fetching /srv/foo\bar.txt therefore stores the copy as bar.txt, not foo\bar.txt. On Windows, it also skips the intended conversion to foo_bar.txt.

Use the remote shell's separators when choosing the basename, as conflict_path already does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Posted by Claude Code on behalf of @leftos.

Confirmed and fixed in 56a5f78 on #627, merged here in 34e3244. basename now takes the host's RemoteShell and splits at its separators only, so /srv/foo\bar.txt on a POSIX host is cached and shown as foo\bar.txt, and on Windows as foo_bar.txt. Two files never shared a copy, because the copy's folder is keyed by a hash of the full path, but the name shown was wrong.

@penso penso added bug Something isn't working P2 Normal backlog: enhancements, documentation, and less disruptive bugs labels Oct 19, 2026
…ines

Three link fixes from review:

- A modifier-click on an address an application wrapped across rows,
  made before the hover's pane.link.resolve answer arrived, opened only
  the first row: with no resolved link to compare, the daemon's
  first-row reading won. A local reading that spans more than one row
  now wins when nothing is resolved yet; a handler still claims the
  click first.
- A relative Windows path such as docs\index.html?x=1 never split off
  its query, because without a drive a backslash read as an escape.
  LinkReading now carries whether the pane's host writes Windows paths,
  judged from the pane's working directory (a drive or UNC path means
  Windows, a leading / means not), else from this machine. Only the
  suffix split uses it; POSIX escapes such as /tmp/a\ b are unchanged.
- file://...#L10 links, and schemeless hyperlinks such as
  src/main.rs#L10-L20, now open at the line the fragment names, as a
  printed src/main.rs#L10 already did.
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.
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.
# Conflicts:
#	crates/herdr-gpui/src/remote_files.rs
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.
# Conflicts:
#	crates/herdr-gpui/src/cli/control/hosts.rs
#	crates/herdr-gpui/src/control/client/hosts.rs
#	crates/herdr-gpui/src/control/protocol/hosts.rs
#	crates/herdr-gpui/src/control/protocol/hosts/tests.rs
#	crates/herdr-gpui/src/control/server/hosts.rs
#	crates/herdr-gpui/src/control/server/hosts/tests.rs
#	crates/herdr-gpui/src/menu/chrome.rs
#	crates/herdr-gpui/src/menu/chrome/keys.rs
# Conflicts:
#	Cargo.toml
#	crates/herdr-gpui/Cargo.toml

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working P2 Normal backlog: enhancements, documentation, and less disruptive bugs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants