From b56e73b68eb4a319a4b88e515dc7755f66a21ea7 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 25 Sep 2026 20:39:10 +0100 Subject: [PATCH] fix(updater): refuse `update` under a package-manager receipt Seal run 36178214528 moved past the relative-path fix (the managed DEB/RPM build step passed on both musl lanes for the first time) and hit the next first-time runner, the actual-package native lifecycle gate: `terraphim-agent update` under a dpkg receipt printed the managed-by message but exited 0. The Gitea #247 contract -- already implemented by terraphim-cli and by the C fixture in test_client_nfpm_native.sh -- requires an explicit `update` under a receipt to exit non-zero, print " update was refused: " to stderr, and leave the installed binary byte-identical, while `check-update` keeps reporting the managed status on stdout with a zero exit. - terraphim_agent: PackageManaged arm in the Command::Update handler (eprintln the refusal line, exit 1) - terraphim_grep: same arm in handle_update_command - update_refusal_tests.rs in both crates: stage the real compiled binary into a temp prefix with a real dpkg/rpm receipt (the updater's own path-derived detection) and assert exit code, exact stderr line, and unchanged bytes; check-update asserts the stdout line and zero exit. No network, no mocks. All six refusal tests pass against the real binaries; learn_no_service_tests and no_thesaurus_cli still pass; rustfmt clean. Refs #337 --- crates/terraphim_agent/src/main.rs | 18 +++- .../tests/update_refusal_tests.rs | 101 ++++++++++++++++++ crates/terraphim_grep/src/main.rs | 26 +++-- .../tests/update_refusal_tests.rs | 99 +++++++++++++++++ 4 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 crates/terraphim_agent/tests/update_refusal_tests.rs create mode 100644 crates/terraphim_grep/tests/update_refusal_tests.rs diff --git a/crates/terraphim_agent/src/main.rs b/crates/terraphim_agent/src/main.rs index 79428f70..e64ce89f 100644 --- a/crates/terraphim_agent/src/main.rs +++ b/crates/terraphim_agent/src/main.rs @@ -2028,10 +2028,20 @@ async fn run_offline_command( let config = UpdaterConfig::new("terraphim-agent").with_version(env!("CARGO_PKG_VERSION")); let updater = TerraphimUpdater::new(config); match updater.check_and_update().await { - Ok(status) => { - println!("{}", status); - return Ok(()); - } + Ok(status) => match status { + // A package-manager receipt owns this install: an explicit + // `update` must refuse with a non-zero exit and the exact + // stderr line the packaging lifecycle gates match on, leaving + // the installed binary untouched (Gitea #247 contract). + terraphim_update::UpdateStatus::PackageManaged { .. } => { + eprintln!("terraphim-agent update was refused: {}", status); + std::process::exit(1); + } + other => { + println!("{}", other); + return Ok(()); + } + }, Err(e) => { eprintln!("Update failed: {}", e); std::process::exit(1); diff --git a/crates/terraphim_agent/tests/update_refusal_tests.rs b/crates/terraphim_agent/tests/update_refusal_tests.rs new file mode 100644 index 00000000..9cc0c771 --- /dev/null +++ b/crates/terraphim_agent/tests/update_refusal_tests.rs @@ -0,0 +1,101 @@ +//! Real-binary tests for the package-managed update refusal contract. +//! +//! The packaging lifecycle gates (`.github/scripts/nfpm/tests/ +//! test_client_nfpm_native.sh` and `test_client_nfpm_native_actual.sh`) +//! install these binaries via dpkg/rpm and require an explicit `update` to +//! refuse: non-zero exit, an exact stderr line, and no write to the installed +//! executable. `check-update` must keep reporting the managed status on +//! stdout with a zero exit. +//! +//! These tests lock that contract against the real compiled binary using the +//! updater's own receipt detection (`/share/terraphim/package-manager.d/ +//! ` relative to the executable's `/bin/` layout): +//! the binary is staged into a temporary prefix with a real receipt file, so +//! no network is touched, no system path is written, and nothing is mocked. + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +const BIN_NAME: &str = "terraphim-agent"; + +/// Stage the real compiled binary into `/bin/terraphim-agent` with a +/// package-manager receipt beside it, exactly as the DEB/RPM packages lay it +/// out under `/usr`. +fn stage_managed_binary(manager: &str) -> (tempfile::TempDir, PathBuf) { + let tmp = tempfile::TempDir::new().expect("temp dir"); + let bin_dir = tmp.path().join("bin"); + let receipt_dir = tmp.path().join("share/terraphim/package-manager.d"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + fs::create_dir_all(&receipt_dir).expect("create receipt dir"); + let bin = bin_dir.join(BIN_NAME); + fs::copy(env!("CARGO_BIN_EXE_terraphim-agent"), &bin).expect("copy real binary"); + fs::write(receipt_dir.join(BIN_NAME), manager).expect("write receipt"); + (tmp, bin) +} + +/// Drive `update` under a receipt and assert the full refusal contract: +/// non-zero exit, the exact stderr line the gates `grep -Fxq` on, and a +/// byte-identical executable afterwards. +fn assert_update_refusal(manager: &str, guidance: &str) { + let (_tmp, bin) = stage_managed_binary(manager); + let before = fs::read(&bin).expect("read binary before update"); + + let output = Command::new(&bin) + .arg("update") + .output() + .expect("run update"); + + assert!( + !output.status.success(), + "update under a {} receipt must exit non-zero, got {:?}", + manager, + output.status.code() + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let expected = format!( + "{BIN_NAME} update was refused: [OK] Managed by {manager}; run `{guidance}` to update" + ); + assert!( + stderr.lines().any(|line| line == expected), + "stderr must contain the exact refusal line {expected:?}; stderr:\n{stderr}" + ); + + let after = fs::read(&bin).expect("read binary after update"); + assert_eq!( + before, after, + "update under a {manager} receipt must not rewrite the binary" + ); +} + +#[test] +fn update_refuses_under_dpkg_receipt() { + assert_update_refusal("dpkg", "sudo apt update && sudo apt upgrade"); +} + +#[test] +fn update_refuses_under_rpm_receipt() { + assert_update_refusal("rpm", "sudo dnf upgrade"); +} + +#[test] +fn check_update_reports_managed_on_stdout_with_zero_exit() { + let (_tmp, bin) = stage_managed_binary("dpkg"); + + let output = Command::new(&bin) + .arg("check-update") + .output() + .expect("run check-update"); + + assert!( + output.status.success(), + "check-update under a dpkg receipt must exit zero, got {:?}", + output.status.code() + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let expected = "[OK] Managed by dpkg; run `sudo apt update && sudo apt upgrade` to update"; + assert!( + stdout.lines().any(|line| line == expected), + "stdout must contain the exact managed line {expected:?}; stdout:\n{stdout}" + ); +} diff --git a/crates/terraphim_grep/src/main.rs b/crates/terraphim_grep/src/main.rs index cd71dbc4..1a542cae 100644 --- a/crates/terraphim_grep/src/main.rs +++ b/crates/terraphim_grep/src/main.rs @@ -148,18 +148,32 @@ fn grep_updater() -> TerraphimUpdater { async fn handle_update_command(command: Command) -> Result<()> { let updater = grep_updater(); - let status = match command { + match command { Command::CheckUpdate => { println!("Checking for terraphim-grep updates..."); - updater.check_update().await? + let status = updater.check_update().await?; + println!("{}", status); + Ok(()) } Command::Update => { println!("Updating terraphim-grep..."); - updater.check_and_update().await? + let status = updater.check_and_update().await?; + match status { + // A package-manager receipt owns this install: an explicit + // `update` must refuse with a non-zero exit and the exact + // stderr line the packaging lifecycle gates match on, leaving + // the installed binary untouched (Gitea #247 contract). + terraphim_update::UpdateStatus::PackageManaged { .. } => { + eprintln!("terraphim-grep update was refused: {}", status); + std::process::exit(1); + } + other => { + println!("{}", other); + Ok(()) + } + } } - }; - println!("{status}"); - Ok(()) + } } /// Discover project-level config from `.terraphim/` directory. diff --git a/crates/terraphim_grep/tests/update_refusal_tests.rs b/crates/terraphim_grep/tests/update_refusal_tests.rs new file mode 100644 index 00000000..089dad68 --- /dev/null +++ b/crates/terraphim_grep/tests/update_refusal_tests.rs @@ -0,0 +1,99 @@ +//! Real-binary tests for the package-managed update refusal contract. +//! +//! Mirrors `terraphim_agent`'s `update_refusal_tests`: the packaging +//! lifecycle gates install `terraphim-grep` via dpkg/rpm and require an +//! explicit `update` to refuse with a non-zero exit, the exact stderr line, +//! and no write to the installed executable, while `check-update` keeps +//! reporting the managed status on stdout with a zero exit. +//! +//! The real compiled binary is staged into a temporary prefix with a real +//! receipt file (`/share/terraphim/package-manager.d/`), +//! using the updater's own path-derived detection: no network, no system +//! paths, nothing mocked. + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +const BIN_NAME: &str = "terraphim-grep"; + +/// Stage the real compiled binary into `/bin/terraphim-grep` with a +/// package-manager receipt beside it, exactly as the DEB/RPM packages lay it +/// out under `/usr`. +fn stage_managed_binary(manager: &str) -> (tempfile::TempDir, PathBuf) { + let tmp = tempfile::TempDir::new().expect("temp dir"); + let bin_dir = tmp.path().join("bin"); + let receipt_dir = tmp.path().join("share/terraphim/package-manager.d"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + fs::create_dir_all(&receipt_dir).expect("create receipt dir"); + let bin = bin_dir.join(BIN_NAME); + fs::copy(env!("CARGO_BIN_EXE_terraphim-grep"), &bin).expect("copy real binary"); + fs::write(receipt_dir.join(BIN_NAME), manager).expect("write receipt"); + (tmp, bin) +} + +/// Drive `update` under a receipt and assert the full refusal contract: +/// non-zero exit, the exact stderr line the gates `grep -Fxq` on, and a +/// byte-identical executable afterwards. +fn assert_update_refusal(manager: &str, guidance: &str) { + let (_tmp, bin) = stage_managed_binary(manager); + let before = fs::read(&bin).expect("read binary before update"); + + let output = Command::new(&bin) + .arg("update") + .output() + .expect("run update"); + + assert!( + !output.status.success(), + "update under a {} receipt must exit non-zero, got {:?}", + manager, + output.status.code() + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let expected = format!( + "{BIN_NAME} update was refused: [OK] Managed by {manager}; run `{guidance}` to update" + ); + assert!( + stderr.lines().any(|line| line == expected), + "stderr must contain the exact refusal line {expected:?}; stderr:\n{stderr}" + ); + + let after = fs::read(&bin).expect("read binary after update"); + assert_eq!( + before, after, + "update under a {manager} receipt must not rewrite the binary" + ); +} + +#[test] +fn update_refuses_under_dpkg_receipt() { + assert_update_refusal("dpkg", "sudo apt update && sudo apt upgrade"); +} + +#[test] +fn update_refuses_under_rpm_receipt() { + assert_update_refusal("rpm", "sudo dnf upgrade"); +} + +#[test] +fn check_update_reports_managed_on_stdout_with_zero_exit() { + let (_tmp, bin) = stage_managed_binary("dpkg"); + + let output = Command::new(&bin) + .arg("check-update") + .output() + .expect("run check-update"); + + assert!( + output.status.success(), + "check-update under a dpkg receipt must exit zero, got {:?}", + output.status.code() + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let expected = "[OK] Managed by dpkg; run `sudo apt update && sudo apt upgrade` to update"; + assert!( + stdout.lines().any(|line| line == expected), + "stdout must contain the exact managed line {expected:?}; stdout:\n{stdout}" + ); +}