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'.
This commit is contained in:
@@ -1,404 +1,305 @@
|
||||
//! Blitz harness for the bash installer (`install.sh`), the second client that
|
||||
//! can brick a machine. Runs the REAL shipped `install.sh` against a fake
|
||||
//! `curl` that can serve the good artifact, truncate it, or serve a right-length
|
||||
//! garbage body, and asserts the same invariant as the Rust blitz:
|
||||
//! Harness for the bootstrap installer (`install.sh` at the repo root), the
|
||||
//! second client that can brick a machine. Runs the REAL shipped script
|
||||
//! against a fake `curl` that serves GitHub-Releases-shaped JSON, the
|
||||
//! archive, and SHA256SUMS from local fixtures, and asserts:
|
||||
//!
|
||||
//! > After any install attempt, `$BIN_DIR/grok` resolves to a binary that runs,
|
||||
//! > OR is still the previous-good binary — never a partial/garbage binary.
|
||||
//! > After any install attempt, `$KIGI_SHARE_DIR/bin/kigi` resolves to a
|
||||
//! > binary that runs, OR the install failed cleanly with nothing activated —
|
||||
//! > never a partial/garbage binary.
|
||||
//!
|
||||
//! Also covers shell-rc rewrite: stowed/symlinked `~/.bashrc` etc. must survive
|
||||
//! reinstall without being replaced by a plain file.
|
||||
//!
|
||||
//! The installer lives in the sibling `kigi-tui` crate; it is resolved by
|
||||
//! relative path. If it cannot be found (e.g. a sandbox that does not vendor it)
|
||||
//! the test skips rather than fail — under the repo's `cargo nextest` workflow
|
||||
//! the path resolves and the installer is exercised end to end.
|
||||
//! Each test uses its own tempdir home and passes PATH/KIGI_SHARE_DIR to the
|
||||
//! child process explicitly, so no process-global state is touched and no
|
||||
//! `#[serial]` is needed.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn script_path(name: &str) -> Option<PathBuf> {
|
||||
dunce::canonicalize(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../kigi-tui/scripts/{name}")),
|
||||
)
|
||||
.ok()
|
||||
.filter(|p| p.exists())
|
||||
}
|
||||
use common::{archive_name, host_platform, make_release_archive, sha256_hex, small_good_artifact};
|
||||
|
||||
fn install_sh_path() -> Option<PathBuf> {
|
||||
script_path("install.sh")
|
||||
// crates/codegen/kigi-update → repo root.
|
||||
dunce::canonicalize(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../install.sh"))
|
||||
.ok()
|
||||
.filter(|p| p.exists())
|
||||
}
|
||||
|
||||
fn host_platform() -> String {
|
||||
let os = if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else {
|
||||
"linux"
|
||||
};
|
||||
let arch = if cfg!(target_arch = "x86_64") {
|
||||
"x86_64"
|
||||
} else {
|
||||
"aarch64"
|
||||
};
|
||||
format!("{os}-{arch}")
|
||||
/// GitHub release JSON with download URLs whose suffixes the fake curl
|
||||
/// dispatches on (the host is irrelevant).
|
||||
fn release_json(version: &str) -> String {
|
||||
let name = archive_name(version);
|
||||
serde_json::json!({
|
||||
"tag_name": format!("v{version}"),
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"assets": [
|
||||
{ "name": name, "browser_download_url": format!("https://example.test/dl/v{version}/{name}") },
|
||||
{ "name": "SHA256SUMS", "browser_download_url": format!("https://example.test/dl/v{version}/SHA256SUMS") },
|
||||
],
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
const GOOD_SCRIPT: &str = "#!/bin/sh\nexit 0\n";
|
||||
const INSTALLER_BLOCK_START: &str = "# >>> grok installer >>>";
|
||||
|
||||
/// Write a fake `curl` that intercepts every download `install.sh` performs.
|
||||
/// `$FAKE_MODE` (full|truncate|garbage) selects the corruption.
|
||||
fn write_fake_curl(dir: &Path) {
|
||||
let body = format!(
|
||||
r#"#!/bin/bash
|
||||
mode="${{FAKE_MODE:-full}}"
|
||||
fullsize={fullsize}
|
||||
head=0; out=""; want_code=0; url=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--head) head=1 ;;
|
||||
-o) shift; out="$1" ;;
|
||||
-w) shift; [ "$1" = '%{{http_code}}' ] && want_code=1 ;;
|
||||
-*) : ;;
|
||||
*) url="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if [ "$head" = 1 ]; then
|
||||
if [ "$want_code" = 1 ]; then printf '200'; else printf 'HTTP/1.1 200 OK\r\nContent-Length: %s\r\n\r\n' "$fullsize"; fi
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$out" ]; then
|
||||
case "$mode" in
|
||||
full) printf '%s' '{good}' > "$out" ;;
|
||||
truncate) printf '\0\0\0\0' > "$out" ;;
|
||||
garbage) head -c "$fullsize" /dev/zero | tr '\0' 'X' > "$out" ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
printf '0.1.181'
|
||||
exit 0
|
||||
"#,
|
||||
fullsize = GOOD_SCRIPT.len(),
|
||||
good = GOOD_SCRIPT,
|
||||
);
|
||||
let path = dir.join("curl");
|
||||
std::fs::write(&path, body).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
/// Fixture directory holding the fake curl + canned responses.
|
||||
struct Fixture {
|
||||
dir: tempfile::TempDir,
|
||||
home: tempfile::TempDir,
|
||||
}
|
||||
|
||||
/// Seed a valid previous-good binary + symlink in the isolated home.
|
||||
fn seed_previous_good(home: &Path, platform: &str) -> PathBuf {
|
||||
let downloads = home.join(".kigi").join("downloads");
|
||||
let bin = home.join(".kigi").join("bin");
|
||||
std::fs::create_dir_all(&downloads).unwrap();
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let prev = downloads.join(format!("grok-{platform}"));
|
||||
std::fs::write(&prev, GOOD_SCRIPT).unwrap();
|
||||
std::fs::set_permissions(&prev, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let link = bin.join("grok");
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::os::unix::fs::symlink(format!("../downloads/grok-{platform}"), &link).unwrap();
|
||||
dunce::canonicalize(&prev).unwrap()
|
||||
}
|
||||
|
||||
/// Re-resolve `$BIN_DIR/grok` from disk and re-run it: the active grok must
|
||||
/// always execute, and never be a `.tmp`/partial file.
|
||||
fn assert_active_grok_runs(home: &Path) {
|
||||
let link = home.join(".kigi").join("bin").join("grok");
|
||||
assert!(link.is_symlink(), "grok must remain a symlink");
|
||||
let resolved =
|
||||
dunce::canonicalize(&link).unwrap_or_else(|e| panic!("grok symlink dangles: {e}"));
|
||||
let name = resolved.file_name().unwrap().to_string_lossy().to_string();
|
||||
assert!(
|
||||
!name.contains(".tmp"),
|
||||
"active grok must not be a temp file: {name}"
|
||||
);
|
||||
let ok = Command::new(&resolved)
|
||||
.arg("--version")
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
assert!(ok, "active grok must run: {}", resolved.display());
|
||||
}
|
||||
|
||||
fn run_installer(install_sh: &Path, home: &Path, fakebin: &Path, mode: &str, shell: &str) -> bool {
|
||||
let path_env = format!("{}:/usr/bin:/bin", fakebin.display());
|
||||
let status = Command::new("/bin/bash")
|
||||
.arg(install_sh)
|
||||
.arg("0.1.181")
|
||||
.env_clear()
|
||||
.env("HOME", home)
|
||||
.env("PATH", path_env)
|
||||
.env("SHELL", shell)
|
||||
.env("KIGI_BIN_DIR", home.join(".kigi").join("bin"))
|
||||
.env("KIGI_CHANNEL", "stable")
|
||||
.env("FAKE_MODE", mode)
|
||||
.status()
|
||||
.expect("spawn bash install.sh");
|
||||
status.success()
|
||||
}
|
||||
|
||||
fn installer_block_count(body: &str) -> usize {
|
||||
body.matches(INSTALLER_BLOCK_START).count()
|
||||
}
|
||||
|
||||
fn assert_single_installer_block(path: &Path, preserved: Option<&str>) {
|
||||
let body = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
panic!("read {}: {e}", path.display());
|
||||
});
|
||||
let n = installer_block_count(&body);
|
||||
assert_eq!(
|
||||
n,
|
||||
1,
|
||||
"{} must contain exactly one grok installer block, got {n}:\n{body}",
|
||||
path.display()
|
||||
);
|
||||
if let Some(marker) = preserved {
|
||||
assert!(
|
||||
body.contains(marker),
|
||||
"{} must keep pre-existing content ({marker:?}):\n{body}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RcLayout {
|
||||
Missing,
|
||||
Plain,
|
||||
StowAbsolute,
|
||||
StowRelative,
|
||||
/// `$root/user/.bashrc` → `../packages/bash/bashrc` (physical relative arm).
|
||||
StowRelativeDotDot,
|
||||
}
|
||||
|
||||
struct ShellRcCase {
|
||||
name: &'static str,
|
||||
script: &'static str,
|
||||
shell: &'static str,
|
||||
rc_name: &'static str,
|
||||
stow_name: &'static str,
|
||||
layout: RcLayout,
|
||||
reinstall: bool,
|
||||
}
|
||||
|
||||
/// Returns `(installer_home, rc_path, stow_target, expected_link_value)`.
|
||||
fn setup_rc(
|
||||
root: &Path,
|
||||
case: &ShellRcCase,
|
||||
) -> (PathBuf, PathBuf, Option<PathBuf>, Option<PathBuf>) {
|
||||
let marker = "# user shell rc\n";
|
||||
match case.layout {
|
||||
RcLayout::Missing => {
|
||||
let home = root.to_path_buf();
|
||||
(home.clone(), home.join(case.rc_name), None, None)
|
||||
}
|
||||
RcLayout::Plain => {
|
||||
let home = root.to_path_buf();
|
||||
let rc_link = home.join(case.rc_name);
|
||||
std::fs::write(&rc_link, marker).unwrap();
|
||||
(home, rc_link, None, None)
|
||||
}
|
||||
RcLayout::StowAbsolute | RcLayout::StowRelative => {
|
||||
let home = root.to_path_buf();
|
||||
let stow_dir = home.join("dotfiles");
|
||||
std::fs::create_dir_all(&stow_dir).unwrap();
|
||||
let target = stow_dir.join(case.stow_name);
|
||||
std::fs::write(&target, marker).unwrap();
|
||||
let link_value = if matches!(case.layout, RcLayout::StowAbsolute) {
|
||||
target.clone()
|
||||
} else {
|
||||
PathBuf::from(format!("dotfiles/{}", case.stow_name))
|
||||
};
|
||||
let rc_link = home.join(case.rc_name);
|
||||
std::os::unix::fs::symlink(&link_value, &rc_link).unwrap();
|
||||
(home, rc_link, Some(target), Some(link_value))
|
||||
}
|
||||
RcLayout::StowRelativeDotDot => {
|
||||
// $HOME = root/user; package is a sibling of user (relative needs `..`).
|
||||
let home = root.join("user");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
let target = root.join("packages/bash/bashrc");
|
||||
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
std::fs::write(&target, marker).unwrap();
|
||||
let link_value = PathBuf::from("../packages/bash/bashrc");
|
||||
let rc_link = home.join(case.rc_name);
|
||||
std::os::unix::fs::symlink(&link_value, &rc_link).unwrap();
|
||||
(home, rc_link, Some(target), Some(link_value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_shell_rc_case(case: &ShellRcCase) {
|
||||
let Some(script) = script_path(case.script) else {
|
||||
eprintln!(
|
||||
"skipping {}: {} not found relative to crate",
|
||||
case.name, case.script
|
||||
);
|
||||
return;
|
||||
};
|
||||
let platform = host_platform();
|
||||
let fakedir = tempfile::tempdir().unwrap();
|
||||
write_fake_curl(fakedir.path());
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let (home_path, rc_path, stow_target, expected_link) = setup_rc(root.path(), case);
|
||||
seed_previous_good(&home_path, &platform);
|
||||
|
||||
assert!(
|
||||
run_installer(&script, &home_path, fakedir.path(), "full", case.shell),
|
||||
"{}: first install should succeed",
|
||||
case.name
|
||||
);
|
||||
|
||||
if case.reinstall {
|
||||
assert!(
|
||||
run_installer(&script, &home_path, fakedir.path(), "full", case.shell),
|
||||
"{}: reinstall should succeed",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
|
||||
match case.layout {
|
||||
RcLayout::Missing | RcLayout::Plain => {
|
||||
assert!(
|
||||
rc_path.is_file() && !rc_path.is_symlink(),
|
||||
"{}: {} must be a regular file",
|
||||
case.name,
|
||||
case.rc_name
|
||||
);
|
||||
let preserved = match case.layout {
|
||||
RcLayout::Plain => Some("# user shell rc"),
|
||||
_ => None,
|
||||
};
|
||||
assert_single_installer_block(&rc_path, preserved);
|
||||
}
|
||||
RcLayout::StowAbsolute | RcLayout::StowRelative | RcLayout::StowRelativeDotDot => {
|
||||
assert!(
|
||||
rc_path.is_symlink(),
|
||||
"{}: {} must remain a symlink after install",
|
||||
case.name,
|
||||
case.rc_name
|
||||
);
|
||||
let link = std::fs::read_link(&rc_path).unwrap();
|
||||
assert_eq!(
|
||||
link,
|
||||
*expected_link.as_ref().unwrap(),
|
||||
"{}: symlink target must be unchanged",
|
||||
case.name
|
||||
);
|
||||
let target = stow_target.as_ref().unwrap();
|
||||
assert_single_installer_block(target, Some("# user shell rc"));
|
||||
}
|
||||
}
|
||||
|
||||
assert_active_grok_runs(&home_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_blitz_keeps_grok_runnable_under_corruption() {
|
||||
let Some(install_sh) = install_sh_path() else {
|
||||
eprintln!("skipping: install.sh not found relative to crate; run under cargo");
|
||||
return;
|
||||
};
|
||||
let platform = host_platform();
|
||||
let fakedir = tempfile::tempdir().unwrap();
|
||||
write_fake_curl(fakedir.path());
|
||||
|
||||
// Each entry: (mode, should the installer succeed?). Loop a few rounds so a
|
||||
// re-install over an existing good install is also exercised.
|
||||
let cases = [
|
||||
("full", true),
|
||||
("truncate", false),
|
||||
("garbage", false),
|
||||
("full", true),
|
||||
("truncate", false),
|
||||
("garbage", false),
|
||||
("full", true),
|
||||
];
|
||||
|
||||
for (mode, expect_ok) in cases {
|
||||
impl Fixture {
|
||||
/// `binary` becomes the `kigi` entry of the served archive; `sums_hash`
|
||||
/// overrides the manifest hash when `Some` (to simulate corruption).
|
||||
fn new(version: &str, binary: &[u8], sums_hash: Option<&str>) -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
seed_previous_good(home.path(), &platform);
|
||||
|
||||
let ok = run_installer(&install_sh, home.path(), fakedir.path(), mode, "/bin/bash");
|
||||
assert_eq!(
|
||||
ok, expect_ok,
|
||||
"install.sh mode={mode} exit success mismatch"
|
||||
let archive = make_release_archive(binary);
|
||||
let hash = match sums_hash {
|
||||
Some(h) => h.to_string(),
|
||||
None => sha256_hex(&archive),
|
||||
};
|
||||
std::fs::write(dir.path().join("release.json"), release_json(version)).unwrap();
|
||||
std::fs::write(dir.path().join("archive.tar.gz"), &archive).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("SHA256SUMS"),
|
||||
format!("{hash} {}\n", archive_name(version)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let d = dir.path().to_string_lossy().replace('\'', "'\\''");
|
||||
let curl = format!(
|
||||
r#"#!/bin/sh
|
||||
echo "$@" >> '{d}/curl-args.log'
|
||||
out=""
|
||||
url=""
|
||||
prev=""
|
||||
for a in "$@"; do
|
||||
if [ "$prev" = "-o" ]; then out="$a"; fi
|
||||
case "$a" in
|
||||
-*) ;;
|
||||
*) url="$a" ;;
|
||||
esac
|
||||
prev="$a"
|
||||
done
|
||||
serve() {{
|
||||
if [ -n "$out" ]; then cat "$1" > "$out"; else cat "$1"; fi
|
||||
}}
|
||||
case "$url" in
|
||||
*/SHA256SUMS) serve '{d}/SHA256SUMS' ;;
|
||||
*.tar.gz) serve '{d}/archive.tar.gz' ;;
|
||||
*/latest|*/tags/v*) serve '{d}/release.json' ;;
|
||||
*) echo "fake curl: unmatched url: $url" >&2; exit 22 ;;
|
||||
esac
|
||||
"#
|
||||
);
|
||||
let curl_path = dir.path().join("curl");
|
||||
std::fs::write(&curl_path, curl).unwrap();
|
||||
std::fs::set_permissions(&curl_path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
// The invariant holds regardless of which path was taken: the active
|
||||
// grok always runs (new good binary on success, previous-good on
|
||||
// rejection).
|
||||
assert_active_grok_runs(home.path());
|
||||
Self { dir, home }
|
||||
}
|
||||
|
||||
fn run(&self, args: &[&str]) -> std::process::Output {
|
||||
let script = install_sh_path().expect("install.sh present at repo root");
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
self.dir.path().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
Command::new("sh")
|
||||
.arg(&script)
|
||||
.args(args)
|
||||
.env("PATH", path)
|
||||
.env("KIGI_SHARE_DIR", self.home.path())
|
||||
.env("HOME", self.home.path())
|
||||
.output()
|
||||
.expect("install.sh must spawn")
|
||||
}
|
||||
|
||||
fn curl_log(&self) -> String {
|
||||
std::fs::read_to_string(self.dir.path().join("curl-args.log")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn active_kigi(&self) -> PathBuf {
|
||||
self.home.path().join("bin").join("kigi")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell-rc rewrite matrix: stow absolute/relative/`..`, plain, first-create, enterprise.
|
||||
fn stderr_of(out: &std::process::Output) -> String {
|
||||
String::from_utf8_lossy(&out.stderr).to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_shell_rc_rewrite_matrix() {
|
||||
let cases = [
|
||||
ShellRcCase {
|
||||
name: "stow absolute bashrc reinstall",
|
||||
script: "install.sh",
|
||||
shell: "/bin/bash",
|
||||
rc_name: ".bashrc",
|
||||
stow_name: "bashrc",
|
||||
layout: RcLayout::StowAbsolute,
|
||||
reinstall: true,
|
||||
},
|
||||
ShellRcCase {
|
||||
name: "stow relative bashrc reinstall",
|
||||
script: "install.sh",
|
||||
shell: "/bin/bash",
|
||||
rc_name: ".bashrc",
|
||||
stow_name: "bashrc",
|
||||
layout: RcLayout::StowRelative,
|
||||
reinstall: true,
|
||||
},
|
||||
ShellRcCase {
|
||||
name: "stow relative ../ bashrc reinstall",
|
||||
script: "install.sh",
|
||||
shell: "/bin/bash",
|
||||
rc_name: ".bashrc",
|
||||
stow_name: "bashrc",
|
||||
layout: RcLayout::StowRelativeDotDot,
|
||||
reinstall: true,
|
||||
},
|
||||
ShellRcCase {
|
||||
name: "plain bashrc reinstall",
|
||||
script: "install.sh",
|
||||
shell: "/bin/bash",
|
||||
rc_name: ".bashrc",
|
||||
stow_name: "bashrc",
|
||||
layout: RcLayout::Plain,
|
||||
reinstall: true,
|
||||
},
|
||||
ShellRcCase {
|
||||
name: "missing bashrc first install",
|
||||
script: "install.sh",
|
||||
shell: "/bin/bash",
|
||||
rc_name: ".bashrc",
|
||||
stow_name: "bashrc",
|
||||
layout: RcLayout::Missing,
|
||||
reinstall: false,
|
||||
},
|
||||
ShellRcCase {
|
||||
name: "enterprise stow absolute bashrc reinstall",
|
||||
script: "install-enterprise.sh",
|
||||
shell: "/bin/bash",
|
||||
rc_name: ".bashrc",
|
||||
stow_name: "bashrc",
|
||||
layout: RcLayout::StowAbsolute,
|
||||
reinstall: true,
|
||||
},
|
||||
];
|
||||
|
||||
for case in &cases {
|
||||
run_shell_rc_case(case);
|
||||
fn install_sh_happy_path_installs_versioned_binary_and_symlink() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
|
||||
|
||||
let out = fx.run(&[]);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"install.sh must succeed: stderr={}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
|
||||
// Managed layout: versioned binary + relative symlink, same as the
|
||||
// self-updater produces.
|
||||
let versioned = fx
|
||||
.home
|
||||
.path()
|
||||
.join("downloads")
|
||||
.join(format!("kigi-0.1.5-{}", host_platform()));
|
||||
assert!(versioned.exists(), "versioned binary installed");
|
||||
assert_eq!(std::fs::read(&versioned).unwrap(), small_good_artifact());
|
||||
|
||||
let link = fx.active_kigi();
|
||||
assert!(link.is_symlink(), "bin/kigi is a symlink");
|
||||
assert_eq!(
|
||||
std::fs::read_link(&link).unwrap(),
|
||||
Path::new("..")
|
||||
.join("downloads")
|
||||
.join(format!("kigi-0.1.5-{}", host_platform())),
|
||||
"symlink must be relative (survives bind-mounted homes)"
|
||||
);
|
||||
|
||||
// The active link runs.
|
||||
let status = Command::new(&link).arg("--version").status().unwrap();
|
||||
assert!(status.success(), "installed kigi must run");
|
||||
|
||||
// Resolved the latest endpoint (no pinned version).
|
||||
assert!(
|
||||
fx.curl_log().contains("/latest"),
|
||||
"must resolve via /latest: {}",
|
||||
fx.curl_log()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_pinned_version_uses_tag_endpoint() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
|
||||
|
||||
let out = fx.run(&["--version", "v0.1.5"]);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"pinned install must succeed: stderr={}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
assert!(
|
||||
fx.curl_log().contains("/tags/v0.1.5"),
|
||||
"must resolve via /tags/v0.1.5: {}",
|
||||
fx.curl_log()
|
||||
);
|
||||
assert!(fx.active_kigi().is_symlink());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_rejects_checksum_mismatch_and_activates_nothing() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), Some(&"0".repeat(64)));
|
||||
|
||||
let out = fx.run(&[]);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"checksum mismatch must fail the install"
|
||||
);
|
||||
assert!(
|
||||
stderr_of(&out).contains("SHA256 mismatch"),
|
||||
"stderr: {}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
let link = fx.active_kigi();
|
||||
assert!(
|
||||
!link.exists() && !link.is_symlink(),
|
||||
"nothing may be activated after a checksum failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_rejects_invalid_version_argument() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
|
||||
|
||||
let out = fx.run(&["--version", "not-a-version"]);
|
||||
assert!(!out.status.success());
|
||||
assert!(
|
||||
stderr_of(&out).contains("invalid version"),
|
||||
"stderr: {}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
assert!(
|
||||
fx.curl_log().is_empty(),
|
||||
"invalid arguments must fail before any network access"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_fails_when_release_lacks_platform_asset() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
|
||||
// Rewrite release.json without the platform archive asset.
|
||||
let json = serde_json::json!({
|
||||
"tag_name": "v0.1.5",
|
||||
"assets": [
|
||||
{ "name": "SHA256SUMS", "browser_download_url": "https://example.test/dl/v0.1.5/SHA256SUMS" },
|
||||
],
|
||||
});
|
||||
std::fs::write(fx.dir.path().join("release.json"), json.to_string()).unwrap();
|
||||
|
||||
let out = fx.run(&[]);
|
||||
assert!(!out.status.success());
|
||||
assert!(
|
||||
stderr_of(&out).contains("no asset"),
|
||||
"stderr: {}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
let link = fx.active_kigi();
|
||||
assert!(!link.exists() && !link.is_symlink());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_fails_when_archive_lacks_kigi_binary() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
|
||||
// Replace the archive with one that has no `kigi` entry; keep the
|
||||
// manifest consistent so the checksum gate passes and the extraction
|
||||
// check is what trips.
|
||||
let archive = common::make_tar_gz(&[("LICENSE", b"license only")]);
|
||||
std::fs::write(
|
||||
fx.dir.path().join("SHA256SUMS"),
|
||||
format!("{} {}\n", sha256_hex(&archive), archive_name("0.1.5")),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(fx.dir.path().join("archive.tar.gz"), &archive).unwrap();
|
||||
|
||||
let out = fx.run(&[]);
|
||||
assert!(!out.status.success());
|
||||
assert!(
|
||||
stderr_of(&out).contains("does not contain a 'kigi' binary"),
|
||||
"stderr: {}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
let link = fx.active_kigi();
|
||||
assert!(!link.exists() && !link.is_symlink());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user