use keyhog::testing::{CliTestApi as _, API};
#[test]
fn legacy_asset_name_matches_historical_release_convention() {
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("macos", "x86_64").as_deref(),
Some("keyhog-macos-x86_64")
);
assert_eq!(
API.asset_name("windows", "x86_64").as_deref(),
Some("keyhog-windows-x86_64.exe")
);
assert_eq!(API.asset_name("linux", "aarch64"), None);
assert_eq!(API.asset_name("windows", "aarch64"), None);
assert_eq!(API.asset_name("linux", "riscv64"), None);
}
#[test]
fn release_channel_state_separates_a_stale_channel_from_a_current_build() {
assert_eq!(
API.release_channel_state("0.5.47", "v0.5.48"),
"update-available"
);
assert_eq!(
API.release_channel_state("0.5.47", "v0.5.47"),
"on-newest-asset"
);
assert_eq!(
API.release_channel_state("0.5.68", "v0.5.47"),
"channel-behind"
);
assert_eq!(
API.release_channel_state("0.5.47", "v0.5.47-rc.1"),
"channel-behind"
);
assert_eq!(
API.release_channel_state("0.5.68", "not-a-tag"),
"on-newest-asset"
);
}
#[cfg(target_os = "linux")]
#[test]
fn release_selection_uses_the_single_linux_asset() {
let assets = ["keyhog-linux-x86_64"];
assert_eq!(
API.select_release_asset_name("v9.9.9", &assets)
.expect("Linux platform asset present"),
"keyhog-linux-x86_64"
);
}
#[test]
fn release_selector_has_no_alternate_asset_fallback() {
let canonical = API
.asset_name(std::env::consts::OS, std::env::consts::ARCH)
.expect("test host has a supported release asset");
let alternate = format!("{canonical}-alternate");
let error = API
.select_release_asset_name("v9.9.9", &[&alternate])
.expect_err("an alternate filename must not satisfy exact platform selection");
assert!(
format!("{error:#}").contains(&canonical),
"selection error must name the exact required asset: {error:#}"
);
}
#[test]
fn release_selector_rejects_duplicate_asset_names() {
let canonical = API
.asset_name(std::env::consts::OS, std::env::consts::ARCH)
.expect("test host has a supported release asset");
let error = API
.select_release_asset_name("v9.9.9", &[&canonical, &canonical])
.expect_err("duplicate release asset names are ambiguous");
assert!(format!("{error:#}").contains("duplicate assets"));
}
#[test]
fn installer_words_match_crates_only_release_contract() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let release_yml =
std::fs::read_to_string(root.join(".github/workflows/release.yml")).expect("release.yml");
let readme = std::fs::read_to_string(root.join("README.md")).expect("README.md");
let install_doc = std::fs::read_to_string(root.join("docs/src/install.md"))
.expect("docs/src/install.md readable");
assert!(
release_yml.contains("bash scripts/publish.sh")
&& release_yml.contains("CARGO_REGISTRY_TOKEN")
&& !release_yml.contains("asset:")
&& !release_yml.contains("upload-release-asset"),
"automatic releases must publish crates.io packages without claiming binary assets"
);
assert!(
readme.contains("cargo install --locked keyhog")
&& install_doc.contains("cargo install --locked --force keyhog")
&& install_doc
.contains("cargo install --locked --force --version '=MAJOR.MINOR.PATCH' keyhog")
&& install_doc.contains("does\nnot publish binary release assets or installer bundles")
&& !readme.contains("macOS release assets")
&& !readme.contains("Windows assets")
&& !install_doc.contains("macOS assets")
&& !install_doc.contains("Windows assets"),
"README and install guide must describe crates.io install, update, and rollback truthfully"
);
}
#[test]
fn semver_parsing_handles_v_prefix_and_suffix() {
assert_eq!(API.parse_semver("v0.5.36"), Some((0, 5, 36)));
assert_eq!(API.parse_semver("0.5.36"), Some((0, 5, 36)));
assert_eq!(API.parse_semver("v1.2.3-rc1"), Some((1, 2, 3)));
assert_eq!(API.parse_semver("garbage"), None);
assert_eq!(API.parse_semver("v1.2"), None);
}
#[test]
fn is_newer_compares_correctly() {
assert!(API.is_newer("0.5.35", "v0.5.36"));
assert!(API.is_newer("0.5.35", "0.6.0"));
assert!(API.is_newer("0.5.35", "1.0.0"));
assert!(!API.is_newer("0.5.36", "v0.5.36"));
assert!(!API.is_newer("0.5.36", "v0.5.35"));
assert!(!API.is_newer("0.5.35", "garbage"));
assert!(API.is_newer("1.0.0-rc.1", "1.0.0"));
assert!(API.is_newer("1.0.0-rc.1", "1.0.0-rc.2"));
assert!(!API.is_newer("1.0.0", "1.0.0-rc.2"));
assert!(!API.is_newer("1.0.0+build.1", "1.0.0+build.2"));
}
#[test]
fn rejects_non_executable_download() {
assert!(!API.looks_like_native_executable(b"<!DOCTYPE html><html>Not Found"));
assert!(!API.looks_like_native_executable(b""));
#[cfg(target_os = "linux")]
assert!(API.looks_like_native_executable(&[0x7F, b'E', b'L', b'F', 2, 1, 1, 0]));
assert!(API.looks_like_native_executable_for_os(&[0x7F, b'E', b'L', b'F', 2, 1, 1, 0], "linux"));
assert!(API.looks_like_native_executable_for_os(&[0xFE, 0xED, 0xFA, 0xCF, 0, 0, 0, 0], "macos"));
assert!(API.looks_like_native_executable_for_os(&[b'M', b'Z', 0x90, 0x00], "windows"));
assert!(!API.looks_like_native_executable_for_os(b"<!DOCTYPE html><html>Not Found", "windows"));
assert!(!API.looks_like_native_executable_for_os(b"<!DOCTYPE html><html>Not Found", "freebsd"));
}
#[test]
fn self_test_detects_planted_secret() {
assert!(API.scan_engine_self_test().expect("self-test runs"));
}
const FIXTURE_DATA: &[u8] = b"keyhog-signature-test-v1\n";
const FIXTURE_SIG: &str = "untrusted comment: signature from rsign secret key\n\
RUTPnJ/p6xVJ3REkJ9dhxwKQpEisq7Y2A4uIZlUzPRM0zDjWidV3sIXjHB8d558++9M0KpCpz6T8efYlVFl/RZhrKIznrUZSGww=\n\
trusted comment: timestamp:1780025193\tfile:/tmp/claude-1000/tmp.JTQWgRt5FO/fixture.bin\tprehashed\n\
L/wvGiwIhpaBlkEUaQ364Q8ph9ksqIxJyIMy1RQbs/QS4+q8biUaJGt+0weV4E0IV/pPHywDFtZhvUD03un2CA==\n";
#[test]
fn release_signature_verifies_against_embedded_key() {
API.verify_release_signature(FIXTURE_DATA, FIXTURE_SIG)
.expect("a genuine signature must verify against the embedded public key");
}
#[test]
fn release_signature_rejects_tampered_payload() {
assert!(
API.verify_release_signature(b"tampered binary contents", FIXTURE_SIG)
.is_err(),
"a signature must not verify against payload it didn't sign"
);
}
#[test]
fn release_signature_rejects_malformed_signature() {
assert!(API
.verify_release_signature(FIXTURE_DATA, "not a minisig file")
.is_err());
assert!(API.verify_release_signature(FIXTURE_DATA, "").is_err());
}
#[test]
fn release_checksum_binds_digest_and_asset_name() {
let digest = "79792c6a6ce7cccb7d14cadf57006754b70a3e90a944cdaaf111ae97f03fbee8";
API.verify_release_checksum(
FIXTURE_DATA,
"keyhog-linux-x86_64",
format!("{digest} keyhog-linux-x86_64\n").as_bytes(),
)
.expect("matching release checksum");
assert!(API
.verify_release_checksum(
b"tampered",
"keyhog-linux-x86_64",
format!("{digest} keyhog-linux-x86_64\n").as_bytes(),
)
.is_err());
assert!(API
.verify_release_checksum(
FIXTURE_DATA,
"keyhog-linux-x86_64",
format!("{digest} another-asset\n").as_bytes(),
)
.is_err());
assert!(API
.verify_release_checksum(FIXTURE_DATA, "keyhog-linux-x86_64", b"not-a-digest\n")
.is_err());
}
fn gpu_literal_sidecar(version: &str, name: &str, artifact: &[u8]) -> Vec<u8> {
use flate2::{write::GzEncoder, Compression};
use std::io::Cursor;
let manifest = serde_json::json!({
"format_version": 1,
"keyhog_version": version,
"artifacts": [{"file_name": name, "byte_len": artifact.len()}],
})
.to_string()
.into_bytes();
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut archive = tar::Builder::new(encoder);
for (path, bytes) in [
("keyhog.gpu-literals/manifest.json", manifest.as_slice()),
("keyhog.gpu-literals/matcher.bin", artifact),
] {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive
.append_data(&mut header, path, Cursor::new(bytes))
.expect("append sidecar entry");
}
archive
.into_inner()
.expect("finish tar")
.finish()
.expect("finish gzip")
}
#[test]
fn gpu_literal_sidecar_binds_manifest_version_and_bytes() {
let archive = gpu_literal_sidecar("0.5.41", "matcher.bin", b"matcher-v1");
let files = API
.parse_gpu_literal_sidecar(&archive, "v0.5.41")
.expect("valid signed-sidecar payload shape");
assert_eq!(
files,
vec![("matcher.bin".to_string(), b"matcher-v1".to_vec())]
);
assert!(API.parse_gpu_literal_sidecar(&archive, "v0.5.42").is_err());
}
#[test]
fn gpu_literal_cache_transaction_commits_or_restores_exact_bytes() {
let dir = tempfile::tempdir().expect("cache tempdir");
let existing = dir.path().join("matcher.bin");
std::fs::write(&existing, b"old").expect("seed old artifact");
API.install_gpu_literal_files_in_dir(
dir.path(),
&[("matcher.bin", b"new"), ("added.bin", b"added")],
false,
)
.expect("stage transaction then roll back");
assert_eq!(std::fs::read(&existing).unwrap(), b"old");
assert!(!dir.path().join("added.bin").exists());
assert!(
!dir.path().join(".installed_manifest.json").exists(),
"rollback must not leave an identity manifest for reverted artifacts"
);
assert!(dir.path().join(".keyhog-maintenance.lock").exists());
API.install_gpu_literal_files_in_dir(
dir.path(),
&[("matcher.bin", b"new"), ("added.bin", b"added")],
true,
)
.expect("commit cache transaction");
assert_eq!(std::fs::read(&existing).unwrap(), b"new");
assert_eq!(
std::fs::read(dir.path().join("added.bin")).unwrap(),
b"added"
);
assert!(dir.path().join(".keyhog-maintenance.lock").exists());
let manifest_path = dir.path().join(".installed_manifest.json");
let committed_manifest = std::fs::read(&manifest_path).expect("read committed manifest");
let manifest: serde_json::Value =
serde_json::from_slice(&committed_manifest).expect("parse committed manifest");
assert_eq!(manifest["version"], 1);
assert_eq!(
manifest["artifacts"]
.as_array()
.expect("manifest artifacts")
.len(),
2
);
API.install_gpu_literal_files_in_dir(
dir.path(),
&[("matcher.bin", b"replacement"), ("added.bin", b"other")],
false,
)
.expect("stage replacement then roll back");
assert_eq!(
std::fs::read(&manifest_path).expect("read restored manifest"),
committed_manifest,
"artifact rollback must restore the matching identity manifest"
);
}
#[test]
fn replace_success_installs_new_and_returns_stash() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("keyhog");
std::fs::write(&exe, b"OLD-WORKING-BINARY").unwrap();
let stash = API
.replace_running_binary(&exe, b"NEW-GOOD-BINARY", |_| true)
.expect("replace should succeed when verify passes");
assert_eq!(std::fs::read(&exe).unwrap(), b"NEW-GOOD-BINARY");
let stash = stash.expect("a prior binary existed, so a stash is returned");
assert_eq!(std::fs::read(&stash).unwrap(), b"OLD-WORKING-BINARY");
API.reap_stale_binaries(&exe);
assert!(!stash.exists(), "reap must remove the stash");
}
#[test]
fn replace_failure_rolls_back_byte_for_byte() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("keyhog");
let original: Vec<u8> = (0u8..=255).cycle().take(4096).collect();
std::fs::write(&exe, &original).unwrap();
let err = API
.replace_running_binary(&exe, b"NEW-BROKEN-BINARY", |_| false)
.expect_err("replace must fail when verify rejects the new binary");
assert!(format!("{err}").contains("rolled back"));
assert_eq!(
std::fs::read(&exe).unwrap(),
original,
"rollback must restore the original binary byte-for-byte"
);
API.reap_stale_binaries(&exe);
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains("keyhog-old"))
.collect();
assert!(leftovers.is_empty(), "rollback must not leave a stash");
}
#[test]
fn fresh_install_failure_removes_broken_binary() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("keyhog");
let err = API
.replace_running_binary(&exe, b"BROKEN", |_| false)
.expect_err("fresh install must fail when verify rejects it");
assert!(format!("{err}").contains("no prior binary"));
assert!(!exe.exists(), "broken fresh install must be removed");
}
#[test]
fn reap_only_touches_this_binarys_stashes() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("keyhog");
std::fs::write(&exe, b"bin").unwrap();
let mine = dir.path().join(".keyhog.keyhog-old-4294967295");
let other = dir.path().join("unrelated.txt");
std::fs::write(&mine, b"old").unwrap();
std::fs::write(&other, b"keep").unwrap();
API.reap_stale_binaries(&exe);
assert!(!mine.exists(), "matching stash must be reaped");
assert!(other.exists(), "unrelated files must be left alone");
assert!(exe.exists(), "the live binary must never be reaped");
}
#[test]
#[cfg(unix)]
fn reap_stale_binaries_preserves_live_peer_pid_artifacts() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("keyhog");
std::fs::write(&exe, b"bin").unwrap();
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg("sleep 30")
.spawn()
.expect("spawn live peer process");
let live_pid = child.id();
let live_stash = dir.path().join(format!(".keyhog.keyhog-old-{live_pid}"));
let live_backup = dir.path().join(format!(".keyhog.keyhog-bak-{live_pid}"));
let live_tmp = dir.path().join(format!(".keyhog-update-{live_pid}.tmp"));
for path in [&live_stash, &live_backup, &live_tmp] {
std::fs::write(path, b"in-flight").unwrap();
}
API.reap_stale_binaries(&exe);
assert!(live_stash.exists(), "live process stash must not be reaped");
assert!(
live_backup.exists(),
"live process rollback backup must not be reaped"
);
assert!(
live_tmp.exists(),
"live process staging tmp must not be reaped"
);
child.kill().expect("stop live peer process");
let _ = child.wait();
}
#[test]
fn reap_stale_binaries_requires_parseable_pid_suffix() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("keyhog");
std::fs::write(&exe, b"bin").unwrap();
let malformed = [
dir.path().join(".keyhog.keyhog-old-"),
dir.path().join(".keyhog.keyhog-bak-not-a-pid"),
dir.path().join(".keyhog-update-123.tmp.extra"),
];
for path in &malformed {
std::fs::write(path, b"keep").unwrap();
}
API.reap_stale_binaries(&exe);
for path in &malformed {
assert!(
path.exists(),
"malformed installer artifact name must not be reaped: {}",
path.display()
);
}
}
#[test]
fn reap_stale_binaries_reaps_digit_only_overflow_pid_artifacts() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("keyhog");
std::fs::write(&exe, b"bin").unwrap();
let overflow_pid = "42949672950000000000000000000000000000000000000000";
let artifacts = [
dir.path()
.join(format!(".keyhog.keyhog-old-{overflow_pid}")),
dir.path()
.join(format!(".keyhog.keyhog-bak-{overflow_pid}")),
dir.path()
.join(format!(".keyhog-update-{overflow_pid}.tmp")),
];
for path in &artifacts {
std::fs::write(path, b"stale").unwrap();
}
API.reap_stale_binaries(&exe);
for path in &artifacts {
assert!(
!path.exists(),
"numeric overflow PID installer artifact must be treated as stale: {}",
path.display()
);
}
}
#[test]
fn install_with_rollback_bool_wrapper_has_one_owner() {
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/installer.rs"))
.expect("installer source readable");
let wrapper = "pub(crate) fn install_with_rollback<F>";
let wrapper_count = src.matches(wrapper).count();
assert_eq!(
wrapper_count, 1,
"install_with_rollback bool compatibility wrapper must have one cfg-neutral owner"
);
assert!(
src.contains("install_with_rollback_checked(exe, bytes, bool_verify_as_result(verify))"),
"install_with_rollback must delegate through the shared bool-to-Result verifier adapter"
);
assert!(
src.matches("fn bool_verify_as_result<F>").count() == 1
&& src.matches("post-install verifier returned false").count() == 1,
"boolean verifier compatibility text must live in one adapter, not per-platform wrappers"
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn unsigned_release_download_fails_closed() {
use httpmock::prelude::*;
let server = MockServer::start();
let mut elf = vec![0x7F, b'E', b'L', b'F'];
elf.extend_from_slice(&[0u8; 64]);
let asset_path = "/download/keyhog-linux-x86_64";
let body = elf.clone();
server.mock(|when, then| {
when.method(GET).path(asset_path);
then.status(200).body(body);
});
server.mock(|when, then| {
when.method(GET).path(format!("{asset_path}.minisig"));
then.status(404).body("Not Found");
});
let res = API
.download_verified_asset(
&API.http_client().unwrap(),
"keyhog-linux-x86_64",
format!("{}{}", server.base_url(), asset_path),
)
.await;
assert!(
res.is_err(),
"a missing .minisig must fail closed (refuse), not install on HTTPS-only trust"
);
let msg = format!("{:#}", res.unwrap_err());
assert!(
msg.contains(".minisig") || msg.to_lowercase().contains("signature"),
"error must name the missing signature as the reason: {msg}"
);
}