feat(remote): let a remote workspace target a Windows host over SSH (#1041)

A remote workspace refused any machine that was not Linux or macOS. This
teaches the installer and the link to reach a Windows OpenSSH host:

- Detection: `uname -sm` is still asked first, unchanged. When it fails
  (cmd.exe / PowerShell) or answers from Git for Windows, MSYS2 or Cygwin,
  a PowerShell probe reads PROCESSOR_ARCHITECTURE. The detected platform
  must match the SFTP home's shape, so a WSL DefaultShell over Windows
  SFTP is refused instead of installing a Linux server at /C:/...
- Assets: tty7-server-windows-{x86_64,aarch64}.exe, verified against
  checksums.txt like every other server; built by new server-windows
  jobs in release.yml and nightly.yml (ARM64 leg non-blocking), and the
  CI vcruntime guard now covers tty7-server.exe.
- Install: %USERPROFILE%\AppData\Local\tty7\bin\tty7-server-cXpY.exe via
  SFTP (/C:/... spelling). No mode bits are required or set. A running
  image is renamed aside to free its name and swept on the next install.
- Commands: every Windows command is a PowerShell script sent as
  -EncodedCommand, which survives cmd.exe, PowerShell and bash as the
  DefaultShell. The daemon is launched through Win32_Process.Create so
  it outlives the SSH session's job object, with this session's
  environment handed over; restarts use a new `tty7-server --stop`.
- Link: a Windows server is always reached by session exec with a plain
  `"C:\...\tty7-server-cXpY.exe" --stdio` (no env probe, no
  stream-local forward).
- Server: `--stdio` (control and --pane) now bridges on Windows to the
  daemon's loopback TCP endpoints instead of refusing.

Tested against a fake Windows host in install/windows_tests.rs; not yet
run against a real Windows machine.
This commit is contained in:
l0ng-ai
2026-09-30 16:36:04 +08:00
committed by GitHub
parent 8efea021e3
commit 53f661e1a1
12 changed files with 2051 additions and 110 deletions
+2 -1
View File
@@ -180,7 +180,8 @@ jobs:
run: |
& ./.github/scripts/assert-no-vcruntime.ps1 `
"target/${{ matrix.target }}/debug/tty7-app.exe" `
"target/${{ matrix.target }}/debug/tty7.exe"
"target/${{ matrix.target }}/debug/tty7.exe" `
"target/${{ matrix.target }}/debug/tty7-server.exe"
# `cargo test` has no timeout of its own, so one hung test is
# indistinguishable from a slow suite until the job hits GitHub's six-hour
+62 -1
View File
@@ -422,11 +422,72 @@ jobs:
path: tty7/dist/${{ matrix.asset }}
if-no-files-found: error
# Mirrors release.yml's server-windows job; keep the two in sync when editing.
# No RUSTFLAGS, for the reason spelled out there: it would drop `+crt-static`.
server-windows:
needs: plan
if: needs.plan.outputs.build == 'true'
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-pc-windows-msvc
asset: tty7-server-windows-x86_64.exe
experimental: false
- target: aarch64-pc-windows-msvc
asset: tty7-server-windows-aarch64.exe
experimental: true
runs-on: windows-latest
continue-on-error: ${{ matrix.experimental }}
env:
CARGO_PROFILE_RELEASE_STRIP: symbols
steps:
- name: Checkout tty7
uses: actions/checkout@v4
with:
path: tty7
- name: Stamp nightly version
working-directory: tty7
shell: bash
run: bash .github/scripts/stamp-version.sh "${{ needs.plan.outputs.version }}"
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: tty7
key: ${{ matrix.target }}
- name: Build tty7-server
working-directory: tty7
run: cargo build --release -p tty7-server --target ${{ matrix.target }}
- name: Assert the binary needs no VC++ redistributable
working-directory: tty7
shell: pwsh
run: '& ./.github/scripts/assert-no-vcruntime.ps1 "target/${{ matrix.target }}/release/tty7-server.exe"'
- name: Stage the asset
working-directory: tty7
shell: pwsh
run: |
New-Item -ItemType Directory -Force dist | Out-Null
Copy-Item "target/${{ matrix.target }}/release/tty7-server.exe" "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v7
with:
name: nightly-${{ matrix.asset }}
path: tty7/dist/${{ matrix.asset }}
if-no-files-found: error
# Single publish step after all platforms succeed, so the rolling release is
# always complete — a failed platform means tonight's nightly is skipped
# entirely and users keep yesterday's, never a partial asset set.
publish:
needs: [plan, build, server-musl, server-macos]
needs: [plan, build, server-musl, server-macos, server-windows]
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
+67 -1
View File
@@ -377,6 +377,72 @@ jobs:
path: tty7/dist/${{ matrix.asset }}
if-no-files-found: error
# The server for remote Windows hosts (Windows OpenSSH), under the same
# contract: flat, version-free names that `install::asset` derives from the
# host's `PROCESSOR_ARCHITECTURE`. They keep `.exe` because the installer
# writes the file under that name, and the checksum line is looked up by it.
#
# No RUSTFLAGS here, unlike the other server jobs: setting it would replace
# `.cargo/config.toml`'s `+crt-static` wholesale, and a server that imports
# VCRUNTIME140.dll will not start on a Windows host without the Visual C++
# redistributable — which a headless server is the likeliest machine to lack.
# Symbols are stripped through the profile instead.
#
# ARM64 is new ground for this repository's Windows builds (the app itself
# ships x64 only), so its leg may fail without holding up the release; an
# ARM64 host then gets a clear "could not download" instead of a server.
server-windows:
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-pc-windows-msvc
asset: tty7-server-windows-x86_64.exe
experimental: false
- target: aarch64-pc-windows-msvc
asset: tty7-server-windows-aarch64.exe
experimental: true
runs-on: windows-latest
continue-on-error: ${{ matrix.experimental }}
env:
CARGO_PROFILE_RELEASE_STRIP: symbols
steps:
- name: Checkout tty7
uses: actions/checkout@v4
with:
path: tty7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: tty7
key: ${{ matrix.target }}
- name: Build tty7-server
working-directory: tty7
run: cargo build --release --locked -p tty7-server --target ${{ matrix.target }}
- name: Assert the binary needs no VC++ redistributable
working-directory: tty7
shell: pwsh
run: '& ./.github/scripts/assert-no-vcruntime.ps1 "target/${{ matrix.target }}/release/tty7-server.exe"'
- name: Stage the asset
working-directory: tty7
shell: pwsh
run: |
New-Item -ItemType Directory -Force dist | Out-Null
Copy-Item "target/${{ matrix.target }}/release/tty7-server.exe" "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v7
with:
name: release-${{ matrix.asset }}
path: tty7/dist/${{ matrix.asset }}
if-no-files-found: error
# Single assembly step, after every GUI platform and every server slice
# succeeds. The release object is
# created as a **draft** and left that way: a draft is invisible to both
@@ -385,7 +451,7 @@ jobs:
# empty. Publishing is the release skill's job — it verifies the platform assets and
# writes the body first, then flips the draft. See .claude/skills/release/SKILL.md.
draft-release:
needs: [build, server-musl, server-macos]
needs: [build, server-musl, server-macos, server-windows]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
env:
+37 -6
View File
@@ -579,13 +579,25 @@ impl<'a> HookTarget<'a> {
}
pub fn remote(host: &'a dyn Host, home: PathBuf) -> HookTarget<'a> {
use crate::daemon::install::asset;
let dialect = crate::daemon::install::RemoteProtocol::of_this_build();
let binary = crate::daemon::install::asset::remote_paths(
&home.to_string_lossy(),
dialect.control,
dialect.protocol,
)
.binary;
let home_str = home.to_string_lossy();
// A Windows server reports its home natively (`C:\Users\me`), and the
// hook command runs there, so the binary is named the way that
// machine's installer put it — and spelled natively again.
let binary = match asset::sftp_path_from_windows(&home_str) {
Some(sftp_home) => {
let installed = asset::remote_paths_on(
asset::RemotePlatform::Windows,
&sftp_home,
dialect.control,
dialect.protocol,
)
.binary;
asset::windows_native_path(&installed).unwrap_or(installed)
}
None => asset::remote_paths(&home_str, dialect.control, dialect.protocol).binary,
};
HookTarget {
host,
home,
@@ -2152,6 +2164,25 @@ mod tests {
}
}
#[test]
fn a_remote_hook_names_the_server_its_own_installer_placed() {
let host = FakeRemote::shared();
let dialect = crate::daemon::install::RemoteProtocol::of_this_build();
let name = crate::daemon::install::asset::binary_name(dialect.control, dialect.protocol);
let unix = HookTarget::remote(&*host, PathBuf::from("/home/me"));
assert_eq!(
unix.exe,
PathBuf::from(format!("/home/me/.local/share/tty7/bin/{name}"))
);
let windows = HookTarget::remote(&*host, PathBuf::from(r"C:\Users\me"));
assert_eq!(
windows.exe,
PathBuf::from(format!(r"C:\Users\me\AppData\Local\tty7\bin\{name}.exe"))
);
}
#[test]
fn the_new_hook_agents_target_the_paths_their_clis_read() {
let host = FakeRemote::shared();
+363 -11
View File
@@ -10,17 +10,69 @@ pub const ASSET_LINUX_AARCH64: &str = "tty7-server-linux-aarch64-musl";
pub const ASSET_MACOS_X86_64: &str = "tty7-server-macos-x86_64";
pub const ASSET_MACOS_AARCH64: &str = "tty7-server-macos-aarch64";
/// Windows servers keep the `.exe` suffix in the published name as well as on
/// disk: it is what makes the file runnable over there, and a checksum line is
/// looked up by exactly this name.
pub const ASSET_WINDOWS_X86_64: &str = "tty7-server-windows-x86_64.exe";
pub const ASSET_WINDOWS_AARCH64: &str = "tty7-server-windows-aarch64.exe";
pub const CHECKSUMS_ASSET: &str = "checksums.txt";
pub const RELEASE_BASE: &str = "https://github.com/l0ng-ai/tty7/releases/download";
pub const INSTALL_DIR_COMPONENTS: [&str; 4] = [".local", "share", "tty7", "bin"];
/// `%LOCALAPPDATA%\tty7\bin`, spelled from the profile directory SFTP reports
/// as home rather than read from the environment: the installer only ever sees
/// the far end through SFTP paths, and `%LOCALAPPDATA%` is this directory on
/// every profile that has not been redirected by policy.
pub const WINDOWS_INSTALL_DIR_COMPONENTS: [&str; 4] = ["AppData", "Local", "tty7", "bin"];
/// Which family of machine a remote workspace installs onto. It decides the
/// install directory, the binary's file name, and — in `install` — the shell
/// every command is phrased for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RemotePlatform {
/// Linux and macOS: a POSIX `sh`, `uname`, and SFTP paths that are the
/// machine's own paths.
#[default]
Unix,
/// Windows OpenSSH: no POSIX shell to count on, and SFTP spells every path
/// with a leading slash before the drive (`/C:/Users/me`).
Windows,
}
impl RemotePlatform {
/// What an SFTP home directory says about the machine behind it.
///
/// Windows OpenSSH's SFTP server is the only one that answers `realpath .`
/// with a drive letter, so the shape of the home alone tells the two apart
/// without a round trip.
pub fn of_sftp_home(home: &str) -> RemotePlatform {
if is_windows_sftp_path(home) {
RemotePlatform::Windows
} else {
RemotePlatform::Unix
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnsupportedTarget {
UnsupportedSystem { raw: String },
UnknownMachine { raw: String },
Unparseable { raw: String },
UnsupportedSystem {
raw: String,
},
UnknownMachine {
raw: String,
},
/// A Windows host whose `PROCESSOR_ARCHITECTURE` names something no server
/// is published for (32-bit x86, Itanium).
UnknownWindowsMachine {
raw: String,
},
Unparseable {
raw: String,
},
}
impl UnsupportedTarget {
@@ -28,6 +80,7 @@ impl UnsupportedTarget {
match self {
Self::UnsupportedSystem { raw }
| Self::UnknownMachine { raw }
| Self::UnknownWindowsMachine { raw }
| Self::Unparseable { raw } => raw,
}
}
@@ -38,8 +91,13 @@ impl fmt::Display for UnsupportedTarget {
match self {
Self::UnsupportedSystem { raw } => write!(
f,
"a remote tty7 workspace needs a Linux or macOS host; this machine reports \
`uname -sm` = {raw:?}"
"a remote tty7 workspace needs a Linux, macOS or Windows host; this machine \
reports `uname -sm` = {raw:?}"
),
Self::UnknownWindowsMachine { raw } => write!(
f,
"no tty7-server is published for this Windows architecture \
(`PROCESSOR_ARCHITECTURE` = {raw:?}); supported: AMD64 and ARM64"
),
Self::UnknownMachine { raw } => write!(
f,
@@ -80,12 +138,41 @@ pub fn asset_for_uname(uname_sm: &str) -> Result<&'static str, UnsupportedTarget
}
}
/// Whether `uname -sm` came from a POSIX layer on top of Windows — Git for
/// Windows, MSYS2 or Cygwin on the `PATH` of the account being logged into.
/// The machine is still Windows, and the server it needs is the Windows one.
pub fn uname_reports_windows(uname_sm: &str) -> bool {
let system = uname_sm.split_whitespace().next().unwrap_or("");
["MINGW", "MSYS_NT", "CYGWIN_NT", "Windows_NT"]
.iter()
.any(|prefix| system.starts_with(prefix))
}
/// The server for a Windows host, from `PROCESSOR_ARCHITECTURE` (or
/// `PROCESSOR_ARCHITEW6432` when the probing shell is a 32-bit process on a
/// 64-bit machine).
///
/// An x64 shell on an ARM64 machine reports `AMD64`, and taking it at its word
/// is right for the same reason as a Rosetta shell on a Mac: the x64 server
/// runs under the emulation the shell asking for it already runs under.
pub fn asset_for_windows_arch(arch: &str) -> Result<&'static str, UnsupportedTarget> {
let raw = arch.trim().to_string();
match raw.to_ascii_uppercase().as_str() {
"AMD64" | "X64" | "EM64T" => Ok(ASSET_WINDOWS_X86_64),
"ARM64" => Ok(ASSET_WINDOWS_AARCH64),
"" => Err(UnsupportedTarget::Unparseable { raw }),
_ => Err(UnsupportedTarget::UnknownWindowsMachine { raw }),
}
}
pub fn interned(name: &str) -> &'static str {
match name {
_ if name == ASSET_LINUX_X86_64 => ASSET_LINUX_X86_64,
_ if name == ASSET_LINUX_AARCH64 => ASSET_LINUX_AARCH64,
_ if name == ASSET_MACOS_X86_64 => ASSET_MACOS_X86_64,
_ if name == ASSET_MACOS_AARCH64 => ASSET_MACOS_AARCH64,
_ if name == ASSET_WINDOWS_X86_64 => ASSET_WINDOWS_X86_64,
_ if name == ASSET_WINDOWS_AARCH64 => ASSET_WINDOWS_AARCH64,
_ => Box::leak(name.to_string().into_boxed_str()),
}
}
@@ -108,23 +195,57 @@ pub struct RemotePaths {
pub binary: String,
pub temp: String,
pub dir_chain: Vec<String>,
/// The machine these paths are on, which is also how every command that
/// names one of them has to be phrased.
pub platform: RemotePlatform,
}
pub fn remote_paths(home: &str, control: u32, protocol: u32) -> RemotePaths {
remote_paths_on(RemotePlatform::Unix, home, control, protocol)
}
/// [`remote_paths`] for either kind of machine. The paths are always in the
/// SFTP spelling — forward slashes, and on Windows a leading `/C:` — because
/// SFTP is what writes them; [`windows_native_path`] turns one into what a
/// Windows command line wants.
pub fn remote_paths_on(
platform: RemotePlatform,
home: &str,
control: u32,
protocol: u32,
) -> RemotePaths {
let home = home.trim_end_matches('/');
let mut dir_chain = Vec::with_capacity(INSTALL_DIR_COMPONENTS.len());
let components = match platform {
RemotePlatform::Unix => INSTALL_DIR_COMPONENTS,
RemotePlatform::Windows => WINDOWS_INSTALL_DIR_COMPONENTS,
};
let mut dir_chain = Vec::with_capacity(components.len());
let mut cursor = home.to_string();
for part in INSTALL_DIR_COMPONENTS {
for part in components {
cursor = format!("{cursor}/{part}");
dir_chain.push(cursor.clone());
}
let bin_dir = cursor;
let name = binary_name(control, protocol);
let (binary, temp) = match platform {
RemotePlatform::Unix => (
format!("{bin_dir}/{name}"),
format!("{bin_dir}/.{name}.tmp"),
),
// The temp name keeps `.exe` last: a Windows shell hands a file with
// any other extension to its file association instead of running it,
// and the upload is run (`--protocol`) before it is renamed into place.
RemotePlatform::Windows => (
format!("{bin_dir}/{name}.exe"),
format!("{bin_dir}/.{name}.tmp.exe"),
),
};
RemotePaths {
binary: format!("{bin_dir}/{name}"),
temp: format!("{bin_dir}/.{name}.tmp"),
binary,
temp,
dir_chain,
bin_dir,
platform,
}
}
@@ -138,13 +259,75 @@ pub fn remote_paths_for_binary(
control: u32,
protocol: u32,
) -> RemotePaths {
let mut paths = remote_paths(home, control, protocol);
remote_paths_for_binary_on(RemotePlatform::Unix, home, binary, control, protocol)
}
pub fn remote_paths_for_binary_on(
platform: RemotePlatform,
home: &str,
binary: &str,
control: u32,
protocol: u32,
) -> RemotePaths {
let mut paths = remote_paths_on(platform, home, control, protocol);
paths.binary = binary.to_string();
paths
}
/// Whether `path` is a Windows path as Windows OpenSSH's SFTP server spells
/// it: `/C:` alone or followed by `/`.
pub fn is_windows_sftp_path(path: &str) -> bool {
let b = path.as_bytes();
b.len() >= 3
&& b[0] == b'/'
&& b[1].is_ascii_alphabetic()
&& b[2] == b':'
&& (b.len() == 3 || b[3] == b'/')
}
/// `/C:/Users/me/x.exe` → `C:\Users\me\x.exe`, the spelling a Windows command
/// line takes. `None` for anything that is not an SFTP Windows path, so a
/// caller can never hand a POSIX path to a Windows shell by accident.
pub fn windows_native_path(sftp: &str) -> Option<String> {
if !is_windows_sftp_path(sftp) {
return None;
}
let mut native = sftp[1..].replace('/', "\\");
if native.len() == 2 {
native.push('\\');
}
Some(native)
}
/// The inverse of [`windows_native_path`]: `C:\x\y.exe` → `/C:/x/y.exe`, for
/// the paths a Windows command reports (the running server's image), so they
/// compare equal to the SFTP paths the installer keeps.
pub fn sftp_path_from_windows(native: &str) -> Option<String> {
let native = native.trim();
let b = native.as_bytes();
if b.len() < 2 || !b[0].is_ascii_alphabetic() || b[1] != b':' {
return None;
}
if b.len() > 2 && b[2] != b'\\' && b[2] != b'/' {
return None;
}
let sftp = format!("/{}", native.replace('\\', "/"));
Some(if sftp.len() > 4 {
sftp.trim_end_matches('/').to_string()
} else {
sftp
})
}
fn strip_exe(name: &str) -> &str {
match name.len().checked_sub(4) {
Some(i) if name.is_char_boundary(i) && name[i..].eq_ignore_ascii_case(".exe") => &name[..i],
_ => name,
}
}
pub fn dialect_from_path(path: &str) -> Option<(u32, u32)> {
let name = path.rsplit('/').next()?;
let name = strip_exe(path.rsplit(['/', '\\']).next()?);
let (control, protocol) = name.strip_prefix("tty7-server-c")?.split_once('p')?;
Some((control.parse().ok()?, protocol.parse().ok()?))
}
@@ -389,6 +572,175 @@ mod tests {
for (c, p) in [(1u32, 1u32), (3, 4), (26, 7)] {
let paths = remote_paths("/home/me", c, p);
assert_eq!(dialect_from_path(&paths.binary), Some((c, p)));
let paths = remote_paths_on(RemotePlatform::Windows, "/C:/Users/me", c, p);
assert_eq!(dialect_from_path(&paths.binary), Some((c, p)));
}
}
#[test]
fn windows_architectures_map_to_the_published_assets() {
for raw in ["AMD64", "amd64", "x64", "EM64T", " AMD64\r\n"] {
assert_eq!(
asset_for_windows_arch(raw).unwrap(),
ASSET_WINDOWS_X86_64,
"{raw:?}"
);
}
assert_eq!(
asset_for_windows_arch("ARM64").unwrap(),
ASSET_WINDOWS_AARCH64
);
for raw in ["x86", "IA64", "ARM"] {
let err = asset_for_windows_arch(raw).unwrap_err();
assert!(
matches!(err, UnsupportedTarget::UnknownWindowsMachine { .. }),
"{raw}: {err:?}"
);
assert_eq!(err.raw(), raw);
}
assert!(matches!(
asset_for_windows_arch(" ").unwrap_err(),
UnsupportedTarget::Unparseable { .. }
));
}
/// Git for Windows, MSYS2 and Cygwin all answer `uname` with a system
/// name that is not `Linux`, and what they sit on is still Windows.
#[test]
fn a_posix_layer_on_windows_is_recognised_as_windows() {
for raw in [
"MINGW64_NT-10.0-19045 x86_64",
"MSYS_NT-10.0-22631 x86_64",
"CYGWIN_NT-10.0 x86_64",
"Windows_NT x86_64",
] {
assert!(uname_reports_windows(raw), "{raw}");
assert!(
matches!(
asset_for_uname(raw).unwrap_err(),
UnsupportedTarget::UnsupportedSystem { .. }
),
"{raw}: uname alone must not pick a Windows server"
);
}
for raw in ["Linux x86_64", "Darwin arm64", "FreeBSD amd64", ""] {
assert!(!uname_reports_windows(raw), "{raw}");
}
}
#[test]
fn windows_remote_paths_live_under_local_app_data_and_end_in_exe() {
let p = remote_paths_on(RemotePlatform::Windows, "/C:/Users/me/", 3, 4);
assert_eq!(p.platform, RemotePlatform::Windows);
assert_eq!(p.bin_dir, "/C:/Users/me/AppData/Local/tty7/bin");
assert_eq!(
p.binary,
"/C:/Users/me/AppData/Local/tty7/bin/tty7-server-c3p4.exe"
);
assert_eq!(
p.temp,
"/C:/Users/me/AppData/Local/tty7/bin/.tty7-server-c3p4.tmp.exe"
);
assert_eq!(
p.dir_chain,
vec![
"/C:/Users/me/AppData",
"/C:/Users/me/AppData/Local",
"/C:/Users/me/AppData/Local/tty7",
"/C:/Users/me/AppData/Local/tty7/bin",
]
);
assert_eq!(
remote_paths("/home/me", 3, 4).platform,
RemotePlatform::Unix,
"the unix spelling is unchanged and says so"
);
}
#[test]
fn an_sftp_home_says_which_platform_it_is_on() {
for home in ["/C:/Users/me", "/c:/Users/me", "/D:", "/C:/"] {
assert_eq!(
RemotePlatform::of_sftp_home(home),
RemotePlatform::Windows,
"{home}"
);
}
for home in ["/home/me", "/", "/C", "/CD:/x", "C:/Users/me", "/Users/c:"] {
assert_eq!(
RemotePlatform::of_sftp_home(home),
RemotePlatform::Unix,
"{home}"
);
}
}
#[test]
fn windows_paths_convert_between_sftp_and_native_spelling() {
assert_eq!(
windows_native_path("/C:/Users/me/AppData/Local/tty7/bin/x.exe").as_deref(),
Some(r"C:\Users\me\AppData\Local\tty7\bin\x.exe")
);
assert_eq!(windows_native_path("/D:").as_deref(), Some(r"D:\"));
assert_eq!(windows_native_path("/home/me/x"), None);
assert_eq!(
sftp_path_from_windows(r"C:\Users\me\AppData\Local\tty7\bin\x.exe").as_deref(),
Some("/C:/Users/me/AppData/Local/tty7/bin/x.exe")
);
assert_eq!(
sftp_path_from_windows("C:/Users/me/\r\n").as_deref(),
Some("/C:/Users/me")
);
assert_eq!(sftp_path_from_windows(r"C:\").as_deref(), Some("/C:/"));
for not_a_drive in [
"",
"/home/me",
r"\\server\share\x.exe",
"C",
"CD:\\x",
"C:x",
] {
assert_eq!(sftp_path_from_windows(not_a_drive), None, "{not_a_drive:?}");
}
let sftp = "/C:/Users/me/AppData/Local/tty7/bin/tty7-server-c3p4.exe";
assert_eq!(
sftp_path_from_windows(&windows_native_path(sftp).unwrap()).as_deref(),
Some(sftp)
);
}
#[test]
fn dialects_are_recoverable_from_a_windows_install_path() {
assert_eq!(
dialect_from_path(r"C:\Users\me\AppData\Local\tty7\bin\tty7-server-c3p4.exe"),
Some((3, 4))
);
assert_eq!(
dialect_from_path("/C:/Users/me/AppData/Local/tty7/bin/tty7-server-c3p4.EXE"),
Some((3, 4))
);
assert_eq!(dialect_from_path(r"C:\tools\tty7-server.exe"), None);
}
#[test]
fn windows_asset_names_are_distinct_and_intern_to_themselves() {
assert_eq!(ASSET_WINDOWS_X86_64, "tty7-server-windows-x86_64.exe");
assert_eq!(ASSET_WINDOWS_AARCH64, "tty7-server-windows-aarch64.exe");
let all = [
ASSET_LINUX_X86_64,
ASSET_LINUX_AARCH64,
ASSET_MACOS_X86_64,
ASSET_MACOS_AARCH64,
ASSET_WINDOWS_X86_64,
ASSET_WINDOWS_AARCH64,
];
for a in all {
assert_eq!(a, interned(a));
for b in all {
assert!(a == b || !a.contains(b), "{a} contains {b}");
}
}
}
}
+219 -63
View File
@@ -10,9 +10,10 @@ pub mod outcome;
#[cfg(feature = "remote-install")]
pub mod proxy;
pub mod ssh_ops;
pub mod windows_host;
pub mod wsl;
pub use asset::{RemotePaths, UnsupportedTarget};
pub use asset::{RemotePaths, RemotePlatform, UnsupportedTarget};
pub use checksums::ChecksumError;
use crate::daemon::ssh::SshConnection;
@@ -665,8 +666,62 @@ impl<'a> Installer<'a> {
self
}
fn paths_for(&self, home: &str) -> RemotePaths {
asset::remote_paths(home, self.dialect.control, self.dialect.protocol)
fn paths_for(&self, platform: RemotePlatform, home: &str) -> RemotePaths {
asset::remote_paths_on(platform, home, self.dialect.control, self.dialect.protocol)
}
/// Which server this machine needs, and what kind of machine it is.
///
/// `uname -sm` first, exactly as before, so a Linux or macOS host is asked
/// nothing new. Only when that fails — `cmd.exe` and PowerShell have no
/// `uname` — or answers from a POSIX layer on Windows (Git for Windows,
/// MSYS2, Cygwin) is the Windows probe tried. And if that does not answer
/// either, the error is `uname`'s: on a machine that is neither, it is the
/// more useful of the two to read.
fn detect_target(&self) -> Result<(RemotePlatform, &'static str), InstallError> {
let unix_error = match self.ops.run("uname -sm") {
Ok(out) if out.success() => match asset::asset_for_uname(&out.stdout) {
Ok(asset) => return Ok((RemotePlatform::Unix, asset)),
Err(target) if asset::uname_reports_windows(&out.stdout) => {
InstallError::Unsupported(target)
}
Err(target) => return Err(InstallError::Unsupported(target)),
},
Ok(out) => InstallError::Probe(out.failure_reason()),
Err(reason) => InstallError::Probe(reason),
};
let arch = self
.ops
.run(&windows_host::probe_command())
.ok()
.filter(ExecOutput::success)
.and_then(|out| windows_host::parse_probe(&out.stdout));
match arch {
Some(arch) => asset::asset_for_windows_arch(&arch)
.map(|asset| (RemotePlatform::Windows, asset))
.map_err(InstallError::Unsupported),
None => Err(unix_error),
}
}
/// The platform the probe found has to be the one SFTP is serving, or the
/// binary would land where the shell running it cannot see it.
///
/// The case this exists for is a Windows host whose OpenSSH `DefaultShell`
/// is WSL's `bash.exe`: `uname` answers Linux, while SFTP is Windows's and
/// writes to `C:\`. Installing a Linux server at `/C:/Users/…` would leave a
/// file no Linux path reaches.
fn check_platform(&self, detected: RemotePlatform, home: &str) -> Result<(), InstallError> {
let served = RemotePlatform::of_sftp_home(home);
if detected == served {
return Ok(());
}
Err(InstallError::Probe(format!(
"the login shell answers like a {detected:?} machine, but SFTP reports a home of \
{home:?}, which is a {served:?} path; a remote workspace needs the shell and SFTP \
to see the same file system (on Windows, set OpenSSH's DefaultShell back to \
cmd.exe or PowerShell)"
)))
}
pub fn replace(&self) -> Result<(), InstallError> {
@@ -690,21 +745,11 @@ impl<'a> Installer<'a> {
fn put_ours_and_cycle(&self, force: bool) -> Result<(), InstallError> {
let home = self.ops.home_dir().map_err(InstallError::NoHome)?;
let paths = self.paths_for(&home);
let paths = self.paths_for(RemotePlatform::of_sftp_home(&home), &home);
if force || !self.published_binary_serves_us(&paths)? {
let uname = self
.ops
.run("uname -sm")
.map_err(InstallError::Probe)
.and_then(|out| {
if out.success() {
Ok(out.stdout)
} else {
Err(InstallError::Probe(out.failure_reason()))
}
})?;
let asset = asset::asset_for_uname(&uname).map_err(InstallError::Unsupported)?;
let (platform, asset) = self.detect_target()?;
self.check_platform(platform, &home)?;
self.install(asset, &paths)?;
}
@@ -722,11 +767,11 @@ impl<'a> Installer<'a> {
path: paths.binary.clone(),
reason,
})?;
if !stat.is_some_and(|s| !s.is_dir && s.mode & 0o100 != 0) {
if !stat.is_some_and(|s| runnable(paths.platform, s)) {
return Ok(false);
}
Ok(self
.probe_protocol(&paths.binary)
.probe_protocol(paths.platform, &paths.binary)
.is_some_and(|spoken| spoken.serves(&self.dialect)))
}
@@ -746,21 +791,11 @@ impl<'a> Installer<'a> {
}
pub fn run(&self) -> Result<InstallReport, InstallError> {
let uname = self
.ops
.run("uname -sm")
.map_err(InstallError::Probe)
.and_then(|out| {
if out.success() {
Ok(out.stdout)
} else {
Err(InstallError::Probe(out.failure_reason()))
}
})?;
let asset = asset::asset_for_uname(&uname).map_err(InstallError::Unsupported)?;
let (platform, asset) = self.detect_target()?;
let home = self.ops.home_dir().map_err(InstallError::NoHome)?;
let paths = self.paths_for(&home);
self.check_platform(platform, &home)?;
let paths = self.paths_for(platform, &home);
let already = self
.ops
@@ -780,9 +815,9 @@ impl<'a> Installer<'a> {
reused: None,
};
let usable = already.is_some_and(|stat| !stat.is_dir && stat.mode & 0o100 != 0);
let usable = already.is_some_and(|stat| runnable(platform, stat));
if !usable {
match self.adoptable_running_server()? {
match self.adoptable_running_server(platform)? {
Some((exe, spoken)) => {
log::info!(
"remote {}: adopting the running {} (control {}, protocol {}) \
@@ -793,7 +828,8 @@ impl<'a> Installer<'a> {
spoken.protocol,
self.version,
);
report.paths = asset::remote_paths_for_binary(
report.paths = asset::remote_paths_for_binary_on(
platform,
&home,
&exe,
self.dialect.control,
@@ -880,11 +916,14 @@ impl<'a> Installer<'a> {
})
}
fn adoptable_running_server(&self) -> Result<Option<(String, RemoteProtocol)>, InstallError> {
let Some(exe) = self.running_server_exe() else {
fn adoptable_running_server(
&self,
platform: RemotePlatform,
) -> Result<Option<(String, RemoteProtocol)>, InstallError> {
let Some(exe) = self.running_server_exe(platform) else {
return Ok(None);
};
let Some(spoken) = self.probe_protocol(&exe) else {
let Some(spoken) = self.probe_protocol(platform, &exe) else {
return Ok(None);
};
if !spoken.serves(&self.dialect) {
@@ -893,8 +932,11 @@ impl<'a> Installer<'a> {
Ok(Some((exe, spoken)))
}
fn probe_protocol(&self, exe: &str) -> Option<RemoteProtocol> {
let cmd = format!("{} {PROTOCOL_FLAG}", shell_quote(exe));
fn probe_protocol(&self, platform: RemotePlatform, exe: &str) -> Option<RemoteProtocol> {
let cmd = match platform {
RemotePlatform::Unix => format!("{} {PROTOCOL_FLAG}", shell_quote(exe)),
RemotePlatform::Windows => windows_host::protocol_command(exe),
};
let out = self.ops.run(&cmd).ok()?;
if !out.success() {
return None;
@@ -902,10 +944,21 @@ impl<'a> Installer<'a> {
RemoteProtocol::parse(&out.stdout)
}
fn running_server_exe(&self) -> Option<String> {
let out = self.ops.run(RUNNING_EXE_COMMAND).ok()?;
let exe = out.stdout.trim();
(!exe.is_empty()).then(|| exe.to_string())
/// The running server's image, in the same spelling as the paths the
/// installer keeps — on Windows the native path the process list reports
/// is turned back into SFTP's, so it compares equal to ours.
fn running_server_exe(&self, platform: RemotePlatform) -> Option<String> {
match platform {
RemotePlatform::Unix => {
let out = self.ops.run(RUNNING_EXE_COMMAND).ok()?;
let exe = out.stdout.trim();
(!exe.is_empty()).then(|| exe.to_string())
}
RemotePlatform::Windows => {
let out = self.ops.run(&windows_host::running_exe_command()).ok()?;
asset::sftp_path_from_windows(out.stdout.trim())
}
}
}
fn install(
@@ -946,7 +999,14 @@ impl<'a> Installer<'a> {
reason,
})?;
}
let _ = self.ops.chmod(&paths.bin_dir, DIR_MODE);
match paths.platform {
RemotePlatform::Unix => {
let _ = self.ops.chmod(&paths.bin_dir, DIR_MODE);
}
// Mode bits mean nothing to NTFS; what needs doing there instead is
// clearing out the images earlier upgrades had to move aside.
RemotePlatform::Windows => self.sweep_moved_aside(paths),
}
let temp = unique_temp(&paths.temp);
@@ -961,14 +1021,16 @@ impl<'a> Installer<'a> {
reason,
})?;
self.ops
.chmod(&temp, BINARY_MODE)
.map_err(|reason| InstallError::Write {
path: temp.clone(),
reason,
})?;
if paths.platform == RemotePlatform::Unix {
self.ops
.chmod(&temp, BINARY_MODE)
.map_err(|reason| InstallError::Write {
path: temp.clone(),
reason,
})?;
}
let spoke = self.probe_protocol(&temp);
let spoke = self.probe_protocol(paths.platform, &temp);
if !spoke.as_ref().is_some_and(|s| s.serves(&self.dialect)) {
let _ = self.ops.remove_file(&temp);
return Err(InstallError::DialectMismatch {
@@ -979,7 +1041,22 @@ impl<'a> Installer<'a> {
}
if let Err(reason) = self.ops.rename(&temp, &paths.binary) {
let _ = self.ops.remove_file(&paths.binary);
match paths.platform {
RemotePlatform::Unix => {
let _ = self.ops.remove_file(&paths.binary);
}
// Windows will not delete an image that is running — and the
// one at this path is running whenever this is an update — but
// it will rename one. Moving it aside frees the name while the
// old daemon keeps executing from the moved file until the
// restart that follows.
RemotePlatform::Windows => {
let aside = moved_aside(&paths.binary);
if self.ops.rename(&paths.binary, &aside).is_err() {
let _ = self.ops.remove_file(&paths.binary);
}
}
}
self.ops
.rename(&temp, &paths.binary)
.map_err(|_| InstallError::Write {
@@ -991,6 +1068,20 @@ impl<'a> Installer<'a> {
Ok((confirmed, bytes))
}
/// Best-effort removal of images [`Installer::install`] moved aside on
/// earlier upgrades. One still running refuses, and stays for the next
/// install to try again.
fn sweep_moved_aside(&self, paths: &RemotePaths) {
let Ok(Some(entries)) = self.ops.list_dir(&paths.bin_dir) else {
return;
};
for name in entries {
if name.starts_with(".tty7-server-") && name.ends_with(MOVED_ASIDE_SUFFIX) {
let _ = self.ops.remove_file(&format!("{}/{name}", paths.bin_dir));
}
}
}
fn load_binary(&self, asset: &'static str) -> Result<LoadedBinary, InstallError> {
let sink = install_progress();
let on_progress = |done: u64, total: Option<u64>| {
@@ -1079,10 +1170,13 @@ impl<'a> Installer<'a> {
/// `None` when the control socket answered, `Some(why)` when it did not.
fn control_probe(&self, paths: &RemotePaths) -> Result<Option<String>, InstallError> {
let cmd = format!(
"{} --stdio --bridge < /dev/null",
shell_quote(&paths.binary)
);
let cmd = match paths.platform {
RemotePlatform::Unix => format!(
"{} --stdio --bridge < /dev/null",
shell_quote(&paths.binary)
),
RemotePlatform::Windows => windows_host::control_probe_command(&paths.binary),
};
match self.ops.run(&cmd) {
Ok(out) if out.success() => Ok(None),
Ok(out) => Ok(Some(out.failure_reason())),
@@ -1096,20 +1190,28 @@ impl<'a> Installer<'a> {
fn launch_daemon(&self, paths: &RemotePaths) -> Result<StartupLog, InstallError> {
let log = StartupLog::for_binary(&paths.binary);
let settle = self.ops.launch_settle(&paths.binary);
let script = match paths.platform {
RemotePlatform::Unix => {
let settle = self.ops.launch_settle(&paths.binary);
launch_script(&paths.binary, &log, settle)
}
RemotePlatform::Windows => {
windows_host::launch_command(&paths.binary, &log.log, &log.exit, &log.nonce)
}
};
self.ops
.spawn_detached(&launch_script(&paths.binary, &log, settle))
.spawn_detached(&script)
.map_err(|reason| InstallError::Launch { reason })?;
Ok(log)
}
fn check_running_build(&self, paths: &RemotePaths) -> Option<MismatchedRemoteDaemon> {
let exe = self.running_server_exe()?;
let exe = self.running_server_exe(paths.platform)?;
let exe = exe.as_str();
if asset::dialect_from_path(exe) == Some(self.dialect.dialect()) || exe == paths.binary {
return None;
}
let spoken = self.probe_protocol(exe);
let spoken = self.probe_protocol(paths.platform, exe);
if spoken.as_ref().is_some_and(|s| s.serves(&self.dialect)) {
log::info!(
"remote {} is served by {exe}, a different build this client speaks to anyway",
@@ -1147,7 +1249,7 @@ impl<'a> Installer<'a> {
/// left exactly as it was, and told to install one first.
pub fn restart_daemon(&self) -> Result<(), InstallError> {
let home = self.ops.home_dir().map_err(InstallError::NoHome)?;
let paths = self.paths_for(&home);
let paths = self.paths_for(RemotePlatform::of_sftp_home(&home), &home);
if !self.published_binary_serves_us(&paths)? {
return Err(InstallError::NoServerToRestart {
host: self.host.clone(),
@@ -1169,7 +1271,11 @@ impl<'a> Installer<'a> {
// a report instead of one glance at a log: the only thing anyone ever
// saw was the timeout below, and it blames a daemon for not stopping
// when nothing had asked it to.
let stop_failure = match self.ops.run(TERMINATE_RUNNING_COMMAND) {
let stop = match paths.platform {
RemotePlatform::Unix => TERMINATE_RUNNING_COMMAND.to_string(),
RemotePlatform::Windows => windows_host::stop_command(&paths.binary),
};
let stop_failure = match self.ops.run(&stop) {
Ok(out) if out.success() => None,
Ok(out) => Some(out.failure_reason()),
Err(reason) => Some(reason),
@@ -1286,13 +1392,21 @@ impl StartupLog {
/// once it has waited for the daemon, so a status carrying this launch's
/// nonce is the death certificate.
fn exit_status(&self, ops: &dyn RemoteOps) -> Option<String> {
let cmd = format!("cat {} 2>/dev/null", shell_quote(&self.exit));
let cmd = if asset::is_windows_sftp_path(&self.exit) {
windows_host::read_exit_command(&self.exit)
} else {
format!("cat {} 2>/dev/null", shell_quote(&self.exit))
};
let out = ops.run(&cmd).ok()?;
let (nonce, status) = out.stdout.trim().split_once(' ')?;
(nonce == self.nonce && !status.is_empty()).then(|| status.to_string())
}
fn tail(&self, ops: &dyn RemoteOps) -> String {
if asset::is_windows_sftp_path(&self.log) {
let cmd = windows_host::tail_command(&self.log, TAIL_BYTES);
return ops.run(&cmd).map(|out| out.stdout).unwrap_or_default();
}
let cmd = format!(
"tail -c {TAIL_BYTES} {} 2>/dev/null",
shell_quote(&self.log)
@@ -1403,12 +1517,51 @@ fn launch_script(binary: &str, log: &StartupLog, settle: Option<String>) -> Stri
fn unique_temp(shared: &str) -> String {
let pid = std::process::id();
if let Some(stem) = shared.strip_suffix(".tmp.exe") {
return format!("{stem}.{pid}.tmp.exe");
}
match shared.strip_suffix(".tmp") {
Some(stem) => format!("{stem}.{pid}.tmp"),
None => format!("{shared}.{pid}"),
}
}
/// Whether a file SFTP describes can be run as the server. Unix wants the
/// owner's execute bit; Windows has none to want — an `.exe` runs because of
/// its name, and SFTP's mode bits there are a translation of ACLs that says
/// nothing reliable about it.
fn runnable(platform: RemotePlatform, stat: RemoteStat) -> bool {
!stat.is_dir
&& match platform {
RemotePlatform::Unix => stat.mode & 0o100 != 0,
RemotePlatform::Windows => true,
}
}
const MOVED_ASIDE_SUFFIX: &str = ".old";
/// Where a running Windows image is renamed to make room for its upgrade: a
/// hidden sibling, so [`Installer::is_first_install`] never counts it, and
/// unique per attempt: an image still running from an earlier move-aside
/// holds its name, and a second upgrade must not need it.
fn moved_aside(binary: &str) -> String {
let (dir, name) = binary.rsplit_once('/').unwrap_or(("", binary));
let id = uuid::Uuid::new_v4().simple().to_string();
format!("{dir}/.{name}.{}{MOVED_ASIDE_SUFFIX}", &id[..12])
}
/// The command a routed link runs on the remote to reach its server:
/// `'<binary>' --stdio` for a POSIX shell, `"<binary>" --stdio` for the
/// `cmd.exe` Windows OpenSSH runs commands through. See
/// [`windows_host::stdio_command`] for why not PowerShell.
pub fn server_stdio_command(binary: &str) -> String {
if asset::is_windows_sftp_path(binary) {
windows_host::stdio_command(binary)
} else {
format!("{} --stdio", shell_quote(binary))
}
}
pub(crate) fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
@@ -1650,3 +1803,6 @@ fn default_fetcher() -> Arc<dyn AssetFetcher> {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod windows_tests;
@@ -54,7 +54,8 @@ impl RemoteOps for SshRemoteOps {
match tokio::time::timeout(COMMAND_TIMEOUT, exec(&conn, &cmd)).await {
Ok(result) => result,
Err(_) => Err(format!(
"the remote did not finish `{cmd}` within {COMMAND_TIMEOUT:?}"
"the remote did not finish `{}` within {COMMAND_TIMEOUT:?}",
super::windows_host::label(&cmd)
)),
}
})
@@ -164,7 +165,7 @@ async fn exec(conn: &Arc<SshConnection>, cmd: &str) -> Result<ExecOutput, String
channel
.exec(true, cmd)
.await
.map_err(|e| format!("could not run `{cmd}`: {e}"))?;
.map_err(|e| format!("could not run `{}`: {e}", super::windows_host::label(cmd)))?;
let _ = channel.eof().await;
let mut stdout = Vec::new();
@@ -0,0 +1,459 @@
//! The commands the installer sends to a remote Windows host.
//!
//! Windows OpenSSH runs an exec request through whatever `DefaultShell` the
//! machine is configured with — `cmd.exe` out of the box, PowerShell or even a
//! Git-for-Windows `bash` on machines that changed it. None of the POSIX
//! scripts in [`super`] survive any of those, and no single quoting scheme
//! survives all three. So every command here is a PowerShell script shipped as
//! `-EncodedCommand`: base64 of UTF-16LE, which is nothing but letters, digits,
//! `+`, `/` and `=` — words every one of those shells passes through untouched.
//! Windows PowerShell 5.1 ships with every supported Windows, so there is
//! nothing to install first.
//!
//! Each script opens with a `# tty7:<tag>` comment. It costs nothing on the far
//! end, and it is how a log line — or a test's fake host — can tell which
//! script an opaque blob of base64 is.
//!
//! Paths arrive in the SFTP spelling the installer keeps (`/C:/Users/me/…`) and
//! are turned into native ones (`C:\Users\me\…`) here, at the last moment.
use base64::Engine as _;
use super::asset;
/// Starts the line [`probe_command`] prints, so nothing a profile or banner
/// prints can pass for the answer.
pub(crate) const PROBE_MARK: &str = "__tty7_windows__";
const TAG_PREFIX: &str = "# tty7:";
/// Quiet progress bars (Windows PowerShell serialises them to stderr as CLIXML
/// when output is redirected) and answer in UTF-8, so a profile directory with
/// a non-ASCII user name survives the trip.
const PREAMBLE: &str = "$ProgressPreference='SilentlyContinue'\n\
try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}\n";
/// `powershell.exe … -EncodedCommand <base64>` for `body`, tagged `tag`.
pub(crate) fn powershell(tag: &str, body: &str) -> String {
format!(
"powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {}",
encode(&script(tag, body))
)
}
fn script(tag: &str, body: &str) -> String {
format!("{TAG_PREFIX}{tag}\n{PREAMBLE}{body}")
}
/// What `-EncodedCommand` takes: base64 over UTF-16LE.
fn encode(script: &str) -> String {
let utf16: Vec<u8> = script.encode_utf16().flat_map(u16::to_le_bytes).collect();
base64::engine::general_purpose::STANDARD.encode(utf16)
}
/// The tag and script behind a command [`powershell`] built, or `None` for
/// anything else. The inverse the tests and the logs read commands through.
pub(crate) fn decode(command: &str) -> Option<(String, String)> {
let b64 = command
.strip_prefix("powershell.exe ")?
.rsplit_once("-EncodedCommand ")?
.1
.trim();
let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
if bytes.len() % 2 != 0 {
return None;
}
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect();
let script = String::from_utf16(&units).ok()?;
let tag = script.lines().next()?.strip_prefix(TAG_PREFIX)?.to_string();
Some((tag, script))
}
/// How to name `command` in a message: an encoded script by its tag, since a
/// few kilobytes of base64 tell a reader nothing; anything else as itself.
pub(crate) fn label(command: &str) -> std::borrow::Cow<'_, str> {
match decode(command) {
Some((tag, _)) => format!("powershell `tty7:{tag}` script").into(),
None => command.into(),
}
}
/// A PowerShell single-quoted literal. PowerShell treats the typographic
/// single quotes as quote characters too, so each of them is doubled along
/// with the ASCII one — a path is data, and must never end the literal.
pub(crate) 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 native spelling of an installer path, quoted for PowerShell.
fn native(sftp: &str) -> String {
ps_quote(&asset::windows_native_path(sftp).unwrap_or_else(|| sftp.to_string()))
}
/// Asks the machine what it is. Printed rather than returned through the exit
/// status: a machine that is not Windows at all fails to run `powershell.exe`,
/// and that failure is the answer too.
///
/// `PROCESSOR_ARCHITEW6432` first: a 32-bit shell on a 64-bit machine reports
/// `x86` in `PROCESSOR_ARCHITECTURE`, and the machine is what the server has to
/// run on.
pub(crate) fn probe_command() -> String {
powershell(
"probe",
&format!(
"$a = $env:PROCESSOR_ARCHITEW6432\n\
if (-not $a) {{ $a = $env:PROCESSOR_ARCHITECTURE }}\n\
[Console]::Out.Write('{PROBE_MARK} ' + $a + \"`n\")\n"
),
)
}
/// The architecture [`probe_command`] reported, or `None` when nothing
/// answered in its words.
pub(crate) fn parse_probe(stdout: &str) -> Option<String> {
stdout.lines().find_map(|line| {
line.trim()
.strip_prefix(PROBE_MARK)
.map(|arch| arch.trim().to_string())
})
}
/// `<exe> --protocol`, exiting with its status.
pub(crate) fn protocol_command(exe: &str) -> String {
powershell(
"protocol",
&format!(
"& {} {}\nexit $LASTEXITCODE\n",
native(exe),
super::PROTOCOL_FLAG
),
)
}
/// `<binary> --stdio --bridge` with stdin closed — the Windows spelling of the
/// unix probe's `< /dev/null`. Started through .NET rather than PowerShell's
/// own native-command plumbing, which is the one way to be sure the child
/// sees end-of-file on stdin rather than PowerShell's console.
pub(crate) fn control_probe_command(binary: &str) -> String {
powershell(
"control-probe",
&format!(
"$p = New-Object System.Diagnostics.Process\n\
$p.StartInfo.FileName = {}\n\
$p.StartInfo.Arguments = '--stdio --bridge'\n\
$p.StartInfo.UseShellExecute = $false\n\
$p.StartInfo.RedirectStandardInput = $true\n\
$p.StartInfo.RedirectStandardOutput = $true\n\
$p.StartInfo.RedirectStandardError = $true\n\
try {{ [void]$p.Start() }} catch {{ [Console]::Error.WriteLine($_.Exception.Message); exit 127 }}\n\
$p.StandardInput.Close()\n\
$err = $p.StandardError.ReadToEndAsync()\n\
[void]$p.StandardOutput.ReadToEnd()\n\
$p.WaitForExit()\n\
[Console]::Error.Write($err.Result)\n\
exit $p.ExitCode\n",
native(binary)
),
)
}
/// The image of the tty7 daemon this account is running, native-spelled, or
/// nothing.
///
/// Only a `--daemon`: the `--stdio` bridges every connected client keeps
/// running are `tty7-server-*` processes too, and one of them is not the
/// server. Only this account's: an administrator can see every user's
/// processes, and another user's daemon is not the one this client talks to.
pub(crate) fn running_exe_command() -> String {
powershell(
"running-exe",
"$me = $env:USERNAME\n\
Get-CimInstance Win32_Process -Filter \"Name LIKE 'tty7-server-%'\" -ErrorAction SilentlyContinue |\n\
Where-Object { $_.ExecutablePath -and $_.CommandLine -like '*--daemon*' } |\n\
Where-Object { (Invoke-CimMethod -InputObject $_ -MethodName GetOwner -ErrorAction SilentlyContinue).User -eq $me } |\n\
Select-Object -First 1 |\n\
ForEach-Object { [Console]::Out.Write($_.ExecutablePath) }\n\
exit 0\n",
)
}
/// Ask the running daemon to stop, through `binary --stop`.
///
/// Windows has no SIGTERM to send, and `Stop-Process` is `TerminateProcess`:
/// the daemon would die without closing its ConPTYs and leave every pane's
/// shell orphaned. `--stop` goes through the daemon's own endpoint instead —
/// the graceful shutdown a local tty7 uses on Windows, with its reap as the
/// fallback. Always exits 0, like the unix command ending in `true`: a daemon
/// that was not running is not a failure to stop it.
pub(crate) fn stop_command(binary: &str) -> String {
powershell("stop", &format!("& {} --stop\nexit 0\n", native(binary)))
}
/// Start the daemon detached from this SSH session, keeping what it says and
/// how it ends — the Windows counterpart of the unix `setsid`/`nohup` launch.
///
/// Detached is the hard part. Windows OpenSSH puts everything a session starts
/// into a job object and closes it with the session, so a daemon started from
/// here — `Start-Process` included — dies the moment this command's channel
/// does. `Win32_Process.Create` asks the WMI service to start the process
/// instead, and the WMI service's children belong to no session's job.
///
/// WMI would start it with a bare environment, so this session's is handed
/// over explicitly. It is the environment the pane shells should inherit, and
/// it is where the daemon finds `%APPDATA%` — the config directory its
/// endpoint files live in, and which the `--stdio` bridges this session starts
/// will look in.
///
/// What WMI starts is a supervisor, not the daemon: a hidden PowerShell that
/// runs it through `cmd` (which is what redirects stdout and stderr into one
/// log file), waits, and records the exit status stamped with this launch's
/// nonce, exactly like the unix wrapper.
pub(crate) fn launch_command(binary: &str, log: &str, exit: &str, nonce: &str) -> String {
let exe = asset::windows_native_path(binary).unwrap_or_else(|| binary.to_string());
let log_native = asset::windows_native_path(log).unwrap_or_else(|| log.to_string());
// `cmd /c ""<exe>" --daemon 1>>"<log>" 2>&1"`: with more than two quotes
// after `/c`, cmd strips the first and the last and runs what is between
// verbatim — the standard way to hand it a quoted program and a quoted
// redirect target in one line.
let with_log = format!("/d /c \"\"{exe}\" --daemon 1>>\"{log_native}\" 2>&1\"");
let without_log = format!("/d /c \"\"{exe}\" --daemon 1>NUL 2>&1\"");
let supervisor = script(
"supervise",
&format!(
"$log = {log}\n\
$exit = {exit}\n\
Remove-Item -LiteralPath $log, $exit -Force -ErrorAction SilentlyContinue\n\
$a = {without}\n\
try {{ [IO.File]::WriteAllText($log, ''); [IO.File]::WriteAllText($exit, ''); $a = {with} }} catch {{}}\n\
$p = Start-Process -FilePath $env:ComSpec -ArgumentList $a -WindowStyle Hidden -PassThru\n\
$null = $p.Handle\n\
$p.WaitForExit()\n\
try {{ [IO.File]::WriteAllText($exit, {nonce} + ' ' + $p.ExitCode) }} catch {{}}\n",
log = native(log),
exit = native(exit),
with = ps_quote(&with_log),
without = ps_quote(&without_log),
nonce = ps_quote(nonce),
),
);
// The supervisor is encoded on the far end rather than here: encoding it
// twice would put it on the wire as base64 of base64 of UTF-16, and the
// outer line has to fit cmd.exe's 8191 characters.
powershell(
"launch",
&format!(
"$s = {supervisor}\n\
$inner = 'powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($s))\n\
$vars = [string[]](Get-ChildItem Env: | ForEach-Object {{ $_.Name + '=' + $_.Value }})\n\
$startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{{ ShowWindow = [uint16]0; EnvironmentVariables = $vars }}\n\
$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{{ CommandLine = $inner; CurrentDirectory = $env:USERPROFILE; ProcessStartupInformation = $startup }}\n\
if (-not $r -or $r.ReturnValue -ne 0) {{\n\
[Console]::Error.WriteLine('Win32_Process.Create could not start the daemon (' + $r.ReturnValue + ')')\n\
exit 1\n\
}}\n\
exit 0\n",
supervisor = ps_quote(&supervisor),
),
)
}
/// The recorded exit status, or nothing. Read shared, since the supervisor may
/// still hold it.
pub(crate) fn read_exit_command(exit: &str) -> String {
powershell(
"read-exit",
&format!(
"try {{\n\
$f = [IO.File]::Open({}, 'Open', 'Read', 'ReadWrite')\n\
$r = New-Object IO.StreamReader($f)\n\
[Console]::Out.Write($r.ReadToEnd())\n\
$r.Close()\n\
}} catch {{}}\n\
exit 0\n",
native(exit)
),
)
}
/// The last `bytes` of the startup log. Opened for shared reading: `cmd` still
/// holds it open for writing while the daemon runs, and a plain read would be
/// refused for exactly as long as the log is interesting.
pub(crate) fn tail_command(log: &str, bytes: usize) -> String {
powershell(
"tail",
&format!(
"try {{\n\
$f = [IO.File]::Open({}, 'Open', 'Read', 'ReadWrite')\n\
$n = [Math]::Min([long]{bytes}, $f.Length)\n\
[void]$f.Seek(-$n, 'End')\n\
$buf = New-Object byte[] $n\n\
$got = $f.Read($buf, 0, $n)\n\
$f.Close()\n\
[Console]::Out.Write([Text.Encoding]::UTF8.GetString($buf, 0, $got))\n\
}} catch {{}}\n\
exit 0\n",
native(log)
),
)
}
/// The command a routed link runs to reach the server: `"<exe>" --stdio`.
///
/// Not PowerShell. The link carries a binary protocol on stdin and stdout, and
/// Windows PowerShell re-encodes a native command's output as text whenever its
/// own output is redirected — which, under sshd, it always is. `cmd.exe`, the
/// stock `DefaultShell`, hands the server the channel's pipes untouched. With
/// exactly two quotes on the line, cmd keeps them when the path has a space in
/// it and strips them harmlessly when it does not.
pub(crate) fn stdio_command(binary: &str) -> String {
let exe = asset::windows_native_path(binary).unwrap_or_else(|| binary.to_string());
format!("\"{exe}\" --stdio")
}
#[cfg(test)]
mod tests {
use super::*;
const BINARY: &str = "/C:/Users/me/AppData/Local/tty7/bin/tty7-server-c3p4.exe";
#[test]
fn an_encoded_command_round_trips_and_carries_its_tag() {
let cmd = powershell("probe", "Write-Output 'héllo ✓'");
let (tag, script) = decode(&cmd).expect("our own encoding decodes");
assert_eq!(tag, "probe");
assert!(script.ends_with("Write-Output 'héllo ✓'"), "{script}");
assert!(script.contains("OutputEncoding"), "{script}");
assert_eq!(decode("uname -sm"), None);
}
/// The whole point of the encoding: whatever the far end's default shell
/// is, nothing in the line means anything to it.
#[test]
fn an_encoded_command_is_inert_in_every_windows_shell() {
for cmd in [
probe_command(),
protocol_command(BINARY),
control_probe_command(BINARY),
running_exe_command(),
stop_command(BINARY),
launch_command(
BINARY,
&format!("{BINARY}.startup.log"),
&format!("{BINARY}.startup.exit"),
"0123456789abcdef0123456789abcdef",
),
read_exit_command(&format!("{BINARY}.startup.exit")),
tail_command(&format!("{BINARY}.startup.log"), 4096),
] {
let (_, b64) = cmd.rsplit_once(' ').unwrap();
assert!(
b64.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')),
"{cmd}"
);
assert!(
cmd.len() < 8000,
"cmd.exe refuses lines over 8191 characters: {} for {:?}",
cmd.len(),
decode(&cmd).map(|(tag, _)| tag)
);
}
}
#[test]
fn quoting_doubles_every_single_quote_powershell_knows() {
assert_eq!(ps_quote(r"C:\Users\me"), r"'C:\Users\me'");
assert_eq!(ps_quote("O'Brien"), "'O''Brien'");
assert_eq!(ps_quote("O\u{2019}Brien"), "'O\u{2019}\u{2019}Brien'");
assert_eq!(ps_quote("$env:x `n"), "'$env:x `n'");
}
#[test]
fn commands_name_the_native_path() {
let (tag, script) = decode(&protocol_command(BINARY)).unwrap();
assert_eq!(tag, "protocol");
assert!(
script.contains(
r"& 'C:\Users\me\AppData\Local\tty7\bin\tty7-server-c3p4.exe' --protocol"
),
"{script}"
);
let (tag, script) = decode(&stop_command(BINARY)).unwrap();
assert_eq!(tag, "stop");
assert!(script.contains("tty7-server-c3p4.exe' --stop"), "{script}");
let (tag, script) = decode(&control_probe_command(BINARY)).unwrap();
assert_eq!(tag, "control-probe");
assert!(script.contains("'--stdio --bridge'"), "{script}");
assert!(script.contains("StandardInput.Close()"), "{script}");
}
#[test]
fn the_probe_answer_is_found_among_banner_noise() {
let out = "Windows PowerShell\r\nCopyright (C) Microsoft\r\n__tty7_windows__ AMD64\r\n";
assert_eq!(parse_probe(out).as_deref(), Some("AMD64"));
assert_eq!(parse_probe("__tty7_windows__ \n").as_deref(), Some(""));
assert_eq!(
parse_probe("'powershell.exe' is not recognized as an internal or external command"),
None
);
}
#[test]
fn the_launch_goes_through_wmi_and_supervises_with_the_nonce() {
let nonce = "0123456789abcdef0123456789abcdef";
let cmd = launch_command(
BINARY,
&format!("{BINARY}.startup.log"),
&format!("{BINARY}.startup.exit"),
nonce,
);
let (tag, outer) = decode(&cmd).unwrap();
assert_eq!(tag, "launch");
assert!(
outer.contains("Win32_Process -MethodName Create"),
"{outer}"
);
assert!(outer.contains("EnvironmentVariables"), "{outer}");
// The supervisor rides inside as a quoted literal, its own quotes
// doubled once more.
assert!(outer.contains("# tty7:supervise"), "{outer}");
assert!(outer.contains(nonce), "{outer}");
assert!(
outer.contains(
r#"/d /c ""C:\Users\me\AppData\Local\tty7\bin\tty7-server-c3p4.exe" --daemon 1>>"C:\Users\me\AppData\Local\tty7\bin\tty7-server-c3p4.exe.startup.log" 2>&1""#
),
"{outer}"
);
assert!(
outer.contains("-WindowStyle Hidden -EncodedCommand"),
"{outer}"
);
}
#[test]
fn the_link_command_is_a_plain_cmd_line() {
assert_eq!(
stdio_command(BINARY),
r#""C:\Users\me\AppData\Local\tty7\bin\tty7-server-c3p4.exe" --stdio"#
);
assert_eq!(
stdio_command("/C:/Users/John Smith/AppData/Local/tty7/bin/x.exe"),
r#""C:\Users\John Smith\AppData\Local\tty7\bin\x.exe" --stdio"#
);
}
}
@@ -0,0 +1,661 @@
//! The installer against a fake Windows OpenSSH host.
//!
//! The fake answers the way such a host does where it matters: `uname` is not
//! a command, SFTP spells paths `/C:/…` and reports no execute bits, a rename
//! onto an existing file fails, and the running server's image cannot be
//! deleted. Every other command has to arrive as one of `windows_host`'s
//! encoded PowerShell scripts — anything else is recorded as foreign, and the
//! tests fail on it, because a POSIX command reaching `cmd.exe` is exactly the
//! bug this suite exists to catch.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::asset::{
ASSET_WINDOWS_AARCH64, ASSET_WINDOWS_X86_64, CHECKSUMS_ASSET, RemotePlatform, UnsupportedTarget,
};
use super::*;
const VERSION: &str = "26.9.9";
const CONTROL: u32 = 3;
const PROTOCOL: u32 = 4;
const HOME: &str = "/C:/Users/me";
const BIN_DIR: &str = "/C:/Users/me/AppData/Local/tty7/bin";
const BINARY: &str = "/C:/Users/me/AppData/Local/tty7/bin/tty7-server-c3p4.exe";
const SERVER_BYTES: &[u8] = b"MZ\x90\x00...a tty7-server.exe, pretend it is 8 MB";
fn ours() -> RemoteProtocol {
RemoteProtocol {
control: CONTROL,
protocol: PROTOCOL,
build: VERSION.to_string(),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum Step {
/// `uname -sm`, which the fake refuses the way cmd.exe does.
Uname,
/// A `windows_host` script, by its tag.
Script(String),
Mkdir(String),
Chmod(String),
Put(String),
Rename {
from: String,
to: String,
},
Remove(String),
/// Anything a Windows shell would not have understood.
Foreign(String),
}
struct FakeWindows {
home: String,
/// `None` is cmd.exe or PowerShell: no `uname` at all.
uname: Option<String>,
/// `None`: PowerShell does not answer either (not Windows after all).
arch: Option<String>,
files: Mutex<HashMap<String, (Vec<u8>, bool)>>,
speaks: Mutex<HashMap<String, RemoteProtocol>>,
uploads_speak: RemoteProtocol,
/// The image of the running daemon, in SFTP spelling.
running: Mutex<Option<String>>,
steps: Mutex<Vec<Step>>,
}
impl FakeWindows {
fn new(arch: &str) -> Self {
let mut files = HashMap::new();
files.insert(HOME.to_string(), (Vec::new(), true));
Self {
home: HOME.to_string(),
uname: None,
arch: Some(arch.to_string()),
files: Mutex::new(files),
speaks: Mutex::new(HashMap::new()),
uploads_speak: ours(),
running: Mutex::new(None),
steps: Mutex::new(Vec::new()),
}
}
fn with_uname(mut self, uname: &str) -> Self {
self.uname = Some(uname.to_string());
self
}
fn not_answering_powershell(mut self) -> Self {
self.arch = None;
self
}
fn installed(self, path: &str, spoken: RemoteProtocol) -> Self {
{
let mut files = self.files.lock().unwrap();
for dir in
asset::remote_paths_on(RemotePlatform::Windows, HOME, CONTROL, PROTOCOL).dir_chain
{
files.insert(dir, (Vec::new(), true));
}
files.insert(path.to_string(), (SERVER_BYTES.to_vec(), false));
}
self.speaks.lock().unwrap().insert(path.to_string(), spoken);
self
}
fn serving(self, image: &str) -> Self {
*self.running.lock().unwrap() = Some(image.to_string());
self
}
fn steps(&self) -> Vec<Step> {
self.steps.lock().unwrap().clone()
}
fn scripts(&self) -> Vec<String> {
self.steps()
.into_iter()
.filter_map(|s| match s {
Step::Script(tag) => Some(tag),
_ => None,
})
.collect()
}
fn assert_nothing_foreign(&self) {
let foreign: Vec<Step> = self
.steps()
.into_iter()
.filter(|s| matches!(s, Step::Foreign(_)))
.collect();
assert!(
foreign.is_empty(),
"a Windows host was sent commands no Windows shell runs: {foreign:?}"
);
}
fn has(&self, path: &str) -> bool {
self.files.lock().unwrap().contains_key(path)
}
fn step(&self, step: Step) {
self.steps.lock().unwrap().push(step);
}
}
fn ok(stdout: &str) -> Result<ExecOutput, String> {
Ok(ExecOutput {
status: Some(0),
stdout: stdout.to_string(),
stderr: String::new(),
})
}
fn failed(status: u32, stderr: &str) -> Result<ExecOutput, String> {
Ok(ExecOutput {
status: Some(status),
stdout: String::new(),
stderr: stderr.to_string(),
})
}
/// The native path a script names as `& '<path>' <flag>`, back in SFTP form.
fn invoked(script: &str, flag: &str) -> Option<String> {
let (before, _) = script.split_once(&format!("' {flag}"))?;
let native = before.rsplit_once("& '")?.1;
asset::sftp_path_from_windows(native)
}
impl RemoteOps for FakeWindows {
fn home_dir(&self) -> Result<String, String> {
Ok(self.home.clone())
}
fn run(&self, cmd: &str) -> Result<ExecOutput, String> {
if cmd == "uname -sm" {
self.step(Step::Uname);
return match &self.uname {
Some(uname) => ok(uname),
None => failed(
1,
"'uname' is not recognized as an internal or external command,\r\n\
operable program or batch file.\r\n",
),
};
}
let Some((tag, script)) = windows_host::decode(cmd) else {
self.step(Step::Foreign(cmd.to_string()));
return failed(1, "is not recognized as an internal or external command");
};
self.step(Step::Script(tag.clone()));
match tag.as_str() {
"probe" => match &self.arch {
Some(arch) => ok(&format!("{} {arch}\r\n", windows_host::PROBE_MARK)),
None => Err("powershell.exe: command not found".into()),
},
"protocol" => {
let exe = invoked(&script, PROTOCOL_FLAG).expect("a native path to probe");
match self.speaks.lock().unwrap().get(&exe) {
Some(spoken) => ok(&format!("{}\r\n", spoken.to_line())),
None => failed(1, "The term is not recognized"),
}
}
"control-probe" => match self.running.lock().unwrap().is_some() {
true => ok(""),
false => failed(
1,
"tty7-server: stdio session ended with error: no daemon port file",
),
},
"running-exe" => ok(&self
.running
.lock()
.unwrap()
.as_deref()
.and_then(asset::windows_native_path)
.unwrap_or_default()),
"stop" => {
assert!(
invoked(&script, "--stop").is_some(),
"the stop goes through a server binary: {script}"
);
*self.running.lock().unwrap() = None;
ok("")
}
"launch" => {
assert!(
script.contains("Win32_Process -MethodName Create"),
"{script}"
);
*self.running.lock().unwrap() = Some(BINARY.to_string());
ok("")
}
"read-exit" | "tail" => ok(""),
other => panic!("the fake does not know the `{other}` script"),
}
}
fn spawn_detached(&self, cmd: &str) -> Result<(), String> {
self.run(cmd).map(|_| ())
}
fn stat(&self, path: &str) -> Result<Option<RemoteStat>, String> {
// Windows OpenSSH reports no execute bits for an .exe.
Ok(self
.files
.lock()
.unwrap()
.get(path)
.map(|(bytes, is_dir)| RemoteStat {
size: bytes.len() as u64,
mode: if *is_dir { 0o40755 } else { 0o100644 },
is_dir: *is_dir,
}))
}
fn mkdir(&self, path: &str) -> Result<(), String> {
self.step(Step::Mkdir(path.to_string()));
assert!(asset::is_windows_sftp_path(path), "{path}");
self.files
.lock()
.unwrap()
.entry(path.to_string())
.or_insert((Vec::new(), true));
Ok(())
}
fn chmod(&self, path: &str, _mode: u32) -> Result<(), String> {
self.step(Step::Chmod(path.to_string()));
Ok(())
}
fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
self.step(Step::Put(path.to_string()));
assert!(
path.ends_with(".exe"),
"an upload is run before it is renamed: {path}"
);
self.files
.lock()
.unwrap()
.insert(path.to_string(), (bytes.to_vec(), false));
self.speaks
.lock()
.unwrap()
.insert(path.to_string(), self.uploads_speak.clone());
Ok(())
}
fn rename(&self, from: &str, to: &str) -> Result<(), String> {
self.step(Step::Rename {
from: from.to_string(),
to: to.to_string(),
});
let mut files = self.files.lock().unwrap();
// Windows OpenSSH renames without replacing.
if files.contains_key(to) {
return Err("4: Failure".into());
}
let file = files.remove(from).ok_or("2: No such file")?;
files.insert(to.to_string(), file);
let mut speaks = self.speaks.lock().unwrap();
if let Some(spoken) = speaks.remove(from) {
speaks.insert(to.to_string(), spoken);
}
// A running image keeps running from wherever it is moved to.
let mut running = self.running.lock().unwrap();
if running.as_deref() == Some(from) {
*running = Some(to.to_string());
}
Ok(())
}
fn remove_file(&self, path: &str) -> Result<(), String> {
self.step(Step::Remove(path.to_string()));
if self.running.lock().unwrap().as_deref() == Some(path) {
return Err("4: Failure (the image is in use)".into());
}
self.files.lock().unwrap().remove(path);
Ok(())
}
fn list_dir(&self, path: &str) -> Result<Option<Vec<String>>, String> {
let files = self.files.lock().unwrap();
if !files.get(path).is_some_and(|(_, dir)| *dir) {
return Ok(None);
}
let prefix = format!("{path}/");
Ok(Some(
files
.keys()
.filter_map(|k| k.strip_prefix(&prefix))
.filter(|rest| !rest.contains('/'))
.map(str::to_string)
.collect(),
))
}
}
struct Release {
fetched: Mutex<Vec<String>>,
}
impl Release {
fn new() -> Self {
Self {
fetched: Mutex::new(Vec::new()),
}
}
}
impl AssetFetcher for Release {
fn get(&self, url: &str) -> Result<Vec<u8>, String> {
self.fetched.lock().unwrap().push(url.to_string());
let digest = checksums::hex(&checksums::sha256(SERVER_BYTES));
if url.ends_with(CHECKSUMS_ASSET) {
return Ok(format!(
"{digest} {ASSET_WINDOWS_X86_64}\n{digest} {ASSET_WINDOWS_AARCH64}\n"
)
.into_bytes());
}
if url.ends_with(ASSET_WINDOWS_X86_64) || url.ends_with(ASSET_WINDOWS_AARCH64) {
return Ok(SERVER_BYTES.to_vec());
}
Err(format!("404: {url}"))
}
}
struct Approve(Mutex<Vec<InstallRequest>>);
impl InstallConfirm for Approve {
fn confirm(&self, request: &InstallRequest) -> InstallDecision {
self.0.lock().unwrap().push(request.clone());
InstallDecision::Approve
}
}
fn installer<'a>(host: &'a FakeWindows, release: &'a Release, user: &'a Approve) -> Installer<'a> {
Installer::new(host, release, user, "win-box")
.with_version(VERSION)
.with_dialect(CONTROL, PROTOCOL)
.with_timeouts(Duration::from_millis(200), Duration::from_millis(10))
.with_shutdown_timeout(Duration::from_millis(200))
}
#[test]
fn a_fresh_windows_host_gets_the_windows_server_in_local_app_data() {
let host = FakeWindows::new("AMD64");
let release = Release::new();
let user = Approve(Mutex::new(Vec::new()));
let report = installer(&host, &release, &user)
.run()
.expect("a Windows host installs");
host.assert_nothing_foreign();
assert_eq!(report.asset, ASSET_WINDOWS_X86_64);
assert_eq!(report.paths.platform, RemotePlatform::Windows);
assert_eq!(report.paths.binary, BINARY);
assert!(report.installed && report.confirmed && report.launched);
assert!(host.has(BINARY));
let asked = user.0.lock().unwrap();
assert_eq!(asked.len(), 1, "a first install is confirmed");
assert_eq!(asked[0].remote_path, BINARY);
assert_eq!(asked[0].asset, ASSET_WINDOWS_X86_64);
assert!(
release.fetched.lock().unwrap()[1].ends_with("/v26.9.9/tty7-server-windows-x86_64.exe"),
"{:?}",
release.fetched.lock().unwrap()
);
let steps = host.steps();
assert_eq!(steps[0], Step::Uname, "unix is still asked first");
assert_eq!(steps[1], Step::Script("probe".into()));
assert!(
!steps.iter().any(|s| matches!(s, Step::Chmod(_))),
"NTFS has no mode bits to set: {steps:?}"
);
for dir in [
"/C:/Users/me/AppData",
"/C:/Users/me/AppData/Local",
"/C:/Users/me/AppData/Local/tty7",
BIN_DIR,
] {
assert!(steps.contains(&Step::Mkdir(dir.into())), "{dir}: {steps:?}");
}
let temp = steps
.iter()
.find_map(|s| match s {
Step::Put(path) => Some(path.clone()),
_ => None,
})
.expect("an upload");
assert!(
temp.starts_with(&format!("{BIN_DIR}/.tty7-server-c3p4.")) && temp.ends_with(".tmp.exe"),
"{temp}"
);
assert!(steps.contains(&Step::Rename {
from: temp.clone(),
to: BINARY.into()
}));
let scripts = host.scripts();
let launch = scripts
.iter()
.position(|t| t == "launch")
.expect("a launch");
let protocol = scripts
.iter()
.position(|t| t == "protocol")
.expect("a probe");
assert!(
protocol < launch,
"the upload is proven before it is started: {scripts:?}"
);
assert_eq!(scripts.last().map(String::as_str), Some("running-exe"));
}
#[test]
fn an_arm64_host_gets_the_arm64_server() {
let host = FakeWindows::new("ARM64");
let release = Release::new();
let user = Approve(Mutex::new(Vec::new()));
let report = installer(&host, &release, &user).run().unwrap();
assert_eq!(report.asset, ASSET_WINDOWS_AARCH64);
host.assert_nothing_foreign();
}
#[test]
fn a_32_bit_windows_is_refused_before_anything_is_written() {
let host = FakeWindows::new("x86");
let release = Release::new();
let user = Approve(Mutex::new(Vec::new()));
let err = installer(&host, &release, &user).run().unwrap_err();
assert!(
matches!(
err,
InstallError::Unsupported(UnsupportedTarget::UnknownWindowsMachine { .. })
),
"{err:?}"
);
assert!(err.to_string().contains("\"x86\""), "{err}");
assert!(release.fetched.lock().unwrap().is_empty());
assert!(!host.has(BIN_DIR));
}
#[test]
fn git_for_windows_on_the_path_still_gets_the_windows_server() {
let host = FakeWindows::new("AMD64").with_uname("MINGW64_NT-10.0-26100 x86_64\n");
let release = Release::new();
let user = Approve(Mutex::new(Vec::new()));
let report = installer(&host, &release, &user).run().unwrap();
assert_eq!(report.asset, ASSET_WINDOWS_X86_64);
assert_eq!(report.paths.binary, BINARY);
host.assert_nothing_foreign();
}
/// Neither probe answering is a machine that is neither, and `uname`'s
/// failure is the one worth reading.
#[test]
fn when_nothing_answers_the_uname_failure_is_reported() {
let host = FakeWindows::new("AMD64").not_answering_powershell();
let release = Release::new();
let user = Approve(Mutex::new(Vec::new()));
let err = installer(&host, &release, &user).run().unwrap_err();
match err {
InstallError::Probe(reason) => {
assert!(reason.contains("'uname' is not recognized"), "{reason}")
}
other => panic!("expected the uname failure, got {other:?}"),
}
}
/// A Windows host whose OpenSSH `DefaultShell` is WSL's bash answers `uname`
/// as Linux while SFTP writes to `C:\`. A Linux server installed at `/C:/…`
/// would be a file no Linux path reaches.
#[test]
fn a_linux_shell_over_windows_sftp_is_refused() {
let host = FakeWindows::new("AMD64").with_uname("Linux x86_64\n");
let release = Release::new();
let user = Approve(Mutex::new(Vec::new()));
let err = installer(&host, &release, &user).run().unwrap_err();
let InstallError::Probe(reason) = &err else {
panic!("expected a probe error, got {err:?}");
};
assert!(reason.contains("DefaultShell"), "{reason}");
assert!(release.fetched.lock().unwrap().is_empty());
assert!(!host.has(BIN_DIR), "nothing was written");
}
#[test]
fn a_serving_windows_host_is_left_alone() {
let host = FakeWindows::new("AMD64")
.installed(BINARY, ours())
.serving(BINARY);
let release = Release::new();
let user = Approve(Mutex::new(Vec::new()));
let report = installer(&host, &release, &user).run().unwrap();
assert!(!report.installed && !report.launched);
assert_eq!(report.mismatch, None);
assert!(
release.fetched.lock().unwrap().is_empty(),
"an .exe with no execute bit over SFTP is still an installed server"
);
host.assert_nothing_foreign();
}
/// The running image cannot be deleted on Windows, but it can be renamed, and
/// that is how an update gets its name back.
#[test]
fn an_update_moves_the_running_image_aside_and_the_next_one_sweeps_it() {
let host = FakeWindows::new("AMD64")
.installed(BINARY, ours())
.serving(BINARY);
let release = Release::new();
let user = Approve(Mutex::new(Vec::new()));
installer(&host, &release, &user)
.replace_forced()
.expect("the update goes through");
host.assert_nothing_foreign();
let steps = host.steps();
let aside = steps
.iter()
.find_map(|s| match s {
Step::Rename { from, to } if from == BINARY => Some(to.clone()),
_ => None,
})
.expect("the running image was moved aside");
assert!(
aside.starts_with(&format!("{BIN_DIR}/.tty7-server-c3p4.exe.")) && aside.ends_with(".old"),
"{aside}"
);
assert!(host.has(BINARY), "the new server took the name");
assert!(
host.has(&aside),
"the old image is still there until it stops running"
);
let scripts = host.scripts();
let stop = scripts.iter().position(|t| t == "stop").expect("a stop");
let launch = scripts
.iter()
.rposition(|t| t == "launch")
.expect("a start");
assert!(stop < launch, "{scripts:?}");
assert!(
user.0.lock().unwrap().is_empty(),
"an update is not a first install"
);
// The old image has stopped; the next update clears it away.
installer(&host, &release, &user).replace_forced().unwrap();
assert!(!host.has(&aside), "the moved-aside image was swept");
}
#[test]
fn a_restart_on_windows_asks_the_server_to_stop_instead_of_signalling_it() {
let host = FakeWindows::new("AMD64")
.installed(BINARY, ours())
.serving(BINARY);
let release = Release::new();
let user = Approve(Mutex::new(Vec::new()));
installer(&host, &release, &user).restart_daemon().unwrap();
host.assert_nothing_foreign();
let steps = host.steps();
assert!(
!steps.contains(&Step::Uname),
"a restart knows the platform from the SFTP home: {steps:?}"
);
let scripts = host.scripts();
assert!(
scripts.windows(2).any(|w| w == ["stop", "control-probe"]),
"{scripts:?}"
);
assert!(scripts.contains(&"launch".to_string()), "{scripts:?}");
}
#[test]
fn another_builds_daemon_is_reported_in_the_installers_spelling() {
let other = "/C:/Users/me/AppData/Local/tty7/bin/tty7-server-c2p4.exe";
let host = FakeWindows::new("AMD64")
.installed(BINARY, ours())
.serving(other);
host.speaks.lock().unwrap().insert(
other.to_string(),
RemoteProtocol {
control: 2,
protocol: 4,
build: "26.8.1".into(),
},
);
let release = Release::new();
let user = Approve(Mutex::new(Vec::new()));
let sink = Arc::new(Mutex::new(Vec::new()));
let report =
with_mismatch_sink(sink.clone(), || installer(&host, &release, &user).run()).unwrap();
let mismatch = report.mismatch.expect("the other build is reported");
assert_eq!(mismatch.running_exe.as_deref(), Some(other));
assert_eq!(mismatch.running_version.as_deref(), Some("26.8.1"));
assert_eq!(sink.lock().unwrap().len(), 1);
host.assert_nothing_foreign();
}
#[test]
fn the_link_command_is_phrased_for_the_hosts_shell() {
assert_eq!(
server_stdio_command(BINARY),
r#""C:\Users\me\AppData\Local\tty7\bin\tty7-server-c3p4.exe" --stdio"#
);
assert_eq!(
server_stdio_command("/home/me/.local/share/tty7/bin/tty7-server-c3p4"),
"'/home/me/.local/share/tty7/bin/tty7-server-c3p4' --stdio",
"the unix command is unchanged"
);
}
@@ -144,6 +144,20 @@ pub fn choose_entry(
}
}
/// The entry a server's location settles without asking the remote anything,
/// or `None` when [`REMOTE_ENV_PROBE`] and [`choose_entry`] have to decide.
///
/// A Windows server is always reached by session exec. The probe is a POSIX
/// `sh` script that no Windows shell runs, and the stream-local forward it
/// would find a socket for does not exist there: a Windows daemon listens on a
/// loopback TCP port guarded by a token, not on a unix socket. Asking anyway
/// would only spend a round trip learning that.
pub fn fixed_entry(server_binary: &str, command: &str) -> Option<RemoteEntry> {
super::install::asset::is_windows_sftp_path(server_binary).then(|| RemoteEntry::SessionExec {
command: command.to_string(),
})
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RemoteEnv {
pub control_sock: Option<String>,
@@ -377,6 +391,28 @@ mod tests {
);
}
#[test]
fn a_windows_server_is_reached_by_session_exec_without_a_probe() {
let command = r#""C:\Users\me\AppData\Local\tty7\bin\tty7-server-c3p4.exe" --stdio"#;
assert_eq!(
fixed_entry(
"/C:/Users/me/AppData/Local/tty7/bin/tty7-server-c3p4.exe",
command
),
Some(RemoteEntry::SessionExec {
command: command.into()
})
);
assert_eq!(
fixed_entry(
"/home/me/.local/share/tty7/bin/tty7-server-c3p4",
"'/home/me/.local/share/tty7/bin/tty7-server-c3p4' --stdio"
),
None,
"a unix server still goes through the probe"
);
}
#[test]
fn the_env_probe_survives_a_chatty_remote() {
let out = "Welcome to Ubuntu!\n\
+12 -12
View File
@@ -446,10 +446,7 @@ impl SshManager {
let base = match server_command {
Some(explicit) => explicit.to_string(),
None => format!(
"{} --stdio",
crate::daemon::install::shell_quote(&installed)
),
None => crate::daemon::install::server_stdio_command(&installed),
};
let command = setup.channel.bridge_command(&base);
@@ -457,14 +454,17 @@ impl SshManager {
RouteChannel::Pane => RemoteEntry::SessionExec {
command: command.clone(),
},
RouteChannel::Control => {
conn.remote_entry_or_init(|| async {
let env = probe_remote_env(conn).await;
let socket = env.as_ref().and_then(remote_link::remote_control_socket);
remote_link::choose_entry(socket.as_deref(), true, &command)
})
.await
}
RouteChannel::Control => match remote_link::fixed_entry(&installed, &command) {
Some(entry) => entry,
None => {
conn.remote_entry_or_init(|| async {
let env = probe_remote_env(conn).await;
let socket = env.as_ref().and_then(remote_link::remote_control_socket);
remote_link::choose_entry(socket.as_deref(), true, &command)
})
.await
}
},
};
if let RemoteEntry::StreamLocal { socket } = &entry {
+130 -13
View File
@@ -8,6 +8,7 @@ USAGE:
tty7-server --daemon [--config-dir <dir>]
tty7-server --stdio [--serve | --bridge] [--control-sock <path>]
tty7-server --stdio --pane [--config-dir <dir>]
tty7-server --stop [--config-dir <dir>]
tty7-server agent-hook <agent> <event>
OPTIONS:
@@ -17,6 +18,7 @@ OPTIONS:
--bridge Forward to the machine's control socket
--pane Forward to the machine's *pane* socket instead
--control-sock <p> Use <p> as the control socket instead of the default
--stop Ask the running daemon to shut down, and wait for it
--config-dir <dir> Use <dir> for the socket, config and session files
--protocol Print the dialects this binary speaks, as JSON
-V, --version Print the version and exit
@@ -57,6 +59,14 @@ fn main() -> ExitCode {
tty7_core::core::crash::install("server");
tty7_core::core::logfile::install("server");
// What a remote Windows host is restarted with. Unix hosts are sent a
// SIGTERM, which Windows has no equivalent of; this is the graceful
// shutdown a local tty7 uses there, with its reap as the fallback.
if args.iter().any(|a| a == "--stop") {
tty7_core::daemon::spawn::stop();
return ExitCode::SUCCESS;
}
if args.iter().any(|a| a == "--stdio") {
return match run_stdio(&args) {
Ok(()) => ExitCode::SUCCESS,
@@ -84,13 +94,18 @@ fn run_daemon() -> ExitCode {
}
fn run_stdio(args: &[String]) -> io::Result<()> {
#[cfg(not(unix))]
#[cfg(windows)]
{
run_stdio_windows(args)
}
#[cfg(not(any(unix, windows)))]
{
let _ = args;
return Err(io::Error::new(
Err(io::Error::new(
io::ErrorKind::Unsupported,
"--stdio is a Unix path; a Windows server is reached over its own transport",
));
"--stdio is not available on this platform",
))
}
#[cfg(unix)]
@@ -192,21 +207,49 @@ fn bridge_panes() -> io::Result<()> {
bridge(upstream)
}
/// What [`bridge`] needs from the connection to the daemon: a unix socket on
/// Linux and macOS, a loopback TCP stream on Windows.
trait Upstream: io::Read + io::Write + Send + Sized + 'static {
fn duplicate(&self) -> io::Result<Self>;
fn shutdown_both(&self);
}
#[cfg(unix)]
fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
use std::io::{Read as _, Write as _};
use std::net::Shutdown;
impl Upstream for std::os::unix::net::UnixStream {
fn duplicate(&self) -> io::Result<Self> {
self.try_clone()
}
let mut up_read = upstream.try_clone()?;
let mut up_write = upstream.try_clone()?;
fn shutdown_both(&self) {
let _ = self.shutdown(std::net::Shutdown::Both);
}
}
let feeder_socket = upstream.try_clone()?;
#[cfg(windows)]
impl Upstream for std::net::TcpStream {
fn duplicate(&self) -> io::Result<Self> {
self.try_clone()
}
fn shutdown_both(&self) {
let _ = self.shutdown(std::net::Shutdown::Both);
}
}
#[cfg(any(unix, windows))]
fn bridge<S: Upstream>(upstream: S) -> io::Result<()> {
use std::io::Write as _;
let mut up_read = upstream.duplicate()?;
let mut up_write = upstream.duplicate()?;
let feeder_socket = upstream.duplicate()?;
let feeder = std::thread::Builder::new()
.name("tty7-stdio-bridge-in".into())
.spawn(move || {
let mut stdin = io::stdin().lock();
let _ = io::copy(&mut stdin, &mut up_write);
let _ = feeder_socket.shutdown(Shutdown::Both);
feeder_socket.shutdown_both();
})?;
let mut stdout = io::stdout().lock();
@@ -220,18 +263,92 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => {
let _ = upstream.shutdown(Shutdown::Both);
upstream.shutdown_both();
drop(feeder);
return Err(e);
}
}
}
let _ = upstream.shutdown(Shutdown::Both);
upstream.shutdown_both();
drop(feeder);
Ok(())
}
/// `--stdio` on a Windows host: always a bridge, to the daemon's loopback
/// endpoints (a TCP port and a token, recorded in the config directory).
///
/// There is no `--serve` here. Serving in this process needs stdin and stdout
/// as a duplex the control server can own, which only exists on unix; and a
/// remote Windows host always has a daemon to bridge to, because the installer
/// starts one before any link is opened.
///
/// A daemon this starts itself — the fallback when none answers — lives inside
/// the SSH session's job object and ends with that connection. The installer
/// launches the long-lived one outside of it; see `install::windows_host`.
#[cfg(windows)]
fn run_stdio_windows(args: &[String]) -> io::Result<()> {
use std::time::{Duration, Instant};
use tty7_core::daemon::{spawn, transport};
use tty7_core::host::server;
let force_serve = args.iter().any(|a| a == "--serve");
let force_bridge = args.iter().any(|a| a == "--bridge");
if force_serve && force_bridge {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--serve and --bridge ask for opposite things",
));
}
if force_serve {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"--serve is not available on Windows; without it, --stdio bridges to the daemon",
));
}
if args.iter().any(|a| a == "--pane") {
let upstream = match transport::connect() {
Ok(s) => s,
Err(e) => {
log_stderr(format_args!(
"no pane daemon at {} ({e}); starting one",
transport::endpoint_display()
));
spawn::ensure_running().map_err(io::Error::other)?;
transport::connect()?
}
};
return bridge(upstream);
}
let explicit = flag_value(args, "--control-sock");
let connect = || match &explicit {
Some(path) => transport::connect_endpoint_at(std::path::Path::new(path)),
None => server::connect_control(),
};
let upstream = match connect() {
Ok(s) => s,
Err(e) if force_bridge || !may_start_daemon(args) => return Err(e),
Err(e) => {
log_stderr(format_args!(
"no control server answering ({e}); starting one"
));
spawn::ensure_running().map_err(io::Error::other)?;
// `ensure_running` returns once the pane endpoint answers; the
// control listener opens a moment after it.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match connect() {
Ok(s) => break s,
Err(e) if Instant::now() >= deadline => return Err(e),
Err(_) => std::thread::sleep(Duration::from_millis(100)),
}
}
}
};
bridge(upstream)
}
fn may_start_daemon(args: &[String]) -> bool {
flag_value(args, "--control-sock").is_none()
}