diff options
Diffstat (limited to 'src/remote/attach.rs')
| -rw-r--r-- | src/remote/attach.rs | 516 |
1 files changed, 468 insertions, 48 deletions
diff --git a/src/remote/attach.rs b/src/remote/attach.rs index a597f564..ace72f84 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -34,6 +34,9 @@ const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json"; const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; const REMOTE_OUTPUT_READY_MARKER: &str = "herdr-remote-output-ready:1"; +const WINDOWS_REMOTE_PATH_MARKER: &str = "herdr-remote-path:1:"; +const WINDOWS_REMOTE_INSTALL_DIR_MARKER: &str = "herdr-remote-install-dir:1:"; +const WINDOWS_REMOTE_INSTALL_RESULT_MARKER: &str = "herdr-remote-install-result:1:"; const SSH_CONTROL_SOCKET_NAME: &str = "ctl"; pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { let session_name = crate::session::active_name() @@ -332,6 +335,11 @@ impl RemoteHerdr { self.executable = RemoteExecutable::PosixShellPath(shell_path); self } + + fn with_windows_path(mut self, path: String) -> Self { + self.executable = RemoteExecutable::WindowsPath(path); + self + } } fn windows_powershell_application_script(path: &str, args: &[&str]) -> String { @@ -586,6 +594,12 @@ impl RemoteSsh { command } + fn scp_command(&self) -> Command { + let mut command = Command::new("scp"); + apply_managed_scp_options(&mut command, self.options()); + command + } + fn sh_output(&self, script: &str) -> io::Result<Output> { let script = posix_remote_output_command(script); let mut child = self @@ -688,6 +702,86 @@ impl RemoteSsh { ))) } } + + fn copy_windows_file(&self, source_path: &Path, remote_path: &str) -> io::Result<()> { + let status = self + .scp_command() + .arg(source_path) + .arg(windows_scp_target(&self.target, remote_path)) + .status() + .map_err(|err| io::Error::new(err.kind(), format!("failed to start scp: {err}")))?; + if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!("scp exited with {status}"))) + } + } + + fn install_windows_herdr( + &self, + remote_herdr: &RemoteHerdr, + source_path: &Path, + identity: &str, + sha256: &str, + ) -> io::Result<RemoteHerdr> { + let installer_dir = private_download_dir("windows-installer")?; + let local_installer_path = installer_dir.join("install.ps1"); + if let Err(err) = fs::write(&local_installer_path, crate::update::WINDOWS_INSTALLER) { + let _ = fs::remove_dir_all(&installer_dir); + return Err(err); + } + + let result = (|| { + let output = self.framed_user_shell_output(&windows_remote_install_prepare_command())?; + if !output.status.success() { + return Err(command_failed("remote install preparation failed", &output)); + } + let remote_dir = parse_windows_remote_path( + &String::from_utf8_lossy(&output.stdout), + WINDOWS_REMOTE_INSTALL_DIR_MARKER, + )?; + let install_result = (|| { + self.copy_windows_file( + &local_installer_path, + &format!(r"{remote_dir}\install.ps1"), + )?; + self.copy_windows_file( + source_path, + &format!(r"{remote_dir}\herdr-windows-x86_64.zip"), + )?; + + let output = self.framed_user_shell_output(&windows_remote_install_command( + &remote_dir, + identity, + sha256, + ))?; + if !output.status.success() { + return Err(command_failed("remote Windows install failed", &output)); + } + parse_windows_remote_path( + &String::from_utf8_lossy(&output.stdout), + WINDOWS_REMOTE_INSTALL_RESULT_MARKER, + ) + .map(|path| remote_herdr.clone().with_windows_path(path)) + })(); + + let cleanup = + self.framed_user_shell_output(&windows_remote_install_cleanup_command(&remote_dir)); + match install_result { + Err(err) => Err(err), + Ok(installed) => { + let output = cleanup?; + if output.status.success() { + Ok(installed) + } else { + Err(command_failed("remote install cleanup failed", &output)) + } + } + } + })(); + let _ = fs::remove_dir_all(installer_dir); + result + } } // Only interactive setup uses this relay. Background probes retain their @@ -804,6 +898,72 @@ fn remote_install_commit_script(tmp_path: &str, dest_path: &str) -> String { ) } +fn windows_remote_install_prepare_command() -> String { + windows_powershell_script_command(&format!( + "$remoteInstallDir = Join-Path ([System.IO.Path]::GetTempPath()) ('herdr-remote-' + [System.Guid]::NewGuid().ToString('N')); [System.IO.Directory]::CreateDirectory($remoteInstallDir) | Out-Null; $encodedInstallDir = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($remoteInstallDir)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_INSTALL_DIR_MARKER}' + $encodedInstallDir); exit 0" + )) +} + +fn windows_scp_target(target: &str, remote_path: &str) -> String { + let remote_path = remote_path.replace('\\', "/"); + match target.strip_prefix("ssh://") { + Some(authority) => { + let mut encoded = String::new(); + for byte in remote_path.bytes() { + if byte.is_ascii_alphanumeric() || b"-_.~/:".contains(&byte) { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + format!("scp://{authority}/{encoded}") + } + None => format!("{target}:{remote_path}"), + } +} + +fn windows_remote_install_command(remote_dir: &str, identity: &str, sha256: &str) -> String { + let installer = crate::platform::quote_powershell_arg(&format!(r"{remote_dir}\install.ps1")); + let package = + crate::platform::quote_powershell_arg(&format!(r"{remote_dir}\herdr-windows-x86_64.zip")); + windows_powershell_script_command(&format!( + r#"$herdrInstaller = {installer}; $herdrPackage = {package}; & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $herdrInstaller -Channel {channel} -LocalPackagePath $herdrPackage -LocalPackageFormat zip -LocalPackageIdentity {identity} -LocalPackageSha256 {sha256}; if ($LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Join-Path $herdrHome 'packages\standalone\current'; $activeTarget = [string](Get-Item -LiteralPath $activeJunction -Force -ErrorAction Stop).Target; if ([string]::IsNullOrWhiteSpace($activeTarget)) {{ throw 'Herdr installer did not activate a concrete release.' }}; $installedHerdr = Join-Path $activeTarget 'herdr.exe'; if (-not (Test-Path -LiteralPath $installedHerdr -PathType Leaf)) {{ throw 'Herdr installer result does not contain herdr.exe.' }}; $encodedResult = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.IO.Path]::GetFullPath($installedHerdr))); [Console]::Out.WriteLine('{WINDOWS_REMOTE_INSTALL_RESULT_MARKER}' + $encodedResult); exit 0"#, + channel = crate::platform::quote_powershell_arg(current_channel()), + identity = crate::platform::quote_powershell_arg(identity), + sha256 = crate::platform::quote_powershell_arg(sha256), + )) +} + +fn windows_remote_install_cleanup_command(remote_dir: &str) -> String { + windows_powershell_script_command(&format!( + "Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction Stop; exit 0", + crate::platform::quote_powershell_arg(remote_dir) + )) +} + +fn parse_windows_remote_path(stdout: &str, marker: &str) -> io::Result<String> { + let encoded = stdout + .lines() + .rev() + .find_map(|line| line.trim().strip_prefix(marker)) + .ok_or_else(|| io::Error::other(format!("remote command did not return {marker}")))?; + decode_windows_remote_path(encoded) +} + +fn decode_windows_remote_path(encoded: &str) -> io::Result<String> { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + io::Error::other(format!("remote path result is not valid base64: {err}")) + })?; + let path = String::from_utf8(bytes) + .map_err(|err| io::Error::other(format!("remote path result is not valid UTF-8: {err}")))?; + if path.is_empty() { + return Err(io::Error::other("remote path result is empty")); + } + Ok(path) +} + impl Drop for RemoteSsh { fn drop(&mut self) { let Some(_options) = self @@ -864,6 +1024,26 @@ fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshO } } +fn apply_managed_scp_options(command: &mut Command, options: Option<&ManagedSshOptions>) { + let Some(options) = options else { + return; + }; + + command.arg("-F").arg(&options.config_path); + if let Some(control_path) = &options.control_path { + command + .arg("-o") + .arg(format!( + "ControlPath={}", + ssh_config_quote(&control_path.to_string_lossy()) + )) + .arg("-o") + .arg("ControlMaster=auto") + .arg("-o") + .arg("ControlPersist=yes"); + } +} + impl InstallSource { fn persistent(path: PathBuf) -> Self { Self { @@ -894,7 +1074,12 @@ pub(super) fn prepare_remote_herdr( let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); if remote_herdr.platform.is_windows() { - return prepare_windows_remote_herdr(ssh, remote_herdr, require_surface_interest); + return prepare_windows_remote_herdr( + ssh, + remote_herdr, + live_handoff_enabled, + require_surface_interest, + ); } let override_binary = remote_binary_override_path()?; let remote_binary_candidates = remote_binary_candidates(ssh, &remote_herdr)?; @@ -964,10 +1149,6 @@ pub(super) fn prepare_remote_herdr( pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteHerdr> { let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); - if remote_herdr.platform.is_windows() { - return prepare_windows_remote_herdr(ssh, remote_herdr, true) - .map(|prepared| prepared.remote_herdr); - } let candidates = remote_binary_candidates(ssh, &remote_herdr)?; for candidate in candidates { if remote_binary_supports_endpoint_requirement(ssh, &candidate, true)? { @@ -987,27 +1168,69 @@ pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteH fn prepare_windows_remote_herdr( ssh: &RemoteSsh, remote_herdr: RemoteHerdr, + live_handoff_enabled: bool, require_surface_interest: bool, ) -> io::Result<PreparedRemoteHerdr> { - if !remote_binary_exists(ssh, &remote_herdr)? { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!( - "herdr.exe is not installed or is not on PATH on Windows host {}; install a compatible Windows package with remote host support and retry", - ssh.target() - ), - )); + let override_package = remote_binary_override_path()?; + let custom_package = override_package.is_some(); + let candidates = remote_binary_candidates(ssh, &remote_herdr)?; + if !custom_package { + for candidate in &candidates { + if remote_binary_supports_endpoint_requirement( + ssh, + candidate, + require_surface_interest, + )? { + return Ok(PreparedRemoteHerdr { + remote_herdr: candidate.clone(), + stop_after_install_approved: false, + }); + } + } + } + + let stop_after_install_approved = if let Some(candidate) = candidates.first() { + confirm_remote_install_with_running_server( + ssh, + candidate, + live_handoff_enabled, + require_surface_interest, + )? + } else { + false + }; + if !stop_after_install_approved { + confirm_remote_install( + &ssh.destination(), + &remote_herdr, + &install_source_description(&remote_herdr.platform, override_package.as_deref()), + )?; } + // Windows needs the complete package, including its app-local ConPTY runtime. + let source = match override_package { + Some(path) => InstallSource::persistent(path), + None => download_release_asset(&remote_herdr.platform)?, + }; + let install_result = (|| { + let sha256 = crate::checksum::file_sha256(&source.path)?; + ssh.install_windows_herdr( + &remote_herdr, + &source.path, + &windows_package_identity(custom_package, &sha256), + &sha256, + ) + })(); + source.cleanup(); + let remote_herdr = install_result?; if !remote_binary_supports_endpoint_requirement(ssh, &remote_herdr, require_surface_interest)? { return Err(io::Error::other(format!( - "herdr.exe on Windows host {} does not support saved SSH endpoint federation; install a compatible Windows package with remote host support and retry", - ssh.target() + "installed remote herdr at {}, but it does not support the required remote hosting capabilities", + remote_herdr.executable.display() ))); } - Ok(PreparedRemoteHerdr { remote_herdr, - stop_after_install_approved: false, + stop_after_install_approved, }) } @@ -1017,11 +1240,7 @@ pub(super) fn find_installed_remote_api_herdr( ) -> io::Result<RemoteHerdr> { let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); - let candidates = if remote_herdr.platform.is_windows() { - vec![remote_herdr] - } else { - remote_binary_candidates(ssh, &remote_herdr)? - }; + let candidates = remote_binary_candidates(ssh, &remote_herdr)?; for candidate in candidates { let probe = ssh.framed_user_shell_output(&remote_api_bridge_command(&candidate, session, true))?; @@ -1120,6 +1339,17 @@ fn remote_binary_candidates( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, ) -> io::Result<Vec<RemoteHerdr>> { + if remote_herdr.platform.is_windows() { + let output = ssh.framed_user_shell_output(&windows_remote_binary_candidate_command())?; + if !output.status.success() { + return Err(command_failed("remote binary discovery failed", &output)); + } + return Ok(windows_remote_binary_candidates( + remote_herdr, + &String::from_utf8_lossy(&output.stdout), + )); + } + let mut candidates = Vec::new(); if let Some(path_candidate) = remote_binary_on_path_any(ssh, remote_herdr)? { @@ -1140,6 +1370,27 @@ fn remote_binary_candidates( Ok(candidates) } +fn windows_remote_binary_candidate_command() -> String { + windows_powershell_script_command(&format!( + r#"function Emit-HerdrPath([string]$CandidatePath) {{ if ([string]::IsNullOrWhiteSpace($CandidatePath) -or -not (Test-Path -LiteralPath $CandidatePath -PathType Leaf)) {{ return }}; $candidateFullPath = [System.IO.Path]::GetFullPath($CandidatePath); $encodedCandidate = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($candidateFullPath)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_PATH_MARKER}' + $encodedCandidate) }}; $pathCommand = Get-Command herdr.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $pathCommand) {{ Emit-HerdrPath $pathCommand.Source }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Get-Item -LiteralPath (Join-Path $herdrHome 'packages\standalone\current') -Force -ErrorAction SilentlyContinue; if ($null -ne $activeJunction -and -not [string]::IsNullOrWhiteSpace([string]$activeJunction.Target)) {{ Emit-HerdrPath (Join-Path ([string]$activeJunction.Target) 'herdr.exe') }}; exit 0"# + )) +} + +fn windows_remote_binary_candidates(remote_herdr: &RemoteHerdr, stdout: &str) -> Vec<RemoteHerdr> { + let mut candidates = Vec::new(); + for path in stdout.lines().filter_map(|line| { + line.trim() + .strip_prefix(WINDOWS_REMOTE_PATH_MARKER) + .and_then(|encoded| decode_windows_remote_path(encoded).ok()) + }) { + push_if_new_remote_binary_candidate( + &mut candidates, + remote_herdr.clone().with_windows_path(path), + ); + } + candidates +} + fn push_if_new_remote_binary_candidate(candidates: &mut Vec<RemoteHerdr>, candidate: RemoteHerdr) { if !candidates .iter() @@ -1275,8 +1526,11 @@ fn remote_binary_supports_endpoint_requirement( remote_herdr: &RemoteHerdr, require_surface_interest: bool, ) -> io::Result<bool> { - Ok(remote_client_status(ssh, remote_herdr)? - .is_some_and(|status| status.supports_endpoint_requirement(require_surface_interest))) + Ok( + remote_client_status(ssh, remote_herdr)?.is_some_and(|status| { + status.supports_endpoint_requirement(&remote_herdr.platform, require_surface_interest) + }), + ) } fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<bool> { @@ -1369,7 +1623,7 @@ fn resolve_install_source( } fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { - if *platform != RemotePlatform::local() { + if platform.is_windows() || *platform != RemotePlatform::local() { return false; } @@ -1378,6 +1632,14 @@ fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { .unwrap_or(false) } +fn windows_package_identity(custom: bool, sha256: &str) -> String { + if custom { + format!("{}-custom.{sha256}", current_version()) + } else { + current_version() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum RemoteServerStatus { Running { @@ -1643,12 +1905,19 @@ struct RemoteClientStatusJson { endpoint_protocol_generation: Option<u32>, #[serde(default)] endpoint_capabilities: Vec<String>, + #[serde(default)] + remote_host_bridge: bool, } impl RemoteClientStatusJson { - fn supports_endpoint_requirement(&self, require_surface_interest: bool) -> bool { + fn supports_endpoint_requirement( + &self, + platform: &RemotePlatform, + require_surface_interest: bool, + ) -> bool { self.endpoint_protocol_generation == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) + && (!platform.is_windows() || self.remote_host_bridge) && (!require_surface_interest || [ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY, @@ -2812,6 +3081,20 @@ fn sanitize_path_component(input: &str) -> String { mod tests { use super::*; + fn decode_windows_command(command: &str) -> String { + let encoded = command + .strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ") + .expect("encoded PowerShell command"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("base64"); + let utf16 = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::<Vec<_>>(); + String::from_utf16(&utf16).expect("UTF-16LE") + } + #[cfg(unix)] thread_local! { pub(super) static UPLOAD_READ_ATTEMPTS: std::cell::RefCell<Option<Arc<std::sync::atomic::AtomicUsize>>> = const { std::cell::RefCell::new(None) }; @@ -3186,7 +3469,8 @@ mod tests { #[cfg(unix)] #[test] fn remote_ssh_command_uses_managed_config_when_present() { - let managed_config = write_managed_ssh_config().expect("write managed config"); + let mut managed_config = write_managed_ssh_config().expect("write managed config"); + managed_config.options.control_path = Some(PathBuf::from("/tmp/herdr test/control")); let config_path = managed_config.options.config_path.clone(); let control_path = managed_config .options @@ -3221,6 +3505,25 @@ mod tests { "example".to_string(), ] ); + + let scp_args = ssh + .scp_command() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::<Vec<_>>(); + assert_eq!( + scp_args, + vec![ + "-F".to_string(), + config_path.to_string_lossy().into_owned(), + "-o".to_string(), + format!("ControlPath=\"{}\"", control_path.to_string_lossy()), + "-o".to_string(), + "ControlMaster=auto".to_string(), + "-o".to_string(), + "ControlPersist=yes".to_string(), + ] + ); } #[cfg(windows)] @@ -3263,6 +3566,15 @@ mod tests { "example".to_string(), ] ); + let scp_args = ssh + .scp_command() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::<Vec<_>>(); + assert_eq!( + scp_args, + vec!["-F".to_string(), config_path.to_string_lossy().into_owned(),] + ); } #[cfg(windows)] @@ -3341,6 +3653,10 @@ mod tests { #[test] fn saved_machine_compatibility_uses_capabilities_not_release_or_private_protocol() { + let linux = RemotePlatform { + os: "linux", + arch: "x86_64", + }; let mut status = RemoteClientStatusJson { version: Some("0.1.0".into()), protocol: Some(1), @@ -3352,16 +3668,17 @@ mod tests { crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(), ], + remote_host_bridge: false, }; - assert!(status.supports_endpoint_requirement(true)); + assert!(status.supports_endpoint_requirement(&linux, true)); for index in 0..status.endpoint_capabilities.len() { let removed = status.endpoint_capabilities.remove(index); - assert!(!status.supports_endpoint_requirement(true)); - assert!(status.supports_endpoint_requirement(false)); + assert!(!status.supports_endpoint_requirement(&linux, true)); + assert!(status.supports_endpoint_requirement(&linux, false)); status.endpoint_capabilities.insert(index, removed); } status.endpoint_protocol_generation = None; - assert!(!status.supports_endpoint_requirement(true)); + assert!(!status.supports_endpoint_requirement(&linux, true)); } #[test] @@ -3409,6 +3726,7 @@ mod tests { .collect::<Vec<_>>(); assert_eq!(args, vec!["-T".to_string(), "example".to_string()]); + assert!(ssh.scp_command().get_args().next().is_none()); } #[test] @@ -3730,20 +4048,6 @@ mod tests { #[test] fn windows_remote_commands_use_one_encoded_powershell_grammar() { - fn decode(command: &str) -> String { - let encoded = command - .strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ") - .expect("encoded PowerShell command"); - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .expect("base64"); - let utf16 = bytes - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) - .collect::<Vec<_>>(); - String::from_utf16(&utf16).expect("UTF-16LE") - } - let executable = RemoteExecutable::WindowsPath("herdr.exe".to_string()); let commands = [ ( @@ -3797,7 +4101,7 @@ mod tests { "[Console]::Out.WriteLine(); [Console]::Out.WriteLine('{REMOTE_OUTPUT_READY_MARKER}'); [Console]::Out.Flush(); " ); for (label, command, expected) in commands { - let script = decode(&command); + let script = decode_windows_command(&command); let script = script .strip_prefix(&preamble) .unwrap_or_else(|| panic!("{label} lacks shared output marker: {script}")); @@ -3806,6 +4110,86 @@ mod tests { } #[test] + fn windows_install_commands_copy_zip_and_return_concrete_path() { + assert_eq!( + windows_scp_target("ssh://user@example:2222", r"C:\Temp\A+B%20 C\ü\install.ps1"), + "scp://user@example:2222/C:/Temp/A%2BB%2520%20C/%C3%BC/install.ps1" + ); + assert_eq!( + windows_scp_target("example", r"C:\Temp\A+B%20 C\install.ps1"), + "example:C:/Temp/A+B%20 C/install.ps1" + ); + let remote_dir = r"C:\Temp\Herdr O'Brien\测试"; + assert_eq!( + windows_scp_target( + "user@example", + &format!(r"{remote_dir}\herdr-windows-x86_64.zip") + ), + "user@example:C:/Temp/Herdr O'Brien/测试/herdr-windows-x86_64.zip" + ); + assert_eq!( + windows_scp_target("ssh://user@example:2222", r"C:\Temp\install.ps1"), + "scp://user@example:2222/C:/Temp/install.ps1" + ); + + let command = decode_windows_command(&windows_remote_install_command( + remote_dir, + "0.9.0-custom.digest", + &"a".repeat(64), + )); + assert!(command.contains("-LocalPackageFormat zip")); + assert!(command.contains("-LocalPackageIdentity 0.9.0-custom.digest")); + assert!(command.contains(".Target")); + assert!(command.contains(WINDOWS_REMOTE_INSTALL_RESULT_MARKER)); + + let installed = r"C:\Users\test\测试\.herdr\packages\standalone\releases\0.9.0\herdr.exe"; + let encoded = base64::engine::general_purpose::STANDARD.encode(installed); + assert_eq!( + parse_windows_remote_path( + &format!("installer chatter\n{WINDOWS_REMOTE_INSTALL_RESULT_MARKER}{encoded}\n"), + WINDOWS_REMOTE_INSTALL_RESULT_MARKER + ) + .unwrap(), + installed + ); + assert!(parse_windows_remote_path( + "installer chatter", + WINDOWS_REMOTE_INSTALL_RESULT_MARKER + ) + .is_err()); + } + + #[test] + fn windows_candidate_discovery_prefers_path_then_active_release() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "windows", + arch: "x86_64", + }); + let path = r"C:\stale\herdr.exe"; + let active = r"C:\Users\test\.herdr\packages\standalone\releases\current\herdr.exe"; + let stdout = format!( + "{WINDOWS_REMOTE_PATH_MARKER}{}\nnoise\n{WINDOWS_REMOTE_PATH_MARKER}{}\n{WINDOWS_REMOTE_PATH_MARKER}{}\n", + base64::engine::general_purpose::STANDARD.encode(path), + base64::engine::general_purpose::STANDARD.encode(active), + base64::engine::general_purpose::STANDARD.encode(active), + ); + let candidates = windows_remote_binary_candidates(&remote_herdr, &stdout); + assert_eq!(candidates.len(), 2); + assert_eq!( + candidates[0].executable, + RemoteExecutable::WindowsPath(path.to_string()) + ); + assert_eq!( + candidates[1].executable, + RemoteExecutable::WindowsPath(active.to_string()) + ); + + let command = decode_windows_command(&windows_remote_binary_candidate_command()); + assert!(command.contains("Get-Command herdr.exe")); + assert!(command.contains("$activeJunction.Target")); + } + + #[test] fn remote_output_framing_discards_any_banner_and_preserves_binary() { let payload = [0, 1, 2, 0xff, b'\n']; let mut input = vec![b'x'; 4 * 1024 * 1024]; @@ -4140,6 +4524,10 @@ mod tests { #[test] fn saved_machine_setup_handoffs_old_server_missing_presentation_fence() { + let linux = RemotePlatform { + os: "linux", + arch: "x86_64", + }; // Captured from Rohan after installing a new binary while the old daemon stayed alive. let installed = parse_client_status_json( r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","presentation_effects_fence","health_check"]}"#, @@ -4149,8 +4537,8 @@ mod tests { r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","health_check"]}"#, ) .unwrap(); - assert!(installed.supports_endpoint_requirement(true)); - assert!(!running_binary.supports_endpoint_requirement(true)); + assert!(installed.supports_endpoint_requirement(&linux, true)); + assert!(!running_binary.supports_endpoint_requirement(&linux, true)); for (live_capabilities, expected) in [ ( running_binary.endpoint_capabilities, @@ -4197,6 +4585,38 @@ mod tests { } #[test] + fn windows_reuse_requires_explicit_remote_host_bridge() { + let windows = RemotePlatform { + os: "windows", + arch: "x86_64", + }; + let linux = RemotePlatform { + os: "linux", + arch: "x86_64", + }; + let old = + parse_client_status_json(r#"{"version":"0.8.2","endpoint_protocol_generation":1}"#) + .unwrap(); + let current = parse_client_status_json( + r#"{"version":"0.9.0","endpoint_protocol_generation":1,"remote_host_bridge":true}"#, + ) + .unwrap(); + + assert!(!old.supports_endpoint_requirement(&windows, false)); + assert!(current.supports_endpoint_requirement(&windows, false)); + assert!(old.supports_endpoint_requirement(&linux, false)); + assert!(!local_binary_can_seed_remote(&windows)); + assert_eq!( + windows_package_identity(true, &"a".repeat(64)), + format!("{}-custom.{}", current_version(), "a".repeat(64)) + ); + assert_eq!( + windows_package_identity(false, "ignored"), + current_version() + ); + } + + #[test] fn parse_remote_server_status_json_reads_running_server() { assert_eq!( parse_remote_server_status_json( |
