use keyhog::testing::{CliTestApi as _, API};
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct RestoreEnv {
release_api_base: Option<String>,
}
impl Drop for RestoreEnv {
fn drop(&mut self) {
match &self.release_api_base {
Some(value) => std::env::set_var("KEYHOG_RELEASE_API_BASE", value),
None => std::env::remove_var("KEYHOG_RELEASE_API_BASE"),
}
}
}
#[test]
fn release_semver_and_version_compare_unchanged_after_split() {
assert_eq!(API.parse_semver("v1.2.3"), Some((1, 2, 3)));
assert_eq!(API.parse_semver("0.5.37"), Some((0, 5, 37)));
assert_eq!(API.parse_semver("v2.0.0-rc1"), Some((2, 0, 0)));
assert_eq!(API.parse_semver("not-a-version"), None);
assert!(API.is_newer("0.5.0", "0.5.1"));
assert!(API.is_newer("0.5.37", "0.6.0"));
assert!(!API.is_newer("0.6.0", "0.5.99"));
assert!(!API.is_newer("garbage", "0.6.0"));
}
#[test]
fn release_asset_naming_unchanged_after_split() {
assert_eq!(
API.asset_name("linux", "x86_64").as_deref(),
Some("keyhog-linux-x86_64")
);
assert_eq!(
API.asset_name("macos", "aarch64").as_deref(),
Some("keyhog-macos-aarch64")
);
assert_eq!(
API.asset_name("windows", "x86_64").as_deref(),
Some("keyhog-windows-x86_64.exe")
);
assert_eq!(API.asset_name("plan9", "riscv"), None);
}
#[test]
fn release_executable_magic_check_unchanged_after_split() {
let elf = [0x7Fu8, b'E', b'L', b'F', 0, 0, 0, 0];
let macho = [0xFEu8, 0xED, 0xFA, 0xCF, 0, 0, 0, 0];
let pe = [b'M', b'Z', 0x90, 0x00];
let html = b"<!DOCTYPE html>";
assert!(API.looks_like_native_executable_for_os(&elf, "linux"));
assert!(API.looks_like_native_executable_for_os(&macho, "macos"));
assert!(API.looks_like_native_executable_for_os(&pe, "windows"));
assert!(!API.looks_like_native_executable_for_os(html, "linux"));
assert!(!API.looks_like_native_executable_for_os(html, "macos"));
assert!(!API.looks_like_native_executable_for_os(html, "windows"));
assert!(!API.looks_like_native_executable_for_os(html, "freebsd"));
assert!(!API.looks_like_native_executable(&[0x7F, b'E']));
}
#[test]
fn release_signature_verify_rejects_garbage_after_split() {
let err = API
.verify_release_signature(b"data", "not-a-minisig")
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("malformed") || msg.contains("signature"),
"malformed signature must be rejected with a signature error, got: {msg}"
);
}
#[test]
fn release_public_constants_and_self_test_reachable_after_split() {
assert_eq!(API.release_repo(), "santhreal/keyhog");
assert!(API.release_public_key().starts_with("RWT"));
let base = API.release_api_base();
assert_eq!(base, "https://api.github.com");
assert!(
API.scan_engine_self_test().expect("self-test must run"),
"scan_engine_self_test must detect its own planted secret after the split"
);
}
#[test]
fn release_api_base_ignores_ambient_env_after_split() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let _restore = RestoreEnv {
release_api_base: std::env::var("KEYHOG_RELEASE_API_BASE").ok(),
};
std::env::set_var("KEYHOG_RELEASE_API_BASE", "http://attacker.invalid");
assert_eq!(
API.release_api_base(),
"https://api.github.com",
"ambient KEYHOG_RELEASE_API_BASE must not redirect production update metadata"
);
}
#[test]
fn local_install_reap_clears_orphans_keeps_unrelated_after_split() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("keyhog");
std::fs::write(&exe, b"WORKING-BINARY").unwrap();
let orphan_stash = dir.path().join(".keyhog.keyhog-old-4294967295");
let orphan_backup = dir.path().join(".keyhog.keyhog-bak-4294967294");
let orphan_tmp = dir.path().join(".keyhog-update-4294967293.tmp");
let unrelated = dir.path().join("config.json");
for p in [&orphan_stash, &orphan_backup, &orphan_tmp, &unrelated] {
std::fs::write(p, b"x").unwrap();
}
API.reap_stale_binaries(&exe);
assert!(!orphan_stash.exists(), "rename-away stash must be reaped");
assert!(!orphan_backup.exists(), "rollback backup must be reaped");
assert!(!orphan_tmp.exists(), "staging tmp must be reaped");
assert!(
unrelated.exists(),
"unrelated sibling must be left untouched"
);
assert!(exe.exists(), "the live binary must never be reaped");
}
#[test]
fn local_install_rollback_restores_on_failed_verify_after_split() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let exe = dir.path().join("keyhog");
std::fs::write(&exe, b"GOOD").unwrap();
let err = API
.install_with_rollback(&exe, b"BAD-NEW-BINARY", |_| false)
.expect_err("a failing health check must error");
assert!(
err.to_string().contains("health check"),
"rollback error must name the failed health check, got: {err}"
);
assert_eq!(
std::fs::read(&exe).unwrap(),
b"GOOD",
"the prior working binary must be restored byte-for-byte on rollback"
);
}
}
#[test]
fn full_installer_call_surface_reachable_after_split() {
let _release_api_base = || API.release_api_base();
let _http_client = || API.http_client();
let _parse_semver = |tag: &str| API.parse_semver(tag);
let _is_newer = |current: &str, latest: &str| API.is_newer(current, latest);
let _asset_name = |os: &str, arch: &str| API.asset_name(os, arch);
let _select_release_asset_name =
|tag: &str, names: &[&str]| API.select_release_asset_name(tag, names);
let client = API.http_client().expect("build release client");
let _resolve_release_at = API.resolve_release_at(&client, None, "http://127.0.0.1:9");
let _looks_like_native_executable = |bytes: &[u8]| API.looks_like_native_executable(bytes);
let _verify_release_signature =
|bytes: &[u8], signature: &str| API.verify_release_signature(bytes, signature);
let _scan_engine_self_test = || API.scan_engine_self_test();
let _current_binary = || API.current_binary();
let _reap_stale_binaries = |exe: &std::path::Path| API.reap_stale_binaries(exe);
let _backup_path = |exe: &std::path::Path| API.backup_path(exe);
let _verify_via_doctor = |exe: &std::path::Path| API.verify_via_doctor(exe);
let _verify_candidate_release =
|exe: &std::path::Path, tag: &str, current: &str, allow: bool| {
API.verify_candidate_release(exe, tag, current, allow)
};
#[cfg(unix)]
{
let _install_with_rollback_checked =
|exe: &std::path::Path,
bytes: &[u8],
verify: fn(&std::path::Path) -> anyhow::Result<()>| {
API.install_with_rollback_checked(exe, bytes, verify)
};
}
}