diff --git a/Cargo.lock b/Cargo.lock index 62327aa..9e73e9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1966,7 +1966,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.29.0" +version = "0.30.0" dependencies = [ "axum", "base64", diff --git a/Cargo.toml b/Cargo.toml index 88df313..3d10877 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[workspace.package].version`), so it MUST be set here for a release to fire # (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) keep their own # independent versions — only the released binary tracks the workspace version. -version = "0.29.0" +version = "0.30.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index dac6066..cfd60d2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1101,6 +1101,47 @@ boundary, so key material never crosses to a paired caller even with a valid tok unit-tested exhaustively: an unpaired caller is denied on every mutation/custody method; a paired token authorizes a mutation but NOT pairing administration; a revoked token is denied on the next request. +### 7.13. DIG auto-update beacon proxy (`control.updater.*`, #515) + +The DIG auto-update beacon (`dig-updater`, a separate installable service — DIG-Network/dig-updater +SPEC §§1–13) checks daily for new releases of the DIG binaries and installs them behind a signed +trust chain. dig-node exposes a THIN proxy to it over the SAME `control.*` gate (§7.2) — it never +re-verifies the beacon's signed manifest and never decides what to install; it only reads the +beacon's world-readable status mirror and shells its own elevation-gated CLI. + +| Method | Params | Result (essentials) | +|---|---|---| +| `control.updater.status` | — | `installed: false` when the beacon has no status.json yet (never an error); else `installed: true`, `status` = the beacon's `status.json` verbatim (dig-updater SPEC §13.2: `schema`, `version`, `channel`, `paused`, `paused_until`, `last_check`, `last_check_kind`, `last_outcome`, `last_reason`, `last_detail`, `components[]`, `next_wake`, `trust_state`) | +| `control.updater.setChannel` | `channel` (string, e.g. `"alpha"`) | The beacon CLI's `channel set --json` output verbatim (dig-updater SPEC §13.3) | +| `control.updater.pause` | `until?` (unix seconds) | The beacon CLI's `pause [--until ] --json` output verbatim | +| `control.updater.resume` | — | The beacon CLI's `resume --json` output verbatim | +| `control.updater.checkNow` | — | The beacon CLI's `check --now --json` output verbatim — a full pass; the call blocks until it completes | + +**Status is read directly off disk, never through the CLI** — `control.updater.status` is the +method a controller polls, and a file read is far cheaper than a process spawn on every poll. The +status directory is a WORLD-READABLE sibling of the beacon's own Admin/SYSTEM-only state directory +(dig-updater SPEC §13.2: `%ProgramData%\DIG\updater-status` on Windows, `/var/lib/dig-updater-status` +on Unix), so dig-node needs no elevation to read it. A present-but-corrupt `status.json` is reported +as `CONTROL_ERROR` (a genuine anomaly); an ABSENT one is `{ "installed": false }` (the beacon may +simply never have been installed on this machine) — never an error either way. + +**Mutations shell the `dig-updater` CLI** — `setChannel`/`pause`/`resume`/`checkNow` invoke the +already elevation-gated operator CLI (dig-updater SPEC §13.3: `channel set`/`pause`/`resume` require +Administrator/root) rather than writing the beacon's Admin-only `config.json` directly. This service +runs privileged (Windows LocalSystem / a root daemon), so it satisfies that elevation check the same +way a human operator running an elevated terminal would. The CLI is resolved by an ABSOLUTE path — +an explicit override, else beside this running `dig-node` binary (the shared bin dir dig-installer +places `digstore`/`dig-node`/`dig-dns` into, and where the beacon installer, #514, is expected to +place `dig-updater`), else a per-OS conventional install root — NEVER a bare name resolved through +`PATH`. No binary resolves → `NOT_SUPPORTED` ("the DIG auto-update beacon is not installed on this +machine"); the CLI runs but declines (a bad channel, a missing elevation) → `CONTROL_ERROR` carrying +the CLI's own `detail` message; the CLI produces unparsable output (a crash) → `CONTROL_ERROR`. + +**Opaque passthrough by design.** Both the status file and every CLI `--json` result are forwarded +as opaque JSON, never re-typed into a dig-node-owned shape — the beacon's schema-versioned wire +contract exists precisely so an independent reader can do this safely: a field the beacon adds later +passes through unchanged, with no shape to keep in sync on this side. + --- ## 8. CLI contract diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index e9daea1..6a30f36 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -18,6 +18,22 @@ default-run = "dig-node" name = "dig-node" path = "src/main.rs" +# A tiny stand-in for the REAL `dig-updater` beacon CLI (a separate repo/release, not +# vendored here), so the `updater.*` control-plane tests (#515) exercise a genuine child- +# process spawn — argv, stdout/exit-code parsing — without depending on that binary being +# installed on the test runner. Controlled entirely by env vars (see the fixture's own doc +# comment); never shipped (the release workflows build only `--bin dig-node`, §2.4a). +# +# Deliberately named WITHOUT "update"/"install"/"setup"/"patch": Windows' installer-detection +# heuristic can require elevation to launch (or even just spawn via CreateProcess) an +# unmanifested `.exe` whose name matches those substrings (`ERROR_ELEVATION_REQUIRED`, 740) — +# hit empirically while developing #515 with a `fake_dig_updater` name. The REAL `dig-updater` +# binary name is the beacon repo's own choice and out of scope here; this fixture just avoids +# the trap for OUR test infra. +[[bin]] +name = "fake_beacon_cli" +path = "tests/fixtures/fake_beacon_cli.rs" + # The service shell's own library (transport/control/CLI/meta). Named `dig_node_service` # so it does not collide with the node library crate, which owns the `dig_node` name. [lib] @@ -46,7 +62,9 @@ dig-wallet = { path = "../dig-wallet" } # and one server framework across the node and the service shell. `ws` enables # `axum::extract::ws` for the `GET /ws/status` liveness endpoint (#239). axum = { version = "0.7", features = ["ws"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time"] } +# `process` backs the async `tokio::process::Command` the `updater` module (#515) spawns the +# `dig-updater` beacon CLI through — a non-blocking child-process wait alongside the axum runtime. +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "process"] } tower-http = { version = "0.6", features = ["cors"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 344878b..037b3ee 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -43,6 +43,8 @@ //! not model: the **pin registry** (which stores the operator chose to host, so they //! survive being listed even before/after caching) and the **upstream override**, //! both persisted in this service's own keys inside the shared `config.json`. +//! `control.updater.*` (#515) proxies the DIG auto-update beacon the same way — see +//! [`crate::updater`] for what it reads directly vs. shells out to. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -573,6 +575,14 @@ pub async fn dispatch_control(ctx: &ControlCtx, id: Value, method: &str, params: "control.hostedStores.status" => hosted_status(ctx, id, params).await, "control.sync.status" => control_ok(id, sync_status(ctx).await), "control.sync.trigger" => sync_trigger(ctx, id, params).await, + // The DIG auto-update beacon proxy (#515) — a THIN passthrough to `dig-updater`'s + // own status file + CLI (see `crate::updater`'s module doc for why nothing here + // re-implements the beacon's trust/install logic). + "control.updater.status" => crate::updater::status(id), + "control.updater.setChannel" => crate::updater::set_channel(id, params).await, + "control.updater.pause" => crate::updater::pause(id, params).await, + "control.updater.resume" => crate::updater::resume(id).await, + "control.updater.checkNow" => crate::updater::check_now(id).await, // Pairing administration (#280) — reached only with the MASTER token (the // gate blocks a paired token from these, see `is_pairing_admin_method`). "control.pairing.list" => crate::pairing::list(&ctx.pairings, &ctx.state_dir, id), diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 27ee2b3..9b6cac1 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -54,6 +54,10 @@ pub mod service; /// paired-token store live so the daemon (which may run as a service under a different OS /// account) and the operator CLI resolve the SAME files. See [`state`]. pub mod state; +/// The beacon (`dig-updater`) RPC proxy (#515): `control.updater.*` reads the DIG auto-update +/// beacon's world-readable status and shells its elevation-gated CLI for channel/pause/resume/ +/// check-now — never a second implementation of the beacon's own trust logic. See [`updater`]. +pub mod updater; pub mod wallet_authz; /// Windows Service Control Protocol entrypoint — only meaningful on Windows, where diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index eb0fffd..2ef1c9d 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -356,6 +356,39 @@ pub fn methods() -> &'static [MethodInfo] { NOT_SUPPORTED if no §21 identity / not eligible.", requires_auth: true, }, + // -- DIG auto-update beacon proxy (#515) — a THIN passthrough to `dig-updater` -- + MethodInfo { + name: "control.updater.status", + served: "control", + summary: "The DIG auto-update beacon's status (channel, paused, last check, \ + per-component decisions); { installed: false } if not installed.", + requires_auth: true, + }, + MethodInfo { + name: "control.updater.setChannel", + served: "control", + summary: "Set the beacon's update channel. Params { channel }.", + requires_auth: true, + }, + MethodInfo { + name: "control.updater.pause", + served: "control", + summary: "Suspend the beacon's auto-updates. Params { until? } (unix seconds).", + requires_auth: true, + }, + MethodInfo { + name: "control.updater.resume", + served: "control", + summary: "Resume the beacon's auto-updates (clears any pause).", + requires_auth: true, + }, + MethodInfo { + name: "control.updater.checkNow", + served: "control", + summary: "Trigger an on-demand full beacon update pass; blocks until it \ + completes.", + requires_auth: true, + }, // -- pairing administration (#280) — MASTER-token only ---------------------------- MethodInfo { name: "control.pairing.list", @@ -876,6 +909,12 @@ mod tests { "control.hostedStores.status", "control.sync.status", "control.sync.trigger", + // DIG auto-update beacon proxy (#515). + "control.updater.status", + "control.updater.setChannel", + "control.updater.pause", + "control.updater.resume", + "control.updater.checkNow", ] { assert!( names.contains(&required), diff --git a/crates/dig-node-service/src/updater.rs b/crates/dig-node-service/src/updater.rs new file mode 100644 index 0000000..b10996f --- /dev/null +++ b/crates/dig-node-service/src/updater.rs @@ -0,0 +1,456 @@ +//! Beacon (`dig-updater`) RPC proxy (#515, beacon epic #504): surfaces the DIG auto-update +//! beacon's status and control over this service's `control.*` surface, so a same-host +//! controller (the extension/hub Updates UI, #516) can show + drive it without shelling out +//! itself. +//! +//! # A THIN proxy — never a second beacon +//! +//! This module never re-verifies a signed manifest, never decides what to install, and never +//! re-implements any part of the beacon's trust chain (dig-updater SPEC §§1-9) — that logic +//! belongs to the beacon ALONE. It only: +//! +//! - **reads** the beacon's world-readable `status.json` (dig-updater SPEC §13.2) directly off +//! disk, because [`status`] is polled frequently by a UI and a file read is far cheaper than +//! spawning a process on every poll; and +//! - **shells** the already elevation-gated `dig-updater` operator CLI (dig-updater SPEC §13.3) +//! for every mutation (`channel set`/`pause`/`resume`) and for an on-demand check +//! (`check --now`) — this service itself runs privileged (Windows LocalSystem / a root daemon), +//! so it can invoke those elevation-gated commands the same way an Administrator/root operator +//! would from a terminal. +//! +//! Both the status file and the CLI's `--json` output are treated as OPAQUE JSON: this proxy +//! never types out the beacon's wire shape, it forwards it verbatim. The beacon's own +//! schema-versioned contract (`status.json`'s `schema` field, dig-updater SPEC §13.2) exists +//! precisely so an independent reader like this one can do that safely — a new field the beacon +//! adds later simply passes through here untouched; there is no shape to keep in sync. +//! +//! # Auth +//! +//! Every method lives in the `control.updater.*` namespace, so it inherits the surrounding +//! `control.*` gate unchanged ([`crate::control::is_control_method`] / +//! [`crate::control::is_authorized`]) — no new auth surface, nothing to remember to gate. + +use std::path::PathBuf; +use std::process::Stdio; + +use serde_json::{json, Value}; + +use crate::control::{control_error, control_ok}; +use crate::meta::ErrorCode; + +/// Overrides where [`status_path`] reads the beacon's status mirror from — set by a test, or by +/// an operator who relocated the beacon's status directory. +pub const STATUS_DIR_ENV: &str = "DIG_UPDATER_STATUS_DIR"; + +/// Overrides which `dig-updater` binary [`resolve_cli_binary`] invokes — set by a test (a fake +/// fixture) or an operator whose beacon install lives at a nonstandard path. +pub const CLI_BIN_ENV: &str = "DIG_UPDATER_BIN"; + +/// The beacon's world-readable status directory (dig-updater SPEC §13.2): a sibling of its +/// Admin/SYSTEM-only state directory, named `-status`. +/// +/// - **Windows:** `%ProgramData%\DIG\updater-status` +/// - **Unix:** `/var/lib/dig-updater-status` +/// +/// [`STATUS_DIR_ENV`] overrides this outright. +fn status_dir() -> PathBuf { + if let Some(over) = std::env::var_os(STATUS_DIR_ENV) { + return PathBuf::from(over); + } + #[cfg(windows)] + { + let program_data = + std::env::var_os("ProgramData").unwrap_or_else(|| r"C:\ProgramData".into()); + PathBuf::from(program_data) + .join("DIG") + .join("updater-status") + } + #[cfg(unix)] + { + PathBuf::from("/var/lib/dig-updater-status") + } +} + +/// The beacon's `status.json` path — `/status.json`. +fn status_path() -> PathBuf { + status_dir().join("status.json") +} + +/// The `dig-updater` CLI's file name on this OS. +fn cli_file_name() -> &'static str { + if cfg!(windows) { + "dig-updater.exe" + } else { + "dig-updater" + } +} + +/// Resolve the `dig-updater` CLI by an ABSOLUTE path — never a bare name spawned through `PATH`, +/// the same discipline the beacon's own broker holds its OWN install path to (dig-updater SPEC +/// §8.3: "invoked by the installer's ABSOLUTE trusted path, never a bare name resolved through +/// PATH"). Checked in order: +/// +/// 1. [`CLI_BIN_ENV`] — an explicit override (tests; a nonstandard install). +/// 2. Beside this running `dig-node` binary — `digstore`/`dig-node`/`dig-dns` already share ONE +/// bin dir on `PATH` (dig-installer's "CLI-on-PATH verification"), and the beacon installer +/// (#514) is expected to place `dig-updater` in that same shared dir. +/// 3. The per-OS conventional install root dig-installer uses for that shared bin dir. +/// +/// `None` when no candidate exists on disk — the caller reports "beacon not installed" rather +/// than attempting an unverified spawn. +fn resolve_cli_binary() -> Option { + let name = cli_file_name(); + + if let Some(over) = std::env::var_os(CLI_BIN_ENV) { + let path = PathBuf::from(over); + if path.is_file() { + return Some(path); + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(candidate) = exe.parent().map(|dir| dir.join(name)) { + if candidate.is_file() { + return Some(candidate); + } + } + } + conventional_bin_dirs() + .into_iter() + .map(|dir| dir.join(name)) + .find(|candidate| candidate.is_file()) +} + +/// The per-OS conventional shared bin dir dig-installer places `digstore`/`dig-node`/`dig-dns` +/// (and, once #514 ships, `dig-updater`) into. +fn conventional_bin_dirs() -> Vec { + #[cfg(windows)] + { + let program_files = + std::env::var_os("ProgramFiles").unwrap_or_else(|| r"C:\Program Files".into()); + vec![PathBuf::from(program_files).join("DIG")] + } + #[cfg(unix)] + { + vec![ + PathBuf::from("/usr/local/bin"), + PathBuf::from("/opt/dig/bin"), + ] + } +} + +/// Why a `dig-updater` CLI invocation didn't yield a usable result. +#[derive(Debug)] +enum CliError { + /// No `dig-updater` binary was found by [`resolve_cli_binary`]. + NotInstalled, + /// The binary was found but the OS failed to spawn it. + Spawn(String), + /// The CLI ran and explicitly declined the request, reporting why (its own + /// `{"status":"error","detail":...}` `--json` failure shape, non-zero exit). + Declined(String), + /// The CLI exited in a shape this proxy cannot interpret (a crash, an unexpected output + /// format) — distinct from a normal, well-formed decline. + Malformed { + exit_code: Option, + stdout: String, + stderr: String, + }, +} + +impl CliError { + /// Render as the control-plane's error envelope. + fn into_response(self, id: Value) -> Value { + match self { + CliError::NotInstalled => control_error( + id, + ErrorCode::NotSupported, + "the DIG auto-update beacon (dig-updater) is not installed on this machine", + ), + CliError::Spawn(e) => control_error( + id, + ErrorCode::ControlError, + format!("failed to invoke dig-updater: {e}"), + ), + CliError::Declined(detail) => control_error( + id, + ErrorCode::ControlError, + format!("dig-updater declined the request: {detail}"), + ), + CliError::Malformed { + exit_code, + stdout, + stderr, + } => control_error( + id, + ErrorCode::ControlError, + format!( + "dig-updater returned unparsable output (exit {exit_code:?}): \ + stdout={stdout:?} stderr={stderr:?}" + ), + ), + } + } +} + +/// Run the resolved `dig-updater` binary with `args` (always appending `--json` so the result is +/// machine-parseable) and classify the outcome. Every command the CLI understands supports +/// `--json` uniformly, including its failure paths (dig-updater SPEC §13.3) — a decline prints +/// `{"status":"error","detail":...}` to STDOUT (never stderr) with a non-zero exit, so both +/// outcomes are parsed the same way and told apart by the parsed shape plus exit status. +async fn run_cli(args: &[&str]) -> Result { + let bin = resolve_cli_binary().ok_or(CliError::NotInstalled)?; + let output = tokio::process::Command::new(&bin) + .args(args) + .arg("--json") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| CliError::Spawn(e.to_string()))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let parsed: Option = serde_json::from_str(&stdout).ok(); + + match (output.status.success(), parsed) { + (true, Some(value)) => Ok(value), + (false, Some(value)) if value.get("status").and_then(|s| s.as_str()) == Some("error") => { + let detail = value + .get("detail") + .and_then(|d| d.as_str()) + .unwrap_or("no detail given") + .to_string(); + Err(CliError::Declined(detail)) + } + _ => Err(CliError::Malformed { + exit_code: output.status.code(), + stdout, + stderr, + }), + } +} + +/// `control.updater.status` — the beacon's status mirror, read directly off disk (never through +/// the CLI: this is the method a UI polls, and a file read is far cheaper than a process spawn on +/// every poll). Absence is a NORMAL outcome — the beacon may simply never have been installed — +/// so it is reported as `{"installed": false}`, never an error; a present-but-corrupt file is a +/// genuine anomaly worth surfacing distinctly. +pub fn status(id: Value) -> Value { + match std::fs::read(status_path()) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(status) => control_ok(id, json!({ "installed": true, "status": status })), + Err(e) => control_error( + id, + ErrorCode::ControlError, + format!("beacon status.json is present but not valid JSON: {e}"), + ), + }, + Err(_) => control_ok(id, json!({ "installed": false })), + } +} + +/// `control.updater.setChannel` — set the beacon's update channel. Params: `{ "channel": "alpha" }`. +pub async fn set_channel(id: Value, params: &Value) -> Value { + let Some(channel) = params.get("channel").and_then(|v| v.as_str()) else { + return control_error( + id, + ErrorCode::InvalidParams, + "control.updater.setChannel requires params.channel (a string, e.g. \"alpha\")", + ); + }; + match run_cli(&["channel", "set", channel]).await { + Ok(result) => control_ok(id, result), + Err(e) => e.into_response(id), + } +} + +/// `control.updater.pause` — suspend the beacon's auto-updates. Params: `{ "until": }` +/// (optional — an omitted `until` pauses indefinitely, until an explicit `resume`). +pub async fn pause(id: Value, params: &Value) -> Value { + let until = params + .get("until") + .and_then(|v| v.as_u64()) + .map(|u| u.to_string()); + let mut args: Vec<&str> = vec!["pause"]; + if let Some(until) = until.as_deref() { + args.push("--until"); + args.push(until); + } + match run_cli(&args).await { + Ok(result) => control_ok(id, result), + Err(e) => e.into_response(id), + } +} + +/// `control.updater.resume` — resume the beacon's auto-updates (clears any pause). +pub async fn resume(id: Value) -> Value { + match run_cli(&["resume"]).await { + Ok(result) => control_ok(id, result), + Err(e) => e.into_response(id), + } +} + +/// `control.updater.checkNow` — trigger an on-demand FULL update pass (`dig-updater check --now`), +/// identical gating to the beacon's own daily schedule. Synchronous: the call blocks until the +/// pass completes (a real pass — fetch, verify, install behind the health gate — can take a +/// while; the caller is expected to show its own progress state, not treat this as instant). +pub async fn check_now(id: Value) -> Value { + match run_cli(&["check", "--now"]).await { + Ok(result) => control_ok(id, result), + Err(e) => e.into_response(id), + } +} + +// The CLI-spawn tests (`set_channel`/`pause`/`resume`/`check_now` run against the real +// `fake_beacon_cli` fixture binary) live in `tests/beacon_cli_process.rs` instead of here: +// `env!("CARGO_BIN_EXE_fake_beacon_cli")` is only defined for INTEGRATION test targets +// (Cargo book, "Environment variables Cargo sets for build scripts"), not for a crate's own +// `--lib` unit-test harness that this `#[cfg(test)]` module compiles into. +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::OnceLock; + use tokio::sync::Mutex; + + /// Serializes tests in this file that mutate the process-global env vars + /// ([`STATUS_DIR_ENV`], [`CLI_BIN_ENV`]) — mirrors `tests/server.rs`'s own `env_guard`. A + /// `tokio::sync::Mutex` (not `std::sync::Mutex`): the one async test here holds the guard + /// across an `.await`, which trips `clippy::await_holding_lock` on a std guard. + fn env_guard() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + static SEQ: AtomicU64 = AtomicU64::new(0); + + /// A unique-per-call scratch path, so concurrent test RUNS (across `cargo test` + /// invocations) never collide even though tests within this file are serialized. + fn unique_path(label: &str) -> PathBuf { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "dig-node-updater-test-{label}-{}-{n}", + std::process::id() + )) + } + + // -- status: read directly off disk -------------------------------------------------- + + #[test] + fn status_reports_not_installed_when_status_json_is_absent() { + let _guard = env_guard().blocking_lock(); + let dir = unique_path("absent"); + let _ = std::fs::remove_dir_all(&dir); + std::env::set_var(STATUS_DIR_ENV, &dir); + + let resp = status(json!(1)); + + assert_eq!(resp["result"]["installed"], json!(false)); + assert!(resp.get("error").is_none()); + std::env::remove_var(STATUS_DIR_ENV); + } + + #[test] + fn status_returns_the_file_verbatim_when_present() { + let _guard = env_guard().blocking_lock(); + let dir = unique_path("present"); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var(STATUS_DIR_ENV, &dir); + let body = json!({ "schema": 1, "version": "0.6.0", "channel": "alpha" }); + std::fs::write(dir.join("status.json"), serde_json::to_vec(&body).unwrap()).unwrap(); + + let resp = status(json!(1)); + + assert_eq!(resp["result"]["installed"], json!(true)); + assert_eq!(resp["result"]["status"], body); + let _ = std::fs::remove_dir_all(&dir); + std::env::remove_var(STATUS_DIR_ENV); + } + + #[test] + fn status_reports_a_control_error_on_corrupt_json_not_not_installed() { + let _guard = env_guard().blocking_lock(); + let dir = unique_path("corrupt"); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var(STATUS_DIR_ENV, &dir); + std::fs::write(dir.join("status.json"), b"{ not json").unwrap(); + + let resp = status(json!(1)); + + assert_eq!(resp["error"]["data"]["code"], json!("CONTROL_ERROR")); + let _ = std::fs::remove_dir_all(&dir); + std::env::remove_var(STATUS_DIR_ENV); + } + + #[test] + fn status_dir_defaults_to_the_documented_per_os_convention_when_unset() { + let _guard = env_guard().blocking_lock(); + std::env::remove_var(STATUS_DIR_ENV); + let dir = status_dir(); + if cfg!(windows) { + assert!(dir.ends_with(r"DIG\updater-status") || dir.ends_with("DIG/updater-status")); + } else { + assert_eq!(dir, PathBuf::from("/var/lib/dig-updater-status")); + } + } + + // -- param validation ------------------------------------------------------------------ + + #[tokio::test] + async fn set_channel_without_a_channel_param_is_invalid_params() { + let resp = set_channel(json!(1), &json!({})).await; + assert_eq!(resp["error"]["data"]["code"], json!("INVALID_PARAMS")); + } + + // -- binary resolution ------------------------------------------------------------------- + + #[test] + fn resolve_cli_binary_prefers_the_explicit_override() { + let _guard = env_guard().blocking_lock(); + // Any existing file proves the override wins — `resolve_cli_binary` only checks + // `is_file()`, so a plain temp file (not a real executable) is a sufficient stand-in + // here; the actual spawn+parse path is exercised end-to-end in `tests/updater_cli.rs`. + let dummy = tempfile::NamedTempFile::new().unwrap(); + std::env::set_var(CLI_BIN_ENV, dummy.path()); + assert_eq!(resolve_cli_binary(), Some(dummy.path().to_path_buf())); + std::env::remove_var(CLI_BIN_ENV); + } + + #[test] + fn resolve_cli_binary_ignores_an_override_pointing_at_a_missing_file() { + let _guard = env_guard().blocking_lock(); + let missing = PathBuf::from("/definitely/does/not/exist/dig-updater"); + std::env::set_var(CLI_BIN_ENV, &missing); + assert_ne!(resolve_cli_binary(), Some(missing)); + std::env::remove_var(CLI_BIN_ENV); + } + + #[tokio::test] + async fn every_mutation_reports_not_installed_when_no_binary_resolves() { + let _guard = env_guard().lock().await; + // No override, and a real `dig-updater` is never present beside this test binary or + // under the conventional install dirs on a CI runner (or a normal dev checkout) — so + // NOT_SUPPORTED is the correct, real outcome for every mutation. + std::env::remove_var(CLI_BIN_ENV); + + let checks = [ + ( + "setChannel", + set_channel(json!(1), &json!({ "channel": "alpha" })).await, + ), + ("pause", pause(json!(1), &json!({})).await), + ("resume", resume(json!(1)).await), + ("checkNow", check_now(json!(1)).await), + ]; + for (label, resp) in checks { + assert_eq!( + resp["error"]["data"]["code"], + json!("NOT_SUPPORTED"), + "{label} should report NOT_SUPPORTED with no dig-updater binary resolvable" + ); + } + } + + // CLI-spawn behavior (arg building, `--json` output parsing, declined/malformed + // classification) is exercised against a REAL child process in `tests/updater_cli.rs`. +} diff --git a/crates/dig-node-service/tests/beacon_cli_process.rs b/crates/dig-node-service/tests/beacon_cli_process.rs new file mode 100644 index 0000000..bd446fa --- /dev/null +++ b/crates/dig-node-service/tests/beacon_cli_process.rs @@ -0,0 +1,198 @@ +//! Wired tests for the beacon (`dig-updater`) RPC proxy (#515) that need a REAL child-process +//! spawn: `control.updater.setChannel`/`pause`/`resume`/`checkNow` run against the +//! `fake_beacon_cli` fixture binary (`tests/fixtures/fake_beacon_cli.rs`), a stand-in for the +//! real `dig-updater` CLI's `--json` I/O contract (dig-updater SPEC §13.3). +//! +//! `dig_node_service::updater`'s own unit tests (`src/updater.rs`) cover `status.json` handling, +//! param validation, and binary resolution WITHOUT spawning anything; these prove the spawn + +//! parse plumbing itself over a real OS process boundary — argv, stdout, and exit code. The full +//! HTTP wire path (the control-token auth gate -> dispatch -> this proxy) is covered separately +//! in `tests/server.rs`. +//! +//! `env!("CARGO_BIN_EXE_fake_beacon_cli")` — the path to the fixture binary Cargo built +//! alongside this test target — is only available in an INTEGRATION test file like this one, +//! which is why these live here rather than in `src/updater.rs`'s own `#[cfg(test)]` module. +//! (The fixture is deliberately NOT named after "dig-updater" — see its own doc comment.) + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::OnceLock; + +use dig_node_service::updater::{check_now, pause, resume, set_channel, CLI_BIN_ENV}; +use serde_json::json; +use tokio::sync::Mutex; + +/// Serializes these tests against each other — they all mutate the SAME process-global +/// `DIG_UPDATER_BIN`/`FAKE_UPDATER_*` env vars the fixture reads. A `tokio::sync::Mutex` (not +/// `std::sync::Mutex`): every test here holds the guard across an `.await`, which trips +/// `clippy::await_holding_lock` on a std guard. +fn env_guard() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +static SEQ: AtomicU64 = AtomicU64::new(0); + +/// A unique-per-call scratch path for the fixture's `FAKE_UPDATER_ARGS_FILE` capture. +fn unique_path(label: &str) -> PathBuf { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "dig-node-updater-cli-test-{label}-{}-{n}", + std::process::id() + )) +} + +fn fake_cli_path() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_fake_beacon_cli")) +} + +fn clear_fixture_env() { + for var in [ + CLI_BIN_ENV, + "FAKE_UPDATER_STDOUT", + "FAKE_UPDATER_EXIT_CODE", + "FAKE_UPDATER_ARGS_FILE", + ] { + std::env::remove_var(var); + } +} + +#[tokio::test] +async fn set_channel_runs_the_cli_and_returns_its_json_verbatim() { + let _guard = env_guard().lock().await; + clear_fixture_env(); + std::env::set_var(CLI_BIN_ENV, fake_cli_path()); + std::env::set_var( + "FAKE_UPDATER_STDOUT", + r#"{"command":"channel","channel":"alpha"}"#, + ); + let args_file = unique_path("args-setchannel"); + std::env::set_var("FAKE_UPDATER_ARGS_FILE", &args_file); + + let resp = set_channel(json!(1), &json!({ "channel": "alpha" })).await; + + assert_eq!(resp["result"]["channel"], json!("alpha")); + assert_eq!( + std::fs::read_to_string(&args_file).unwrap(), + "channel set alpha --json", + "the exact argv dig-updater received" + ); + + let _ = std::fs::remove_file(&args_file); + clear_fixture_env(); +} + +#[tokio::test] +async fn pause_with_until_passes_the_flag_through() { + let _guard = env_guard().lock().await; + clear_fixture_env(); + std::env::set_var(CLI_BIN_ENV, fake_cli_path()); + std::env::set_var( + "FAKE_UPDATER_STDOUT", + r#"{"command":"pause","paused":true,"paused_until":500}"#, + ); + let args_file = unique_path("args-pause"); + std::env::set_var("FAKE_UPDATER_ARGS_FILE", &args_file); + + let resp = pause(json!(1), &json!({ "until": 500 })).await; + + assert_eq!(resp["result"]["paused"], json!(true)); + assert_eq!(resp["result"]["paused_until"], json!(500)); + assert_eq!( + std::fs::read_to_string(&args_file).unwrap(), + "pause --until 500 --json" + ); + + let _ = std::fs::remove_file(&args_file); + clear_fixture_env(); +} + +#[tokio::test] +async fn resume_runs_with_no_extra_flags() { + let _guard = env_guard().lock().await; + clear_fixture_env(); + std::env::set_var(CLI_BIN_ENV, fake_cli_path()); + std::env::set_var( + "FAKE_UPDATER_STDOUT", + r#"{"command":"pause","paused":false,"paused_until":null}"#, + ); + let args_file = unique_path("args-resume"); + std::env::set_var("FAKE_UPDATER_ARGS_FILE", &args_file); + + let resp = resume(json!(1)).await; + + assert_eq!(resp["result"]["paused"], json!(false)); + assert_eq!( + std::fs::read_to_string(&args_file).unwrap(), + "resume --json" + ); + + let _ = std::fs::remove_file(&args_file); + clear_fixture_env(); +} + +#[tokio::test] +async fn check_now_forwards_the_pass_report_on_success() { + let _guard = env_guard().lock().await; + clear_fixture_env(); + std::env::set_var(CLI_BIN_ENV, fake_cli_path()); + std::env::set_var( + "FAKE_UPDATER_STDOUT", + r#"{"applied":false,"reason":"paused","detail":null,"components":[],"state_advanced":false}"#, + ); + let args_file = unique_path("args-checknow"); + std::env::set_var("FAKE_UPDATER_ARGS_FILE", &args_file); + + let resp = check_now(json!(1)).await; + + assert_eq!(resp["result"]["applied"], json!(false)); + assert_eq!(resp["result"]["reason"], json!("paused")); + assert_eq!( + std::fs::read_to_string(&args_file).unwrap(), + "check --now --json" + ); + + let _ = std::fs::remove_file(&args_file); + clear_fixture_env(); +} + +#[tokio::test] +async fn a_declined_cli_run_surfaces_its_detail_as_a_control_error() { + let _guard = env_guard().lock().await; + clear_fixture_env(); + std::env::set_var(CLI_BIN_ENV, fake_cli_path()); + std::env::set_var( + "FAKE_UPDATER_STDOUT", + r#"{"status":"error","detail":"channel 'stable' is reserved"}"#, + ); + std::env::set_var("FAKE_UPDATER_EXIT_CODE", "2"); + + let resp = set_channel(json!(1), &json!({ "channel": "stable" })).await; + + assert_eq!(resp["error"]["data"]["code"], json!("CONTROL_ERROR")); + assert!(resp["error"]["message"] + .as_str() + .unwrap() + .contains("reserved")); + + clear_fixture_env(); +} + +#[tokio::test] +async fn a_crashing_cli_is_reported_as_malformed_not_silently_swallowed() { + let _guard = env_guard().lock().await; + clear_fixture_env(); + std::env::set_var(CLI_BIN_ENV, fake_cli_path()); + std::env::set_var("FAKE_UPDATER_STDOUT", "this is not json"); + std::env::set_var("FAKE_UPDATER_EXIT_CODE", "101"); + + let resp = check_now(json!(1)).await; + + assert_eq!(resp["error"]["data"]["code"], json!("CONTROL_ERROR")); + assert!(resp["error"]["message"] + .as_str() + .unwrap() + .contains("unparsable")); + + clear_fixture_env(); +} diff --git a/crates/dig-node-service/tests/fixtures/fake_beacon_cli.rs b/crates/dig-node-service/tests/fixtures/fake_beacon_cli.rs new file mode 100644 index 0000000..c488ff5 --- /dev/null +++ b/crates/dig-node-service/tests/fixtures/fake_beacon_cli.rs @@ -0,0 +1,37 @@ +//! A stand-in for the real `dig-updater` beacon CLI, used ONLY by the `control.updater.*` +//! control-plane tests (#515). The real binary lives in the separate `DIG-Network/dig-updater` +//! release and is not vendored here; this fixture reproduces just enough of its `--json` I/O +//! contract (SPEC §13.3 of that repo: a command prints one JSON object to stdout and exits zero +//! on success, non-zero on failure) so the tests exercise a REAL child-process spawn — not a +//! mock of [`std::process::Command`] itself. +//! +//! Named `fake_beacon_cli` (not `fake-dig-updater`) DELIBERATELY: Windows' installer-detection +//! heuristic can refuse to launch (`ERROR_ELEVATION_REQUIRED`, 740) an unmanifested `.exe` whose +//! name contains "update"/"install"/"setup"/"patch" — hit empirically while developing #515. +//! +//! Entirely driven by environment variables, set by the test right before it runs: +//! +//! - `FAKE_UPDATER_STDOUT` — the exact bytes to print to stdout (default `{}`). +//! - `FAKE_UPDATER_EXIT_CODE` — the process exit code (default `0`). +//! - `FAKE_UPDATER_ARGS_FILE` — when set, the argv this process received (space-joined) is +//! written there, so a test can assert the caller built the right command line. + +use std::io::Write; + +fn main() { + let stdout = std::env::var("FAKE_UPDATER_STDOUT").unwrap_or_else(|_| "{}".to_string()); + let exit_code: i32 = std::env::var("FAKE_UPDATER_EXIT_CODE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + if let Ok(args_file) = std::env::var("FAKE_UPDATER_ARGS_FILE") { + let argv: Vec = std::env::args().skip(1).collect(); + if let Ok(mut f) = std::fs::File::create(args_file) { + let _ = f.write_all(argv.join(" ").as_bytes()); + } + } + + println!("{stdout}"); + std::process::exit(exit_code); +} diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index cefeca1..57f45b8 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -1026,6 +1026,111 @@ async fn control_sync_status_reports_availability() { assert!(resp["result"]["pinned_total"].is_u64()); } +// --- #515: DIG auto-update beacon proxy (`control.updater.*`) ---------------------- +// +// A THIN passthrough to `dig-updater`'s own status file + CLI (`crate::updater`'s own +// unit tests cover the arg-building/output-parsing logic in isolation); these prove the +// FULL wire path — HTTP -> the control-token gate -> dispatch -> the proxy -> a real +// child-process spawn (the `fake_beacon_cli` fixture, `tests/fixtures/fake_beacon_cli.rs`) +// -> the response back over HTTP. + +/// `control.updater.*` requires the SAME control token as every other `control.*` method — +/// a read (`status`) and a mutation (`checkNow`) are both rejected without one. +#[tokio::test] +async fn control_updater_methods_without_token_are_rejected() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _token, _hold) = start_companion_full(&upstream).await; + + for method in ["control.updater.status", "control.updater.checkNow"] { + let resp = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 1, "method": method }), + None, + ) + .await; + assert_eq!( + resp["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} must require the control token" + ); + } +} + +/// The full status round trip (absent -> present, read directly off disk) PLUS a real +/// mutation (`setChannel`, spawned against the `fake_beacon_cli` fixture) — end to end +/// over the real HTTP server, with the control token. +#[tokio::test] +async fn control_updater_status_and_mutation_wired_over_http() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, token, _hold) = start_companion_full(&upstream).await; + + // -- status: absent (no beacon installed on this runner) is a normal, non-error result. + let status_dir = std::env::temp_dir().join(format!( + "dig-node-updater-e2e-status-{}-{}", + std::process::id(), + line!() + )); + let _ = std::fs::remove_dir_all(&status_dir); + std::env::set_var("DIG_UPDATER_STATUS_DIR", &status_dir); + + let absent = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 1, "method": "control.updater.status" }), + Some(&token), + ) + .await; + assert_eq!(absent["result"]["installed"], json!(false)); + + // -- status: present is forwarded verbatim. + std::fs::create_dir_all(&status_dir).unwrap(); + let body = json!({ "schema": 1, "version": "0.6.0", "channel": "alpha", "paused": false }); + std::fs::write( + status_dir.join("status.json"), + serde_json::to_vec(&body).unwrap(), + ) + .unwrap(); + + let present = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 2, "method": "control.updater.status" }), + Some(&token), + ) + .await; + assert_eq!(present["result"]["installed"], json!(true)); + assert_eq!(present["result"]["status"], body); + + // -- a real mutation, spawned against the fake CLI fixture — proves setChannel walks + // the whole path (auth -> dispatch -> a real child process -> its JSON stdout) rather + // than being tested only in isolation (`crate::updater`'s own unit tests). + std::env::set_var("DIG_UPDATER_BIN", env!("CARGO_BIN_EXE_fake_beacon_cli")); + std::env::set_var( + "FAKE_UPDATER_STDOUT", + r#"{"command":"channel","channel":"alpha"}"#, + ); + std::env::set_var("FAKE_UPDATER_EXIT_CODE", "0"); + + let set_channel = post_rpc( + &addr, + json!({ + "jsonrpc": "2.0", "id": 3, "method": "control.updater.setChannel", + "params": { "channel": "alpha" } + }), + Some(&token), + ) + .await; + assert_eq!(set_channel["result"]["channel"], json!("alpha")); + + let _ = std::fs::remove_dir_all(&status_dir); + for var in [ + "DIG_UPDATER_STATUS_DIR", + "DIG_UPDATER_BIN", + "FAKE_UPDATER_STDOUT", + "FAKE_UPDATER_EXIT_CODE", + ] { + std::env::remove_var(var); + } +} + #[tokio::test] async fn control_unknown_method_is_method_not_found() { let (upstream, _calls) = start_mock_upstream().await;