mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-08-21 14:44:03 +00:00
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:
parent
2450bc79f8
commit
3d0914bd13
7 changed files with 706 additions and 9 deletions
|
|
@ -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"
|
||||
|
|
|
|||
322
crates/ruvector-staged-workspace/src/adapter.rs
Normal file
322
crates/ruvector-staged-workspace/src/adapter.rs
Normal 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}")),
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
222
crates/ruvector-staged-workspace/tests/adapter.rs
Normal file
222
crates/ruvector-staged-workspace/tests/adapter.rs
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue