diff --git a/CHANGELOG.md b/CHANGELOG.md index cdfe7b6..6c8585e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,35 @@ The project follows Semantic Versioning. `0.0.x` says plainly that the wire surface is proven against the consumer's conformance but has not yet been run against a real installation on every platform it claims. +An entry is never edited after its release. It says what that release was, +including claims a later release made false. + ## [Unreleased] +## [0.0.2] - 2026-08-25 + +Installs OpenCode itself, not only its configuration. + +- `software_install`, `software_update` and `software_remove`, in the shape + agreed with the consumer on `ai-engineers-guild/ai_stp#414`: `--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, 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. +- Software lands under `--prefix`, never the configuration target, and spends + no backup slot: there are ten, and they hold configuration. +- Reads the one archive shape every vendor ships -- a gzip-compressed tar, or + plain bytes. POSIX `ustar` and GNU tar with long-name headers; regular files + and directories only. Every other entry type is refused by name. +- The vendored provider kit moves to 0.2.1. A permission profile this build + never advertised now answers `unsupported_permission_profile` instead of the + nearest thing the previous closed set had. + +Linux, macOS and Windows; x86_64 and arm64. + ## [0.0.1] - 2026-08-24 First release. Installs, reselects, restores and removes a complete diff --git a/Cargo.lock b/Cargo.lock index 95fd472..aed3d9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "harness-runtime" -version = "0.0.1" +version = "0.0.2" dependencies = [ "provider-v3", "serde", @@ -128,7 +128,7 @@ dependencies = [ [[package]] name = "opencode-setup-system" -version = "0.0.1" +version = "0.0.2" dependencies = [ "harness-runtime", "provider-v3", @@ -147,7 +147,7 @@ dependencies = [ [[package]] name = "provider-v3" -version = "0.0.1" +version = "0.0.2" dependencies = [ "serde", "serde_json", @@ -209,7 +209,7 @@ dependencies = [ [[package]] name = "setup-core" -version = "0.0.1" +version = "0.0.2" dependencies = [ "miniz_oxide", "serde", diff --git a/Cargo.toml b/Cargo.toml index 686f535..48ab424 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.0.1" +version = "0.0.2" edition = "2024" rust-version = "1.89" license = "AGPL-3.0-or-later" @@ -23,9 +23,9 @@ sha2 = "0.11" # `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" } +setup-core = { path = "crates/setup-core", version = "0.0.2" } +provider-v3 = { path = "crates/provider-v3", version = "0.0.2" } +harness-runtime = { path = "crates/harness-runtime", version = "0.0.2" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index c7de08c..cd473f5 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,15 @@ the whole configuration — not a pointer to somewhere the content really lives. That is what makes restore mean something: it returns the instructions, skills, agents, commands, hooks, MCP entries and settings together, in one step. -> **Status: complete for the five core operations.** `install`, `replace`, -> `backup`, `restore` and `remove` all work, over the wire and from the local -> catalog. The software lifecycle and `launch` are optional in the contract and -> are not declared, because this build does not perform them. +> **Status: complete for the five core operations and the program lifecycle.** +> +> `install`, `replace`, `backup`, `restore` and `remove` all work, over the wire +> and from the local catalog. `launch` is optional in the contract and is not +> declared. +> +> The software lifecycle installs the product itself: a plan names the +> exact bytes offline, whoever holds the network fetches them, and apply +> verifies and installs with the network gone. ## Using it @@ -142,7 +147,7 @@ release is a convenience, not the authorised copy. ```bash docker run --rm -v "$HOME/.config:/config" \ - ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.1 \ + ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.2 \ status --target /config/ --json ``` diff --git a/SUPPORT.md b/SUPPORT.md index 679071a..561edb5 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -20,11 +20,14 @@ slot holds whatever the target held when it was captured. ## What is not supported -The software lifecycle — installing, updating or removing the product itself — -and `launch` are optional in the provider contract and are not declared here, -because this build does not perform them. A provider that advertised them would -let a caller ask for something that cannot be honoured, which is worse than not -offering them. +The software lifecycle — installing, updating and removing the product +itself — is declared and does work. `plan` names the exact bytes offline, +whoever holds the network fetches them, and `apply` verifies and installs +with the network gone. + +`launch` is optional in the provider contract and is not declared here. A +provider that advertised an operation it cannot perform would let a caller ask +for something that cannot be honoured, which is worse than not offering it. All five core operations do work: `backup`, `restore`, `remove`, `install` and `replace`, both from the local setup catalog and from an `ai-stp-bundle/1` diff --git a/crates/setup-core/src/archive.rs b/crates/setup-core/src/archive.rs index dbd896c..04a0fb0 100644 --- a/crates/setup-core/src/archive.rs +++ b/crates/setup-core/src/archive.rs @@ -77,10 +77,25 @@ fn refuse(detail: impl Into) -> Error { Error::new(ReasonCode::IntegrityMismatch, detail) } +/// A failure that came from the *destination*: a disk, a permission, a path. +/// +/// The caller's own machine could not do what was asked, which is a different +/// problem from bytes that are not a well-formed archive. fn from_io(detail: &str, error: io::Error) -> Error { Error::new(ReasonCode::StateUnavailable, format!("{detail}: {error}")).with_source(error) } +/// A failure that came from the *source*: the bytes are not what they claim. +/// +/// A malformed DEFLATE stream or a truncated tar arrives as an `io::Error`, +/// because that is how a reader reports it -- but reporting it as +/// `state_unavailable` would send whoever reads the refusal to look at their +/// disk instead of at the archive they were handed. A robustness pass over 256 +/// corrupted archives found 163 of them answering that way. +fn from_source_io(detail: &str, error: io::Error) -> Error { + Error::new(ReasonCode::IntegrityMismatch, 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 @@ -109,7 +124,7 @@ impl Gunzip { let mut head = [0_u8; 10]; inner .read_exact(&mut head) - .map_err(|error| from_io("gzip header could not be read", error))?; + .map_err(|error| from_source_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}", @@ -131,20 +146,20 @@ impl Gunzip { } 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))?; + inner.read_exact(&mut length).map_err(|error| { + from_source_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))?; + .map_err(|error| from_source_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) + from_source_io(&format!("gzip {what} field could not be read"), error) })?; if byte[0] == 0 { break; @@ -156,7 +171,7 @@ impl Gunzip { let mut check = [0_u8; 2]; inner .read_exact(&mut check) - .map_err(|error| from_io("gzip header checksum could not be read", error))?; + .map_err(|error| from_source_io("gzip header checksum could not be read", error))?; } Ok(Self { @@ -389,7 +404,7 @@ impl Tar { let read = self .inner .read(&mut self.buffer[..want]) - .map_err(|error| from_io("archive content could not be read", error))?; + .map_err(|error| from_source_io("archive content could not be read", error))?; if read == 0 { return Err(refuse("archive ended in the middle of an entry")); } @@ -418,7 +433,7 @@ impl Tar { self.padding = 0; self.inner .read_exact(&mut waste[..take]) - .map_err(|error| from_io("archive padding could not be read", error)) + .map_err(|error| from_source_io("archive padding could not be read", error)) } /// Read one 512-byte block, returning `None` at the end-of-archive marker. @@ -429,7 +444,7 @@ impl Tar { let read = self .inner .read(&mut block[have..]) - .map_err(|error| from_io("archive header could not be read", error))?; + .map_err(|error| from_source_io("archive header could not be read", error))?; if read == 0 { if have == 0 { return Ok(None); @@ -1136,6 +1151,110 @@ mod tests { fs::remove_dir_all(&into).unwrap(); } + /// A deterministic mutator, so a failure is reproducible from the seed. + /// + /// Not a random number generator worth the name -- an LCG with the + /// constants from Numerical Recipes. It only has to visit a lot of + /// different byte patterns in the same order every time. + fn scramble(seed: u64, bytes: &mut [u8], edits: usize) { + let mut state = seed; + for _ in 0..edits { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + // Taking bytes out of the state rather than casting it: the + // truncation is the point, and saying so with `to_le_bytes` needs + // no exception from the lint that would otherwise object. + let octets = state.to_le_bytes(); + let at = usize::from(u16::from_le_bytes([octets[2], octets[3]])) % bytes.len(); + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + bytes[at] = state.to_le_bytes()[3]; + } + } + + #[test] + fn arbitrary_bytes_are_refused_and_never_panic() { + // This reader is handed whatever a vendor's CDN returned, before any + // digest has been checked -- `apply` verifies the artifact, but the + // gzip and tar framing is parsed to *get* to the bytes a digest covers. + // So every malformed input has to be a refusal, not an abort, and not a + // write outside the destination. + // + // A real fuzzer would be better. This is the shape of one that runs in + // the ordinary test suite: a known-good archive, corrupted in a + // reproducible sequence, plus inputs that were never an archive at all. + let good = gzip_tar( + &[ + Item::directory("package"), + Item::file("package/program", &[9_u8; 3000], 0o755), + Item::file("package/notes.md", b"read me", 0o644), + ], + Dialect::Gnu, + ); + + let into = scratch("arbitrary"); + for seed in 0..256_u64 { + let mut corrupted = good.clone(); + let edits = 1 + usize::try_from(seed % 12).unwrap_or(0); + scramble(seed, &mut corrupted, edits); + // Whatever it decides, it must decide it: no panic, no hang, and + // nothing left outside the directory it was given. And when it + // refuses, it must say the archive is wrong -- not that this + // machine's state is unavailable, which would send whoever reads + // the refusal to look at their disk. All 256 answered that way once. + match extract_gzip_tar(corrupted.as_slice(), &into, ROOMY) { + Ok(_) => {} + Err(error) => assert_eq!( + error.reason(), + ReasonCode::IntegrityMismatch, + "seed {seed} blamed the wrong side: {error}" + ), + } + assert!( + !into.join("..").join("escaped").exists(), + "seed {seed} wrote outside the destination" + ); + } + + // Inputs that are not archives at all, including ones whose first bytes + // look like one. + for (label, bytes) in [ + ("empty", [].as_slice()), + ("one byte", b"\x1f".as_slice()), + ("gzip magic only", b"\x1f\x8b".as_slice()), + ( + "gzip header, no body", + b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff".as_slice(), + ), + ("zip", b"PK\x03\x04\x14\x00\x00\x00".as_slice()), + ( + "text", + b"this is not an archive, it is a sentence".as_slice(), + ), + ] { + let outcome = extract_gzip_tar(bytes, &into, ROOMY); + assert!(outcome.is_err(), "{label} was accepted as an archive"); + } + + let _ = fs::remove_dir_all(&into); + } + + #[test] + fn a_declared_size_larger_than_the_archive_is_refused_not_trusted() { + // The header says how long the entry is. A reader that believed it + // would read past the end of what it was given. + let mut raw = tar(&[Item::file("payload", b"short", 0o644)], Dialect::Posix); + raw[124..136].copy_from_slice(b"77777777777\0"); + 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()); + + let into = scratch("bigsize"); + let error = extract_gzip_tar(gzip(&raw).as_slice(), &into, ROOMY).unwrap_err(); + assert_eq!(error.reason(), ReasonCode::IntegrityMismatch); + let _ = fs::remove_dir_all(&into); + } + #[test] fn a_header_whose_checksum_does_not_match_is_refused() { let mut raw = tar(&[Item::file("payload", b"x", 0o644)], Dialect::Posix); diff --git a/install.ps1 b/install.ps1 index 7e6dba3..bfeb4fb 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,7 +7,7 @@ # powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.1.0 [CmdletBinding()] param( - [string]$Version = "0.0.1", + [string]$Version = "0.0.2", [string]$InstallDir = "$env:LOCALAPPDATA\Programs\opencode-setup-system" ) $ErrorActionPreference = "Stop" diff --git a/install.sh b/install.sh index 6e0e2f8..91670f9 100644 --- a/install.sh +++ b/install.sh @@ -14,7 +14,7 @@ set -eu REPO="NDDev-OpenNetwork/opencode-setup-system" BINARY="opencode-setup-system" -VERSION="${1:-0.0.1}" +VERSION="${1:-0.0.2}" PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}" case "$(uname -s)" in