use std::env;
use std::ffi::OsString;
use std::fs;
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use tempfile::TempDir;
struct Server {
home: TempDir,
binary: PathBuf,
socket: PathBuf,
path: OsString,
shim: PathBuf,
}
impl Server {
fn new() -> Self {
let path = env::var_os("PATH").expect("integration tests require tmux on PATH");
let binary = env::split_paths(&path)
.map(|directory| directory.join("tmux"))
.find(|candidate| {
fs::metadata(candidate).is_ok_and(|metadata| {
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
})
})
.expect("integration tests require tmux 3.3a or newer on PATH")
.canonicalize()
.unwrap();
let home = tempfile::tempdir().unwrap();
fs::set_permissions(home.path(), fs::Permissions::from_mode(0o700)).unwrap();
let socket = home.path().join("s");
assert!(socket.as_os_str().len() < 104, "socket path too long");
for directory in ["config", "data", "runtime", "bin"] {
fs::DirBuilder::new()
.mode(0o700)
.create(home.path().join(directory))
.unwrap();
}
let shim = home.path().join("bin/tmux");
fs::write(
&shim,
format!(
"#!/bin/sh\nif [ \"$1\" = '-V' ]; then\n printf '%s\\n' 'tmux 0.0'\n exit 0\nfi\nexec '{}' \"$@\"\n",
binary.to_str().unwrap().replace('\'', "'\\''")
),
)
.unwrap();
fs::set_permissions(&shim, fs::Permissions::from_mode(0o700)).unwrap();
let server = Self {
home,
binary,
socket,
path,
shim,
};
success(server.tmux(&[
"-f",
"/dev/null",
"new-session",
"-d",
"-s",
"fixture",
"/bin/sh",
]));
assert!(success(server.tmux(&["list-clients"])).is_empty());
server
}
fn command(&self, binary: &Path) -> Command {
let mut command = Command::new(binary);
command
.env_clear()
.env("PATH", &self.path)
.env("HOME", self.home.path())
.env("XDG_CONFIG_HOME", self.home.path().join("config"))
.env("XDG_DATA_HOME", self.home.path().join("data"))
.env("XDG_STATE_HOME", self.home.path().join("state"))
.env("XDG_RUNTIME_DIR", self.home.path().join("runtime"))
.env("SHELL", "/bin/sh")
.env("TERM", "xterm-256color")
.env_remove("TMUX")
.env_remove("TMUX_PANE")
.env_remove("AFT_TMUX_BINARY")
.current_dir(self.home.path())
.stdin(Stdio::null());
command
}
fn tmux(&self, args: &[&str]) -> Output {
self.command(&self.binary)
.arg("-S")
.arg(&self.socket)
.args(args)
.output()
.unwrap()
}
fn cli(&self, args: &[&str], explicit_binary: Option<&Path>) -> Output {
let mut command = self.command(Path::new(env!("CARGO_BIN_EXE_agent-float-term")));
let path = env::join_paths(
std::iter::once(self.shim.parent().unwrap().to_path_buf())
.chain(env::split_paths(&self.path)),
)
.unwrap();
command
.env("PATH", path)
.args(args)
.arg("--socket")
.arg(&self.socket);
if let Some(binary) = explicit_binary {
command.env("AFT_TMUX_BINARY", binary);
}
command.output().unwrap()
}
fn record(&self) -> serde_json::Value {
let records: Vec<_> = fs::read_dir(self.home.path().join("state/agent-float-term/runtime"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.collect();
assert_eq!(records.len(), 1, "expected one per-socket binding record");
serde_json::from_slice(&fs::read(&records[0]).unwrap()).unwrap()
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self
.command(&self.binary)
.arg("-S")
.arg(&self.socket)
.arg("kill-server")
.output();
}
}
fn success(output: Output) -> String {
assert!(
output.status.success(),
"status: {}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
#[test]
fn mismatch_without_record_rejects_doctor_and_bind_without_mutations() {
let server = Server::new();
let version = success(server.tmux(&["display-message", "-p", "#{version}"]));
assert_ne!(version.trim(), "0.0");
success(server.tmux(&[
"bind-key",
"-T",
"root",
"F7",
"display-message",
"fixture existing binding",
]));
let keys = success(server.tmux(&["list-keys"]));
let sessions = success(server.tmux(&["list-sessions", "-F", "#{session_id}:#{session_name}"]));
assert!(!server.home.path().join("state").exists());
for args in [&["doctor"][..], &["bind", "--replace-key"][..]] {
let output = server.cli(args, None);
assert!(
!output.status.success(),
"{args:?} accepted a mismatched client"
);
let error = String::from_utf8(output.stderr).unwrap();
assert!(
error.contains(&format!(
"tmux client 0.0 does not match running server {}",
version.trim()
)),
"{error}"
);
assert!(error.contains("AFT_TMUX_BINARY"), "{error}");
assert!(error.contains("run bind"), "{error}");
assert!(
error.contains("Existing sessions were preserved"),
"{error}"
);
assert_eq!(success(server.tmux(&["list-keys"])), keys);
assert!(success(server.tmux(&["show-options", "-gqv", "@aft_generation"])).is_empty());
assert_eq!(
success(server.tmux(&["list-sessions", "-F", "#{session_id}:#{session_name}"])),
sessions
);
assert!(!server.home.path().join("state").exists());
}
}
#[test]
fn bind_rejects_executable_replaced_during_version_check_without_mutations() {
for explicit in [false, true] {
let server = Server::new();
let version = success(server.tmux(&["display-message", "-p", "#{version}"]));
assert_ne!(version.trim(), "0.0");
success(server.tmux(&[
"bind-key",
"-T",
"root",
"F7",
"display-message",
"fixture existing binding",
]));
let keys = success(server.tmux(&["list-keys"]));
let sessions =
success(server.tmux(&["list-sessions", "-F", "#{session_id}:#{session_name}"]));
let counter = server.home.path().join("version-calls");
fs::write(&counter, "").unwrap();
fs::set_permissions(&counter, fs::Permissions::from_mode(0o600)).unwrap();
let replacement = server.home.path().join("bin/replacement");
fs::copy(&server.shim, &replacement).unwrap();
fs::set_permissions(&replacement, fs::Permissions::from_mode(0o700)).unwrap();
let payload = fs::read(&replacement).unwrap();
fs::write(
&server.shim,
format!(
"#!/bin/sh\nset -eu\nif [ \"$1\" = '-V' ]; then\n printf 'call\\n' >> '{counter}'\n if [ \"$(wc -l < '{counter}')\" -eq 2 ]; then\n mv '{replacement}' '{shim}'\n fi\n printf '%s\\n' 'tmux {version}'\n exit 0\nfi\nexec '{binary}' \"$@\"\n",
counter = counter.to_str().unwrap().replace('\'', "'\\''"),
replacement = replacement.to_str().unwrap().replace('\'', "'\\''"),
shim = server.shim.to_str().unwrap().replace('\'', "'\\''"),
version = version.trim().replace('\'', "'\\''"),
binary = server.binary.to_str().unwrap().replace('\'', "'\\''"),
),
)
.unwrap();
assert!(!server.home.path().join("state").exists());
let output = server.cli(
&["bind", "--replace-key"],
explicit.then_some(server.shim.as_path()),
);
assert!(
!output.status.success(),
"accepted version race (explicit={explicit})"
);
let error = String::from_utf8(output.stderr).unwrap();
assert!(
error.contains("tmux executable changed while checking its version; retry bind"),
"wrong refusal (explicit={explicit}): {error}"
);
assert_eq!(fs::read_to_string(&counter).unwrap(), "call\ncall\n");
assert!(
!replacement.exists(),
"replacement was not moved into place"
);
assert_eq!(fs::read(&server.shim).unwrap(), payload);
assert_eq!(
success(server.command(&server.shim).arg("-V").output().unwrap()),
"tmux 0.0\n"
);
assert_eq!(success(server.tmux(&["list-keys"])), keys);
assert!(success(server.tmux(&["show-options", "-gqv", "@aft_generation"])).is_empty());
assert_eq!(
success(server.tmux(&["list-sessions", "-F", "#{session_id}:#{session_name}"])),
sessions
);
let runtime = server.home.path().join("state/agent-float-term/runtime");
match fs::read_dir(&runtime) {
Ok(entries) => {
for entry in entries {
let path = entry.unwrap().path();
assert_eq!(
path.extension().and_then(|extension| extension.to_str()),
Some("lock"),
"failed bind persisted state or a false cached stamp: {}",
path.display()
);
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => panic!("cannot inspect {}: {error}", runtime.display()),
}
}
}
#[test]
fn approved_client_is_reused_and_preserved_across_path_mismatch_rebinds() {
let server = Server::new();
let version = success(server.tmux(&["display-message", "-p", "#{version}"]));
success(server.cli(&["bind"], Some(&server.binary)));
let record = server.record();
assert_eq!(record["binary"], server.binary.to_str().unwrap());
assert_eq!(record["socket"], server.socket.to_str().unwrap());
assert_eq!(record["key"], "F7");
let installed = record["installed"].as_str().unwrap();
assert!(installed.contains("AFT_TMUX_BINARY="), "{installed}");
assert!(
installed.contains(server.binary.to_str().unwrap()),
"{installed}"
);
assert!(
!installed.contains(server.shim.to_str().unwrap()),
"{installed}"
);
let keys = success(server.tmux(&["list-keys"]));
let client = format!(
"tmux client: {} ({})",
server.binary.display(),
version.trim()
);
for _ in 0..2 {
let doctor = success(server.cli(&["doctor"], None));
assert!(doctor.lines().any(|line| line == client), "{doctor}");
assert!(doctor.contains("Owned key binding: intact"), "{doctor}");
assert!(!doctor.contains(server.shim.to_str().unwrap()), "{doctor}");
assert_eq!(server.record(), record, "doctor must not rewrite state");
success(server.cli(&["bind"], None));
assert_eq!(success(server.tmux(&["list-keys"])), keys);
assert_eq!(
server.record(),
record,
"rebind must retain the approved client"
);
}
}
#[test]
fn doctor_reports_last_popup_failure_without_creating_state() {
let server = Server::new();
success(server.tmux(&[
"set-option",
"-g",
"@aft_last_error",
"pane %0: fixture popup failure",
]));
let keys = success(server.tmux(&["list-keys"]));
assert!(!server.home.path().join("state").exists());
let doctor = success(server.cli(&["doctor"], Some(&server.binary)));
assert!(
doctor
.lines()
.any(|line| line == "Last popup failure: pane %0: fixture popup failure"),
"{doctor}"
);
assert!(
doctor.contains("Owned key binding: absent or changed; run bind"),
"{doctor}"
);
assert!(!doctor.contains("show-options"), "{doctor}");
assert!(!doctor.contains("set-option"), "{doctor}");
assert_eq!(success(server.tmux(&["list-keys"])), keys);
assert!(!server.home.path().join("state").exists());
}