Files
Kigi-CLI/crates/codegen/kigi-update/tests/test_blitz_cancel.rs
T
ZacharyZhang-NY 86e3724310 F8: distribution and GitHub-Releases self-update
- .github/workflows/release.yml: on tag v* build all 5 targets (macOS
  arm64/x86_64, Linux arm64/x86_64 incl. free arm runners, Windows
  x86_64) with the release-dist profile, archive kigi-<version>-<triple>
  with LICENSE/NOTICE/THIRD-PARTY-NOTICES, generate SHA256SUMS, publish
  the release (prerelease for tags containing '-'), with a tag↔workspace
  version guard.
- install.sh / install.ps1 (repo root): platform detection, latest or
  --version download from GitHub Releases, SHA-256 verification against
  SHA256SUMS, install into the kigi home's downloads/ + bin/kigi symlink
  (the same layout the self-updater manages), smoke test, PATH guidance.
- kigi-update rewritten onto the GitHub Releases API (documented wire
  shape; stable=/latest, alpha=semver-max across the list, pinned=/tags):
  SHA-256 gate before any binary swap, tar.gz/zip extraction per
  platform, atomic bin/kigi symlink swap, channel/rollback semantics and
  the KIGI_AUTO_UPDATE gate preserved verbatim; every x.ai/GCS/npm
  endpoint deleted, npm/gh-release installers removed, legacy grok/agent
  links retired on install. kigi-env owns the update base URL with a
  KIGI_UPDATE_BASE_URL override (this is what the test artifact server
  injects).
- .cargo/config.toml: removed the non-portable neoverse-v2 CPU pin on
  Linux arm64 (fleet-specific); RELRO/NX hardening link-args now apply
  to the gnu targets too, matching the release-dist profile's contract.
- THIRD-PARTY-NOTICES regenerated via cargo-about (about.toml +
  template); the M0 hand-built file is dropped and README points at the
  generated one. docs/RELEASE.md carries the release checklist.
- Deleted xAI-era leftovers: kigi-tui/scripts/install*.{sh,ps1} (x.ai
  CDN) and the @xai-official/grok npm skeleton (PRD F8: no npm).

Gates: fmt clean; workspace check/clippy 0/0 (--locked, -D warnings);
kigi-update 58 lib + 86 integration tests green; deny ok;
release-dist build of kigi-bin succeeds and reports 'kigi 0.1.0'.
2026-07-18 00:54:53 -04:00

408 lines
17 KiB
Rust

//! Blitz harness: hammer the download + install lifecycle while injecting a
//! truncation / corruption / cancel at every point, and after every iteration
//! assert the single invariant that makes the brick impossible:
//!
//! > `~/.kigi/bin/kigi` resolves to a binary that passes the smoke-test, OR it
//! > is still the previous-good binary. It is never a broken/partial binary,
//! > and a `.tmp` never masquerades as the active binary.
//!
//! The invariant is checked by RE-RESOLVING the symlink and RE-RUNNING the
//! binary from disk every time — never by re-reading a value the harness set.
//!
//! A controllable raw HTTP/1.1 GitHub-Releases-shaped server serves release
//! JSON, SHA256SUMS, and the archive, and can truncate the archive body,
//! close the connection early, serve a right-length-but-garbage body (caught
//! by the SHA-256 gate), serve a correctly-checksummed archive whose binary
//! fails to run (caught by the smoke test), or hang mid-transfer — for both
//! the parallel byte-range path and the single-connection path.
#![cfg(unix)]
mod common;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serial_test::serial;
use common::artifact_server::{ArtifactServer, Mode};
use common::{
can_exec_shell_scripts, host_platform, make_update_config, reset_home, small_good_artifact,
test_home,
};
use kigi_update::auto_update::install_internal_from_base;
// ─────────────────────────────────────────────────────────────────────────────
// Artifacts + fixtures
// ─────────────────────────────────────────────────────────────────────────────
/// A real executable whose ARCHIVE clears the 16 MiB parallel threshold (at
/// least 2 chunks), so the parallel byte-range path is exercised. The shell
/// exits on line 2, never reading the padding — which is pseudo-random bytes
/// so gzip cannot compress the archive below the threshold.
fn large_good_artifact() -> Vec<u8> {
let mut v = b"#!/bin/sh\nexit 0\n".to_vec();
v.reserve(34 * 1024 * 1024);
// xorshift64* keeps the padding incompressible without an RNG dependency.
let mut x: u64 = 0x243F6A8885A308D3;
while v.len() < 34 * 1024 * 1024 {
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
v.extend_from_slice(&x.wrapping_mul(0x2545F4914F6CDD1D).to_le_bytes());
}
v
}
/// Seed a previous-good versioned binary + the managed `kigi` symlink.
/// Returns the absolute path of the seeded binary.
fn seed_previous_good(home: &Path, version: &str, platform: &str) -> PathBuf {
let downloads = home.join("downloads");
let bin = home.join("bin");
std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap();
let prev = downloads.join(format!("kigi-{version}-{platform}"));
std::fs::write(&prev, small_good_artifact()).unwrap();
std::fs::set_permissions(&prev, std::fs::Permissions::from_mode(0o755)).unwrap();
let rel = format!("../downloads/kigi-{version}-{platform}");
let link = bin.join("kigi");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&rel, &link).unwrap();
dunce::canonicalize(&prev).unwrap()
}
/// What the active `kigi` should resolve to after an install attempt.
#[derive(Clone, Copy, PartialEq)]
enum Expect {
/// The new version was installed and activated.
NewBinary,
/// The install was rejected/cancelled; the previous-good binary stays live.
PreviousGood,
}
/// THE invariant. Re-resolves the on-disk symlink and RE-EXECUTES the resolved
/// binary; never inspects a harness-held value. Guarantees the active managed
/// link is always runnable and is never a `.tmp` or a partial file.
fn assert_invariant(home: &Path, prev_good: &Path, new_binary: &Path, expect: Expect) {
let link = home.join("bin").join("kigi");
assert!(link.is_symlink(), "kigi must remain a symlink");
// Resolve from disk. canonicalize fails on a dangling link — that alone
// would be a brick.
let resolved = dunce::canonicalize(&link)
.unwrap_or_else(|e| panic!("active kigi symlink does not resolve: {e}"));
// A `.tmp` file must never be the live target.
let resolved_name = resolved.file_name().unwrap().to_string_lossy().to_string();
assert!(
!resolved_name.contains(".tmp"),
"active kigi must not be a temp file: {resolved_name}"
);
// Re-run the resolved binary from disk: the active link must always run.
let ran_ok = std::process::Command::new(&resolved)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(
ran_ok,
"active kigi must pass the smoke-test, but {} did not run",
resolved.display()
);
match expect {
Expect::NewBinary => assert_eq!(
resolved,
dunce::canonicalize(new_binary).unwrap(),
"expected the newly-installed binary to be active"
),
Expect::PreviousGood => assert_eq!(
resolved, prev_good,
"expected the previous-good binary to stay active after a rejected install"
),
}
}
/// Run one install attempt against `server` in `mode`, optionally cancelling it
/// after `cancel_after`, then assert the invariant.
async fn run_one(
server: &ArtifactServer,
mode: Mode,
version: &str,
cancel_after: Option<Duration>,
) {
let home = test_home();
reset_home();
let platform = host_platform();
let prev_good = seed_previous_good(home, "0.1.100", &platform);
let new_binary = home
.join("downloads")
.join(format!("kigi-{version}-{platform}"));
let cfg = make_update_config("stable");
server.set_mode(mode);
let base = server.base();
let install = install_internal_from_base(Some(version), &cfg, &base);
let expect = match (mode, cancel_after) {
(Mode::Full, None) => {
install.await.expect("full artifact install should succeed");
Expect::NewBinary
}
(_, Some(deadline)) => {
// Cancel mid-flight by dropping the future at the timeout.
let _ = tokio::time::timeout(deadline, install).await;
Expect::PreviousGood
}
_ => {
let result = install.await;
assert!(
result.is_err(),
"corrupt artifact ({mode:?}) must not install successfully"
);
Expect::PreviousGood
}
};
assert_invariant(home, &prev_good, &new_binary, expect);
}
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic matrix — single-connection path (small archive)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn blitz_single_connection_matrix() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let server = ArtifactServer::start(small_good_artifact());
let len = server.archive_len("0.1.181");
// Happy path first so we know the symlink CAN move to the new binary.
run_one(&server, Mode::Full, "0.1.181", None).await;
// Right-length garbage — caught by the SHA-256 gate.
run_one(&server, Mode::Garbage, "0.1.181", None).await;
// Correctly-checksummed archive with a broken binary — caught by the
// smoke test.
run_one(&server, Mode::BadBinary, "0.1.181", None).await;
// Premature EOF at several offsets — caught by the length/transport
// checks (and the checksum gate as belt-and-suspenders).
for k in [0usize, 1, len / 2, len.saturating_sub(1)] {
run_one(&server, Mode::Truncate(k), "0.1.181", None).await;
}
// Cancel mid-transfer at several offsets (incl. before any byte and before
// the HEAD completes), each dropping the in-flight future.
for k in [0usize, len / 2, len.saturating_sub(1)] {
run_one(
&server,
Mode::Hang(k),
"0.1.181",
Some(Duration::from_millis(300)),
)
.await;
}
// A clean serve still succeeds after the failure matrix. NOTE: run_one
// calls reset_home() at the start of every case, so this checks the happy
// path stays reachable — not recovery over a dirty dir. The genuine
// recovery-without-reset assertion lives in
// smoke_and_checksum_failures_keep_previous_good_then_recover.
run_one(&server, Mode::Full, "0.1.182", None).await;
}
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic matrix — parallel byte-range path (>= 16 MiB archive)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn blitz_parallel_path_matrix() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let server = ArtifactServer::start(large_good_artifact());
let len = server.archive_len("0.1.181");
assert!(
len >= 16 * 1024 * 1024,
"archive must clear the parallel threshold (got {len} bytes)"
);
// Happy path through the parallel reassembly.
run_one(&server, Mode::Full, "0.1.181", None).await;
// Right-length garbage reassembled from range chunks — checksum catches.
run_one(&server, Mode::Garbage, "0.1.181", None).await;
// Short chunk inside the range / set_len zero region. With Content-Length
// present (the blitz server always sends it), a premature close surfaces as
// a reqwest stream error that rejects the chunk; the parallel path falls
// back to single-connection, which hits the same truncation.
for k in [0usize, 1024, len / 3, len - 4096] {
run_one(&server, Mode::Truncate(k), "0.1.181", None).await;
}
// Cancel mid-chunk.
run_one(
&server,
Mode::Hang(len / 4),
"0.1.181",
Some(Duration::from_millis(400)),
)
.await;
// Clean serve recovers.
run_one(&server, Mode::Full, "0.1.182", None).await;
}
// ─────────────────────────────────────────────────────────────────────────────
// Checksum + smoke-test rejections keep previous-good, then recover WITHOUT
// a reset in between.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn smoke_and_checksum_failures_keep_previous_good_then_recover() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let server = ArtifactServer::start(small_good_artifact());
let home = test_home();
reset_home();
let platform = host_platform();
let prev_good = seed_previous_good(home, "0.1.100", &platform);
let cfg = make_update_config("stable");
let base = server.base();
let new_binary = home
.join("downloads")
.join(format!("kigi-0.1.181-{platform}"));
// Checksum failure (garbage body) keeps previous good.
server.set_mode(Mode::Garbage);
let result = install_internal_from_base(Some("0.1.181"), &cfg, &base).await;
assert!(result.is_err(), "garbage archive must not install");
assert_invariant(home, &prev_good, &new_binary, Expect::PreviousGood);
// Smoke-test failure (valid checksum, broken binary) keeps previous good.
server.set_mode(Mode::BadBinary);
let result = install_internal_from_base(Some("0.1.181"), &cfg, &base).await;
assert!(result.is_err(), "broken binary must not install");
assert_invariant(home, &prev_good, &new_binary, Expect::PreviousGood);
// A subsequent clean serve must succeed over the SAME dirty state.
server.set_mode(Mode::Full);
install_internal_from_base(Some("0.1.181"), &cfg, &base)
.await
.expect("clean serve after failures should succeed");
assert_invariant(home, &prev_good, &new_binary, Expect::NewBinary);
}
// ─────────────────────────────────────────────────────────────────────────────
// Bounded randomized fuzz (CI) + ignored stress (1e5+ iterations).
// ─────────────────────────────────────────────────────────────────────────────
/// Cheap deterministic PRNG so the fuzz needs no extra dependency.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
// xorshift64*
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545F4914F6CDD1D)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
}
async fn fuzz_loop(iterations: usize, seed: u64) {
let server = ArtifactServer::start(small_good_artifact());
let len = server.archive_len("0.1.181");
let mut rng = Rng(seed);
for i in 0..iterations {
let version = if i % 2 == 0 { "0.1.181" } else { "0.1.182" };
// Periodically verify a clean serve still installs (recovery), but keep
// the bulk on the fast corruption/cancel paths so the loop stays cheap
// enough for high iteration counts.
if i % 10 == 9 {
run_one(&server, Mode::Full, version, None).await;
continue;
}
match rng.below(4) {
0 => run_one(&server, Mode::Garbage, version, None).await,
1 => run_one(&server, Mode::BadBinary, version, None).await,
2 => {
// k in [0, len): always strictly truncating (k == len would be
// a complete transfer).
let k = rng.below(len);
run_one(&server, Mode::Truncate(k), version, None).await;
}
_ => {
// k in [0, len): Hang holds the socket after k bytes without
// ever meeting Content-Length, so the client always cancels
// mid-flight. k == len would transmit the whole body, letting
// the install complete and the swap land before the deadline —
// contradicting run_one's PreviousGood expectation (the same
// reason the Truncate branch above uses rng.below(len)).
let k = rng.below(len);
run_one(
&server,
Mode::Hang(k),
version,
Some(Duration::from_millis(80)),
)
.await;
}
}
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn blitz_fuzz_bounded() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
// Kept bounded so CI stays fast; the exhaustive run is the ignored test
// below. Every iteration still re-resolves and re-runs the on-disk binary.
fuzz_loop(120, 0x9E3779B97F4A7C15).await;
}
/// The "test it a million times, cancelling at every point" stress run. Gated
/// behind `#[ignore]`; invoke via
/// `cargo nextest run -p kigi-update --run-ignored all`.
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "stress: 100k iterations"]
async fn blitz_fuzz_stress() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let iterations: usize = std::env::var("KIGI_BLITZ_ITERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100_000);
fuzz_loop(iterations, 0xDEADBEEFCAFEF00D).await;
}