Files
tty7/src/core/shell_quote.rs
T
l0ng-ai 975e3edf9b Fix Windows path quoting, wire up Checkout to…, bound the Spawn reply (#705)
* fix(windows,scm,daemon): quote paths per shell, wire Checkout to, bound Spawn

Five fixes from a whole-codebase audit, in one sweep because they share
the paths they touch.

Path quoting had two implementations. file_tree::shell_quote_for wrapped
the path in quotes and picked the right ones per shell (#593);
view::shell_escape_path escaped with backslashes, which is POSIX-only
and collides head-on with the Windows path separator, so a dropped file,
a pasted path, a staged image path and an accepted completion candidate
all lost their separators there. completion::complete_path stripped the
same backslashes back off before looking a path up, so inline path
completion could never resolve a directory on Windows either. Both now
go through one core::shell_quote module, and shell_word_start tracks
quoting across the word so a second Tab still finds the word it just
inserted.

"Checkout to..." was registered, listed in the palette, bindable, and
handled by an empty match arm — invoking it did nothing at all. It now
opens an inline input row in the SCM panel, the twin of the existing
"create branch" one.

RemoteTerminal's Spawn read the daemon's reply with no deadline, while
Attach in the same file and PaneSession::spawn_over in core both bound
theirs. A daemon caught mid-restart accepts the connection and never
serves it, and the local route spawns synchronously on the UI thread, so
the silence froze the window on "new tab".

Two Windows papercuts: client_hostname spawned a console program from a
GUI process (a visible console flash) where COMPUTERNAME already has the
answer, and completion generators were a silent no-op with no way to
tell "produced nothing" from "never ran".

Three duplicated implementations merged: proc_name existed twice in the
daemon with a different fallback in each, the GUI's control link was the
one client socket that skipped transport::tune, and fps.rs and perf.rs
were the same windowed meter copied twice.

* refactor(completion): stop declaring spec fields nothing reads

The Fig spec structs mirrored seven keys the completer never looks at,
each held up by its own #[allow(dead_code)]. Serde ignores unknown
fields by default, so dropping the declarations parses the same specs
and drops the attributes with them.

* refactor(daemon): delete the loopback-forward management pipeline

Two protocol messages, their kind codes, encode and decode arms, two
daemon dispatch arms, two wire structs and two GUI client wrappers all
existed to reach SshManager::list_loopback_forwards and
close_loopback_forward, which were hardcoded to Vec::new() and false.
Nothing called the client wrappers either.

The kind codes are left as holes rather than renumbered, the way 13
already is, so the wire format is unchanged for every other message.

known-hosts management looks like the same shape but is not: its backend
parses the real file, fingerprints keys and rewrites through a 0600 temp
file. That one keeps its client half and gains a comment saying it is an
interface waiting for a screen.

* test(ssh): cover the host-key policy table and both proxy handshakes

The host-key decision is lifted out of check_server_key into
host_key_action, so what to do about Known/Unknown/Changed/
ChangedAlgorithm/Revoked can be read and tested without a server, a
broker or a known_hosts file. Eight tests pin it, including the two
subtleties the comments already claimed: verify_host_keys=false still
rejects a revoked key, and a new algorithm asks the unknown-host prompt
rather than a new variant older peers cannot decode.

socks5_connect and http_connect are split into connect + handshake, the
handshake generic over the stream, so nine tests drive them from an
in-memory duplex: length-prefix framing, the variable-length bound
address, auth refusal, reply codes, and the header terminator.

* test(cli,daemon): cover server binary resolution and the procargs parser

server_exe is split into environment lookup and resolve_server_exe, the
latter taking its three sources and an is_exe predicate so seven tests
can pin the precedence without touching the filesystem. Holding the
sibling to is_file rather than exists fixes a directory named
tty7-server shadowing the real binary on PATH.

parse_macos_procargs gets six tests over the KERN_PROCARGS2 layout:
exec-path skipping, however many bytes of alignment padding follow it,
argc bounding argv so the environment stays out, truncation, and a short
buffer.

* test(ui): cover the host-op pool decisions and the local reconnect schedule

The pool's retire condition moves into should_retire with the reason
named: a worker must not retire on the timeout alone, because submit
counted it as idle and so did not spawn a replacement for the job that
landed meanwhile.

LocalLink::tick's schedule moves into due(), taking the clock and the
link's state as arguments. The first attempt going out immediately, the
backoff only applying from the second, and a pending deadline not being
pushed further out by later ticks are now pinned. The identical
scheduler in remote_workspace had TestAppContext coverage; this one,
which every launch depends on, had none.

* fix(completion): unquote across the whole word, not just its first character

The round-trip test caught two things the first cut got wrong. A quote
can open partway into a word — quote_for_shell emits ~/'My Documents' so
the shell still expands the tilde — and a single-quoted body is literal
all through, so unescaping backslashes inside one took the separators
out of 'C:\Users\me'. Scanning with a quote state handles both, and
makes the '\'' seam fall out of the state changes rather than needing a
case of its own.

The GPUI test for accepting a candidate follows the insertion from
backslash escaping to quoting.

* fix(windows): unbreak the Windows build and quote for PowerShell's own dialect

`Instant` was moved behind `#[cfg(unix)]` while the generator cache still
uses it unconditionally, so the Windows target stopped compiling.

The quoting module treated every shell but cmd.exe as POSIX, including
PowerShell. PowerShell does not join a quoted string to the bare word beside
it, so the `'\''` seam is not a seam there — `C:\Users\O'Brien` came out as
three tokens, and the completion un-quoter turned the apostrophe back into a
backslash. Quoting is now a three-way dialect (cmd / PowerShell / POSIX)
chosen once and threaded through completion in place of the escapes flag.

* test(file-tree): name the shell where the quoting rule is the POSIX one

`shell_quote_for(_, None)` answers from the platform, so an assertion about
the `'\''` seam has to say which shell it means or it fails on Windows,
where the unnamed shell is PowerShell.
2026-08-20 23:33:26 +08:00

323 lines
13 KiB
Rust

//! One set of rules for putting a real path onto a command line, shared by
//! everything that inserts one.
//!
//! Three places need this: the file tree's `cd` and paste, the terminal's
//! drop / clipboard / staged-image insertion, and completion accepting a
//! candidate. They used to carry three separate implementations, and on
//! Windows two of them disagreed — `file_tree::shell_quote_for` wrapped the
//! path in quotes and worked, while `view::shell_escape_path` escaped with
//! backslashes and ate the path separators of `C:\Users\me` (#593 fixed the
//! first one and never reached the other two).
//!
//! Three shells, three rules:
//!
//! - cmd.exe treats only double quotes as quoting. A single quote is an
//! ordinary character there, so the POSIX form splits the path at its first
//! space. Windows paths cannot contain `"`, so there is nothing to escape
//! inside the quotes.
//! - PowerShell takes `'...'`, and writes an embedded `'` twice. The POSIX
//! `'\''` seam is not a seam there — PowerShell does not join a quoted
//! string to the bare word beside it — so `C:\Users\O'Brien` came out as
//! something PowerShell reads as three tokens.
//! - Every POSIX shell takes `'...'` too, and breaks out for an embedded `'`
//! via `'\''`.
//!
//! Backslash escaping is not used to quote anywhere. Only POSIX shells
//! understand it, and on Windows it collides head-on with the path separator.
//!
//! A leading `~/` stays outside the quotes: quoting it would make it a literal
//! and lose the home expansion the user is asking for.
/// Characters that need no quoting in any shell we target.
fn is_bare(c: char) -> bool {
c.is_alphanumeric() || "/.-_~+".contains(c)
}
/// How a shell wants a literal string written.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Quoting {
/// cmd.exe: `"..."`, and a Windows path cannot hold a `"` to escape.
Cmd,
/// PowerShell and pwsh: `'...'`, with an embedded `'` written `''`.
PowerShell,
/// Everything else: `'...'`, with an embedded `'` written `'\''`.
Posix,
}
/// The shell binary's name, without a directory and without a `.exe` suffix.
fn base_name(program: &str) -> &str {
let base = program.rsplit(['\\', '/']).next().unwrap_or(program);
let cut = base.len().saturating_sub(4);
match base.get(cut..) {
Some(tail) if tail.eq_ignore_ascii_case(".exe") => &base[..cut],
_ => base,
}
}
/// Which dialect the pane's shell speaks.
///
/// `shell_program` is the pane's shell binary as `ShellSpec::program` reports
/// it. `None` means the pane has not resolved one yet, and the platform is the
/// only evidence there is: a Windows pane is overwhelmingly PowerShell, and
/// everywhere else it is something POSIX. (WSL panes get their paths rewritten
/// to `/mnt/...` before they reach here.)
pub fn quoting_for(shell_program: Option<&str>) -> Quoting {
let Some(base) = shell_program.map(base_name) else {
return if cfg!(windows) {
Quoting::PowerShell
} else {
Quoting::Posix
};
};
if base.eq_ignore_ascii_case("cmd") {
Quoting::Cmd
} else if base.eq_ignore_ascii_case("powershell") || base.eq_ignore_ascii_case("pwsh") {
Quoting::PowerShell
} else {
Quoting::Posix
}
}
/// Quote `path` as a single argument for the shell the pane is running.
pub fn quote_for_shell(path: &str, shell_program: Option<&str>) -> String {
quote_as(path, quoting_for(shell_program))
}
/// [`quote_for_shell`] with the dialect already decided.
fn quote_as(path: &str, quoting: Quoting) -> String {
if path.is_empty() {
return match quoting {
Quoting::Cmd => "\"\"".to_string(),
_ => "''".to_string(),
};
}
// `~/` has to stay unquoted for the shell to expand it, so quote only the
// rest. A bare `~` is already covered by `is_bare`.
if let Some(rest) = path.strip_prefix("~/") {
if rest.is_empty() {
return "~/".to_string();
}
return format!("~/{}", quote_as(rest, quoting));
}
if path.chars().all(is_bare) {
return path.to_string();
}
match quoting {
Quoting::Cmd => format!("\"{path}\""),
Quoting::PowerShell => format!("'{}'", path.replace('\'', "''")),
Quoting::Posix => format!("'{}'", path.replace('\'', r"'\''")),
}
}
/// Undo [`quote_for_shell`] far enough to look the path up on disk.
///
/// Completion re-reads the word under the cursor after the user has already
/// accepted one candidate, so whatever quoting went in has to come back out
/// before the word can be resolved against the filesystem. The word is
/// mid-typing and therefore usually *un*terminated, so a lone leading quote
/// counts.
///
/// Only a POSIX shell treats a backslash as an escape character. A user who
/// typed `My\ Docs` by hand there expects it honoured; on Windows the same
/// character is a path separator and must survive untouched.
///
/// Inside single quotes it is neither, on any shell: a single-quoted string is
/// literal from end to end. Unescaping there would take the separators out of
/// `'C:\Users\me'` — exactly the form [`quote_for_shell`] produces for that
/// path.
///
/// The scan tracks quoting across the whole word rather than looking at the
/// first character, because a quote can open partway in: `~/'My Documents'` has
/// to keep its `~/` outside so the shell expands it, and the `'\''` seam that
/// carries a quote through a POSIX single-quoted string is three state changes
/// in a row rather than a special case.
pub fn unquote_word(word: &str, quoting: Quoting) -> String {
let posix_escapes = quoting == Quoting::Posix;
let mut out = String::with_capacity(word.len());
let mut quote: Option<char> = None;
let mut chars = word.chars().peekable();
while let Some(c) = chars.next() {
match (quote, c) {
// PowerShell's own seam: inside `'...'` a doubled quote is one
// literal quote and closes nothing.
(Some('\''), '\'') if quoting == Quoting::PowerShell && chars.peek() == Some(&'\'') => {
chars.next();
out.push('\'');
}
(Some(q), _) if c == q => quote = None,
// A backslash escapes inside double quotes and outside quotes, but
// never inside single ones. A trailing one has nothing to escape
// and stands for itself.
(Some('"') | None, '\\') if posix_escapes => match chars.next() {
Some(next) => out.push(next),
None => out.push('\\'),
},
(Some(_), _) => out.push(c),
(None, '\'' | '"') => quote = Some(c),
(None, _) => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_path_is_left_alone() {
assert_eq!(
quote_for_shell("/Users/me/notes.txt", None),
"/Users/me/notes.txt"
);
assert_eq!(quote_for_shell("notes.txt", None), "notes.txt");
assert_eq!(quote_for_shell("--message", None), "--message");
}
#[test]
fn posix_shells_get_single_quotes() {
assert_eq!(
quote_for_shell("/Users/me/My File (1).txt", Some("zsh")),
"'/Users/me/My File (1).txt'"
);
assert_eq!(
quote_for_shell("/a/$HOME & more", None),
"'/a/$HOME & more'"
);
assert_eq!(quote_for_shell("it's here", Some("zsh")), r"'it'\''s here'");
assert_eq!(quote_for_shell("", None), "''");
}
#[test]
fn a_newline_survives_inside_the_quotes() {
assert_eq!(quote_for_shell("a\nb", None), "'a\nb'");
}
/// The bug this module exists for: a Windows path used to come out as
/// `C:\\Users\\me\\My\ Docs`, which the shell then un-escaped back into a
/// path with no separators at all.
#[test]
fn a_windows_path_keeps_its_separators() {
assert_eq!(
quote_for_shell(r"C:\Users\me\My Docs", Some("powershell.exe")),
r"'C:\Users\me\My Docs'"
);
assert_eq!(
quote_for_shell(r"C:\Users\me\My Docs", Some(r"C:\Windows\System32\cmd.exe")),
"\"C:\\Users\\me\\My Docs\""
);
}
#[test]
fn cmd_exe_is_recognised_by_basename_on_either_separator() {
for p in [
"cmd",
"cmd.exe",
"CMD.EXE",
r"C:\Windows\System32\cmd.exe",
"/c/Windows/System32/cmd.exe",
] {
assert_eq!(
quote_for_shell("a b", Some(p)),
"\"a b\"",
"{p} should be recognised as cmd.exe"
);
}
assert_eq!(quote_for_shell("a b", Some("pwsh")), "'a b'");
assert_eq!(quote_for_shell("a b", Some("powershell.exe")), "'a b'");
}
#[test]
fn a_tilde_stays_outside_the_quotes_so_the_shell_expands_it() {
assert_eq!(quote_for_shell("~/My Documents", None), "~/'My Documents'");
assert_eq!(quote_for_shell("~/notes.txt", None), "~/notes.txt");
assert_eq!(quote_for_shell("~", None), "~");
// Not a home reference — a file whose name starts with a tilde.
assert_eq!(quote_for_shell("~weird name", None), "'~weird name'");
}
/// An apostrophe is the one character the three dialects disagree about,
/// and `C:\Users\O'Brien` is a real Windows home directory. Whatever a
/// shell is handed has to be what comes back out of it.
#[test]
fn unquoting_undoes_what_quoting_did_in_every_dialect() {
for shell in [Some("zsh"), Some("powershell.exe"), Some("cmd.exe")] {
for path in [
"/Users/me/My File (1).txt",
"it's here",
r"C:\Users\me\My Docs",
r"C:\Users\O'Brien\notes.txt",
"~/My Documents",
] {
let quoted = quote_for_shell(path, shell);
assert_eq!(
unquote_word(&quoted, quoting_for(shell)),
path,
"round trip of {path} under {shell:?} (quoted as {quoted})"
);
}
}
}
/// PowerShell does not join a quoted string to the bare word beside it, so
/// the POSIX `'\''` seam is not a seam there — it is three tokens.
#[test]
fn powershell_doubles_an_embedded_quote_where_posix_breaks_out() {
assert_eq!(
quote_for_shell(r"C:\Users\O'Brien\a.txt", Some("powershell.exe")),
r"'C:\Users\O''Brien\a.txt'"
);
assert_eq!(
quote_for_shell("it's here", Some("pwsh")),
"'it''s here'",
"pwsh speaks the same dialect"
);
assert_eq!(
quote_for_shell(r"C:\Users\O'Brien\a.txt", Some("CMD.EXE")),
"\"C:\\Users\\O'Brien\\a.txt\"",
"cmd.exe quotes with \", so an apostrophe needs nothing"
);
}
#[test]
fn an_unterminated_quote_still_unquotes() {
// What completion actually sees: the user is mid-word.
assert_eq!(unquote_word("'My Doc", Quoting::Posix), "My Doc");
assert_eq!(unquote_word("\"My Doc", Quoting::Cmd), "My Doc");
}
#[test]
fn a_hand_typed_backslash_escape_is_honoured_only_where_it_is_one() {
assert_eq!(
unquote_word(r"My\ Documents", Quoting::Posix),
"My Documents"
);
// On Windows the same bytes are a path, not an escape — this is the
// half of the bug that made inline path completion unable to resolve
// any directory there.
assert_eq!(unquote_word(r"C:\Users\me", Quoting::Cmd), r"C:\Users\me");
assert_eq!(
unquote_word(r"C:\Users\me", Quoting::PowerShell),
r"C:\Users\me"
);
assert_eq!(unquote_word(r"trailing\", Quoting::Posix), r"trailing\");
}
#[test]
fn the_shell_decides_the_dialect() {
assert_eq!(quoting_for(Some("zsh")), Quoting::Posix);
assert_eq!(quoting_for(Some("/bin/bash")), Quoting::Posix);
assert_eq!(quoting_for(Some("cmd.exe")), Quoting::Cmd);
assert_eq!(quoting_for(Some("powershell.exe")), Quoting::PowerShell);
assert_eq!(quoting_for(Some("POWERSHELL.EXE")), Quoting::PowerShell);
assert_eq!(quoting_for(Some("pwsh")), Quoting::PowerShell);
let posix_escapes_for = |s| quoting_for(s) == Quoting::Posix;
assert!(posix_escapes_for(Some("zsh")));
assert!(posix_escapes_for(Some("/bin/bash")));
assert!(!posix_escapes_for(Some("cmd.exe")));
assert!(!posix_escapes_for(Some("powershell.exe")));
assert!(!posix_escapes_for(Some("pwsh")));
assert_eq!(posix_escapes_for(None), !cfg!(windows));
}
}