diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b7c5121..de003c46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c3bb4871..e3243a9b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 921e4653..384a72f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 0b655d70..530a3f8d 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -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(); diff --git a/crates/tty7-core/src/daemon/install/asset.rs b/crates/tty7-core/src/daemon/install/asset.rs index cbc37e51..03eccd4d 100644 --- a/crates/tty7-core/src/daemon/install/asset.rs +++ b/crates/tty7-core/src/daemon/install/asset.rs @@ -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, + /// 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 { + 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 { + 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}"); + } } } } diff --git a/crates/tty7-core/src/daemon/install/mod.rs b/crates/tty7-core/src/daemon/install/mod.rs index b61c8ceb..253efd9d 100644 --- a/crates/tty7-core/src/daemon/install/mod.rs +++ b/crates/tty7-core/src/daemon/install/mod.rs @@ -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 { - 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, InstallError> { - let Some(exe) = self.running_server_exe() else { + fn adoptable_running_server( + &self, + platform: RemotePlatform, + ) -> Result, 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 { - let cmd = format!("{} {PROTOCOL_FLAG}", shell_quote(exe)); + fn probe_protocol(&self, platform: RemotePlatform, exe: &str) -> Option { + 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 { - 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 { + 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 { let sink = install_progress(); let on_progress = |done: u64, total: Option| { @@ -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, 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 { 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 { - 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 { - 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) -> 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: +/// `'' --stdio` for a POSIX shell, `"" --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 { #[cfg(test)] mod tests; + +#[cfg(test)] +mod windows_tests; diff --git a/crates/tty7-core/src/daemon/install/ssh_ops.rs b/crates/tty7-core/src/daemon/install/ssh_ops.rs index df4f1b2c..acd920c7 100644 --- a/crates/tty7-core/src/daemon/install/ssh_ops.rs +++ b/crates/tty7-core/src/daemon/install/ssh_ops.rs @@ -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, cmd: &str) -> Result` 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 ` 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 = 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 = 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 { + stdout.lines().find_map(|line| { + line.trim() + .strip_prefix(PROBE_MARK) + .map(|arch| arch.trim().to_string()) + }) +} + +/// ` --protocol`, exiting with its status. +pub(crate) fn protocol_command(exe: &str) -> String { + powershell( + "protocol", + &format!( + "& {} {}\nexit $LASTEXITCODE\n", + native(exe), + super::PROTOCOL_FLAG + ), + ) +} + +/// ` --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 """ --daemon 1>>"" 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: `"" --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"# + ); + } +} diff --git a/crates/tty7-core/src/daemon/install/windows_tests.rs b/crates/tty7-core/src/daemon/install/windows_tests.rs new file mode 100644 index 00000000..9ed4e96c --- /dev/null +++ b/crates/tty7-core/src/daemon/install/windows_tests.rs @@ -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, + /// `None`: PowerShell does not answer either (not Windows after all). + arch: Option, + files: Mutex, bool)>>, + speaks: Mutex>, + uploads_speak: RemoteProtocol, + /// The image of the running daemon, in SFTP spelling. + running: Mutex>, + steps: Mutex>, +} + +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 { + self.steps.lock().unwrap().clone() + } + + fn scripts(&self) -> Vec { + self.steps() + .into_iter() + .filter_map(|s| match s { + Step::Script(tag) => Some(tag), + _ => None, + }) + .collect() + } + + fn assert_nothing_foreign(&self) { + let foreign: Vec = 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 { + Ok(ExecOutput { + status: Some(0), + stdout: stdout.to_string(), + stderr: String::new(), + }) +} + +fn failed(status: u32, stderr: &str) -> Result { + Ok(ExecOutput { + status: Some(status), + stdout: String::new(), + stderr: stderr.to_string(), + }) +} + +/// The native path a script names as `& '' `, back in SFTP form. +fn invoked(script: &str, flag: &str) -> Option { + 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 { + Ok(self.home.clone()) + } + + fn run(&self, cmd: &str) -> Result { + 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, 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>, 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>, +} + +impl Release { + fn new() -> Self { + Self { + fetched: Mutex::new(Vec::new()), + } + } +} + +impl AssetFetcher for Release { + fn get(&self, url: &str) -> Result, 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>); + +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" + ); +} diff --git a/crates/tty7-core/src/daemon/remote_link.rs b/crates/tty7-core/src/daemon/remote_link.rs index 79f3553f..07e5353e 100644 --- a/crates/tty7-core/src/daemon/remote_link.rs +++ b/crates/tty7-core/src/daemon/remote_link.rs @@ -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 { + 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, @@ -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\ diff --git a/crates/tty7-core/src/daemon/ssh/mod.rs b/crates/tty7-core/src/daemon/ssh/mod.rs index 5e939f10..4e74d3ef 100644 --- a/crates/tty7-core/src/daemon/ssh/mod.rs +++ b/crates/tty7-core/src/daemon/ssh/mod.rs @@ -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 { diff --git a/crates/tty7-server/src/main.rs b/crates/tty7-server/src/main.rs index ed759a5c..d307cc02 100644 --- a/crates/tty7-server/src/main.rs +++ b/crates/tty7-server/src/main.rs @@ -8,6 +8,7 @@ USAGE: tty7-server --daemon [--config-dir ] tty7-server --stdio [--serve | --bridge] [--control-sock ] tty7-server --stdio --pane [--config-dir ] + tty7-server --stop [--config-dir ] tty7-server agent-hook 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

Use

as the control socket instead of the default + --stop Ask the running daemon to shut down, and wait for it --config-dir

Use 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; + 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.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.try_clone() + } + + fn shutdown_both(&self) { + let _ = self.shutdown(std::net::Shutdown::Both); + } +} + +#[cfg(any(unix, windows))] +fn bridge(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() }