From d4a1251e6a2bf88212499ee8227d8b43723ac32d Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 25 Aug 2026 21:16:30 +0500 Subject: [PATCH 1/2] chore(release): 0.0.2 Bumps the version and turns CHANGELOG.md into a real ledger: the entry for 0.0.1 stays as it was written, including the claims 0.0.2 made false, and the new entry says what this release is. README.md and SUPPORT.md stop making an unconditional claim that the software lifecycle is not declared. That was true when it was written and is now true of exactly one of the seven trees, so each says what is true of the tree it is in. The install scripts default to 0.0.2. --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ Cargo.lock | 8 ++++---- Cargo.toml | 8 ++++---- README.md | 15 ++++++++++----- SUPPORT.md | 13 ++++++++----- install.ps1 | 2 +- install.sh | 2 +- 7 files changed, 55 insertions(+), 20 deletions(-) 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/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 From 934595b10bd51f12b301eae084bfe500fefa3c92 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 25 Aug 2026 21:53:00 +0500 Subject: [PATCH 2/2] test: hand the archive reader bytes nobody wrote, and fix what it blamed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 256 reproducible corruptions of a known-good archive, plus six inputs that were never one. All are refused and none panics — and they now answer `integrity_mismatch` rather than `state_unavailable`, which had been saying the machine could not read its own state when the archive was simply malformed. --- crates/setup-core/src/archive.rs | 139 ++++++++++++++++++++++++++++--- 1 file changed, 129 insertions(+), 10 deletions(-) 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);