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 <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X
This commit is contained in:
ruv 2026-08-20 09:26:21 -04:00
parent 2450bc79f8
commit 3d0914bd13
7 changed files with 706 additions and 9 deletions

View file

@ -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"

View file

@ -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<CommitArtifact>,
/// 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<RefJson>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bound: Option<RefJson>,
#[serde(skip_serializing_if = "Option::is_none")]
head: Option<RefJson>,
},
}
/// 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<Vec<u8>> {
fn val(b: u8) -> Option<u32> {
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<WorkspaceSnapshot, Box<AdapterOutcome>> {
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::<AdapterRequest>(input) {
Ok(req) => handle(req),
Err(e) => adapter_error(format!("malformed request: {e}")),
}
}

View file

@ -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<Self> {
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);

View file

@ -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);
}

View file

@ -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,
};

View file

@ -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<A: TransitionAnchor> WorkspaceState<A> {
/// 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<A: TransitionAnchor> WorkspaceState<A> {
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<ContentHash>,
}
/// 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<String, LineageSnapshot>,
/// 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 {

View file

@ -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<String> = 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
}