From cf1bb2e4361e4ea40e885bf75fc1d5c043bce7a6 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Tue, 6 Oct 2026 01:21:47 -0400 Subject: [PATCH] Repair native installs whose executable bytes drifted (#13) A valid managed receipt with mismatched executable bytes previously failed admission before any command dispatch, so `update` itself could not repair the installation and startup hard-failed every invocation. - `update` (explicit install) repairs: receipt-bound identity under the exclusive lock, drifted bytes preserved beside the install record, pinned installs restored to their recorded pin, recorded tag reinstalled when nothing newer is published. - `update status`/`update check` report `mismatch` instead of failing. - Automatic-policy startup repairs and re-enters the verified image before product work; non-auto policies and suppressing contexts fail closed as before. Attempts are daily-bounded via a dedicated last_repair_unix stamp so a routine check never delays self-healing. - Replacement invariants compare the stored receipt rather than the legitimately-drifted bytes; the recorded pin may no longer be changed by a replacement instead of pinning being impossible to publish. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 1 + rust/src/updater.rs | 318 ++++++++++++++++++++++++++++++++++++----- rust/tests/native.rs | 333 +++++++++++++++++++++++++++++++++++++++++++ spec/contract.json | 1 + 4 files changed, 618 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 2949484..384a9aa 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ check contacts your configured release service and records the check time. | Report | Next action | | --- | --- | | `busy` | Wait for the active command or package-manager operation to finish, then retry. Do not kill unrelated processes or remove their locks. | +| `mismatch` | The installed executable does not match its verified install receipt. An explicit `update` reinstalls a verified release; automatic-policy startups already repaired and re-entered. | | A previous update did not finish verification | Run the CLI's explicit `update` command or follow its documented reinstall procedure before running product commands. | | Repair would require a downgrade | Use the product's documented installer rather than forcing automatic replacement. | | `unsupported` | Update through the installation method you already use, such as your package manager. | diff --git a/rust/src/updater.rs b/rust/src/updater.rs index c11a269..839ee7f 100644 --- a/rust/src/updater.rs +++ b/rust/src/updater.rs @@ -177,20 +177,31 @@ impl InstallRequest<'_> { "Automatic update was cancelled by a saved policy change before replacement", )); } - let now = self.updater.inspect()?; - if now.receipt != self.installation.receipt { + // Compare the stored install record, not executable bytes: under a + // repair the bytes legitimately differ from the receipt being + // replaced, and drifted bytes get overwritten either way. + let now = self.updater.stored_receipt()?; + if now != self.installation.receipt { return Err(Error::new( ErrorCode::Ownership, "Installation changed before replacement", )); } - Ok(now) + Ok(VerifiedInstallation { receipt: now }) } /// Publish the new receipt only after the verified executable is in place. /// The product remains responsible for rollback if this fails. pub fn publish_receipt(&self, receipt: &InstallReceipt) -> Result<()> { self.updater.validate_target(receipt, self.release)?; + // A replacement preserves the recorded pin: repair restores a pinned + // install as pinned, and no update may silently set or clear one. + if receipt.pinned != self.installation.receipt.pinned { + return Err(Error::new( + ErrorCode::Ownership, + "Install receipt may not change the recorded version pin", + )); + } let existing: InstallReceipt = filesystem::read_json(filesystem::open_path(&self.paths.receipt, false)?)?; if existing != self.installation.receipt { @@ -224,6 +235,12 @@ pub enum UpdateStatus { Current, Available, Updated, + /// An install whose recorded release stayed selected but whose executable + /// bytes were restored because they no longer matched the receipt. + Repaired, + /// The install receipt is valid but the executable bytes differ from it; + /// an explicit `update` restores a verified release. + Mismatch, Enabled, Disabled, Unsupported, @@ -388,6 +405,7 @@ struct State { policy: Policy, last_check_unix: Option, installation_key: Option, + last_repair_unix: Option, } impl Default for State { fn default() -> Self { @@ -396,6 +414,7 @@ impl Default for State { policy: Policy::Auto, last_check_unix: None, installation_key: None, + last_repair_unix: None, } } } @@ -683,6 +702,20 @@ impl Updater { } fn inspect_native(&self, allow_pinned: bool) -> Result { + let installation = self.inspect_receipt_bound(allow_pinned)?; + if filesystem::sha256_file(&self.executable)? != installation.receipt.binary_sha256 { + return Err(Error::new( + ErrorCode::Ownership, + "Running executable bytes do not match the native install receipt", + )); + } + Ok(installation) + } + + /// Everything `inspect_native` proves except executable byte equality with + /// the receipt: a valid product-owned native receipt bound to this managed + /// path, outside package-manager and source-checkout locations. + fn inspect_receipt_bound(&self, allow_pinned: bool) -> Result { if !filesystem::supported() { return Err(Error::new(ErrorCode::Unsupported, "Native self-update is not yet supported on Windows; use the product's verified Windows installer. No executable or state was changed.")); } @@ -720,18 +753,19 @@ impl Updater { } } filesystem::verify_executable_mode(&self.executable)?; - if filesystem::sha256_file(&self.executable)? != receipt.binary_sha256 { - return Err(Error::new( - ErrorCode::Ownership, - "Running executable bytes do not match the native install receipt", - )); - } if receipt.pinned && !allow_pinned { return Err(Error::new(ErrorCode::Unsupported, "This installation is explicitly version-bound; self-update will not change the pin.")); } Ok(VerifiedInstallation { receipt }) } + /// The stored install record alone. Under an owned lock this is the + /// identity a replacement must not disturb; executable bytes are verified + /// separately where they matter. + fn stored_receipt(&self) -> Result { + filesystem::read_json(filesystem::open_path(&self.paths.receipt, false)?) + } + fn verify_loaded_image(&self, installation: &VerifiedInstallation) -> Result<()> { let matches = match &self.product.running_identity { RunningIdentity::Release { @@ -813,10 +847,7 @@ impl Updater { fn validate_target(&self, receipt: &InstallReceipt, release: &Release) -> Result<()> { receipt.validate(&self.product, &self.executable)?; - if receipt.pinned - || receipt.release_tag != release.tag_name - || receipt.release_id != release.id - { + if receipt.release_tag != release.tag_name || receipt.release_id != release.id { return Err(Error::new( ErrorCode::Ownership, "Installed receipt is not for the selected exact release", @@ -835,8 +866,9 @@ impl Updater { activity_lock: &OwnedLock, ) -> Result { coordination.check_lock(activity_lock, "activity.lock")?; - let checked = self.inspect()?; - if checked.receipt != current.receipt { + // Receipt equality is the invariant here; drifted executable bytes are + // exactly what this replacement is about to overwrite. + if self.stored_receipt()? != current.receipt { return Err(Error::new( ErrorCode::Ownership, "Installation changed before update", @@ -853,9 +885,15 @@ impl Updater { activity_lock, }; let result = installer.install(&request); - match (result, self.inspect()) { + // Post-install verification admits a preserved pin: it checks that the + // published receipt and executable bytes again describe the installed + // release, not whether the install is self-update eligible. + match (result, self.inspect_native(true)) { (Ok(()), Ok(after)) => { self.validate_target(&after.receipt, release).map_err(|_| Error::new(ErrorCode::UnsafeInstallation, "Installer did not publish the selected verified release; product work must not continue"))?; + if after.receipt.pinned != current.receipt.pinned { + return Err(Error::new(ErrorCode::UnsafeInstallation, "Update changed the installation's recorded version pin; product work must not continue. Restore using the product's verified installer.")); + } Ok(after) } (Err(error), Ok(after)) if after.receipt == current.receipt => Err(Error::new(ErrorCode::Installer, format!("Update failed and the original installation remains verified: {error}"))), @@ -897,16 +935,66 @@ impl Updater { )); } let state = self.state()?; - let current = match self.inspect() { - Ok(value) => value, + let (current, repair) = match self.inspect() { + Ok(value) => (value, false), Err(error) if error.code == ErrorCode::Unsupported => { - return Ok(self.report( - UpdateStatus::Unsupported, - state.policy, - None, - false, - Some(error.message), - )) + // `inspect()` reports pinned installations as unsupported before + // comparing bytes. A pinned install whose bytes drifted repairs + // to its recorded pin; everything else reports exactly as before. + match self.inspect_receipt_bound(true) { + Ok(bound) + if command == CommandAction::Install + && filesystem::sha256_file(&self.executable) + .is_ok_and(|sha| sha != bound.receipt.binary_sha256) => + { + (bound, true) + } + _ => { + return Ok(self.report( + UpdateStatus::Unsupported, + state.policy, + None, + false, + Some(error.message), + )) + } + } + } + // The receipt still proves this path is owned by the product's + // installer; only the executable bytes drifted (a manual copy, an + // interrupted earlier write). An explicit install repairs it; + // read-only actions report the mismatch instead of failing. + Err(error) if error.code == ErrorCode::Ownership => { + let bound = self.inspect_receipt_bound(true)?; + match command { + CommandAction::Install => (bound, true), + CommandAction::Check | CommandAction::Status => { + let mut report = self.report( + UpdateStatus::Mismatch, + state.policy, + Some(&bound), + false, + Some("The installed executable does not match its verified install receipt; an explicit `update` restores a verified release.".into()), + ); + if command == CommandAction::Check { + report.latest = source + .releases(&self.product) + .ok() + .and_then(|releases| { + release::select( + &self.product, + &bound.receipt.release_tag, + releases, + ) + .ok() + .flatten() + }) + .map(|release| release.tag_name); + } + return Ok(report); + } + _ => return Err(error), + } } Err(error) => return Err(error), }; @@ -931,13 +1019,27 @@ impl Updater { Some("Another command is using or updating this installation.".into()), )); }; - let current = self.inspect()?; - self.verify_loaded_image(¤t)?; - let selected = release::select( - &self.product, - ¤t.receipt.release_tag, - source.releases(&self.product)?, - )?; + let current = if repair { + // Receipt equality is the admission invariant under repair; the + // drifted bytes are exactly what replacement overwrites. + if self.stored_receipt()? != current.receipt { + return Err(Error::new( + ErrorCode::Ownership, + "Installation changed before repair", + )); + } + current + } else { + let checked = self.inspect()?; + self.verify_loaded_image(&checked)?; + checked + }; + let releases = source.releases(&self.product)?; + let selected = if repair { + self.repair_selection(¤t.receipt, releases)? + } else { + release::select(&self.product, ¤t.receipt.release_tag, releases)? + }; let Some(release) = selected else { return Ok(self.report( UpdateStatus::Current, @@ -958,6 +1060,11 @@ impl Updater { report.latest = Some(release.tag_name); return Ok(report); } + let preserved = if repair { + Some(self.preserve_drifted_bytes()?) + } else { + None + }; let after = self.replace(¤t, &release, installer, false, &coordination, &lock)?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -966,18 +1073,78 @@ impl Updater { store.update(|s| { s.last_check_unix = Some(now); s.installation_key = Some(self.key()); + s.last_repair_unix = None; })?; let mut report = self.report( - UpdateStatus::Updated, + if repair { + UpdateStatus::Repaired + } else { + UpdateStatus::Updated + }, state.policy, Some(&after), false, - None, + preserved.map(|path| { + format!( + "The replaced executable was preserved at {}.", + path.display() + ) + }), ); report.latest = Some(release.tag_name); Ok(report) } + /// Select what an explicit repair installs. A pinned installation is + /// restored to its recorded pin; otherwise the newest acceptable release + /// wins, falling back to reinstalling the recorded release itself. + fn repair_selection( + &self, + receipt: &InstallReceipt, + releases: Vec, + ) -> Result> { + let recorded = || { + releases + .iter() + .find(|release| release.tag_name == receipt.release_tag) + }; + let selected = if receipt.pinned { + recorded().cloned() + } else { + match release::select(&self.product, &receipt.release_tag, releases.clone())? { + Some(release) => Some(release), + None => recorded().cloned(), + } + }; + match selected { + Some(release) => { + release.validate(&self.product)?; + Ok(Some(release)) + } + None if receipt.pinned => Err(Error::new( + ErrorCode::Release, + "The pinned release recorded by this installation is no longer published; repair cannot change the pin.", + )), + None => Err(Error::new( + ErrorCode::Release, + "The release recorded by this installation is no longer published; reinstall with the product's verified installer.", + )), + } + } + + /// Preserve the drifted executable bytes beside the install record before + /// replacement. The path is receipt-owned, but the replaced bytes may be a + /// build someone still wants or evidence worth keeping. + fn preserve_drifted_bytes(&self) -> Result { + let sha = filesystem::sha256_file(&self.executable)?; + let backup = self + .coordination_directory() + .join(format!("replaced-{sha}")); + std::fs::copy(&self.executable, &backup) + .map_err(|error| Error::io("Preserve the replaced executable", error))?; + Ok(backup) + } + /// Product entrypoints with offline, pin, nesting or deployment admission /// policies should use this command boundary. CI/HRANESS_NO_UPDATE suppress /// incidental work only; an explicit update remains possible in CI. @@ -1033,6 +1200,22 @@ impl Updater { ), }); } + // A receipt-owned path whose bytes drifted repairs itself under + // the saved automatic policy before any product work runs them. + Err(error) + if error.code == ErrorCode::Ownership && self.may_repair(context) => + { + match self.repair_at_startup(context, source, installer) { + Ok(Some(outcome)) => return Ok(outcome), + Ok(None) => return Err(error), + Err(repair_error) => { + return Err(Error::new( + ErrorCode::UnsafeInstallation, + format!("Managed installation could not be verified: {error}. Automatic repair failed: {repair_error}"), + )) + } + } + } Err(error) => return Err(error), } } else { @@ -1058,10 +1241,23 @@ impl Updater { }); } Err(error) => { + if error.code == ErrorCode::Ownership && self.may_repair(context) { + drop(admission); + match self.repair_at_startup(context, source, installer) { + Ok(Some(outcome)) => return Ok(outcome), + Ok(None) => {} + Err(repair_error) => { + return Err(Error::new( + ErrorCode::UnsafeInstallation, + format!("Managed installation could not be verified after admission: {error}. Automatic repair failed: {repair_error}"), + )) + } + } + } return Err(Error::new( ErrorCode::UnsafeInstallation, format!("Managed installation could not be verified after admission: {error}"), - )) + )); } }; if preflight.is_some_and(|before| before.receipt != initial.receipt) { @@ -1214,6 +1410,58 @@ impl Updater { } } + /// Repair writes installed code, so it runs only under the saved automatic + /// policy and outside incidental-suppression contexts (explicit product + /// offline/nested/deployment bindings, CI, `--no-update`, re-entry). + fn may_repair(&self, context: &StartupContext) -> bool { + !context.no_incidental() && self.state().is_ok_and(|state| state.policy == Policy::Auto) + } + + /// Repair a receipt-owned installation whose executable bytes drifted, then + /// re-enter the verified image once. Returns None when a repair was already + /// attempted recently or did not change the installation; the caller then + /// refuses product work on the unverified bytes. + fn repair_at_startup( + &self, + context: &StartupContext, + source: &dyn ReleaseSource, + installer: &dyn Installer, + ) -> Result> { + // Bound automatic repair attempts separately from the daily check: a + // failing repair does not retry a download on every command start, and + // an unrelated recent check must not postpone self-healing. + let store = Store::open(&self.paths.state_dir, true)?; + let state = store.read()?; + let attempted = state.installation_key.as_deref() == Some(self.key().as_str()) + && state + .last_repair_unix + .is_some_and(|last| context.now_unix < last || context.now_unix - last < DAY); + if attempted { + return Ok(None); + } + store.update(|s| { + s.last_repair_unix = Some(context.now_unix); + s.installation_key = Some(self.key()); + })?; + let result = + self.execute_with_context(CommandAction::Install, context, source, installer)?; + if !matches!( + result.status, + UpdateStatus::Updated | UpdateStatus::Repaired + ) { + return Ok(None); + } + let verified = self.inspect()?; + let lease = Store::open(&self.coordination_directory(), true)?.shared()?; + Ok(Some(StartupOutcome::Reenter(Reentry { + updater: Box::new(self.clone()), + installation: Box::new(verified), + args: context.args.clone(), + lease, + report: result, + }))) + } + fn continue_under_lease( &self, store: &Store, diff --git a/rust/tests/native.rs b/rust/tests/native.rs index 7039434..237886e 100644 --- a/rust/tests/native.rs +++ b/rust/tests/native.rs @@ -1653,3 +1653,336 @@ fn delayed_old_compiled_image_is_rejected_after_atomic_path_replacement() { b"v1.1.0" ); } + +fn drift_executable(fixture: &Fixture, bytes: &[u8]) { + // Simulate a manual copy over the managed path: the receipt stays the + // record of a verified install while the bytes no longer match it. + fs::write(&fixture.executable, bytes).unwrap(); + fs::set_permissions(&fixture.executable, fs::Permissions::from_mode(0o755)).unwrap(); +} + +fn startup_error(outcome: Result) -> Error { + match outcome { + Err(error) => error, + _ => panic!("expected startup error"), + } +} + +fn preserved_drift(fixture: &Fixture, drifted: &[u8]) -> PathBuf { + fixture + .executable + .parent() + .unwrap() + .join(format!(".hraness-cli-update-{}", fixture.product.id)) + .join(format!("replaced-{}", digest(drifted))) +} + +#[test] +fn drifted_executable_reports_mismatch_and_explicit_update_repairs() { + let fixture = Fixture::new(); + let drifted = b"#!/bin/sh\nexit 99\n"; + drift_executable(&fixture, drifted); + + // Read-only actions diagnose the drift instead of failing before dispatch. + let status = fixture + .updater + .execute(CommandAction::Status, &Never, &Never) + .unwrap(); + assert_eq!(status.status, UpdateStatus::Mismatch); + assert_eq!(status.current.as_deref(), Some("v1.0.0")); + assert!(status.reason.unwrap().contains("receipt")); + let check = fixture + .updater + .execute(CommandAction::Check, &fixture.source("v1.1.0"), &Never) + .unwrap(); + assert_eq!(check.status, UpdateStatus::Mismatch); + assert_eq!(check.current.as_deref(), Some("v1.0.0")); + assert_eq!(check.latest.as_deref(), Some("v1.1.0")); + + // An explicit update reinstalls a verified release: drifted bytes are + // preserved beside the install record and the receipt again matches the + // executable it describes. + let installer = Replace::new(); + let report = fixture + .updater + .execute( + CommandAction::Install, + &fixture.source("v1.1.0"), + &installer, + ) + .unwrap(); + assert_eq!(report.status, UpdateStatus::Repaired); + assert_eq!(report.current.as_deref(), Some("v1.1.0")); + assert_eq!(report.latest.as_deref(), Some("v1.1.0")); + assert!(report.reason.unwrap().contains("preserved")); + assert_eq!(fs::read(&fixture.executable).unwrap(), installer.bytes); + assert_eq!(fixture.receipt().release_tag, "v1.1.0"); + assert_eq!( + fs::read(preserved_drift(&fixture, drifted)).unwrap(), + drifted + ); + fixture.updater.inspect().unwrap(); +} + +#[test] +fn drift_repairs_to_recorded_release_when_nothing_newer_exists() { + let fixture = Fixture::new(); + drift_executable(&fixture, b"drifted"); + let report = fixture + .updater + .execute( + CommandAction::Install, + &fixture.source("v1.0.0"), + &Replace::new(), + ) + .unwrap(); + assert_eq!(report.status, UpdateStatus::Repaired); + assert_eq!(report.latest.as_deref(), Some("v1.0.0")); + assert_eq!(fixture.receipt().release_tag, "v1.0.0"); + fixture.updater.inspect().unwrap(); +} + +#[test] +fn drifted_install_without_published_recorded_release_fails_closed() { + let fixture = Fixture::new(); + drift_executable(&fixture, b"drifted"); + let source = Source { + releases: vec![release_for(&fixture.product, "v0.9.0")], + calls: Cell::new(0), + offline: false, + }; + let error = fixture + .updater + .execute(CommandAction::Install, &source, &Replace::new()) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::Release); + assert_eq!(fs::read(&fixture.executable).unwrap(), b"drifted"); +} + +#[test] +fn drifted_pinned_install_repairs_to_its_pin() { + let fixture = Fixture::new(); + let mut receipt = fixture.receipt(); + receipt.pinned = true; + fixture.raw_receipt(&receipt); + drift_executable(&fixture, b"drifted"); + // Both tags remain published; the pin wins over the newer release. + let source = Source { + releases: vec![ + release_for(&fixture.product, "v1.0.0"), + release_for(&fixture.product, "v1.1.0"), + ], + calls: Cell::new(0), + offline: false, + }; + let report = fixture + .updater + .execute(CommandAction::Install, &source, &Replace::new()) + .unwrap(); + assert_eq!(report.status, UpdateStatus::Repaired); + assert_eq!(report.latest.as_deref(), Some("v1.0.0")); + // The pin is preserved and the receipt again describes the bytes on disk. + let receipt = fixture.receipt(); + assert_eq!(receipt.release_tag, "v1.0.0"); + assert!(receipt.pinned); + assert_eq!( + receipt.binary_sha256, + digest(&fs::read(&fixture.executable).unwrap()) + ); +} + +#[test] +fn startup_repairs_drift_and_reenters_verified_image() { + let fixture = Fixture::new(); + drift_executable(&fixture, b"drifted"); + let outcome = fixture + .updater + .startup( + &fixture.context(), + &fixture.source("v1.1.0"), + &Replace::new(), + ) + .unwrap(); + let reentry = match outcome { + StartupOutcome::Reenter(reentry) => reentry, + _ => panic!("expected reentry after automatic repair"), + }; + assert_eq!(reentry.report.status, UpdateStatus::Repaired); + assert_eq!(reentry.report.latest.as_deref(), Some("v1.1.0")); + drop(reentry); + fixture.updater.inspect().unwrap(); + + // The re-entered process is the repaired image: model it with the new + // embedded release identity and confirm it continues as an ordinary + // current installation without repeating repair or touching the network. + let mut product = fixture.product.clone(); + product.running_identity = RunningIdentity::Release { + release_tag: "v1.1.0", + build_sha: None, + }; + let reentered = + Updater::for_executable(product, fixture.paths.clone(), fixture.executable.clone()) + .unwrap(); + let mut context = fixture.context(); + context.reentered = true; + let source = fixture.source("v1.1.0"); + let (lease, report) = continuing(reentered.startup(&context, &source, &Never).unwrap()); + assert_eq!(report.status, UpdateStatus::Skipped); + assert_eq!(source.calls.get(), 0); + assert!(lease.is_some()); +} + +#[test] +fn drifted_startup_fails_closed_under_disabled_policy_but_update_repairs() { + let fixture = Fixture::new(); + // A prior healthy startup leaves the coordination records a real managed + // installation has, so the drifted startup exercises the admission arm. + let _ = continuing( + fixture + .updater + .startup(&fixture.context(), &fixture.source("v1.0.0"), &Never) + .unwrap(), + ); + drift_executable(&fixture, b"drifted"); + fixture + .updater + .execute(CommandAction::Disable, &Never, &Never) + .unwrap(); + let source = fixture.source("v1.1.0"); + let installer = Replace::new(); + let error = startup_error( + fixture + .updater + .startup(&fixture.context(), &source, &installer), + ); + assert_eq!(error.code, ErrorCode::UnsafeInstallation); + assert_eq!(source.calls.get(), 0); + assert_eq!(installer.calls.get(), 0); + + // The saved opt-out suppresses only automatic repair; an explicit update + // still restores a verified release. + let report = fixture + .updater + .execute( + CommandAction::Install, + &fixture.source("v1.0.0"), + &installer, + ) + .unwrap(); + assert_eq!(report.status, UpdateStatus::Repaired); + fixture.updater.inspect().unwrap(); +} + +#[test] +fn drifted_startup_suppressing_contexts_never_repair() { + let fixture = Fixture::new(); + drift_executable(&fixture, b"drifted"); + for context in [ + StartupContext { + offline: true, + ..fixture.context() + }, + StartupContext { + ci: true, + ..fixture.context() + }, + StartupContext { + reentered: true, + ..fixture.context() + }, + StartupContext { + no_update: true, + ..fixture.context() + }, + ] { + let source = fixture.source("v1.1.0"); + let installer = Replace::new(); + let error = startup_error(fixture.updater.startup(&context, &source, &installer)); + assert_eq!(error.code, ErrorCode::Ownership); + assert_eq!(source.calls.get(), 0); + assert_eq!(installer.calls.get(), 0); + } +} + +#[test] +fn failed_startup_repair_stays_closed_and_is_daily_bounded() { + let fixture = Fixture::new(); + drift_executable(&fixture, b"drifted"); + let failing = Source { + releases: vec![release_for(&fixture.product, "v1.1.0")], + calls: Cell::new(0), + offline: true, + }; + let error = startup_error(fixture.updater.startup( + &fixture.context(), + &failing, + &Replace::new(), + )); + assert_eq!(error.code, ErrorCode::UnsafeInstallation); + assert!(error.message.contains("repair failed")); + assert_eq!(failing.calls.get(), 1); + + // The attempt is recorded like the daily check: the next startup does not + // retry the network even though a healthy source would now succeed. + let healthy = fixture.source("v1.1.0"); + let error = startup_error(fixture.updater.startup( + &fixture.context(), + &healthy, + &Replace::new(), + )); + assert_eq!(error.code, ErrorCode::UnsafeInstallation); + assert_eq!(healthy.calls.get(), 0); +} + +#[test] +fn drifted_executable_still_rejects_foreign_receipts() { + let fixture = Fixture::new(); + drift_executable(&fixture, b"drifted"); + let mut receipt = fixture.receipt(); + receipt.executable = fixture.executable.with_file_name("different"); + fixture.raw_receipt(&receipt); + for action in [CommandAction::Install, CommandAction::Check] { + assert_eq!( + fixture + .updater + .execute(action, &fixture.source("v1.1.0"), &Replace::new()) + .unwrap_err() + .code, + ErrorCode::Ownership + ); + } +} + +#[test] +fn drifted_installer_failure_leaves_mismatch_closed() { + let fixture = Fixture::new(); + drift_executable(&fixture, b"drifted"); + // A failure after replacing bytes but before publishing the receipt leaves + // an uncertain installation: still mismatched, still refusing admission, + // still repairable by a subsequent explicit update. + let mut installer = Replace::new(); + installer.failure = Some("after"); + let error = fixture + .updater + .execute( + CommandAction::Install, + &fixture.source("v1.1.0"), + &installer, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::UnsafeInstallation); + assert_eq!( + fixture.updater.inspect().unwrap_err().code, + ErrorCode::Ownership + ); + let report = fixture + .updater + .execute( + CommandAction::Install, + &fixture.source("v1.0.0"), + &Replace::new(), + ) + .unwrap(); + assert_eq!(report.status, UpdateStatus::Repaired); + fixture.updater.inspect().unwrap(); +} diff --git a/spec/contract.json b/spec/contract.json index 5ae0402..45b9f60 100644 --- a/spec/contract.json +++ b/spec/contract.json @@ -21,6 +21,7 @@ "Preserve explicit product offline, nested invocation, deployment and exact-version-binding policies.", "A supported installation uses active-command leases and an exclusive update lock shared by aliases. Check leases again under the lock before replacing code.", "A successful automatic update re-enters the same verified installation once with original arguments and stdin before product work. It never replays completed work.", + "A valid native receipt whose executable bytes drifted is repairable: explicit `update` reinstalls a verified release under any policy, and automatic-policy startups repair and re-enter before product work. The drifted image never runs product work; repair failure or a non-automatic policy fails closed.", "Metadata/offline failures leave an ordinary command usable. An uncertain or failed code replacement must not execute mixed installed code.", "Do not kill, restart, or reconfigure another process, service, job, or product database." ],