diff --git a/crates/spt/src/cli.rs b/crates/spt/src/cli.rs index 8d5b4315..d6db4189 100644 --- a/crates/spt/src/cli.rs +++ b/crates/spt/src/cli.rs @@ -9587,6 +9587,347 @@ fn report_apply_outcome(outcome: spt_daemon::ApplyStagedOutcome) -> i32 { } } +/// The bundled-adapters archive's index file (releases#338). +const BUNDLE_INDEX_FILE: &str = "bundle.json"; + +/// One member row of a bundle's `bundle.json` (releases#338): the adapter +/// name, the version its archive carries, the archive's file name inside the +/// bundle, and that archive's sha256. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +struct BundleMember { + name: String, + version: String, + asset: String, + sha256: String, +} + +/// A bundle's `bundle.json` (releases#338). +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +struct BundleIndex { + members: Vec, +} + +/// What the bundle leg does with one member, decided from the registry +/// (releases#338). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BundleMemberAction { + /// No record: install it. + Install, + /// Registered at an OLDER version: upgrade it. + Upgrade, + /// Registered at an equal or newer version: untouched, never downgraded. + Untouched, + /// Removed by the operator (`adapter remove`): the bundle does not bring + /// it back on every core update. + Removed, +} + +/// PURE, and the whole no-downgrade rule: only an absent member installs and +/// only a strictly NEWER bundled version upgrades. `registered` is the +/// member's registered version and whether that record is active, `None` +/// when there is no record at all. +// [impl->REQ-BUNDLE-APPLY-MEMBERS] +fn bundle_member_action(registered: Option<(&str, bool)>, bundled: &str) -> BundleMemberAction { + match registered { + None => BundleMemberAction::Install, + Some((_, false)) => BundleMemberAction::Removed, + Some((installed, true)) if version_is_newer(bundled, installed) => { + BundleMemberAction::Upgrade + } + Some(_) => BundleMemberAction::Untouched, + } +} + +/// What the bundle leg did with one member. +#[derive(Debug, Clone, PartialEq, Eq)] +enum BundleMemberOutcome { + Installed, + Upgraded { from: String }, + Untouched { installed: String }, + Removed, + /// Refused before anything was registered: bad name, missing archive, + /// sha256 not the one `bundle.json` names, or a manifest that is not the + /// member it claims to be. + Rejected(String), + /// Verified, but the install or upgrade did not land. + Failed(String), +} + +/// What one apply's bundle leg concluded (releases#338). +#[derive(Debug, Clone, PartialEq, Eq)] +enum BundleLanding { + /// The staged set signs no bundle — every set through v0.72.0 and every + /// debug rollout set. Nothing to apply, silently. + NothingToApply, + /// The whole bundle was skipped; the words say why. + Skipped(String), + /// One outcome per `bundle.json` member, in its order. + Members(Vec<(String, BundleMemberOutcome)>), +} + +/// A `bundle.json` name used as ONE path component (a member name, an asset +/// file name): no separators, no leading dot, so neither can climb out of the +/// extraction dir or the adapters home. +fn bundle_safe_component(s: &str) -> bool { + !s.is_empty() + && !s.starts_with('.') + && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +/// Land the staged bundled adapters after a successful binary apply +/// (releases#338): install each absent member, upgrade each older one, and +/// leave an equal-or-newer one alone. A bundle failure is loud +/// (`UPDATE_BUNDLE_SKIPPED`) and never changes the binary apply's outcome, +/// which is decided before this runs — the docs-leg isolation precedent. +/// Floors are judged against the core this apply just staged. +// [impl->REQ-BUNDLE-APPLY-MEMBERS] +fn land_staged_bundle(cache: &spt_daemon::ReleaseCache) { + let staged_json = cache.staged_update().map(|s| s.metadata_json().to_string()); + let core = floor_basis_version(&floor_basis(staged_json.as_deref()), env!("CARGO_PKG_VERSION")); + let scratch = perch::spt_home().join("bundle.x"); + if let BundleLanding::Skipped(why) = + land_bundle_in(cache, &spt_store::perch::adapters_dir(), &scratch, &core) + { + eprintln!("UPDATE_BUNDLE_SKIPPED: {why} — the core update stands"); + } +} + +/// [`land_staged_bundle`] with its directories and floor basis injected. +// [impl->REQ-BUNDLE-APPLY-MEMBERS] +fn land_bundle_in( + cache: &spt_daemon::ReleaseCache, + adapters: &std::path::Path, + scratch: &std::path::Path, + core_version: &str, +) -> BundleLanding { + let meta = match cache.staged_update() { + Some(spt_daemon::StagedUpdate::Set(signed)) => { + serde_json::from_str::(&signed.metadata_json).ok() + } + _ => None, + }; + // No bundle entry = NOTHING TO APPLY. The verifier is never asked about a + // bundle-less set: it refuses one as Malformed, which is the RELEASE + // path's rule (never sign without it), not the apply path's. + let Some(meta) = meta.filter(|m| m.bundle.is_some()) else { + return BundleLanding::NothingToApply; + }; + let Some(bytes) = cache.staged_bundle() else { + return BundleLanding::Skipped( + "the signed set declares bundled adapters but none is staged".to_string(), + ); + }; + // Verified against the SIGNED entry before a single member is extracted. + if let Err(reason) = spt_daemon::verify_update_set_bundle(&meta, &bytes) { + return BundleLanding::Skipped(format!( + "the staged bundle failed re-verification against the signed set: {reason}" + )); + } + let _ = std::fs::remove_dir_all(scratch); + let landing = land_verified_bundle(&bytes, scratch, adapters, core_version, meta.version); + let _ = std::fs::remove_dir_all(scratch); + landing +} + +/// Extract the VERIFIED bundle bytes (written to `scratch` first, so what is +/// extracted is exactly what was verified) and land each member. The whole +/// archive is extracted and members are read from real dirents, the +/// `extract_release_archive` precedent: tar records every entry as `./` +/// (the release side packs `-C .`), so a reader addressing members by +/// bare name inside the archive would find none. +// [impl->REQ-BUNDLE-APPLY-MEMBERS] +fn land_verified_bundle( + bytes: &[u8], + scratch: &std::path::Path, + adapters: &std::path::Path, + core_version: &str, + counter: u64, +) -> BundleLanding { + let archive = scratch.join(spt_daemon::release::BUNDLE_ASSET_NAME); + let tree = scratch.join("members"); + if let Err(e) = std::fs::create_dir_all(&tree).and_then(|_| std::fs::write(&archive, bytes)) { + return BundleLanding::Skipped(format!("stage {}: {e}", scratch.display())); + } + if let Err(e) = tar_extract_all(&archive, &tree) { + return BundleLanding::Skipped(format!("extract: {e}")); + } + let index: BundleIndex = match std::fs::read_to_string(tree.join(BUNDLE_INDEX_FILE)) + .map_err(|e| e.to_string()) + .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string())) + { + Ok(i) => i, + Err(e) => return BundleLanding::Skipped(format!("{BUNDLE_INDEX_FILE}: {e}")), + }; + let members = index + .members + .iter() + .map(|m| { + let outcome = land_bundle_member(m, &tree, adapters, core_version, counter); + eprintln!("{}", render_bundle_member(m, &outcome)); + (m.name.clone(), outcome) + }) + .collect(); + BundleLanding::Members(members) +} + +/// One member's machine-greppable line. +fn render_bundle_member(m: &BundleMember, outcome: &BundleMemberOutcome) -> String { + let (name, version) = (&m.name, &m.version); + match outcome { + BundleMemberOutcome::Installed => { + format!("UPDATE_BUNDLE_MEMBER_INSTALLED:{name}: {version} (built-in)") + } + BundleMemberOutcome::Upgraded { from } => { + format!("UPDATE_BUNDLE_MEMBER_UPGRADED:{name}: {from} -> {version} (built-in)") + } + BundleMemberOutcome::Untouched { installed } => format!( + "UPDATE_BUNDLE_MEMBER_CURRENT:{name}: installed {installed}, bundled {version} — \ + untouched (never downgraded)" + ), + BundleMemberOutcome::Removed => format!( + "UPDATE_BUNDLE_MEMBER_REMOVED:{name}: removed by the operator — not reinstalled" + ), + BundleMemberOutcome::Rejected(why) => { + format!("UPDATE_BUNDLE_MEMBER_REJECTED:{name}: {why} — nothing registered") + } + BundleMemberOutcome::Failed(why) => format!("UPDATE_BUNDLE_MEMBER_FAILED:{name}: {why}"), + } +} + +/// Install or upgrade one member through the EXISTING adapter paths — a +/// fresh member through [`complete_adapter_add`] (the `adapter add` tail), an +/// older one through [`update_one_adapter`] with the member as its candidate — +/// then mark the record `(built-in)` once the member's version is what is +/// registered. The member keeps its own avenue: nothing here writes its +/// manifest's `[update]` or the retained `install_source`. +// [impl->REQ-BUNDLE-APPLY-MEMBERS] +fn land_bundle_member( + m: &BundleMember, + tree: &std::path::Path, + adapters: &std::path::Path, + core_version: &str, + counter: u64, +) -> BundleMemberOutcome { + use spt_runtime::registry; + if !bundle_safe_component(&m.name) || !bundle_safe_component(&m.asset) { + return BundleMemberOutcome::Rejected(format!( + "unsafe name or asset in {BUNDLE_INDEX_FILE} ({:?}, {:?})", + m.name, m.asset + )); + } + let current = registry::registered(adapters).into_iter().find(|(r, _)| r.name == m.name); + let state = match (¤t, registry::load_record(adapters, &m.name)) { + (Some((_, man)), _) => Some((man.adapter.version.clone(), true)), + (None, Ok(r)) if !r.active => Some((String::new(), false)), + (None, Ok(_)) => { + return BundleMemberOutcome::Failed( + "registered, but its manifest is unreadable — not touched".to_string(), + ) + } + (None, Err(_)) => None, + }; + let action = bundle_member_action(state.as_ref().map(|(v, a)| (v.as_str(), *a)), &m.version); + match action { + BundleMemberAction::Untouched => { + return BundleMemberOutcome::Untouched { + installed: state.map(|(v, _)| v).unwrap_or_default(), + } + } + BundleMemberAction::Removed => return BundleMemberOutcome::Removed, + BundleMemberAction::Install | BundleMemberAction::Upgrade => {} + } + // Each member against ITS bundle.json sha256 before anything registers. + let bytes = match std::fs::read(tree.join(&m.asset)) { + Ok(b) => b, + Err(e) => { + return BundleMemberOutcome::Rejected(format!("{} not in the bundle: {e}", m.asset)) + } + }; + let actual = spt_daemon::sha256_hex(&bytes); + if !actual.eq_ignore_ascii_case(&m.sha256) { + return BundleMemberOutcome::Rejected(format!( + "{} sha256 {actual} is not the {} {BUNDLE_INDEX_FILE} names", + m.asset, m.sha256 + )); + } + let github = adapters.join("_github"); + let staged = github.join(format!("bundle-{}.spt", m.name)); + if let Err(e) = std::fs::create_dir_all(&github).and_then(|_| std::fs::write(&staged, &bytes)) { + return BundleMemberOutcome::Failed(format!("stage: {e}")); + } + if let Err(reason) = check_archive_identity(&staged, &m.name, &m.version, None) { + let _ = std::fs::remove_file(&staged); + return BundleMemberOutcome::Rejected(reason); + } + let anchor = format!("signed spt-core release bundle (counter {counter})"); + let from = match (action, current) { + (BundleMemberAction::Upgrade, Some((record, manifest))) => { + let from = manifest.adapter.version.clone(); + // AUTO-SET SEAM (W7, releases#336): a NEWER member is APPLIED here + // unconditionally, because `DaemonConfig.auto_classes` does not + // exist yet and adapters are in its default set. W7 gates this arm + // on the node's auto set: outside it the member is offered + // (consent-gated), never applied. + let outcome = update_one_adapter( + adapters, + &record, + &manifest, + core_version, + AdapterRoute { peers: false, channel: false }, + Some(AdapterCandidate { + staged, + version: m.version.clone(), + signature_hex: None, + peer: None, + trust_anchor: Some(anchor), + }), + ); + if !matches!(outcome, AdapterUpdateOutcome::Updated { .. }) { + spt_proto::cap_eprintln!("UPDATE_BUNDLE_MEMBER_NOTE:{}: {outcome:?}", m.name); + } + Some(from) + } + _ => { + let dest = github.join(format!("bundle-{}", m.name)); + let swapped = std::fs::create_dir_all(&dest) + .map_err(|e| e.to_string()) + .and_then(|_| apply_release_crc_swap(&staged, &dest)); + let _ = std::fs::remove_file(&staged); + if let Err(e) = swapped { + return BundleMemberOutcome::Failed(format!("extract: {e}")); + } + let retain = RetainPending { + bytes, + signature_hex: None, + install_source: None, + trust_anchor: Some(anchor), + }; + complete_adapter_add(adapters, &dest, now_ms(), Some(retain), core_version); + None + } + }; + // Landed = the member's version is what the registry now runs. Only then + // is the record marked: the mark says where the CURRENT bytes came from. + let landed = registry::registered(adapters) + .iter() + .any(|(r, man)| r.name == m.name && man.adapter.version == m.version); + if !landed { + return BundleMemberOutcome::Failed(format!( + "{} is not registered after the apply", + m.version + )); + } + if let Err(e) = registry::mark_built_in(adapters, &m.name) { + return BundleMemberOutcome::Failed(format!( + "registered, but the (built-in) mark failed: {e}" + )); + } + match from { + Some(from) => BundleMemberOutcome::Upgraded { from }, + None => BundleMemberOutcome::Installed, + } +} + /// `spt update apply [--finish]` — the explicit ack named by the update-consent /// notif. NO LONGER boots the daemon first (the REQ-UPDATE-ONE-SHOT-FINISH wart): /// a stopped box used to `ensure_daemon_announced` the OLD broker up just to hand @@ -9626,6 +9967,8 @@ fn cmd_update_apply(finish: bool) -> i32 { let code = cmd_update_apply_finish(&cache, &keys, &exe); if code == 0 { land_staged_docs(&cache); + // [impl->REQ-BUNDLE-APPLY-MEMBERS] + land_staged_bundle(&cache); } return code; } @@ -9647,6 +9990,9 @@ fn cmd_update_apply(finish: bool) -> i32 { // [impl->REQ-DOCS-RELEASE-ASSET] if code == 0 { land_staged_docs(&cache); + // The bundled adapters, isolated the same way (releases#338). + // [impl->REQ-BUNDLE-APPLY-MEMBERS] + land_staged_bundle(&cache); } code } @@ -11652,6 +11998,34 @@ fn cmd_update_fetch(channel: Option, tag: Option, apply: bool) - } } } + + // 3c. The bundled adapters (releases#338) — best-effort exactly like the + // docs: a bundle-less set fetches nothing, and any failure is loud and + // leaves the binary staging below unchanged. + // [impl->REQ-BUNDLE-APPLY-MEMBERS] + if let Some(bundle) = &meta.bundle { + eprintln!(" fetching {} (bundled adapters)…", bundle.asset_name); + match fetch_release_asset_bytes( + &repo, + tag.as_deref(), + &bundle.asset_name, + EffectiveTransport::Gh, + &scratch, + ) { + Ok(bytes) => match spt_daemon::verify_update_set_bundle(&meta, &bytes) { + Ok(()) => { + if let Err(e) = cache.stage_bundle(&bytes) { + eprintln!("UPDATE_BUNDLE_SKIPPED: stage: {e} — retry next fetch"); + } + } + Err(reason) => eprintln!("UPDATE_BUNDLE_SKIPPED: {reason} — retry next fetch"), + }, + Err(e) => eprintln!( + "UPDATE_BUNDLE_SKIPPED: {} from {repo}: {e} — retry next fetch", + bundle.asset_name + ), + } + } let _ = std::fs::remove_dir_all(&scratch); // 4. The full per-node front door for THIS platform (selects + classifies) @@ -21432,6 +21806,153 @@ fn subprocess_detail(stderr: &str, stdout: &str) -> String { } } +/// Conduct one bounded adapter template (first-update / uninstall) — +/// every subprocess gets a timeout (REQ-HAZARD-SUBPROCESS-TIMEOUT). +/// +/// A non-zero exit surfaces the subprocess's OWN stderr/stdout (bug #1: the +/// install-as-first-update swallowed the real error, printing only the exit +/// code — mirror `run_update_post_step`'s detail-surfacing so the operator +/// sees WHY it failed). [impl->REQ-ADAPTER-ADD-SURFACE-ERRORS] +fn conduct_adapter_template(label: &str, template: &str) -> i32 { + match spt_runtime::run_bounded_command( + template, + &std::collections::BTreeMap::new(), + Duration::from_secs(300), + None, + ) { + Ok(out) if out.success() => { + eprintln!("ADAPTER_{label}_OK"); + 0 + } + Ok(out) => { + let detail = subprocess_detail(&out.stderr, &out.stdout); + eprintln!("ADAPTER_{label}_FAIL: exit {:?}{detail}", out.status_code); + 1 + } + Err(e) => { + eprintln!("ADAPTER_{label}_FAIL: {e}"); + 1 + } + } +} + +/// Register an adapter whose files are already in place at `source` and +/// finish its install: retain the archive for peers, heal the entry exec bit, +/// nudge its service and the serving registry, then conduct the declared +/// avenue's install step and `[update.post]`. The tail of `adapter add`, +/// shared with the bundled-adapters leg (releases#338) so a bundle member is +/// installed by exactly the path an operator's `adapter add` takes. +/// `core_version` is the core the floor is judged against: the running CLI +/// for `adapter add`, the staged core for the bundle leg. +// [impl->REQ-BUNDLE-APPLY-MEMBERS] +fn complete_adapter_add( + adapters: &std::path::Path, + source: &std::path::Path, + now_ms: u64, + retain: Option, + core_version: &str, +) -> i32 { + use spt_runtime::registry; + let (record, manifest) = match registry::register_with_core(adapters, source, now_ms, core_version) { + Ok(r) => r, + Err(e) => { + eprintln!("ADAPTER_ADD_FAIL: {e}"); + return 1; + } + }; + eprintln!( + "ADAPTER_ADD:{}:{:?}:{:?} (registered)", + record.name, record.kind, record.mode + ); + // [impl->REQ-ADAPTER-PEER-SERVE] + if let Some(p) = retain { + retain_installed_adapter( + &spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases")), + &manifest, + &p.bytes, + p.signature_hex, + p.install_source, + p.trust_anchor, + ); + } + // An install is the first update (releases#62): heal a declared entry + // binary that arrived without its exec bit, loud, before its service is + // asked to start. + // [impl->REQ-ADAPTER-ENTRY-EXEC-BIT] + spt_runtime::entry_exec::force_entry_exec( + &manifest, + std::path::Path::new(&record.source_dir), + &record.name, + ); + // Installing an adapter never requires restarting spt to bring its + // service up (the operator addition folded into REQ-RESIDENT-SERVICE). + nudge_adapter_service(&manifest, &record.name); + nudge_serving_registry(); + + // Operator ruling (bug #1): a completed install ALSO runs the + // `[update.post]` composite step — previously it ran ONLY on an + // explicit `spt adapter update`, so a fresh install skipped its + // delegated post-step (e.g. syncing the harness plugin). With NO + // `[update.post]` this is exactly today's behavior (fires the + // `[update].message` on the fresh install). A post-step failure is + // loud (nonzero exit) but never unwinds the committed registration. + // [impl->REQ-ADAPTER-ADD-SURFACE-ERRORS] + let install_post_step = || { + let installed = manifest.adapter.version.as_str(); + i32::from(!run_update_post_step( + &manifest, + &record.name, + true, + "", + installed, + std::path::Path::new(&record.source_dir), + )) + }; + + // Install is the first update (REQ-INSTALL-4) — conduct the + // declared avenue once, through the same verdict logic the + // self-update ripple uses (REQ-UPD-5). + match manifest.update.as_ref() { + None => { + eprintln!("ADAPTER_INSTALL_SKIP: no [update] avenue (manifest-only adapter)"); + 0 + } + Some(u) => match spt_daemon::adapter_update::plan_adapter_update(u, None) { + spt_daemon::adapter_update::AdapterUpdateOutcome::Delegate(cmd) => { + let rc = conduct_adapter_template("INSTALL", &cmd); + // Only run the post-step once the acquisition succeeded. + if rc == 0 { install_post_step() } else { rc } + } + spt_daemon::adapter_update::AdapterUpdateOutcome::Skipped(reason) => { + // file_pull with no payload yet: the install is GENUINELY + // pending — the payload arrives later over the update engine + // (peer/self-fetch); only the registration holds for now. + // [impl->REQ-INSTALL-9] + eprintln!( + "ADAPTER_INSTALL_PENDING:{}: {reason:?} (payload rides the update engine)", + record.name + ); + 0 + } + verdict => { + // gh_release / managed acquisition: by here fetch_release_adapter + // ALREADY extracted the files and register() ran — the adapter + // IS installed; the [update] avenue is a no-op at add time and + // conducts on the update engine. "DEFERRED" mis-read as "install + // pending" (false for eager-extract acquisition). [impl->REQ-INSTALL-9] + eprintln!( + "ADAPTER_INSTALLED:{} (registered + extracted; [update] avenue \ + conducts on the update engine, not at add time) [{verdict:?}]", + record.name + ); + // The eager-extract acquisition IS the install — run its + // `[update.post]` too (bug #1 operator ruling). + install_post_step() + } + }, + } +} + fn cmd_adapter(action: AdapterCmd, json: bool) -> i32 { use spt_runtime::registry; use std::time::Duration; @@ -21442,36 +21963,6 @@ fn cmd_adapter(action: AdapterCmd, json: bool) -> i32 { .map(|d| d.as_millis() as u64) .unwrap_or(0); - /// Conduct one bounded adapter template (first-update / uninstall) — - /// every subprocess gets a timeout (REQ-HAZARD-SUBPROCESS-TIMEOUT). - /// - /// A non-zero exit surfaces the subprocess's OWN stderr/stdout (bug #1: the - /// install-as-first-update swallowed the real error, printing only the exit - /// code — mirror `run_update_post_step`'s detail-surfacing so the operator - /// sees WHY it failed). [impl->REQ-ADAPTER-ADD-SURFACE-ERRORS] - fn conduct(label: &str, template: &str) -> i32 { - match spt_runtime::run_bounded_command( - template, - &std::collections::BTreeMap::new(), - Duration::from_secs(300), - None, - ) { - Ok(out) if out.success() => { - eprintln!("ADAPTER_{label}_OK"); - 0 - } - Ok(out) => { - let detail = subprocess_detail(&out.stderr, &out.stdout); - eprintln!("ADAPTER_{label}_FAIL: exit {:?}{detail}", out.status_code); - 1 - } - Err(e) => { - eprintln!("ADAPTER_{label}_FAIL: {e}"); - 1 - } - } - } - match action { AdapterCmd::Add { path, @@ -21612,104 +22103,7 @@ fn cmd_adapter(action: AdapterCmd, json: bool) -> i32 { } }; - let (record, manifest) = match registry::register(&adapters, &source, now_ms) { - Ok(r) => r, - Err(e) => { - eprintln!("ADAPTER_ADD_FAIL: {e}"); - return 1; - } - }; - eprintln!( - "ADAPTER_ADD:{}:{:?}:{:?} (registered)", - record.name, record.kind, record.mode - ); - // [impl->REQ-ADAPTER-PEER-SERVE] - if let Some(p) = retain { - retain_installed_adapter( - &spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases")), - &manifest, - &p.bytes, - p.signature_hex, - p.install_source, - p.trust_anchor, - ); - } - // An install is the first update (releases#62): heal a declared entry - // binary that arrived without its exec bit, loud, before its service is - // asked to start. - // [impl->REQ-ADAPTER-ENTRY-EXEC-BIT] - spt_runtime::entry_exec::force_entry_exec( - &manifest, - std::path::Path::new(&record.source_dir), - &record.name, - ); - // Installing an adapter never requires restarting spt to bring its - // service up (the operator addition folded into REQ-RESIDENT-SERVICE). - nudge_adapter_service(&manifest, &record.name); - nudge_serving_registry(); - - // Operator ruling (bug #1): a completed install ALSO runs the - // `[update.post]` composite step — previously it ran ONLY on an - // explicit `spt adapter update`, so a fresh install skipped its - // delegated post-step (e.g. syncing the harness plugin). With NO - // `[update.post]` this is exactly today's behavior (fires the - // `[update].message` on the fresh install). A post-step failure is - // loud (nonzero exit) but never unwinds the committed registration. - // [impl->REQ-ADAPTER-ADD-SURFACE-ERRORS] - let install_post_step = || { - let installed = manifest.adapter.version.as_str(); - i32::from(!run_update_post_step( - &manifest, - &record.name, - true, - "", - installed, - std::path::Path::new(&record.source_dir), - )) - }; - - // Install is the first update (REQ-INSTALL-4) — conduct the - // declared avenue once, through the same verdict logic the - // self-update ripple uses (REQ-UPD-5). - match manifest.update.as_ref() { - None => { - eprintln!("ADAPTER_INSTALL_SKIP: no [update] avenue (manifest-only adapter)"); - 0 - } - Some(u) => match spt_daemon::adapter_update::plan_adapter_update(u, None) { - spt_daemon::adapter_update::AdapterUpdateOutcome::Delegate(cmd) => { - let rc = conduct("INSTALL", &cmd); - // Only run the post-step once the acquisition succeeded. - if rc == 0 { install_post_step() } else { rc } - } - spt_daemon::adapter_update::AdapterUpdateOutcome::Skipped(reason) => { - // file_pull with no payload yet: the install is GENUINELY - // pending — the payload arrives later over the update engine - // (peer/self-fetch); only the registration holds for now. - // [impl->REQ-INSTALL-9] - eprintln!( - "ADAPTER_INSTALL_PENDING:{}: {reason:?} (payload rides the update engine)", - record.name - ); - 0 - } - verdict => { - // gh_release / managed acquisition: by here fetch_release_adapter - // ALREADY extracted the files and register() ran — the adapter - // IS installed; the [update] avenue is a no-op at add time and - // conducts on the update engine. "DEFERRED" mis-read as "install - // pending" (false for eager-extract acquisition). [impl->REQ-INSTALL-9] - eprintln!( - "ADAPTER_INSTALLED:{} (registered + extracted; [update] avenue \ - conducts on the update engine, not at add time) [{verdict:?}]", - record.name - ); - // The eager-extract acquisition IS the install — run its - // `[update.post]` too (bug #1 operator ruling). - install_post_step() - } - }, - } + complete_adapter_add(&adapters, &source, now_ms, retain, env!("CARGO_PKG_VERSION")) } AdapterCmd::Remove { name, force } => match registry::deregister(&adapters, &name) { Ok(uninstall) => { @@ -21726,7 +22120,7 @@ fn cmd_adapter(action: AdapterCmd, json: bool) -> i32 { eprintln!("ACTIVE_PROFILE_PRUNED:{name} ({})", pruned.join(", ")); } match uninstall { - Some(tpl) if force => conduct("UNINSTALL", &tpl), + Some(tpl) if force => conduct_adapter_template("UNINSTALL", &tpl), Some(_) => { eprintln!( "ADAPTER_UNINSTALL_DEFERRED: manifest declares an uninstall \ @@ -22902,7 +23296,7 @@ fn cmd_adapter_update( let outcomes = fan_out_adapter_updates(&names, |i| { let (record, manifest) = selected[i]; if let Some(route) = route_of(record, manifest) { - update_one_adapter(adapters, record, manifest, core_version, route) + update_one_adapter(adapters, record, manifest, core_version, route, None) } else { // Loud skip — a local-path/dev registration has nothing to pull. spt_proto::cap_eprintln!( @@ -22935,6 +23329,7 @@ fn update_one_adapter( manifest: &spt_runtime::manifest::Manifest, core_version: &str, route: AdapterRoute, + preset: Option, ) -> AdapterUpdateOutcome { use spt_runtime::manifest::UpdateAvenue; use spt_runtime::registry; @@ -22971,8 +23366,12 @@ fn update_one_adapter( // serves a newer copy the channel is not consulted this run — the // next run asks the channel again once no peer is ahead. // [impl->REQ-ADAPTER-PEERS-FIRST] - let mut candidate: Option = None; - if route.peers { + // A `preset` candidate (the bundled-adapters leg, releases#338) is + // already verified bytes in hand: it takes the apply arm below exactly + // like a peer or channel candidate, and neither source is asked. + // [impl->REQ-BUNDLE-APPLY-MEMBERS] + let mut candidate: Option = preset; + if candidate.is_none() && route.peers { candidate = peer_update_candidate(adapters, &record.name, installed, installed_key.as_deref()); } // What the channel said when it had nothing newer (the UPTODATE line). @@ -41518,6 +41917,7 @@ mod tests { source_dir: source_dir.to_string_lossy().into_owned(), registered_at_ms: 1, active: true, + built_in: false, }; let dest = adapter_update_install_dir(&record); @@ -41558,3 +41958,341 @@ mod tests { ); } } + +/// releases#338 apply side — the bundled-adapters leg. +#[cfg(test)] +mod bundle_apply_tests { + use super::*; + + /// A member adapter's manifest: a harness declaring its OWN release avenue. + fn member_manifest(name: &str, version: &str) -> String { + format!( + "[adapter]\nname = \"{name}\"\nkind = \"harness\"\nversion = \"{version}\"\n\ + min_spt_core_version = \"0\"\nhostable_types = [\"LiveAgent\"]\n\n\ + [update]\navenue = \"gh_release\"\nrepo = \"example/{name}\"\n" + ) + } + + fn tar(template: &str, keys: &[(&str, &std::path::Path)]) -> spt_runtime::BoundedOutput { + let keys = keys + .iter() + .map(|(k, p)| (k.to_string(), p.to_string_lossy().into_owned())) + .collect(); + spt_runtime::run_bounded_command(template, &keys, Duration::from_secs(60), None) + .expect("spawn tar") + } + + /// Pack a member `.spt` (plain tar of a dir holding its manifest) and + /// return its bytes. + fn member_spt(tmp: &std::path::Path, name: &str, version: &str) -> Vec { + let src = tmp.join(format!("{name}-{version}-src")); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("manifest.toml"), member_manifest(name, version)).unwrap(); + let archive = tmp.join(format!("{name}-{version}.spt")); + let out = tar("tar -cf \"{a}\" -C \"{s}\" .", &[("a", &archive), ("s", &src)]); + assert!(out.success(), "pack member: {}", out.stderr); + std::fs::read(&archive).unwrap() + } + + /// Build a bundle the way the RELEASE side does (`tar -czf -C + /// .`, xtask bundle.rs `assemble`): `members` are + /// `(name, version, archive bytes, sha256 written into bundle.json)`. + fn bundle(tmp: &std::path::Path, tag: &str, members: &[(&str, &str, &[u8], String)]) -> Vec { + let tree = tmp.join(format!("bundle-{tag}-tree")); + std::fs::create_dir_all(&tree).unwrap(); + let mut index = Vec::new(); + for (name, version, bytes, sha) in members { + let asset = format!("{name}.spt"); + std::fs::write(tree.join(&asset), bytes).unwrap(); + index.push(serde_json::json!({ + "name": name, "version": version, "asset": asset, "sha256": sha, + })); + } + std::fs::write( + tree.join(BUNDLE_INDEX_FILE), + serde_json::json!({ "members": index }).to_string(), + ) + .unwrap(); + let out_path = tmp.join(format!("bundle-{tag}.tar.gz")); + let out = tar("tar -czf \"{o}\" -C \"{t}\" .", &[("o", &out_path), ("t", &tree)]); + assert!(out.success(), "pack bundle: {}", out.stderr); + std::fs::read(&out_path).unwrap() + } + + /// The signed set a real apply would hold, shaped exactly like xtask's + /// `debug_rollout_meta` (every field it sets), with `bundle` optional. + fn set_meta(version: u64, bundle: Option<&[u8]>) -> spt_daemon::UpdateSetMetadata { + spt_daemon::UpdateSetMetadata { + version, + channel: "debug".to_string(), + expires_at_ms: u64::MAX / 2, + key_id: "rig".to_string(), + artifacts: std::collections::BTreeMap::from([( + spt_daemon::current_platform().to_string(), + spt_daemon::UpdateArtifactMetadata { + artifact_sha256: spt_daemon::sha256_hex(b"BIN"), + brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION, + broker_resource_abi: spt_daemon::update::BROKER_RESOURCE_ABI, + asset_name: Some("spt-test-artifact".to_string()), + }, + )]), + provenance: None, + product_version: "0.73.0".to_string(), + bundle: bundle.map(|b| spt_daemon::UpdateBundleMetadata { + asset_name: spt_daemon::release::BUNDLE_ASSET_NAME.to_string(), + sha256: spt_daemon::sha256_hex(b), + }), + docs: None, + } + } + + /// Stage `meta` with this platform's artifact (and the bundle bytes, when + /// given) into a release cache, and PROVE the set is staged: a cache that + /// reads no staged set would pass the bundle-less row for the wrong + /// reason (it did, before this assert existed). + fn stage(dir: &std::path::Path, meta: &spt_daemon::UpdateSetMetadata, bytes: Option<&[u8]>) + -> spt_daemon::ReleaseCache + { + let cache = spt_daemon::ReleaseCache::open(dir); + let signed = spt_daemon::SignedUpdateSet { + metadata_json: serde_json::to_string(meta).unwrap(), + signature_hex: "cd".repeat(64), + }; + let artifacts = std::collections::BTreeMap::from([( + spt_daemon::current_platform().to_string(), + b"BIN".to_vec(), + )]); + cache.stage_update_set(&signed, &artifacts).unwrap(); + if let Some(b) = bytes { + cache.stage_bundle(b).unwrap(); + } + assert!( + matches!(cache.staged_update(), Some(spt_daemon::StagedUpdate::Set(_))), + "the set is staged" + ); + cache + } + + fn members(landing: BundleLanding) -> Vec<(String, BundleMemberOutcome)> { + match landing { + BundleLanding::Members(m) => m, + other => panic!("expected a per-member landing, got {other:?}"), + } + } + + // [unit->REQ-BUNDLE-APPLY-MEMBERS] ROW 1: a set with NO bundle entry — the + // exact shape `debug_rollout_meta` signs, and every set through v0.72.0 — + // applies clean and installs nothing: no adapters dir, no scratch. The + // trap this row exists for is live in the same fixture: the verifier + // REFUSES that set (Malformed), so a leg that asked it would have turned + // "nothing to apply" into a skipped-bundle failure. + #[test] + fn a_bundle_less_set_is_nothing_to_apply_and_never_reaches_the_verifier() { + let tmp = tempfile::tempdir().unwrap(); + let meta = set_meta(96, None); + assert!( + matches!( + spt_daemon::verify_update_set_bundle(&meta, b"any"), + Err(spt_daemon::RejectReason::Malformed(_)) + ), + "the trap: the verifier refuses a bundle-less set" + ); + let cache = stage(&tmp.path().join("releases"), &meta, None); + let adapters = tmp.path().join("adapters"); + let scratch = tmp.path().join("bundle.x"); + assert_eq!( + land_bundle_in(&cache, &adapters, &scratch, "9.9.9"), + BundleLanding::NothingToApply + ); + assert!(!adapters.exists(), "nothing installed"); + assert!(!scratch.exists(), "nothing extracted"); + } + + // [unit->REQ-BUNDLE-APPLY-MEMBERS] ROW 2: a release-shaped bundle records + // its entries as `./` (the H2 measurement, re-measured here on this + // platform's tar). NEGATIVE CONTROL: a lookup of a BARE name among the + // recorded names finds nothing. Whether tar itself forgives a bare member + // name on extraction depends on WHICH tar runs — measured 2026-09-24 on + // hfenduleam on one release-shaped archive: bsdtar 3.8.4 (the tar this + // test's own spawn resolved) extracted `bundle.json` by its bare name, + // rc 0; GNU tar 1.35 refused it, rc 2 "Not found in archive", and took + // `./bundle.json`, rc 0 — so the reader + // addresses no member by name at all: it extracts the whole archive and + // reads real dirents, and finds the index and every member in order. + #[test] + fn bundle_members_are_found_through_the_dot_slash_prefix() { + let tmp = tempfile::tempdir().unwrap(); + let a = member_spt(tmp.path(), "alpha", "1.0.0"); + let b = member_spt(tmp.path(), "beta", "2.0.0"); + // Wrong digests on purpose: this row is about FINDING members, so + // nothing may install. + let bytes = bundle( + tmp.path(), + "dot", + &[("alpha", "1.0.0", &a, "0".repeat(64)), ("beta", "2.0.0", &b, "0".repeat(64))], + ); + let file = tmp.path().join("bundle-dot.tar.gz"); + let listing = tar("tar -tzf \"{f}\"", &[("f", &file)]); + assert!(listing.success()); + let names: Vec<&str> = listing.stdout.lines().map(str::trim).collect(); + assert!(names.contains(&"./bundle.json"), "recorded with ./: {names:?}"); + assert!(!names.contains(&"bundle.json"), "never bare: {names:?}"); + + let recorded = |bare: &str| names.iter().find(|n| n.strip_prefix("./") == Some(bare)).copied(); + assert_eq!( + names.iter().find(|n| **n == "bundle.json"), + None, + "NEGATIVE CONTROL: a bare-name lookup among the recorded names fails" + ); + assert_eq!(recorded("bundle.json"), Some("./bundle.json")); + assert_eq!(recorded("alpha.spt"), Some("./alpha.spt")); + + let meta = set_meta(97, Some(&bytes)); + let cache = stage(&tmp.path().join("releases"), &meta, Some(&bytes)); + let adapters = tmp.path().join("adapters"); + let got = members(land_bundle_in(&cache, &adapters, &tmp.path().join("x"), "9.9.9")); + let found: Vec<&str> = got.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(found, ["alpha", "beta"], "every member found, bundle.json order"); + } + + // [unit->REQ-BUNDLE-APPLY-MEMBERS] ROW 3: no downgrade. The pure rule, then + // the leg: an installed member at an EQUAL version and one at a NEWER + // version than the bundle carries are both left exactly as they were — + // same record, same version, no (built-in) mark, no bundle home created. + #[test] + fn an_equal_or_newer_installed_member_is_untouched() { + use BundleMemberAction::*; + assert_eq!(bundle_member_action(None, "1.0.0"), Install); + assert_eq!(bundle_member_action(Some(("0.9.0", true)), "1.0.0"), Upgrade); + assert_eq!(bundle_member_action(Some(("1.0.0", true)), "1.0.0"), Untouched); + assert_eq!(bundle_member_action(Some(("1.1.0", true)), "1.0.0"), Untouched); + assert_eq!(bundle_member_action(Some(("", false)), "1.0.0"), Removed); + + let _home = spt_test_support::TestHome::new(); + let tmp = tempfile::tempdir().unwrap(); + let adapters = spt_store::perch::adapters_dir(); + for (installed, bundled) in [("1.0.0", "1.0.0"), ("1.1.0", "1.0.0")] { + let src = tmp.path().join(format!("installed-{installed}")); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("manifest.toml"), member_manifest("gamma", installed)).unwrap(); + spt_runtime::registry::register(&adapters, &src, 7).unwrap(); + let before = std::fs::read(adapters.join("gamma").join("record.toml")).unwrap(); + + let arch = member_spt(tmp.path(), "gamma", bundled); + let sha = spt_daemon::sha256_hex(&arch); + let bytes = bundle(tmp.path(), installed, &[("gamma", bundled, &arch, sha)]); + let cache = stage(&tmp.path().join(format!("rel-{installed}")), &set_meta(98, Some(&bytes)), Some(&bytes)); + let got = members(land_bundle_in(&cache, &adapters, &tmp.path().join("x"), "9.9.9")); + assert_eq!( + got, + [("gamma".to_string(), BundleMemberOutcome::Untouched { installed: installed.to_string() })] + ); + assert_eq!( + std::fs::read(adapters.join("gamma").join("record.toml")).unwrap(), + before, + "record byte-identical ({installed} installed, {bundled} bundled)" + ); + assert!(!adapters.join("_github").join("bundle-gamma").exists()); + } + } + + // [unit->REQ-BUNDLE-APPLY-MEMBERS] ROW 4: a member whose bytes are not the + // ones `bundle.json` names is REJECTED and nothing is registered — while + // the signed bundle as a whole verified. CONTROL: the same member with its + // true digest installs, registers and is marked (built-in), so the reject + // was the digest and nothing else. + #[test] + fn a_member_not_matching_bundle_json_is_rejected_and_nothing_registers() { + let _home = spt_test_support::TestHome::new(); + let tmp = tempfile::tempdir().unwrap(); + let adapters = spt_store::perch::adapters_dir(); + let arch = member_spt(tmp.path(), "delta", "1.0.0"); + + let bad = bundle(tmp.path(), "bad", &[("delta", "1.0.0", &arch, spt_daemon::sha256_hex(b"other"))]); + let cache = stage(&tmp.path().join("rel-bad"), &set_meta(99, Some(&bad)), Some(&bad)); + let got = members(land_bundle_in(&cache, &adapters, &tmp.path().join("x"), "9.9.9")); + assert!( + matches!(&got[..], [(n, BundleMemberOutcome::Rejected(why))] if n == "delta" && why.contains("sha256")), + "{got:?}" + ); + assert!(spt_runtime::registry::all_records(&adapters).is_empty(), "nothing registered"); + assert!(!adapters.join("_github").join("bundle-delta").exists(), "nothing extracted"); + + let good = bundle(tmp.path(), "good", &[("delta", "1.0.0", &arch, spt_daemon::sha256_hex(&arch))]); + let cache = stage(&tmp.path().join("rel-good"), &set_meta(100, Some(&good)), Some(&good)); + let got = members(land_bundle_in(&cache, &adapters, &tmp.path().join("x"), "9.9.9")); + assert_eq!(got, [("delta".to_string(), BundleMemberOutcome::Installed)]); + let record = spt_runtime::registry::load_record(&adapters, "delta").unwrap(); + assert!(record.active && record.built_in, "installed and marked: {record:?}"); + } + + // [unit->REQ-BUNDLE-APPLY-MEMBERS] doyle 2026-09-24 amendment: the mark + // says where the CURRENT bytes came from. A bundle upgrade of an + // own-avenue member SETS it — and leaves the retained install_source (the + // other axis) exactly as it was; the member's next update from its own + // avenue CLEARS it. That update is driven through `update_one_adapter`'s + // candidate arm, the arm a channel release (`peer: None`) and a peer copy + // both take; only the candidate's origin differs. + #[test] + fn a_bundle_upgrade_sets_the_mark_and_an_own_avenue_update_clears_it() { + let _home = spt_test_support::TestHome::new(); + let tmp = tempfile::tempdir().unwrap(); + let adapters = spt_store::perch::adapters_dir(); + let home = adapters.join("_github").join("example-eps"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("manifest.toml"), member_manifest("eps", "1.0.0")).unwrap(); + spt_runtime::registry::register(&adapters, &home, 1).unwrap(); + let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases")); + let seeded = spt_daemon::relcache::RetainedAdapter { + kind: "harness".into(), + version: "1.0.0".into(), + sha256: "00".into(), + signing_key: None, + signature_hex: None, + install_source: Some(ADAPTER_SOURCE_SUBNET.to_string()), + trust_anchor: None, + }; + cache.retain_adapter("eps", b"old", &seeded).unwrap(); + + let v11 = member_spt(tmp.path(), "eps", "1.1.0"); + let bytes = bundle(tmp.path(), "up", &[("eps", "1.1.0", &v11, spt_daemon::sha256_hex(&v11))]); + let rel = stage(&tmp.path().join("rel-up"), &set_meta(101, Some(&bytes)), Some(&bytes)); + let got = members(land_bundle_in(&rel, &adapters, &tmp.path().join("x"), "9.9.9")); + assert_eq!(got, [("eps".to_string(), BundleMemberOutcome::Upgraded { from: "1.0.0".into() })]); + let record = spt_runtime::registry::load_record(&adapters, "eps").unwrap(); + assert!(record.built_in, "a bundle upgrade SETS the mark"); + assert_eq!(record.source_dir, home.to_string_lossy(), "upgraded in place, not re-homed"); + let retained = cache.retained_adapter("eps").unwrap(); + assert_eq!(retained.version, "1.1.0", "the bundle's bytes are what is served"); + assert_eq!( + retained.install_source.as_deref(), + Some(ADAPTER_SOURCE_SUBNET), + "a bundle upgrade never rewrites install_source" + ); + + let (rec, man) = spt_runtime::registry::registered(&adapters) + .into_iter() + .find(|(r, _)| r.name == "eps") + .unwrap(); + let v12 = member_spt(tmp.path(), "eps", "1.2.0"); + let staged = tmp.path().join("eps-channel.spt"); + std::fs::write(&staged, &v12).unwrap(); + let outcome = update_one_adapter( + &adapters, + &rec, + &man, + "9.9.9", + AdapterRoute { peers: false, channel: false }, + Some(AdapterCandidate { + staged, + version: "1.2.0".into(), + signature_hex: None, + peer: None, + trust_anchor: None, + }), + ); + assert!(matches!(outcome, AdapterUpdateOutcome::Updated { .. }), "{outcome:?}"); + let record = spt_runtime::registry::load_record(&adapters, "eps").unwrap(); + assert!(!record.built_in, "an own-avenue update CLEARS the mark"); + } +}