feat(ssh): shell integration for Windows hosts whose default shell is PowerShell (#1040)

The remote shell probe only understood POSIX answers, so a native SSH pane
on a Windows OpenSSH host always fell back to a plain shell with no prompt
marks, cwd reporting or title.

The probe is now a polyglot every default shell can read: POSIX shells
answer as before; PowerShell answers with its edition and $PSHOME, which
name the exact executable; cmd.exe echoes the line back verbatim and is
recognised, then deliberately left alone. For PowerShell the bootstrap
starts a second, interactive PowerShell of the same executable with the
existing integration via -NoLogo -NoExit -EncodedCommand (comments
stripped, about 6.5K chars total, well inside the Win32 command-line
limit), and a redialled pane's start directory (/C:/..., C:/..., UNC) is
restored with Set-Location -LiteralPath inside the encoded script.
This commit is contained in:
l0ng-ai
2026-09-30 16:35:59 +08:00
committed by GitHub
parent 0701278fe9
commit 8efea021e3
2 changed files with 566 additions and 5 deletions
@@ -1270,19 +1270,62 @@ pub fn setup(program: Option<&str>, args: &[String], has_custom_args: bool) -> O
}
pub mod remote {
use super::{FISH_INTEGRATION, bash_rcfile, shell_quote, zsh_redirectors};
use super::{
FISH_INTEGRATION, POWERSHELL_INTEGRATION, bash_rcfile, powershell_encoded_command,
shell_quote, zsh_redirectors,
};
pub const PROBE_COMMAND: &str = "echo __tty7_shell; echo $SHELL";
/// Asks the far host which shell it runs, and is written to be read by
/// every shell sshd might hand it to — the exec request runs through the
/// account's default shell, and on Windows OpenSSH that is `cmd.exe /c`,
/// `powershell.exe -c` or `pwsh -c`, not a POSIX `-c`. So it sticks to
/// words all of them read without complaint, and each answers differently:
///
/// - a POSIX shell (and fish) prints the marker, `$SHELL`, and the two
/// `__tty7_ps*=` keys with nothing after the `=` (fish drops the word
/// outright and prints an empty line);
/// - PowerShell has no `$SHELL` variable, so that `echo` prints nothing,
/// and fills the keys from its own automatic variables — the edition
/// (`Desktop` is Windows PowerShell 5.1, `Core` is pwsh) and the
/// directory its executable lives in;
/// - cmd.exe has no `;` separator: it echoes the whole line back verbatim,
/// marker and all, on one line.
///
/// The shape of each word is load-bearing. No quotes: Windows OpenSSH
/// hands the command to the shell through a Win32 command line, and `"`s
/// there are argv syntax, gone before PowerShell reads the text. No
/// parentheses or pipes: a POSIX shell parses the whole line before
/// running any of it, so one construct it rejects would cost the answer
/// it does give. `key=$VAR` expands in every dialect that expands at all
/// (PowerShell's argument mode included), the `=` ending the key.
pub const PROBE_COMMAND: &str = "echo __tty7_shell; echo $SHELL; \
echo __tty7_psedition=$PSEdition; echo __tty7_pshome=$PSHOME";
const PROBE_MARKER: &str = "__tty7_shell";
const PS_EDITION_KEY: &str = "__tty7_psedition=";
const PS_HOME_KEY: &str = "__tty7_pshome=";
const HEREDOC: &str = "__TTY7_RC_EOF__";
/// The longest bootstrap sent to a PowerShell host. Windows caps a
/// process command line at 32,767 UTF-16 units, and the bootstrap rides
/// inside two of them: sshd's `<shell> -c <bootstrap>`, then that shell's
/// launch of the interactive PowerShell, which is a little shorter. The
/// headroom is for sshd's part — the shell's path and switch — with room
/// to spare for any quoting it adds. cmd.exe's far smaller 8,191 never
/// applies: nothing is sent through cmd.
const POWERSHELL_COMMAND_LINE_BUDGET: usize = 30_000;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RemoteShell {
Zsh,
Bash,
Fish,
/// Windows PowerShell or pwsh as a Windows account's default shell.
/// The path the probe pairs with it is the executable, `C:\…\pwsh.exe`.
PowerShell,
}
impl RemoteShell {
@@ -1302,13 +1345,63 @@ pub mod remote {
.map(|l| l.trim_end_matches('\r').trim())
.skip_while(|l| *l != PROBE_MARKER);
lines.next()?;
let path = lines.find(|l| !l.is_empty())?;
let lines: Vec<&str> = lines.collect();
if let Some(exe) = powershell_from_probe(&lines) {
return Some((RemoteShell::PowerShell, exe));
}
let path = *lines.iter().find(|l| !l.is_empty())?;
if !path.starts_with('/') {
return None;
}
RemoteShell::from_path(path).map(|shell| (shell, path.to_string()))
}
/// Whether the probe was answered by cmd.exe — Windows OpenSSH's default
/// shell out of the box. Such a host is left a plain shell, on purpose:
/// cmd has no hook that could carry the integration, and starting
/// PowerShell where the account asked for cmd would change the user's
/// shell rather than instrument it. A host whose `DefaultShell` names
/// PowerShell gets it. Only for the log, so the absence explains itself.
pub fn probe_answered_by_cmd(output: &str) -> bool {
let echoed = format!("{PROBE_MARKER};");
output.lines().any(|l| l.trim().starts_with(&echoed))
}
/// The PowerShell executable a probe describes, if PowerShell answered.
///
/// The edition names the binary — `powershell.exe` is only ever Windows
/// PowerShell, `pwsh` only ever Core — and `$PSHOME` is the directory it
/// sits in, so the path is exactly the shell that ran the probe: the one
/// the account is configured with, whatever other edition is installed
/// beside it. A Windows host with pwsh installed but Windows PowerShell as
/// its default stays on Windows PowerShell, just as a plain shell request
/// would have left it.
///
/// Windows hosts only. pwsh as a Unix login shell answers the probe too,
/// but there the binary in `$PSHOME` is not always runnable on its own —
/// a package manager's install launches it through a wrapper that sets up
/// the .NET runtime first, and the wrapper is what `$SHELL` names, where
/// the probe cannot read it — so such a host keeps its plain shell.
fn powershell_from_probe(lines: &[&str]) -> Option<String> {
let field = |key: &str| lines.iter().find_map(|l| l.strip_prefix(key));
let edition = field(PS_EDITION_KEY)?;
let home = field(PS_HOME_KEY)?.trim_end_matches(['/', '\\']);
if !is_windows_path(home) || home.contains('\0') {
return None;
}
match edition {
"Desktop" => Some(format!(r"{home}\powershell.exe")),
"Core" => Some(format!(r"{home}\pwsh.exe")),
_ => None,
}
}
/// A drive (`C:\…`, `C:/…`) or UNC (`\\server\…`) path.
fn is_windows_path(path: &str) -> bool {
let b = path.as_bytes();
(b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') || path.starts_with(r"\\")
}
/// The script a native SSH session execs in place of a bare shell request.
///
/// `start_dir` is a directory *on the remote host* — where the pane was the
@@ -1328,6 +1421,9 @@ pub mod remote {
shell_path: &str,
start_dir: Option<&str>,
) -> String {
if shell == RemoteShell::PowerShell {
return powershell_bootstrap(shell_path, start_dir);
}
let mut out = start_dir
.and_then(usable_start_dir)
.map(|dir| cd_line(shell, dir))
@@ -1336,6 +1432,7 @@ pub mod remote {
RemoteShell::Zsh => zsh_bootstrap(shell_path),
RemoteShell::Bash => bash_bootstrap(shell_path),
RemoteShell::Fish => fish_bootstrap(shell_path),
RemoteShell::PowerShell => unreachable!("returned above"),
});
out
}
@@ -1357,10 +1454,127 @@ pub mod remote {
let quoted = match shell {
RemoteShell::Zsh | RemoteShell::Bash => shell_quote(dir),
RemoteShell::Fish => fish_quote(dir),
RemoteShell::PowerShell => unreachable!("PowerShell moves with Set-Location"),
};
format!("builtin cd -- {quoted} 2>/dev/null\n")
}
/// The command the host's default PowerShell runs: start a second,
/// interactive PowerShell — the same executable — carrying the
/// integration.
///
/// A second process because Windows has no `exec`: the shell sshd started
/// was told `-c`, which runs one command and leaves, and nothing can turn
/// it interactive after the fact. It waits on the child and hands back its
/// exit code, so the session ends when the user's shell does.
///
/// The child is started the way a local PowerShell pane is (`-NoLogo
/// -NoExit -EncodedCommand`), so the user's profile loads first and the
/// integration wraps whatever prompt it settled on. The start directory
/// rides inside the encoded script too — base64 carries any path through
/// the Win32 command line untouched, where a literal would be at the
/// mercy of sshd's quoting — which puts it after the profile: a profile
/// that moves somewhere is overruled for a redialled pane, which is the
/// point of asking for a directory at all. One that has gone away fails
/// quietly into wherever the profile left the session.
fn powershell_bootstrap(exe: &str, start_dir: Option<&str>) -> String {
let build = |dir: Option<&str>| {
let mut script = dir.map(set_location_line).unwrap_or_default();
script.push_str(&compact_powershell(POWERSHELL_INTEGRATION));
format!(
"& {} -NoLogo -NoExit -EncodedCommand {}; exit $LASTEXITCODE",
ps_quote(exe),
powershell_encoded_command(&script)
)
};
let dir = start_dir.and_then(powershell_start_dir);
let with_dir = build(dir.as_deref());
// A start directory long enough to push the command line past what
// Windows will run is dropped rather than costing the session: the
// pane opens where the login leaves it, still integrated.
if dir.is_some() && with_dir.chars().count() > POWERSHELL_COMMAND_LINE_BUDGET {
return build(None);
}
with_dir
}
/// `dir` as a path the far Windows host can only read as one place.
///
/// The integration reports its cwd the way a `file://` URI spells a drive
/// path — `/C:/Users/ann` — and the local side may have kept it that way
/// (a Unix tty7) or already unwrapped it to `C:/Users/ann` (a Windows
/// one); both mean drive C. A UNC share comes back as `//server/share/…`.
/// Anything else is refused as ambiguous: `/Users/ann` is relative to
/// whichever drive is current, and a bare `C:` means wherever drive C
/// last was.
fn powershell_start_dir(dir: &str) -> Option<String> {
if dir.contains('\0') {
return None;
}
let drive = |b: &[u8]| {
b.len() >= 3
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& matches!(b[2], b'/' | b'\\')
};
if drive(dir.as_bytes()) {
return Some(dir.to_string());
}
if let Some(rest) = dir.strip_prefix('/')
&& drive(rest.as_bytes())
{
return Some(rest.to_string());
}
let unc = dir.replace('/', r"\");
let (server, rest) = unc.strip_prefix(r"\\")?.split_once('\\')?;
let share = rest.split('\\').next().unwrap_or_default();
// `\\?\` and `\\.\` are device namespaces, not a server's shares.
let named = |s: &str| !s.is_empty() && s != "?" && s != ".";
(named(server) && named(share)).then_some(unc)
}
/// `-LiteralPath`, so brackets and `*` in a directory name are only
/// characters; a directory that is gone is caught rather than reported,
/// and `Stop` makes sure it is catchable — without it, a missing path is a
/// non-terminating error that prints straight past the `try`.
fn set_location_line(dir: &str) -> String {
format!(
"try {{ Set-Location -LiteralPath {} -ErrorAction Stop }} catch {{ }}\n",
ps_quote(dir)
)
}
/// PowerShell single-quoting. PowerShell takes the typographic single
/// quotes for quote marks as well, so they are doubled along with `'`.
fn ps_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if matches!(c, '\'' | '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}') {
out.push(c);
}
out.push(c);
}
out.push('\'');
out
}
/// The integration with its comments, blank lines and indentation
/// dropped. It travels as base64 of UTF-16 — each character costs 8/3 of
/// one on the command line — and the commentary is half of it. The script
/// has no here-strings and no string that breaks onto a line starting
/// with `#`, so such a line is always a comment.
fn compact_powershell(script: &str) -> String {
let mut out = String::with_capacity(script.len());
for line in script.lines().map(str::trim) {
if !line.is_empty() && !line.starts_with('#') {
out.push_str(line);
out.push('\n');
}
}
out
}
fn fish_quote(s: &str) -> String {
format!("'{}'", s.replace('\\', r"\\").replace('\'', r"\'"))
}
@@ -1736,6 +1950,346 @@ fi
assert!(ok, "bash rejected the remote rcfile:\n{stderr}");
}
}
// --- Windows OpenSSH hosts ---
const WIN_PS51: &str = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe";
const WIN_PWSH: &str = r"C:\Program Files\PowerShell\7\pwsh.exe";
#[test]
fn probe_names_the_powershell_that_answered_it() {
// What each edition prints for PROBE_COMMAND: no line at all for
// `echo $SHELL`, then the two keys. Windows output is CRLF.
assert_eq!(
parse_probe(
"__tty7_shell\r\n__tty7_psedition=Desktop\r\n\
__tty7_pshome=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\r\n"
),
Some((RemoteShell::PowerShell, WIN_PS51.to_string()))
);
assert_eq!(
parse_probe(
"__tty7_shell\r\n__tty7_psedition=Core\r\n\
__tty7_pshome=C:\\Program Files\\PowerShell\\7\\\r\n"
),
Some((RemoteShell::PowerShell, WIN_PWSH.to_string()))
);
// A profile that talks on the way in.
assert_eq!(
parse_probe(
"Loading personal and system profiles took 412ms.\r\n__tty7_shell\r\n\
__tty7_psedition=Core\r\n__tty7_pshome=D:\\tools\\pwsh\r\n"
),
Some((
RemoteShell::PowerShell,
r"D:\tools\pwsh\pwsh.exe".to_string()
))
);
}
#[test]
fn probe_declines_powershell_answers_it_cannot_place() {
for out in [
// pwsh as a Unix login shell keeps its plain shell.
"__tty7_shell\n__tty7_psedition=Core\n__tty7_pshome=/opt/microsoft/powershell/7\n",
"__tty7_shell\n__tty7_psedition=Desktop\n__tty7_pshome=/opt/ps\n",
"__tty7_shell\n__tty7_psedition=Core\n__tty7_pshome=\n",
"__tty7_shell\n__tty7_psedition=Classic\n__tty7_pshome=C:\\ps\n",
"__tty7_shell\n__tty7_psedition=Core\n__tty7_pshome=relative\\ps\n",
// Keys before the marker are someone's banner, not an answer.
"__tty7_psedition=Core\n__tty7_pshome=C:\\ps\n__tty7_shell\n",
] {
assert_eq!(parse_probe(out), None, "{out:?}");
}
}
#[test]
fn cmd_answers_the_probe_with_its_own_echo_and_is_left_alone() {
// cmd has no `;`: the first `echo` prints the rest of the line.
let echoed = PROBE_COMMAND.strip_prefix("echo ").unwrap();
let out = format!("{echoed}\r\n");
assert_eq!(parse_probe(&out), None);
assert!(probe_answered_by_cmd(&out));
// sshd may hand cmd the command in quotes; cmd echoes the tail one.
assert!(probe_answered_by_cmd(&format!("{echoed}\"\r\n")));
assert!(!probe_answered_by_cmd("__tty7_shell\n/bin/zsh\n"));
assert!(!probe_answered_by_cmd(
"__tty7_shell\n__tty7_psedition=Core\n__tty7_pshome=C:\\ps\n"
));
}
#[test]
fn posix_answers_are_read_as_before_with_the_empty_keys_trailing() {
assert_eq!(
parse_probe("__tty7_shell\n/bin/zsh\n__tty7_psedition=\n__tty7_pshome=\n"),
Some((RemoteShell::Zsh, "/bin/zsh".to_string()))
);
// fish drops a word whose variable is unset: two empty lines.
assert_eq!(
parse_probe("__tty7_shell\n/usr/bin/fish\n\n\n"),
Some((RemoteShell::Fish, "/usr/bin/fish".to_string()))
);
// An unset $SHELL must not promote a key line to the shell path.
assert_eq!(
parse_probe("__tty7_shell\n\n__tty7_psedition=\n__tty7_pshome=\n"),
None
);
}
/// Runs PROBE_COMMAND through every shell installed here the way sshd
/// would (`<shell> -c <command>`), and checks each is recognised as
/// itself. A Unix pwsh is declined like any Unix PowerShell, but what
/// it prints shows the probe's words expand in PowerShell's argument
/// mode — the same parser Windows PowerShell and a Windows pwsh use.
#[cfg(unix)]
#[test]
fn real_shells_answer_the_probe_as_themselves() {
use std::process::Command;
let run = |bin: &str, args: &[&str]| {
Command::new(bin)
.args(args)
.arg("-c")
.arg(PROBE_COMMAND)
.env("SHELL", format!("/usr/bin/{bin}"))
.output()
.ok()
};
for (bin, shell) in [
("sh", None),
("dash", None),
("bash", Some(RemoteShell::Bash)),
("zsh", Some(RemoteShell::Zsh)),
("fish", Some(RemoteShell::Fish)),
] {
let Some(out) = run(bin, &[]) else { continue };
let parsed = parse_probe(&String::from_utf8_lossy(&out.stdout));
assert_eq!(parsed.map(|(s, _)| s), shell, "{bin}: {out:?}");
}
if let Some(out) = run("pwsh", &["-NoProfile", "-NonInteractive"]) {
let text = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.first(), Some(&PROBE_MARKER), "{text:?}");
assert_eq!(lines.get(1), Some(&"__tty7_psedition=Core"), "{text:?}");
let home = lines
.get(2)
.and_then(|l| l.strip_prefix(PS_HOME_KEY))
.unwrap_or_default();
assert!(
std::path::Path::new(home).join("pwsh").is_file(),
"{home:?} is not pwsh's home ({text:?})"
);
assert_eq!(parse_probe(&text), None);
assert!(!probe_answered_by_cmd(&text));
}
}
/// The script inside `-EncodedCommand`, and everything around it.
fn split_powershell_bootstrap(script: &str) -> (&str, String, &str) {
let (head, rest) = script
.split_once("-EncodedCommand ")
.expect("carries an encoded command");
let (b64, tail) = rest.split_once(';').expect("ends with the exit");
(head, super::super::tests::decode_utf16le_base64(b64), tail)
}
#[test]
fn powershell_bootstrap_starts_the_same_powershell_with_the_integration() {
let script = bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, None);
assert!(
!script.contains('\n'),
"one line through the Win32 command line"
);
assert!(!script.contains('"'), "no argv quoting for sshd to eat");
let (head, body, tail) = split_powershell_bootstrap(&script);
assert_eq!(
head,
r"& 'C:\Program Files\PowerShell\7\pwsh.exe' -NoLogo -NoExit "
);
assert_eq!(tail, " exit $LASTEXITCODE");
assert_eq!(body, compact_powershell(POWERSHELL_INTEGRATION));
for needle in ["]133;A", "]133;B", "]133;C", "]133;D", "]7;file://", "]0;"] {
assert!(body.contains(needle), "compacted away {needle}");
}
assert!(!body.lines().any(|l| l.starts_with('#')));
let ps51 = bootstrap_command(RemoteShell::PowerShell, WIN_PS51, None);
assert!(ps51.starts_with(&format!("& '{WIN_PS51}' -NoLogo -NoExit ")));
}
#[test]
fn windows_start_dirs_are_taken_only_when_they_name_one_place() {
let cases = [
("/C:/Users/ann/my dir", Some("C:/Users/ann/my dir")),
("C:/Users/ann", Some("C:/Users/ann")),
(r"d:\work", Some(r"d:\work")),
("/C:/", Some("C:/")),
("//fs01/share/team", Some(r"\\fs01\share\team")),
(r"\\fs01\share", Some(r"\\fs01\share")),
// Relative to whichever drive is current.
("/Users/ann", None),
// Wherever drive C last was.
("C:", None),
("/C:", None),
("C:Users", None),
("~/x", None),
("x", None),
("", None),
("//fs01", None),
("//fs01/", None),
(r"\\?\C:\x", None),
(r"\\.\pipe\x", None),
("C:/a\0b", None),
];
for (dir, want) in cases {
assert_eq!(powershell_start_dir(dir).as_deref(), want, "{dir:?}");
}
}
#[test]
fn a_start_dir_opens_the_encoded_script_with_one_quiet_set_location() {
let dir = "/C:/Users/ann/it's ‘quoted’ $HOME";
let script = bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, Some(dir));
let (_, body, _) = split_powershell_bootstrap(&script);
let (first, rest) = body.split_once('\n').unwrap();
assert_eq!(
first,
"try { Set-Location -LiteralPath 'C:/Users/ann/it''s ‘‘quoted’’ $HOME' \
-ErrorAction Stop } catch { }"
);
assert_eq!(rest, compact_powershell(POWERSHELL_INTEGRATION));
for refused in ["/Users/ann", "C:", "relative"] {
assert_eq!(
bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, Some(refused)),
bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, None),
"{refused:?} must not become a Set-Location"
);
}
}
#[test]
fn powershell_bootstrap_fits_a_windows_command_line() {
// sshd's own part: the shell's path and its `-c`.
let sshd = WIN_PWSH.len() + " -c ".len() + 2;
let longest_classic = format!("C:/{}", "d".repeat(257));
for dir in [None, Some(longest_classic.as_str())] {
let script = bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, dir);
let len = script.chars().count();
assert!(
len <= POWERSHELL_COMMAND_LINE_BUDGET && len + sshd < 32_767,
"{len} chars with start dir {dir:?}"
);
}
// A long-path-aware host can report a far longer cwd. It costs the
// start dir, not the session.
let huge = format!("C:/{}", "d/".repeat(8_000));
assert_eq!(
bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, Some(&huge)),
bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, None),
);
}
#[test]
fn ps_quote_doubles_every_single_quote_powershell_honours() {
assert_eq!(ps_quote("a'b"), "'a''b'");
assert_eq!(ps_quote("a\u{2019}b"), "'a\u{2019}\u{2019}b'");
assert_eq!(ps_quote(r"C:\x $y"), r"'C:\x $y'");
}
/// A throwaway home for a pwsh run, so no real profile is read.
#[cfg(unix)]
fn pwsh(home: &std::path::Path) -> std::process::Command {
let mut cmd = std::process::Command::new("pwsh");
cmd.current_dir(home)
.env("HOME", home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("XDG_DATA_HOME", home.join(".local/share"))
.env("XDG_CACHE_HOME", home.join(".cache"))
.env_remove("TTY7_SHELL_INTEGRATION");
cmd
}
/// Runs the bootstrap in a real pwsh the way a PowerShell default
/// shell runs an exec request (`<shell> -c <bootstrap>`), with the
/// Windows executable swapped for the local one and commands on stdin
/// in place of a user, and checks the child decodes and runs the
/// integration and hands its exit code back out through the parent.
#[cfg(unix)]
#[test]
fn a_real_pwsh_runs_the_bootstrap_end_to_end() {
use std::io::Write as _;
use std::process::Stdio;
let home = tempfile::tempdir().expect("tempdir");
let script = bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, None).replacen(
&ps_quote(WIN_PWSH),
"'pwsh'",
1,
);
let Ok(mut child) = pwsh(home.path())
.args(["-NoProfile", "-c", &script])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
else {
return;
};
child
.stdin
.take()
.unwrap()
.write_all(
b"Write-Output \"integrated=$env:TTY7_SHELL_INTEGRATION\"\n\
Write-Output \"prompt=$((Get-Command prompt).ScriptBlock -match '133;A')\"\n\
exit 7\n",
)
.unwrap();
let out = child.wait_with_output().expect("pwsh ran");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stdout.contains("integrated=1"), "{stdout}\n{stderr}");
assert!(stdout.contains("prompt=True"), "{stdout}\n{stderr}");
assert_eq!(out.status.code(), Some(7), "{stdout}\n{stderr}");
}
/// The start directory line in a real pwsh: it lands in a directory
/// that is there, and says nothing and stays put for one that is gone.
#[cfg(unix)]
#[test]
fn a_real_pwsh_lands_in_the_start_dir_and_stays_quiet_without_it() {
let root = tempfile::tempdir().expect("tempdir");
let root = root.path().canonicalize().expect("canonical tempdir");
let home = root.join("home");
let target = root.join("it's [a] ‘dir’");
std::fs::create_dir_all(&home).expect("home");
std::fs::create_dir_all(&target).expect("target");
let missing = root.join("gone since");
for (dir, want) in [(&target, &target), (&missing, &home)] {
let script = format!(
"{}(Get-Location).Path",
set_location_line(dir.to_str().unwrap())
);
let Ok(out) = pwsh(&home)
.args(["-NoProfile", "-NonInteractive", "-c", &script])
.output()
else {
return;
};
assert!(out.status.success(), "{out:?}");
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim_end(),
want.to_str().unwrap()
);
assert!(
out.stderr.is_empty(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
}
}
@@ -2856,7 +3410,7 @@ mod tests {
);
}
fn decode_utf16le_base64(b64: &str) -> String {
pub(super) fn decode_utf16le_base64(b64: &str) -> String {
fn val(c: u8) -> Option<u32> {
match c {
b'A'..=b'Z' => Some((c - b'A') as u32),
+8 -1
View File
@@ -800,7 +800,14 @@ async fn probe_remote_shell(conn: &SshConnection) -> Option<(remote::RemoteShell
};
let _ = tokio::time::timeout(PROBE_TIMEOUT, collect).await;
remote::parse_probe(&String::from_utf8_lossy(&out))
let out = String::from_utf8_lossy(&out);
if remote::probe_answered_by_cmd(&out) {
log::debug!(
"ssh {:?}: default shell is cmd.exe, left as it is",
conn.key()
);
}
remote::parse_probe(&out)
}
async fn probe_remote_env(conn: &SshConnection) -> Option<remote_link::RemoteEnv> {