#![cfg(unix)] use std::fs; use std::io::{Read, Write}; use std::os::unix::fs::PermissionsExt; use std::process::Command; use std::sync::mpsc; use std::time::Duration; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; // No real SSH connection or server is started. Stop at startup after recording setup actions. const SSH: &str = r#"#!/bin/sh for arg do last=$arg; done if [ "$last" = 'command -v herdr' ]; then echo /home/remote/.local/bin/herdr exit 0 fi case "$last" in 'tee '*) cat >/dev/null; exit 0 ;; esac script=$(cat) case "$script" in *'uname -s'*) uname -s; uname -m ;; *'version='*) echo /home/remote/.local/bin/herdr ;; *'status client --json'*) if [ "$FAKE_INSTALLED" = new ] || [ -f "$FAKE_ROOT/installed" ]; then printf '%s\n' "$FAKE_CLIENT_STATUS" else echo '{"version":"0.8.2","protocol":20}' fi ;; *'status server --json'*) if [ -f "$FAKE_ROOT/stopped" ]; then echo '{"running":false}' else echo '{"running":true,"version":"0.8.2","capabilities":{"live_handoff":true,"detached_server_daemon":true}}' fi ;; *'server live-handoff'*) echo handoff >>"$FAKE_ROOT/actions"; exit 1 ;; *'server stop'*) echo stop >>"$FAKE_ROOT/actions"; touch "$FAKE_ROOT/stopped" ;; *'remote-client-bridge'*) echo start >>"$FAKE_ROOT/actions"; echo 'test startup failure' >&2; exit 1 ;; *'mkdir -p'*) printf '/fake/tmp\000/fake/herdr\000' ;; *'chmod 755'*) echo install >>"$FAKE_ROOT/actions"; touch "$FAKE_ROOT/installed" ;; *'command -v herdr'*) echo /home/remote/.local/bin/herdr ;; *) echo "unexpected fake SSH script: $script" >&2; exit 1 ;; esac "#; struct SetupResult { output: String, actions: String, prompts: usize, } fn setup(installed: &str, answer: &str, handoff: bool) -> SetupResult { let root = std::path::PathBuf::from(format!( "/var/tmp/herdr-machine-setup-{}-{}-{}-{}", std::process::id(), installed, answer.trim().is_empty(), handoff )); let app = if cfg!(debug_assertions) { "herdr-dev" } else { "herdr" }; fs::create_dir_all(root.join("bin")).unwrap(); fs::create_dir_all(root.join("config").join(app)).unwrap(); fs::write(root.join("bin/ssh"), SSH).unwrap(); fs::set_permissions(root.join("bin/ssh"), fs::Permissions::from_mode(0o700)).unwrap(); fs::write( root.join("config").join(app).join("config.toml"), "onboarding = false\n[remote]\nmanage_ssh_config = false\n", ) .unwrap(); let status = Command::new(env!("CARGO_BIN_EXE_herdr")) .args(["status", "client", "--json"]) .output() .unwrap(); assert!(status.status.success()); let pair = native_pty_system().openpty(PtySize::default()).unwrap(); let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); if handoff { command.args(["--remote", "fake-host", "--handoff"]); } else { command.args(["machine", "add", "fake-host", "--label", "VPS"]); } command.env( "PATH", format!("{}:/usr/bin:/bin", root.join("bin").display()), ); command.env("HOME", &root); command.env("XDG_CONFIG_HOME", root.join("config")); command.env("XDG_STATE_HOME", root.join("state")); command.env("XDG_RUNTIME_DIR", &root); command.env("FAKE_ROOT", &root); command.env("FAKE_INSTALLED", installed); command.env( "FAKE_CLIENT_STATUS", String::from_utf8(status.stdout).unwrap(), ); for name in [ "HERDR_ENV", "HERDR_SESSION", "HERDR_SOCKET_PATH", "HERDR_CLIENT_SOCKET_PATH", "HERDR_REMOTE_BINARY", "HERDR_CONFIG_PATH", ] { command.env_remove(name); } let mut child = pair.slave.spawn_command(command).unwrap(); drop(pair.slave); let mut reader = pair.master.try_clone_reader().unwrap(); let mut writer = pair.master.take_writer().unwrap(); let (tx, rx) = mpsc::channel(); let reading = std::thread::spawn(move || { let mut buffer = [0; 4096]; while let Ok(len) = reader.read(&mut buffer) { if len == 0 || tx.send(buffer[..len].to_vec()).is_err() { break; } } }); let mut output = String::new(); let mut prompts = 0; let mut timed_out = false; loop { match rx.recv_timeout(Duration::from_secs(20)) { Ok(bytes) => { output.push_str(&String::from_utf8_lossy(&bytes)); let count = output.matches("[y/N]").count() + output.matches("[Y/n]").count(); if count > prompts { writer .write_all(if prompts == 0 { answer.as_bytes() } else { b"n\n" }) .unwrap(); writer.flush().unwrap(); prompts = count; } } Err(mpsc::RecvTimeoutError::Disconnected) => break, Err(mpsc::RecvTimeoutError::Timeout) => { timed_out = true; child.kill().unwrap(); break; } } } child.wait().unwrap(); drop(writer); drop(pair.master); reading.join().unwrap(); let actions = fs::read_to_string(root.join("actions")).unwrap_or_default(); // Every scenario either cancels or reaches the intentionally failed startup. let saved = root .join("state") .join(app) .join("client/endpoints.json") .exists(); fs::remove_dir_all(root).unwrap(); assert!( !saved, "failed or cancelled setup saved the machine: {output}" ); assert!(!timed_out, "setup timed out: {output}"); SetupResult { output, actions, prompts, } } #[test] fn machine_setup_old_install_requires_one_explicit_stop_approval() { let result = setup("old", "y\n", false); assert_eq!(result.prompts, 1, "{}", result.output); assert!( result .output .contains("This stops active remote pane processes"), "{}", result.output ); assert_eq!( result.actions, "install\nstop\nstart\n", "{}", result.output ); } #[test] fn machine_setup_old_install_enter_cancels_without_remote_changes() { let result = setup("old", "\n", false); assert!(result.actions.is_empty(), "{}", result.output); assert!( result.output.contains("machine was not saved"), "{}", result.output ); } #[test] fn machine_setup_new_install_old_server_enter_does_not_stop_or_handoff() { let result = setup("new", "\n", false); assert!(result.actions.is_empty(), "{}", result.output); assert!( result .output .contains("This stops active remote pane processes"), "{}", result.output ); } #[test] fn machine_setup_new_install_old_server_approval_stops_then_starts_without_installing() { let result = setup("new", "y\n", false); assert_eq!(result.prompts, 1, "{}", result.output); assert_eq!(result.actions, "stop\nstart\n", "{}", result.output); } #[test] fn machine_setup_explicit_remote_handoff_remains_available_but_restart_fallback_defaults_to_no() { let result = setup("new", "\n", true); assert_eq!(result.actions, "handoff\n", "{}", result.output); assert!( result .output .contains("This stops active remote pane processes"), "{}", result.output ); }