Files
Kigi-CLI/crates/codegen/kigi-update/src/auto_update.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

3288 lines
130 KiB
Rust

use anyhow::{Context, Result};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use futures::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use tokio::io::AsyncWriteExt;
use crate::version::{
Release, UpdateConfig, fetch_latest_version, get_installed_kigi_version, get_latest_version,
is_version_cache_fresh, try_fetch_stable_version, write_version_cache,
};
use kigi_shell::util::config;
use kigi_shell::util::kigi_home::{kigi_application, kigi_home};
#[derive(Clone, Copy, Debug)]
pub enum UpdateRunMode {
Blocking,
NonBlocking,
}
const PROMPT_UPDATE_NOW: &str = "Update now? [Y/n/d]";
const MSG_AUTO_UPDATE_BACKGROUND: &str = "Auto-update running in background.";
const MSG_RUN_UPDATE_MANUAL: &str = "Run `kigi update` to get the latest version.";
/// Manual-install one-liner for this platform's bootstrap installer
/// (install.sh / install.ps1 hosted at the repo root, PRD F8).
fn manual_install_cmd() -> &'static str {
if cfg!(windows) {
"irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1 | iex"
} else {
"curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.sh | sh"
}
}
/// Build a reinstall hint for a known installer type. Every installer is
/// "internal" (GitHub Releases) today; the parameter survives so the hint
/// stays correct if another backend ever returns.
fn reinstall_hint(_installer: &str) -> String {
format!("Please reinstall via:\n {}", manual_install_cmd())
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateStatus {
pub current_version: String,
pub latest_version: Option<String>,
pub update_available: bool,
pub installer: Option<String>,
pub channel: String,
pub auto_update: Option<bool>,
pub error: Option<String>,
}
/// Format and print an [`UpdateStatus`] to stdout.
pub fn print_update_status(status: &UpdateStatus, json: bool) -> anyhow::Result<()> {
if json {
let payload = serde_json::to_string(status)?;
println!("{payload}");
return Ok(());
}
if let Some(error) = status.error.as_deref() {
println!("Kigi - v{} [{}]", status.current_version, status.channel);
println!("Update check failed: {error}");
return Ok(());
}
let channel_label = format!(" [{}]", status.channel);
if status.update_available {
if let Some(latest_version) = status.latest_version.as_deref() {
println!(
"A new version of Kigi is available: {} -> {}{}",
status.current_version, latest_version, channel_label
);
} else {
println!("A new version of Kigi is available.");
}
return Ok(());
}
if let Some(latest_version) = status.latest_version.as_deref() {
println!(
"Kigi - v{} (latest: {}){}",
status.current_version, latest_version, channel_label
);
return Ok(());
}
println!("Kigi - v{}{}", status.current_version, channel_label);
Ok(())
}
pub async fn check_update_status(update_config: &UpdateConfig) -> UpdateStatus {
let installer = get_installer().await.map(|value| value.to_string());
let current_version = get_installed_kigi_version();
let current_config = config::load_config().await;
let auto_update = current_config.cli.auto_update;
let channel = update_config.channel.clone();
let Some(ref _inst) = installer else {
return UpdateStatus {
current_version,
latest_version: None,
update_available: false,
installer,
channel,
auto_update,
error: None,
};
};
match get_latest_version(update_config).await {
Ok(latest_version) => {
let mut error = None;
// --check reports upgrades only; a rolled-back pointer isn't a "new version" to advertise here (auto-update converges separately).
let allow_downgrade = false;
let update_available =
match needs_update(&current_version, &latest_version, &channel, allow_downgrade) {
Some(value) => value,
None => {
// Distinguish parse failure from unsupported channel for clearer diagnostics.
let parse_ok = semver::Version::parse(&current_version).is_ok()
&& semver::Version::parse(&latest_version).is_ok();
error = Some(if parse_ok {
format!(
"Unsupported release channel '{}' (current={}, latest={}). \
Supported channels: stable, alpha, enterprise.",
channel, current_version, latest_version
)
} else {
format!(
"Failed to parse versions (current={}, latest={})",
current_version, latest_version
)
});
false
}
};
UpdateStatus {
current_version,
latest_version: Some(latest_version),
update_available,
installer,
channel,
auto_update,
error,
}
}
Err(err) => UpdateStatus {
current_version,
latest_version: None,
update_available: false,
installer,
channel,
auto_update,
error: Some(err.to_string()),
},
}
}
/// Installer + version the leader/background path should converge to: an
/// upgrade OR an authoritative-installer rollback. `None` means stay put.
/// Gates on the installer (via `installer_allows_downgrade`) so the decision
/// depends on the installer, never the caller.
pub async fn auto_update_target(update_config: &UpdateConfig) -> Option<(&'static str, String)> {
let installer = get_installer().await?;
let current = get_installed_kigi_version();
let latest = fetch_latest_version(update_config).await.ok()?;
needs_update(
&current,
&latest,
&update_config.channel,
installer_allows_downgrade(installer),
)
.unwrap_or(false)
.then_some((installer, latest))
}
/// Outcome of [`ensure_latest_on_disk`].
#[derive(Debug)]
pub struct EnsureLatestOutcome {
/// Version this call downloaded and installed; `None` when the disk was
/// already current (or there was no installer).
pub installed: Option<String>,
/// The running process differs from what is now on disk in the channel's
/// update direction — the caller should relaunch onto the on-disk binary.
pub relaunch_needed: bool,
}
/// One leader auto-update pass: converge the on-disk install to the latest
/// release (downloading **only** when the disk is actually behind it), then
/// report whether the running process should relaunch onto the on-disk binary.
///
/// Unlike [`run_update`] this never uses the compiled-in version for the
/// download decision — a binary already installed by another process (TUI
/// background download, explicit `kigi update`) is reused as-is. This both
/// removes the duplicate download in leader mode and stops the pre-fix
/// hourly re-download while a busy leader keeps deferring its relaunch.
///
/// When the disk version is unknowable ([`disk_version_for_installer`]:
/// Windows copy-based installs, dev builds), this degrades to the pre-fix
/// behavior — download when the *running* process is stale, relaunch only
/// after a download this pass actually installed something.
pub async fn ensure_latest_on_disk(update_config: &UpdateConfig) -> Result<EnsureLatestOutcome> {
let mut outcome = EnsureLatestOutcome {
installed: None,
relaunch_needed: false,
};
let Some(installer) = get_installer().await else {
return Ok(outcome);
};
let allow_downgrade = installer_allows_downgrade(installer);
let latest = fetch_latest_version(update_config).await?;
let effective_current =
disk_version_for_installer(installer).unwrap_or_else(get_installed_kigi_version);
if needs_update(
&effective_current,
&latest,
&update_config.channel,
allow_downgrade,
)
.unwrap_or(false)
{
run_install_script(installer, Some(&latest), update_config).await?;
outcome.installed = Some(latest.clone());
}
// Relaunch when the running binary differs from what's on disk in the
// channel's update direction — covers binaries installed by other
// processes, not just the install above.
let running = get_installed_kigi_version();
if let Some(disk_now) =
disk_version_for_installer(installer).or_else(|| outcome.installed.clone())
{
outcome.relaunch_needed =
needs_update(&running, &disk_now, &update_config.channel, allow_downgrade)
.unwrap_or(false);
}
Ok(outcome)
}
/// Disk-version probe gated on the installer actually maintaining the
/// managed `~/.kigi/bin/kigi` symlink. Only the internal (GitHub Releases)
/// installer writes that symlink; unknown installers report no trustworthy
/// disk version.
fn disk_version_for_installer(installer: &str) -> Option<String> {
match installer {
"internal" => crate::version::installed_on_disk_version(),
_ => None,
}
}
fn env_installer() -> Option<&'static str> {
if let Ok(v) = std::env::var("KIGI_INSTALLER") {
return match v.to_ascii_lowercase().as_str() {
"internal" => Some("internal"),
_ => None,
};
}
if std::env::var_os("KIGI_MANAGED_BY_INTERNAL").is_some() {
return Some("internal");
}
None
}
/// Resolve the active installer backend. Every supported install path
/// (install.sh, install.ps1, self-update) is "internal" — binaries from this
/// repo's GitHub Releases (PRD F8: no PyPI/npm packages).
pub async fn get_installer() -> Option<&'static str> {
if let Some(i) = env_installer() {
return Some(i);
}
// Any persisted installer value maps to the single supported backend.
let _ = config::load_config().await.cli.installer;
Some("internal")
}
fn needs_update(current: &str, target: &str, channel: &str, allow_downgrade: bool) -> Option<bool> {
let current = semver::Version::parse(current).ok()?;
let target = semver::Version::parse(target).ok()?;
match channel {
"stable" | "enterprise" => {
if !target.pre.is_empty() {
tracing::warn!(
%current, %target,
channel = %channel,
"stable/enterprise channel received pre-release candidate, rejecting"
);
return Some(false);
}
if !current.pre.is_empty() {
return Some(true);
}
}
"alpha" => {}
_ => return None,
}
Some(if allow_downgrade {
target != current
} else {
target > current
})
}
/// Returns `true` for installer backends whose version source is
/// authoritative (this repo's GitHub Releases), meaning a release rollback
/// (deleted/yanked latest) is intentional and should trigger a client
/// downgrade. Unknown backends never downgrade.
fn installer_allows_downgrade(installer: &str) -> bool {
installer == "internal"
}
/// Result of a background update availability check.
#[derive(Debug, Clone)]
pub struct UpdateAvailable {
/// The latest version string (e.g. "0.1.2").
pub latest_version: String,
}
/// Outcome of [`check_update_background`].
pub struct BackgroundUpdateCheck {
/// `Some` when the *running* binary is older than the latest release —
/// drives the in-TUI restart hint regardless of who downloads the binary.
pub update: Option<UpdateAvailable>,
/// Handle to the background `kigi update` child, `Some` only when a
/// download was actually started (the on-disk install was behind the
/// latest release). The TUI parks this and `wait()`s on it at
/// quit-for-update time instead of spawning a second downloader.
pub download: Option<tokio::process::Child>,
}
impl BackgroundUpdateCheck {
fn none() -> Self {
Self {
update: None,
download: None,
}
}
}
/// Check for available updates without blocking the TUI startup.
///
/// Sets [`BackgroundUpdateCheck::update`] when the running binary is older
/// than the latest release. If `auto_update` is enabled **and the on-disk
/// install is also behind it**, kicks off a non-blocking download (spawns
/// `kigi update` as a detached child process) so the new binary is ready
/// when the user quits and relaunches. When another process (an earlier TUI,
/// the leader's hourly checker) already put the target version on disk, no
/// download is started — only the restart hint is surfaced.
pub async fn check_update_background(update_config: &UpdateConfig) -> BackgroundUpdateCheck {
let Some(installer) = get_installer().await else {
return BackgroundUpdateCheck::none();
};
if is_version_cache_fresh().await {
return BackgroundUpdateCheck::none();
}
let current_config = config::load_config().await;
if current_config.cli.auto_update == Some(false) {
return BackgroundUpdateCheck::none();
}
let current_version = get_installed_kigi_version();
let latest_version = match fetch_latest_version(update_config).await {
Ok(v) => v,
Err(_) => return BackgroundUpdateCheck::none(),
};
let allow_downgrade = installer_allows_downgrade(installer);
if !needs_update(
&current_version,
&latest_version,
&update_config.channel,
allow_downgrade,
)
.unwrap_or(false)
{
let stable_ptr = try_fetch_stable_version().await;
write_version_cache(&latest_version, stable_ptr.as_deref()).await;
return BackgroundUpdateCheck::none();
}
// Only download when the on-disk install is behind the latest release;
// the running process being stale (checked above) just means "show the
// restart hint". The quit-for-update path's `kigi update` child resolves
// to "Already up to date" against the same disk state.
let disk_needs_download = match disk_version_for_installer(installer) {
Some(disk) => needs_update(
&disk,
&latest_version,
&update_config.channel,
allow_downgrade,
)
.unwrap_or(true),
None => true,
};
// Kick off a non-blocking download so the binary is ready when the
// user restarts (or accepts the in-TUI restart prompt).
let download = if disk_needs_download {
match run_update_subcommand(UpdateRunMode::NonBlocking).await {
Ok(child) => child,
Err(e) => {
tracing::warn!("Background update download failed to start: {e}");
None
}
}
} else {
tracing::info!(
latest_version = %latest_version,
"Background update: target already on disk, skipping download"
);
None
};
BackgroundUpdateCheck {
update: Some(UpdateAvailable { latest_version }),
download,
}
}
/// Returns Ok(true) if a blocking update ran; otherwise Ok(false).
pub async fn run_update_if_available(
run_mode: UpdateRunMode,
interactive: bool,
update_config: &UpdateConfig,
) -> Result<bool> {
let installer = get_installer().await;
if installer.is_none() {
// Skip update check if no known installer.
return Ok(false);
}
if is_version_cache_fresh().await {
return Ok(false);
}
let current_config = config::load_config().await;
// Skip update check if auto-update is explicitly disabled.
if current_config.cli.auto_update == Some(false) {
return Ok(false);
}
// Resolve effective auto_update: None defaults to true (first-run).
let auto_update = current_config.cli.auto_update.unwrap_or(true);
if current_config.cli.auto_update.is_none()
&& let Err(e) = config::update_config(|st| {
if st.cli.auto_update.is_none() {
st.cli.auto_update = Some(true);
}
})
.await
{
tracing::warn!("Failed to save auto-update setting: {}", e);
}
let current_version = get_installed_kigi_version();
// installer is guaranteed Some by the guard at the top of this function.
let inst = installer.unwrap();
// Fetch without writing version.json — we only cache after confirming the
// update is not needed or after a successful blocking install. This prevents
// a failed background download from suppressing retries for the TTL window.
let latest_version = match fetch_latest_version(update_config).await {
Ok(v) => v,
Err(_) => return Ok(false),
};
if !needs_update(
&current_version,
&latest_version,
&update_config.channel,
installer_allows_downgrade(inst),
)
.unwrap_or(false)
{
let stable_ptr = try_fetch_stable_version().await;
write_version_cache(&latest_version, stable_ptr.as_deref()).await;
return Ok(false);
}
let channel_label = format!(" [{}]", update_config.channel);
if auto_update {
eprintln!(
"A new version of Kigi is available: {} -> {}{}",
current_version, latest_version, channel_label
);
if interactive {
if let Err(e) = run_update_subcommand(run_mode).await {
eprintln!("Update failed: {}", e);
} else if matches!(run_mode, UpdateRunMode::Blocking) {
return Ok(true);
} else {
eprintln!("{}", MSG_AUTO_UPDATE_BACKGROUND);
return Ok(false);
}
} else if let Err(e) = run_update_subcommand(run_mode).await {
eprintln!("Update failed: {}", e);
} else if matches!(run_mode, UpdateRunMode::Blocking) {
return Ok(true);
}
return Ok(false);
} else {
if current_config
.cli
.dismissed_version
.as_deref()
.is_some_and(|v| v == latest_version)
{
return Ok(false);
}
eprintln!(
"A new version of Kigi is available: {} -> {}{}",
current_version, latest_version, channel_label
);
if interactive {
eprintln!("{}", PROMPT_UPDATE_NOW);
let mut line = String::new();
if io::stdin().read_line(&mut line).is_ok() {
let ans = line.trim().to_ascii_lowercase();
if ans.is_empty() || ans == "y" || ans == "yes" {
if let Err(e) = run_update_subcommand(run_mode).await {
eprintln!("Update failed: {}", e);
} else if matches!(run_mode, UpdateRunMode::Blocking) {
return Ok(true);
} else {
eprintln!("{}", MSG_AUTO_UPDATE_BACKGROUND);
return Ok(false);
}
} else if ans == "d" || ans == "dismiss" {
let dismissed = latest_version.clone();
if let Err(e) = config::update_config(|st| {
st.cli.dismissed_version = Some(dismissed);
})
.await
{
tracing::warn!("Failed to save dismissed version: {}", e);
}
}
}
} else {
eprintln!("{}", MSG_RUN_UPDATE_MANUAL);
}
}
Ok(false)
}
/// Launch "kigi update" in blocking or non-blocking mode.
///
/// In `NonBlocking` mode the spawned child's handle is returned so the caller
/// can later `wait()` on the in-flight download (e.g. the TUI's
/// quit-for-update path) instead of blind-spawning a second downloader.
/// Dropping the handle does not kill the child (`kill_on_drop` is off), so
/// callers that don't care can ignore it. `Blocking` mode returns `None`.
async fn run_update_subcommand(run_mode: UpdateRunMode) -> Result<Option<tokio::process::Child>> {
let exe = std::env::current_exe()?;
let mut cmd = tokio::process::Command::new(exe);
cmd.arg("update");
match run_mode {
UpdateRunMode::Blocking => {
// stderr must be null, not piped: `.status()` does not drain
// pipes, so if the child writes more than the OS pipe buffer
// (~16 KB macOS / ~64 KB Linux) to stderr (e.g. download
// progress bars), the child blocks on the write while the
// parent blocks on waitpid — deadlocking both processes.
// With `panic = "abort"`, the blocked child eventually
// receives SIGABRT.
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
// inherit, not piped: the TUI is already restored so the
// parent's stderr fd is a normal terminal. inherit lets
// the child's diagnostic output reach the user. piped +
// status() would immediately close the read end → EPIPE
// → panic → SIGABRT (signal 6) under panic=abort.
.stderr(Stdio::inherit());
// No detach: the child must stay in the foreground process group so Ctrl+C cancels it with the parent; the atomic install protocol makes mid-download kills safe.
let status = cmd.status().await?;
if !status.success() {
anyhow::bail!("kigi update failed with {}", status);
}
Ok(None)
}
UpdateRunMode::NonBlocking => {
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
// Detach = new session (Ctrl+C isolation), not handle abandonment:
// the child is still ours to wait() on.
kigi_tools::util::detach_command(&mut cmd);
let child = cmd.spawn()?;
Ok(Some(child))
}
}
}
/// Resolve the kigi binary path for re-execution after an update.
///
/// `current_exe()` resolves symlinks via `/proc/self/exe` (see proc(5)),
/// so it returns the old versioned target after a symlink swap.
/// Prefer `~/.kigi/bin/kigi` which always points to the latest version.
fn resolve_restart_exe() -> Result<PathBuf> {
let canonical = kigi_application();
if canonical.exists() {
return Ok(canonical);
}
Ok(std::env::current_exe()?)
}
/// Restart kigi with the original command-line arguments to pick up the update.
pub fn restart_kigi() -> Result<()> {
let exe = resolve_restart_exe()?;
let mut cmd = std::process::Command::new(exe);
for arg in std::env::args_os().skip(1) {
cmd.arg(arg);
}
cmd.env_clear();
cmd.envs(std::env::vars_os().filter(|(k, _)| k != "KIGI_AUTO_UPDATE"));
eprintln!("Restarting Kigi...");
// Use exec on Unix to replace the current process, avoiding stdio issues
// when the parent exits. On Windows, fall back to spawn + exit.
#[cfg(unix)]
{
// Flush output before exec to ensure messages are visible
let _ = io::stdout().flush();
let _ = io::stderr().flush();
let err = cmd.exec();
// exec only returns if there was an error
anyhow::bail!("Failed to exec: {}", err);
}
#[cfg(not(unix))]
{
// Flush output before exit to ensure messages are visible
let _ = io::stdout().flush();
let _ = io::stderr().flush();
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let _ = cmd.spawn()?;
std::process::exit(0);
}
}
pub async fn run_install_script(
installer: &str,
target: Option<&str>,
update_config: &UpdateConfig,
) -> Result<()> {
let result = install_internal(target, update_config).await;
if result.is_ok() {
remove_stale_models_cache().await;
}
result.map_err(|e| {
anyhow::anyhow!(
"Auto-update failed: {:#}\n\n{}",
e,
reinstall_hint(installer)
)
})
}
/// Detect the current platform (os, arch) for versioned on-disk binary names
/// (`kigi-<version>-<os>-<arch>`).
pub(crate) fn detect_platform() -> Result<(&'static str, &'static str)> {
let os = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else {
anyhow::bail!("Unsupported OS");
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
anyhow::bail!("Unsupported architecture");
};
Ok((os, arch))
}
/// Rust target triple for this build — the key that maps a platform to its
/// release-asset name. Must stay in lockstep with the five targets built by
/// `.github/workflows/release.yml` and the tables in install.sh/install.ps1.
pub(crate) fn target_triple() -> Result<&'static str> {
let triple = if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
"x86_64-apple-darwin"
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
"aarch64-unknown-linux-gnu"
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
"x86_64-unknown-linux-gnu"
} else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
"x86_64-pc-windows-msvc"
} else {
anyhow::bail!("no released kigi artifact for this platform")
};
Ok(triple)
}
/// Release archives are tar.gz everywhere except Windows (zip).
pub(crate) const ARCHIVE_EXT: &str = if cfg!(windows) { "zip" } else { "tar.gz" };
/// Release-asset archive name for `version` on this platform:
/// `kigi-<version>-<target-triple>.{tar.gz|zip}` (PRD F8).
pub(crate) fn release_asset_name(version: &str) -> Result<String> {
Ok(format!("kigi-{version}-{}.{ARCHIVE_EXT}", target_triple()?))
}
/// Name of the checksum manifest asset attached to every release.
pub(crate) const SHA256SUMS_ASSET: &str = "SHA256SUMS";
/// Age past which a leftover `.tmp` download file (or a freshly-renamed
/// versioned binary) is considered abandoned (crashed/killed updater) and
/// safe for `cleanup_old_downloads` to sweep. Generous compared to the
/// longest plausible download (per-request budget is
/// [`DOWNLOAD_REQUEST_TIMEOUT`]; the leader check+download pass matches) so
/// a concurrent updater's in-flight or just-landed file is never deleted
/// out from under it.
const STALE_TMP_AGE: Duration = Duration::from_secs(60 * 60);
/// Total timeout for a CLI artifact download request (including body).
const DOWNLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(20 * 60);
/// Unique temp path for an in-flight download of `dest`.
///
/// Appends `.{pid}-{seq}.tmp` to the FULL file name instead of using
/// `Path::with_extension`, which treats everything after the last dot of the
/// versioned name as the extension (`kigi-0.1.1-linux-x86_64` →
/// `kigi-0.1.tmp`) and therefore collides for every `0.1.x` version. The PID
/// plus a per-process counter makes the name unique per download attempt —
/// across processes (two updaters racing in the same instant, the accepted
/// lock-free residual race) and within one process — so no racer can ever
/// rename another's half-written temp file into place. Leftovers older than
/// [`STALE_TMP_AGE`] are swept by `cleanup_old_downloads`.
fn tmp_download_path(dest: &Path) -> PathBuf {
unique_temp_sibling(dest, "tmp")
}
/// Unique temp path `<base>.{pid}-{seq}.{ext}`, appended to the full name so a
/// versioned base like `kigi-0.1.1` doesn't collide via `with_extension`.
/// PID + per-process counter keep racing updaters from clobbering each other.
fn unique_temp_sibling(base: &Path, ext: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let mut name = base
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(format!(
".{}-{}.{ext}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
base.with_file_name(name)
}
/// Set `+x` on the temp file before renaming onto `dest`, so a concurrent
/// same-version installer never execs `dest` while it is still 0644.
async fn publish_downloaded_artifact(tmp: &Path, dest: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(tmp, std::fs::Permissions::from_mode(0o755)).await?;
}
tokio::fs::rename(tmp, dest).await?;
Ok(())
}
/// Files smaller than this are not worth fragmenting across parallel chunks.
const PARALLEL_DOWNLOAD_MIN_BYTES: u64 = 16 * 1024 * 1024;
/// Pick chunk count from file size: 1 chunk per 16 MiB, capped at 8.
fn parallel_chunk_count(size: u64) -> u64 {
let size_mb = size / (1024 * 1024);
(size_mb / 16).clamp(1, 8)
}
/// HTTP client for release-asset downloads. GitHub requires a `User-Agent`
/// on every request; asset downloads follow redirects to the CDN.
fn asset_client() -> Result<reqwest::Client> {
Ok(reqwest::Client::builder()
.user_agent(concat!("kigi/", env!("CARGO_PKG_VERSION")))
.timeout(DOWNLOAD_REQUEST_TIMEOUT)
.build()?)
}
/// Try a parallel byte-range download to `dest`. Returns Err if the server
/// doesn't advertise a Content-Length, the file is too small to be worth
/// splitting, the range request is rejected, or any chunk transfer fails.
/// The caller is expected to fall back to a single-connection download on Err.
async fn try_parallel_download(url: &str, dest: &Path, with_progress: bool) -> Result<()> {
let client = asset_client()?;
let head = client.head(url).send().await?;
if !head.status().is_success() {
anyhow::bail!("HEAD failed: HTTP {}", head.status());
}
let size = head
.content_length()
.ok_or_else(|| anyhow::anyhow!("response missing Content-Length"))?;
if size < PARALLEL_DOWNLOAD_MIN_BYTES {
anyhow::bail!("file too small for parallel download ({} bytes)", size);
}
let n_chunks = parallel_chunk_count(size);
if n_chunks < 2 {
anyhow::bail!(
"file size yields {} chunk(s); not worth parallelizing",
n_chunks
);
}
let chunk_size = size.div_ceil(n_chunks);
let pb = if with_progress {
let pb = ProgressBar::new(size);
pb.set_style(
ProgressStyle::default_bar()
.template(" {bar:30.cyan/dim} {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("━╸─"),
);
Some(pb)
} else {
None
};
let tmp = tmp_download_path(dest);
// Pre-allocate so each task can seek+write to its own range concurrently.
// One blocking-pool hop instead of two per tokio::fs call.
let tmp_for_alloc = tmp.clone();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
let f = std::fs::File::create(&tmp_for_alloc)?;
f.set_len(size)?;
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("blocking pre-allocate task panicked: {e}"))??;
let tasks = (0..n_chunks).map(|i| {
let start = i * chunk_size;
let end = std::cmp::min(start + chunk_size, size) - 1;
let url = url.to_string();
let tmp = tmp.clone();
let client = client.clone();
let pb = pb.clone();
async move { download_range(&client, &url, &tmp, start, end, pb.as_ref()).await }
});
let result = futures::future::try_join_all(tasks).await;
if let Some(pb) = &pb {
pb.finish_and_clear();
}
match result {
Ok(_) => {
publish_downloaded_artifact(&tmp, dest).await?;
Ok(())
}
Err(e) => {
let _ = tokio::fs::remove_file(&tmp).await;
Err(e)
}
}
}
/// Fetch bytes `[start, end]` (inclusive) of `url` and write them at `start`
/// in `dest`. Errors if the server doesn't return `206 Partial Content`.
///
/// Streams from the network into a `Vec<u8>` (so progress ticks smoothly as
/// bytes arrive), then issues a single `spawn_blocking` per chunk to do the
/// open + seek + write_all in `std::fs`. This avoids the per-write hop into
/// tokio's blocking pool that `tokio::fs::File::write_all` performs on every
/// ~8 KiB Bytes item from `bytes_stream()`.
async fn download_range(
client: &reqwest::Client,
url: &str,
dest: &Path,
start: u64,
end: u64,
progress: Option<&ProgressBar>,
) -> Result<()> {
let resp = client
.get(url)
.header("Range", format!("bytes={}-{}", start, end))
.send()
.await?;
if resp.status() != reqwest::StatusCode::PARTIAL_CONTENT {
anyhow::bail!("range request rejected: HTTP {}", resp.status());
}
let mut buf = Vec::with_capacity((end - start + 1) as usize);
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if let Some(pb) = progress {
pb.inc(chunk.len() as u64);
}
buf.extend_from_slice(&chunk);
}
let dest = dest.to_owned();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
use std::io::{Seek, SeekFrom, Write};
let mut f = std::fs::OpenOptions::new().write(true).open(&dest)?;
f.seek(SeekFrom::Start(start))?;
f.write_all(&buf)?;
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("blocking write task panicked: {e}"))??;
Ok(())
}
/// Download a file from `url` to `dest` with a terminal progress bar.
///
/// If the server provides a `Content-Length` header, a determinate bar is shown
/// with bytes downloaded, total size, and ETA. Otherwise a spinner with a byte
/// counter is used as a fallback.
#[doc(hidden)]
pub async fn download_with_progress(url: &str, dest: &Path) -> Result<()> {
// Try parallel byte-range first. Falls through to single-connection on any
// failure (HEAD missing Content-Length, ranges rejected, partial-fetch error).
match try_parallel_download(url, dest, true).await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::debug!("parallel download failed, falling back to single connection: {e}")
}
}
let client = asset_client()?;
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Download failed: HTTP {}", resp.status());
}
let total_size = resp.content_length();
let pb = if let Some(size) = total_size {
let pb = ProgressBar::new(size);
pb.set_style(
ProgressStyle::default_bar()
.template(" {bar:30.cyan/dim} {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("━╸─"),
);
pb
} else {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template(" {spinner:.cyan} {bytes} downloaded")
.unwrap(),
);
pb.enable_steady_tick(Duration::from_millis(100));
pb
};
// Stream to a temp file, then rename atomically
let tmp = tmp_download_path(dest);
let mut file = tokio::fs::File::create(&tmp).await?;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
pb.inc(chunk.len() as u64);
}
file.flush().await?;
drop(file);
pb.finish_and_clear();
publish_downloaded_artifact(&tmp, dest).await?;
Ok(())
}
/// Download a file silently (no progress bar).
#[doc(hidden)]
pub async fn download_silent(url: &str, dest: &Path) -> Result<()> {
match try_parallel_download(url, dest, false).await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::debug!("parallel download failed, falling back to single connection: {e}")
}
}
let client = asset_client()?;
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Download failed: HTTP {}", resp.status());
}
let tmp = tmp_download_path(dest);
let mut file = tokio::fs::File::create(&tmp).await?;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
}
file.flush().await?;
drop(file);
publish_downloaded_artifact(&tmp, dest).await?;
Ok(())
}
/// Fetch a small text asset (the SHA256SUMS manifest) into memory.
async fn fetch_asset_text(url: &str) -> Result<String> {
let client = asset_client()?;
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Download failed: HTTP {} for {}", resp.status(), url);
}
Ok(resp.text().await?)
}
/// Hex-encoded SHA-256 of a file, computed off the async runtime.
async fn sha256_hex_of_file(path: &Path) -> Result<String> {
let path = path.to_owned();
tokio::task::spawn_blocking(move || -> Result<String> {
use sha2::{Digest, Sha256};
let mut f = std::fs::File::open(&path)?;
let mut hasher = Sha256::new();
std::io::copy(&mut f, &mut hasher)?;
Ok(format!("{:x}", hasher.finalize()))
})
.await
.map_err(|e| anyhow::anyhow!("sha256 task panicked: {e}"))?
}
/// Look up the expected SHA-256 for `asset_name` in a `sha256sum`-format
/// manifest (`<hex><whitespace><name>` per line; a leading `*` on the name
/// marks binary mode and is ignored).
fn expected_sha256_for(sums: &str, asset_name: &str) -> Result<String> {
for line in sums.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Some((hash, name)) = line.split_once(char::is_whitespace) else {
continue;
};
if name.trim().trim_start_matches('*') != asset_name {
continue;
}
let hash = hash.trim();
if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
return Ok(hash.to_ascii_lowercase());
}
anyhow::bail!("malformed SHA256SUMS entry for {asset_name}: '{line}'");
}
anyhow::bail!("SHA256SUMS has no entry for {asset_name}")
}
/// Extract the single `kigi` binary from a release archive to `dest_tmp`
/// (caller publishes it with the usual chmod+rename). Archives are tar.gz on
/// Unix and zip on Windows, containing `kigi(.exe)` plus license files.
async fn extract_kigi_binary(archive: &Path, dest_tmp: &Path) -> Result<()> {
let archive = archive.to_owned();
let dest_tmp = dest_tmp.to_owned();
tokio::task::spawn_blocking(move || extract_kigi_binary_blocking(&archive, &dest_tmp))
.await
.map_err(|e| anyhow::anyhow!("archive extraction task panicked: {e}"))?
}
#[cfg(not(windows))]
fn extract_kigi_binary_blocking(archive: &Path, dest_tmp: &Path) -> Result<()> {
let f = std::fs::File::open(archive)?;
let gz = flate2::read::GzDecoder::new(f);
let mut ar = tar::Archive::new(gz);
for entry in ar.entries()? {
let mut entry = entry?;
let is_kigi = entry
.path()?
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "kigi");
if is_kigi {
let mut out = std::fs::File::create(dest_tmp)?;
std::io::copy(&mut entry, &mut out)?;
return Ok(());
}
}
anyhow::bail!("archive {} contains no 'kigi' binary", archive.display())
}
#[cfg(windows)]
fn extract_kigi_binary_blocking(archive: &Path, dest_tmp: &Path) -> Result<()> {
let f = std::fs::File::open(archive)?;
let mut zip = zip::ZipArchive::new(f)?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
let base_name = entry
.name()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_string();
if base_name.eq_ignore_ascii_case("kigi.exe") {
let mut out = std::fs::File::create(dest_tmp)?;
std::io::copy(&mut entry, &mut out)?;
return Ok(());
}
}
anyhow::bail!(
"archive {} contains no 'kigi.exe' binary",
archive.display()
)
}
/// Delete `~/.kigi/models_cache.json` after a successful update.
///
/// The cache embeds the binary version and will be treated as a miss by the
/// new binary anyway, but removing it eagerly avoids a wasted disk read +
/// deserialize on first launch.
async fn remove_stale_models_cache() {
let cache = kigi_home().join("models_cache.json");
match tokio::fs::remove_file(&cache).await {
Ok(()) => tracing::debug!("removed stale models_cache.json after update"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::debug!("failed to remove stale models cache: {e}"),
}
}
/// Remove stale links/binaries from `~/.kigi/bin/` left by installations
/// that predate the single-binary distribution rewrite (`agent`). `kigi`
/// is the single managed entry point now.
async fn remove_legacy_links(bin_dir: &Path) {
let name = if cfg!(windows) { "agent.exe" } else { "agent" };
let link = bin_dir.join(name);
if link.exists() || link.is_symlink() {
let _ = tokio::fs::remove_file(&link).await;
}
}
async fn install_internal(target: Option<&str>, update_config: &UpdateConfig) -> Result<()> {
install_internal_from_base(target, update_config, &crate::version::update_base_url()).await
}
/// Test-visible entry point: same as [`install_internal`] but resolves
/// releases from `base_url` (a GitHub-Releases-shaped API) instead of
/// [`kigi_env::update_base_url`]. Persists installer config and writes to
/// `~/.kigi/bin/`, so callers must isolate `KIGI_SHARE_DIR`.
#[doc(hidden)]
pub async fn install_internal_from_base(
target: Option<&str>,
update_config: &UpdateConfig,
base_url: &str,
) -> Result<()> {
let download = download_verified_from_base(target, update_config, base_url).await?;
activate_verified_download(&download).await
}
const SMOKE_TEST_TIMEOUT: Duration = Duration::from_secs(10);
async fn smoke_test_binary(binary_path: &Path) -> bool {
let mut cmd = tokio::process::Command::new(binary_path);
cmd.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
kigi_tools::util::detach_command(&mut cmd);
match tokio::time::timeout(SMOKE_TEST_TIMEOUT, cmd.status()).await {
Ok(Ok(status)) => status.success(),
_ => false,
}
}
/// A downloaded, checksum-verified, and smoke-tested binary in
/// `~/.kigi/downloads/`, not yet activated as the managed `kigi`.
struct VerifiedDownload {
version: String,
binary_path: PathBuf,
}
/// Resolve the release to install: pinned version → `GET {base}/tags/v{v}`;
/// otherwise the channel's latest.
async fn resolve_release(target: Option<&str>, channel: &str, base_url: &str) -> Result<Release> {
match target {
Some(v) => {
semver::Version::parse(v)
.map_err(|_| anyhow::anyhow!("invalid version format: '{}'", v))?;
crate::version::fetch_release_for_version_from_base(v, base_url).await
}
None => crate::version::fetch_latest_release_from_base(channel, base_url).await,
}
}
/// Download phase: resolve the release, download the platform archive,
/// verify its SHA-256 against the release's SHA256SUMS manifest, extract the
/// `kigi` binary, and smoke-test it. Nothing is activated yet.
async fn download_verified_from_base(
target: Option<&str>,
update_config: &UpdateConfig,
base_url: &str,
) -> Result<VerifiedDownload> {
let (os, arch) = detect_platform()?;
let platform = format!("{}-{}", os, arch);
let release = resolve_release(target, &update_config.channel, base_url).await?;
let version = release.version()?;
let asset_name = release_asset_name(&version)?;
let archive_asset = release.asset(&asset_name)?;
let sums_asset = release.asset(SHA256SUMS_ASSET)?;
// Fetch the checksum manifest FIRST: if it's missing or unreadable we
// fail before spending bandwidth on the archive.
let sums = fetch_asset_text(&sums_asset.browser_download_url).await?;
let expected = expected_sha256_for(&sums, &asset_name)?;
let kigi_home = kigi_home();
let download_dir = kigi_home.join("downloads");
tokio::fs::create_dir_all(&download_dir).await?;
let archive_path = download_dir.join(&asset_name);
let binary_name = format!("kigi-{}-{}", version, platform);
let binary_path = download_dir.join(&binary_name);
eprintln!(" Downloading kigi v{} ({})...", version, target_triple()?);
download_with_progress(&archive_asset.browser_download_url, &archive_path).await?;
// Checksum gate: a corrupt or tampered archive is deleted and never
// extracted, let alone activated.
let actual = sha256_hex_of_file(&archive_path).await?;
if actual != expected {
let _ = tokio::fs::remove_file(&archive_path).await;
anyhow::bail!(
"SHA256 mismatch for {asset_name}: expected {expected}, got {actual}.\n\
Your current version is unchanged."
);
}
// Extract to a unique temp sibling, then publish (chmod +x, atomic
// rename) so a concurrent same-version installer never sees a partial
// or non-executable binary at the final path.
let extract_tmp = unique_temp_sibling(&binary_path, "tmp");
if let Err(e) = extract_kigi_binary(&archive_path, &extract_tmp).await {
let _ = tokio::fs::remove_file(&extract_tmp).await;
let _ = tokio::fs::remove_file(&archive_path).await;
return Err(e);
}
publish_downloaded_artifact(&extract_tmp, &binary_path).await?;
// The archive has served its purpose; the versioned binary is what
// `cleanup_old_downloads` retention manages.
let _ = tokio::fs::remove_file(&archive_path).await;
// Smoke-test: run the binary before activating it. A truncated or
// corrupt extraction is caught here and never becomes the active kigi.
if !smoke_test_binary(&binary_path).await {
let _ = tokio::fs::remove_file(&binary_path).await;
// No prefix: run_install_script's wrap adds "Auto-update failed:".
anyhow::bail!(
"downloaded binary failed to run.\n\
Your current version is unchanged.\n\
To update manually: {}",
manual_install_cmd()
);
}
Ok(VerifiedDownload {
version,
binary_path,
})
}
/// Local activation phase: swap the managed `~/.kigi/bin/kigi` link to the
/// downloaded binary and finish bookkeeping.
async fn activate_verified_download(download: &VerifiedDownload) -> Result<()> {
let kigi_home = kigi_home();
let download_dir = kigi_home.join("downloads");
let bin_dir = kigi_home.join("bin");
tokio::fs::create_dir_all(&bin_dir).await?;
// Atomic swap of ~/.kigi/bin/kigi -> downloaded binary.
let link_path = swap_managed_bin_link(&download.binary_path, &bin_dir).await?;
remove_legacy_links(&bin_dir).await;
eprintln!();
// Clean up old versioned binaries (keeps current + 1 previous).
cleanup_old_downloads(&download_dir, "kigi", &download.version).await;
// Persist installer to config.toml so future runs auto-detect internal.
let _ = config::update_config(|st| {
st.cli.installer = Some("internal".to_string());
})
.await;
// Regenerate shell completions so they reflect the new binary's CLI surface.
// Best-effort: failures are silently ignored (same as the installer).
regenerate_completions(&link_path, &kigi_home).await;
Ok(())
}
/// Regenerate shell completions after a binary update (best-effort).
///
/// Spawns the newly-installed binary with `completions <shell>` for each
/// supported shell and writes the output to the standard completion paths.
/// Failures are silently ignored — completions are a nice-to-have, not a
/// requirement for a successful update.
async fn regenerate_completions(binary: &Path, kigi_home: &Path) {
// Derive $HOME independently — kigi_home may be overridden via KIGI_SHARE_DIR
// env var, so kigi_home.parent() isn't necessarily the user's home dir.
#[allow(deprecated)]
let user_home = std::env::home_dir().unwrap_or_default();
let completions: &[(&str, PathBuf)] = &[
("bash", kigi_home.join("completions/bash/kigi.bash")),
("zsh", kigi_home.join("completions/zsh/_kigi")),
("fish", user_home.join(".config/fish/completions/kigi.fish")),
];
for (shell, dest) in completions {
if let Some(parent) = dest.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let mut cmd = tokio::process::Command::new(binary);
cmd.args(["completions", shell])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
kigi_tools::util::detach_command(&mut cmd);
let Ok(output) = cmd.output().await else {
continue;
};
if output.status.success() && !output.stdout.is_empty() {
let _ = tokio::fs::write(dest, &output.stdout).await;
}
}
}
/// Compute a relative symlink target from `link` to `target`.
///
/// When both paths share a grandparent (e.g. `~/.kigi/bin/kigi` and
/// `~/.kigi/downloads/kigi-0.1.2-linux-x86_64`), returns a relative path
/// like `../downloads/kigi-0.1.2-linux-x86_64`. When they share the same
/// parent directory, returns just the filename. Falls back to the absolute
/// `target` path for any other layout.
///
/// Relative symlinks survive Docker bind-mounts where `~/.kigi/` is mapped
/// into a container with a different `$HOME` (and thus a different absolute
/// prefix).
#[cfg(unix)]
fn relative_symlink_target(target: &Path, link: &Path) -> PathBuf {
let (Some(target_parent), Some(link_parent)) = (target.parent(), link.parent()) else {
return target.to_path_buf();
};
// Same directory — just the filename (e.g. kigi-latest -> kigi-0.1.2-…)
if target_parent == link_parent
&& let Some(name) = target.file_name()
{
return PathBuf::from(name);
}
// Sibling directories — ../target_dir/filename (e.g. bin/kigi -> ../downloads/kigi-…)
if let (Some(tp), Some(lp)) = (target_parent.parent(), link_parent.parent())
&& tp == lp
&& let (Some(dir_name), Some(file_name)) = (target_parent.file_name(), target.file_name())
{
return Path::new("..").join(dir_name).join(file_name);
}
target.to_path_buf()
}
/// Swap `~/.kigi/bin/kigi` to point at `binary_path`. Returns the link path
/// (for [`regenerate_completions`]).
///
/// Unix: atomic symlink swap with relative target (survives Docker
/// bind-mounts of `~/.kigi/`); a failed swap leaves the prior link intact.
/// Windows: [`windows_replace_exe`], which restores the prior binary itself
/// when the replacement copy fails.
async fn swap_managed_bin_link(binary_path: &Path, bin_dir: &Path) -> Result<PathBuf> {
let kigi_name = if cfg!(windows) { "kigi.exe" } else { "kigi" };
let link_path = bin_dir.join(kigi_name);
#[cfg(unix)]
{
let rel_target = relative_symlink_target(binary_path, &link_path);
atomic_symlink_swap(&rel_target, &link_path)
.await
.with_context(|| format!("swapping managed bin link {}", link_path.display()))?;
}
#[cfg(windows)]
{
windows_replace_exe(binary_path, &link_path)
.await
.with_context(|| format!("replacing managed binary {}", link_path.display()))?;
}
#[cfg(not(any(unix, windows)))]
{
// No managed bin layout on this target; no-op.
let _ = binary_path;
}
Ok(link_path)
}
/// Atomically swap a symlink to point to a new target.
///
/// Creates a temporary symlink next to `link_path`, then renames it over the
/// old symlink. This avoids the remove-then-create race where the path
/// briefly doesn't exist, and — crucially — never deletes the old target
/// file. On macOS (especially Apple Silicon), deleting a binary that a
/// running process has mmap'd causes SIGKILL because the kernel can no longer
/// verify the code signature of the executable pages.
#[cfg(unix)]
async fn atomic_symlink_swap(target: &Path, link_path: &Path) -> Result<()> {
// Per-racer temp name: a shared one makes remove_file → symlink racy
// (EEXIST, or ENOENT when another racer renames the link away).
sweep_stale_tmp_links(link_path, STALE_TMP_AGE).await;
let tmp_link = unique_temp_sibling(link_path, "tmp-link");
let _ = tokio::fs::remove_file(&tmp_link).await;
tokio::fs::symlink(target, &tmp_link).await?;
tokio::fs::rename(&tmp_link, link_path).await?;
Ok(())
}
/// Remove `<link>.*.tmp-link` siblings left by a swap that crashed between
/// symlink and rename. Only those older than `max_age` are removed, so a
/// concurrent racer's in-flight link is never deleted out from under it.
#[cfg(unix)]
async fn sweep_stale_tmp_links(link_path: &Path, max_age: Duration) {
let (Some(dir), Some(name)) = (
link_path.parent(),
link_path.file_name().and_then(|n| n.to_str()),
) else {
return;
};
let prefix = format!("{name}.");
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let fname = entry.file_name();
let Some(fname) = fname.to_str() else {
continue;
};
if !fname.starts_with(&prefix) || !fname.ends_with(".tmp-link") {
continue;
}
let stale = tokio::fs::symlink_metadata(entry.path())
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
.is_some_and(|age| age > max_age);
if stale {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
/// Replace an executable that may be locked by a running process (Windows).
///
/// On Windows the kernel prevents writes to a running executable but allows
/// renames. If a direct copy fails with a sharing violation, this renames
/// `dest` aside and copies `src` into the freed path. If the copy then
/// fails, the rename is rolled back to avoid a broken install.
///
/// The aside target is normally `<dest>.old`, but a leftover `.old` can
/// itself still be a running image (the session that was live during the
/// previous update keeps executing the renamed-aside file), and a running
/// image can neither be deleted nor rename-replaced. In that case `dest` is
/// renamed to a unique `<dest>.old.{pid}-{seq}.old` sibling instead, so a
/// locked leftover can never block the update. All `.old` leftovers are
/// swept best-effort at the start of each cycle; still-locked ones survive
/// until a later update runs after those processes exit.
#[cfg(windows)]
async fn windows_replace_exe(src: &Path, dest: &Path) -> Result<()> {
let file_name = dest
.file_name()
.ok_or_else(|| anyhow::anyhow!("destination has no filename: {}", dest.display()))?
.to_string_lossy();
let old = dest.with_file_name(format!("{file_name}.old"));
sweep_old_exe_backups(&old).await;
match tokio::fs::copy(src, dest).await {
Ok(_) => return Ok(()),
// ERROR_SHARING_VIOLATION (32) / ERROR_ACCESS_DENIED (5): exe is
// locked by a running process. Fall through to rename-and-replace.
Err(e) if matches!(e.raw_os_error(), Some(32) | Some(5)) => {
tracing::debug!("exe locked, falling back to rename: {e}");
}
Err(e) => return Err(e.into()),
}
// A .old that survived the sweep is locked; renaming onto it would need
// to delete-replace it and fail, so divert to a guaranteed-free name.
let old_is_free = matches!(
tokio::fs::symlink_metadata(&old).await,
Err(e) if e.kind() == std::io::ErrorKind::NotFound
);
let mut aside = if old_is_free {
old.clone()
} else {
let diverted = unique_temp_sibling(&old, "old");
tracing::debug!(
"stale {} is locked; diverting aside to {}",
old.display(),
diverted.display()
);
diverted
};
// Move the locked file aside, then copy the new binary into place.
let mut rename_result = tokio::fs::rename(dest, &aside).await;
// Pid reuse can collide a diverted name with a dead updater's
// still-locked leftover, and a racer can occupy a just-checked-free
// .old; a fresh unique sibling clears both tails (3 attempts total).
for _ in 0..2 {
match &rename_result {
Err(e) if matches!(e.raw_os_error(), Some(32) | Some(5)) => {
tracing::debug!(
"rename aside to {} failed; retrying with a fresh name: {e}",
aside.display()
);
aside = unique_temp_sibling(&old, "old");
rename_result = tokio::fs::rename(dest, &aside).await;
}
_ => break,
}
}
rename_result.map_err(|e| {
anyhow::anyhow!(
"cannot rename locked executable {}: {e}\n\
Close all running kigi sessions and retry.",
dest.display(),
)
})?;
match tokio::fs::copy(src, dest).await {
Ok(_) => Ok(()),
Err(e) => {
// Rollback: restore the old binary so the install isn't broken.
let _ = tokio::fs::rename(&aside, dest).await;
Err(e.into())
}
}
}
/// Best-effort removal of `<exe>.old` plus the unique
/// `<exe>.old.{pid}-{seq}.old` asides accumulated by prior update cycles.
/// Locked ones (still-running images) survive and are collected by a later
/// update once those processes exit. The `<exe>.old` prefix keeps the sweep
/// away from `<exe>` itself, other executables' leftovers, and the `.tmp`
/// sibling shapes.
///
/// Unlike `sweep_stale_tmp_links` there is deliberately no `max_age` gate:
/// rename preserves mtime, so a racer's seconds-old aside already looks
/// days old and age cannot distinguish it; in-use asides survive deletion
/// by being locked; and deleting a racer's fresh unlocked aside (its
/// rollback source while both racers converge on the same dest) is the
/// accepted lock-free residual race (see `tmp_download_path`).
#[cfg(windows)]
async fn sweep_old_exe_backups(old: &Path) {
let _ = tokio::fs::remove_file(old).await;
let (Some(dir), Some(old_name)) = (old.parent(), old.file_name().and_then(|n| n.to_str()))
else {
return;
};
let prefix = format!("{old_name}.");
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if name.starts_with(&prefix) && name.ends_with(".old") {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
/// Best-effort cleanup of old versioned binaries for a given binary name.
///
/// Keeps the current version plus one previous version (in case a process is
/// still running the old binary and hasn't fully loaded all pages yet —
/// deleting it on macOS causes SIGKILL because the kernel can no longer
/// verify the code signature).
///
/// `bin_prefix` is the binary name prefix, e.g. `"kigi"`. Files must match
/// `{bin_prefix}-{digit}*` to be considered versioned binaries (this avoids
/// `kigi-*` matching `kigi-latest` or differently-suffixed siblings).
///
/// Temporary/partial files (containing `.tmp`) are deleted only once they
/// are **stale** (mtime older than [`STALE_TMP_AGE`]). A fresh `.tmp` may be
/// a concurrent updater's in-flight download — the same-instant race the
/// lock-free design accepts — and deleting it out from under that updater
/// would make its atomic rename fail.
async fn cleanup_old_downloads(dir: &Path, bin_prefix: &str, current_version: &str) {
let prefix = format!("{}-", bin_prefix);
let current_semver = match semver::Version::parse(current_version) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
"cleanup_old_downloads: invalid current version '{}': {}",
current_version,
e
);
return;
}
};
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(rd) => rd,
Err(e) => {
tracing::warn!(
"cleanup_old_downloads: failed to read {}: {}",
dir.display(),
e
);
return;
}
};
let mut versioned: Vec<(semver::Version, String)> = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if !name.starts_with(&prefix) {
continue;
}
// Temp/partial files: sweep only STALE ones. A fresh `.tmp` may be a
// concurrent updater's in-flight download — deleting it would make
// that updater's atomic rename fail with ENOENT.
if name.contains(".tmp") {
let stale = match entry.metadata().await.and_then(|m| m.modified()) {
Ok(modified) => std::time::SystemTime::now()
.duration_since(modified)
.map(|age| age > STALE_TMP_AGE)
// Future mtime (clock skew): can't tell — leave it.
.unwrap_or(false),
// Unknown mtime: leave it; it is swept once readable+old.
Err(_) => false,
};
if stale && let Err(e) = tokio::fs::remove_file(entry.path()).await {
tracing::warn!("failed to remove stale temp file {}: {}", name, e);
}
continue;
}
// Skip symlinks (e.g. kigi-latest).
if let Ok(ft) = entry.file_type().await
&& ft.is_symlink()
{
continue;
}
// The suffix after the prefix must start with a digit to be a versioned
// binary (avoids `kigi-latest` and other non-versioned siblings).
let suffix = &name[prefix.len()..];
if !suffix.starts_with(|c: char| c.is_ascii_digit()) {
continue;
}
// Extract the version portion via the shared parser (handles the
// managed `kigi-0.1.0-macos-aarch64`, pre-release, and bare
// `kigi-0.1.0` layouts — see `version_from_versioned_binary_name`).
let Some(ver_str) = crate::version::version_from_versioned_binary_name(&name, bin_prefix)
else {
continue;
};
if let Ok(v) = semver::Version::parse(&ver_str) {
// Skip the current version — never delete it.
if v == current_semver {
continue;
}
versioned.push((v, name));
}
}
// Sort descending by version so the newest is first.
versioned.sort_by(|a, b| b.0.cmp(&a.0));
// Keep the most recent old version (index 0), delete the rest (index 1+).
for (_, name) in versioned.iter().skip(1) {
let path = dir.join(name);
// Same freshness guard as the `.tmp` sweep: a versioned binary
// written moments ago is likely a concurrent installer's
// just-renamed download (its symlink swap hasn't happened yet) —
// deleting it would leave that installer's swap pointing at
// nothing. Old binaries from previous releases are days old.
let fresh = tokio::fs::metadata(&path)
.await
.and_then(|m| m.modified())
.ok()
.and_then(|modified| std::time::SystemTime::now().duration_since(modified).ok())
.is_some_and(|age| age <= STALE_TMP_AGE);
if fresh {
continue;
}
if let Err(e) = tokio::fs::remove_file(&path).await {
tracing::warn!("failed to remove old binary {}: {}", name, e);
}
}
}
pub async fn apply_channel_switch(channel_switch: Option<&str>, update_config: &mut UpdateConfig) {
if let Some(ch) = channel_switch
&& update_config.channel != ch
{
let _ = config::update_config(|st| {
st.cli.channel = Some(ch.to_string());
})
.await;
update_config.channel = ch.to_string();
eprintln!("Switched to {} channel.", ch);
}
}
/// Run the `kigi update` command. Returns `Ok(Some(version))` when the target
/// version is present on disk afterwards — either installed by this call or
/// found already installed (e.g. by a concurrent background download); returns
/// `Ok(None)` when there is no installer or no applicable target. Callers use
/// the returned version to signal a running leader to relaunch onto the new
/// binary (see the pager's post-update leader relaunch) — that signal must
/// fire even when the download itself was skipped, so a stale leader still
/// picks up a binary someone else installed.
pub async fn run_update(
force: bool,
pinned_version: Option<&str>,
channel_switch: Option<&str>,
update_config: &mut UpdateConfig,
) -> Result<Option<String>> {
apply_channel_switch(channel_switch, update_config).await;
let installer = match get_installer().await {
Some(i) => i,
None => {
eprintln!("Auto-update is not available for manual installations.");
return Ok(None);
}
};
// Persist installer if not already saved
let cfg = config::load_config().await;
if cfg.cli.installer.is_none() {
let _ = config::update_config(|st| {
st.cli.installer = Some(installer.to_string());
})
.await;
}
let current_version = get_installed_kigi_version();
// When --version is given, skip the latest-version check and install directly
if let Some(version) = pinned_version {
if let Err(e) = crate::minimum_version::check_install_target(version) {
anyhow::bail!("{e}");
}
eprintln!(
"Installing Kigi {} (current: {})...",
version, current_version
);
eprintln!();
run_install_script(installer, Some(version), update_config).await?;
refresh_deployment_config().await;
if let Err(e) = config::update_config(|st| {
st.cli.auto_update = Some(false);
})
.await
{
tracing::warn!("Failed to persist auto_update=false for pinned install: {e}");
}
eprintln!(" ✓ kigi v{} installed successfully!", version);
eprintln!(" Please restart Kigi.");
return Ok(Some(version.to_string()));
}
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template(" {spinner:.cyan} Checking for updates...")
.unwrap(),
);
pb.enable_steady_tick(Duration::from_millis(100));
let latest_version = fetch_latest_version(update_config).await?;
pb.finish_and_clear();
let install_target = match crate::minimum_version::apply_floor(&latest_version) {
Ok(t) => t,
Err(e) => anyhow::bail!("{e}"),
};
if install_target != latest_version {
eprintln!(
"Latest available is {} but the configured minimum is higher; \
installing {} instead.",
latest_version, install_target
);
}
// What's on disk wins over this process's compiled-in version: a
// concurrent or earlier updater (TUI background download, leader hourly
// checker) may already have installed the target, in which case there is
// nothing to download.
let effective_current =
disk_version_for_installer(installer).unwrap_or_else(|| current_version.clone());
if !force {
match needs_update(
&effective_current,
&install_target,
&update_config.channel,
installer_allows_downgrade(installer),
) {
Some(true) => {}
Some(false) => {
// Explicit channel switch (--stable / --alpha) with a
// different target version: install even though the current
// version is "newer" by semver. This handles switching from
// alpha 0.2.X back to stable 0.1.220 where 0.2.X > 0.1.220.
if channel_switch.is_some() && effective_current != install_target {
// Fall through to install
} else {
let stable_ptr = try_fetch_stable_version().await;
write_version_cache(&install_target, stable_ptr.as_deref()).await;
eprintln!("Already up to date ({}).", effective_current);
// Retry if a prior sync failed.
refresh_deployment_config().await;
// The target is on disk even though this call installed
// nothing — report it so the caller still signals stale
// leaders to relaunch onto it (signalling is directional
// and skips leaders already at/after this version).
return Ok(Some(install_target));
}
}
None => {
// Distinguish parse failure from unsupported channel.
let parse_ok = semver::Version::parse(&effective_current).is_ok()
&& semver::Version::parse(&install_target).is_ok();
if parse_ok {
anyhow::bail!(
"Unsupported release channel '{}' (current={}, target={}). \
Supported channels: stable, alpha, enterprise. \
Use --stable or --alpha to override, or set [cli] channel in config.toml.",
update_config.channel,
effective_current,
install_target
);
} else {
anyhow::bail!(
"Failed to parse versions (current={}, target={})",
effective_current,
install_target
);
}
}
}
}
let target_version = if force
&& !needs_update(
&effective_current,
&install_target,
&update_config.channel,
installer_allows_downgrade(installer),
)
.unwrap_or(true)
{
eprintln!(
"Forcing reinstall of Kigi {} (already up to date)",
effective_current
);
&effective_current
} else {
eprintln!("Updating Kigi {}{}", effective_current, install_target);
&install_target
};
eprintln!();
run_install_script(installer, Some(target_version), update_config).await?;
// Fetch the stable version now so the new binary has it immediately
// for channel_label() display, rather than waiting for the next
// TTL-gated update check (~30 min).
let stable_ptr = try_fetch_stable_version().await;
write_version_cache(target_version, stable_ptr.as_deref()).await;
refresh_deployment_config().await;
eprintln!(" ✓ kigi v{} installed successfully!", target_version);
if !force && std::env::var_os("KIGI_AUTO_UPDATE").is_none() {
eprintln!(" Please restart Kigi.");
}
Ok(Some(target_version.to_string()))
}
/// Refresh managed config post-update (best-effort, staleness-gated), for
/// deployment-key and team principals alike.
async fn refresh_deployment_config() {
if !kigi_shell::managed_config::has_principal() {
return;
}
if !kigi_shell::managed_config::is_fetch_enabled() {
return;
}
// Clear a logged-out team's files before deciding to fetch (mirrors the loop).
kigi_shell::managed_config::clear_orphan();
if !kigi_shell::config::is_managed_config_stale_for(
&kigi_shell::managed_config::current_serving_identity(),
) {
return;
}
match kigi_shell::managed_config::sync().await {
Ok(true) => eprintln!(" Applied managed configuration."),
Ok(false) => tracing::debug!("no managed configuration to apply"),
// Auth issues aren't actionable mid-update: quiet here, loud on `kigi setup`.
Err(e) if e.is_auth_rejection() => tracing::debug!("managed config not applied: {e}"),
Err(e) if e.is_retryable() => {
tracing::debug!("managed config refresh failed: {e}");
eprintln!(" Couldn't apply managed configuration. Run `kigi setup` to retry.");
}
Err(e) => eprintln!(" Couldn't apply managed configuration. {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tmp_download_path_is_unique_per_version_and_per_attempt() {
// `with_extension("tmp")` would collapse every 0.1.x versioned name
// onto a single `kigi-0.1.tmp`; the helper must keep distinct
// versions distinct AND make repeated attempts (same process, e.g.
// concurrent tokio tasks) unique.
let dest_181 = Path::new("/home/u/.kigi/downloads/kigi-0.1.181-linux-x86_64");
let dest_182 = Path::new("/home/u/.kigi/downloads/kigi-0.1.182-linux-x86_64");
let a = tmp_download_path(dest_181);
let b = tmp_download_path(dest_182);
assert_ne!(a, b, "different versions must not share a temp file");
let a2 = tmp_download_path(dest_181);
assert_ne!(
a, a2,
"two attempts for the same dest must not share a temp file"
);
let name = a.file_name().unwrap().to_string_lossy().to_string();
assert!(
name.starts_with("kigi-0.1.181-linux-x86_64."),
"full versioned name must be preserved: {name}"
);
assert!(
name.ends_with(".tmp") && name.contains(&std::process::id().to_string()),
"temp name must embed the PID and end in .tmp (cleanup sweeps *.tmp*): {name}"
);
assert_eq!(
a.parent(),
Path::new("/home/u/.kigi/downloads").into(),
"temp file must stay in the destination directory for atomic rename"
);
}
// ──────────────────────────────────────────────────────────────────────
// needs_update — channel/upgrade/downgrade semantics
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_needs_update_matrix() {
// (current, target, channel, allow_downgrade, expected)
let cases: &[(&str, &str, &str, bool, Option<bool>)] = &[
// Same version: never an update, regardless of allow_downgrade.
("0.1.141", "0.1.141", "stable", false, Some(false)),
("0.2.5", "0.2.5", "stable", true, Some(false)),
("0.2.5", "0.2.5", "alpha", true, Some(false)),
// Plain upgrades.
("0.1.140", "0.1.141", "stable", false, Some(true)),
("0.2.5", "0.2.7", "stable", true, Some(true)),
("0.2.5", "0.2.7", "alpha", false, Some(true)),
("0.1.140", "0.1.999", "stable", false, Some(true)),
("0.1.999", "0.2.0", "stable", false, Some(true)),
("99.99.99", "100.0.0", "stable", false, Some(true)),
("0.0.0", "0.0.1", "stable", false, Some(true)),
// Downgrades: only when the installer allows them.
("0.1.141", "0.1.140", "stable", false, Some(false)),
("0.2.7", "0.2.5", "stable", true, Some(true)),
("0.2.7", "0.2.5", "alpha", true, Some(true)),
("0.1.207", "0.1.206", "enterprise", true, Some(true)),
("2.0.0", "1.99.99", "stable", true, Some(true)),
("2.0.0", "1.99.99", "stable", false, Some(false)),
// Pre-release targets rejected on stable/enterprise, even for
// rollbacks.
("0.1.139", "0.1.140-alpha.1", "stable", false, Some(false)),
("0.2.7", "0.2.5-alpha.1", "stable", true, Some(false)),
("0.2.7", "0.2.5-alpha.1", "enterprise", true, Some(false)),
(
"0.1.150-alpha.1",
"0.1.151-alpha.1",
"stable",
false,
Some(false),
),
// Pre-release CURRENT on stable/enterprise: force-install a
// release even if semver-lower, independent of allow_downgrade.
("0.1.149-alpha.1", "0.1.148", "stable", false, Some(true)),
("0.1.149-alpha.1", "0.1.148", "stable", true, Some(true)),
(
"0.1.206-alpha.3",
"0.1.206",
"enterprise",
false,
Some(true),
),
// Alpha channel follows raw semver.
("0.1.140-alpha.8", "0.1.140", "alpha", false, Some(true)),
(
"0.1.148-alpha.1",
"0.1.148-alpha.3",
"alpha",
false,
Some(true),
),
(
"0.1.148-alpha.3",
"0.1.148-alpha.2",
"alpha",
false,
Some(false),
),
(
"0.1.148-alpha.3",
"0.1.148-alpha.2",
"alpha",
true,
Some(true),
),
("0.1.150-alpha.99", "0.1.150", "alpha", false, Some(true)),
(
"0.1.150-alpha.5",
"0.1.150-beta.1",
"alpha",
false,
Some(true),
),
("0.1.140", "0.1.139-alpha.5", "alpha", false, Some(false)),
// Enterprise behaves like stable for upgrades.
("0.1.205", "0.1.206", "enterprise", false, Some(true)),
("0.1.207", "0.1.206", "enterprise", false, Some(false)),
(
"0.1.205",
"0.1.206-alpha.1",
"enterprise",
false,
Some(false),
),
// Parse failures and unknown channels → None.
("not-a-version", "0.1.141", "stable", false, None),
("0.1.141", "garbage", "stable", false, None),
("garbage", "0.1.141", "alpha", false, None),
("", "0.1.141", "stable", false, None),
("0.1.141", "", "stable", false, None),
(" 0.1.141", "0.1.142", "stable", false, None),
("0.1", "0.1.141", "stable", false, None),
("0.1.140", "0.1.141", "beta", false, None),
("0.1.140", "0.1.141", "beta", true, None),
("0.1.140", "0.1.141", "STABLE", false, None),
("0.1.140", "0.1.141", "Stable", false, None),
("0.1.140", "0.1.141", "", false, None),
];
for (current, target, channel, allow_downgrade, expected) in cases {
assert_eq!(
needs_update(current, target, channel, *allow_downgrade),
*expected,
"needs_update({current:?}, {target:?}, {channel:?}, {allow_downgrade})"
);
}
}
#[test]
fn test_needs_update_with_build_metadata_uses_semver_crate_ordering() {
// SUBTLE: per the semver SPEC, build metadata (after `+`) MUST be
// ignored when determining version precedence. However the `semver`
// crate's `PartialOrd` impl compares build metadata lexicographically
// for differing values. So `0.1.141+xyz > 0.1.141+abc` returns true
// here even though spec-wise they are equal.
//
// This means the release pipeline MUST NOT publish multiple builds of
// the same version differing only in build metadata, or auto-update
// will bounce users between them. The test locks in the surprising
// behavior so it can't change silently.
assert_eq!(
needs_update("0.1.141+abc", "0.1.141+xyz", "stable", false),
Some(true),
"semver crate orders by build metadata lexicographically (contra spec)"
);
assert_eq!(
needs_update("0.1.141", "0.1.141+abc", "stable", false),
Some(true)
);
}
// ──────────────────────────────────────────────────────────────────────
// installer_allows_downgrade
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_installer_allows_downgrade_internal_only() {
assert!(installer_allows_downgrade("internal"));
assert!(!installer_allows_downgrade("unknown"));
assert!(!installer_allows_downgrade(""));
assert!(!installer_allows_downgrade("npm"));
assert!(!installer_allows_downgrade("homebrew"));
}
// ──────────────────────────────────────────────────────────────────────
// atomic_symlink_swap
// ──────────────────────────────────────────────────────────────────────
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_creates_new_symlink() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("binary-v1");
std::fs::write(&target, "v1").unwrap();
let link = dir.path().join("kigi");
// No existing symlink — should create one.
atomic_symlink_swap(&target, &link).await.unwrap();
assert!(link.is_symlink());
assert_eq!(std::fs::read_link(&link).unwrap(), target);
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v1");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_replaces_existing_and_preserves_old_target() {
let dir = tempfile::tempdir().unwrap();
let target_v1 = dir.path().join("binary-v1");
std::fs::write(&target_v1, "v1-content").unwrap();
let target_v2 = dir.path().join("binary-v2");
std::fs::write(&target_v2, "v2-content").unwrap();
let link = dir.path().join("kigi");
std::os::unix::fs::symlink(&target_v1, &link).unwrap();
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v1-content");
atomic_symlink_swap(&target_v2, &link).await.unwrap();
assert!(link.is_symlink());
assert_eq!(std::fs::read_link(&link).unwrap(), target_v2);
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2-content");
// The old target file must still exist on disk — this is the key
// property that prevents SIGKILL on macOS. Running processes that
// have binary-v1 mmap'd can continue to page-fault from it.
assert!(target_v1.exists(), "old binary must not be deleted");
assert_eq!(std::fs::read_to_string(&target_v1).unwrap(), "v1-content");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_replaces_regular_file() {
// If the canonical path is a regular file (from an old non-symlink
// installation), the swap should still work by replacing it.
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("binary-v2");
std::fs::write(&target, "v2").unwrap();
let link = dir.path().join("kigi");
// Simulate an old installation where kigi is a regular file.
std::fs::write(&link, "old-binary").unwrap();
atomic_symlink_swap(&target, &link).await.unwrap();
assert!(link.is_symlink());
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_succeeds_despite_leftover_tmp_link() {
// A leftover .tmp-link from a crashed swap must not block a new swap:
// unique per-racer temp names mean no collision.
let dir = tempfile::tempdir().unwrap();
let target_v1 = dir.path().join("binary-v1");
std::fs::write(&target_v1, "v1").unwrap();
let target_v2 = dir.path().join("binary-v2");
std::fs::write(&target_v2, "v2").unwrap();
let link = dir.path().join("kigi");
std::os::unix::fs::symlink(&target_v1, &link).unwrap();
std::os::unix::fs::symlink(&target_v1, link.with_extension("tmp-link")).unwrap();
atomic_symlink_swap(&target_v2, &link).await.unwrap();
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2");
}
#[cfg(unix)]
#[tokio::test]
async fn test_sweep_stale_tmp_links_removes_stale_keeps_fresh_and_active() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("binary-v1");
std::fs::write(&target, "v1").unwrap();
let link = dir.path().join("kigi");
std::os::unix::fs::symlink(&target, &link).unwrap();
// Old- and new-style leftover temp links.
let leftover_old = dir.path().join("kigi.tmp-link");
let leftover_new = dir.path().join("kigi.123-0.tmp-link");
std::os::unix::fs::symlink(&target, &leftover_old).unwrap();
std::os::unix::fs::symlink(&target, &leftover_new).unwrap();
// max_age = ZERO: every leftover is stale and removed; the active
// `kigi` link (no `.tmp-link` suffix) is untouched.
sweep_stale_tmp_links(&link, Duration::ZERO).await;
assert!(!leftover_old.exists() && !leftover_new.exists());
assert!(link.is_symlink(), "active link must be preserved");
// A fresh leftover under a real max_age is preserved — it could be a
// concurrent racer's in-flight link.
let fresh = dir.path().join("kigi.999-9.tmp-link");
std::os::unix::fs::symlink(&target, &fresh).unwrap();
sweep_stale_tmp_links(&link, Duration::from_secs(3600)).await;
assert!(fresh.exists(), "fresh tmp-link must be preserved");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_multiple_sequential_swaps() {
// Simulate v1 -> v2 -> v3 -> v4 sequential swaps.
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("kigi");
for i in 1..=4 {
let target = dir.path().join(format!("binary-v{}", i));
std::fs::write(&target, format!("content-v{}", i)).unwrap();
atomic_symlink_swap(&target, &link).await.unwrap();
assert!(link.is_symlink());
assert_eq!(
std::fs::read_to_string(&link).unwrap(),
format!("content-v{}", i)
);
}
// All old binaries should still be on disk.
for i in 1..=4 {
let target = dir.path().join(format!("binary-v{}", i));
assert!(target.exists(), "binary-v{} should still exist", i);
}
// No temp files should remain.
let tmp_link = link.with_extension("tmp-link");
assert!(!tmp_link.exists(), "no temp link should remain");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_broken_symlink_target() {
// If the current symlink is broken (target deleted externally),
// the swap should still succeed.
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("kigi");
// Create a broken symlink — points to a file that doesn't exist.
std::os::unix::fs::symlink(dir.path().join("deleted-binary"), &link).unwrap();
assert!(link.is_symlink());
assert!(!link.exists(), "broken symlink should not 'exist'");
// New target to swap to.
let target = dir.path().join("binary-v2");
std::fs::write(&target, "v2").unwrap();
atomic_symlink_swap(&target, &link).await.unwrap();
assert!(link.is_symlink());
assert!(link.exists(), "symlink should now resolve");
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2");
}
#[cfg(unix)]
#[test]
fn test_relative_symlink_target_layouts() {
// bin/kigi -> ../downloads/kigi-0.1.2 (sibling directories)
let target = Path::new("/home/alice/.kigi/downloads/kigi-0.1.2");
let link = Path::new("/home/alice/.kigi/bin/kigi");
assert_eq!(
relative_symlink_target(target, link),
PathBuf::from("../downloads/kigi-0.1.2")
);
// downloads/kigi-latest -> kigi-0.1.2 (same directory)
let link = Path::new("/home/alice/.kigi/downloads/kigi-latest");
assert_eq!(
relative_symlink_target(target, link),
PathBuf::from("kigi-0.1.2")
);
// /usr/local/bin/kigi -> absolute (different grandparents)
let link = Path::new("/usr/local/bin/kigi");
assert_eq!(
relative_symlink_target(target, link),
PathBuf::from("/home/alice/.kigi/downloads/kigi-0.1.2")
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_relative_symlink_survives_directory_move() {
// Simulates Docker bind-mount: create ~/.kigi/ layout at path A,
// then copy it to path B and verify the symlink still resolves.
let dir = tempfile::tempdir().unwrap();
let alice = dir.path().join("alice").join(".kigi");
let alice_downloads = alice.join("downloads");
let alice_bin = alice.join("bin");
std::fs::create_dir_all(&alice_downloads).unwrap();
std::fs::create_dir_all(&alice_bin).unwrap();
std::fs::write(alice_downloads.join("kigi-0.1.2"), "binary-content").unwrap();
let rel_target = Path::new("../downloads/kigi-0.1.2");
let link = alice_bin.join("kigi");
atomic_symlink_swap(rel_target, &link).await.unwrap();
assert_eq!(std::fs::read_to_string(&link).unwrap(), "binary-content");
// "Bind-mount" to bob: copy the entire .kigi tree.
let bob_home = dir.path().join("bob");
std::fs::create_dir_all(&bob_home).unwrap();
let bob = bob_home.join(".kigi");
let copy_status = std::process::Command::new("cp")
.args(["-a", alice.to_str().unwrap(), bob.to_str().unwrap()])
.status()
.unwrap();
assert!(copy_status.success());
let bob_link = bob.join("bin").join("kigi");
assert!(bob_link.is_symlink());
assert_eq!(
std::fs::read_link(&bob_link).unwrap(),
PathBuf::from("../downloads/kigi-0.1.2"),
"symlink target should be relative"
);
assert_eq!(
std::fs::read_to_string(&bob_link).unwrap(),
"binary-content",
"relative symlink should resolve at the new path"
);
}
// ──────────────────────────────────────────────────────────────────────
// cleanup_old_downloads
// ──────────────────────────────────────────────────────────────────────
/// Backdate a file's mtime past [`STALE_TMP_AGE`] so cleanup treats it
/// as an abandoned download / genuinely old binary.
fn make_stale(path: &Path) {
let old = std::time::SystemTime::now() - (STALE_TMP_AGE + Duration::from_secs(60));
let f = std::fs::File::options().write(true).open(path).unwrap();
f.set_times(std::fs::FileTimes::new().set_modified(old))
.unwrap();
}
/// Backdate every file in `dir`. Cleanup deliberately never deletes a
/// freshly-written binary or temp file (it may belong to a concurrent
/// in-flight install), so retention-policy tests must age their fixtures
/// to look like real leftovers from previous releases.
fn make_all_stale(dir: &Path) {
for entry in std::fs::read_dir(dir).unwrap() {
let p = entry.unwrap().path();
if p.is_file() {
make_stale(&p);
}
}
}
#[tokio::test]
async fn test_cleanup_old_downloads_keeps_current_plus_one() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
for v in ["0.1.140", "0.1.141", "0.1.142", "0.1.143", "0.1.144"] {
std::fs::write(d.join(format!("kigi-{}-macos-aarch64", v)), v).unwrap();
}
std::fs::write(d.join("kigi-0.1.145-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "kigi", "0.1.145").await;
assert!(d.join("kigi-0.1.145-macos-aarch64").exists(), "current");
assert!(d.join("kigi-0.1.144-macos-aarch64").exists(), "N-1");
for v in ["0.1.140", "0.1.141", "0.1.142", "0.1.143"] {
assert!(
!d.join(format!("kigi-{}-macos-aarch64", v)).exists(),
"{v} should be deleted"
);
}
}
#[tokio::test]
async fn test_cleanup_old_downloads_removes_stale_tmp_keeps_fresh_tmp() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Stale tmp: abandoned by a crashed updater — swept.
std::fs::write(d.join("kigi-0.1.140-macos-aarch64.tmp"), "partial").unwrap();
make_stale(&d.join("kigi-0.1.140-macos-aarch64.tmp"));
// Fresh tmp: a concurrent updater's in-flight download — kept, or
// its atomic rename would fail with ENOENT.
std::fs::write(d.join("kigi-0.1.142-macos-aarch64.77-0.tmp"), "inflight").unwrap();
std::fs::write(d.join("kigi-0.1.141-macos-aarch64"), "current").unwrap();
cleanup_old_downloads(d, "kigi", "0.1.141").await;
assert!(
!d.join("kigi-0.1.140-macos-aarch64.tmp").exists(),
"stale tmp cleaned up"
);
assert!(
d.join("kigi-0.1.142-macos-aarch64.77-0.tmp").exists(),
"fresh in-flight tmp must NOT be swept"
);
assert!(
d.join("kigi-0.1.141-macos-aarch64").exists(),
"current kept"
);
}
#[tokio::test]
async fn test_cleanup_old_downloads_keeps_fresh_versioned_binary() {
// A versioned binary written moments ago may be a concurrent
// installer's just-renamed download whose symlink swap hasn't
// happened yet — even when the retention policy would otherwise
// delete it, it must survive until it ages.
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
for v in ["0.1.138", "0.1.139", "0.1.140"] {
std::fs::write(d.join(format!("kigi-{v}-macos-aarch64")), v).unwrap();
}
std::fs::write(d.join("kigi-0.1.141-macos-aarch64"), "current").unwrap();
make_all_stale(d);
// .138 is re-written NOW — simulating a racer that just renamed its
// download into place (e.g. a rollback install racing an upgrade).
std::fs::write(d.join("kigi-0.1.138-macos-aarch64"), "in-flight").unwrap();
cleanup_old_downloads(d, "kigi", "0.1.141").await;
assert!(d.join("kigi-0.1.141-macos-aarch64").exists(), "current");
assert!(d.join("kigi-0.1.140-macos-aarch64").exists(), "N-1 kept");
assert!(
d.join("kigi-0.1.138-macos-aarch64").exists(),
"fresh just-renamed binary must NOT be deleted"
);
assert!(
!d.join("kigi-0.1.139-macos-aarch64").exists(),
"genuinely old binary still swept"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_cleanup_old_downloads_skips_symlinks() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// kigi-latest is a symlink — must be skipped.
let target = d.join("kigi-0.1.141-macos-aarch64");
std::fs::write(&target, "current").unwrap();
std::os::unix::fs::symlink(&target, d.join("kigi-latest")).unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "kigi", "0.1.141").await;
assert!(
d.join("kigi-latest").exists(),
"symlink must not be deleted"
);
assert!(target.exists(), "current must not be deleted");
}
#[tokio::test]
async fn test_cleanup_old_downloads_version_prefix_collision() {
// Regression: version "0.1.14" must not protect "0.1.140", "0.1.141".
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("kigi-0.1.14-macos-aarch64"), "current").unwrap();
std::fs::write(d.join("kigi-0.1.140-macos-aarch64"), "old-140").unwrap();
std::fs::write(d.join("kigi-0.1.141-macos-aarch64"), "old-141").unwrap();
std::fs::write(d.join("kigi-0.1.13-macos-aarch64"), "old-13").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "kigi", "0.1.14").await;
assert!(d.join("kigi-0.1.14-macos-aarch64").exists(), "current");
assert!(
d.join("kigi-0.1.141-macos-aarch64").exists(),
"N-1 is 0.1.141"
);
assert!(!d.join("kigi-0.1.140-macos-aarch64").exists());
assert!(!d.join("kigi-0.1.13-macos-aarch64").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_alpha_and_mixed_versions() {
// Pre-release names parse whole, and semver ordering decides N-1.
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("kigi-0.1.148-macos-aarch64"), "stable-148").unwrap();
std::fs::write(d.join("kigi-0.1.149-alpha.1-macos-aarch64"), "alpha-149").unwrap();
std::fs::write(d.join("kigi-0.1.149-macos-aarch64"), "stable-149").unwrap();
std::fs::write(d.join("kigi-0.1.150-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "kigi", "0.1.150").await;
assert!(d.join("kigi-0.1.150-macos-aarch64").exists(), "current");
// Newest old is 0.1.149 stable (semver: 0.1.149 > 0.1.149-alpha.1).
assert!(
d.join("kigi-0.1.149-macos-aarch64").exists(),
"N-1 is stable 0.1.149"
);
assert!(!d.join("kigi-0.1.149-alpha.1-macos-aarch64").exists());
assert!(!d.join("kigi-0.1.148-macos-aarch64").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_ignores_non_versioned_and_unrelated_files() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("kigi-latest"), "alias").unwrap();
std::fs::write(d.join("kigi-9garbage-macos-aarch64"), "junk").unwrap();
std::fs::write(d.join("README.md"), "readme").unwrap();
std::fs::write(d.join("other-tool-0.1.0"), "other").unwrap();
std::fs::write(d.join("kigi-0.1.140-macos-aarch64"), "v140").unwrap();
std::fs::write(d.join("kigi-0.1.141-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "kigi", "0.1.141").await;
assert!(d.join("kigi-latest").exists());
assert!(
d.join("kigi-9garbage-macos-aarch64").exists(),
"unparseable file must be ignored, not deleted"
);
assert!(d.join("README.md").exists());
assert!(d.join("other-tool-0.1.0").exists());
assert!(d.join("kigi-0.1.140-macos-aarch64").exists(), "N-1 kept");
}
#[tokio::test]
async fn test_cleanup_old_downloads_invalid_current_version_is_no_op() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("kigi-0.1.140-macos-aarch64"), "v140").unwrap();
std::fs::write(d.join("kigi-0.1.141-macos-aarch64"), "v141").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "kigi", "not-a-version").await;
assert!(d.join("kigi-0.1.140-macos-aarch64").exists());
assert!(d.join("kigi-0.1.141-macos-aarch64").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_missing_dir_no_panic() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
cleanup_old_downloads(&missing, "kigi", "0.1.141").await;
}
#[tokio::test]
async fn test_cleanup_old_downloads_multiplatform_in_same_dir() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Same version, multiple platforms: both are "current" via the
// version equality check.
std::fs::write(d.join("kigi-0.1.141-macos-aarch64"), "mac").unwrap();
std::fs::write(d.join("kigi-0.1.141-linux-x86_64"), "linux").unwrap();
std::fs::write(d.join("kigi-0.1.140-macos-aarch64"), "old-mac").unwrap();
std::fs::write(d.join("kigi-0.1.139-macos-aarch64"), "older-mac").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "kigi", "0.1.141").await;
assert!(d.join("kigi-0.1.141-macos-aarch64").exists());
assert!(d.join("kigi-0.1.141-linux-x86_64").exists());
assert!(d.join("kigi-0.1.140-macos-aarch64").exists());
assert!(!d.join("kigi-0.1.139-macos-aarch64").exists());
}
// ──────────────────────────────────────────────────────────────────────
// reinstall_hint / manual_install_cmd
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_reinstall_hint_points_at_repo_install_script() {
let hint = reinstall_hint("internal");
if cfg!(windows) {
assert!(hint.contains("irm"), "should suggest irm install: {hint}");
assert!(
hint.contains("ZacharyZhang-NY/Kigi-CLI/main/install.ps1"),
"should reference the repo's install.ps1: {hint}"
);
} else {
assert!(hint.contains("curl"), "should suggest curl install: {hint}");
assert!(
hint.contains("ZacharyZhang-NY/Kigi-CLI/main/install.sh"),
"should reference the repo's install.sh: {hint}"
);
}
// Unknown installers fall back to the same hint.
assert_eq!(reinstall_hint("homebrew"), hint);
assert_eq!(reinstall_hint(""), hint);
}
// ──────────────────────────────────────────────────────────────────────
// Asset naming: targets → release asset names
// ──────────────────────────────────────────────────────────────────────
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
#[test]
fn test_detect_platform_matches_compile_time_cfg() {
let (os, arch) = detect_platform().unwrap();
if cfg!(target_os = "macos") {
assert_eq!(os, "macos");
}
if cfg!(target_os = "linux") {
assert_eq!(os, "linux");
}
if cfg!(target_os = "windows") {
assert_eq!(os, "windows");
}
if cfg!(target_arch = "x86_64") {
assert_eq!(arch, "x86_64");
}
if cfg!(target_arch = "aarch64") {
assert_eq!(arch, "aarch64");
}
}
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
#[test]
fn test_release_asset_name_matches_release_workflow_naming() {
// Must stay in lockstep with .github/workflows/release.yml, which
// publishes kigi-<version>-<target-triple>.{tar.gz|zip}.
let triple = target_triple().unwrap();
assert!(
[
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]
.contains(&triple),
"triple {triple} is not one of the five released targets"
);
let name = release_asset_name("0.1.0").unwrap();
if cfg!(windows) {
assert_eq!(name, format!("kigi-0.1.0-{triple}.zip"));
} else {
assert_eq!(name, format!("kigi-0.1.0-{triple}.tar.gz"));
}
}
// ──────────────────────────────────────────────────────────────────────
// SHA256SUMS parsing
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_expected_sha256_for_parses_sha256sum_format() {
let sums = "\
0000000000000000000000000000000000000000000000000000000000000001 kigi-0.1.0-aarch64-apple-darwin.tar.gz
0000000000000000000000000000000000000000000000000000000000000002 kigi-0.1.0-x86_64-unknown-linux-gnu.tar.gz
0000000000000000000000000000000000000000000000000000000000000003 *kigi-0.1.0-x86_64-pc-windows-msvc.zip
";
assert_eq!(
expected_sha256_for(sums, "kigi-0.1.0-aarch64-apple-darwin.tar.gz").unwrap(),
"0000000000000000000000000000000000000000000000000000000000000001"
);
assert_eq!(
expected_sha256_for(sums, "kigi-0.1.0-x86_64-unknown-linux-gnu.tar.gz").unwrap(),
"0000000000000000000000000000000000000000000000000000000000000002"
);
// `*name` binary-mode marker is accepted.
assert_eq!(
expected_sha256_for(sums, "kigi-0.1.0-x86_64-pc-windows-msvc.zip").unwrap(),
"0000000000000000000000000000000000000000000000000000000000000003"
);
// Uppercase hashes normalize to lowercase.
let upper = "ABCDEF0000000000000000000000000000000000000000000000000000000000 a.tar.gz";
assert_eq!(
expected_sha256_for(upper, "a.tar.gz").unwrap(),
"abcdef0000000000000000000000000000000000000000000000000000000000"
);
}
#[test]
fn test_expected_sha256_for_missing_or_malformed_entries_error() {
let sums =
"0000000000000000000000000000000000000000000000000000000000000001 present.tar.gz\n";
let err = expected_sha256_for(sums, "absent.tar.gz").unwrap_err();
assert!(format!("{err}").contains("no entry"), "err: {err}");
// Truncated hash is malformed, not silently accepted.
let bad = "deadbeef present.tar.gz\n";
let err = expected_sha256_for(bad, "present.tar.gz").unwrap_err();
assert!(format!("{err}").contains("malformed"), "err: {err}");
// Non-hex hash of the right length is malformed too.
let nonhex = format!("{} present.tar.gz\n", "g".repeat(64));
let err = expected_sha256_for(&nonhex, "present.tar.gz").unwrap_err();
assert!(format!("{err}").contains("malformed"), "err: {err}");
// Empty manifest.
let err = expected_sha256_for("", "present.tar.gz").unwrap_err();
assert!(format!("{err}").contains("no entry"), "err: {err}");
}
#[tokio::test]
async fn test_sha256_hex_of_file_matches_known_vector() {
// SHA-256("abc") is a NIST test vector.
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("abc.txt");
std::fs::write(&p, b"abc").unwrap();
assert_eq!(
sha256_hex_of_file(&p).await.unwrap(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
// ──────────────────────────────────────────────────────────────────────
// Archive extraction (Unix: tar.gz)
// ──────────────────────────────────────────────────────────────────────
#[cfg(not(windows))]
fn make_tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(gz);
for (name, data) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder.append_data(&mut header, name, *data).unwrap();
}
builder.into_inner().unwrap().finish().unwrap()
}
#[cfg(not(windows))]
#[tokio::test]
async fn test_extract_kigi_binary_finds_kigi_entry() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("kigi-0.1.0-test.tar.gz");
std::fs::write(
&archive,
make_tar_gz(&[
("LICENSE", b"license text"),
("kigi", b"#!/bin/sh\nexit 0\n"),
]),
)
.unwrap();
let out = dir.path().join("kigi-extracted");
extract_kigi_binary(&archive, &out).await.unwrap();
assert_eq!(std::fs::read(&out).unwrap(), b"#!/bin/sh\nexit 0\n");
}
#[cfg(not(windows))]
#[tokio::test]
async fn test_extract_kigi_binary_missing_entry_errors() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("kigi-0.1.0-test.tar.gz");
std::fs::write(&archive, make_tar_gz(&[("LICENSE", b"license only")])).unwrap();
let out = dir.path().join("kigi-extracted");
let err = extract_kigi_binary(&archive, &out).await.unwrap_err();
assert!(format!("{err}").contains("no 'kigi' binary"), "err: {err}");
}
#[cfg(not(windows))]
#[tokio::test]
async fn test_extract_kigi_binary_garbage_archive_errors() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("kigi-0.1.0-test.tar.gz");
std::fs::write(&archive, b"this is not a gzip stream").unwrap();
let out = dir.path().join("kigi-extracted");
assert!(extract_kigi_binary(&archive, &out).await.is_err());
}
// ──────────────────────────────────────────────────────────────────────
// UpdateStatus serialization (camelCase contract for --json clients)
// ──────────────────────────────────────────────────────────────────────
fn make_status() -> UpdateStatus {
UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: Some("0.1.151".to_string()),
update_available: true,
installer: Some("internal".to_string()),
channel: "stable".to_string(),
auto_update: Some(true),
error: None,
}
}
#[test]
fn test_update_status_serializes_camel_case_keys() {
let s = make_status();
let v = serde_json::to_value(&s).unwrap();
let obj = v.as_object().unwrap();
assert!(obj.contains_key("currentVersion"));
assert!(obj.contains_key("latestVersion"));
assert!(obj.contains_key("updateAvailable"));
assert!(obj.contains_key("installer"));
assert!(obj.contains_key("channel"));
assert!(obj.contains_key("autoUpdate"));
assert!(obj.contains_key("error"));
// Snake-case names must NOT leak.
assert!(!obj.contains_key("current_version"));
assert!(!obj.contains_key("latest_version"));
assert!(!obj.contains_key("update_available"));
assert!(!obj.contains_key("auto_update"));
}
#[test]
fn test_update_status_field_values_round_trip_through_json() {
let s = make_status();
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["currentVersion"], "0.1.150");
assert_eq!(v["latestVersion"], "0.1.151");
assert_eq!(v["updateAvailable"], true);
assert_eq!(v["installer"], "internal");
assert_eq!(v["channel"], "stable");
assert_eq!(v["autoUpdate"], true);
assert!(v["error"].is_null());
}
#[test]
fn test_update_status_optional_none_serializes_to_null() {
let s = UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: None,
update_available: false,
installer: None,
channel: "stable".to_string(),
auto_update: None,
error: None,
};
let v = serde_json::to_value(&s).unwrap();
assert!(v["latestVersion"].is_null());
assert!(v["installer"].is_null());
assert!(v["autoUpdate"].is_null());
assert!(v["error"].is_null());
assert_eq!(v["updateAvailable"], false);
}
#[test]
fn test_update_status_with_error_field_serialized() {
let s = UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: None,
update_available: false,
installer: Some("internal".to_string()),
channel: "stable".to_string(),
auto_update: Some(true),
error: Some("GitHub API returned HTTP 403".to_string()),
};
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["error"], "GitHub API returned HTTP 403");
}
#[test]
fn test_update_status_json_is_valid_single_object() {
// Whatever we add to UpdateStatus in the future, the serialization
// must remain a single JSON object (not an array, primitive, etc.).
let s = make_status();
let json = serde_json::to_string(&s).unwrap();
assert!(json.starts_with('{'), "must be a JSON object: {json}");
assert!(json.ends_with('}'), "must be a JSON object: {json}");
// Single line: no embedded newlines (the wire format is one line).
assert!(!json.contains('\n'), "must be single line: {json}");
}
// ──────────────────────────────────────────────────────────────────────
// print_update_status — both code paths must not panic or error.
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_print_update_status_all_shapes_return_ok() {
print_update_status(&make_status(), true).unwrap();
print_update_status(&make_status(), false).unwrap();
print_update_status(
&UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: None,
update_available: false,
installer: None,
channel: "stable".to_string(),
auto_update: None,
error: None,
},
false,
)
.unwrap();
print_update_status(
&UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: Some("0.1.150".to_string()),
update_available: false,
installer: Some("internal".to_string()),
channel: "stable".to_string(),
auto_update: Some(true),
error: Some("network down".to_string()),
},
false,
)
.unwrap();
}
// ──────────────────────────────────────────────────────────────────────
// UpdateRunMode
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_update_run_mode_is_copy_clone_debug() {
// The ergonomic Copy/Clone/Debug derives must not regress: we pass
// `run_mode` by value through several layers.
let m1 = UpdateRunMode::Blocking;
let m2 = m1; // Copy
let m3 = m1; // Copy again, m1 not moved
assert!(matches!(m1, UpdateRunMode::Blocking));
assert!(matches!(m2, UpdateRunMode::Blocking));
assert!(matches!(m3, UpdateRunMode::Blocking));
// Debug exists.
let _ = format!("{:?}", UpdateRunMode::NonBlocking);
}
// ──────────────────────────────────────────────────────────────────────
// Constants — lock them in so silent renames are caught.
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_user_facing_constants_are_stable() {
assert_eq!(PROMPT_UPDATE_NOW, "Update now? [Y/n/d]");
assert_eq!(
MSG_AUTO_UPDATE_BACKGROUND,
"Auto-update running in background."
);
assert_eq!(
MSG_RUN_UPDATE_MANUAL,
"Run `kigi update` to get the latest version."
);
assert_eq!(SHA256SUMS_ASSET, "SHA256SUMS");
if cfg!(windows) {
assert_eq!(ARCHIVE_EXT, "zip");
} else {
assert_eq!(ARCHIVE_EXT, "tar.gz");
}
}
// ──────────────────────────────────────────────────────────────────────
// env_installer — env-var based, must run serially.
//
// Resolution order (matches function body):
// 1. KIGI_INSTALLER (internal; anything else → None)
// 2. KIGI_MANAGED_BY_INTERNAL → internal
// 3. None
// ──────────────────────────────────────────────────────────────────────
/// Snapshot every installer-related env var so the test can clear them
/// at start and restore them at end.
struct InstallerEnvGuard {
prev: Vec<(&'static str, Option<std::ffi::OsString>)>,
}
impl InstallerEnvGuard {
fn isolate() -> Self {
const VARS: &[&str] = &["KIGI_INSTALLER", "KIGI_MANAGED_BY_INTERNAL"];
let prev: Vec<_> = VARS.iter().map(|k| (*k, std::env::var_os(k))).collect();
unsafe {
for k in VARS {
std::env::remove_var(k);
}
}
Self { prev }
}
}
impl Drop for InstallerEnvGuard {
fn drop(&mut self) {
unsafe {
for (k, v) in &self.prev {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
}
}
#[test]
#[serial_test::serial]
fn test_env_installer_no_vars_returns_none() {
let _g = InstallerEnvGuard::isolate();
assert_eq!(env_installer(), None);
}
#[test]
#[serial_test::serial]
fn test_env_installer_explicit_internal() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("KIGI_INSTALLER", "internal") };
assert_eq!(env_installer(), Some("internal"));
// Case-insensitive.
unsafe { std::env::set_var("KIGI_INSTALLER", "INTERNAL") };
assert_eq!(env_installer(), Some("internal"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_unknown_or_empty_returns_none() {
// CRITICAL: when the explicit env var is set to something we don't
// recognize, we early-return None and do NOT fall through to the
// other env vars.
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("KIGI_INSTALLER", "npm") };
unsafe { std::env::set_var("KIGI_MANAGED_BY_INTERNAL", "1") };
assert_eq!(
env_installer(),
None,
"explicit unknown KIGI_INSTALLER must early-return None, not fall through"
);
unsafe { std::env::set_var("KIGI_INSTALLER", "") };
assert_eq!(env_installer(), None);
}
#[test]
#[serial_test::serial]
fn test_env_installer_managed_by_internal() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("KIGI_MANAGED_BY_INTERNAL", "1") };
assert_eq!(env_installer(), Some("internal"));
// The check is `is_some` — any value (including empty) wins.
unsafe { std::env::set_var("KIGI_MANAGED_BY_INTERNAL", "") };
assert_eq!(env_installer(), Some("internal"));
}
// ──────────────────────────────────────────────────────────────────────
// windows_replace_exe — runs only on Windows CI
// ──────────────────────────────────────────────────────────────────────
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_creates_and_overwrites_dest() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new-binary.exe");
std::fs::write(&src, "new content").unwrap();
let dest = dir.path().join("kigi.exe");
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"new content");
std::fs::write(&src, "newer content").unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"newer content");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_cleans_stale_old_backup() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "new").unwrap();
let dest = dir.path().join("kigi.exe");
std::fs::write(&dest, "current").unwrap();
let old = dir.path().join("kigi.exe.old");
std::fs::write(&old, "stale-from-prior-update").unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "new");
assert!(!old.exists(), "stale .old must be removed");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_no_filename_returns_err() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.exe");
std::fs::write(&src, "data").unwrap();
let bad_dest = dir.path().join("..");
let err = windows_replace_exe(&src, &bad_dest).await.unwrap_err();
assert!(format!("{err:#}").contains("no filename"), "error: {err:#}");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_locked_file_renames_aside() {
// Simulate a running .exe: blocks writes but allows rename.
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x00000001;
const FILE_SHARE_DELETE: u32 = 0x00000004;
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "updated binary").unwrap();
let dest = dir.path().join("kigi.exe");
std::fs::write(&dest, "running binary").unwrap();
let _lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
.open(&dest)
.unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "updated binary");
let old = dir.path().join("kigi.exe.old");
assert!(old.exists(), ".old must exist after rename fallback");
drop(_lock);
assert_eq!(std::fs::read_to_string(&old).unwrap(), "running binary");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_rollback_on_copy_failure() {
// No stale .old: the aside IS kigi.exe.old, so this pins the
// non-diverted rollback branch (rename .old back onto dest).
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x00000001;
const FILE_SHARE_DELETE: u32 = 0x00000004;
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "updated binary").unwrap();
let dest = dir.path().join("kigi.exe");
std::fs::write(&dest, "original").unwrap();
// Dest locked like a running exe: blocks writes but allows rename.
let _dest_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
.open(&dest)
.unwrap();
// Exclusive src lock: both copies fail with a sharing violation, so
// the rename runs and the second copy triggers the rollback.
let _src_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(0)
.open(&src)
.unwrap();
let result = windows_replace_exe(&src, &dest).await;
drop(_src_lock);
drop(_dest_lock);
assert!(result.is_err());
assert_eq!(
std::fs::read_to_string(&dest).unwrap(),
"original",
"rollback must restore the original binary"
);
let old = dir.path().join("kigi.exe.old");
assert!(!old.exists(), "rollback must consume the .old aside");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_locked_stale_old_does_not_block_update() {
// A leftover .old can still be a running image (the session live
// during the previous update): undeletable, so the rename must
// divert to a unique aside instead of failing on the locked name.
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x00000001;
const FILE_SHARE_DELETE: u32 = 0x00000004;
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "updated binary").unwrap();
let dest = dir.path().join("kigi.exe");
std::fs::write(&dest, "running binary").unwrap();
let old = dir.path().join("kigi.exe.old");
std::fs::write(&old, "previous binary").unwrap();
// No FILE_SHARE_DELETE: .old cannot be deleted or rename-replaced.
let _old_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ)
.open(&old)
.unwrap();
// Dest locked like a running exe: blocks writes but allows rename.
let _dest_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
.open(&dest)
.unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "updated binary");
assert_eq!(
std::fs::read_to_string(&old).unwrap(),
"previous binary",
"locked .old must be left in place"
);
let asides: Vec<PathBuf> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("kigi.exe.old.") && n.ends_with(".old"))
})
.collect();
assert_eq!(
asides.len(),
1,
"dest must be renamed to a unique aside: {asides:?}"
);
assert_eq!(
std::fs::read_to_string(&asides[0]).unwrap(),
"running binary"
);
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_sweeps_accumulated_asides() {
// Asides pile up while superseded sessions keep running; a later
// update must collect the no-longer-locked ones — but never another
// executable's leftovers.
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "new").unwrap();
let dest = dir.path().join("kigi.exe");
std::fs::write(&dest, "current").unwrap();
let old = dir.path().join("kigi.exe.old");
std::fs::write(&old, "stale").unwrap();
let aside_a = dir.path().join("kigi.exe.old.1234-0.old");
let aside_b = dir.path().join("kigi.exe.old.1234-1.old");
std::fs::write(&aside_a, "aside-a").unwrap();
std::fs::write(&aside_b, "aside-b").unwrap();
let other_old = dir.path().join("other.exe.old");
std::fs::write(&other_old, "other-old").unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "new");
assert!(!old.exists(), "legacy .old must be swept");
assert!(!aside_a.exists(), "aside must be swept");
assert!(!aside_b.exists(), "aside must be swept");
assert!(
other_old.exists(),
"other executables' leftovers must be untouched"
);
}
}