From 3d0914bd13b63edde691503080205b4208c5eaeb Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 20 Aug 2026 09:26:21 -0400 Subject: [PATCH] feat: staged-workspace subprocess adapter for the WP15 acceptance-test binder Adds the cross-language bridge WP15's RvfWorkspaceBinder (PR #872, crates/ruvector-sota-bench/harness) consumes -- a subprocess JSON protocol rather than NAPI, keeping the ADR-318 gate off the platform binary build matrix: - bin staged-workspace-adapter: one JSON request on stdin, one JSON response on stdout. Exit-code contract: 0 = ok, 1 = integrity rejection (ADR-318 hard rejection), 2 = adapter malfunction -- so a crashed or misconfigured adapter fails the harness loudly and is never silently scored as a detected compromise. - Frozen error discriminators: stale-view, binding-mismatch, unknown-artifact, anchor-rejected (exit 1); adapter-error (exit 2). - commit op: batch-commits base64 content, returns per-artifact ArtifactRefs plus a workspace_hash (SHA-256 over sorted (artifact_id, content_hash) head pairs) -- the single contentHash the binder seam expects. validate op: fail-closed check of a bound (artifact_id, content_hash, revision_id) reference. - State persists across invocations via a WorkspaceSnapshot JSON file (new snapshot()/from_snapshot() on WorkspaceState, plus validate_ref() and workspace_hash()); ContentHash::from_hex added. - Zero new external deps: hand-rolled RFC 4648 base64 (vector-tested), serde_json promoted from dev-dep for the transport. - 22 tests green (7 new adapter-protocol tests incl. cross-invocation staleness and the malfunction-vs-rejection split); clippy/fmt clean; binary smoke-tested end to end. Refs ruvnet/RuVector#863, ruvnet/RuVector#862. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X --- crates/ruvector-staged-workspace/Cargo.toml | 8 +- .../ruvector-staged-workspace/src/adapter.rs | 322 ++++++++++++++++++ .../ruvector-staged-workspace/src/artifact.rs | 16 + .../src/bin/staged-workspace-adapter.rs | 31 ++ crates/ruvector-staged-workspace/src/lib.rs | 5 +- .../src/workspace.rs | 111 +++++- .../tests/adapter.rs | 222 ++++++++++++ 7 files changed, 706 insertions(+), 9 deletions(-) create mode 100644 crates/ruvector-staged-workspace/src/adapter.rs create mode 100644 crates/ruvector-staged-workspace/src/bin/staged-workspace-adapter.rs create mode 100644 crates/ruvector-staged-workspace/tests/adapter.rs diff --git a/crates/ruvector-staged-workspace/Cargo.toml b/crates/ruvector-staged-workspace/Cargo.toml index 2b36e9cd0..339152648 100644 --- a/crates/ruvector-staged-workspace/Cargo.toml +++ b/crates/ruvector-staged-workspace/Cargo.toml @@ -11,11 +11,15 @@ categories = ["data-structures"] [dependencies] serde = { workspace = true } +# JSON transport for the subprocess adapter (state snapshots + the +# stdin/stdout protocol consumed by WP15's TS acceptance harness). +serde_json = { workspace = true } # The repo's existing RVF-layer SHA-256 (FIPS 180-4, NIST-vector verified, # no_std, zero deps). ADR-318 §Decision 1 reuses RVF's canonical-format # discipline; the program's canonical receipt contract (ruflo ADR-322C, # adopted via ADR-312) specifies SHA-256 — no new hash is introduced. rvf-types = { version = "0.2", path = "../rvf/rvf-types", default-features = false } -[dev-dependencies] -serde_json = { workspace = true } +[[bin]] +name = "staged-workspace-adapter" +path = "src/bin/staged-workspace-adapter.rs" diff --git a/crates/ruvector-staged-workspace/src/adapter.rs b/crates/ruvector-staged-workspace/src/adapter.rs new file mode 100644 index 000000000..94b283cbe --- /dev/null +++ b/crates/ruvector-staged-workspace/src/adapter.rs @@ -0,0 +1,322 @@ +//! Subprocess adapter protocol for cross-language consumers (WP15's +//! TypeScript `RvfWorkspaceBinder` in `crates/ruvector-sota-bench/harness`). +//! +//! One JSON request on stdin, one JSON response on stdout (see the +//! `staged-workspace-adapter` bin). The **exit code** carries the +//! fail-closed semantics, and the `error` discriminator is a stable, +//! machine-readable contract: +//! +//! | Exit | Meaning | `error` values | +//! |------|---------|----------------| +//! | 0 | operation succeeded | — | +//! | 1 | **integrity rejection** (ADR-318 hard rejection) | `stale-view`, `binding-mismatch`, `unknown-artifact`, `anchor-rejected` | +//! | 2 | **adapter malfunction** (bad input, missing/corrupt state file, IO) | `adapter-error` | +//! +//! A consumer maps exit 1 to "detected compromise" and must treat exit 2 +//! (or a crash — no JSON at all) as a loud harness error, never as a +//! silently scored detection. The discriminator strings above are frozen; +//! new failure modes get new strings, existing ones do not change. +//! +//! State is persisted between invocations as a [`WorkspaceSnapshot`] JSON +//! file passed in the request's `state` field. + +use serde::{Deserialize, Serialize}; + +use crate::anchor::NoopAnchor; +use crate::artifact::{ArtifactRef, ContentHash}; +use crate::workspace::{WorkspaceError, WorkspaceSnapshot, WorkspaceState}; + +/// Stable error discriminators (see module docs; frozen contract). +pub mod error_code { + /// View bound to a hash that is no longer the live head. + pub const STALE_VIEW: &str = "stale-view"; + /// Hash matches the head but the revision id does not (forged binding). + pub const BINDING_MISMATCH: &str = "binding-mismatch"; + /// No such artifact lineage in the workspace state. + pub const UNKNOWN_ARTIFACT: &str = "unknown-artifact"; + /// The ADR-312 anchor refused the transition (commit aborted). + pub const ANCHOR_REJECTED: &str = "anchor-rejected"; + /// Adapter malfunction: malformed request, bad hex/base64, missing or + /// corrupt state file, IO failure. NOT an integrity signal. + pub const ADAPTER_ERROR: &str = "adapter-error"; +} + +/// One artifact to commit: lineage id plus standard base64 (RFC 4648, with +/// padding) of its content bytes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitArtifact { + /// Lineage identity (e.g. a workspace-relative path). + pub artifact_id: String, + /// Standard base64 of the artifact's content bytes. + pub content_b64: String, +} + +/// A request to the adapter (tagged by `op`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "kebab-case")] +pub enum AdapterRequest { + /// Commit one or more artifacts, then report per-artifact refs and the + /// whole-workspace hash. + Commit { + /// Actor recorded on the transition records. + actor_id: String, + /// Artifacts to commit (applied in the order given). + artifacts: Vec, + /// Path to the snapshot JSON file (created if absent, updated on + /// success). + state: String, + }, + /// Validate a bound (artifact_id, content_hash, revision_id) reference + /// against the live head in the state file. + Validate { + /// Lineage the reference points at. + artifact_id: String, + /// 64-char hex of the bound content hash. + content_hash: String, + /// The bound revision id. + revision_id: u64, + /// Path to the snapshot JSON file (must exist — a missing state + /// file is an adapter error, not an integrity rejection). + state: String, + }, +} + +/// A version reference in responses (hex-rendered hash). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RefJson { + /// Lineage identity. + pub artifact_id: String, + /// 64-char lowercase hex of the content hash. + pub content_hash: String, + /// Revision id. + pub revision_id: u64, +} + +impl From<&ArtifactRef> for RefJson { + fn from(r: &ArtifactRef) -> Self { + Self { + artifact_id: r.artifact_id.clone(), + content_hash: r.content_hash.to_hex(), + revision_id: r.revision_id, + } + } +} + +/// Adapter response. `ok: true` ⇔ exit code 0. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AdapterResponse { + /// Successful commit: per-artifact refs plus the workspace hash + /// (SHA-256 over all sorted lineage heads). + CommitOk { + ok: bool, + artifacts: Vec, + workspace_hash: String, + }, + /// Successful validate: the reference is current. + ValidateOk { ok: bool }, + /// Rejection or malfunction; `error` is a frozen [`error_code`] string. + Err { + ok: bool, + error: String, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bound: Option, + #[serde(skip_serializing_if = "Option::is_none")] + head: Option, + }, +} + +/// Outcome of handling a request: the response plus the process exit code +/// the bin must use (0 ok / 1 integrity rejection / 2 adapter error). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdapterOutcome { + /// JSON-serializable response body. + pub response: AdapterResponse, + /// 0, 1, or 2 per the module-level table. + pub exit_code: i32, +} + +fn adapter_error(detail: String) -> AdapterOutcome { + AdapterOutcome { + response: AdapterResponse::Err { + ok: false, + error: error_code::ADAPTER_ERROR.to_string(), + detail: Some(detail), + bound: None, + head: None, + }, + exit_code: 2, + } +} + +fn integrity_rejection(err: &WorkspaceError) -> AdapterOutcome { + let (code, bound, head) = match err { + WorkspaceError::StaleView(c) => ( + error_code::STALE_VIEW, + Some(RefJson::from(&c.bound)), + Some(RefJson::from(&c.head)), + ), + WorkspaceError::BindingMismatch(c) => ( + error_code::BINDING_MISMATCH, + Some(RefJson::from(&c.bound)), + Some(RefJson::from(&c.head)), + ), + WorkspaceError::UnknownArtifact(_) => (error_code::UNKNOWN_ARTIFACT, None, None), + WorkspaceError::AnchorRejected(_) => (error_code::ANCHOR_REJECTED, None, None), + }; + AdapterOutcome { + response: AdapterResponse::Err { + ok: false, + error: code.to_string(), + detail: Some(err.to_string()), + bound, + head, + }, + exit_code: 1, + } +} + +/// Decode standard base64 (RFC 4648 §4, `+/` alphabet, `=` padding). +/// Whitespace is not accepted. Returns `None` on any malformed input. +pub fn base64_decode(input: &str) -> Option> { + fn val(b: u8) -> Option { + match b { + b'A'..=b'Z' => Some((b - b'A') as u32), + b'a'..=b'z' => Some((b - b'a') as u32 + 26), + b'0'..=b'9' => Some((b - b'0') as u32 + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let bytes = input.as_bytes(); + if !bytes.len().is_multiple_of(4) { + return None; + } + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + for (i, chunk) in bytes.chunks_exact(4).enumerate() { + let last = i == bytes.len() / 4 - 1; + let pad = if last { + chunk.iter().rev().take_while(|&&b| b == b'=').count() + } else { + 0 + }; + if pad > 2 || chunk[..4 - pad].contains(&b'=') { + return None; + } + let mut acc: u32 = 0; + for &b in &chunk[..4 - pad] { + acc = (acc << 6) | val(b)?; + } + acc <<= 6 * pad as u32; + let full = [(acc >> 16) as u8, (acc >> 8) as u8, acc as u8]; + out.extend_from_slice(&full[..3 - pad]); + } + Some(out) +} + +fn load_state(path: &str, must_exist: bool) -> Result> { + match std::fs::read_to_string(path) { + Ok(text) => serde_json::from_str(&text) + .map_err(|e| Box::new(adapter_error(format!("corrupt state file {path}: {e}")))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound && !must_exist => { + Ok(WorkspaceSnapshot { + lineages: Default::default(), + transitions: Vec::new(), + next_sequence: 0, + next_view_id: 0, + }) + } + Err(e) => Err(Box::new(adapter_error(format!( + "cannot read state file {path}: {e}" + )))), + } +} + +/// Handle one parsed request against the state file. Pure of process +/// concerns (stdin/stdout/exit) so it is unit-testable; the bin wraps it. +pub fn handle(request: AdapterRequest) -> AdapterOutcome { + match request { + AdapterRequest::Commit { + actor_id, + artifacts, + state, + } => { + let snapshot = match load_state(&state, false) { + Ok(s) => s, + Err(out) => return *out, + }; + let mut ws = WorkspaceState::from_snapshot(NoopAnchor, snapshot); + let mut refs = Vec::with_capacity(artifacts.len()); + for a in &artifacts { + let content = match base64_decode(&a.content_b64) { + Some(c) => c, + None => { + return adapter_error(format!( + "invalid base64 content for artifact {:?}", + a.artifact_id + )) + } + }; + match ws.commit(&a.artifact_id, &content, &actor_id) { + Ok(r) => refs.push(RefJson::from(&r)), + Err(e) => return integrity_rejection(&e), + } + } + let serialized = match serde_json::to_string(&ws.snapshot()) { + Ok(s) => s, + Err(e) => return adapter_error(format!("cannot serialize state: {e}")), + }; + if let Err(e) = std::fs::write(&state, serialized) { + return adapter_error(format!("cannot write state file {state}: {e}")); + } + AdapterOutcome { + response: AdapterResponse::CommitOk { + ok: true, + artifacts: refs, + workspace_hash: ws.workspace_hash().to_hex(), + }, + exit_code: 0, + } + } + AdapterRequest::Validate { + artifact_id, + content_hash, + revision_id, + state, + } => { + let snapshot = match load_state(&state, true) { + Ok(s) => s, + Err(out) => return *out, + }; + let hash = match ContentHash::from_hex(&content_hash) { + Some(h) => h, + None => return adapter_error(format!("invalid content_hash hex {content_hash:?}")), + }; + let ws = WorkspaceState::from_snapshot(NoopAnchor, snapshot); + let bound = ArtifactRef { + artifact_id, + content_hash: hash, + revision_id, + }; + match ws.validate_ref(&bound) { + Ok(()) => AdapterOutcome { + response: AdapterResponse::ValidateOk { ok: true }, + exit_code: 0, + }, + Err(e) => integrity_rejection(&e), + } + } + } +} + +/// Parse a raw request string and handle it (malformed JSON is an adapter +/// error, exit 2 — never an integrity signal). +pub fn handle_raw(input: &str) -> AdapterOutcome { + match serde_json::from_str::(input) { + Ok(req) => handle(req), + Err(e) => adapter_error(format!("malformed request: {e}")), + } +} diff --git a/crates/ruvector-staged-workspace/src/artifact.rs b/crates/ruvector-staged-workspace/src/artifact.rs index a254d26e2..aed671fc3 100644 --- a/crates/ruvector-staged-workspace/src/artifact.rs +++ b/crates/ruvector-staged-workspace/src/artifact.rs @@ -19,6 +19,22 @@ impl ContentHash { Self(rvf_types::sha256::sha256(content)) } + /// Parse a 64-char hex rendering back into a hash (case-insensitive). + /// Returns `None` for any other length or non-hex characters. + pub fn from_hex(hex: &str) -> Option { + let bytes = hex.as_bytes(); + if bytes.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (i, chunk) in bytes.chunks_exact(2).enumerate() { + let hi = (chunk[0] as char).to_digit(16)?; + let lo = (chunk[1] as char).to_digit(16)?; + out[i] = ((hi << 4) | lo) as u8; + } + Some(Self(out)) + } + /// Lowercase hex rendering (64 chars). pub fn to_hex(&self) -> String { let mut s = String::with_capacity(64); diff --git a/crates/ruvector-staged-workspace/src/bin/staged-workspace-adapter.rs b/crates/ruvector-staged-workspace/src/bin/staged-workspace-adapter.rs new file mode 100644 index 000000000..ad26beb69 --- /dev/null +++ b/crates/ruvector-staged-workspace/src/bin/staged-workspace-adapter.rs @@ -0,0 +1,31 @@ +//! `staged-workspace-adapter` — subprocess bridge for cross-language +//! consumers of the ADR-318 content-hash binding (see the crate's +//! `adapter` module for the frozen protocol and exit-code contract). +//! +//! Reads ONE JSON request from stdin, writes ONE JSON response to stdout, +//! and exits 0 (ok), 1 (integrity rejection), or 2 (adapter malfunction). + +use std::io::Read; + +fn main() { + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + // No JSON contractually required here, but emit the standard + // adapter-error shape anyway so consumers see a reason. + println!( + "{{\"ok\":false,\"error\":\"adapter-error\",\"detail\":\"cannot read stdin: {e}\"}}" + ); + std::process::exit(2); + } + let outcome = ruvector_staged_workspace::adapter::handle_raw(&input); + match serde_json::to_string(&outcome.response) { + Ok(json) => println!("{json}"), + Err(e) => { + println!( + "{{\"ok\":false,\"error\":\"adapter-error\",\"detail\":\"cannot serialize response: {e}\"}}" + ); + std::process::exit(2); + } + } + std::process::exit(outcome.exit_code); +} diff --git a/crates/ruvector-staged-workspace/src/lib.rs b/crates/ruvector-staged-workspace/src/lib.rs index c37fbfe96..a9d44ef1d 100644 --- a/crates/ruvector-staged-workspace/src/lib.rs +++ b/crates/ruvector-staged-workspace/src/lib.rs @@ -44,6 +44,7 @@ //! an internal OfficeQA-equivalent task set — a future benchmark deliverable, //! not part of this slice. +pub mod adapter; pub mod anchor; pub mod artifact; pub mod view; @@ -55,4 +56,6 @@ pub use anchor::{ }; pub use artifact::{ArtifactRef, ContentHash}; pub use view::{StagedView, ViewKind}; -pub use workspace::{ViewConflict, WorkspaceError, WorkspaceState}; +pub use workspace::{ + LineageSnapshot, ViewConflict, WorkspaceError, WorkspaceSnapshot, WorkspaceState, +}; diff --git a/crates/ruvector-staged-workspace/src/workspace.rs b/crates/ruvector-staged-workspace/src/workspace.rs index 3eace3406..ca545b771 100644 --- a/crates/ruvector-staged-workspace/src/workspace.rs +++ b/crates/ruvector-staged-workspace/src/workspace.rs @@ -1,9 +1,11 @@ //! Workspace state: artifact lineages, view staging, and the fail-closed //! staleness gate (ADR-318 §Decision 3). -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::time::{SystemTime, UNIX_EPOCH}; +use serde::{Deserialize, Serialize}; + use crate::anchor::{AnchorReceipt, EvidenceGrade, TransitionAnchor, WorkspaceTransitionRecord}; use crate::artifact::{ArtifactRef, ContentHash}; use crate::view::{StagedView, ViewKind}; @@ -205,24 +207,32 @@ impl WorkspaceState { /// equal the artifact's live head hash (and the revision ids must /// agree). Any mismatch is a hard rejection (ADR-318 §Decision 3). pub fn validate(&self, view: &StagedView) -> Result<(), WorkspaceError> { - let id = &view.artifact.artifact_id; + self.validate_ref(&view.artifact) + } + + /// Validate a bare [`ArtifactRef`] against the live head — the same + /// fail-closed check as [`validate`](Self::validate), for callers (like + /// the subprocess adapter or an external binder) that carry a version + /// reference without a full [`StagedView`]. + pub fn validate_ref(&self, bound: &ArtifactRef) -> Result<(), WorkspaceError> { + let id = &bound.artifact_id; let head = self .lineages .get(id) .map(|l| &l.head) .ok_or_else(|| WorkspaceError::UnknownArtifact(id.clone()))?; - let hash_ok = view.artifact.content_hash == head.content_hash; - let rev_ok = view.artifact.revision_id == head.revision_id; + let hash_ok = bound.content_hash == head.content_hash; + let rev_ok = bound.revision_id == head.revision_id; match (hash_ok, rev_ok) { (true, true) => Ok(()), (false, _) => Err(WorkspaceError::StaleView(Box::new(ViewConflict { artifact_id: id.clone(), - bound: view.artifact.clone(), + bound: bound.clone(), head: head.clone(), }))), (true, false) => Err(WorkspaceError::BindingMismatch(Box::new(ViewConflict { artifact_id: id.clone(), - bound: view.artifact.clone(), + bound: bound.clone(), head: head.clone(), }))), } @@ -255,6 +265,95 @@ impl WorkspaceState { pub fn history(&self, artifact_id: &str) -> Option<&[ContentHash]> { self.lineages.get(artifact_id).map(|l| l.history.as_slice()) } + + /// SHA-256 over all current lineage heads, in sorted `artifact_id` + /// order (each pair encoded as u64-LE id length, id bytes, 32 hash + /// bytes). One hash summarizing the whole workspace's bound state — + /// the single `contentHash` WP15's acceptance-test binder consumes. + pub fn workspace_hash(&self) -> ContentHash { + let mut ids: Vec<&String> = self.lineages.keys().collect(); + ids.sort(); + let mut bytes = Vec::new(); + for id in ids { + let head = &self.lineages[id].head; + bytes.extend_from_slice(&(id.len() as u64).to_le_bytes()); + bytes.extend_from_slice(id.as_bytes()); + bytes.extend_from_slice(&head.content_hash.0); + } + ContentHash::of(&bytes) + } + + /// Serializable snapshot of the full workspace state (lineages, + /// transition log with receipts, counters), for persistence across + /// adapter invocations. Restore with + /// [`from_snapshot`](Self::from_snapshot). + pub fn snapshot(&self) -> WorkspaceSnapshot { + WorkspaceSnapshot { + lineages: self + .lineages + .iter() + .map(|(id, l)| { + ( + id.clone(), + LineageSnapshot { + head: l.head.clone(), + history: l.history.clone(), + }, + ) + }) + .collect(), + transitions: self.transitions.clone(), + next_sequence: self.next_sequence, + next_view_id: self.next_view_id, + } + } + + /// Rebuild a workspace from a [`snapshot`](Self::snapshot), anchoring + /// future transitions through `anchor`. + pub fn from_snapshot(anchor: A, snapshot: WorkspaceSnapshot) -> Self { + Self { + anchor, + lineages: snapshot + .lineages + .into_iter() + .map(|(id, l)| { + ( + id, + ArtifactLineage { + head: l.head, + history: l.history, + }, + ) + }) + .collect(), + transitions: snapshot.transitions, + next_sequence: snapshot.next_sequence, + next_view_id: snapshot.next_view_id, + } + } +} + +/// Serializable form of one artifact lineage (see [`WorkspaceSnapshot`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LineageSnapshot { + /// The lineage's live head. + pub head: ArtifactRef, + /// Every committed hash, oldest first; last equals `head.content_hash`. + pub history: Vec, +} + +/// Serializable snapshot of a [`WorkspaceState`] (uses a `BTreeMap` so the +/// serialized form is deterministic). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceSnapshot { + /// All artifact lineages by id. + pub lineages: BTreeMap, + /// The (record, receipt) audit log, oldest first. + pub transitions: Vec<(WorkspaceTransitionRecord, AnchorReceipt)>, + /// Next transition sequence number. + pub next_sequence: u64, + /// Next staged-view id. + pub next_view_id: u64, } fn now_ns() -> u64 { diff --git a/crates/ruvector-staged-workspace/tests/adapter.rs b/crates/ruvector-staged-workspace/tests/adapter.rs new file mode 100644 index 000000000..d6ba10313 --- /dev/null +++ b/crates/ruvector-staged-workspace/tests/adapter.rs @@ -0,0 +1,222 @@ +//! Subprocess-adapter protocol tests: exit-code contract (0 ok / 1 +//! integrity rejection / 2 adapter malfunction) and frozen error +//! discriminators, exercised through `adapter::handle_raw` — the exact +//! function the `staged-workspace-adapter` bin wraps. + +use ruvector_staged_workspace::adapter::{ + base64_decode, error_code, handle_raw, AdapterOutcome, AdapterResponse, +}; +use ruvector_staged_workspace::ContentHash; + +/// Unique state-file path per test; removed on drop. +struct StateFile(std::path::PathBuf); + +impl StateFile { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "staged-workspace-adapter-test-{}-{name}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + Self(path) + } + fn path(&self) -> &str { + self.0.to_str().unwrap() + } +} + +impl Drop for StateFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +fn commit_request(state: &str, artifacts: &[(&str, &str)]) -> String { + let list: Vec = artifacts + .iter() + .map(|(id, b64)| format!("{{\"artifact_id\":\"{id}\",\"content_b64\":\"{b64}\"}}")) + .collect(); + format!( + "{{\"op\":\"commit\",\"actor_id\":\"harness\",\"artifacts\":[{}],\"state\":\"{state}\"}}", + list.join(",") + ) +} + +fn validate_request(state: &str, id: &str, hash_hex: &str, rev: u64) -> String { + format!( + "{{\"op\":\"validate\",\"artifact_id\":\"{id}\",\"content_hash\":\"{hash_hex}\",\"revision_id\":{rev},\"state\":\"{state}\"}}" + ) +} + +fn expect_err(outcome: &AdapterOutcome) -> (&str, Option<&str>, Option<&str>) { + match &outcome.response { + AdapterResponse::Err { + ok, + error, + bound, + head, + .. + } => { + assert!(!ok); + ( + error.as_str(), + bound.as_ref().map(|r| r.content_hash.as_str()), + head.as_ref().map(|r| r.content_hash.as_str()), + ) + } + other => panic!("expected error response, got {other:?}"), + } +} + +// "abc" and "abcd" in standard base64. +const ABC_B64: &str = "YWJj"; +const ABCD_B64: &str = "YWJjZA=="; + +#[test] +fn commit_then_validate_current_exits_zero() { + let state = StateFile::new("commit-validate-ok"); + let out = handle_raw(&commit_request(state.path(), &[("doc.md", ABC_B64)])); + assert_eq!(out.exit_code, 0); + let (hash_hex, ws_hash) = match &out.response { + AdapterResponse::CommitOk { + ok, + artifacts, + workspace_hash, + } => { + assert!(ok); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0].content_hash, ContentHash::of(b"abc").to_hex()); + assert_eq!(artifacts[0].revision_id, 1); + (artifacts[0].content_hash.clone(), workspace_hash.clone()) + } + other => panic!("expected CommitOk, got {other:?}"), + }; + // Workspace hash is recomputable from the documented formula: + // SHA-256 over sorted (u64-LE id length, id bytes, 32 hash bytes). + let mut expected = Vec::new(); + expected.extend_from_slice(&(b"doc.md".len() as u64).to_le_bytes()); + expected.extend_from_slice(b"doc.md"); + expected.extend_from_slice(&ContentHash::of(b"abc").0); + assert_eq!(ws_hash, ContentHash::of(&expected).to_hex()); + + let out = handle_raw(&validate_request(state.path(), "doc.md", &hash_hex, 1)); + assert_eq!(out.exit_code, 0); + assert_eq!(out.response, AdapterResponse::ValidateOk { ok: true }); +} + +#[test] +fn stale_reference_across_invocations_exits_one_with_stale_view() { + let state = StateFile::new("stale"); + // Invocation 1: commit v1. + let v1_hash = ContentHash::of(b"abc").to_hex(); + assert_eq!( + handle_raw(&commit_request(state.path(), &[("doc.md", ABC_B64)])).exit_code, + 0 + ); + // Invocation 2 (fresh handle over the same state file): commit v2. + assert_eq!( + handle_raw(&commit_request(state.path(), &[("doc.md", ABCD_B64)])).exit_code, + 0 + ); + // Invocation 3: the v1 reference is now stale — exit 1, discriminator + // "stale-view", with bound/head refs for the executor's report. + let out = handle_raw(&validate_request(state.path(), "doc.md", &v1_hash, 1)); + assert_eq!(out.exit_code, 1); + let (code, bound, head) = expect_err(&out); + assert_eq!(code, error_code::STALE_VIEW); + assert_eq!(bound, Some(v1_hash.as_str())); + assert_eq!(head, Some(ContentHash::of(b"abcd").to_hex().as_str())); +} + +#[test] +fn forged_revision_exits_one_with_binding_mismatch() { + let state = StateFile::new("forged-rev"); + handle_raw(&commit_request(state.path(), &[("doc.md", ABC_B64)])); + let out = handle_raw(&validate_request( + state.path(), + "doc.md", + &ContentHash::of(b"abc").to_hex(), + 99, + )); + assert_eq!(out.exit_code, 1); + assert_eq!(expect_err(&out).0, error_code::BINDING_MISMATCH); +} + +#[test] +fn unknown_artifact_exits_one_with_unknown_artifact() { + let state = StateFile::new("unknown"); + handle_raw(&commit_request(state.path(), &[("doc.md", ABC_B64)])); + let out = handle_raw(&validate_request( + state.path(), + "no/such/file", + &ContentHash::of(b"abc").to_hex(), + 1, + )); + assert_eq!(out.exit_code, 1); + assert_eq!(expect_err(&out).0, error_code::UNKNOWN_ARTIFACT); +} + +#[test] +fn malfunctions_exit_two_with_adapter_error_never_one() { + // Malformed request JSON. + let out = handle_raw("{not json"); + assert_eq!(out.exit_code, 2); + assert_eq!(expect_err(&out).0, error_code::ADAPTER_ERROR); + + // Bad base64 content. + let state = StateFile::new("bad-b64"); + let out = handle_raw(&commit_request(state.path(), &[("doc.md", "!!!!")])); + assert_eq!(out.exit_code, 2); + assert_eq!(expect_err(&out).0, error_code::ADAPTER_ERROR); + + // Missing state file on validate: setup malfunction, loud — must NOT + // be scored as a detected compromise. + let missing = StateFile::new("never-created"); + let out = handle_raw(&validate_request( + missing.path(), + "doc.md", + &ContentHash::of(b"abc").to_hex(), + 1, + )); + assert_eq!(out.exit_code, 2); + assert_eq!(expect_err(&out).0, error_code::ADAPTER_ERROR); + + // Bad hex in content_hash. + let state = StateFile::new("bad-hex"); + handle_raw(&commit_request(state.path(), &[("doc.md", ABC_B64)])); + let out = handle_raw(&validate_request(state.path(), "doc.md", "zz", 1)); + assert_eq!(out.exit_code, 2); + assert_eq!(expect_err(&out).0, error_code::ADAPTER_ERROR); +} + +#[test] +fn multi_artifact_commit_orders_workspace_hash_by_id() { + let a = StateFile::new("multi-a"); + let b = StateFile::new("multi-b"); + // Same artifacts, different commit order -> same workspace hash. + let out_ab = handle_raw(&commit_request( + a.path(), + &[("a.md", ABC_B64), ("b.md", ABCD_B64)], + )); + let out_ba = handle_raw(&commit_request( + b.path(), + &[("b.md", ABCD_B64), ("a.md", ABC_B64)], + )); + let hash = |o: &AdapterOutcome| match &o.response { + AdapterResponse::CommitOk { workspace_hash, .. } => workspace_hash.clone(), + other => panic!("expected CommitOk, got {other:?}"), + }; + assert_eq!(hash(&out_ab), hash(&out_ba)); +} + +#[test] +fn base64_decoder_matches_rfc_4648_vectors() { + assert_eq!(base64_decode(""), Some(vec![])); + assert_eq!(base64_decode("YQ=="), Some(b"a".to_vec())); + assert_eq!(base64_decode("YWI="), Some(b"ab".to_vec())); + assert_eq!(base64_decode("YWJj"), Some(b"abc".to_vec())); + assert_eq!(base64_decode("YWJjZA=="), Some(b"abcd".to_vec())); + assert_eq!(base64_decode("YWJj\n"), None); // whitespace rejected + assert_eq!(base64_decode("YQ=A"), None); // padding mid-chunk + assert_eq!(base64_decode("Y"), None); // bad length +}