diff --git a/hub/docs/MAINTAINER.md b/hub/docs/MAINTAINER.md index eb99b047..75136e48 100644 --- a/hub/docs/MAINTAINER.md +++ b/hub/docs/MAINTAINER.md @@ -71,3 +71,109 @@ If a PR is stalled, contentious, or raises questions HUB can't resolve: ## Effective date This ownership transfer is effective 2026-06-09 (announcement: `shared-context/forum/cbp-hub-track-ownership-transfer-2026-06-09.md`). The supervisor track on HUB comes up at the office today; until then, PRs queue and HUB merges them when its track goes live. + +## Deploy ratification — is this seat running what we approved? (F0.3 / R7c) + +The fleet's currency check answers *"is the running image the on-disk binary, +and does that binary postdate the merged source?"* Both arms are necessary and +neither is sufficient: **a binary built from a parked feature branch passes +both.** The process matches the file, and the file is newer than anything +merged. That is not hypothetical — a build on a parked branch put unmerged code +at `ExecStart` here and HEAD-based currency called it clean. + +**Currency is not ratification.** Ratification is a human (or a supervisor that +verified the build) asserting *this commit is the one this seat may run*. + +### The two records, produced independently + +| record | who writes it | what it says | +|---|---|---| +| running build | the compiler, stamped into the artifact | the commit + tree state this binary was built from | +| ratified build | the deploy path, via `scripts/ratify-build.sh` | the commit this seat is approved to run | + +The daemon **only reads** the manifest. A process that could write its own +ratification record would be certifying itself. + +### Using it + +```bash +# ratify what a specific artifact attests about itself (preferred — the artifact +# is ASKED, via `hub build-info` JSON, rather than trusting memory of what was +# built or parsing an abbreviated sha out of human --version text) +hub/scripts/ratify-build.sh --from-binary /path/to/hub \ + --manifest /etc/web4/ratified-build.json --by dp + +# or ratify an explicit commit, optionally pinning the binary digest too +hub/scripts/ratify-build.sh /path/to/hub --manifest ... +``` + +**The manifest records a FULL 40-character commit id.** `ratify-build.sh` resolves an +abbreviation against the repository before writing (and refuses if it cannot), and the daemon +refuses a short one at admission. A short sha is a *repository-local locator* whose uniqueness +changes as history grows — not a durable identity — and in the commit-only fallback it is the +only identity claim carrying the control. + +**Pin the binary digest.** Pass the binary path so the manifest records +`ratified_binary_sha256`. Without it the check can only make the weaker +**commit-level** claim, and the operator page labels it as such — because two +builds of the same commit are not the same executable. A different toolchain, +different feature flags, or a substituted artifact all preserve the commit while +changing the bytes. *Commit identity is provenance; artifact identity is the +ratification claim.* + +**Set `HUB_EXEC_PATH`** to the artifact the unit will execute, so the staged arm +has something to check. Unset, that arm reports `unknown` — it deliberately does +**not** fall back to the running image, because reporting a fact about the +present as a fact about the next restart is the substitution this check exists to +catch. + +`--from-binary` **refuses** a dirty or unverifiable build: such an artifact is +not any commit, so ratifying "the commit" would name something that does not +describe the bytes. + +Point the daemon at the manifest with `HUB_RATIFIED_MANIFEST` (else it reads +`/ratified-build.json`). **Prefer a path the daemon user cannot +write** — root-owned, `0644`. A ratification record writable by the thing it +ratifies is not a control. + +### What the operator sees + +`/admin` renders a **Deploy ratification** block with two arms: + +- **Running** — the **executing image's bytes** (read via `/proc/self/exe`, which + stays readable after a replace-in-place, so it is the bytes actually running + rather than the bytes now at the path) vs the manifest. Falls back to the + commit-level claim, clearly labelled, when no digest was ratified. +- **Staged at exec path** — the artifact the unit will run *next*, so an + unratified binary dropped in place is visible **before** the restart that + makes it live (set `HUB_EXEC_PATH` when the unit's path differs from the + process image). + +Verdicts fail closed and keep their failure exits distinct: + +| verdict | meaning | +|---|---| +| `current` | clean build, commit matches the manifest | +| `STALE` | established as NOT the ratified build (different commit, or a modified tree — a dirty build is not the ratified artifact even when the commit matches) | +| `unknown` | could not be established: no manifest, an unreadable one, or a build whose provenance is unverified. **Never a pass** — it wears the warning pill, because an operator must not read "we could not check" as "checked and fine". | + +`unknown` and `STALE` are deliberately separate: *"nobody has ratified anything +here"* calls for a different response than *"this seat is running something +nobody approved."* + +### Not yet closed: the deploy closure itself + +R7c has a third limb this does not implement: **writes to the deploy closure — +the unit file, the deploy scripts, the exec path, and the ratification manifest — +must themselves be gated, refused, and escalatable.** Ratification and visibility +land here; the write-protection does not. + +The reason is worth recording, because it is the control demonstrating itself: +that closure is enforced by the hestia gate's canonical governance-file list, +which lives in a file that is *itself* on that list. A session operating under +the gate cannot edit it — which is exactly the intended behaviour for +authority-bearing surfaces, and why the change belongs to a deliberate, +separately-reviewed act rather than a side effect of this work. + +**Phase 0 is not complete until that lands.** Tracked separately; do not read the +Deploy ratification block as evidence that the deploy path is write-protected. diff --git a/hub/hub-daemon/src/admin.rs b/hub/hub-daemon/src/admin.rs index 91f1d1a2..0e3619a4 100644 --- a/hub/hub-daemon/src/admin.rs +++ b/hub/hub-daemon/src/admin.rs @@ -265,6 +265,100 @@ async fn landing_page(State(s): State) -> Result, AdminE Ok(public_layout(&s.hub_name, &s.hub_id.to_string(), &body)) } + +/// F0.3 (R7c): the deploy-ratification block for the operator surface. +/// +/// Two independently-produced records are compared: what this binary attests +/// about itself (compile-time stamp) and what the supervisor recorded as +/// approved. Both arms render — the RUNNING one, and the STAGED one at the +/// exec path, which answers before a restart makes an unratified artifact the +/// running one. +/// +/// Fail-closed in rendering as well as in logic: `unknown` gets the warning +/// pill, not a neutral one. An operator scanning this page must not read +/// "we could not check" as "checked and fine". +fn ratified_block(s: &RestState) -> String { + use hub_lib::ratified::{self, DeployVerdict}; + let path = s.paths.ratified_manifest(); + let (manifest, read_err) = match ratified::RatifiedManifest::read(&path) { + Ok(m) => (m, None), + Err(e) => (None, Some(e.to_string())), + }; + // The RUNNING arm decides on the executing image's bytes when the manifest + // pins a digest — commit identity is provenance, artifact identity is the + // ratification claim. + // + // Hashing the image is ~20 MB read + digest (12-36 ms measured) and runs + // SYNCHRONOUSLY inside an async handler, so it is computed only when a + // manifest actually pins a digest to compare against. Without one, + // `evaluate_running` returns before ever looking at it — which is every + // seat that has not been ratified yet, i.e. the common case today. + let running_digest = if ratified::is_artifact_pinned(manifest.as_ref()) { + ratified::running_image_sha256() + } else { + None + }; + let running = ratified::evaluate_running( + &hub_lib::build_info::BUILD, running_digest.as_deref(), manifest.as_ref()); + // The STAGED arm answers "what will the supervisor execute next?" — which + // only the supervisor's configured path can answer. Falling back to this + // process's own image would label a fact about the PRESENT as a fact about + // the NEXT restart, which is precisely the substitution this check exists + // to catch. Unconfigured ⇒ Unknown, never inferred. + let staged = match std::env::var("HUB_EXEC_PATH") { + Ok(p) if !p.trim().is_empty() => { + let path = std::path::PathBuf::from(p); + let digest = ratified::file_sha256(&path); + ratified::evaluate_staged(digest.as_deref(), manifest.as_ref()) + } + _ => DeployVerdict::Unknown { + reason: "HUB_EXEC_PATH is not set, so the artifact the supervisor will execute on restart \ + is not known to this process — it is NOT assumed to be the running image" + .to_string(), + }, + }; + + let pill = |v: &DeployVerdict| match v { + DeployVerdict::Current => r#"current"#.to_string(), + DeployVerdict::Stale { .. } => r#"STALE"#.to_string(), + DeployVerdict::Unknown { .. } => r#"unknown"#.to_string(), + }; + let detail = |v: &DeployVerdict| v.detail() + .map(|d| format!(" — {}", html_escape(d))) + .unwrap_or_default(); + + let b = &hub_lib::build_info::BUILD; + let mut out = String::from("

Deploy ratification

"); + // A commit-only `current` must not read as "these are the ratified bytes". + let claim_note = if running.is_current() && !ratified::is_artifact_pinned(manifest.as_ref()) { + " (commit-level only — no artifact digest ratified)" + } else { + "" + }; + out.push_str(&format!( + "
Running
{}{}{}
", pill(&running), detail(&running), claim_note)); + out.push_str(&format!( + "
Staged at exec path
{}{}
", pill(&staged), detail(&staged))); + out.push_str(&format!( + "
This binary
{} ({:?}, built {})
", + html_escape(b.git_sha_short), b.provenance, html_escape(b.built_at))); + match (&manifest, &read_err) { + (Some(m), _) => out.push_str(&format!( + "
Ratified
{}{}{}
", + html_escape(&m.ratified_git_sha), + m.ratified_by.as_deref().map(|w| format!(" by {}", html_escape(w))).unwrap_or_default(), + m.ratified_at.as_deref().map(|a| format!(" at {}", html_escape(a))).unwrap_or_default())), + (None, Some(e)) => out.push_str(&format!( + "
Ratified
manifest unreadable \ + — {}
", html_escape(e))), + (None, None) => out.push_str(&format!( + "
Ratified
no manifest at {}
", + html_escape(&path.display().to_string()))), + } + out.push_str("
"); + out +} + async fn overview(State(s): State) -> Result, AdminError> { let ledger = s.ledger.lock().await; let projected = HubState::project(&*ledger); @@ -298,6 +392,12 @@ async fn overview(State(s): State) -> Result, AdminError body.push_str(&format!("
Head hash
{}
", html_escape(&head_hash))); body.push_str(""); + // F0.3 (R7c): is this seat running what the society ratified? Placed high + // on the overview, next to identity — an operator who reads no further + // still sees whether the thing they are administering is the approved + // build. (A verdict rendered somewhere nobody looks is not filed.) + body.push_str(&ratified_block(&s)); + body.push_str("

Membership

"); body.push_str(&format!("
Members
{}
", projected.member_count())); body.push_str(&format!("
Member pubkeys pinned
{}
", projected.member_pubkeys.len())); @@ -1465,3 +1565,129 @@ pub fn operator_router(state: RestState) -> Router { .with_state(state) } + +#[cfg(test)] +mod ratified_surface_tests { + use super::*; + use crate::rest::channel_e2e_tests::fresh_rest_state; + use axum::extract::State; + + async fn overview_html(s: &RestState) -> String { + overview(State(s.clone())).await.map(|Html(h)| h) + .unwrap_or_else(|e| panic!("overview failed: {} {}", e.0, e.1)) + } + + fn write_manifest(s: &RestState, json: &str) { + std::fs::write(s.paths.ratified_manifest(), json).expect("write manifest"); + } + + /// `HUB_EXEC_PATH` is process-global, and cargo runs these tests on + /// parallel threads — one test's `set_var` leaks into another's read. + /// (Caught by the full-suite run: both env tests passed in isolation and + /// one failed together, which is how env-racing test flakiness always + /// presents.) Every test that touches the variable takes this lock, so + /// they serialize against each other while the rest of the suite still + /// runs in parallel. + static EXEC_PATH_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// **The guard must be run against what it guards.** Each arm INDUCES its + /// condition and asserts the operator page changes — a rendering test that + /// only ever sees one state proves nothing about the other. + #[tokio::test] + async fn operator_page_distinguishes_unknown_stale_and_current() { + let (_tmp, state) = fresh_rest_state(None).await; + + // ARM 1 — nothing ratified. Must read `unknown`, never a pass, and must + // carry the WARNING pill: an operator scanning the page cannot be + // allowed to read "we could not check" as "checked and fine". + let html = overview_html(&state).await; + assert!(html.contains("Deploy ratification"), "the section renders"); + assert!(html.contains("no manifest at"), "says what is missing"); + let running_line = html.split("
Running
").nth(1).expect("running row"); + assert!(running_line.contains("pill-warn"), + "an unknown seat wears the warning pill, not a neutral one"); + assert!(!running_line.starts_with("
current"), + "unratified must never render current"); + + // ARM 2 — a manifest ratifying a DIFFERENT commit. This is the + // parked-checkout shape: the binary is clean and newer than merged + // source (every currency arm passes) but nobody ratified this commit. + write_manifest(&state, r#"{"ratified_git_sha":"0000000deadbeef00000000deadbeef000000000","ratified_by":"dp"}"#); + let html = overview_html(&state).await; + let running_line = html.split("
Running
").nth(1).expect("running row"); + assert!(running_line.contains("STALE"), + "a clean build of an unratified commit is STALE, not current:\n{running_line}"); + assert!(html.contains("0000000deadbeef"), "the ratified sha is shown for comparison"); + + // ARM 3 — ratify what is actually running. Under `cargo test` the build + // stamp may be `unknown` provenance, which is legitimately `unknown` + // rather than `current`; assert the DISCRIMINATING property instead — + // matching the running commit must not still read STALE-for-mismatch. + let running_sha = hub_lib::build_info::BUILD.git_sha; + if running_sha != "unknown" { + write_manifest(&state, &format!( + r#"{{"ratified_git_sha":"{running_sha}","ratified_by":"dp"}}"#)); + let html = overview_html(&state).await; + let running_line = html.split("
Running
").nth(1).expect("running row"); + assert!(!running_line.contains("is ratified for this seat"), + "a matching commit must not report a commit mismatch:\n{running_line}"); + } + } + + /// A manifest that exists but is malformed is its own state — not silently + /// the same as absent, and emphatically not a pass. + #[tokio::test] + async fn a_malformed_manifest_is_surfaced_not_swallowed() { + let (_tmp, state) = fresh_rest_state(None).await; + write_manifest(&state, "{ this is not json"); + let html = overview_html(&state).await; + assert!(html.contains("manifest unreadable"), + "a broken ratification record is shown to the operator"); + let running_line = html.split("
Running
").nth(1).expect("running row"); + assert!(running_line.contains("pill-warn"), "and still fails closed"); + } + + /// Review follow-up (PR 708): with no configured exec path, the staged arm + /// must say UNKNOWN — it must not label this process's own image as "what + /// the supervisor will run next", which would report a fact about the + /// present as a fact about the next restart. + #[tokio::test] + async fn staged_arm_does_not_infer_the_future_exec_path() { + let (_tmp, state) = fresh_rest_state(None).await; + write_manifest(&state, &format!( + r#"{{"ratified_git_sha":"abcdef1234567890abcdef1234567890abcdef12","ratified_binary_sha256":"{}"}}"#, + "aa".repeat(32))); + let _env = EXEC_PATH_ENV.lock().unwrap_or_else(|e| e.into_inner()); + std::env::remove_var("HUB_EXEC_PATH"); + let html = overview_html(&state).await; + let staged_line = html.split("
Staged at exec path
").nth(1).expect("staged row"); + assert!(staged_line.contains("unknown"), + "unconfigured next-exec path is unknown, not inferred:\n{staged_line}"); + assert!(staged_line.contains("not assumed") || staged_line.contains("NOT assumed"), + "and says so explicitly:\n{staged_line}"); + } + + /// The staged arm answers BEFORE a restart: an unratified artifact at the + /// exec path is a fact the operator should see now, not discover after the + /// ignition that makes it the running binary. + #[tokio::test] + async fn staged_artifact_mismatch_is_visible_without_a_restart() { + let (tmp, state) = fresh_rest_state(None).await; + // A manifest that ratifies some binary digest... + write_manifest(&state, &format!( + r#"{{"ratified_git_sha":"abcdef1234567890abcdef1234567890abcdef12","ratified_binary_sha256":"{}"}}"#, + "aa".repeat(32))); + // ...and an artifact at the exec path that is NOT it. + let staged = tmp.path().join("staged-hub"); + std::fs::write(&staged, b"an unratified binary").unwrap(); + let _env = EXEC_PATH_ENV.lock().unwrap_or_else(|e| e.into_inner()); + std::env::set_var("HUB_EXEC_PATH", &staged); + let html = overview_html(&state).await; + std::env::remove_var("HUB_EXEC_PATH"); + + let staged_line = html.split("
Staged at exec path
").nth(1).expect("staged row"); + assert!(staged_line.contains("STALE"), "staged mismatch is STALE:\n{staged_line}"); + assert!(staged_line.contains("next restart"), + "the operator is told the consequence, not just the fact"); + } +} diff --git a/hub/hub-daemon/src/main.rs b/hub/hub-daemon/src/main.rs index 46c2b44e..24c121a2 100644 --- a/hub/hub-daemon/src/main.rs +++ b/hub/hub-daemon/src/main.rs @@ -43,6 +43,16 @@ struct Cli { #[derive(Subcommand, Debug)] enum Command { + /// Print this binary's build identity as JSON. + /// + /// F0.3 (R7c): the deploy path ratifies an ARTIFACT, and it must be able to + /// ask the artifact what it is in a form meant for machines. Parsing the + /// human `--version` line yields an abbreviated sha and a format that is + /// free to change; this emits the same stamp the daemon publishes, with the + /// FULL commit, so a ratification record cannot be built on a truncated or + /// mis-parsed identity. + BuildInfo, + /// Initialize a new hub society in the given directory. /// /// Two modes: @@ -561,6 +571,12 @@ async fn main() -> Result<()> { println!("Run `hub --help` for available commands."); Ok(()) } + Some(Command::BuildInfo) => { + // Serialized from the same `BUILD` constant the daemon publishes — + // one record rendered twice, not two records to keep in step. + println!("{}", serde_json::to_string_pretty(&hub_lib::build_info::BUILD)?); + Ok(()) + } Some(Command::Init { name, sovereign_lct, sovereign_hestia, sovereign_lct_id, sovereign_pubkey, hub_dir, storage, dynamodb_table, dynamodb_region, dynamodb_endpoint, diff --git a/hub/hub-lib/src/hub.rs b/hub/hub-lib/src/hub.rs index 2431521d..fd45f9ce 100644 --- a/hub/hub-lib/src/hub.rs +++ b/hub/hub-lib/src/hub.rs @@ -315,6 +315,17 @@ impl HubPaths { } pub fn config(&self) -> PathBuf { self.root.join("config.toml") } + /// F0.3 (R7c): the supervisor-owned record of the build this seat is + /// approved to run. Written by the deploy path, never by the daemon — a + /// process that could write its own ratification would be certifying + /// itself. Overridable via `HUB_RATIFIED_MANIFEST` for fleets that keep it + /// outside the hub root (e.g. root-owned, so the daemon user cannot write + /// it at all — the preferred deployment). + pub fn ratified_manifest(&self) -> PathBuf { + std::env::var("HUB_RATIFIED_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|_| self.root.join("ratified-build.json")) + } pub fn charter(&self) -> PathBuf { self.root.join("charter.json") } pub fn society(&self) -> PathBuf { self.root.join("society.json") } pub fn ledger(&self) -> PathBuf { self.root.join("ledger.jsonl") } diff --git a/hub/hub-lib/src/lib.rs b/hub/hub-lib/src/lib.rs index 7733c78c..a5148dcd 100644 --- a/hub/hub-lib/src/lib.rs +++ b/hub/hub-lib/src/lib.rs @@ -36,6 +36,7 @@ pub mod law; pub mod ledger; pub mod pair_message; pub mod proposal; +pub mod ratified; pub mod replay; pub mod session; diff --git a/hub/hub-lib/src/ratified.rs b/hub/hub-lib/src/ratified.rs new file mode 100644 index 00000000..98f1a97e --- /dev/null +++ b/hub/hub-lib/src/ratified.rs @@ -0,0 +1,655 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Metalinxx Inc. + +//! Is the hub executing a **ratified** build? — Sprint F0.3 / PRD R7c. +//! +//! ## Currency is not ratification +//! +//! The fleet already has a currency instrument: it answers *does the running +//! process image equal the on-disk binary, and does that binary postdate the +//! merged source?* Both are necessary and neither is sufficient, because a +//! binary built from a **parked feature branch** passes both — the process +//! matches the file, and the file is newer than anything merged. That is not a +//! hypothetical: a build on a parked branch put unmerged code at `ExecStart` +//! and HEAD-based currency called it clean. +//! +//! This module asks the question currency cannot: *is what runs here the build +//! the society ratified?* It compares two records that are produced +//! independently — +//! +//! - **what is running** — [`crate::build_info::BUILD`], stamped into the +//! artifact at compile time, so the running binary attests its own identity +//! rather than an observer reconstructing it from file mtimes and `/proc` +//! inodes (a reconstruction that has already failed open here); +//! - **what was ratified** — a supervisor-owned manifest, written by the deploy +//! path, never by the daemon. +//! +//! The daemon is deliberately a **reader** of the manifest. A process that +//! could write its own ratification record would be certifying itself, which is +//! the shape this exists to refuse. +//! +//! ## Fail-closed +//! +//! Every unestablished condition resolves to [`DeployVerdict::Unknown`], never +//! to `Current`. An absent manifest, an unparseable one, a build whose +//! provenance could not be established — each is *"we do not know"*, which is +//! an operator-visible state and not a pass. `Unknown` and `Stale` are kept +//! distinct for the same reason `Refuted` and `Undecidable` are in the sponsor +//! predicate: a guard with one failure exit either certifies a lie or becomes +//! unsatisfiable, and "nobody has ratified anything yet" calls for a different +//! human response than "this seat is running something that was not ratified." + +use crate::build_info::{BuildInfo, Provenance}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// The supervisor-owned record of what this seat is approved to run. +/// +/// Written by the deploy path (a human or a supervisor process that has +/// verified the build), read by everyone. Deliberately small: a ratification +/// record with many fields invites partial writes and per-field drift. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct RatifiedManifest { + /// The commit the ratified build was compiled from. Compared against + /// [`BuildInfo::git_sha`], which the running binary attests about itself. + pub ratified_git_sha: String, + /// SHA-256 of the ratified binary, hex. Lets the staged-artifact check + /// (`ExecStart` path) answer *before* a restart makes it the running one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ratified_binary_sha256: Option, + /// RFC 3339 instant of ratification — operator context, not a check input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ratified_at: Option, + /// Who ratified it — operator context. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ratified_by: Option, +} + +impl RatifiedManifest { + /// Read the manifest from `path`. A missing file is `Ok(None)` — "no + /// ratification recorded" is a legitimate state that must render as + /// `Unknown`, not as an error that a caller might swallow into a pass. + /// A present-but-unparseable file is `Err`: something IS there and it is + /// wrong, which is not the same as nothing being there. + pub fn read(path: &Path) -> anyhow::Result> { + match std::fs::read_to_string(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow::anyhow!("reading ratified manifest: {e}")), + Ok(s) => { + let m: Self = serde_json::from_str(&s) + .map_err(|e| anyhow::anyhow!("parsing ratified manifest: {e}"))?; + m.validate()?; + Ok(Some(m)) + } + } + } + + /// Admit only a well-formed record. **The validation belongs here, at + /// admission, not at the writer.** + /// + /// The writer already checks shape (`ratify-build.sh` enforces hex), but + /// this module's entire premise is that the daemon must assume nothing + /// about the writer — the manifest is deliberately owned by a different + /// principal, root-owned and daemon-unwritable, because "a process that + /// could write its own ratification record would be certifying itself". A + /// writer untrusted enough to need that asymmetry is untrusted enough to + /// need input validation. Leaving the check on the writer's side put the + /// whole guarantee in the hands of the party it is designed to distrust. + /// + /// A malformed record therefore reaches the same fail-closed rendering as + /// any other unparseable one (`manifest unreadable` ⇒ `Unknown`, never a + /// pass) instead of reaching a comparison at all. + fn validate(&self) -> anyhow::Result<()> { + let sha = self.ratified_git_sha.trim(); + if sha.is_empty() { + anyhow::bail!("ratified manifest has an empty ratified_git_sha"); + } + // FULL commit id, not an abbreviation. A short sha is a + // repository-local locator whose uniqueness changes as history grows — + // it is not a durable identity token, and in the commit-only fallback + // (no artifact digest pinned) it is the ONLY identity claim carrying + // the control. Accepting 7 hex characters would ratify any future + // commit sharing 28 bits of prefix. + if sha.len() != 40 || !sha.chars().all(|c| c.is_ascii_hexdigit()) { + anyhow::bail!( + "ratified_git_sha must be a full 40-character hex commit id \ + (got {} character(s)); an abbreviation is a repo-local locator, \ + not an identity — resolve it before writing the manifest", + sha.chars().count() + ); + } + if let Some(d) = self.ratified_binary_sha256.as_deref() { + let d = d.trim(); + if d.len() != 64 || !d.chars().all(|c| c.is_ascii_hexdigit()) { + anyhow::bail!( + "ratified_binary_sha256 must be 64 hex characters (got {})", + d.chars().count() + ); + } + } + Ok(()) + } +} + +/// What the seat is running, relative to what was ratified. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "verdict", rename_all = "lowercase")] +pub enum DeployVerdict { + /// Running exactly the ratified build, from a clean tree. + Current, + /// Established as NOT the ratified build. Carries what differs. + Stale { reason: String }, + /// Could not be established either way. Never a pass. + Unknown { reason: String }, +} + +impl DeployVerdict { + pub fn token(&self) -> &'static str { + match self { + DeployVerdict::Current => "current", + DeployVerdict::Stale { .. } => "stale", + DeployVerdict::Unknown { .. } => "unknown", + } + } + /// Only `Current` is a pass. Used by callers that need a boolean without + /// re-deriving the fail-closed rule (and thereby getting it wrong). + pub fn is_current(&self) -> bool { + matches!(self, DeployVerdict::Current) + } + pub fn detail(&self) -> Option<&str> { + match self { + DeployVerdict::Current => None, + DeployVerdict::Stale { reason } | DeployVerdict::Unknown { reason } => Some(reason), + } + } +} + +/// Evaluate the **running** binary against the ratified manifest. +/// +/// Pure: the running side comes from the compile-time stamp the artifact +/// carries plus the digest of the executing image, the ratified side from the +/// supervisor's record. +/// +/// **Commit identity is provenance; artifact identity is the ratification +/// claim.** Two builds of the same commit are not the same executable — a +/// different toolchain, different feature flags, or a tampered artifact all +/// keep the commit while changing the bytes. So when the manifest records a +/// binary digest, that digest is authoritative and must match; the commit +/// check remains as the earlier, cheaper discriminator. A manifest with no +/// digest can only support the weaker commit-level claim, and callers are +/// expected to say so on the operator surface rather than let it read as a +/// full artifact match. +/// +/// `running_sha256` is the digest of the executing image (on Linux, read via +/// `/proc/self/exe`, which stays readable even after a replace-in-place has +/// unlinked the original path — that is the point: it is the bytes actually +/// running, not the bytes currently at the path). +pub fn evaluate_running( + build: &BuildInfo, + running_sha256: Option<&str>, + manifest: Option<&RatifiedManifest>, +) -> DeployVerdict { + let Some(m) = manifest else { + return DeployVerdict::Unknown { + reason: "no ratified-build manifest on this seat — nothing has been recorded as \ + approved to run here".to_string(), + }; + }; + // A build that cannot say what it came from cannot be matched to anything. + if build.git_sha == "unknown" || build.git_sha.is_empty() { + return DeployVerdict::Unknown { + reason: "this binary carries no build commit, so it cannot be compared to the \ + ratified one".to_string(), + }; + } + // Provenance first: a dirty tree means the artifact is not any commit, so + // a matching sha would be a coincidence of naming, not of content. Unknown + // provenance is explicitly NOT folded into clean. + match build.provenance { + Provenance::Dirty => { + return DeployVerdict::Stale { + reason: format!( + "built from a MODIFIED tree at {} — a dirty build is not the ratified \ + artifact even when the commit matches", + build.git_sha_short + ), + }; + } + Provenance::Unknown => { + return DeployVerdict::Unknown { + reason: format!( + "build provenance at {} could not be established; an unverified tree \ + state is not an assertion that it was clean", + build.git_sha_short + ), + }; + } + Provenance::Clean => {} + } + // Full-to-full. `read()` admitted only a 40-hex `ratified_git_sha`, and the + // build stamp is 40-hex by construction, so there is no shorter-operand case + // left to have an opinion about — the prefix-identity question is closed at + // admission rather than re-decided here on every render. + if !m.ratified_git_sha.trim().eq_ignore_ascii_case(build.git_sha.trim()) { + return DeployVerdict::Stale { + reason: format!( + "running {} but {} is ratified for this seat", + build.git_sha_short, + short(&m.ratified_git_sha) + ), + }; + } + // The commit matches. That is provenance, not identity — decide on the + // artifact when the manifest names one. + match (m.ratified_binary_sha256.as_deref(), running_sha256) { + (Some(ratified), Some(running)) => { + if running.eq_ignore_ascii_case(ratified) { + DeployVerdict::Current + } else { + DeployVerdict::Stale { + reason: format!( + "the EXECUTING artifact ({}…) is not the ratified binary ({}…) — same \ + commit, different bytes (toolchain, build flags, or substitution)", + short(running), + short(ratified) + ), + } + } + } + // A digest was ratified but the running image could not be read: the + // authoritative check could not be performed, so this is not a pass. + (Some(_), None) => DeployVerdict::Unknown { + reason: "the ratified manifest pins a binary digest, but the executing image \ + could not be read to compare against it".to_string(), + }, + // No digest ratified: the commit-level claim is the strongest available. + // Callers surface this as the weaker claim it is. + (None, _) => DeployVerdict::Current, + } +} + +/// Does the manifest support a full **artifact**-level claim, or only the +/// weaker commit-level one? The operator surface renders the difference so a +/// commit-only `current` is never read as "these are the ratified bytes". +pub fn is_artifact_pinned(manifest: Option<&RatifiedManifest>) -> bool { + manifest.map(|m| m.ratified_binary_sha256.is_some()).unwrap_or(false) +} + +/// SHA-256 of the **executing image**. On Linux `/proc/self/exe` resolves to +/// the running inode even after the file at that path has been replaced or +/// unlinked, which is exactly what this needs: the bytes in memory, not the +/// bytes someone has since staged. Falls back to `current_exe` elsewhere. +pub fn running_image_sha256() -> Option { + let proc_self = Path::new("/proc/self/exe"); + if proc_self.exists() { + if let Some(d) = file_sha256(proc_self) { + return Some(d); + } + } + std::env::current_exe().ok().as_deref().and_then(file_sha256) +} + +/// Evaluate the artifact **staged at the exec path** against the manifest. +/// +/// This is the arm that answers before a restart: an unratified binary dropped +/// where the unit will next execute it is a fact the operator should see +/// *now*, not discover after the ignition that makes it the running one. (The +/// deploy path is part of the governance closure precisely because a write +/// that redirects *which* binary executes is equivalent to a write to the +/// binary.) +/// +/// `staged_sha256` is the digest of the file at the exec path, or `None` when +/// it could not be read. +pub fn evaluate_staged( + staged_sha256: Option<&str>, + manifest: Option<&RatifiedManifest>, +) -> DeployVerdict { + let Some(m) = manifest else { + return DeployVerdict::Unknown { + reason: "no ratified-build manifest to compare the staged artifact against" + .to_string(), + }; + }; + let Some(ratified) = m.ratified_binary_sha256.as_deref() else { + return DeployVerdict::Unknown { + reason: "the ratified manifest records no binary digest, so a staged artifact \ + cannot be checked before it runs".to_string(), + }; + }; + let Some(staged) = staged_sha256 else { + return DeployVerdict::Unknown { + reason: "could not read the artifact at the exec path".to_string(), + }; + }; + if staged.eq_ignore_ascii_case(ratified) { + DeployVerdict::Current + } else { + DeployVerdict::Stale { + reason: format!( + "the artifact staged at the exec path ({}…) is not the ratified binary ({}…) — \ + the next restart would run something unratified", + short(staged), + short(ratified) + ), + } + } +} + +/// SHA-256 of a file, hex. `None` when it cannot be read — the caller renders +/// that as `Unknown`, never as a match. +pub fn file_sha256(path: &Path) -> Option { + std::fs::read(path).ok().map(|b| web4_core::crypto::sha256_hex(&b)) +} + +fn short(s: &str) -> String { + s.chars().take(12).collect() +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + + // Full 40-hex commit ids. SHA_A and SHA_PREFIX_TWIN deliberately share the + // first 7 characters — the abbreviation a human would type — and are + // otherwise distinct, which is the pair the prefix-identity test needs. + pub(super) const SHA_A: &str = "abcdef1234567890abcdef1234567890abcdef12"; + pub(super) const SHA_B: &str = "feedface00000000feedface00000000feedface"; + pub(super) const SHA_PREFIX_TWIN: &str = "abcdef19999999999999999999999999999999f"; + + pub(super) fn build_clean_at(sha: &'static str) -> BuildInfo { build(sha, Provenance::Clean) } + pub(super) fn build_dirty_at(sha: &'static str) -> BuildInfo { build(sha, Provenance::Dirty) } + pub(super) fn build(sha: &'static str, prov: Provenance) -> BuildInfo { + BuildInfo { + version: "test", + git_sha: sha, + git_sha_short: "abcdef1", + provenance: prov, + built_at: "2026-08-13T00:00:00Z", + } + } + + pub(super) fn manifest(sha: &str) -> RatifiedManifest { + RatifiedManifest { + ratified_git_sha: sha.to_string(), + ratified_binary_sha256: None, + ratified_at: None, + ratified_by: None, + } + } + + #[test] + fn matching_clean_build_is_current() { + let b = build(SHA_A, Provenance::Clean); + assert_eq!(evaluate_running(&b, None, Some(&manifest(SHA_A))), DeployVerdict::Current); + } + + /// **The parked-checkout case this module exists for.** The binary is + /// clean, newer than merged source, and the process image matches the file + /// — every currency arm passes — but it was built from a commit nobody + /// ratified. + #[test] + fn a_clean_build_of_an_unratified_commit_is_stale() { + let b = build(SHA_B, Provenance::Clean); + let v = evaluate_running(&b, None, Some(&manifest(SHA_A))); + assert!(matches!(v, DeployVerdict::Stale { .. }), "got {v:?}"); + assert!(v.detail().unwrap().contains("ratified")); + assert!(!v.is_current()); + } + + /// A dirty tree is not the ratified artifact even when the commit matches: + /// the binary is not any commit. + #[test] + fn a_dirty_build_is_stale_even_at_the_ratified_commit() { + let b = build(SHA_A, Provenance::Dirty); + let v = evaluate_running(&b, None, Some(&manifest(SHA_A))); + assert!(matches!(v, DeployVerdict::Stale { .. }), "got {v:?}"); + assert!(v.detail().unwrap().contains("MODIFIED")); + } + + /// Unknown provenance is NOT folded into clean — an unverified tree state + /// is not an assertion that it was clean. + #[test] + fn unknown_provenance_is_unknown_not_current() { + let b = build(SHA_A, Provenance::Unknown); + let v = evaluate_running(&b, None, Some(&manifest(SHA_A))); + assert!(matches!(v, DeployVerdict::Unknown { .. }), "got {v:?}"); + assert!(!v.is_current()); + } + + /// The two failure exits stay distinct: "nothing was ratified" needs a + /// different human response from "this is not the ratified build". + #[test] + fn absent_manifest_is_unknown_not_stale_and_never_current() { + let b = build(SHA_A, Provenance::Clean); + let v = evaluate_running(&b, None, None); + assert!(matches!(v, DeployVerdict::Unknown { .. }), "got {v:?}"); + assert!(!v.is_current(), "an unratified seat never reads as a pass"); + } + + #[test] + fn a_build_with_no_commit_cannot_be_matched() { + let b = build("unknown", Provenance::Clean); + assert!(matches!( + evaluate_running(&b, None, Some(&manifest(SHA_A))), + DeployVerdict::Unknown { .. } + )); + } + + /// **Review finding (PR 708): a short sha is a locator, not an identity.** + /// Its uniqueness changes as history grows, so accepting one would ratify + /// any future commit sharing 28 bits of prefix. Abbreviations are refused + /// at ADMISSION, which closes the question by construction — there is no + /// shorter-operand case left for the comparison to have an opinion about. + #[test] + fn an_abbreviated_sha_is_refused_at_admission() { + for short in ["abcdef1", "abcdef1234567890", &SHA_A[..39]] { + let m = RatifiedManifest { + ratified_git_sha: short.to_string(), + ratified_binary_sha256: None, ratified_at: None, ratified_by: None, + }; + let e = m.validate().expect_err(&format!("{short} must be refused")); + assert!(e.to_string().contains("full 40"), "{e}"); + } + // ...and the full one is admitted. + assert!(manifest(SHA_A).validate().is_ok()); + } + + /// The pair the prefix bug would have confused: two DISTINCT full commits + /// sharing the first 7 hex characters. Ratifying one must never make the + /// other `Current`. This cannot pass by accident under a byte-compare-only + /// fix, which is why it is the discriminating one. + #[test] + fn two_commits_sharing_a_prefix_are_never_interchangeable() { + assert_eq!(&SHA_A[..7], &SHA_PREFIX_TWIN[..7], "the fixture must actually collide"); + assert_ne!(SHA_A, SHA_PREFIX_TWIN); + let ratified = manifest(SHA_A); + let twin_build = build(SHA_PREFIX_TWIN, Provenance::Clean); + let v = evaluate_running(&twin_build, None, Some(&ratified)); + assert!(matches!(v, DeployVerdict::Stale { .. }), + "a prefix twin must not read as the ratified commit: {v:?}"); + assert!(!v.is_current()); + } + + #[test] + fn staged_artifact_is_checked_before_it_runs() { + let mut m = manifest(SHA_A); + m.ratified_binary_sha256 = Some("aa".repeat(32)); + assert_eq!(evaluate_staged(Some(&"aa".repeat(32)), Some(&m)), DeployVerdict::Current); + let v = evaluate_staged(Some(&"bb".repeat(32)), Some(&m)); + assert!(matches!(v, DeployVerdict::Stale { .. }), "got {v:?}"); + assert!(v.detail().unwrap().contains("next restart"), + "the operator is told what the consequence is"); + // Unreadable artifact, and a manifest with no digest, are both unknown. + assert!(matches!(evaluate_staged(None, Some(&m)), DeployVerdict::Unknown { .. })); + let no_digest = manifest(SHA_A); + assert!(matches!( + evaluate_staged(Some(&"aa".repeat(32)), Some(&no_digest)), + DeployVerdict::Unknown { .. } + )); + } + + #[test] + fn manifest_read_distinguishes_absent_from_malformed() { + let dir = std::env::temp_dir().join(format!("hub-ratified-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let missing = dir.join("nope.json"); + assert!(RatifiedManifest::read(&missing).unwrap().is_none(), + "absent is a state, not an error"); + + let bad = dir.join("bad.json"); + std::fs::write(&bad, b"{not json").unwrap(); + assert!(RatifiedManifest::read(&bad).is_err(), + "something IS there and it is wrong — not the same as nothing"); + + let empty_sha = dir.join("empty.json"); + std::fs::write(&empty_sha, br#"{"ratified_git_sha":" "}"#).unwrap(); + assert!(RatifiedManifest::read(&empty_sha).is_err(), + "a manifest that ratifies nothing is malformed, not permissive"); + + let good = dir.join("good.json"); + std::fs::write(&good, format!(r#"{{"ratified_git_sha":"{SHA_A}","ratified_by":"dp"}}"#).as_bytes()).unwrap(); + let m = RatifiedManifest::read(&good).unwrap().expect("parsed"); + assert_eq!(m.ratified_git_sha, SHA_A); + assert_eq!(m.ratified_by.as_deref(), Some("dp")); + } +} + +#[cfg(test)] +mod artifact_identity_tests { + use super::*; + use super::tests::*; + + /// **The review finding (PR 708).** Same commit does NOT mean same + /// executable: a different toolchain, different feature flags, or an + /// outright substitution all preserve the commit while changing the bytes. + /// When the manifest pins a digest, the digest decides. + #[test] + fn same_commit_different_bytes_is_stale() { + let b = build_clean_at(SHA_A); + let mut m = manifest(SHA_A); + m.ratified_binary_sha256 = Some("aa".repeat(32)); + + // The ratified artifact: current. + assert_eq!(evaluate_running(&b, Some(&"aa".repeat(32)), Some(&m)), DeployVerdict::Current); + + // A different build of the SAME commit: stale, and the operator is told + // why a matching commit is not a match. + let v = evaluate_running(&b, Some(&"bb".repeat(32)), Some(&m)); + assert!(matches!(v, DeployVerdict::Stale { .. }), "got {v:?}"); + // Single arm, deliberately: the previous two-arm `||` had an unreachable + // first arm (it matched raw source whitespace that `\`-continuation + // removes at runtime), so it checked one property while looking like it + // checked two. + let why = v.detail().unwrap(); + assert!(why.contains("different bytes"), "reason names the distinction: {why:?}"); + assert!(why.contains("same commit"), "and names what is NOT the difference: {why:?}"); + assert!(!v.is_current()); + } + + /// A pinned digest that cannot be checked is UNKNOWN, never a pass — the + /// authoritative comparison did not happen. + #[test] + fn pinned_digest_with_unreadable_image_is_unknown() { + let b = build_clean_at(SHA_A); + let mut m = manifest(SHA_A); + m.ratified_binary_sha256 = Some("aa".repeat(32)); + let v = evaluate_running(&b, None, Some(&m)); + assert!(matches!(v, DeployVerdict::Unknown { .. }), "got {v:?}"); + assert!(!v.is_current()); + } + + /// Without a pinned digest the commit-level claim is all there is. It may + /// read `current`, but callers must be able to tell it apart from a full + /// artifact match — hence `is_artifact_pinned`. + #[test] + fn commit_only_ratification_is_marked_as_the_weaker_claim() { + let b = build_clean_at(SHA_A); + let m = manifest(SHA_A); + assert_eq!(evaluate_running(&b, Some(&"cc".repeat(32)), Some(&m)), DeployVerdict::Current); + assert!(!is_artifact_pinned(Some(&m)), "commit-only ratification is distinguishable"); + let mut pinned = m.clone(); + pinned.ratified_binary_sha256 = Some("cc".repeat(32)); + assert!(is_artifact_pinned(Some(&pinned))); + assert!(!is_artifact_pinned(None)); + } + + /// The artifact check does not rescue a wrong commit or a dirty tree — + /// those are decided before it and stay decided. + #[test] + fn artifact_match_cannot_launder_a_wrong_commit_or_dirty_tree() { + let mut m = manifest(SHA_A); + m.ratified_binary_sha256 = Some("aa".repeat(32)); + let wrong_commit = build_clean_at(SHA_B); + assert!(matches!( + evaluate_running(&wrong_commit, Some(&"aa".repeat(32)), Some(&m)), + DeployVerdict::Stale { .. })); + let dirty = build_dirty_at(SHA_A); + assert!(matches!( + evaluate_running(&dirty, Some(&"aa".repeat(32)), Some(&m)), + DeployVerdict::Stale { .. })); + } +} + +#[cfg(test)] +mod malformed_manifest_tests { + use super::*; + use super::tests::*; + + /// **The blocking review finding (PR 708), as a regression.** The manifest + /// is operator-supplied JSON from a principal this module deliberately does + /// NOT trust, and the old comparison byte-sliced it: + /// `a[..n]` where `n = a.len().min(b.len())`. A multi-byte character + /// straddling byte 40 is not a char boundary, so a manifest of + /// `"aééé…"` panicked — inside `/admin`, the very surface that reports the + /// ratification verdict, taking the page down. + /// + /// Second time this exact shape shipped in this sprint (the degraded-log + /// truncation was the first), which is why the fix is structural: validate + /// at admission so no non-hex string ever reaches a comparison, rather than + /// making one comparison site multibyte-safe. + #[test] + fn a_non_ascii_sha_yields_a_verdict_not_a_panic() { + let dir = std::env::temp_dir().join(format!("hub-ratified-mb-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("m.json"); + // 41 bytes / 21 chars — byte 40 lands inside the final 'é'. + let payload = format!("a{}", "é".repeat(20)); + assert_eq!(payload.len(), 41); + assert!(!payload.is_char_boundary(40), "fixture must straddle byte 40"); + std::fs::write(&path, format!(r#"{{"ratified_git_sha":"{payload}"}}"#)).unwrap(); + + // Admission refuses it — the same fail-closed exit as any other + // malformed record, reached without a comparison. + let err = RatifiedManifest::read(&path).expect_err("a non-hex sha is malformed"); + assert!(err.to_string().contains("full 40"), "{err}"); + + // And the evaluation path is unreachable-by-construction for such a + // record; called directly with one anyway, it still returns a verdict. + let m = RatifiedManifest { + ratified_git_sha: payload, + ratified_binary_sha256: None, ratified_at: None, ratified_by: None, + }; + let v = evaluate_running(&build(SHA_A, Provenance::Clean), None, Some(&m)); + assert!(matches!(v, DeployVerdict::Stale { .. }), "verdict, not panic: {v:?}"); + assert!(!v.is_current()); + } + + /// The same class on the other operator-supplied field. + #[test] + fn a_malformed_binary_digest_is_refused_at_admission() { + for bad in ["nothex", &"a".repeat(63), &"é".repeat(32)] { + let m = RatifiedManifest { + ratified_git_sha: SHA_A.to_string(), + ratified_binary_sha256: Some(bad.to_string()), + ratified_at: None, ratified_by: None, + }; + assert!(m.validate().is_err(), "{bad:?} must be refused"); + } + let ok = RatifiedManifest { + ratified_git_sha: SHA_A.to_string(), + ratified_binary_sha256: Some("ab".repeat(32)), + ratified_at: None, ratified_by: None, + }; + assert!(ok.validate().is_ok()); + } +} diff --git a/hub/scripts/ratify-build.sh b/hub/scripts/ratify-build.sh new file mode 100755 index 00000000..dda00910 --- /dev/null +++ b/hub/scripts/ratify-build.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# ratify-build.sh — record which build a hub seat is approved to run. +# +# Sprint F0.3 / PRD R7c. This writes the SUPERVISOR side of the deploy +# ratification check; the daemon only ever reads it. That asymmetry is the +# point: a process that could write its own ratification record would be +# certifying itself, which is precisely the shape the check exists to refuse. +# +# WHY THIS EXISTS, given the fleet already has a currency check: +# The currency instrument answers "is the running image the on-disk binary, +# and does that binary postdate the merged source?" A binary built from a +# PARKED FEATURE BRANCH passes both arms — the process matches the file, and +# the file is newer than anything merged. Measured on this fleet: a build on +# a parked branch put unmerged code at ExecStart and HEAD-based currency read +# it clean. Currency is not ratification. +# +# WHAT RATIFICATION MEANS HERE: a human (or a supervisor that verified it) +# asserts "this commit is the one this seat may run." The daemon then compares +# that against what the running binary attests about itself (a compile-time +# stamp, not an observer's reconstruction from mtimes and /proc inodes — that +# reconstruction has already failed open here). +# +# Usage: +# ratify-build.sh [binary-path] [--manifest ] [--by ] +# ratify-build.sh --from-binary ... # read the sha the binary attests +# +# The manifest path defaults to $HUB_RATIFIED_MANIFEST, else /ratified-build.json +# via --root, else ./ratified-build.json. +# +# DEPLOYMENT NOTE: prefer a manifest the daemon user CANNOT write (root-owned, +# 0644). The daemon needs read only, and a ratification record writable by the +# thing it ratifies is not a control. Point the daemon at it with +# HUB_RATIFIED_MANIFEST. +# +# Exit codes: 0 written · 1 refused (bad input / unverifiable) · 2 usage. + +set -euo pipefail + +die() { echo "ratify-build: $*" >&2; exit 1; } +usage(){ sed -n '1,40p' "$0" | grep '^#' | sed 's/^# \{0,1\}//'; exit 2; } + +SHA=""; BIN=""; MANIFEST="${HUB_RATIFIED_MANIFEST:-}"; BY="${USER:-unknown}"; ROOT="" +FROM_BINARY=0 + +while [ $# -gt 0 ]; do + case "$1" in + --from-binary) FROM_BINARY=1; BIN="${2:-}"; shift 2 ;; + --manifest) MANIFEST="${2:-}"; shift 2 ;; + --by) BY="${2:-}"; shift 2 ;; + --root) ROOT="${2:-}"; shift 2 ;; + -h|--help) usage ;; + -*) die "unknown flag $1" ;; + *) if [ -z "$SHA" ] && [ "$FROM_BINARY" = 0 ]; then SHA="$1" + elif [ -z "$BIN" ]; then BIN="$1" + else die "unexpected argument $1"; fi; shift ;; + esac +done + +if [ -z "$MANIFEST" ]; then + if [ -n "$ROOT" ]; then MANIFEST="$ROOT/ratified-build.json" + else MANIFEST="./ratified-build.json"; fi +fi + +# --from-binary: ask the artifact what it is, rather than trusting the operator's +# memory of what they built. `hub --version` prints the same stamp the daemon +# publishes, so the ratified record and the running record cannot disagree about +# what the string means. +if [ "$FROM_BINARY" = 1 ]; then + [ -n "$BIN" ] || die "--from-binary needs a binary path" + [ -x "$BIN" ] || die "not executable: $BIN" + # Ask the artifact in a MACHINE-READABLE form. Parsing the human `--version` + # line yields an ABBREVIATED sha and a format free to change; `build-info` + # emits the same stamp the daemon publishes, with the full commit, so a + # ratification record is never built on a truncated or mis-parsed identity. + INFO="$("$BIN" build-info 2>/dev/null)" || die "could not run '$BIN build-info' (binary too old?)" + json_str() { printf '%s' "$INFO" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1; } + SHA="$(json_str git_sha)" + PROV="$(json_str provenance)" + [ -n "$SHA" ] || die "no git_sha in build-info output: $INFO" + # A dirty or unverifiable build is not a ratifiable artifact: it is not any + # commit, so ratifying "the commit" would name something that does not + # describe the bytes. Refuse rather than record a claim that cannot hold. + case "$PROV" in + clean) ;; + dirty) die "refusing to ratify a build from a MODIFIED tree ($SHA dirty) — it is not any commit" ;; + *) die "refusing to ratify a build whose provenance is '$PROV' — unverified is not clean" ;; + esac +fi + +[ -n "$SHA" ] || usage +printf '%s' "$SHA" | grep -Eq '^[0-9a-fA-F]{7,40}$' || die "not a git sha: $SHA" + +# PERSIST A FULL COMMIT ID, never an abbreviation. A short sha is a +# repository-LOCAL locator whose uniqueness changes as history grows; it is not a +# durable identity token, and in the commit-only fallback (no artifact digest +# pinned) it is the ONLY identity claim carrying the control. A 7-hex manifest +# would ratify any future commit sharing 28 bits of prefix. +# +# So an abbreviation is RESOLVED here, at write time, where a repository exists to +# resolve it against — the daemon has no repo and refuses anything short at admission. +if [ "${#SHA}" -ne 40 ]; then + FULL="$(git rev-parse --verify --quiet "${SHA}^{commit}" 2>/dev/null || true)" + if [ -n "$FULL" ] && [ "${#FULL}" -eq 40 ]; then + echo "ratify-build: resolved ${SHA} -> ${FULL}" >&2 + SHA="$FULL" + else + die "refusing to ratify an abbreviated sha ('$SHA'): a repo-local locator is not an identity. Run inside a repo containing the commit, or pass the full 40-character id." + fi +fi + +DIGEST="" +if [ -n "$BIN" ]; then + [ -f "$BIN" ] || die "no such binary: $BIN" + DIGEST="$(sha256sum "$BIN" | cut -d' ' -f1)" +fi + +# JSON-escape operator-supplied text: a name containing a quote or backslash +# would otherwise emit a manifest that fails to parse — which the daemon reports +# as "manifest unreadable" (fail-closed, so not dangerous, but a self-inflicted +# outage of the control). +json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } +BY_ESC="$(json_escape "$BY")" + +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +TMP="$(mktemp "${MANIFEST}.XXXXXX")" +{ + printf '{\n' + printf ' "ratified_git_sha": "%s",\n' "$SHA" + [ -n "$DIGEST" ] && printf ' "ratified_binary_sha256": "%s",\n' "$DIGEST" + printf ' "ratified_at": "%s",\n' "$TS" + printf ' "ratified_by": "%s"\n' "$BY_ESC" + printf '}\n' +} > "$TMP" +# mktemp creates 0600. The daemon usually runs as a DIFFERENT user than the one +# ratifying (the whole point: the manifest should not be writable by the thing it +# ratifies), so a 0600 manifest would be unreadable to it and every seat would +# render `unknown` — a control that silently disables itself. Widen to 0644 +# BEFORE the rename, so the file is never briefly readable in a half-written state. +chmod 0644 "$TMP" +# Atomic replace: a half-written ratification record must never be readable. +mv -f "$TMP" "$MANIFEST" + +echo "ratified $SHA${DIGEST:+ (binary ${DIGEST:0:12}…)} → $MANIFEST" +echo "the seat's operator page will now compare its running build against this."