mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-03 00:01:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user