From 8cfea2e8fcab4e6bfdbd5e3be371ef74721431fb Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 25 Aug 2026 19:41:10 +0500 Subject: [PATCH 1/2] feat: install the product itself, not only configure it This setup system now installs its harness. `software_install`, `software_update` and `software_remove` are declared, and the shape they take was agreed with the consumer on `ai-engineers-guild/ai_stp#414` rather than invented here: `--target` is the configuration directory and `--prefix` is the program directory, the plan carries an array `software_artifacts`, and `apply` receives one repeated `--software-artifact` per element in the plan's order. The provider never opens a socket. The contract gives software a download phase and gives a provider no command to run it in -- there is no `download` among the seven -- so `plan` names one url, one length and one digest while offline, whoever holds the network fetches exactly that, and `apply` re-checks it offline and installs. `setup-core::archive` reads the one shape every vendor ships: a gzip-compressed tar, or plain bytes. Both dialects that actually appear are accepted -- POSIX `ustar` and GNU tar with its long-name headers -- and exactly three entry types: a regular file, a directory, and that long name. Every other type flag is refused by name, because extraction that honours a symlink can be made to write through it. It streams, because one of these payloads inflates to 391 MB. DEFLATE comes from `miniz_oxide`; the gzip framing and the tar reading are here, where the refusals can be ours. The vendored provider kit moves to 0.2.1, which adds `unsupported_permission_profile`. A permission profile this build never advertised used to answer `projection_profile_mismatch` -- documented in place as a compromise, because the closed set had nothing better. It now answers what actually happened. --- Cargo.lock | 16 + Cargo.toml | 4 + crates/harness-runtime/src/facts.rs | 31 +- crates/harness-runtime/src/human.rs | 4 + crates/harness-runtime/src/lib.rs | 16 +- crates/harness-runtime/src/software.rs | 244 ++++ crates/harness-runtime/src/wire.rs | 602 ++++++++- crates/opencode-setup-system/src/main.rs | 3 + crates/opencode-setup-system/src/software.rs | 146 +++ crates/provider-v3/src/argv.rs | 75 +- crates/provider-v3/src/lib.rs | 2 +- crates/provider-v3/src/plan.rs | 44 +- crates/provider-v3/src/reason.rs | 5 + crates/provider-v3/src/vocabulary.rs | 22 + crates/provider-v3/src/zip.rs | 20 +- crates/setup-core/Cargo.toml | 1 + crates/setup-core/src/archive.rs | 1152 ++++++++++++++++++ crates/setup-core/src/checksum.rs | 63 + crates/setup-core/src/error.rs | 3 + crates/setup-core/src/lib.rs | 19 + crates/setup-core/src/software.rs | 595 +++++++++ provider-kit/v3/KIT-IDENTITY.json | 4 +- provider-kit/v3/SHA256SUMS | 4 +- provider-kit/v3/conformance-cases.json | 4 + provider-kit/v3/manifest.json | 3 +- references/opencode-baseline.json | 48 +- 26 files changed, 3058 insertions(+), 72 deletions(-) create mode 100644 crates/harness-runtime/src/software.rs create mode 100644 crates/opencode-setup-system/src/software.rs create mode 100644 crates/setup-core/src/archive.rs create mode 100644 crates/setup-core/src/checksum.rs create mode 100644 crates/setup-core/src/software.rs diff --git a/Cargo.lock b/Cargo.lock index 332de77..95fd472 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "block-buffer" version = "0.12.1" @@ -111,6 +117,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + [[package]] name = "opencode-setup-system" version = "0.0.1" @@ -196,6 +211,7 @@ dependencies = [ name = "setup-core" version = "0.0.1" dependencies = [ + "miniz_oxide", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index da5ff55..686f535 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,10 @@ authors = ["Danil Silantyev / NDDev"] serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } sha2 = "0.11" +# DEFLATE only. The gzip framing and the tar reading are ours (see +# `setup-core::archive`); an inflate loop is not, because its bugs are +# memory-safety bugs and it is not improved by being hand-written here. +miniz_oxide = "0.9" setup-core = { path = "crates/setup-core", version = "0.0.1" } provider-v3 = { path = "crates/provider-v3", version = "0.0.1" } harness-runtime = { path = "crates/harness-runtime", version = "0.0.1" } diff --git a/crates/harness-runtime/src/facts.rs b/crates/harness-runtime/src/facts.rs index 39284a4..78020f8 100644 --- a/crates/harness-runtime/src/facts.rs +++ b/crates/harness-runtime/src/facts.rs @@ -15,6 +15,7 @@ use provider_v3::{ Command, ComponentKind, Declaration, Operation, ProjectionKind, ProjectionProfile, ProviderInfo, }; use setup_core::digest; +use setup_core::software::{Delivery, Software}; /// One harness, as the runtime needs to know it. #[derive(Debug, Clone, Copy)] @@ -67,6 +68,13 @@ pub struct Harness { pub max_bytes: u64, /// The exact provider-kit revision this build was compiled against. pub kit_identity: &'static str, + /// How the product's own software is installed, when this build can do it. + /// + /// `None` means the software lifecycle is not offered at all. So does a + /// [`Delivery::Manager`], which is a different statement -- the product is + /// installable, but not by fetching bytes whose digest was fixed in advance + /// -- and the refusal says which. + pub software: Option, } /// How many backup slots a target keeps. @@ -180,6 +188,24 @@ impl Harness { /// implement them — declaring one would let a consumer call an operation /// that cannot be honoured, which is worse than not offering it. /// + /// The operations this build actually performs. + /// + /// The software lifecycle is optional in the contract, and declaring an + /// operation a build cannot perform lets a consumer ask for something that + /// cannot be honoured. So it appears here only when this harness carries an + /// artifact table -- never when the product is delivered by a package + /// manager this provider does not run. + #[must_use] + pub fn operations(&self) -> &'static [Operation] { + match self.software { + Some(Software { + delivery: Delivery::Artifacts(_), + .. + }) => Operation::CORE_AND_SOFTWARE, + _ => Operation::CORE, + } + } + /// # Errors /// /// Propagates a declaration refusal. @@ -191,7 +217,7 @@ impl Harness { provider_version: self.version, provider_build_digest: &build_digest, commands: Command::CORE, - operations: Operation::CORE, + operations: self.operations(), supported_os: &["linux", "macos", "windows"], supported_arch: &["x86_64", "arm64"], permission_profiles: self.permission_profiles, @@ -206,7 +232,10 @@ mod tests { use super::*; + /// A harness that offers no software lifecycle, which is most of what the + /// declaration tests are about. pub(crate) const SAMPLE: Harness = Harness { + software: None, harness_id: "sample", provider_id: "sample-setup-system", version: "0.1.0", diff --git a/crates/harness-runtime/src/human.rs b/crates/harness-runtime/src/human.rs index 5f5b2bb..df7879b 100644 --- a/crates/harness-runtime/src/human.rs +++ b/crates/harness-runtime/src/human.rs @@ -559,6 +559,10 @@ fn mutate( restore_target_digest, permission_profile: None, expires_at: &expiry::deadline_in(PLAN_WINDOW_SECONDS, SystemTime::now()), + // The human surface drives configuration, never the product's own + // install: that arrives over the wire, with artifacts somebody else + // downloaded between planning and applying. + software_artifacts: Vec::new(), effects: effect_lines(harness, &effect, applied.setup_id.as_deref()), })?; let plan_digest = artifact.digest()?; diff --git a/crates/harness-runtime/src/lib.rs b/crates/harness-runtime/src/lib.rs index 0c80002..3ef6dda 100644 --- a/crates/harness-runtime/src/lib.rs +++ b/crates/harness-runtime/src/lib.rs @@ -17,19 +17,27 @@ //! `replace` materialize an `ai-stp-bundle/1` the consumer sends, or a complete //! setup from the local catalog when the owner asks for one by name. //! -//! The software lifecycle and `launch` are optional in the contract and are not -//! declared at all. Declaring an optional operation this runtime cannot perform -//! would let a consumer call something that cannot be honoured, which is worse -//! than not offering it. +//! The software lifecycle is optional in the contract, and a harness declares +//! it only when it carries an artifact table -- so six of the seven do, and pi +//! does not, because npm resolves its dependency closure at install time and +//! there is no single artifact whose digest can be fixed in advance. `launch` +//! is declared by none. Declaring an optional operation this runtime cannot +//! perform would let a consumer call something that cannot be honoured, which +//! is worse than not offering it. pub mod catalog; pub mod expiry; pub mod facts; pub mod human; +pub(crate) mod software; pub mod wire; pub use catalog::{Catalog, Setup}; +// The software types belong to the kernel, but a setup system declares its +// artifact table and depends only on this crate. Re-exported so that stays +// true rather than widening seven dependency lists to reach past it. pub use facts::{BACKUP_SLOTS, BUNDLE_FORMAT, Harness}; +pub use setup_core::software::{Artifact, Delivery, Shape, Software}; pub use wire::dispatch; use std::process::ExitCode; diff --git a/crates/harness-runtime/src/software.rs b/crates/harness-runtime/src/software.rs new file mode 100644 index 0000000..4d6f71a --- /dev/null +++ b/crates/harness-runtime/src/software.rs @@ -0,0 +1,244 @@ +//! The product's own lifecycle: planning what to fetch, and installing it. +//! +//! The argv and the plan shape here are not this program's invention. They were +//! proposed on `ai-engineers-guild/ai_stp#414`, agreed, and recorded in that +//! project's `docs/contracts/provider-protocol.md`. Where an earlier version of +//! this file guessed, it guessed differently -- one `--artifact`, a single +//! object, the program under the target -- and the agreement is what this now +//! follows: +//! +//! * `--target` is the configuration directory; `--prefix` is the program +//! directory. Different paths with different lifetimes, both absolute. +//! * The plan carries an array. One element is one file, and `apply` receives +//! one repeated `--software-artifact` per element **in that order**, so which +//! file answers which entry is never inferred. +//! * `--software-version` omitted means the pinned version; given means exactly +//! that one. +//! * An unpinned platform refuses with `unsupported_platform`. +//! * `software_remove` plans and applies with no download and no artifact. +//! +//! It is deliberately not routed through [`crate::wire::perform`], and the +//! reason is not convenience. That path exists to mutate the namespaces this +//! provider owns inside a *target*: it captures a backup slot, re-checks the +//! target's identity, and journals. A software install writes under `--prefix` +//! and touches no namespace, so running it through that path would spend one of +//! ten backup slots on a capture nobody can use. Installing ten times would +//! evict every configuration backup the target had. +//! +//! What it does keep is a lock, because two installs racing into one directory +//! is a real failure. It needs nothing more, because the layout makes the +//! operation atomic by construction: bytes land in a directory named for their +//! version, and the entry point is pointed at them only once every byte is +//! written. An interrupted install leaves a partial directory the next one +//! replaces, and an entry point still naming the version that worked. + +use std::path::{Path, PathBuf}; + +use provider_v3::plan::SoftwareArtifact; +use provider_v3::{Error, Operation, Result, WireReason}; +use setup_core::platform_of_this_host; +use setup_core::software::{self, Delivery, Software}; + +use crate::facts::Harness; + +/// The software this harness installs, or the reason it installs none. +fn declared(harness: &Harness) -> Result { + match harness.software { + Some( + found @ Software { + delivery: Delivery::Artifacts(_), + .. + }, + ) => Ok(found), + Some(Software { + delivery: Delivery::Manager { tool, reason }, + command, + .. + }) => Err(Error::refuse( + WireReason::UnsupportedOperation, + format!( + "{command} is installed by {tool}, which resolves a dependency closure: {reason}" + ), + )), + None => Err(Error::refuse( + WireReason::UnsupportedOperation, + format!( + "{} does not implement the software lifecycle", + harness.provider_id + ), + )), + } +} + +/// The program directory a software operation was given. +fn program_directory(prefix: Option<&Path>, operation: Operation) -> Result { + prefix.map(Path::to_path_buf).ok_or_else(|| { + Error::refuse( + WireReason::ProviderUnavailable, + format!( + "{operation} installs a program, which lives under --prefix, not under --target; \ + name an absolute --prefix" + ), + ) + }) +} + +/// The version this operation is for, refusing one this build does not pin. +fn version_for(declared: &Software, asked: Option<&str>) -> Result<()> { + match asked { + None => Ok(()), + Some(wanted) if wanted == declared.version => Ok(()), + Some(wanted) => Err(Error::refuse( + WireReason::UnsupportedOperation, + format!( + "this build pins {} {}; it cannot install {wanted}, and installing the pinned one \ + instead would be answering a question nobody asked", + declared.command, declared.version + ), + )), + } +} + +/// Plan one software operation: name the exact bytes, with no network open. +/// +/// # Errors +/// +/// Refuses when this harness declares no software lifecycle, when no `--prefix` +/// was given, when a version other than the pinned one was asked for, or when +/// the vendor publishes no build for the running platform. +pub(crate) fn plan( + harness: &Harness, + prefix: Option<&Path>, + operation: Operation, + software_version: Option<&str>, +) -> Result<(Vec, Vec)> { + let declared = declared(harness)?; + let root = program_directory(prefix, operation)?; + version_for(&declared, software_version)?; + + let entry_point = format!("bin/{}", declared.command); + let exposed = root.join(&entry_point); + + if operation == Operation::SoftwareRemove { + return Ok(( + Vec::new(), + vec![ + format!( + "remove {}, the {} tree this provider installed", + root.join(declared.version).display(), + declared.version + ), + format!("remove {}", exposed.display()), + ], + )); + } + + let (os, arch) = platform_of_this_host(); + let artifact = declared.artifact_for(os, arch)?; + let effects = vec![ + format!( + "download {} ({} bytes) in the operation's own download phase", + artifact.url, artifact.bytes + ), + format!("check those bytes against {}", artifact.sha256), + match artifact.shape { + software::Shape::Raw => format!( + "place them as {}", + root.join(declared.version).join(declared.command).display() + ), + software::Shape::GzipTar => format!( + "extract them into {}, whose {} is the program", + root.join(declared.version).display(), + artifact.member + ), + }, + format!("point {} at it", exposed.display()), + ]; + + Ok(( + vec![SoftwareArtifact { + platform: artifact.platform.to_owned(), + url: artifact.url.to_owned(), + sha256: artifact.sha256.to_owned(), + byte_length: artifact.bytes, + entry_point, + }], + effects, + )) +} + +/// Apply one software operation under a lock, with no network open. +/// +/// # Errors +/// +/// Refuses a missing `--prefix`, a count of downloaded files that does not match +/// what the plan named, bytes that are not the ones it named, or an archive that +/// does not hold the member it named. +pub(crate) fn apply( + harness: &Harness, + prefix: Option<&Path>, + operation: Operation, + downloaded: &[PathBuf], +) -> Result { + let declared = declared(harness)?; + let root = program_directory(prefix, operation)?; + + // The lock lives in this provider's own dotted directory inside the prefix, + // not at its root. `acquire` takes a control directory, and a `target.lock` + // sitting beside `bin/` in a program directory would be both misnamed and + // in the way of whoever looks in there for a program. + let control = root.join(harness.control_directory); + std::fs::create_dir_all(&control).map_err(|error| { + Error::refuse( + WireReason::ProviderUnavailable, + format!("--prefix {} cannot be created: {error}", root.display()), + ) + })?; + let mut guard = setup_core::lock::TargetLock::acquire(&control)?; + guard.annotate(&format!("{} {operation}", harness.provider_id))?; + + if operation == Operation::SoftwareRemove { + if !downloaded.is_empty() { + return Err(Error::refuse( + WireReason::UnsupportedOperation, + "software_remove downloads nothing, so it takes no --software-artifact", + )); + } + let removed = software::remove(&declared, &root)?; + return Ok(serde_json::json!({ + "state": "verified", + "operation": operation.as_str(), + "command": declared.command, + "version": declared.version, + "removed": removed, + })); + } + + // One file per entry the plan named. This build's table names one, so a + // second file is a caller holding a plan from a different build -- which the + // plan digest already refuses, but saying which mismatch it is costs a line. + let [path] = downloaded else { + return Err(Error::refuse( + WireReason::ProviderUnavailable, + format!( + "{operation} installs the 1 artifact the plan named, and {} were given; \ + pass one --software-artifact per entry, in the plan's order", + downloaded.len() + ), + )); + }; + + let (os, arch) = platform_of_this_host(); + let artifact = declared.artifact_for(os, arch)?; + let installed = software::install(&declared, artifact, path, &root)?; + + Ok(serde_json::json!({ + "state": "verified", + "operation": operation.as_str(), + "command": declared.command, + "version": installed.version, + "entry_point": format!("bin/{}", declared.command), + "executable": installed.executable.to_string_lossy(), + "files": installed.files, + })) +} diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index ce56428..6e4b329 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -34,6 +34,7 @@ use setup_core::{digest, lock}; use crate::catalog::Setup; use crate::expiry; use crate::facts::{self, Harness}; +use crate::software; /// Answer one parsed invocation. /// @@ -56,8 +57,18 @@ pub fn dispatch(harness: &Harness, invocation: Invocation) -> Result apply(harness, &target, &plan_path, &plan_digest, bundle.as_ref()), + } => apply( + harness, + &target, + &plan_path, + &plan_digest, + bundle.as_ref(), + prefix.as_deref(), + &software_artifacts, + ), Invocation::RecoverOperation { target } => recover(harness, &target), Invocation::Launch { .. } => Err(Error::refuse( WireReason::UnsupportedOperation, @@ -279,20 +290,46 @@ fn validate_bundle(harness: &Harness, bundle: &ArgvBundle) -> serde_json::Value /// plan that could never be applied -- and a refusal deferred to apply time /// arrives after the consumer has stored the plan, scheduled it, and come back. fn honourable(harness: &Harness, request: &PlanRequest) -> Result<()> { + // A flag that means nothing to this operation is refused rather than + // dropped: silently ignoring it would report success for a request that was + // only partly understood, which is the rule the argv parser already keeps. + if !Operation::SOFTWARE.contains(&request.operation) { + if let Some(named) = request.prefix.as_deref() { + return Err(Error::refuse( + WireReason::UnsupportedOperation, + format!( + "{} configures a target and installs no program, so --prefix {} means \ + nothing to it", + request.operation, + named.display() + ), + )); + } + if let Some(asked) = request.software_version.as_deref() { + return Err(Error::refuse( + WireReason::UnsupportedOperation, + format!( + "{} installs no program, so --software-version {asked:?} means nothing to it", + request.operation + ), + )); + } + } + // A profile this build never advertised cannot be honoured, and recording // it in a plan would be worse than refusing: the apply would run under the // only posture this build has while the artifact claimed another. // - // The reason is a compromise and worth naming as one. The contract's closed - // set carries no permission-profile refusal -- `unsupported_operation` is - // false because the operation is supported, and this is the nearest thing - // to "a profile you named is not one I have". The detail says exactly what - // happened, because that is the part a reader can trust here. + // This used to answer `projection_profile_mismatch`, documented here as a + // compromise: the closed set carried no permission-profile refusal, and that + // was the nearest thing to "a profile you named is not one I have". Kit + // 0.2.1 added `unsupported_permission_profile`, so the compromise is over + // and the reason says what actually happened. if let Some(profile) = request.permission_profile.as_deref() && !harness.permission_profiles.contains(&profile) { return Err(Error::refuse( - WireReason::ProjectionProfileMismatch, + WireReason::UnsupportedPermissionProfile, format!( "{profile:?} is not a permission profile {} declares; it offers {:?}", harness.provider_id, harness.permission_profiles @@ -318,6 +355,38 @@ fn honourable(harness: &Harness, request: &PlanRequest) -> Result<()> { } /// Produce a plan without touching the target. +/// What writing a bundle over the target will do, enumerated for the plan. +/// +/// The bundle is read and verified here rather than at apply time only, so a +/// plan is never issued for bytes that would be refused when it was applied. +fn bundle_effects(harness: &Harness, request: &PlanRequest) -> Result> { + let Some(named) = request.bundle.as_ref() else { + return Err(Error::refuse( + WireReason::UnsupportedBundleFormat, + format!( + "{} arrives as a bundle, and none was named", + request.operation + ), + )); + }; + let verified = verified_bundle(harness, named)?; + let mut effects = vec![ + "capture the current target into a new backup slot".to_owned(), + format!( + "write the {} declared files over the entries this provider owns", + verified.files.len() + ), + ]; + effects.extend( + verified + .files + .keys() + .take(16) + .map(|path| format!("write {path}")), + ); + Ok(effects) +} + fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result { let (resolved, control, pool) = open(harness, target)?; setup_core::journal::require_clean_for_planning( @@ -332,7 +401,18 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result { + let (planned, effects) = software::plan( + harness, + request.prefix.as_deref(), + request.operation, + request.software_version.as_deref(), + )?; + software_artifacts = planned; + (effects, None, None) + } Operation::Backup => ( vec![format!( "capture {} into a new backup slot", @@ -361,34 +441,8 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result { - let Some(named) = request.bundle.as_ref() else { - return Err(Error::refuse( - WireReason::UnsupportedBundleFormat, - format!( - "{} arrives as a bundle, and none was named", - request.operation - ), - )); - }; - let verified = verified_bundle(harness, named)?; - let mut effects = vec![ - "capture the current target into a new backup slot".to_owned(), - format!( - "write the {} declared files over the entries this provider owns", - verified.files.len() - ), - ]; - effects.extend( - verified - .files - .keys() - .take(16) - .map(|path| format!("write {path}")), - ); - (effects, None, None) - } - other => { + Operation::Install | Operation::Replace => (bundle_effects(harness, request)?, None, None), + other @ Operation::Launch => { return Err(Error::refuse( WireReason::UnsupportedOperation, format!("{other} is not declared by this provider"), @@ -411,6 +465,7 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result, + prefix: Option<&Path>, + // `downloaded`, not `artifacts`: the plan artifact is read into a local of + // a similar name a few lines below, and one of the two had to give way. + downloaded: &[std::path::PathBuf], ) -> Result { let verified: Option; let artifact = load_plan(plan_path, plan_digest)?; @@ -539,6 +598,23 @@ fn apply( Some(_) => {} } + // The software lifecycle writes under the control directory and never + // touches the namespaces the effect machinery below exists to mutate, so it + // parts company here rather than pretending to be one of those effects. + if Operation::SOFTWARE.contains(&operation) { + return software::apply(harness, prefix, operation, downloaded); + } + if let Some(named) = prefix { + return Err(Error::refuse( + WireReason::UnsupportedOperation, + format!( + "{operation} configures a target and installs no program, so --prefix {} means \ + nothing to it", + named.display() + ), + )); + } + // A bundle names itself: the contract asks provider state to record which // bundle put the bytes there, and it arrives bound to exact identities. let mut applied = Applied::default(); @@ -977,7 +1053,44 @@ pub(crate) mod tests_support { use crate::facts::Harness; + /// The bytes `TEST_SOFTWARE` installs. A shell script rather than a real + /// binary, so the test can run what it installed and read the answer. + pub(crate) const TEST_PAYLOAD: &[u8] = b"#!/bin/sh\nexec echo test-harness 1.2.3\n"; + + /// One artifact, published for every platform, so the test does not depend + /// on which machine runs it. `Raw` because raw bytes have one digest on + /// every system, where a compressor's output is only as stable as its + /// version. + pub(crate) const TEST_ARTIFACTS: &[setup_core::software::Artifact] = &[ + test_artifact("linux/x86_64"), + test_artifact("linux/arm64"), + test_artifact("macos/x86_64"), + test_artifact("macos/arm64"), + test_artifact("windows/x86_64"), + test_artifact("windows/arm64"), + ]; + + const fn test_artifact(platform: &'static str) -> setup_core::software::Artifact { + setup_core::software::Artifact { + platform, + url: "https://example.invalid/test-harness", + bytes: 39, + sha256: "sha256:0c7c47cc1bc9116feb15bd468d039e954093ccfca8d6246b32ea94d1ab2213ad", + shape: setup_core::software::Shape::Raw, + member: "", + } + } + + pub(crate) const TEST_SOFTWARE: setup_core::software::Software = + setup_core::software::Software { + version: "1.2.3", + command: "test-harness", + delivery: setup_core::software::Delivery::Artifacts(TEST_ARTIFACTS), + unsupported: &[], + }; + pub(crate) const TEST: Harness = Harness { + software: Some(TEST_SOFTWARE), harness_id: "test", provider_id: "test-setup-system", version: "0.1.0", @@ -1014,7 +1127,7 @@ mod tests { use super::*; - use crate::wire::tests_support::TEST; + use crate::wire::tests_support::{TEST, TEST_PAYLOAD}; const RELEASE: &str = "sha256:3333333333333333333333333333333333333333333333333333333333333333"; @@ -1226,7 +1339,13 @@ mod tests { "not-declared-anywhere", ], )); - assert!(error.reason().is_some(), "a refusal must name a reason"); + // This assertion used to be `is_some()`, because the closed set carried + // no permission-profile refusal and the reason on the wire was a + // documented compromise. Kit 0.2.1 added one, so the test names it. + assert_eq!( + error.reason(), + Some(WireReason::UnsupportedPermissionProfile) + ); assert!( error.detail().contains("not-declared-anywhere"), "{}", @@ -1911,4 +2030,413 @@ mod tests { "# first\n" ); } + + // ── the software lifecycle ─────────────────────────────────────────────── + + /// The program directory: a sibling of the target, never inside it. + fn prefix_for(target: &Path) -> std::path::PathBuf { + target.join("..").join("program") + } + + /// Write the bytes a consumer would have fetched between the two phases. + fn downloaded(target: &Path, bytes: &[u8]) -> std::path::PathBuf { + let at = target.join("..").join("downloaded-artifact"); + fs::write(&at, bytes).unwrap(); + at + } + + /// The argv every software plan in these tests shares. + fn software_plan_args<'a>(operation: &'a str, prefix: &'a str) -> Vec<&'a str> { + vec![ + "--operation", + operation, + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01SOFT", + "--expires-at", + far_future(), + "--prefix", + prefix, + ] + } + + /// An absolute program directory beside the target, created and canonical. + fn ready_prefix(target: &Path) -> String { + let prefix = prefix_for(target); + fs::create_dir_all(&prefix).unwrap(); + fs::canonicalize(&prefix) + .unwrap() + .to_string_lossy() + .into_owned() + } + + fn plan_then_install( + target: &Path, + operation: &str, + artifact: Option<&Path>, + ) -> serde_json::Value { + let prefix = ready_prefix(target); + let planned = run(args( + "plan-operation", + target, + &software_plan_args(operation, &prefix), + )); + assert_eq!(planned["state"], "planned", "plan refused: {planned}"); + let plan_path = target.join("..").join(format!("plan-{operation}.json")); + fs::write( + &plan_path, + setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(), + ) + .unwrap(); + + let digest = planned["plan_digest"].as_str().unwrap().to_owned(); + let path = plan_path.to_string_lossy().into_owned(); + let mut extra = vec![ + "--plan", + &path, + "--plan-digest", + &digest, + "--provider-release-digest", + RELEASE, + "--prefix", + &prefix, + ]; + let held; + if let Some(file) = artifact { + held = file.to_string_lossy().into_owned(); + extra.push("--software-artifact"); + extra.push(&held); + } + run(args("apply-operation", target, &extra)) + } + + /// Plan a software operation and return the whole response. + fn software_plan(target: &Path, operation: &str) -> serde_json::Value { + let prefix = ready_prefix(target); + run(args( + "plan-operation", + target, + &software_plan_args(operation, &prefix), + )) + } + + #[test] + fn a_software_plan_names_the_exact_bytes_before_any_network_is_open() { + let target = seeded("software-plan"); + let planned = software_plan(&target, "software_install"); + + // The array, and the five fields agreed on ai_stp#414. One element is + // one file, and apply receives one --software-artifact per element. + let artifacts = planned["plan"]["software_artifacts"].as_array().unwrap(); + assert_eq!(artifacts.len(), 1); + let only = &artifacts[0]; + let mut fields: Vec<&str> = only + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + fields.sort_unstable(); + assert_eq!( + fields, + vec!["byte_length", "entry_point", "platform", "sha256", "url"], + "the plan carries the agreed fields and no others" + ); + assert_eq!(only["byte_length"], 39); + assert_eq!( + only["sha256"], + "sha256:0c7c47cc1bc9116feb15bd468d039e954093ccfca8d6246b32ea94d1ab2213ad" + ); + assert_eq!(only["entry_point"], "bin/test-harness"); + + // The plan says the download is somebody else's phase, which is why + // this provider opens no socket in any of the three. + let effects = planned["effects"].as_array().unwrap(); + assert!( + effects[0].as_str().unwrap().contains("download phase"), + "{effects:?}" + ); + } + + #[test] + fn a_software_operation_without_a_prefix_says_where_a_program_lives() { + let target = seeded("software-noprefix"); + let error = refuse(args( + "plan-operation", + &target, + &[ + "--operation", + "software_install", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01SOFT", + "--expires-at", + far_future(), + ], + )); + assert!(error.detail().contains("--prefix"), "{}", error.detail()); + } + + #[test] + fn a_relative_prefix_is_refused_because_a_plan_cannot_be_bound_to_one() { + // Refused by the parser, before dispatch sees it: a path that resolves + // against whatever directory the caller happened to be in is not + // something a plan can be bound to, and that is true of every command. + let target = seeded("software-relprefix"); + let error = argv::parse(args( + "plan-operation", + &target, + &software_plan_args("software_install", "program"), + )) + .unwrap_err(); + assert!(error.detail().contains("absolute"), "{}", error.detail()); + } + + #[test] + fn a_prefix_on_an_operation_that_installs_nothing_is_refused_not_ignored() { + let target = seeded("software-strayprefix"); + let error = refuse(args( + "plan-operation", + &target, + &[ + "--operation", + "backup", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01SOFT", + "--expires-at", + far_future(), + "--prefix", + "/tmp", + ], + )); + assert!( + error.detail().contains("means nothing"), + "{}", + error.detail() + ); + } + + #[test] + fn a_version_this_build_does_not_pin_is_refused_rather_than_neighboured() { + let target = seeded("software-version"); + let prefix = ready_prefix(&target); + let mut arguments = software_plan_args("software_install", &prefix); + arguments.extend_from_slice(&["--software-version", "9.9.9"]); + let error = refuse(args("plan-operation", &target, &arguments)); + assert!(error.detail().contains("1.2.3"), "{}", error.detail()); + + // The pinned one is accepted when named explicitly. + let mut exact = software_plan_args("software_install", &prefix); + exact.extend_from_slice(&["--software-version", "1.2.3"]); + let planned = run(args("plan-operation", &target, &exact)); + assert_eq!(planned["state"], "planned"); + } + + #[test] + fn installing_places_a_command_and_leaves_the_configuration_alone() { + let target = seeded("software-install"); + let before = run(args("status", &target, &[]))["target_identity_digest"].clone(); + + let file = downloaded(&target, TEST_PAYLOAD); + let applied = plan_then_install(&target, "software_install", Some(&file)); + assert_eq!(applied["state"], "verified"); + assert_eq!(applied["version"], "1.2.3"); + + let exposed = Path::new(&ready_prefix(&target)) + .to_path_buf() + .join("bin") + .join("test-harness"); + assert!(exposed.symlink_metadata().is_ok(), "no command was exposed"); + assert_eq!(fs::read(&exposed).unwrap(), TEST_PAYLOAD); + + // The bytes live in a directory named for their version, so a second + // version can arrive without disturbing this one. + assert!( + Path::new(&ready_prefix(&target)) + .to_path_buf() + .join("1.2.3") + .join("test-harness") + .is_file() + ); + + // The whole claim of this path: a program was installed and not one + // byte of the configuration this provider owns moved. + let after = run(args("status", &target, &[]))["target_identity_digest"].clone(); + assert_eq!( + before, after, + "installing software moved the target identity" + ); + assert_eq!( + fs::read_to_string(target.join("AGENTS.md")).unwrap(), + "# first\n" + ); + } + + #[test] + #[cfg(unix)] + fn what_was_installed_actually_runs() { + let target = seeded("software-runs"); + let file = downloaded(&target, TEST_PAYLOAD); + let applied = plan_then_install(&target, "software_install", Some(&file)); + + let output = std::process::Command::new(applied["executable"].as_str().unwrap()) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "test-harness 1.2.3" + ); + } + + #[test] + fn installing_software_spends_no_backup_slot() { + // Ten slots exist and they hold configuration. If a software install + // captured one, installing ten times would evict every backup a person + // took of the thing this provider actually owns. + let target = seeded("software-slots"); + let file = downloaded(&target, TEST_PAYLOAD); + plan_then_install(&target, "software_install", Some(&file)); + + let slots = target.join(TEST.control_directory).join("backups"); + let taken = fs::read_dir(&slots).map_or(0, Iterator::count); + assert_eq!( + taken, 0, + "a software install captured a configuration backup" + ); + } + + #[test] + fn bytes_that_are_not_the_ones_the_plan_named_are_refused() { + let target = seeded("software-digest"); + let mut tampered = TEST_PAYLOAD.to_vec(); + tampered[0] = b'X'; + let file = downloaded(&target, &tampered); + + let prefix = ready_prefix(&target); + let planned = software_plan(&target, "software_install"); + let plan_path = target.join("..").join("plan-tampered.json"); + fs::write( + &plan_path, + setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(), + ) + .unwrap(); + let error = refuse(args( + "apply-operation", + &target, + &[ + "--plan", + &plan_path.to_string_lossy(), + "--plan-digest", + planned["plan_digest"].as_str().unwrap(), + "--provider-release-digest", + RELEASE, + "--prefix", + &prefix, + "--software-artifact", + &file.to_string_lossy(), + ], + )); + assert_eq!(error.reason(), Some(WireReason::DigestMismatch)); + assert!( + !Path::new(&ready_prefix(&target)) + .to_path_buf() + .join("bin") + .exists() + ); + } + + #[test] + fn an_install_with_no_artifact_says_what_is_missing() { + let target = seeded("software-missing"); + let prefix = ready_prefix(&target); + let planned = software_plan(&target, "software_install"); + let plan_path = target.join("..").join("plan-missing.json"); + fs::write( + &plan_path, + setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(), + ) + .unwrap(); + let error = refuse(args( + "apply-operation", + &target, + &[ + "--plan", + &plan_path.to_string_lossy(), + "--plan-digest", + planned["plan_digest"].as_str().unwrap(), + "--provider-release-digest", + RELEASE, + "--prefix", + &prefix, + ], + )); + assert!( + error.detail().contains("--software-artifact"), + "{}", + error.detail() + ); + } + + #[test] + fn removing_takes_the_program_back_down() { + let target = seeded("software-remove"); + let file = downloaded(&target, TEST_PAYLOAD); + plan_then_install(&target, "software_install", Some(&file)); + assert!( + Path::new(&ready_prefix(&target)) + .to_path_buf() + .join("bin/test-harness") + .symlink_metadata() + .is_ok() + ); + + let removed = plan_then_install(&target, "software_remove", None); + assert_eq!(removed["removed"], true); + assert!( + !Path::new(&ready_prefix(&target)) + .to_path_buf() + .join("1.2.3") + .exists() + ); + assert!( + Path::new(&ready_prefix(&target)) + .to_path_buf() + .join("bin/test-harness") + .symlink_metadata() + .is_err() + ); + } + + #[test] + fn a_build_that_installs_software_declares_all_three_operations() { + let info = TEST.provider_info().unwrap(); + assert!(info.declares(Operation::SoftwareInstall)); + assert!(info.declares(Operation::SoftwareUpdate)); + assert!(info.declares(Operation::SoftwareRemove)); + } + + #[test] + fn a_build_that_installs_no_software_declares_none_of_them() { + // Declaring an operation a build cannot perform lets a consumer ask for + // something that cannot be honoured, which is worse than not offering. + let mut bare = TEST; + bare.software = None; + let info = bare.provider_info().unwrap(); + assert!(!info.declares(Operation::SoftwareInstall)); + assert!(!info.declares(Operation::SoftwareUpdate)); + assert!(!info.declares(Operation::SoftwareRemove)); + + let error = software::plan( + &bare, + Some(Path::new("/nowhere")), + Operation::SoftwareInstall, + None, + ) + .unwrap_err(); + assert_eq!(error.reason(), Some(WireReason::UnsupportedOperation)); + } } diff --git a/crates/opencode-setup-system/src/main.rs b/crates/opencode-setup-system/src/main.rs index 2362c01..d440e6d 100644 --- a/crates/opencode-setup-system/src/main.rs +++ b/crates/opencode-setup-system/src/main.rs @@ -12,6 +12,8 @@ use std::process::ExitCode; +mod software; + use harness_runtime::Harness; use provider_v3::{ComponentKind, ProjectionKind}; @@ -59,6 +61,7 @@ pub const OPENCODE: Harness = Harness { max_files: 8192, max_bytes: 64 * 1024 * 1024, kit_identity: include_str!("../../../provider-kit/v3/KIT-IDENTITY.json"), + software: Some(software::SOFTWARE), }; fn main() -> ExitCode { diff --git a/crates/opencode-setup-system/src/software.rs b/crates/opencode-setup-system/src/software.rs new file mode 100644 index 0000000..e1d3782 --- /dev/null +++ b/crates/opencode-setup-system/src/software.rs @@ -0,0 +1,146 @@ +//! Opencode's own program, as measured rather than as described. +//! +//! Generated by `tools/transcribe_software.py` from the `software_artifacts` +//! block of `references/opencode-baseline.json`, which +//! `tools/refresh_software_pins.py` writes from bytes it actually fetched. +//! Every member path below was read out of the archive it names, not assumed: +//! codex's carries the target triple and so genuinely differs per platform. +//! +//! Do not edit. The test at the bottom re-reads that baseline and compares it +//! field by field, so an edit here fails rather than silently installing bytes +//! nobody measured. + +use harness_runtime::{Artifact, Delivery, Shape, Software}; + +/// The artifacts opencode is published as. +pub(crate) const ARTIFACTS: &[Artifact] = &[ + Artifact { + platform: "linux/arm64", + url: "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.23.tgz", + bytes: 59_944_416, + sha256: "sha256:ca35ecf5e33bab3e1d9e45b4387a07ed7804178c046b44d797306f2c82ba581e", + shape: Shape::GzipTar, + member: "package/bin/opencode", + }, + Artifact { + platform: "linux/x86_64", + url: "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.23.tgz", + bytes: 60_167_326, + sha256: "sha256:b987ad440e278a8d543ec99cbac11a14fb3d19ae7254fecfe825dba1f133729e", + shape: Shape::GzipTar, + member: "package/bin/opencode", + }, + Artifact { + platform: "macos/arm64", + url: "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.23.tgz", + bytes: 45_938_603, + sha256: "sha256:1415acecc9f520cf28459cef66f9487a9b173ad10b0ea093b8f5087401b0e125", + shape: Shape::GzipTar, + member: "package/bin/opencode", + }, + Artifact { + platform: "macos/x86_64", + url: "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.23.tgz", + bytes: 48_114_961, + sha256: "sha256:d217059f954f34458ff1ee41b946d22ff0cb5d7d165d165dfa72bc4e65faac0a", + shape: Shape::GzipTar, + member: "package/bin/opencode", + }, + Artifact { + platform: "windows/arm64", + url: "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.23.tgz", + bytes: 58_397_371, + sha256: "sha256:919de21beb75a2f93776a7550da5d964b0231545ba6e3ce84b19f565544b1e18", + shape: Shape::GzipTar, + member: "package/bin/opencode.exe", + }, + Artifact { + platform: "windows/x86_64", + url: "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.23.tgz", + bytes: 60_078_745, + sha256: "sha256:28e4a6f4079f907b1fd198c7aeea6be96dcf7d6edc428cc7199a4cbe3e3ac6ed", + shape: Shape::GzipTar, + member: "package/bin/opencode.exe", + }, +]; + +/// Opencode's program, and where its bytes come from. +pub(crate) const SOFTWARE: Software = Software { + version: "1.18.23", + command: "opencode", + delivery: Delivery::Artifacts(ARTIFACTS), + unsupported: &[], +}; + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + + // Named rather than glob-imported: a product delivered by a package manager + // has no `Artifact` in scope, and the test is the same text for all seven. + use harness_runtime::{Delivery, Shape}; + + use super::SOFTWARE; + + fn measured() -> serde_json::Value { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../references/opencode-baseline.json"); + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() + } + + #[test] + fn every_artifact_compiled_in_is_the_one_the_baseline_measured() { + let block = &measured()["software_artifacts"]; + assert_eq!(block["version"], SOFTWARE.version); + assert_eq!(block["command"], SOFTWARE.command); + + let Delivery::Artifacts(compiled) = SOFTWARE.delivery else { + // A product delivered by a package manager has no artifacts, and + // the baseline must agree that it has none. + assert_eq!(block["shape"], "manager"); + assert!(block["platforms"].as_object().unwrap().is_empty()); + return; + }; + let published = block["platforms"].as_object().unwrap(); + assert_eq!( + compiled.len(), + published.len(), + "the table and the baseline disagree on how many platforms exist" + ); + for artifact in compiled { + let entry = &published[artifact.platform]; + assert_eq!(entry["url"], artifact.url, "{}", artifact.platform); + assert_eq!(entry["bytes"], artifact.bytes, "{}", artifact.platform); + assert_eq!(entry["sha256"], artifact.sha256, "{}", artifact.platform); + let member = entry.get("member").and_then(serde_json::Value::as_str); + assert_eq!( + member.unwrap_or(""), + artifact.member, + "{} names a different member", + artifact.platform + ); + assert_eq!( + artifact.shape == Shape::Raw, + member.is_none(), + "{} disagrees about whether the bytes are the program", + artifact.platform + ); + } + } + + #[test] + fn a_platform_the_vendor_does_not_publish_is_listed_rather_than_missing() { + let block = &measured()["software_artifacts"]; + let unpublished: Vec<&str> = block + .get("unpublished") + .and_then(serde_json::Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(serde_json::Value::as_str) + .collect() + }) + .unwrap_or_default(); + assert_eq!(unpublished, SOFTWARE.unsupported); + } +} diff --git a/crates/provider-v3/src/argv.rs b/crates/provider-v3/src/argv.rs index 806fd92..e53224f 100644 --- a/crates/provider-v3/src/argv.rs +++ b/crates/provider-v3/src/argv.rs @@ -81,6 +81,17 @@ pub enum Invocation { provider_release_digest: String, /// The bundle, when the operation carries one. bundle: Option, + /// The program directory, when the operation installs software. + prefix: Option, + /// The downloaded files, one per artifact the plan named, in its order. + /// + /// The contract gives software a download phase between planning and + /// applying and gives the provider no command to run it in -- there is + /// no `download` among the seven. So the consumer fetches what the plan + /// named and hands the files back here, which is why this provider never + /// opens a socket in any phase. The order is how each file is matched to + /// its entry, so nothing about which is which has to be inferred. + software_artifacts: Vec, }, /// Resolve an interrupted operation from its journal. RecoverOperation { @@ -127,6 +138,17 @@ pub struct PlanRequest { pub permission_profile: Option, /// The bundle, when the operation carries one. pub bundle: Option, + /// The program directory, when the operation installs software. + /// + /// Not the target. The configuration a provider owns and the program it + /// installs are different paths with different lifetimes, and conflating + /// them would tie a program to one of the several targets it can serve. + pub prefix: Option, + /// The exact version to install. + /// + /// Omitted means the version this build pins. Given means exactly that one, + /// and anything else is refused rather than quietly installing a neighbour. + pub software_version: Option, } impl Invocation { @@ -224,6 +246,8 @@ where backup_ref: flags.take_optional("--backup-ref"), permission_profile: flags.take_optional("--permission-profile"), bundle: flags.take_bundle()?, + prefix: flags.take_prefix()?, + software_version: flags.take_optional("--software-version"), }, } } @@ -233,6 +257,12 @@ where plan_digest: flags.take_required("--plan-digest")?, provider_release_digest: flags.take_required("--provider-release-digest")?, bundle: flags.take_bundle()?, + prefix: flags.take_prefix()?, + software_artifacts: flags + .take_repeated("--software-artifact") + .into_iter() + .map(PathBuf::from) + .collect(), }, }; @@ -250,13 +280,21 @@ fn local(detail: impl Into) -> Error { /// than something to ignore. A provider that silently dropped an argument it did /// not understand would report success for a request it only partly performed. struct Flags { - values: BTreeMap, + values: BTreeMap>, switches: Vec, } +/// The flags a caller may give more than once. +/// +/// Exactly one: `apply-operation` receives one downloaded file per artifact the +/// plan named, in the plan's order. Every other flag is still refused twice +/// over, because a second value where one is expected is a caller that meant +/// two different things and only one of them would happen. +const REPEATABLE: &[&str] = &["--software-artifact"]; + impl Flags { fn parse(tokens: &[String]) -> Result { - let mut values = BTreeMap::new(); + let mut values: BTreeMap> = BTreeMap::new(); let mut switches = Vec::new(); let mut index = 0; while index < tokens.len() { @@ -280,22 +318,31 @@ impl Flags { if value.starts_with("--") { return Err(local(format!("{token} has no value"))); } - if values.insert(token.clone(), value.clone()).is_some() { + let seen = values.entry(token.clone()).or_default(); + if !seen.is_empty() && !REPEATABLE.contains(&token.as_str()) { return Err(local(format!("{token} was given twice"))); } + seen.push(value.clone()); index += 2; } Ok(Self { values, switches }) } fn take_required(&mut self, name: &str) -> Result { - self.values - .remove(name) + self.take_optional(name) .ok_or_else(|| local(format!("{name} is required"))) } fn take_optional(&mut self, name: &str) -> Option { - self.values.remove(name) + self.values.remove(name)?.into_iter().next() + } + + /// Every value of a flag a caller may repeat, in the order they were given. + /// + /// The order is load-bearing: it is how `apply` knows which file answers + /// which entry of the plan's `software_artifacts` array. + fn take_repeated(&mut self, name: &str) -> Vec { + self.values.remove(name).unwrap_or_default() } fn take_switch(&mut self, name: &str) -> bool { @@ -347,6 +394,22 @@ impl Flags { })) } + /// The program directory, checked to be absolute. + /// + /// The contract says both `--target` and `--prefix` are absolute. A relative + /// one would resolve against whatever directory the caller happened to be + /// in, which is not a property a plan can be bound to. + fn take_prefix(&mut self) -> Result> { + let Some(text) = self.take_optional("--prefix") else { + return Ok(None); + }; + let path = PathBuf::from(&text); + if !path.is_absolute() { + return Err(local(format!("--prefix {text:?} is not an absolute path"))); + } + Ok(Some(path)) + } + fn require_exhausted(&self) -> Result<()> { if let Some((name, _)) = self.values.iter().next() { return Err(local(format!("{name} is not an argument of this command"))); diff --git a/crates/provider-v3/src/lib.rs b/crates/provider-v3/src/lib.rs index 9b5099b..731bc3c 100644 --- a/crates/provider-v3/src/lib.rs +++ b/crates/provider-v3/src/lib.rs @@ -49,7 +49,7 @@ pub use argv::{Invocation, PlanRequest}; pub use bundle::Bundle; pub use error::{Error, Result}; pub use info::{Declaration, ProjectionProfile, ProviderInfo}; -pub use plan::{BundleBinding, PlanArtifact, PlanInputs}; +pub use plan::{BundleBinding, PlanArtifact, PlanInputs, SoftwareArtifact}; pub use reason::WireReason; pub use vocabulary::{ Command, ComponentKind, Operation, PLAN_DOMAIN, PLAN_FORMAT, PROJECTION_DOMAIN, diff --git a/crates/provider-v3/src/plan.rs b/crates/provider-v3/src/plan.rs index 0f7e408..5a8ed19 100644 --- a/crates/provider-v3/src/plan.rs +++ b/crates/provider-v3/src/plan.rs @@ -21,7 +21,7 @@ //! provider that planned against a different target or a different bundle is //! caught by disagreement rather than by trust. -use serde::Serialize; +use serde::{Deserialize, Serialize}; use setup_core::digest; use crate::error::{Error, Result}; @@ -46,6 +46,35 @@ pub struct BundleBinding { pub bundle_size: u64, } +/// The artifact a software operation needs, stated before any network is open. +/// +/// This is the whole reason the contract gives software a download phase of its +/// own. Planning names the exact bytes -- one url, one length, one digest -- +/// while the provider is offline, and applying re-checks them while it is +/// offline again. Whoever holds the network in between fetches what this names +/// and nothing else, so no part of *what* gets installed is decided at a moment +/// when the answer could come from the network. +/// The five fields agreed on `ai_stp#414` and recorded in +/// `docs/contracts/provider-protocol.md`, and no others. +/// +/// Everything the fetching side needs and nothing it does not. Whether the +/// bytes are the program or enclose it is this provider's business, decided +/// from the table compiled into it, and putting it here would invite a consumer +/// to act on it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SoftwareArtifact { + /// The platform this artifact is for, as the consumer spells it. + pub platform: String, + /// Where the bytes come from. + pub url: String, + /// The `sha256:`-prefixed digest of those bytes. + pub sha256: String, + /// How many bytes to expect. + pub byte_length: u64, + /// The path, relative to `--prefix`, that will run the program. + pub entry_point: String, +} + /// The provider's immutable description of one effect. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PlanArtifact { @@ -83,6 +112,14 @@ pub struct PlanArtifact { pub platform: serde_json::Value, /// When this plan stops being applicable. pub expires_at: String, + /// The artifacts a software operation will fetch and install. + /// + /// One element is one file. `apply` receives one `--software-artifact` per + /// element in this order, so which file answers which entry never has to be + /// inferred. Empty for every configuration operation, which reaches nothing, + /// and for `software_remove`, which downloads nothing. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub software_artifacts: Vec, /// What applying it will do, in order. Never empty. pub effects: Vec, } @@ -118,6 +155,9 @@ pub struct PlanInputs<'a> { pub permission_profile: Option, /// When the plan expires. pub expires_at: &'a str, + /// The artifacts a software operation will fetch, in the order `apply` + /// will be handed them. + pub software_artifacts: Vec, /// What applying it will do. Never empty. pub effects: Vec, } @@ -175,6 +215,7 @@ impl PlanArtifact { permission_profile: inputs.permission_profile, platform: platform::echo(), expires_at: inputs.expires_at.to_owned(), + software_artifacts: inputs.software_artifacts, effects: inputs.effects, }) } @@ -304,6 +345,7 @@ mod tests { fn inputs(operation: Operation) -> PlanInputs<'static> { PlanInputs { + software_artifacts: Vec::new(), provider_id: "claude-setup-system", provider_version: "0.1.0", provider_build_digest: DIGEST, diff --git a/crates/provider-v3/src/reason.rs b/crates/provider-v3/src/reason.rs index 07d7c15..e106f18 100644 --- a/crates/provider-v3/src/reason.rs +++ b/crates/provider-v3/src/reason.rs @@ -44,6 +44,8 @@ pub enum WireReason { UnsupportedOperation, /// The bundle was compiled for a different projection profile. ProjectionProfileMismatch, + /// A permission profile this provider does not offer. + UnsupportedPermissionProfile, /// The running operating system is outside the declared matrix. UnsupportedPlatform, /// The running architecture is outside the declared matrix. @@ -77,6 +79,7 @@ impl WireReason { Self::UnsupportedProtocolVersion, Self::UnsupportedOperation, Self::ProjectionProfileMismatch, + Self::UnsupportedPermissionProfile, Self::UnsupportedPlatform, Self::UnsupportedArchitecture, Self::RecoveryRequired, @@ -100,6 +103,7 @@ impl WireReason { Self::UnsupportedNativeSurface => "unsupported_native_surface", Self::UnsupportedProtocolVersion => "unsupported_protocol_version", Self::UnsupportedOperation => "unsupported_operation", + Self::UnsupportedPermissionProfile => "unsupported_permission_profile", Self::ProjectionProfileMismatch => "projection_profile_mismatch", Self::UnsupportedPlatform => "unsupported_platform", Self::UnsupportedArchitecture => "unsupported_architecture", @@ -141,6 +145,7 @@ impl From for WireReason { ReasonCode::UnsupportedBundleFormat => Self::UnsupportedBundleFormat, ReasonCode::UnsupportedProtocolVersion => Self::UnsupportedProtocolVersion, ReasonCode::ProjectionProfileMismatch => Self::ProjectionProfileMismatch, + ReasonCode::UnsupportedPermissionProfile => Self::UnsupportedPermissionProfile, ReasonCode::UnsupportedPlatform => Self::UnsupportedPlatform, ReasonCode::UnsupportedArchitecture => Self::UnsupportedArchitecture, ReasonCode::RecoveryRequired => Self::RecoveryRequired, diff --git a/crates/provider-v3/src/vocabulary.rs b/crates/provider-v3/src/vocabulary.rs index 1a3991f..7d12e8a 100644 --- a/crates/provider-v3/src/vocabulary.rs +++ b/crates/provider-v3/src/vocabulary.rs @@ -158,6 +158,28 @@ impl Operation { Self::Restore, ]; + /// Core plus the software lifecycle, for a provider that performs both. + pub const CORE_AND_SOFTWARE: &'static [Self] = &[ + Self::Backup, + Self::Install, + Self::Remove, + Self::Replace, + Self::Restore, + Self::SoftwareInstall, + Self::SoftwareUpdate, + Self::SoftwareRemove, + ]; + + /// The optional operations that install the product itself. + /// + /// Declared together or not at all. A provider offering to install but not + /// to remove leaves a caller holding something it cannot put down. + pub const SOFTWARE: &'static [Self] = &[ + Self::SoftwareInstall, + Self::SoftwareUpdate, + Self::SoftwareRemove, + ]; + /// The wire spelling. #[must_use] pub const fn as_str(self) -> &'static str { diff --git a/crates/provider-v3/src/zip.rs b/crates/provider-v3/src/zip.rs index 271c800..c1c9fc3 100644 --- a/crates/provider-v3/src/zip.rs +++ b/crates/provider-v3/src/zip.rs @@ -207,21 +207,11 @@ fn refuse(detail: impl Into) -> Error { /// CRC-32, the variant ZIP uses. /// -/// Written here rather than taken as a dependency: it is twenty lines, and a -/// checksum this reader uses to decide whether to trust bytes is not a good -/// place to add a supply-chain edge. -#[must_use] -pub fn crc32(bytes: &[u8]) -> u32 { - let mut crc = 0xFFFF_FFFF_u32; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - let mask = 0_u32.wrapping_sub(crc & 1); - crc = (crc >> 1) ^ (0xEDB8_8320 & mask); - } - } - !crc -} +/// Re-exported rather than written twice: gzip uses the same polynomial, so +/// this reader and `setup-core::archive` were carrying identical loops. One +/// implementation means one place for a checksum bug to be, and the path here +/// stays where every caller already expects it. +pub use setup_core::checksum::crc32; pub mod build { //! A canonical writer: the reader's statement of what it expects, executable. diff --git a/crates/setup-core/Cargo.toml b/crates/setup-core/Cargo.toml index e5146c7..5b28cc7 100644 --- a/crates/setup-core/Cargo.toml +++ b/crates/setup-core/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true authors.workspace = true [dependencies] +miniz_oxide = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/setup-core/src/archive.rs b/crates/setup-core/src/archive.rs new file mode 100644 index 0000000..dbd896c --- /dev/null +++ b/crates/setup-core/src/archive.rs @@ -0,0 +1,1152 @@ +//! Reading the one archive shape every vendor ships. +//! +//! Six of the seven products distribute their binary as a gzip-compressed tar, +//! and the seventh publishes the executable as plain bytes. Nothing else +//! appears: no zip, no zstd, no brotli. That is measured rather than assumed -- +//! every artifact named in a baseline was fetched and its raw headers read. +//! +//! The measurement mattered, because the convenient tools lie about it. Python's +//! `tarfile` consumes GNU long-name headers transparently and reports only the +//! members, so an archive that needs them looks identical to one that does not. +//! Reading `typeflag` off the raw 512-byte blocks says what is really there: +//! +//! ```text +//! claude '0' x4 ustar\0 +//! codex '0' x8 ustar\0 +//! opencode '0' x2 ustar\0 +//! antigravity '0' x1 ustar␠␠ +//! cursor '0' x442 '5' x127 'L' x52 ustar␠␠ +//! ``` +//! +//! So this reader accepts exactly two dialects -- POSIX `ustar` and GNU tar -- +//! and exactly three entry types: a regular file, a directory, and the GNU +//! long-name header that carries a path too long for the 100-byte field. Every +//! other type flag is refused **by name**, because the reason a symlink is +//! refused is worth saying out loud: extraction that honours one can be made to +//! write through it, outside the directory the caller asked for. +//! +//! It streams. `claude`'s payload inflates to 391 MB and `cursor`'s tree holds +//! 569 entries; buffering either whole would cost more memory than the machines +//! this runs on can spare, so bytes move from the compressed input to the file +//! on disk without a copy of the archive existing anywhere. + +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Component, Path}; + +use crate::checksum::continue_crc32; +use crate::error::{Error, ReasonCode, Result}; + +/// How large a header block is, and the unit every tar offset is a multiple of. +const BLOCK: usize = 512; + +/// What one archive entry is. +/// +/// Only two kinds exist here. Anything else the format can express is refused +/// before it becomes an [`Entry`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// A regular file. + File, + /// A directory. + Directory, +} + +/// One entry's metadata, as this reader is willing to describe it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// The path inside the archive, already checked to be relative and safe. + pub path: String, + /// Whether it is a file or a directory. + pub kind: Kind, + /// The permission bits the archive recorded. + pub mode: u32, + /// The byte length of the entry's content. Zero for a directory. + pub size: u64, +} + +impl Entry { + /// Whether the archive marked this entry executable. + #[must_use] + pub const fn is_executable(&self) -> bool { + self.mode & 0o111 != 0 + } +} + +fn refuse(detail: impl Into) -> Error { + Error::new(ReasonCode::IntegrityMismatch, detail) +} + +fn from_io(detail: &str, error: io::Error) -> Error { + Error::new(ReasonCode::StateUnavailable, format!("{detail}: {error}")).with_source(error) +} + +/// A gzip member, inflated as it is read. +/// +/// Only the framing is written here. The DEFLATE stream inside it is decoded by +/// `miniz_oxide`, which is the one part of this worth taking as a dependency: +/// an inflate loop is several hundred lines of Huffman decoding whose bugs are +/// memory-safety bugs, and it is not improved by being ours. +pub struct Gunzip { + inner: R, + state: Box, + input: Vec, + filled: usize, + consumed: usize, + finished: bool, + crc: u32, + length: u64, +} + +impl Gunzip { + /// Read the gzip header and prepare to inflate what follows. + /// + /// # Errors + /// + /// Refuses a stream that is not a gzip member, or that uses a compression + /// method other than DEFLATE. + pub fn new(mut inner: R) -> Result { + let mut head = [0_u8; 10]; + inner + .read_exact(&mut head) + .map_err(|error| from_io("gzip header could not be read", error))?; + if head[0] != 0x1F || head[1] != 0x8B { + return Err(refuse(format!( + "not a gzip member: magic {:#04x}{:02x}", + head[0], head[1] + ))); + } + if head[2] != 8 { + return Err(refuse(format!( + "gzip compression method {} is not DEFLATE", + head[2] + ))); + } + + let flags = head[3]; + if flags & 0b1110_0000 != 0 { + return Err(refuse(format!( + "gzip reserved flag bits set: {flags:#010b}" + ))); + } + if flags & 0b0000_0100 != 0 { + let mut length = [0_u8; 2]; + inner + .read_exact(&mut length) + .map_err(|error| from_io("gzip extra field length could not be read", error))?; + let mut extra = vec![0_u8; usize::from(u16::from_le_bytes(length))]; + inner + .read_exact(&mut extra) + .map_err(|error| from_io("gzip extra field could not be read", error))?; + } + for (bit, what) in [(0b0000_1000_u8, "name"), (0b0001_0000, "comment")] { + if flags & bit != 0 { + let mut byte = [0_u8; 1]; + loop { + inner.read_exact(&mut byte).map_err(|error| { + from_io(&format!("gzip {what} field could not be read"), error) + })?; + if byte[0] == 0 { + break; + } + } + } + } + if flags & 0b0000_0010 != 0 { + let mut check = [0_u8; 2]; + inner + .read_exact(&mut check) + .map_err(|error| from_io("gzip header checksum could not be read", error))?; + } + + Ok(Self { + inner, + state: miniz_oxide::inflate::stream::InflateState::new_boxed( + miniz_oxide::DataFormat::Raw, + ), + input: vec![0_u8; 64 * 1024], + filled: 0, + consumed: 0, + finished: false, + crc: 0, + length: 0, + }) + } + + /// Check the trailer against what was actually inflated. + fn finish(&mut self) -> io::Result<()> { + let mut trailer = [0_u8; 8]; + let mut have = self.filled - self.consumed; + let carried = have.min(8); + trailer[..carried].copy_from_slice(&self.input[self.consumed..self.consumed + carried]); + self.consumed += carried; + have = carried; + while have < 8 { + let read = self.inner.read(&mut trailer[have..])?; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "gzip trailer is truncated", + )); + } + have += read; + } + + let stated_crc = u32::from_le_bytes([trailer[0], trailer[1], trailer[2], trailer[3]]); + let stated_length = u32::from_le_bytes([trailer[4], trailer[5], trailer[6], trailer[7]]); + if stated_crc != self.crc { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "gzip CRC-32 mismatch: stated {stated_crc:#010x}, inflated {:#010x}", + self.crc + ), + )); + } + #[allow(clippy::cast_possible_truncation)] + let low = self.length as u32; + if stated_length != low { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "gzip length mismatch: stated {stated_length}, inflated {}", + self.length + ), + )); + } + Ok(()) + } +} + +impl Read for Gunzip { + fn read(&mut self, out: &mut [u8]) -> io::Result { + if self.finished || out.is_empty() { + return Ok(0); + } + loop { + if self.consumed == self.filled { + self.filled = self.inner.read(&mut self.input)?; + self.consumed = 0; + if self.filled == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "gzip stream ended before the DEFLATE data did", + )); + } + } + + let result = miniz_oxide::inflate::stream::inflate( + &mut self.state, + &self.input[self.consumed..self.filled], + out, + miniz_oxide::MZFlush::None, + ); + self.consumed += result.bytes_consumed; + let written = result.bytes_written; + if written > 0 { + self.crc = continue_crc32(self.crc, &out[..written]); + self.length = self.length.wrapping_add(written as u64); + } + + match result.status { + Ok(miniz_oxide::MZStatus::StreamEnd) => { + self.finished = true; + self.finish()?; + return Ok(written); + } + Ok(_) => { + if written > 0 { + return Ok(written); + } + } + Err(error) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("DEFLATE stream is malformed: {error:?}"), + )); + } + } + } + } +} + +/// A tar stream, read one entry at a time. +pub struct Tar { + inner: R, + remaining: u64, + padding: usize, + /// Allocated once. `copy_entry` runs per entry, and cursor's tree has 569 + /// of them; a fresh 64 KB buffer each time would be 569 allocations to + /// move the same bytes. + buffer: Box<[u8]>, +} + +impl Tar { + /// Wrap a reader positioned at the first header block. + #[must_use] + pub fn new(inner: R) -> Self { + Self { + inner, + remaining: 0, + padding: 0, + buffer: vec![0_u8; 64 * 1024].into_boxed_slice(), + } + } + + /// Advance to the next entry, skipping any content the caller did not read. + /// + /// Returns `None` at the end-of-archive marker. + /// + /// # Errors + /// + /// Refuses an unknown magic, a header whose checksum does not match, a path + /// that is not safely relative, or any entry type outside file, directory + /// and GNU long name. + pub fn next_entry(&mut self) -> Result> { + self.skip_rest()?; + + let mut long_name: Option = None; + loop { + let Some(block) = self.read_block()? else { + return Ok(None); + }; + + verify_checksum(&block)?; + let magic = &block[257..263]; + let gnu = magic == b"ustar "; + let posix = magic == b"ustar\0"; + if !gnu && !posix { + return Err(refuse(format!( + "tar magic {:?} is neither POSIX ustar nor GNU tar", + String::from_utf8_lossy(magic) + ))); + } + + let size = octal(&block[124..136], "size")?; + let mode = u32::try_from(octal(&block[100..108], "mode")?).unwrap_or(0o644); + self.remaining = size; + self.padding = padding_for(size); + + let flag = block[156]; + match flag { + b'L' => { + if !gnu { + return Err(refuse( + "a GNU long-name header appeared in a POSIX ustar archive", + )); + } + let mut raw = Vec::new(); + self.copy_entry(&mut raw)?; + let text = String::from_utf8(raw) + .map_err(|_| refuse("a GNU long name is not valid UTF-8"))?; + long_name = Some(text.trim_end_matches('\0').to_owned()); + } + b'0' | b'\0' | b'5' => { + let path = match long_name.take() { + Some(name) => name, + None => joined_name(&block, posix)?, + }; + let kind = if flag == b'5' || path.ends_with('/') { + Kind::Directory + } else { + Kind::File + }; + let path = check_relative(path.trim_end_matches('/'))?; + if kind == Kind::Directory && size != 0 { + return Err(refuse(format!("directory {path} carries {size} bytes"))); + } + return Ok(Some(Entry { + path, + kind, + mode, + size, + })); + } + other => return Err(refuse(refusal_for(other))), + } + } + } + + /// Give the wrapped reader back, so the caller can finish the stream. + /// + /// A tar ends at its zero-block marker, which is *not* the end of the gzip + /// member around it. Stopping there leaves the trailer unread, and the + /// trailer is the only thing that says whether what was inflated is what + /// was compressed -- so the CRC would never be checked at all. A test + /// corrupting the trailer found exactly that. + pub fn into_inner(self) -> R { + self.inner + } + + /// Write the current entry's content to `out`. + /// + /// # Errors + /// + /// Fails if the archive ends early or the sink refuses the bytes. + pub fn copy_entry(&mut self, out: &mut impl Write) -> Result<()> { + while self.remaining > 0 { + let want = usize::try_from(self.remaining.min(self.buffer.len() as u64)).unwrap_or(1); + let read = self + .inner + .read(&mut self.buffer[..want]) + .map_err(|error| from_io("archive content could not be read", error))?; + if read == 0 { + return Err(refuse("archive ended in the middle of an entry")); + } + out.write_all(&self.buffer[..read]) + .map_err(|error| from_io("archive content could not be written", error))?; + self.remaining -= read as u64; + } + self.skip_padding() + } + + fn skip_rest(&mut self) -> Result<()> { + let mut sink = io::sink(); + if self.remaining > 0 { + self.copy_entry(&mut sink) + } else { + self.skip_padding() + } + } + + fn skip_padding(&mut self) -> Result<()> { + if self.padding == 0 { + return Ok(()); + } + let mut waste = [0_u8; BLOCK]; + let take = self.padding; + self.padding = 0; + self.inner + .read_exact(&mut waste[..take]) + .map_err(|error| from_io("archive padding could not be read", error)) + } + + /// Read one 512-byte block, returning `None` at the end-of-archive marker. + fn read_block(&mut self) -> Result> { + let mut block = [0_u8; BLOCK]; + let mut have = 0; + while have < BLOCK { + let read = self + .inner + .read(&mut block[have..]) + .map_err(|error| from_io("archive header could not be read", error))?; + if read == 0 { + if have == 0 { + return Ok(None); + } + return Err(refuse("archive ended in the middle of a header")); + } + have += read; + } + if block.iter().all(|byte| *byte == 0) { + return Ok(None); + } + Ok(Some(block)) + } +} + +/// Why one type flag is refused, said in the terms that make it a decision. +fn refusal_for(flag: u8) -> String { + let what = match flag { + b'1' => "a hard link", + b'2' => "a symbolic link", + b'3' => "a character device", + b'4' => "a block device", + b'6' => "a FIFO", + b'7' => "a contiguous file", + b'x' | b'g' => "a pax extended header", + b'K' => "a GNU long link name", + _ => "an entry type outside the format this reader accepts", + }; + format!( + "refusing {what} (type flag {:?}): extraction here writes regular files and directories \ + and nothing else, so that no entry can redirect a later write outside the destination", + char::from(flag) + ) +} + +/// The header checksum, computed the way the format defines it. +fn verify_checksum(block: &[u8; BLOCK]) -> Result<()> { + let stated = octal(&block[148..156], "checksum")?; + let mut unsigned = 0_u64; + let mut signed = 0_i64; + for (index, byte) in block.iter().enumerate() { + let value = if (148..156).contains(&index) { + b' ' + } else { + *byte + }; + unsigned += u64::from(value); + signed += i64::from(value.cast_signed()); + } + if stated == unsigned || i64::try_from(stated).is_ok_and(|want| want == signed) { + return Ok(()); + } + Err(refuse(format!( + "tar header checksum mismatch: stated {stated}, computed {unsigned}" + ))) +} + +/// A tar numeric field: octal digits, then a NUL or a space. +fn octal(field: &[u8], what: &str) -> Result { + let text: Vec = field + .iter() + .copied() + .take_while(|byte| *byte != 0 && *byte != b' ') + .skip_while(|byte| *byte == b' ') + .collect(); + if text.is_empty() { + return Ok(0); + } + let text = std::str::from_utf8(&text) + .map_err(|_| refuse(format!("tar {what} field is not ASCII octal")))?; + u64::from_str_radix(text, 8) + .map_err(|_| refuse(format!("tar {what} field {text:?} is not octal"))) +} + +/// POSIX ustar splits a long path across `prefix` and `name`. +fn joined_name(block: &[u8; BLOCK], posix: bool) -> Result { + let name = field_text(&block[0..100], "name")?; + if !posix { + return Ok(name); + } + let prefix = field_text(&block[345..500], "prefix")?; + if prefix.is_empty() { + Ok(name) + } else { + Ok(format!("{prefix}/{name}")) + } +} + +fn field_text(field: &[u8], what: &str) -> Result { + let end = field + .iter() + .position(|byte| *byte == 0) + .unwrap_or(field.len()); + std::str::from_utf8(&field[..end]) + .map(str::to_owned) + .map_err(|_| refuse(format!("tar {what} field is not valid UTF-8"))) +} + +fn padding_for(size: u64) -> usize { + let remainder = usize::try_from(size % BLOCK as u64).unwrap_or(0); + if remainder == 0 { 0 } else { BLOCK - remainder } +} + +/// Refuse any path that could name something outside the destination. +/// +/// The rules are deliberately stricter than the format allows. A tar may carry +/// an absolute path, a `..`, or a Windows drive letter; honouring any of them +/// means the caller asked to fill one directory and got a write somewhere else. +fn check_relative(path: &str) -> Result { + if path.is_empty() { + return Err(refuse("archive entry has an empty path")); + } + if path.starts_with('/') || path.starts_with('\\') { + return Err(refuse(format!("archive entry {path} is an absolute path"))); + } + if path.contains('\\') { + return Err(refuse(format!( + "archive entry {path} contains a backslash, which names a different file on each system" + ))); + } + if path.contains('\0') { + return Err(refuse("archive entry path contains a NUL byte")); + } + if path.as_bytes().get(1) == Some(&b':') { + return Err(refuse(format!( + "archive entry {path} carries a drive letter" + ))); + } + for part in path.split('/') { + if part == ".." { + return Err(refuse(format!( + "archive entry {path} climbs out of the destination" + ))); + } + } + Ok(path.to_owned()) +} + +/// How much an extraction is allowed to produce. +/// +/// A plan states the compressed length it expects; it cannot state what the +/// archive will inflate to without trusting the archive. These are the caller's +/// own limits, checked as bytes arrive, so a stream that keeps producing is +/// stopped rather than filling the disk. +#[derive(Debug, Clone, Copy)] +pub struct Limits { + /// The largest number of entries the archive may contain. + pub entries: u64, + /// The largest total byte count the archive may inflate to. + pub bytes: u64, +} + +/// Extract a gzip-compressed tar into `destination`. +/// +/// Returns what was written, in archive order. +/// +/// # Errors +/// +/// Refuses a malformed stream, an unsafe path, an entry type outside file and +/// directory, or an archive that exceeds `limits`. +pub fn extract_gzip_tar( + source: impl Read, + destination: &Path, + limits: Limits, +) -> Result> { + let mut tar = Tar::new(Gunzip::new(source)?); + let mut written = Vec::new(); + let mut total = 0_u64; + + fs::create_dir_all(destination) + .map_err(|error| from_io("destination could not be created", error))?; + + while let Some(entry) = tar.next_entry()? { + if written.len() as u64 >= limits.entries { + return Err(refuse(format!( + "archive holds more than the {} entries this extraction allows", + limits.entries + ))); + } + total = total.saturating_add(entry.size); + if total > limits.bytes { + return Err(refuse(format!( + "archive inflates past the {} bytes this extraction allows", + limits.bytes + ))); + } + + let path = destination.join(&entry.path); + guard_within(destination, &path)?; + match entry.kind { + Kind::Directory => { + fs::create_dir_all(&path) + .map_err(|error| from_io("archive directory could not be created", error))?; + } + Kind::File => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + from_io("archive parent directory could not be created", error) + })?; + } + let mut file = fs::File::create(&path) + .map_err(|error| from_io("archive file could not be created", error))?; + tar.copy_entry(&mut file)?; + file.sync_all() + .map_err(|error| from_io("archive file could not be flushed", error))?; + apply_mode(&path, entry.mode)?; + } + } + written.push(entry); + } + + // Read past the tar's end-of-archive marker to the end of the gzip member, + // so its trailer is reached and its CRC-32 and length are checked. What + // remains is padding, so this costs nothing and is the only place the + // compression's own integrity check happens. + let mut rest = tar.into_inner(); + io::copy(&mut rest, &mut io::sink()) + .map_err(|error| refuse(format!("archive did not end cleanly: {error}")))?; + + Ok(written) +} + +/// Prove the joined path really is inside the destination. +/// +/// [`check_relative`] rejects the paths that could escape, and this repeats the +/// question against the assembled path. Two checks rather than one because the +/// cost is a string comparison and the failure they prevent is a write outside +/// the directory the caller named. +fn guard_within(destination: &Path, candidate: &Path) -> Result<()> { + let escapes = candidate + .components() + .any(|component| matches!(component, Component::ParentDir)); + if escapes || !candidate.starts_with(destination) { + return Err(refuse(format!( + "{} is not inside {}", + candidate.display(), + destination.display() + ))); + } + Ok(()) +} + +/// Carry the archive's executable bit across, where the system has one. +#[cfg(unix)] +fn apply_mode(path: &Path, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let bits = if mode & 0o111 == 0 { 0o644 } else { 0o755 }; + fs::set_permissions(path, fs::Permissions::from_mode(bits)) + .map_err(|error| from_io("archive file permissions could not be set", error)) +} + +/// Windows has no mode bits to carry, and executability is decided by extension. +#[cfg(not(unix))] +fn apply_mode(_path: &Path, _mode: u32) -> Result<()> { + Ok(()) +} + +/// Place plain bytes as an executable. +/// +/// Grok publishes its binary directly rather than inside an archive, so this is +/// the whole of its extraction: the artifact *is* the program. +/// +/// # Errors +/// +/// Fails if the destination cannot be created or written. +pub fn place_executable(source: impl Read, destination: &Path) -> Result { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|error| from_io("destination directory could not be created", error))?; + } + let mut file = fs::File::create(destination) + .map_err(|error| from_io("executable could not be created", error))?; + let mut source = source; + let written = io::copy(&mut source, &mut file) + .map_err(|error| from_io("executable could not be written", error))?; + file.sync_all() + .map_err(|error| from_io("executable could not be flushed", error))?; + apply_mode(destination, 0o755)?; + Ok(written) +} + +pub mod build { + //! A writer for both dialects: the reader's expectations, executable. + //! + //! Having both halves here lets a test prove the reader accepts a correct + //! archive and refuses each specific corruption of it, rather than + //! asserting against a fixture nobody can regenerate. It is public because + //! the software lifecycle's own tests need to produce an artifact of + //! exactly this shape, and building one elsewhere would be rebuilding the + //! part most likely to drift. + + use super::BLOCK; + + /// Which dialect to emit. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum Dialect { + /// POSIX `ustar`, splitting long paths across `prefix` and `name`. + Posix, + /// GNU tar, carrying long paths in a preceding `L` entry. + Gnu, + } + + impl Dialect { + const fn magic(self) -> &'static [u8; 8] { + match self { + // `ustar\0` then version `00`. + Self::Posix => b"ustar\x0000", + // GNU writes the magic and version as one space-padded field. + Self::Gnu => b"ustar \0", + } + } + } + + /// One entry to write. + pub struct Item<'a> { + /// The path inside the archive. + pub path: &'a str, + /// The content. Empty for a directory. + pub body: &'a [u8], + /// The permission bits. + pub mode: u32, + /// Whether this is a directory. + pub directory: bool, + } + + impl<'a> Item<'a> { + /// A regular file. + #[must_use] + pub const fn file(path: &'a str, body: &'a [u8], mode: u32) -> Self { + Self { + path, + body, + mode, + directory: false, + } + } + + /// A directory. + #[must_use] + pub const fn directory(path: &'a str) -> Self { + Self { + path, + body: &[], + mode: 0o755, + directory: true, + } + } + } + + fn write_field(block: &mut [u8; BLOCK], at: usize, text: &[u8]) { + block[at..at + text.len()].copy_from_slice(text); + } + + fn header(path: &str, size: u64, mode: u32, flag: u8, dialect: Dialect) -> [u8; BLOCK] { + let mut block = [0_u8; BLOCK]; + let (prefix, name) = match dialect { + Dialect::Posix if path.len() > 100 => match path[..100].rfind('/') { + Some(cut) => (&path[..cut], &path[cut + 1..]), + None => ("", path), + }, + _ => ("", path), + }; + write_field(&mut block, 0, name.as_bytes()); + write_field(&mut block, 100, format!("{mode:07o}\0").as_bytes()); + write_field(&mut block, 108, b"0000000\0"); + write_field(&mut block, 116, b"0000000\0"); + write_field(&mut block, 124, format!("{size:011o}\0").as_bytes()); + write_field(&mut block, 136, b"00000000000\0"); + block[156] = flag; + write_field(&mut block, 257, dialect.magic()); + if !prefix.is_empty() { + write_field(&mut block, 345, prefix.as_bytes()); + } + + // The checksum is computed with its own field read as spaces. + write_field(&mut block, 148, b" "); + let sum: u64 = block.iter().map(|byte| u64::from(*byte)).sum(); + write_field(&mut block, 148, format!("{sum:06o}\0 ").as_bytes()); + block + } + + fn push_entry(out: &mut Vec, block: &[u8; BLOCK], body: &[u8]) { + out.extend_from_slice(block); + out.extend_from_slice(body); + let remainder = body.len() % BLOCK; + if remainder != 0 { + out.extend(std::iter::repeat_n(0_u8, BLOCK - remainder)); + } + } + + /// Write a tar stream in the given dialect. + #[must_use] + pub fn tar(items: &[Item<'_>], dialect: Dialect) -> Vec { + let mut out = Vec::new(); + for item in items { + let stored = if item.directory { + format!("{}/", item.path.trim_end_matches('/')) + } else { + item.path.to_owned() + }; + if dialect == Dialect::Gnu && stored.len() > 100 { + let mut name = stored.clone().into_bytes(); + name.push(0); + let long = header("././@LongLink", name.len() as u64, 0o644, b'L', dialect); + push_entry(&mut out, &long, &name); + } + let flag = if item.directory { b'5' } else { b'0' }; + let block = header(&stored, item.body.len() as u64, item.mode, flag, dialect); + push_entry(&mut out, &block, item.body); + } + // The end-of-archive marker: two zero blocks. + out.extend(std::iter::repeat_n(0_u8, BLOCK * 2)); + out + } + + /// Wrap bytes in a gzip member. + #[must_use] + pub fn gzip(payload: &[u8]) -> Vec { + let mut out = vec![0x1F, 0x8B, 8, 0, 0, 0, 0, 0, 0, 0xFF]; + out.extend_from_slice(&miniz_oxide::deflate::compress_to_vec(payload, 6)); + out.extend_from_slice(&crate::checksum::crc32(payload).to_le_bytes()); + #[allow(clippy::cast_possible_truncation)] + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out + } + + /// A complete gzip-compressed tar, the shape every vendor ships. + #[must_use] + pub fn gzip_tar(items: &[Item<'_>], dialect: Dialect) -> Vec { + gzip(&tar(items, dialect)) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + + use std::path::PathBuf; + + use super::build::{Dialect, Item, gzip, gzip_tar, tar}; + use super::*; + + const ROOMY: Limits = Limits { + entries: 4096, + bytes: 1 << 30, + }; + + fn scratch(name: &str) -> PathBuf { + let path = + std::env::temp_dir().join(format!("setup-core-archive-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + path + } + + fn read(root: &Path, relative: &str) -> Vec { + fs::read(root.join(relative)).unwrap() + } + + #[test] + fn a_posix_archive_shaped_like_claudes_round_trips() { + // Four flat files, one of them the executable. Measured from + // `@anthropic-ai/claude-code-linux-x64`. + let archive = gzip_tar( + &[ + Item::file("package/claude", b"ELF...", 0o755), + Item::file("package/package.json", b"{}", 0o644), + Item::file("package/README.md", b"read me", 0o644), + Item::file("package/LICENSE.md", b"licence", 0o644), + ], + Dialect::Posix, + ); + let into = scratch("posix"); + let written = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap(); + + assert_eq!(written.len(), 4); + assert_eq!(read(&into, "package/claude"), b"ELF..."); + assert_eq!(read(&into, "package/package.json"), b"{}"); + assert!(written[0].is_executable()); + assert!(!written[1].is_executable()); + fs::remove_dir_all(&into).unwrap(); + } + + #[test] + fn a_gnu_archive_with_long_names_and_directories_round_trips() { + // Cursor's shape: GNU magic, real directories, and paths past the + // 100-byte `name` field carried in `L` headers. Python's `tarfile` + // hides this difference; the reader must not. + let long = "dist-package/node_modules/better-sqlite3/build/Release/obj.target/deps/sqlite3/very/deeply/nested/better_sqlite3.node"; + assert!( + long.len() > 100, + "the fixture must exercise the long-name path" + ); + let archive = gzip_tar( + &[ + Item::directory("dist-package"), + Item::directory("dist-package/node_modules"), + Item::file("dist-package/cursor-agent", b"#!/bin/sh\n", 0o755), + Item::file(long, b"native module", 0o755), + ], + Dialect::Gnu, + ); + let into = scratch("gnu"); + let written = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap(); + + assert_eq!(written.len(), 4); + assert_eq!(written[0].kind, Kind::Directory); + assert_eq!(written[3].path, long); + assert_eq!(read(&into, long), b"native module"); + assert!(into.join("dist-package/node_modules").is_dir()); + fs::remove_dir_all(&into).unwrap(); + } + + #[test] + fn a_single_file_archive_shaped_like_antigravitys_round_trips() { + let archive = gzip_tar( + &[Item::file("antigravity", b"one binary", 0o755)], + Dialect::Gnu, + ); + let into = scratch("single"); + let written = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap(); + assert_eq!(written.len(), 1); + assert_eq!(read(&into, "antigravity"), b"one binary"); + fs::remove_dir_all(&into).unwrap(); + } + + /// Replace the type flag of the first header inside an uncompressed tar. + fn with_type_flag(items: &[Item<'_>], flag: u8) -> Vec { + let mut raw = tar(items, Dialect::Posix); + raw[156] = flag; + // The checksum covered the old flag, so recompute it or the reader + // would refuse for the wrong reason and the test would prove nothing. + for byte in &mut raw[148..156] { + *byte = b' '; + } + let sum: u64 = raw[..BLOCK].iter().map(|byte| u64::from(*byte)).sum(); + raw[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes()); + gzip(&raw) + } + + #[test] + fn every_entry_type_outside_file_and_directory_is_refused_by_name() { + let items = [Item::file("payload", b"x", 0o644)]; + for (flag, expected) in [ + (b'1', "a hard link"), + (b'2', "a symbolic link"), + (b'3', "a character device"), + (b'4', "a block device"), + (b'6', "a FIFO"), + (b'7', "a contiguous file"), + (b'x', "a pax extended header"), + (b'K', "a GNU long link name"), + ] { + let into = scratch(&format!("flag-{flag}")); + let error = extract_gzip_tar(with_type_flag(&items, flag).as_slice(), &into, ROOMY) + .unwrap_err(); + assert_eq!(error.reason(), ReasonCode::IntegrityMismatch); + assert!( + error.detail().contains(expected), + "refusal for {:?} does not name it: {}", + char::from(flag), + error.detail() + ); + let _ = fs::remove_dir_all(&into); + } + } + + #[test] + fn a_path_that_climbs_out_of_the_destination_is_refused() { + let archive = gzip_tar(&[Item::file("../escaped", b"x", 0o644)], Dialect::Posix); + let into = scratch("climb"); + let error = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap_err(); + assert!(error.detail().contains("climbs out"), "{}", error.detail()); + let _ = fs::remove_dir_all(&into); + } + + #[test] + fn an_absolute_path_is_refused() { + let archive = gzip_tar(&[Item::file("/etc/passwd", b"x", 0o644)], Dialect::Posix); + let into = scratch("absolute"); + let error = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap_err(); + assert!( + error.detail().contains("absolute path"), + "{}", + error.detail() + ); + let _ = fs::remove_dir_all(&into); + } + + #[test] + fn a_backslash_path_is_refused_because_it_names_a_different_file_per_system() { + let archive = gzip_tar(&[Item::file("dir\\file", b"x", 0o644)], Dialect::Posix); + let into = scratch("backslash"); + let error = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap_err(); + assert!(error.detail().contains("backslash"), "{}", error.detail()); + let _ = fs::remove_dir_all(&into); + } + + #[test] + fn bytes_that_are_not_a_gzip_member_are_refused_before_anything_is_written() { + let into = scratch("notgzip"); + let error = + extract_gzip_tar(b"PK\x03\x04 this is a zip".as_slice(), &into, ROOMY).unwrap_err(); + assert!( + error.detail().contains("not a gzip member"), + "{}", + error.detail() + ); + assert!(!into.exists() || fs::read_dir(&into).unwrap().next().is_none()); + let _ = fs::remove_dir_all(&into); + } + + #[test] + fn a_corrupted_payload_fails_the_gzip_checksum() { + let mut archive = gzip_tar( + &[Item::file("payload", b"honest bytes", 0o644)], + Dialect::Posix, + ); + // Break the stored CRC rather than the data: the DEFLATE stream stays + // decodable, so the only thing that can catch this is the trailer. + let at = archive.len() - 8; + archive[at] ^= 0xFF; + let into = scratch("crc"); + let error = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap_err(); + assert!( + error.detail().contains("CRC-32 mismatch"), + "{}", + error.detail() + ); + let _ = fs::remove_dir_all(&into); + } + + #[test] + fn a_truncated_archive_is_refused_rather_than_treated_as_complete() { + let archive = gzip_tar( + &[Item::file("payload", &[7_u8; 4096], 0o644)], + Dialect::Posix, + ); + let into = scratch("truncated"); + let cut = archive.len() / 2; + let error = extract_gzip_tar(&archive[..cut], &into, ROOMY).unwrap_err(); + assert!( + error.detail().contains("ended") || error.detail().contains("truncated"), + "{}", + error.detail() + ); + let _ = fs::remove_dir_all(&into); + } + + #[test] + fn an_archive_past_the_entry_limit_is_stopped() { + let bodies: Vec = (0..8).map(|index| format!("file-{index}")).collect(); + let items: Vec> = bodies + .iter() + .map(|name| Item::file(name, b"x", 0o644)) + .collect(); + let into = scratch("entries"); + let limits = Limits { + entries: 3, + bytes: 1 << 20, + }; + let error = extract_gzip_tar(gzip_tar(&items, Dialect::Posix).as_slice(), &into, limits) + .unwrap_err(); + assert!( + error.detail().contains("more than the 3 entries"), + "{}", + error.detail() + ); + let _ = fs::remove_dir_all(&into); + } + + #[test] + fn an_archive_that_inflates_past_the_byte_limit_is_stopped() { + let archive = gzip_tar( + &[Item::file("big", &vec![0_u8; 100_000], 0o644)], + Dialect::Posix, + ); + let into = scratch("bytes"); + let limits = Limits { + entries: 16, + bytes: 4096, + }; + let error = extract_gzip_tar(archive.as_slice(), &into, limits).unwrap_err(); + assert!( + error.detail().contains("inflates past"), + "{}", + error.detail() + ); + let _ = fs::remove_dir_all(&into); + } + + #[test] + fn plain_bytes_are_placed_as_an_executable() { + let into = scratch("raw"); + let destination = into.join("bin/grok"); + let written = place_executable(b"a whole program".as_slice(), &destination).unwrap(); + assert_eq!(written, 15); + assert_eq!(fs::read(&destination).unwrap(), b"a whole program"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&destination).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755, "the placed artifact must be runnable"); + } + fs::remove_dir_all(&into).unwrap(); + } + + #[test] + fn a_header_whose_checksum_does_not_match_is_refused() { + let mut raw = tar(&[Item::file("payload", b"x", 0o644)], Dialect::Posix); + raw[0] = b'X'; + let into = scratch("checksum"); + let error = extract_gzip_tar(gzip(&raw).as_slice(), &into, ROOMY).unwrap_err(); + assert!( + error.detail().contains("checksum mismatch"), + "{}", + error.detail() + ); + let _ = fs::remove_dir_all(&into); + } +} diff --git a/crates/setup-core/src/checksum.rs b/crates/setup-core/src/checksum.rs new file mode 100644 index 0000000..5b389af --- /dev/null +++ b/crates/setup-core/src/checksum.rs @@ -0,0 +1,63 @@ +//! CRC-32, the checksum both archive formats carry. +//! +//! This is not a digest and is kept away from [`crate::digest`] deliberately. +//! SHA-256 decides whether bytes are the ones a plan named; CRC-32 only detects +//! that a stream arrived intact, and the two must never be confused at a call +//! site. Nothing here is a security property. +//! +//! Written rather than taken as a dependency: it is twenty lines, ZIP and gzip +//! use the same polynomial, and a checksum that decides whether to trust a +//! stream is not a good place to add a supply-chain edge. + +/// The reflected polynomial ZIP and gzip share. +const POLYNOMIAL: u32 = 0xEDB8_8320; + +/// CRC-32 over a complete buffer. +#[must_use] +pub fn crc32(bytes: &[u8]) -> u32 { + continue_crc32(0, bytes) +} + +/// CRC-32 over one more piece of a stream. +/// +/// Streaming matters here: the archives this checks inflate to hundreds of +/// megabytes, and buffering one to checksum it would defeat the point of +/// reading it incrementally. +#[must_use] +pub fn continue_crc32(seed: u32, bytes: &[u8]) -> u32 { + let mut crc = !seed; + for byte in bytes { + crc ^= u32::from(*byte); + for _ in 0..8 { + let mask = 0_u32.wrapping_sub(crc & 1); + crc = (crc >> 1) ^ (POLYNOMIAL & mask); + } + } + !crc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_published_check_value_is_reproduced() { + // The value every CRC-32 implementation is checked against. + assert_eq!(crc32(b"123456789"), 0xCBF4_3926); + } + + #[test] + fn the_empty_input_checksums_to_zero() { + assert_eq!(crc32(b""), 0); + } + + #[test] + fn streaming_in_pieces_agrees_with_hashing_the_whole() { + let whole = b"the quick brown fox jumps over the lazy dog"; + let mut running = 0; + for piece in whole.chunks(7) { + running = continue_crc32(running, piece); + } + assert_eq!(running, crc32(whole)); + } +} diff --git a/crates/setup-core/src/error.rs b/crates/setup-core/src/error.rs index 545bf28..2ec09a6 100644 --- a/crates/setup-core/src/error.rs +++ b/crates/setup-core/src/error.rs @@ -23,6 +23,8 @@ pub enum ReasonCode { UnsupportedProtocolVersion, /// The projection profile does not match the one the bundle was built for. ProjectionProfileMismatch, + /// A permission profile this provider does not offer. + UnsupportedPermissionProfile, /// The running operating system is outside the declared support matrix. UnsupportedPlatform, /// The running architecture is outside the declared support matrix. @@ -55,6 +57,7 @@ impl ReasonCode { Self::UnsupportedBundleFormat => "unsupported_bundle_format", Self::UnsupportedProtocolVersion => "unsupported_protocol_version", Self::ProjectionProfileMismatch => "projection_profile_mismatch", + Self::UnsupportedPermissionProfile => "unsupported_permission_profile", Self::UnsupportedPlatform => "unsupported_platform", Self::UnsupportedArchitecture => "unsupported_architecture", Self::RecoveryRequired => "recovery_required", diff --git a/crates/setup-core/src/lib.rs b/crates/setup-core/src/lib.rs index 9d38b3a..1e3f7c6 100644 --- a/crates/setup-core/src/lib.rs +++ b/crates/setup-core/src/lib.rs @@ -17,13 +17,32 @@ //! leaves evidence rather than ambiguity. [`journal`] owns what that evidence //! means and which command is allowed to resolve it. +pub mod archive; pub mod backup; pub mod canonical; +pub mod checksum; pub mod digest; pub mod error; pub mod journal; pub mod lock; +pub mod software; pub mod stamp; pub mod target; pub use error::{Error, ReasonCode, Result}; + +/// This host's operating system and architecture, in the consumer's spellings. +/// +/// `provider-v3` owns the canonical answer and depends on this crate, so it +/// cannot be asked from here. The two are bound by a test rather than by an +/// import. +#[must_use] +pub fn platform_of_this_host() -> (&'static str, &'static str) { + ( + std::env::consts::OS, + match std::env::consts::ARCH { + "aarch64" => "arm64", + other => other, + }, + ) +} diff --git a/crates/setup-core/src/software.rs b/crates/setup-core/src/software.rs new file mode 100644 index 0000000..c004ca6 --- /dev/null +++ b/crates/setup-core/src/software.rs @@ -0,0 +1,595 @@ +//! Installing the product itself, from bytes a plan named in advance. +//! +//! The contract splits every software operation into three phases: `plan` with +//! no network, `download` with `artifact_download`, and `apply` with no network +//! again. That split is the whole design. `plan` states the exact artifact for +//! the running platform -- url, byte length, `sha256` -- offline, from a table +//! compiled into this binary. `download` fetches precisely that. `apply` +//! re-checks the digest and extracts, offline. Nothing about what gets +//! installed is decided while the network is reachable. +//! +//! Where it lands is a directory of its own, named by the caller as `--prefix` +//! and distinct from the configuration target. That separation is in the agreed +//! contract and it is the right shape: a setup system owns `native_namespaces` +//! inside a target and preserves everything else verbatim, so installing a +//! program there would claim a path it has promised not to touch -- and one +//! program can serve several targets, which a program living inside one of them +//! could not. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::archive::{self, Limits}; +use crate::digest; +use crate::error::{Error, ReasonCode, Result}; + +/// How an artifact becomes a program on disk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shape { + /// The artifact's bytes *are* the executable. + /// + /// Grok publishes this way. Its npm package ships the same program + /// Brotli-compressed to fit the registry's tarball ceiling; the direct + /// distribution its own installer uses needs no decompression at all, and + /// the two were measured byte-identical. + Raw, + /// A gzip-compressed tar. The executable is one member inside it. + /// + /// The rest of the tree is not incidental and is never discarded: codex + /// ships `rg`, `zsh` and `bwrap` beside its binary, and cursor's executable + /// is a shell launcher that runs a bundled `node`. + GzipTar, +} + +/// One platform's artifact, exactly as a plan will state it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Artifact { + /// The platform key, spelled the way the consumer spells it: `linux/x86_64`. + pub platform: &'static str, + /// Where the bytes come from. + pub url: &'static str, + /// How many bytes to expect. + pub bytes: u64, + /// The `sha256:`-prefixed digest of those bytes. + pub sha256: &'static str, + /// How to turn the artifact into a program. + pub shape: Shape, + /// The executable's path inside the archive. Empty when [`Shape::Raw`]. + pub member: &'static str, +} + +/// How a product's software reaches a machine. +#[derive(Debug, Clone, Copy)] +pub enum Delivery { + /// Artifacts this provider fetches and places itself. + Artifacts(&'static [Artifact]), + /// A package manager resolves a dependency closure. + /// + /// Recorded rather than attempted. Running one means executing whatever the + /// registry resolves to, which is a different security question from + /// fetching bytes whose digest was decided in advance, and it is not + /// answered by pretending the operation is the same shape. + Manager { + /// The tool the product's own documentation names. + tool: &'static str, + /// Why this provider does not run it yet. + reason: &'static str, + }, +} + +/// A product's software lifecycle, as the runtime needs to know it. +#[derive(Debug, Clone, Copy)] +pub struct Software { + /// The version this build installs. + pub version: &'static str, + /// The command name the installed program answers to. + pub command: &'static str, + /// How it is delivered. + pub delivery: Delivery, + /// Platforms the vendor does not publish for. + /// + /// Said out loud rather than left as an absence: cursor ships no Windows + /// build, and a caller deserves that answer instead of "not found". + pub unsupported: &'static [&'static str], +} + +/// What an install produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Installed { + /// The version now on disk. + pub version: String, + /// The directory holding this version. + pub root: PathBuf, + /// The executable a caller runs. + pub executable: PathBuf, + /// How many files the artifact produced. + pub files: usize, +} + +impl Software { + /// The artifact for one platform, or the reason there is not one. + /// + /// # Errors + /// + /// Refuses with `unsupported_platform` when the vendor publishes nothing + /// for this operating system, and `unsupported_architecture` when it + /// publishes for the system but not this machine. + pub fn artifact_for(&self, os: &str, arch: &str) -> Result<&'static Artifact> { + let Delivery::Artifacts(artifacts) = self.delivery else { + return Err(Error::new( + ReasonCode::UnsupportedOperation, + match self.delivery { + Delivery::Manager { tool, reason } => { + format!("{} is delivered by {tool}: {reason}", self.command) + } + Delivery::Artifacts(_) => unreachable!(), + }, + )); + }; + + let wanted = format!("{os}/{arch}"); + if let Some(found) = artifacts.iter().find(|entry| entry.platform == wanted) { + return Ok(found); + } + + // Distinguish "no build for this system" from "no build for this + // machine". The consumer's closed reason set separates them, and the + // two are different problems for whoever reads the refusal. + let prefix = format!("{os}/"); + let system_is_published = artifacts + .iter() + .any(|entry| entry.platform.starts_with(&prefix)); + let reason = if system_is_published { + ReasonCode::UnsupportedArchitecture + } else { + ReasonCode::UnsupportedPlatform + }; + Err(Error::new( + reason, + format!( + "{} publishes no build for {wanted}; it publishes {}", + self.command, + artifacts + .iter() + .map(|entry| entry.platform) + .collect::>() + .join(", ") + ), + )) + } +} + +impl Artifact { + /// Check downloaded bytes against what the plan said they would be. + /// + /// Both the length and the digest, in that order: a truncated download is + /// the common case and saying so is more useful than a digest mismatch. + /// + /// # Errors + /// + /// Refuses with `integrity_mismatch` when either disagrees. + pub fn verify(&self, downloaded: &Path) -> Result<()> { + let found = fs::metadata(downloaded) + .map_err(|error| { + Error::new( + ReasonCode::StateUnavailable, + format!("downloaded artifact could not be read: {error}"), + ) + .with_source(error) + })? + .len(); + if found != self.bytes { + return Err(Error::new( + ReasonCode::IntegrityMismatch, + format!( + "{} is {found} bytes; the plan named {}", + downloaded.display(), + self.bytes + ), + )); + } + let measured = digest::of_file(downloaded)?; + if measured != self.sha256 { + return Err(Error::new( + ReasonCode::IntegrityMismatch, + format!( + "{} hashes to {measured}; the plan named {}", + downloaded.display(), + self.sha256 + ), + )); + } + Ok(()) + } + + /// How much this artifact is allowed to produce. + /// + /// A compressed length says nothing about what it inflates to, so the limit + /// is the caller's rather than the archive's. Sixteen times the compressed + /// size with a floor covers every measured artifact -- the widest is + /// claude's, at a little over three -- and stops a stream that would + /// otherwise keep producing until the disk filled. + #[must_use] + pub const fn limits(&self) -> Limits { + Limits { + entries: 65_536, + bytes: match self.bytes.checked_mul(16) { + Some(scaled) if scaled > 64 * 1024 * 1024 => scaled, + _ => 64 * 1024 * 1024, + }, + } + } +} + +/// Install a verified artifact under `root`, replacing any same-version tree. +/// +/// The digest is checked here rather than trusted from the download phase, +/// because this phase is the one that runs without a network and is therefore +/// the one whose check means something. +/// +/// # Errors +/// +/// Refuses an artifact that does not match the plan, an archive this reader +/// will not accept, or an archive that does not contain the member it named. +pub fn install( + software: &Software, + artifact: &Artifact, + downloaded: &Path, + root: &Path, +) -> Result { + artifact.verify(downloaded)?; + + let version_root = root.join(software.version); + if version_root.exists() { + fs::remove_dir_all(&version_root).map_err(|error| { + Error::new( + ReasonCode::StateUnavailable, + format!( + "the existing {} tree could not be cleared: {error}", + software.version + ), + ) + .with_source(error) + })?; + } + + let source = fs::File::open(downloaded).map_err(|error| { + Error::new( + ReasonCode::StateUnavailable, + format!("downloaded artifact could not be opened: {error}"), + ) + .with_source(error) + })?; + + let (executable, files) = match artifact.shape { + Shape::Raw => { + let placed = version_root.join(software.command); + archive::place_executable(source, &placed)?; + (placed, 1) + } + Shape::GzipTar => { + let entries = archive::extract_gzip_tar(source, &version_root, artifact.limits())?; + let found = entries + .iter() + .any(|entry| entry.path == artifact.member && entry.kind == archive::Kind::File); + if !found { + return Err(Error::new( + ReasonCode::IntegrityMismatch, + format!( + "the archive does not contain {}, which the plan named as the executable", + artifact.member + ), + )); + } + (version_root.join(artifact.member), entries.len()) + } + }; + + let exposed = root.join("bin").join(software.command); + expose(&executable, &exposed)?; + + Ok(Installed { + version: software.version.to_owned(), + root: version_root, + executable: exposed, + files, + }) +} + +/// Remove one installed version, and the exposed command if it pointed at it. +/// +/// # Errors +/// +/// Fails if the tree exists and cannot be removed. +pub fn remove(software: &Software, root: &Path) -> Result { + let version_root = root.join(software.version); + if !version_root.exists() { + return Ok(false); + } + fs::remove_dir_all(&version_root).map_err(|error| { + Error::new( + ReasonCode::StateUnavailable, + format!( + "the {} tree could not be removed: {error}", + software.version + ), + ) + .with_source(error) + })?; + let exposed = root.join("bin").join(software.command); + if exposed.symlink_metadata().is_ok() { + fs::remove_file(&exposed).map_err(|error| { + Error::new( + ReasonCode::StateUnavailable, + format!("{} could not be removed: {error}", exposed.display()), + ) + .with_source(error) + })?; + } + Ok(true) +} + +/// Point one stable path at the executable inside a versioned tree. +/// +/// The member is left where the archive put it. Codex's binary needs the `rg` +/// and `bwrap` beside it and cursor's launcher needs its bundled `node`, so +/// moving the executable out of its tree would produce a file that runs on the +/// machine it was built on and nowhere else. +fn expose(executable: &Path, exposed: &Path) -> Result<()> { + let fail = |error: std::io::Error| { + Error::new( + ReasonCode::StateUnavailable, + format!("{} could not be exposed: {error}", exposed.display()), + ) + .with_source(error) + }; + + if let Some(parent) = exposed.parent() { + fs::create_dir_all(parent).map_err(fail)?; + } + if exposed.symlink_metadata().is_ok() { + fs::remove_file(exposed).map_err(fail)?; + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(executable, exposed).map_err(fail) + } + #[cfg(not(unix))] + { + // Windows reserves symlink creation for privileged or developer-mode + // processes, so a hard link is what actually works; a copy is the last + // resort and costs a second copy of a large binary. + fs::hard_link(executable, exposed) + .or_else(|_| fs::copy(executable, exposed).map(|_| ())) + .map_err(fail) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + + use super::*; + use crate::archive::build::{Dialect, Item, gzip_tar}; + + /// Codex's real shape: the executable is deep inside a tree whose siblings + /// it needs at runtime. + const CODEX_MEMBER: &str = "package/vendor/x86_64-unknown-linux-musl/bin/codex"; + + const ARTIFACTS: &[Artifact] = &[ + Artifact { + platform: "linux/x86_64", + url: "https://example.invalid/linux-x86_64.tgz", + bytes: 0, + sha256: "sha256:0", + shape: Shape::GzipTar, + member: CODEX_MEMBER, + }, + Artifact { + platform: "linux/arm64", + url: "https://example.invalid/linux-arm64.tgz", + bytes: 0, + sha256: "sha256:0", + shape: Shape::GzipTar, + member: CODEX_MEMBER, + }, + ]; + + fn software() -> Software { + Software { + version: "1.2.3", + command: "codex", + delivery: Delivery::Artifacts(ARTIFACTS), + unsupported: &["windows/x86_64"], + } + } + + fn scratch(name: &str) -> PathBuf { + let path = + std::env::temp_dir().join(format!("setup-core-software-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + path + } + + /// Write an archive to disk and return it with an artifact describing it. + fn staged(name: &str, body: &[u8], member: &'static str) -> (PathBuf, Artifact) { + let raw = gzip_tar( + &[ + Item::directory("package"), + Item::file(member, body, 0o755), + Item::file("package/README.md", b"read me", 0o644), + ], + Dialect::Gnu, + ); + let at = scratch(name); + fs::create_dir_all(&at).unwrap(); + let file = at.join("artifact.tgz"); + fs::write(&file, &raw).unwrap(); + let artifact = Artifact { + platform: "linux/x86_64", + url: "https://example.invalid/artifact.tgz", + bytes: raw.len() as u64, + sha256: Box::leak(digest::of_bytes(&raw).into_boxed_str()), + shape: Shape::GzipTar, + member, + }; + (at, artifact) + } + + #[test] + fn the_artifact_for_this_platform_is_the_one_named_for_it() { + let found = software().artifact_for("linux", "x86_64").unwrap(); + assert_eq!(found.url, "https://example.invalid/linux-x86_64.tgz"); + } + + #[test] + fn a_system_the_vendor_does_not_build_for_is_an_unsupported_platform() { + let error = software().artifact_for("windows", "x86_64").unwrap_err(); + assert_eq!(error.reason(), ReasonCode::UnsupportedPlatform); + assert!( + error.detail().contains("linux/x86_64"), + "{}", + error.detail() + ); + } + + #[test] + fn a_machine_the_vendor_does_not_build_for_is_an_unsupported_architecture() { + // The system is published, this machine is not. The consumer's closed + // set separates these and so must the refusal. + let error = software().artifact_for("linux", "riscv64").unwrap_err(); + assert_eq!(error.reason(), ReasonCode::UnsupportedArchitecture); + } + + #[test] + fn a_product_delivered_by_a_package_manager_says_so_rather_than_pretending() { + let pi = Software { + version: "0.84.3", + command: "pi", + delivery: Delivery::Manager { + tool: "npm", + reason: "its dependency closure is resolved at install time", + }, + unsupported: &[], + }; + let error = pi.artifact_for("linux", "x86_64").unwrap_err(); + assert_eq!(error.reason(), ReasonCode::UnsupportedOperation); + assert!(error.detail().contains("npm"), "{}", error.detail()); + } + + #[test] + fn an_archive_installs_and_exposes_one_stable_command() { + let (at, artifact) = staged("install", b"#!/bin/sh\necho hi\n", CODEX_MEMBER); + let root = at.join("software"); + let installed = install(&software(), &artifact, &at.join("artifact.tgz"), &root).unwrap(); + + assert_eq!(installed.version, "1.2.3"); + assert_eq!(installed.files, 3); + assert_eq!(installed.executable, root.join("bin/codex")); + // The executable stays inside its tree; only a link leaves it. Codex + // needs `rg` and `bwrap` beside it, so moving the binary would break it. + assert!(root.join("1.2.3").join(CODEX_MEMBER).is_file()); + assert!(root.join("1.2.3/package/README.md").is_file()); + assert_eq!( + fs::read(&installed.executable).unwrap(), + b"#!/bin/sh\necho hi\n" + ); + fs::remove_dir_all(&at).unwrap(); + } + + #[test] + fn installing_twice_replaces_the_tree_rather_than_merging_into_it() { + let (at, artifact) = staged("twice", b"first", CODEX_MEMBER); + let root = at.join("software"); + install(&software(), &artifact, &at.join("artifact.tgz"), &root).unwrap(); + let stray = root.join("1.2.3/package/left-over"); + fs::write(&stray, b"from an older install").unwrap(); + + install(&software(), &artifact, &at.join("artifact.tgz"), &root).unwrap(); + assert!(!stray.exists(), "a replaced tree must not keep older files"); + fs::remove_dir_all(&at).unwrap(); + } + + #[test] + fn bytes_that_are_not_the_ones_the_plan_named_are_refused() { + let (at, mut artifact) = staged("digest", b"payload", CODEX_MEMBER); + artifact.sha256 = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + let error = install( + &software(), + &artifact, + &at.join("artifact.tgz"), + &at.join("s"), + ) + .unwrap_err(); + assert_eq!(error.reason(), ReasonCode::IntegrityMismatch); + assert!(error.detail().contains("hashes to"), "{}", error.detail()); + fs::remove_dir_all(&at).unwrap(); + } + + #[test] + fn a_truncated_download_is_named_as_a_length_problem_not_a_digest_one() { + let (at, artifact) = staged("length", b"payload", CODEX_MEMBER); + let file = at.join("artifact.tgz"); + let mut bytes = fs::read(&file).unwrap(); + bytes.truncate(bytes.len() - 4); + fs::write(&file, &bytes).unwrap(); + + let error = install(&software(), &artifact, &file, &at.join("s")).unwrap_err(); + assert!( + error.detail().contains("bytes; the plan named"), + "{}", + error.detail() + ); + fs::remove_dir_all(&at).unwrap(); + } + + #[test] + fn an_archive_without_the_member_the_plan_named_is_refused() { + let (at, mut artifact) = staged("member", b"payload", CODEX_MEMBER); + artifact.member = "package/vendor/somewhere-else/bin/codex"; + let error = install( + &software(), + &artifact, + &at.join("artifact.tgz"), + &at.join("s"), + ) + .unwrap_err(); + assert!( + error.detail().contains("does not contain"), + "{}", + error.detail() + ); + fs::remove_dir_all(&at).unwrap(); + } + + #[test] + fn removing_takes_the_tree_and_the_exposed_command_with_it() { + let (at, artifact) = staged("remove", b"payload", CODEX_MEMBER); + let root = at.join("software"); + let installed = install(&software(), &artifact, &at.join("artifact.tgz"), &root).unwrap(); + assert!(installed.executable.symlink_metadata().is_ok()); + + assert!(remove(&software(), &root).unwrap()); + assert!(!root.join("1.2.3").exists()); + assert!(installed.executable.symlink_metadata().is_err()); + // Removing what is already gone is not a failure, and says so. + assert!(!remove(&software(), &root).unwrap()); + fs::remove_dir_all(&at).unwrap(); + } + + #[test] + fn the_inflation_limit_scales_with_the_artifact_but_never_below_a_floor() { + let small = Artifact { + bytes: 10, + ..ARTIFACTS[0] + }; + assert_eq!(small.limits().bytes, 64 * 1024 * 1024); + let large = Artifact { + bytes: 121_422_431, + ..ARTIFACTS[0] + }; + // Claude's is the widest measured expansion, a little over three times. + assert!(large.limits().bytes > 391_948_592); + } +} diff --git a/provider-kit/v3/KIT-IDENTITY.json b/provider-kit/v3/KIT-IDENTITY.json index 539b88d..83a5f9d 100644 --- a/provider-kit/v3/KIT-IDENTITY.json +++ b/provider-kit/v3/KIT-IDENTITY.json @@ -1,11 +1,11 @@ { - "aggregate_digest": "sha256:d45add27fded30962f411441547c92cc9d06264035c2d314357c24d3d983b819", + "aggregate_digest": "sha256:24609284f0eb6c8aae7185765eaac0477fab953c0b5b6522203f95dc8375f45a", "files": [ "conformance-cases.json", "manifest.json", "provider-info.schema.json" ], - "kit_version": "0.2.0", + "kit_version": "0.2.1", "protocol_version": 3, "schema": "ai-stp-provider-kit-identity/1" } diff --git a/provider-kit/v3/SHA256SUMS b/provider-kit/v3/SHA256SUMS index e341784..a2566b8 100644 --- a/provider-kit/v3/SHA256SUMS +++ b/provider-kit/v3/SHA256SUMS @@ -1,3 +1,3 @@ -bf541040850cdba66b3bf0e9283e12d45573b195ea0cc9b13f66594f328d11c2 conformance-cases.json -be1b49d81412e974ba47bc071275a87df1ff3cff7a227481b3d46a5e7a3d0339 manifest.json +8cd34b83b7f3b9b933a0e71e1c63a506da63934d1dd12cca54c7a3853bdc1c7e conformance-cases.json +1e618bb35f771875568661003515168acb2e0fed764ae31ddff275ad7c174b27 manifest.json 070f2f013ac478862719fcfc69c04c376aa1aa1d9e0687237aa291509ccbfcad provider-info.schema.json diff --git a/provider-kit/v3/conformance-cases.json b/provider-kit/v3/conformance-cases.json index 7595c8c..51b604a 100644 --- a/provider-kit/v3/conformance-cases.json +++ b/provider-kit/v3/conformance-cases.json @@ -70,6 +70,10 @@ "case": "projection_profile_mismatch", "expected_reason": "projection_profile_mismatch" }, + { + "case": "unsupported_permission_profile", + "expected_reason": "unsupported_permission_profile" + }, { "case": "unsupported_platform", "expected_reason": "unsupported_platform" diff --git a/provider-kit/v3/manifest.json b/provider-kit/v3/manifest.json index 52ce8b7..1c23733 100644 --- a/provider-kit/v3/manifest.json +++ b/provider-kit/v3/manifest.json @@ -39,7 +39,7 @@ ], "decision": "docs/adr/ADR-0061-capability-negotiated-provider-protocol-v3.md", "generated_from": "apps/cli/src/ai_stp_cli/provider/protocol_v3.py", - "kit_version": "0.2.0", + "kit_version": "0.2.1", "operation_network": { "backup": [ { @@ -194,6 +194,7 @@ "unsupported_bundle_format", "unsupported_protocol_version", "projection_profile_mismatch", + "unsupported_permission_profile", "unsupported_platform", "unsupported_architecture" ] diff --git a/references/opencode-baseline.json b/references/opencode-baseline.json index 234b40c..f28db0a 100644 --- a/references/opencode-baseline.json +++ b/references/opencode-baseline.json @@ -1,5 +1,5 @@ { - "schema_version": 2, + "schema_version": 3, "verified_version_ref": "build/version.json:opencode_version", "runtime": { "product": "OpenCode", @@ -90,5 +90,49 @@ "OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_SHARE" ], - "verified_at": "2026-08-25T12:00:27+00:00" + "verified_at": "2026-08-25T13:06:34+00:00", + "software_artifacts": { + "command": "opencode", + "shape": "gzip-tar", + "platforms": { + "linux/arm64": { + "url": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.23.tgz", + "bytes": 59944416, + "sha256": "sha256:ca35ecf5e33bab3e1d9e45b4387a07ed7804178c046b44d797306f2c82ba581e", + "member": "package/bin/opencode" + }, + "linux/x86_64": { + "url": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.23.tgz", + "bytes": 60167326, + "sha256": "sha256:b987ad440e278a8d543ec99cbac11a14fb3d19ae7254fecfe825dba1f133729e", + "member": "package/bin/opencode" + }, + "macos/arm64": { + "url": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.23.tgz", + "bytes": 45938603, + "sha256": "sha256:1415acecc9f520cf28459cef66f9487a9b173ad10b0ea093b8f5087401b0e125", + "member": "package/bin/opencode" + }, + "macos/x86_64": { + "url": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.23.tgz", + "bytes": 48114961, + "sha256": "sha256:d217059f954f34458ff1ee41b946d22ff0cb5d7d165d165dfa72bc4e65faac0a", + "member": "package/bin/opencode" + }, + "windows/arm64": { + "url": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.23.tgz", + "bytes": 58397371, + "sha256": "sha256:919de21beb75a2f93776a7550da5d964b0231545ba6e3ce84b19f565544b1e18", + "member": "package/bin/opencode.exe" + }, + "windows/x86_64": { + "url": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.23.tgz", + "bytes": 60078745, + "sha256": "sha256:28e4a6f4079f907b1fd198c7aeea6be96dcf7d6edc428cc7199a4cbe3e3ac6ed", + "member": "package/bin/opencode.exe" + } + }, + "version": "1.18.23", + "verified_at": "2026-08-25T13:06:34+00:00" + } } From 13aa922cc3f00d68cf75e9979dbe23362cc4108a Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 25 Aug 2026 19:48:21 +0500 Subject: [PATCH 2/2] fix(test): a prefix fixture that was only absolute on one of the three systems `rust / test (windows-latest)` failed here while ubuntu and macos passed. The test passed the literal `/tmp`, which on Windows is rooted but not absolute, so the argv parser refused it one step earlier and the test never reached its assertion. `std::env::temp_dir()` is absolute on all three. The product code was correct throughout: `Path::is_absolute` is platform-aware, and refusing a Windows path with no drive is what "both absolute" means there. --- crates/harness-runtime/src/wire.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index 6e4b329..2809057 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -2197,6 +2197,11 @@ mod tests { #[test] fn a_prefix_on_an_operation_that_installs_nothing_is_refused_not_ignored() { let target = seeded("software-strayprefix"); + // Not a literal `/tmp`: on Windows that is rooted but not absolute, so + // the parser refuses it one step earlier and this test never reaches + // its assertion. The three-OS matrix caught exactly that. + let elsewhere = std::env::temp_dir(); + let elsewhere = elsewhere.to_string_lossy(); let error = refuse(args( "plan-operation", &target, @@ -2210,7 +2215,7 @@ mod tests { "--expires-at", far_future(), "--prefix", - "/tmp", + &elsewhere, ], )); assert!(