use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
mod execution_packs;
mod gpu_artifacts;
mod release;
pub(crate) use execution_packs::*;
pub(crate) use gpu_artifacts::*;
pub(crate) use release::*;
fn remove_installer_artifact_best_effort(path: &Path, context: &str) {
if let Err(error) = std::fs::remove_file(path) {
tracing::warn!(
path = %path.display(),
%error,
%context,
"failed to remove installer artifact; it may need manual cleanup"
);
}
}
pub(crate) fn current_binary() -> Result<std::path::PathBuf> {
let exe = std::env::current_exe().context("locate current executable")?;
std::fs::canonicalize(&exe).with_context(|| {
format!(
"resolve current executable symlink target for {} before self-update",
exe.display()
)
})
}
#[cfg(unix)]
fn require_trusted_install_directory(dir: &Path) -> Result<()> {
use std::os::unix::fs::MetadataExt;
let metadata = std::fs::metadata(dir)
.with_context(|| format!("inspect install directory {}", dir.display()))?;
if !metadata.is_dir() {
anyhow::bail!("install parent {} is not a directory", dir.display());
}
let effective_uid = unsafe { libc::geteuid() };
if metadata.uid() != effective_uid && metadata.uid() != 0 {
anyhow::bail!(
"refusing to update through install directory '{}' owned by uid {} while running as uid {}. \
Fix: run KeyHog as the directory owner, or reinstall into a root-owned system directory.",
dir.display(),
metadata.uid(),
effective_uid
);
}
if metadata.mode() & 0o022 != 0 {
anyhow::bail!(
"refusing to update through group/world-writable install directory '{}' (mode {:04o}); \
another user could replace update artifacts. Fix: remove group/world write permission \
from the directory or reinstall KeyHog into a private directory.",
dir.display(),
metadata.mode() & 0o7777
);
}
Ok(())
}
#[cfg(unix)]
fn create_installer_artifact(path: &Path, purpose: &str) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.with_context(|| {
format!(
"create {purpose} {} exclusively; an existing path is refused to prevent symlink replacement",
path.display()
)
})
}
#[cfg(unix)]
pub(crate) fn install_binary(exe: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let _publish_span = keyhog_profile::span(keyhog_profile::Stage::Reporting);
let dir = exe
.parent()
.ok_or_else(|| anyhow!("current executable has no parent directory"))?;
require_trusted_install_directory(dir)?;
let tmp = dir.join(format!(".keyhog-update-{}.tmp", std::process::id()));
let cleanup = |e: std::io::Error| {
remove_installer_artifact_best_effort(&tmp, "failed unix install_binary cleanup");
e
};
let mut staged = create_installer_artifact(&tmp, "update staging file")?;
staged
.write_all(bytes)
.map_err(cleanup)
.context("write candidate binary bytes")?;
staged
.set_permissions(std::fs::Permissions::from_mode(0o755))
.map_err(cleanup)
.context("chmod the new binary")?;
staged
.sync_all()
.map_err(cleanup)
.context("flush the new binary before atomic replacement")?;
drop(staged);
std::fs::rename(&tmp, exe)
.map_err(cleanup)
.with_context(|| format!("atomically replace {}", exe.display()))?;
Ok(())
}
fn stash_path(exe: &Path) -> PathBuf {
let name = exe
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "keyhog".to_string()); let parent = exe.parent().unwrap_or_else(|| Path::new(".")); parent.join(format!(".{name}.keyhog-old-{}", std::process::id()))
}
fn write_executable(path: &Path, bytes: &[u8]) -> Result<()> {
std::fs::write(path, bytes).with_context(|| {
format!(
"write new binary to {} (the install dir must be writable; re-run with \
elevated permissions or reinstall if keyhog lives in a system path)",
path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
.context("chmod the new binary")?;
}
Ok(())
}
pub(crate) fn replace_running_binary<F>(
exe: &Path,
bytes: &[u8],
verify: F,
) -> Result<Option<PathBuf>>
where
F: FnOnce(&Path) -> bool,
{
replace_running_binary_checked(exe, bytes, bool_verify_as_result(verify))
}
fn bool_verify_as_result<F>(verify: F) -> impl FnOnce(&Path) -> Result<()>
where
F: FnOnce(&Path) -> bool,
{
move |path| {
if verify(path) {
Ok(())
} else {
Err(anyhow!("post-install verifier returned false"))
}
}
}
fn replace_running_binary_checked<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<Option<PathBuf>>
where
F: FnOnce(&Path) -> Result<()>,
{
let had_prior = exe.exists();
let stash = stash_path(exe);
if had_prior {
std::fs::rename(exe, &stash).with_context(|| {
format!(
"stash the current binary to {} before replacing it (the install dir \
must be writable so a failed update can roll back)",
stash.display()
)
})?;
}
if let Err(e) = write_executable(exe, bytes) {
if had_prior {
if let Err(restore_err) = std::fs::rename(&stash, exe) {
return Err(e).with_context(|| {
format!(
"ROLLBACK FAILED after a failed binary write: the original working binary \
could not be restored from {} to {} ({restore_err}). It is stranded at \
{}; restore it manually.",
stash.display(),
exe.display(),
stash.display()
)
});
}
}
return Err(e);
}
let verify_error = match verify(exe) {
Ok(()) => return Ok(had_prior.then_some(stash)),
Err(error) => error,
};
let removed = std::fs::remove_file(exe);
if had_prior {
std::fs::rename(&stash, exe).with_context(|| {
format!(
"ROLLBACK FAILED: the new binary failed its health check ({verify_error}) and the \
stashed working binary at {} could not be restored over {}. Restore it manually.",
stash.display(),
exe.display()
)
})?;
return Err(anyhow!(
"new binary failed its post-install health check: {verify_error}; rolled back to the previous \
working binary. The release may be broken for this host (libc/GPU driver) - \
try `keyhog update --version <older-tag>` or report the release."
));
}
match removed {
Ok(()) => Err(anyhow!(
"installed binary failed its post-install health check: {verify_error}; removed it because no prior \
binary to roll back to. The release may be broken for this host."
)),
Err(remove_err) => Err(anyhow!(
"installed binary failed its post-install health check: {verify_error}; it could NOT be removed from \
{} ({remove_err}) and there is no prior binary to roll back to - delete it manually. The release \
may be broken for this host.",
exe.display()
)),
}
}
pub(crate) fn reap_stale_binaries(exe: &Path) {
let Some(parent) = exe.parent() else { return };
let name = exe
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "keyhog".to_string()); let stash_prefix = format!(".{name}.keyhog-old-");
let backup_prefix = format!(".{name}.keyhog-bak-");
let Ok(entries) = std::fs::read_dir(parent) else {
return;
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
tracing::warn!(
dir = %parent.display(),
%error,
"cannot read installer artifact directory entry while reaping stale binaries; skipping entry"
);
continue;
}
};
let fname = entry.file_name();
let fname = fname.to_string_lossy();
if should_reap_installer_artifact(&fname, &stash_prefix, &backup_prefix) {
remove_installer_artifact_best_effort(&entry.path(), "stale installer artifact reap");
}
}
}
fn should_reap_installer_artifact(fname: &str, stash_prefix: &str, backup_prefix: &str) -> bool {
installer_artifact_pid(fname, stash_prefix, backup_prefix)
.is_some_and(|pid| !process_is_running(pid))
}
fn installer_artifact_pid(fname: &str, stash_prefix: &str, backup_prefix: &str) -> Option<u32> {
if let Some(raw_pid) = fname.strip_prefix(stash_prefix) {
return parse_artifact_pid(raw_pid);
}
if let Some(raw_pid) = fname.strip_prefix(backup_prefix) {
return parse_artifact_pid(raw_pid);
}
fname
.strip_prefix(".keyhog-update-")
.and_then(|rest| rest.strip_suffix(".tmp"))
.and_then(parse_artifact_pid)
}
fn parse_artifact_pid(raw: &str) -> Option<u32> {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
match raw.parse() {
Ok(pid) => Some(pid),
Err(error) => {
tracing::warn!(
pid = raw,
%error,
"installer artifact filename carries an invalid PID; treating it as stale"
);
Some(u32::MAX)
}
}
}
#[cfg(unix)]
pub(crate) fn process_is_running(pid: u32) -> bool {
if pid == std::process::id() {
return false;
}
let Ok(pid) = libc::pid_t::try_from(pid) else {
return false;
};
if pid <= 0 {
return false;
}
let rc = unsafe { libc::kill(pid, 0) };
if rc == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(windows)]
pub(crate) fn process_is_running(pid: u32) -> bool {
use std::ffi::c_void;
if pid == std::process::id() {
return false;
}
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
#[link(name = "kernel32")]
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> *mut c_void;
fn CloseHandle(hObject: *mut c_void) -> i32;
fn GetLastError() -> u32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
const ERROR_INVALID_PARAMETER: u32 = 87;
return unsafe { GetLastError() } != ERROR_INVALID_PARAMETER;
}
unsafe {
CloseHandle(handle);
}
true
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn process_is_running(_pid: u32) -> bool {
false
}
#[cfg(windows)]
pub(crate) fn install_binary(exe: &Path, bytes: &[u8]) -> Result<()> {
let _publish_span = keyhog_profile::span(keyhog_profile::Stage::Reporting);
let _ = replace_running_binary(exe, bytes, |_| true)?; Ok(())
}
pub(crate) fn backup_path(exe: &Path) -> PathBuf {
let name = exe
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "keyhog".to_string()); let parent = exe.parent().unwrap_or_else(|| Path::new(".")); parent.join(format!(".{name}.keyhog-bak-{}", std::process::id()))
}
pub(crate) fn verify_via_doctor_checked(exe: &Path) -> Result<()> {
let status = std::process::Command::new(exe)
.arg("doctor")
.status()
.with_context(|| {
format!(
"run candidate binary health check: {} doctor",
exe.display()
)
})?;
if status.success() {
Ok(())
} else {
Err(anyhow!(
"candidate binary doctor exited with {status}; run `{}` doctor` for the full report",
exe.display()
))
}
}
fn extract_keyhog_version(stdout: &str) -> Option<String> {
stdout.lines().find_map(|line| {
line.trim_start()
.strip_prefix("KeyHog v")
.and_then(|rest| rest.split_whitespace().next())
.filter(|version| !version.is_empty())
.map(str::to_string)
})
}
fn candidate_reported_version(exe: &Path) -> Result<String> {
let output = std::process::Command::new(exe)
.arg("--version")
.output()
.with_context(|| {
format!(
"run candidate binary version check: {} --version",
exe.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"candidate binary --version exited with {}; stderr: {}",
output.status,
stderr.trim()
));
}
let stdout = String::from_utf8(output.stdout)
.context("candidate binary --version wrote non-UTF-8 stdout")?;
extract_keyhog_version(&stdout).ok_or_else(|| {
anyhow!(
"candidate binary --version did not print a `KeyHog v<semver>` line; stdout: {}",
stdout.trim()
)
})
}
fn semver_version(label: &str, version: &str) -> Result<semver::Version> {
release::parse_version(version)
.ok_or_else(|| anyhow!("{label} `{version}` is not a parseable semver"))
}
pub(crate) fn verify_candidate_release(
exe: &Path,
expected_release_tag: &str,
current_version: &str,
allow_explicit_downgrade: bool,
) -> Result<()> {
let _verify_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
verify_via_doctor_checked(exe)?;
let observed_version = candidate_reported_version(exe)?;
let observed = semver_version("candidate binary version", &observed_version)?;
let expected = semver_version("release tag", expected_release_tag)?;
if observed != expected {
return Err(anyhow!(
"candidate binary version does not match release tag: binary reports v{} but release metadata resolved {}; refusing to install a mismatched signed binary",
observed_version,
expected_release_tag
));
}
if !allow_explicit_downgrade {
let current = semver_version("current binary version", current_version)?;
if observed.cmp_precedence(¤t).is_lt() {
return Err(anyhow!(
"candidate binary reports v{} which is older than the running keyhog v{}; refusing implicit downgrade",
observed_version,
current_version
));
}
}
Ok(())
}
pub(crate) fn install_with_rollback<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<()>
where
F: FnOnce(&Path) -> bool,
{
install_with_rollback_checked(exe, bytes, bool_verify_as_result(verify))
}
#[cfg(unix)]
pub(crate) fn install_with_rollback_checked<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<()>
where
F: FnOnce(&Path) -> Result<()>,
{
use std::os::unix::fs::PermissionsExt;
let had_prior = exe.exists();
let backup = backup_path(exe);
if had_prior {
let dir = exe
.parent()
.ok_or_else(|| anyhow!("current executable has no parent directory"))?;
require_trusted_install_directory(dir)?;
let mut source = std::fs::File::open(exe)
.with_context(|| format!("open current binary {} for backup", exe.display()))?;
let mut backup_file = create_installer_artifact(&backup, "rollback backup")?;
if let Err(error) = std::io::copy(&mut source, &mut backup_file) {
remove_installer_artifact_best_effort(
&backup,
"failed unix rollback backup cleanup after copy error",
);
return Err(error).with_context(|| {
format!(
"copy current binary into rollback backup {}",
backup.display()
)
});
}
backup_file
.set_permissions(std::fs::Permissions::from_mode(0o755))
.and_then(|()| backup_file.sync_all())
.map_err(|error| {
remove_installer_artifact_best_effort(
&backup,
"failed unix rollback backup cleanup after finalize error",
);
error
})
.with_context(|| {
format!(
"finalize executable rollback backup {} before updating",
backup.display()
)
})?;
}
if let Err(e) = install_binary(exe, bytes) {
if had_prior {
remove_installer_artifact_best_effort(
&backup,
"failed unix rollback backup cleanup after install error",
);
}
return Err(e);
}
let verify_error = match verify(exe) {
Ok(()) => {
if had_prior {
remove_installer_artifact_best_effort(
&backup,
"failed unix rollback backup cleanup after successful install",
);
}
return Ok(());
}
Err(error) => error,
};
if had_prior {
std::fs::rename(&backup, exe).with_context(|| {
format!(
"ROLLBACK FAILED: the new binary failed its health check ({verify_error}) and the \
backup at {} could not be restored over {}. Reinstall manually from {}",
backup.display(),
exe.display(),
backup.display()
)
})?;
Err(anyhow!(
"new binary failed its post-install health check: {verify_error}; rolled back to the previous \
working binary. The release may be broken for this host (libc/GPU driver) - \
try `keyhog update --version <older-tag>` or report the release."
))
} else {
match std::fs::remove_file(exe) {
Ok(()) => Err(anyhow!(
"installed binary failed its post-install health check: {verify_error}; removed it because no prior \
binary to roll back to. The release may be broken for this host."
)),
Err(remove_err) => Err(anyhow!(
"installed binary failed its post-install health check: {verify_error}; it could NOT be removed \
from {} ({remove_err}) and there is no prior binary to roll back to - delete it manually. The \
release may be broken for this host.",
exe.display()
)),
}
}
}
#[cfg(windows)]
pub(crate) fn install_with_rollback_checked<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<()>
where
F: FnOnce(&Path) -> Result<()>,
{
let stash = replace_running_binary_checked(exe, bytes, verify)?;
if let Some(stash) = stash {
remove_installer_artifact_best_effort(
&stash,
"failed windows rename-away stash cleanup after successful install",
);
}
Ok(())
}