From 800495fd8ab98b1c88b72dd3385216db1676b6fb Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 10 Jul 2026 20:20:21 -0400 Subject: [PATCH 01/35] build: pin kdl for Rust 1.94.1 (Task 3) --- Cargo.lock | 42 ++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 2 +- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d95159c..c762f28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4213,13 +4213,13 @@ dependencies = [ [[package]] name = "kdl" -version = "6.7.1" +version = "6.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "082ddf81b2acd76fe04412655d17befedfff1837db772f8e74c38050d25ed670" +checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e" dependencies = [ "miette", - "num-traits", - "winnow 0.7.15", + "num", + "winnow 0.6.24", ] [[package]] @@ -4985,6 +4985,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.6", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.3.3" @@ -5068,6 +5082,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.6", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -10289,6 +10314,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 69b02d9..29252c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,7 +97,7 @@ hex = "0.4.3" postcard = { version = "1.1.3", features = ["alloc"] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0" } -kdl = { version = "6.5", default-features = false } +kdl = { version = "=6.5.0", default-features = false } pest = "2.7" pest_derive = "2.7" serde-wasm-bindgen = { version = "0.6" } From 51c3e0eae176c7dd7c2298e905d7559bd15d29c3 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 10 Jul 2026 20:37:06 -0400 Subject: [PATCH 02/35] build: add Task 3 bridge RED boundary --- .github/scripts/ci_matrix.json | 5 + .github/workflows/release-bridge.yml | 106 ++++++++++++++++++++ Cargo.lock | 8 ++ Cargo.toml | 1 + PATCHES.md | 23 +++++ bridge/Cargo.toml | 15 +++ bridge/src/main.rs | 7 ++ bridge/src/protocol.rs | 106 ++++++++++++++++++++ bridge/src/runtime.rs | 9 ++ bridge/src/schema.rs | 108 +++++++++++++++++++++ bridge/tests/runtime.rs | 139 +++++++++++++++++++++++++++ bridge/tests/stdio.rs | 129 +++++++++++++++++++++++++ 12 files changed, 656 insertions(+) create mode 100644 .github/workflows/release-bridge.yml create mode 100644 PATCHES.md create mode 100644 bridge/Cargo.toml create mode 100644 bridge/src/main.rs create mode 100644 bridge/src/protocol.rs create mode 100644 bridge/src/runtime.rs create mode 100644 bridge/src/schema.rs create mode 100644 bridge/tests/runtime.rs create mode 100644 bridge/tests/stdio.rs diff --git a/.github/scripts/ci_matrix.json b/.github/scripts/ci_matrix.json index 034e148..15b45ac 100644 --- a/.github/scripts/ci_matrix.json +++ b/.github/scripts/ci_matrix.json @@ -9,6 +9,11 @@ "packages": "-p encrypted-spaces-backend -p encrypted-spaces-backend-server", "filter": "" }, + { + "group": "bridge", + "packages": "-p encrypted-spaces-bridge", + "filter": "" + }, { "group": "sdk", "packages": "-p encrypted-spaces-sdk -p encrypted-spaces-sdk-fuzzer", diff --git a/.github/workflows/release-bridge.yml b/.github/workflows/release-bridge.yml new file mode 100644 index 0000000..520d54b --- /dev/null +++ b/.github/workflows/release-bridge.yml @@ -0,0 +1,106 @@ +name: Bridge Release Contract + +# Contract-only RED workflow. It validates the future public release shape but +# does not publish releases and does not claim release readiness yet. +# release_ready: false +on: + workflow_dispatch: {} + +permissions: + contents: read + +env: + RELEASE_READY: false + UPSTREAM_COMMIT: 4cda0ae87698135aa672990e6e68cf7873847426 + RUST_VERSION: 1.94.1 + RISC0_SKIP_BUILD: 1 + PROVENANCE: required-before-publish + +jobs: + contract: + runs-on: ubuntu-24.04 + steps: + - name: Checkout pinned upstream descendant + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate public release contract + shell: bash + run: | + set -euo pipefail + test "$RELEASE_READY" = false + git merge-base --is-ancestor "$UPSTREAM_COMMIT" "$GITHUB_SHA" + grep -Fq 'kdl = { version = "=6.5.0"' Cargo.toml + grep -Fq 'version = "6.5.0"' Cargo.lock + test -f LICENSE + # NOTICE is a deliberate release gate until the legal attribution + # file is supplied; this workflow must stay RED before publishing. + test -f NOTICE + test -f PATCHES.md + grep -Fq "$UPSTREAM_COMMIT" PATCHES.md + grep -Fq '800495f' PATCHES.md + grep -Fq "Rust $RUST_VERSION" PATCHES.md + + - name: Install pinned Rust toolchain and expose matching tools + shell: bash + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal + RUSTC="$(rustup which --toolchain "$RUST_VERSION" rustc)" + RUSTDOC="$(rustup which --toolchain "$RUST_VERSION" rustdoc)" + echo "RUSTUP_TOOLCHAIN=$RUST_VERSION" >> "$GITHUB_ENV" + echo "RUSTC=$RUSTC" >> "$GITHUB_ENV" + echo "RUSTDOC=$RUSTDOC" >> "$GITHUB_ENV" + echo "$(dirname "$RUSTC")" >> "$GITHUB_PATH" + "$RUSTC" --version + "$RUSTDOC" --version + + - name: Validate build and publication markers + shell: bash + run: | + set -euo pipefail + grep -Fq 'sha256sum' .github/workflows/release-bridge.yml + grep -Fq 'shasum -a 256' .github/workflows/release-bridge.yml + grep -Fq 'provenance' .github/workflows/release-bridge.yml + grep -Fq 'LICENSE' .github/workflows/release-bridge.yml + grep -Fq 'NOTICE' .github/workflows/release-bridge.yml + grep -Fq 'does not publish' .github/workflows/release-bridge.yml + + assets: + needs: contract + if: ${{ false }} + strategy: + matrix: + include: + - component: backend + os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - component: backend + os: ubuntu-24.04 + target: aarch64-unknown-linux-gnu + - component: backend + os: macos-13 + target: x86_64-apple-darwin + - component: backend + os: macos-14 + target: aarch64-apple-darwin + - component: bridge + os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - component: bridge + os: ubuntu-24.04 + target: aarch64-unknown-linux-gnu + - component: bridge + os: macos-13 + target: x86_64-apple-darwin + - component: bridge + os: macos-14 + target: aarch64-apple-darwin + runs-on: ${{ matrix.os }} + steps: + - name: Future asset contract + run: | + echo "${{ matrix.component }}-${{ matrix.target }}" + echo "checksums: sha256sum / shasum -a 256" + echo "provenance: $PROVENANCE" diff --git a/Cargo.lock b/Cargo.lock index c762f28..097642f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2243,6 +2243,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "encrypted-spaces-bridge" +version = "0.0.1" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "encrypted-spaces-changelog-core" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 29252c2..c7bd274 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "backend/acl-types", "backend/server", "backend/storage-encoding", + "bridge", "demos/tauri/src-tauri", "demos/tauri/test-harness", "crypto", diff --git a/PATCHES.md b/PATCHES.md new file mode 100644 index 0000000..b39eee2 --- /dev/null +++ b/PATCHES.md @@ -0,0 +1,23 @@ +# Task 3 Patch Ledger + +This ledger records the intentional Task 3 delta from upstream commit +`4cda0ae87698135aa672990e6e68cf7873847426`. + +## Applied + +- `800495f` pins `kdl` to `=6.5.0` and repairs the matching lock entries for + Rust `1.94.1`. +- Adds `encrypted-spaces-bridge`, a typed versioned JSONL stdio boundary with + bounded frames and secret-redacted protocol errors. +- Adds RED tests for the bridge lifecycle, data, membership, cancellation, + framing, process exit, and release contract surfaces. +- Adds a public-repository release contract workflow. It does not publish and + `RELEASE_READY=false` is intentional. + +## Explicitly deferred to Task 4 + +- All SDK/backend runtime behavior. Every declared operation currently returns + `NOT_IMPLEMENTED`. +- Real Linux/macOS amd64/arm64 builds, checksums, and provenance attestations. +- The Apache `NOTICE` attribution file. The release workflow keeps this as a + failing legal-release gate until supplied. diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml new file mode 100644 index 0000000..54e3ff1 --- /dev/null +++ b/bridge/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "encrypted-spaces-bridge" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Bounded JSONL runtime bridge boundary for encrypted spaces" + +[[bin]] +name = "encrypted-spaces-bridge" +path = "src/main.rs" + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/bridge/src/main.rs b/bridge/src/main.rs new file mode 100644 index 0000000..8e98028 --- /dev/null +++ b/bridge/src/main.rs @@ -0,0 +1,7 @@ +mod protocol; +mod runtime; +mod schema; + +fn main() -> std::io::Result<()> { + protocol::run(std::io::stdin().lock(), std::io::stdout().lock()) +} diff --git a/bridge/src/protocol.rs b/bridge/src/protocol.rs new file mode 100644 index 0000000..f941173 --- /dev/null +++ b/bridge/src/protocol.rs @@ -0,0 +1,106 @@ +use crate::runtime; +use crate::schema::{Request, PROTOCOL_VERSION}; +use serde::Serialize; +use std::io::{self, BufRead, Read, Write}; + +pub const MAX_FRAME_BYTES: usize = 64 * 1024; + +#[derive(Debug, Serialize)] +pub struct Response { + pub version: u16, + pub request_id: Option, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct ErrorBody { + pub code: &'static str, + pub message: &'static str, +} + +impl Response { + pub fn error(request_id: Option, code: &'static str, message: &'static str) -> Self { + Self { + version: PROTOCOL_VERSION, + request_id, + ok: false, + error: Some(ErrorBody { code, message }), + } + } + + pub fn not_implemented(request_id: String) -> Self { + Self::error( + Some(request_id), + "NOT_IMPLEMENTED", + "runtime operation is not implemented", + ) + } +} + +enum FrameError { + TooLarge, + Io(io::Error), +} + +pub fn run(reader: R, mut writer: W) -> io::Result<()> { + let mut reader = io::BufReader::new(reader); + loop { + let frame = match read_frame(&mut reader) { + Ok(Some(frame)) => frame, + Ok(None) => return Ok(()), + Err(FrameError::TooLarge) => { + write_response( + &mut writer, + Response::error(None, "FRAME_TOO_LARGE", "JSONL frame exceeds maximum size"), + )?; + continue; + } + Err(FrameError::Io(error)) => return Err(error), + }; + + let response = match serde_json::from_slice::(&frame) { + Ok(request) => match request.validate() { + Ok(()) => runtime::dispatch(request), + Err(_) => Response::error(None, "INVALID_REQUEST", "invalid bridge request"), + }, + Err(_) => Response::error(None, "INVALID_JSON", "malformed JSONL frame"), + }; + write_response(&mut writer, response)?; + } +} + +fn read_frame(reader: &mut R) -> Result>, FrameError> { + let mut frame = Vec::with_capacity(1024); + let mut byte = [0u8; 1]; + loop { + let read = reader.read(&mut byte).map_err(FrameError::Io)?; + if read == 0 { + return if frame.is_empty() { + Ok(None) + } else { + Ok(Some(frame)) + }; + } + if byte[0] == b'\n' { + return Ok(Some(frame)); + } + if frame.len() == MAX_FRAME_BYTES { + loop { + let read = reader.read(&mut byte).map_err(FrameError::Io)?; + if read == 0 || byte[0] == b'\n' { + break; + } + } + return Err(FrameError::TooLarge); + } + frame.push(byte[0]); + } +} + +fn write_response(writer: &mut W, response: Response) -> io::Result<()> { + serde_json::to_writer(&mut *writer, &response).map_err(io::Error::other)?; + writer.write_all(b"\n")?; + writer.flush() +} diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs new file mode 100644 index 0000000..c19410f --- /dev/null +++ b/bridge/src/runtime.rs @@ -0,0 +1,9 @@ +use crate::protocol::Response; +use crate::schema::Request; + +/// RED boundary only: Task 4 owns the real SDK/backend runtime integration. +pub fn dispatch(request: Request) -> Response { + let _operation_name = request.operation.name(); + let _ = request.payload; + Response::not_implemented(request.request_id) +} diff --git a/bridge/src/schema.rs b/bridge/src/schema.rs new file mode 100644 index 0000000..7a21987 --- /dev/null +++ b/bridge/src/schema.rs @@ -0,0 +1,108 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const PROTOCOL_VERSION: u16 = 1; +pub const MAX_REQUEST_ID_BYTES: usize = 256; +pub const MAX_ACTOR_ID_BYTES: usize = 256; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Request { + pub version: u16, + pub request_id: String, + pub actor_id: String, + pub operation: Operation, + #[serde(default)] + pub payload: Value, +} + +impl Request { + pub fn validate(&self) -> Result<(), &'static str> { + if self.version != PROTOCOL_VERSION { + return Err("unsupported protocol version"); + } + if self.request_id.is_empty() || self.request_id.len() > MAX_REQUEST_ID_BYTES { + return Err("request id exceeds maximum size"); + } + if self.actor_id.is_empty() || self.actor_id.len() > MAX_ACTOR_ID_BYTES { + return Err("actor id exceeds maximum size"); + } + Ok(()) + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Operation { + Hello, + Version, + Cancel, + #[serde(alias = "space.create")] + Create, + #[serde(alias = "space.join")] + Join, + #[serde(alias = "space.snapshot")] + Snapshot, + #[serde(alias = "space.restore")] + Restore, + #[serde(alias = "space.sync")] + Sync, + #[serde(rename = "table.insert")] + TableInsert, + #[serde(rename = "table.select")] + TableSelect, + #[serde(rename = "list.create")] + ListCreate, + #[serde(rename = "list.append")] + ListAppend, + #[serde(rename = "list.read")] + ListRead, + #[serde(rename = "text.create")] + TextCreate, + #[serde(rename = "text.edit")] + TextEdit, + #[serde(rename = "text.read")] + TextRead, + #[serde(rename = "file.put")] + FilePut, + #[serde(rename = "file.get")] + FileGet, + #[serde(rename = "member.invite")] + MemberInvite, + #[serde(rename = "member.join")] + MemberJoin, + #[serde(rename = "member.remove")] + MemberRemove, + Close, + Shutdown, +} + +impl Operation { + pub const fn name(&self) -> &'static str { + match self { + Self::Hello => "hello", + Self::Version => "version", + Self::Cancel => "cancel", + Self::Create => "create", + Self::Join => "join", + Self::Snapshot => "snapshot", + Self::Restore => "restore", + Self::Sync => "sync", + Self::TableInsert => "table.insert", + Self::TableSelect => "table.select", + Self::ListCreate => "list.create", + Self::ListAppend => "list.append", + Self::ListRead => "list.read", + Self::TextCreate => "text.create", + Self::TextEdit => "text.edit", + Self::TextRead => "text.read", + Self::FilePut => "file.put", + Self::FileGet => "file.get", + Self::MemberInvite => "member.invite", + Self::MemberJoin => "member.join", + Self::MemberRemove => "member.remove", + Self::Close => "close", + Self::Shutdown => "shutdown", + } + } +} diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs new file mode 100644 index 0000000..a505735 --- /dev/null +++ b/bridge/tests/runtime.rs @@ -0,0 +1,139 @@ +use serde_json::{json, Value}; +use std::io::Write; +use std::process::{Command, Stdio}; + +fn invoke(operation: &str, actor: &str, payload: Value) -> Value { + let frame = serde_json::to_string(&json!({ + "version": 1, + "request_id": format!("runtime-{operation}"), + "actor_id": actor, + "operation": operation, + "payload": payload, + })) + .expect("request JSON") + + "\n"; + let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn bridge"); + child + .stdin + .take() + .expect("bridge stdin") + .write_all(frame.as_bytes()) + .expect("write bridge frame"); + let output = child.wait_with_output().expect("wait for bridge"); + assert!(output.status.success(), "bridge exited: {output:?}"); + serde_json::from_slice( + output + .stdout + .split(|byte| *byte == b'\n') + .next() + .expect("response"), + ) + .expect("bridge response JSON") +} + +fn assert_future_success(response: &Value, operation: &str) { + assert_eq!(response["version"], 1, "{operation} response version"); + assert_eq!(response["ok"], true, "{operation} is still RED: {response}"); +} + +fn red_operation(operation: &str) { + let actor = format!("actor-{operation}"); + let response = invoke( + operation, + &actor, + json!({"key": format!("key-{operation}"), "value": format!("value-{operation}")}), + ); + assert_future_success(&response, operation); +} + +#[test] +fn runtime_create_join_snapshot_restore_sync_are_red() { + for operation in ["create", "join", "snapshot", "restore", "sync"] { + red_operation(operation); + } +} + +#[test] +fn runtime_table_insert_select_are_red() { + for operation in ["table.insert", "table.select"] { + red_operation(operation); + } +} + +#[test] +fn runtime_list_create_append_read_are_red() { + for operation in ["list.create", "list.append", "list.read"] { + red_operation(operation); + } +} + +#[test] +fn runtime_text_create_edit_read_are_red() { + for operation in ["text.create", "text.edit", "text.read"] { + red_operation(operation); + } +} + +#[test] +fn runtime_file_put_get_are_red() { + for operation in ["file.put", "file.get"] { + red_operation(operation); + } +} + +#[test] +fn runtime_member_invite_join_remove_are_red() { + for operation in ["member.invite", "member.join", "member.remove"] { + red_operation(operation); + } +} + +#[test] +fn runtime_release_contract_is_present_but_not_ready() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let workflow = std::fs::read_to_string(root.join(".github/workflows/release-bridge.yml")) + .expect("release contract workflow"); + let matrix = + std::fs::read_to_string(root.join(".github/scripts/ci_matrix.json")).expect("CI matrix"); + let patches = std::fs::read_to_string(root.join("PATCHES.md")).expect("PATCHES ledger"); + let cargo = std::fs::read_to_string(root.join("Cargo.toml")).expect("workspace manifest"); + let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("workspace lockfile"); + + for asset in ["backend", "bridge"] { + assert!(workflow.contains(asset), "workflow omits {asset} asset"); + } + for target in [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + ] { + assert!(workflow.contains(target), "workflow omits {target}"); + } + for marker in [ + "sha256sum", + "provenance", + "LICENSE", + "NOTICE", + "4cda0ae", + "1.94.1", + "RISC0_SKIP_BUILD: 1", + ] { + assert!( + workflow.contains(marker), + "workflow omits release marker {marker}" + ); + } + assert!(workflow.contains("release_ready: false")); + assert!(workflow.contains("does not publish")); + assert!(matrix.contains("encrypted-spaces-bridge")); + assert!(cargo.contains("kdl = { version = \"=6.5.0\"")); + assert!(lock.contains("name = \"kdl\"\nversion = \"6.5.0\"")); + assert!(patches.contains("4cda0ae")); + assert!(patches.contains("800495f")); + assert!(patches.contains("NOT_IMPLEMENTED")); +} diff --git a/bridge/tests/stdio.rs b/bridge/tests/stdio.rs new file mode 100644 index 0000000..45a3491 --- /dev/null +++ b/bridge/tests/stdio.rs @@ -0,0 +1,129 @@ +use serde_json::{json, Value}; +use std::io::Write; +use std::process::{Command, Stdio}; + +const MAX_FRAME_BYTES: usize = 64 * 1024; + +fn invoke(frame: &str) -> Vec { + let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn bridge"); + child + .stdin + .take() + .expect("bridge stdin") + .write_all(frame.as_bytes()) + .expect("write bridge frame"); + let output = child.wait_with_output().expect("wait for bridge"); + assert!(output.status.success(), "bridge exited: {output:?}"); + String::from_utf8(output.stdout) + .expect("bridge stdout is UTF-8") + .lines() + .map(|line| serde_json::from_str(line).expect("bridge emits JSONL")) + .collect() +} + +fn request(operation: &str, actor: &str, payload: Value) -> String { + serde_json::to_string(&json!({ + "version": 1, + "request_id": format!("request-{operation}"), + "actor_id": actor, + "operation": operation, + "payload": payload, + })) + .expect("request JSON") + + "\n" +} + +fn actor(operation: &str) -> String { + format!("actor-{operation}") +} + +fn assert_future_success(response: &Value, operation: &str) { + assert_eq!(response["version"], 1, "{operation} response version"); + assert_eq!(response["ok"], true, "{operation} is still RED: {response}"); +} + +#[test] +fn protocol_hello_is_red() { + let actor = actor("hello"); + let response = &invoke(&request("hello", &actor, json!({})))[0]; + assert_future_success(response, "hello"); +} + +#[test] +fn protocol_version_is_red() { + let actor = actor("version"); + let response = &invoke(&request("version", &actor, json!({})))[0]; + assert_future_success(response, "version"); +} + +#[test] +fn protocol_malformed_frame_has_stable_error() { + let response = &invoke("not-json\n")[0]; + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "INVALID_JSON"); + assert_eq!(response["error"]["message"], "malformed JSONL frame"); +} + +#[test] +fn protocol_oversized_frame_has_stable_error() { + let frame = format!("{}\n", "x".repeat(MAX_FRAME_BYTES + 1)); + let response = &invoke(&frame)[0]; + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "FRAME_TOO_LARGE"); + assert_eq!( + response["error"]["message"], + "JSONL frame exceeds maximum size" + ); +} + +#[test] +fn protocol_cancellation_is_red() { + let actor = actor("cancel"); + let response = &invoke(&request( + "cancel", + &actor, + json!({"request_id": "request-1"}), + ))[0]; + assert_future_success(response, "cancel"); +} + +#[test] +fn protocol_errors_redact_secret_material() { + let secret = "request-secret-value"; + let actor = actor("secret-check"); + let response = &invoke(&request( + "table.insert", + &actor, + json!({"value": "value-parametric", "secret": secret}), + ))[0]; + let serialized = response.to_string(); + assert!( + !serialized.contains(secret), + "secret leaked in response: {serialized}" + ); + assert_eq!(response["error"]["code"], "NOT_IMPLEMENTED"); +} + +#[test] +fn protocol_close_is_red() { + let actor = actor("close"); + let response = &invoke(&request("close", &actor, json!({})))[0]; + assert_future_success(response, "close"); +} + +#[test] +fn protocol_shutdown_is_red() { + let actor = actor("shutdown"); + let response = &invoke(&request("shutdown", &actor, json!({})))[0]; + assert_future_success(response, "shutdown"); +} + +#[test] +fn protocol_process_exits_after_stdin_eof() { + let responses = invoke(""); + assert!(responses.is_empty(), "EOF should not create a response"); +} From a2f7266525bdbc6156bc57b762225cf97435bbf7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 02:11:13 -0400 Subject: [PATCH 03/35] fix: align bridge review contract --- .github/workflows/release-bridge.yml | 61 +++++++++++++++++++------- PATCHES.md | 10 ++--- bridge/src/runtime.rs | 2 +- bridge/src/schema.rs | 20 ++++----- bridge/tests/runtime.rs | 64 ++++++++++++++++++++++++---- bridge/tests/stdio.rs | 13 ++++++ 6 files changed, 129 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release-bridge.yml b/.github/workflows/release-bridge.yml index 520d54b..78350a1 100644 --- a/.github/workflows/release-bridge.yml +++ b/.github/workflows/release-bridge.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout pinned upstream descendant - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: fetch-depth: 0 @@ -53,8 +53,8 @@ jobs: echo "RUSTC=$RUSTC" >> "$GITHUB_ENV" echo "RUSTDOC=$RUSTDOC" >> "$GITHUB_ENV" echo "$(dirname "$RUSTC")" >> "$GITHUB_PATH" - "$RUSTC" --version - "$RUSTDOC" --version + "$RUSTC" --version | grep -Fq " $RUST_VERSION " + "$RUSTDOC" --version | grep -Fq " $RUST_VERSION " - name: Validate build and publication markers shell: bash @@ -69,38 +69,67 @@ jobs: assets: needs: contract - if: ${{ false }} strategy: matrix: include: - component: backend os: ubuntu-24.04 - target: x86_64-unknown-linux-gnu + target: linux-amd64 + archive: encrypted-spaces-backend-linux-amd64.tar.gz - component: backend os: ubuntu-24.04 - target: aarch64-unknown-linux-gnu + target: linux-arm64 + archive: encrypted-spaces-backend-linux-arm64.tar.gz - component: backend os: macos-13 - target: x86_64-apple-darwin + target: macos-amd64 + archive: encrypted-spaces-backend-macos-amd64.tar.gz - component: backend os: macos-14 - target: aarch64-apple-darwin + target: macos-arm64 + archive: encrypted-spaces-backend-macos-arm64.tar.gz - component: bridge os: ubuntu-24.04 - target: x86_64-unknown-linux-gnu + target: linux-amd64 + archive: encrypted-spaces-bridge-linux-amd64.tar.gz - component: bridge os: ubuntu-24.04 - target: aarch64-unknown-linux-gnu + target: linux-arm64 + archive: encrypted-spaces-bridge-linux-arm64.tar.gz - component: bridge os: macos-13 - target: x86_64-apple-darwin + target: macos-amd64 + archive: encrypted-spaces-bridge-macos-amd64.tar.gz - component: bridge os: macos-14 - target: aarch64-apple-darwin + target: macos-arm64 + archive: encrypted-spaces-bridge-macos-arm64.tar.gz runs-on: ${{ matrix.os }} steps: - - name: Future asset contract + - name: Checkout release contents + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + + - name: Verify dist release contract + shell: bash + env: + DIST_CONTRACT: dist + ARCHIVE: ${{ matrix.archive }} run: | - echo "${{ matrix.component }}-${{ matrix.target }}" - echo "checksums: sha256sum / shasum -a 256" - echo "provenance: $PROVENANCE" + set -euo pipefail + archive="$DIST_CONTRACT/$ARCHIVE" + checksum="$DIST_CONTRACT/checksums/$ARCHIVE.sha256" + provenance="$DIST_CONTRACT/provenance/$ARCHIVE.intoto.jsonl" + license="$DIST_CONTRACT/LICENSE" + notice="$DIST_CONTRACT/NOTICE" + manifest="$DIST_CONTRACT/release-manifest.json" + test -s "$manifest" + test -s "$archive" + test -s "$checksum" + test -s "$provenance" + test -s "$license" + test -s "$notice" + grep -Fq "$ARCHIVE" "$manifest" + grep -Fq "$ARCHIVE" "$checksum" + grep -Fq "$ARCHIVE" "$provenance" + grep -Fq 'Apache License' "$license" + grep -Fq 'Apache' "$notice" diff --git a/PATCHES.md b/PATCHES.md index b39eee2..5fd63d2 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -1,20 +1,20 @@ -# Task 3 Patch Ledger +# Upstream Delta and Release Ledger -This ledger records the intentional Task 3 delta from upstream commit +This ledger records the intentional delta from upstream commit `4cda0ae87698135aa672990e6e68cf7873847426`. -## Applied +## Applied Changes - `800495f` pins `kdl` to `=6.5.0` and repairs the matching lock entries for Rust `1.94.1`. - Adds `encrypted-spaces-bridge`, a typed versioned JSONL stdio boundary with bounded frames and secret-redacted protocol errors. -- Adds RED tests for the bridge lifecycle, data, membership, cancellation, +- Adds boundary tests for the bridge lifecycle, data, membership, cancellation, framing, process exit, and release contract surfaces. - Adds a public-repository release contract workflow. It does not publish and `RELEASE_READY=false` is intentional. -## Explicitly deferred to Task 4 +## Pending Release Work - All SDK/backend runtime behavior. Every declared operation currently returns `NOT_IMPLEMENTED`. diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index c19410f..5fc028d 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -1,7 +1,7 @@ use crate::protocol::Response; use crate::schema::Request; -/// RED boundary only: Task 4 owns the real SDK/backend runtime integration. +/// Boundary only: SDK/backend runtime integration is pending. pub fn dispatch(request: Request) -> Response { let _operation_name = request.operation.name(); let _ = request.payload; diff --git a/bridge/src/schema.rs b/bridge/src/schema.rs index 7a21987..c4ed4c6 100644 --- a/bridge/src/schema.rs +++ b/bridge/src/schema.rs @@ -37,15 +37,15 @@ pub enum Operation { Hello, Version, Cancel, - #[serde(alias = "space.create")] + #[serde(rename = "space.create")] Create, - #[serde(alias = "space.join")] + #[serde(rename = "space.join")] Join, - #[serde(alias = "space.snapshot")] + #[serde(rename = "space.snapshot")] Snapshot, - #[serde(alias = "space.restore")] + #[serde(rename = "space.restore")] Restore, - #[serde(alias = "space.sync")] + #[serde(rename = "space.sync")] Sync, #[serde(rename = "table.insert")] TableInsert, @@ -83,11 +83,11 @@ impl Operation { Self::Hello => "hello", Self::Version => "version", Self::Cancel => "cancel", - Self::Create => "create", - Self::Join => "join", - Self::Snapshot => "snapshot", - Self::Restore => "restore", - Self::Sync => "sync", + Self::Create => "space.create", + Self::Join => "space.join", + Self::Snapshot => "space.snapshot", + Self::Restore => "space.restore", + Self::Sync => "space.sync", Self::TableInsert => "table.insert", Self::TableSelect => "table.select", Self::ListCreate => "list.create", diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index a505735..96d6634 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -51,8 +51,14 @@ fn red_operation(operation: &str) { } #[test] -fn runtime_create_join_snapshot_restore_sync_are_red() { - for operation in ["create", "join", "snapshot", "restore", "sync"] { +fn runtime_space_lifecycle_is_red() { + for operation in [ + "space.create", + "space.join", + "space.snapshot", + "space.restore", + "space.sync", + ] { red_operation(operation); } } @@ -93,7 +99,7 @@ fn runtime_member_invite_join_remove_are_red() { } #[test] -fn runtime_release_contract_is_present_but_not_ready() { +fn release_contract_is_red_until_dist_and_notice_exist() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let workflow = std::fs::read_to_string(root.join(".github/workflows/release-bridge.yml")) .expect("release contract workflow"); @@ -106,15 +112,30 @@ fn runtime_release_contract_is_present_but_not_ready() { for asset in ["backend", "bridge"] { assert!(workflow.contains(asset), "workflow omits {asset} asset"); } - for target in [ - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - "x86_64-apple-darwin", - "aarch64-apple-darwin", - ] { + for target in ["linux-amd64", "linux-arm64", "macos-amd64", "macos-arm64"] { assert!(workflow.contains(target), "workflow omits {target}"); } + for archive in [ + "encrypted-spaces-backend-linux-amd64.tar.gz", + "encrypted-spaces-backend-linux-arm64.tar.gz", + "encrypted-spaces-backend-macos-amd64.tar.gz", + "encrypted-spaces-backend-macos-arm64.tar.gz", + "encrypted-spaces-bridge-linux-amd64.tar.gz", + "encrypted-spaces-bridge-linux-arm64.tar.gz", + "encrypted-spaces-bridge-macos-amd64.tar.gz", + "encrypted-spaces-bridge-macos-arm64.tar.gz", + ] { + assert!(workflow.contains(archive), "workflow omits {archive}"); + } for marker in [ + "DIST_CONTRACT: dist", + "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", + "test -s \"$manifest\"", + "test -s \"$archive\"", + "test -s \"$checksum\"", + "test -s \"$provenance\"", + "test -s \"$license\"", + "test -s \"$notice\"", "sha256sum", "provenance", "LICENSE", @@ -128,6 +149,9 @@ fn runtime_release_contract_is_present_but_not_ready() { "workflow omits release marker {marker}" ); } + assert!(!workflow.contains("if: ${{ false }}")); + assert!(!workflow.contains("echo \"${{ matrix.component }}-${{ matrix.target }}\"")); + assert!(!workflow.contains("Future asset contract")); assert!(workflow.contains("release_ready: false")); assert!(workflow.contains("does not publish")); assert!(matrix.contains("encrypted-spaces-bridge")); @@ -136,4 +160,26 @@ fn runtime_release_contract_is_present_but_not_ready() { assert!(patches.contains("4cda0ae")); assert!(patches.contains("800495f")); assert!(patches.contains("NOT_IMPLEMENTED")); + + let mut missing = vec!["NOTICE".to_owned()]; + for asset in [ + "encrypted-spaces-backend-linux-amd64.tar.gz", + "encrypted-spaces-backend-linux-arm64.tar.gz", + "encrypted-spaces-backend-macos-amd64.tar.gz", + "encrypted-spaces-backend-macos-arm64.tar.gz", + "encrypted-spaces-bridge-linux-amd64.tar.gz", + "encrypted-spaces-bridge-linux-arm64.tar.gz", + "encrypted-spaces-bridge-macos-amd64.tar.gz", + "encrypted-spaces-bridge-macos-arm64.tar.gz", + ] { + let path = root.join("dist").join(asset); + if !path.is_file() || std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) == 0 { + missing.push(format!("dist/{asset}")); + } + } + assert!( + missing.len() == 0, + "release contract RED: missing NOTICE/dist release assets: {}", + missing.join(", ") + ); } diff --git a/bridge/tests/stdio.rs b/bridge/tests/stdio.rs index 45a3491..1658f4f 100644 --- a/bridge/tests/stdio.rs +++ b/bridge/tests/stdio.rs @@ -60,6 +60,19 @@ fn protocol_version_is_red() { assert_future_success(response, "version"); } +#[test] +fn protocol_bare_space_lifecycle_names_are_rejected() { + for operation in ["create", "join", "snapshot", "restore", "sync"] { + let actor = actor(operation); + let response = &invoke(&request(operation, &actor, json!({})))[0]; + assert_eq!( + response["ok"], false, + "bare operation accepted: {operation}" + ); + assert_eq!(response["error"]["code"], "INVALID_JSON"); + } +} + #[test] fn protocol_malformed_frame_has_stable_error() { let response = &invoke("not-json\n")[0]; From 9f128e28cde22df053b37b3e59fa75f5e35c997a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 02:34:42 -0400 Subject: [PATCH 04/35] fix(bridge): harden stdio protocol --- bridge/src/protocol.rs | 58 ++++-- bridge/src/schema.rs | 5 - bridge/tests/stdio.rs | 439 ++++++++++++++++++++++++++++++++++------- 3 files changed, 415 insertions(+), 87 deletions(-) diff --git a/bridge/src/protocol.rs b/bridge/src/protocol.rs index f941173..cb2a640 100644 --- a/bridge/src/protocol.rs +++ b/bridge/src/protocol.rs @@ -1,6 +1,7 @@ use crate::runtime; -use crate::schema::{Request, PROTOCOL_VERSION}; +use crate::schema::{Operation, Request, MAX_REQUEST_ID_BYTES, PROTOCOL_VERSION}; use serde::Serialize; +use serde_json::Value; use std::io::{self, BufRead, Read, Write}; pub const MAX_FRAME_BYTES: usize = 64 * 1024; @@ -55,22 +56,55 @@ pub fn run(reader: R, mut writer: W) -> io::Result<()> { &mut writer, Response::error(None, "FRAME_TOO_LARGE", "JSONL frame exceeds maximum size"), )?; - continue; + return Ok(()); } Err(FrameError::Io(error)) => return Err(error), }; - let response = match serde_json::from_slice::(&frame) { - Ok(request) => match request.validate() { - Ok(()) => runtime::dispatch(request), - Err(_) => Response::error(None, "INVALID_REQUEST", "invalid bridge request"), - }, - Err(_) => Response::error(None, "INVALID_JSON", "malformed JSONL frame"), - }; + let response = parse_request(&frame); write_response(&mut writer, response)?; } } +fn parse_request(frame: &[u8]) -> Response { + let value = match serde_json::from_slice::(frame) { + Ok(value) => value, + Err(_) => return Response::error(None, "INVALID_JSON", "malformed JSONL frame"), + }; + let request_id = parsed_request_id(&value); + if has_unknown_operation(&value) { + return Response::error(request_id, "UNKNOWN_OPERATION", "unknown bridge operation"); + } + + let request = match serde_json::from_value::(value) { + Ok(request) => request, + Err(_) => { + return Response::error(request_id, "INVALID_REQUEST", "invalid bridge request"); + } + }; + match request.validate() { + Ok(()) => runtime::dispatch(request), + Err(_) => Response::error(request_id, "INVALID_REQUEST", "invalid bridge request"), + } +} + +fn has_unknown_operation(value: &Value) -> bool { + value + .get("operation") + .and_then(Value::as_str) + .is_some_and(|operation| { + serde_json::from_value::(Value::String(operation.to_owned())).is_err() + }) +} + +fn parsed_request_id(value: &Value) -> Option { + value + .get("request_id") + .and_then(Value::as_str) + .filter(|request_id| !request_id.is_empty() && request_id.len() <= MAX_REQUEST_ID_BYTES) + .map(str::to_owned) +} + fn read_frame(reader: &mut R) -> Result>, FrameError> { let mut frame = Vec::with_capacity(1024); let mut byte = [0u8; 1]; @@ -87,12 +121,6 @@ fn read_frame(reader: &mut R) -> Result>, FrameError> return Ok(Some(frame)); } if frame.len() == MAX_FRAME_BYTES { - loop { - let read = reader.read(&mut byte).map_err(FrameError::Io)?; - if read == 0 || byte[0] == b'\n' { - break; - } - } return Err(FrameError::TooLarge); } frame.push(byte[0]); diff --git a/bridge/src/schema.rs b/bridge/src/schema.rs index c4ed4c6..18102f7 100644 --- a/bridge/src/schema.rs +++ b/bridge/src/schema.rs @@ -3,14 +3,12 @@ use serde_json::Value; pub const PROTOCOL_VERSION: u16 = 1; pub const MAX_REQUEST_ID_BYTES: usize = 256; -pub const MAX_ACTOR_ID_BYTES: usize = 256; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Request { pub version: u16, pub request_id: String, - pub actor_id: String, pub operation: Operation, #[serde(default)] pub payload: Value, @@ -24,9 +22,6 @@ impl Request { if self.request_id.is_empty() || self.request_id.len() > MAX_REQUEST_ID_BYTES { return Err("request id exceeds maximum size"); } - if self.actor_id.is_empty() || self.actor_id.len() > MAX_ACTOR_ID_BYTES { - return Err("actor id exceeds maximum size"); - } Ok(()) } } diff --git a/bridge/tests/stdio.rs b/bridge/tests/stdio.rs index 1658f4f..3d19815 100644 --- a/bridge/tests/stdio.rs +++ b/bridge/tests/stdio.rs @@ -1,35 +1,182 @@ use serde_json::{json, Value}; -use std::io::Write; -use std::process::{Command, Stdio}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; const MAX_FRAME_BYTES: usize = 64 * 1024; +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(2); +const EXIT_TIMEOUT: Duration = Duration::from_secs(2); +const DEFAULT_ACTOR_ID: &str = "configured-test-actor"; +type StderrCapture = Arc, String>>>>; -fn invoke(frame: &str) -> Vec { - let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .expect("spawn bridge"); - child - .stdin - .take() - .expect("bridge stdin") - .write_all(frame.as_bytes()) - .expect("write bridge frame"); - let output = child.wait_with_output().expect("wait for bridge"); - assert!(output.status.success(), "bridge exited: {output:?}"); - String::from_utf8(output.stdout) - .expect("bridge stdout is UTF-8") - .lines() - .map(|line| serde_json::from_str(line).expect("bridge emits JSONL")) - .collect() -} - -fn request(operation: &str, actor: &str, payload: Value) -> String { +enum StdoutEvent { + Line(String), + Eof, + Error(String), +} + +struct Bridge { + child: Child, + stdin: Option, + stdout: mpsc::Receiver, + stdout_thread: Option>, + stderr: StderrCapture, + stderr_thread: Option>, +} + +impl Bridge { + fn spawn(actor_id: &str) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) + .env("ENCRYPTED_SPACES_ACTOR_ID", actor_id) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn bridge"); + let stdin = child.stdin.take().expect("bridge stdin"); + let stdout = child.stdout.take().expect("bridge stdout"); + let stderr = child.stderr.take().expect("bridge stderr"); + + let (stdout_sender, stdout_receiver) = mpsc::channel(); + let stdout_thread = thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => { + let _ = stdout_sender.send(StdoutEvent::Eof); + break; + } + Ok(_) => { + if stdout_sender.send(StdoutEvent::Line(line)).is_err() { + break; + } + } + Err(error) => { + let _ = stdout_sender.send(StdoutEvent::Error(error.to_string())); + break; + } + } + } + }); + + let stderr_bytes = Arc::new(Mutex::new(None)); + let captured_stderr = Arc::clone(&stderr_bytes); + let stderr_thread = thread::spawn(move || { + let mut reader = BufReader::new(stderr); + let mut bytes = Vec::new(); + let result = reader + .read_to_end(&mut bytes) + .map(|_| bytes) + .map_err(|error| error.to_string()); + *captured_stderr.lock().expect("lock stderr capture") = Some(result); + }); + + Self { + child, + stdin: Some(stdin), + stdout: stdout_receiver, + stdout_thread: Some(stdout_thread), + stderr: stderr_bytes, + stderr_thread: Some(stderr_thread), + } + } + + fn send_raw(&mut self, frame: &[u8]) { + let stdin = self.stdin.as_mut().expect("bridge stdin is open"); + stdin.write_all(frame).expect("write bridge frame"); + stdin.flush().expect("flush bridge frame"); + } + + fn send_request(&mut self, request_id: &str, operation: &str, payload: Value) { + self.send_raw(request(request_id, operation, payload).as_bytes()); + } + + fn receive(&self) -> Value { + match self.stdout.recv_timeout(RESPONSE_TIMEOUT) { + Ok(StdoutEvent::Line(line)) => { + serde_json::from_str(&line).expect("bridge response is JSONL") + } + Ok(StdoutEvent::Eof) => panic!("bridge stdout closed before a response"), + Ok(StdoutEvent::Error(error)) => panic!("bridge stdout read failed: {error}"), + Err(mpsc::RecvTimeoutError::Timeout) => { + panic!("bridge did not respond within {RESPONSE_TIMEOUT:?}") + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("bridge stdout reader disconnected") + } + } + } + + fn expect_stdout_eof(&self) { + match self.stdout.recv_timeout(RESPONSE_TIMEOUT) { + Ok(StdoutEvent::Eof) => {} + Ok(StdoutEvent::Line(_)) => panic!("bridge emitted an unexpected response"), + Ok(StdoutEvent::Error(error)) => panic!("bridge stdout read failed: {error}"), + Err(error) => panic!("bridge stdout did not close: {error}"), + } + } + + fn close_stdin(&mut self) { + self.stdin.take(); + } + + fn wait_for_exit(&mut self) -> ExitStatus { + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if let Some(status) = self.child.try_wait().expect("poll bridge process") { + return status; + } + assert!( + Instant::now() < deadline, + "bridge did not exit within {EXIT_TIMEOUT:?}" + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn finish(mut self) -> (ExitStatus, String) { + self.close_stdin(); + let status = self.wait_for_exit(); + self.join_readers(); + let stderr = self + .stderr + .lock() + .expect("lock stderr capture") + .take() + .expect("stderr capture completed") + .expect("read bridge stderr"); + let stderr = String::from_utf8(stderr).expect("bridge stderr is UTF-8"); + (status, stderr) + } + + fn join_readers(&mut self) { + if let Some(thread) = self.stdout_thread.take() { + thread.join().expect("join stdout reader"); + } + if let Some(thread) = self.stderr_thread.take() { + thread.join().expect("join stderr reader"); + } + } +} + +impl Drop for Bridge { + fn drop(&mut self) { + self.stdin.take(); + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + self.join_readers(); + } +} + +fn request(request_id: &str, operation: &str, payload: Value) -> String { serde_json::to_string(&json!({ "version": 1, - "request_id": format!("request-{operation}"), - "actor_id": actor, + "request_id": request_id, "operation": operation, "payload": payload, })) @@ -37,8 +184,12 @@ fn request(operation: &str, actor: &str, payload: Value) -> String { + "\n" } -fn actor(operation: &str) -> String { - format!("actor-{operation}") +fn exact_size_request(size: usize) -> String { + let empty = request("exact-max", "table.select", json!({"padding": ""})); + let padding = "x".repeat(size - (empty.len() - 1)); + let frame = request("exact-max", "table.select", json!({"padding": padding})); + assert_eq!(frame.len() - 1, size, "frame size excludes newline"); + frame } fn assert_future_success(response: &Value, operation: &str) { @@ -47,44 +198,82 @@ fn assert_future_success(response: &Value, operation: &str) { } #[test] -fn protocol_hello_is_red() { - let actor = actor("hello"); - let response = &invoke(&request("hello", &actor, json!({})))[0]; - assert_future_success(response, "hello"); +fn protocol_hello_reports_process_configured_actor_is_red() { + for actor_id in ["configured-actor-one", "configured-actor-two"] { + let mut bridge = Bridge::spawn(actor_id); + bridge.send_request("hello-request", "hello", json!({})); + let response = bridge.receive(); + assert_future_success(&response, "hello"); + assert_eq!(response["result"]["actor_id"], actor_id); + } } #[test] fn protocol_version_is_red() { - let actor = actor("version"); - let response = &invoke(&request("version", &actor, json!({})))[0]; - assert_future_success(response, "version"); + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_request("version-request", "version", json!({})); + assert_future_success(&bridge.receive(), "version"); } #[test] -fn protocol_bare_space_lifecycle_names_are_rejected() { +fn protocol_request_supplied_actor_id_is_rejected() { + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let frame = serde_json::to_string(&json!({ + "version": 1, + "request_id": "actor-in-frame", + "actor_id": "caller-selected-actor", + "operation": "table.select", + "payload": {}, + })) + .expect("request JSON") + + "\n"; + bridge.send_raw(frame.as_bytes()); + + let response = bridge.receive(); + assert_eq!(response["request_id"], "actor-in-frame"); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "INVALID_REQUEST"); +} + +#[test] +fn protocol_bare_space_lifecycle_names_are_unknown() { for operation in ["create", "join", "snapshot", "restore", "sync"] { - let actor = actor(operation); - let response = &invoke(&request(operation, &actor, json!({})))[0]; + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_request("bare-operation", operation, json!({})); + let response = bridge.receive(); assert_eq!( response["ok"], false, "bare operation accepted: {operation}" ); - assert_eq!(response["error"]["code"], "INVALID_JSON"); + assert_eq!(response["error"]["code"], "UNKNOWN_OPERATION"); } } #[test] fn protocol_malformed_frame_has_stable_error() { - let response = &invoke("not-json\n")[0]; + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_raw(b"not-json\n"); + let response = bridge.receive(); assert_eq!(response["ok"], false); assert_eq!(response["error"]["code"], "INVALID_JSON"); assert_eq!(response["error"]["message"], "malformed JSONL frame"); } #[test] -fn protocol_oversized_frame_has_stable_error() { - let frame = format!("{}\n", "x".repeat(MAX_FRAME_BYTES + 1)); - let response = &invoke(&frame)[0]; +fn protocol_accepts_a_frame_at_the_exact_limit() { + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_raw(exact_size_request(MAX_FRAME_BYTES).as_bytes()); + let response = bridge.receive(); + assert_eq!(response["request_id"], "exact-max"); + assert_eq!(response["error"]["code"], "NOT_IMPLEMENTED"); +} + +#[test] +fn protocol_oversize_error_is_prompt_while_stdin_remains_open() { + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_raw(&vec![b'x'; MAX_FRAME_BYTES + 1]); + + let response = bridge.receive(); assert_eq!(response["ok"], false); assert_eq!(response["error"]["code"], "FRAME_TOO_LARGE"); assert_eq!( @@ -94,49 +283,165 @@ fn protocol_oversized_frame_has_stable_error() { } #[test] -fn protocol_cancellation_is_red() { - let actor = actor("cancel"); - let response = &invoke(&request( +fn protocol_process_terminates_after_oversize_without_draining_stdin() { + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_raw(&vec![b'x'; MAX_FRAME_BYTES + 1]); + assert_eq!(bridge.receive()["error"]["code"], "FRAME_TOO_LARGE"); + + let status = bridge.wait_for_exit(); + assert!(status.success(), "bridge exited with {status}"); +} + +#[test] +fn protocol_recovers_in_malformed_invalid_valid_order() { + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_raw(b"not-json\n"); + let invalid = serde_json::to_string(&json!({ + "version": 2, + "request_id": "invalid-request", + "operation": "table.select", + "payload": {}, + })) + .expect("request JSON") + + "\n"; + bridge.send_raw(invalid.as_bytes()); + bridge.send_request("valid-request", "table.select", json!({})); + + let malformed = bridge.receive(); + let invalid = bridge.receive(); + let valid = bridge.receive(); + assert_eq!(malformed["error"]["code"], "INVALID_JSON"); + assert_eq!(invalid["request_id"], "invalid-request"); + assert_eq!(invalid["error"]["code"], "INVALID_REQUEST"); + assert_eq!(valid["request_id"], "valid-request"); + assert_eq!(valid["error"]["code"], "NOT_IMPLEMENTED"); +} + +#[test] +fn protocol_validation_error_preserves_request_id() { + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let frame = serde_json::to_string(&json!({ + "version": 2, + "request_id": "unsupported-version", + "operation": "table.select", + "payload": {}, + })) + .expect("request JSON") + + "\n"; + bridge.send_raw(frame.as_bytes()); + + let response = bridge.receive(); + assert_eq!(response["request_id"], "unsupported-version"); + assert_eq!(response["error"]["code"], "INVALID_REQUEST"); +} + +#[test] +fn protocol_invalid_oversized_request_id_is_not_reflected() { + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let oversized_request_id = "x".repeat(257); + let frame = serde_json::to_string(&json!({ + "version": 1, + "request_id": oversized_request_id, + "operation": "table.select", + "payload": {}, + })) + .expect("request JSON") + + "\n"; + bridge.send_raw(frame.as_bytes()); + + let response = bridge.receive(); + assert_eq!(response["request_id"], Value::Null); + assert_eq!(response["error"]["code"], "INVALID_REQUEST"); +} + +#[test] +fn protocol_unknown_operation_is_distinct_from_malformed_json() { + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_request("unknown-request", "space.unknown", json!({})); + bridge.send_raw(b"{not-json}\n"); + + let unknown = bridge.receive(); + let malformed = bridge.receive(); + assert_eq!(unknown["request_id"], "unknown-request"); + assert_eq!(unknown["error"]["code"], "UNKNOWN_OPERATION"); + assert_eq!(malformed["error"]["code"], "INVALID_JSON"); +} + +#[test] +fn protocol_cancellation_interrupts_sync_and_keeps_process_usable_is_red() { + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_request("hello-before-cancel", "hello", json!({})); + assert_future_success(&bridge.receive(), "hello"); + + bridge.send_request( + "sync-to-cancel", + "space.sync", + json!({"space_id": "cancel-contract-space", "wait_for_change_ms": 30_000}), + ); + bridge.send_request( + "cancel-request", "cancel", - &actor, - json!({"request_id": "request-1"}), - ))[0]; - assert_future_success(response, "cancel"); + json!({"request_id": "sync-to-cancel"}), + ); + + let canceled = bridge.receive(); + assert_eq!(canceled["request_id"], "sync-to-cancel"); + assert_eq!(canceled["ok"], false); + assert_eq!(canceled["error"]["code"], "CANCELED"); + + let cancel_ack = bridge.receive(); + assert_eq!(cancel_ack["request_id"], "cancel-request"); + assert_eq!(cancel_ack["ok"], true); + + bridge.send_request("version-after-cancel", "version", json!({})); + assert_future_success(&bridge.receive(), "version after cancel"); } #[test] -fn protocol_errors_redact_secret_material() { +fn protocol_validation_errors_redact_secret_material() { let secret = "request-secret-value"; - let actor = actor("secret-check"); - let response = &invoke(&request( - "table.insert", - &actor, - json!({"value": "value-parametric", "secret": secret}), - ))[0]; + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let frame = serde_json::to_string(&json!({ + "version": 2, + "request_id": "secret-validation", + "operation": "table.insert", + "payload": {"value": "value-parametric", "secret": secret}, + })) + .expect("request JSON") + + "\n"; + bridge.send_raw(frame.as_bytes()); + + let response = bridge.receive(); + assert_eq!(response["error"]["code"], "INVALID_REQUEST"); let serialized = response.to_string(); assert!( !serialized.contains(secret), - "secret leaked in response: {serialized}" + "secret leaked in bridge response" ); - assert_eq!(response["error"]["code"], "NOT_IMPLEMENTED"); + let (status, stderr) = bridge.finish(); + assert!(status.success(), "bridge exited with {status}"); + assert!(!stderr.contains(secret), "secret leaked in bridge stderr"); } #[test] fn protocol_close_is_red() { - let actor = actor("close"); - let response = &invoke(&request("close", &actor, json!({})))[0]; - assert_future_success(response, "close"); + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_request("close-request", "close", json!({})); + assert_future_success(&bridge.receive(), "close"); } #[test] fn protocol_shutdown_is_red() { - let actor = actor("shutdown"); - let response = &invoke(&request("shutdown", &actor, json!({})))[0]; - assert_future_success(response, "shutdown"); + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.send_request("shutdown-request", "shutdown", json!({})); + assert_future_success(&bridge.receive(), "shutdown"); } #[test] fn protocol_process_exits_after_stdin_eof() { - let responses = invoke(""); - assert!(responses.is_empty(), "EOF should not create a response"); + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + bridge.close_stdin(); + let status = bridge.wait_for_exit(); + assert!(status.success(), "bridge exited with {status}"); + bridge.expect_stdout_eof(); } From 5f2f83f304b40674eddb0a176311a448be44bf23 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 02:36:09 -0400 Subject: [PATCH 05/35] test(bridge): harden runtime release contracts --- .github/workflows/release-bridge.yml | 155 ++-- bridge/tests/runtime.rs | 1069 ++++++++++++++++++++++---- 2 files changed, 1043 insertions(+), 181 deletions(-) diff --git a/.github/workflows/release-bridge.yml b/.github/workflows/release-bridge.yml index 78350a1..8610c3e 100644 --- a/.github/workflows/release-bridge.yml +++ b/.github/workflows/release-bridge.yml @@ -1,10 +1,8 @@ name: Bridge Release Contract -# Contract-only RED workflow. It validates the future public release shape but -# does not publish releases and does not claim release readiness yet. -# release_ready: false +# Manual validation only. This workflow does not publish release assets. on: - workflow_dispatch: {} + workflow_dispatch: permissions: contents: read @@ -14,62 +12,44 @@ env: UPSTREAM_COMMIT: 4cda0ae87698135aa672990e6e68cf7873847426 RUST_VERSION: 1.94.1 RISC0_SKIP_BUILD: 1 - PROVENANCE: required-before-publish jobs: - contract: + legal: + name: Legal and source contract runs-on: ubuntu-24.04 steps: - - name: Checkout pinned upstream descendant + - name: Checkout pinned source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: fetch-depth: 0 - - name: Validate public release contract + - name: Verify legal and source records shell: bash run: | set -euo pipefail test "$RELEASE_READY" = false git merge-base --is-ancestor "$UPSTREAM_COMMIT" "$GITHUB_SHA" - grep -Fq 'kdl = { version = "=6.5.0"' Cargo.toml - grep -Fq 'version = "6.5.0"' Cargo.lock - test -f LICENSE - # NOTICE is a deliberate release gate until the legal attribution - # file is supplied; this workflow must stay RED before publishing. - test -f NOTICE - test -f PATCHES.md + test -s LICENSE + test -s NOTICE + test -s PATCHES.md grep -Fq "$UPSTREAM_COMMIT" PATCHES.md grep -Fq '800495f' PATCHES.md - grep -Fq "Rust $RUST_VERSION" PATCHES.md + grep -Fq "Rust \`$RUST_VERSION\`" PATCHES.md + grep -Fq 'kdl = { version = "=6.5.0"' Cargo.toml + grep -Fq 'version = "6.5.0"' Cargo.lock - - name: Install pinned Rust toolchain and expose matching tools + - name: Verify pinned Rust toolchain shell: bash run: | set -euo pipefail rustup toolchain install "$RUST_VERSION" --profile minimal - RUSTC="$(rustup which --toolchain "$RUST_VERSION" rustc)" - RUSTDOC="$(rustup which --toolchain "$RUST_VERSION" rustdoc)" - echo "RUSTUP_TOOLCHAIN=$RUST_VERSION" >> "$GITHUB_ENV" - echo "RUSTC=$RUSTC" >> "$GITHUB_ENV" - echo "RUSTDOC=$RUSTDOC" >> "$GITHUB_ENV" - echo "$(dirname "$RUSTC")" >> "$GITHUB_PATH" - "$RUSTC" --version | grep -Fq " $RUST_VERSION " - "$RUSTDOC" --version | grep -Fq " $RUST_VERSION " - - - name: Validate build and publication markers - shell: bash - run: | - set -euo pipefail - grep -Fq 'sha256sum' .github/workflows/release-bridge.yml - grep -Fq 'shasum -a 256' .github/workflows/release-bridge.yml - grep -Fq 'provenance' .github/workflows/release-bridge.yml - grep -Fq 'LICENSE' .github/workflows/release-bridge.yml - grep -Fq 'NOTICE' .github/workflows/release-bridge.yml - grep -Fq 'does not publish' .github/workflows/release-bridge.yml + rustup run "$RUST_VERSION" rustc --version | grep -Fq " $RUST_VERSION " + rustup run "$RUST_VERSION" rustdoc --version | grep -Fq " $RUST_VERSION " assets: - needs: contract + name: ${{ matrix.archive }} strategy: + fail-fast: false matrix: include: - component: backend @@ -81,7 +61,7 @@ jobs: target: linux-arm64 archive: encrypted-spaces-backend-linux-arm64.tar.gz - component: backend - os: macos-13 + os: macos-15-intel target: macos-amd64 archive: encrypted-spaces-backend-macos-amd64.tar.gz - component: backend @@ -97,7 +77,7 @@ jobs: target: linux-arm64 archive: encrypted-spaces-bridge-linux-arm64.tar.gz - component: bridge - os: macos-13 + os: macos-15-intel target: macos-amd64 archive: encrypted-spaces-bridge-macos-amd64.tar.gz - component: bridge @@ -106,30 +86,105 @@ jobs: archive: encrypted-spaces-bridge-macos-arm64.tar.gz runs-on: ${{ matrix.os }} steps: - - name: Checkout release contents + - name: Checkout pinned source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - name: Verify dist release contract + - name: Verify release asset shell: bash env: DIST_CONTRACT: dist + COMPONENT: ${{ matrix.component }} + TARGET: ${{ matrix.target }} ARCHIVE: ${{ matrix.archive }} run: | set -euo pipefail archive="$DIST_CONTRACT/$ARCHIVE" checksum="$DIST_CONTRACT/checksums/$ARCHIVE.sha256" provenance="$DIST_CONTRACT/provenance/$ARCHIVE.intoto.jsonl" - license="$DIST_CONTRACT/LICENSE" - notice="$DIST_CONTRACT/NOTICE" manifest="$DIST_CONTRACT/release-manifest.json" test -s "$manifest" test -s "$archive" test -s "$checksum" test -s "$provenance" - test -s "$license" - test -s "$notice" - grep -Fq "$ARCHIVE" "$manifest" - grep -Fq "$ARCHIVE" "$checksum" - grep -Fq "$ARCHIVE" "$provenance" - grep -Fq 'Apache License' "$license" - grep -Fq 'Apache' "$notice" + + jq -e \ + --arg component "$COMPONENT" \ + --arg target "$TARGET" \ + --arg archive "$ARCHIVE" \ + --arg upstream "$UPSTREAM_COMMIT" \ + --arg toolchain "$RUST_VERSION" ' + .upstream_commit == $upstream and + .rust_toolchain == $toolchain and + (.artifacts | length == 8) and + ([.artifacts[].archive] | unique | length == 8) and + ([.artifacts[] | select( + .component == $component and + .target == $target and + .archive == $archive and + (.sha256 | test("^[0-9a-f]{64}$")) + )] | length == 1) + ' "$manifest" + manifest_digest="$(jq -er \ + --arg component "$COMPONENT" \ + --arg target "$TARGET" \ + --arg archive "$ARCHIVE" ' + .artifacts[] | + select( + .component == $component and + .target == $target and + .archive == $archive + ) | + .sha256 + ' "$manifest")" + + if [[ "$RUNNER_OS" == Linux ]]; then + actual_digest="$(sha256sum "$archive" | awk '{print $1}')" + (cd "$DIST_CONTRACT" && sha256sum -c "checksums/$ARCHIVE.sha256") + elif [[ "$RUNNER_OS" == macOS ]]; then + actual_digest="$(shasum -a 256 "$archive" | awk '{print $1}')" + (cd "$DIST_CONTRACT" && shasum -a 256 -c "checksums/$ARCHIVE.sha256") + else + echo "unsupported checksum platform: $RUNNER_OS" >&2 + exit 1 + fi + test "$actual_digest" = "$manifest_digest" + + jq -se \ + --arg archive "$ARCHIVE" \ + --arg digest "$actual_digest" \ + --arg upstream "$UPSTREAM_COMMIT" \ + --arg toolchain "$RUST_VERSION" ' + length > 0 and any(.[]; + ._type == "https://in-toto.io/Statement/v1" and + .predicateType == "https://slsa.dev/provenance/v1" and + any(.subject[]?; + .name == $archive and .digest.sha256 == $digest + ) and + .predicate.buildDefinition.externalParameters.upstream_commit == $upstream and + .predicate.buildDefinition.externalParameters.rust_toolchain == $toolchain + ) + ' "$provenance" + + listing="$(tar -tzf "$archive")" + for required in "encrypted-spaces-$COMPONENT" LICENSE NOTICE; do + awk -F/ -v required="$required" ' + $NF == required { found = 1 } + END { exit !found } + ' <<< "$listing" + done + + aggregate: + name: Release contract result + if: ${{ always() }} + needs: [legal, assets] + runs-on: ubuntu-24.04 + steps: + - name: Require all release gates + shell: bash + env: + LEGAL_RESULT: ${{ needs.legal.result }} + ASSET_RESULT: ${{ needs.assets.result }} + run: | + set -euo pipefail + test "$LEGAL_RESULT" = success + test "$ASSET_RESULT" = success diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 96d6634..d34fac1 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -1,185 +1,992 @@ +use serde::de::DeserializeOwned; +use serde::Deserialize; use serde_json::{json, Value}; -use std::io::Write; -use std::process::{Command, Stdio}; - -fn invoke(operation: &str, actor: &str, payload: Value) -> Value { - let frame = serde_json::to_string(&json!({ - "version": 1, - "request_id": format!("runtime-{operation}"), - "actor_id": actor, - "operation": operation, - "payload": payload, - })) - .expect("request JSON") - + "\n"; - let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .expect("spawn bridge"); - child - .stdin - .take() - .expect("bridge stdin") - .write_all(frame.as_bytes()) - .expect("write bridge frame"); - let output = child.wait_with_output().expect("wait for bridge"); - assert!(output.status.success(), "bridge exited: {output:?}"); - serde_json::from_slice( - output - .stdout - .split(|byte| *byte == b'\n') - .next() - .expect("response"), - ) - .expect("bridge response JSON") -} - -fn assert_future_success(response: &Value, operation: &str) { - assert_eq!(response["version"], 1, "{operation} response version"); - assert_eq!(response["ok"], true, "{operation} is still RED: {response}"); -} - -fn red_operation(operation: &str) { - let actor = format!("actor-{operation}"); - let response = invoke( - operation, - &actor, - json!({"key": format!("key-{operation}"), "value": format!("value-{operation}")}), - ); - assert_future_success(&response, operation); +use std::collections::BTreeMap; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +const PROTOCOL_VERSION: u64 = 1; +const UPSTREAM_COMMIT: &str = "4cda0ae87698135aa672990e6e68cf7873847426"; +const RUST_TOOLCHAIN: &str = "1.94.1"; + +struct BridgeProcess { + child: Child, + stdin: Option, + stdout: BufReader, + scenario: String, + next_request: usize, +} + +impl BridgeProcess { + fn spawn(scenario: &str, actor: &str) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) + .env("ENCRYPTED_SPACES_ACTOR_ID", actor) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn bridge"); + let stdin = child.stdin.take().expect("bridge stdin"); + let stdout = BufReader::new(child.stdout.take().expect("bridge stdout")); + Self { + child, + stdin: Some(stdin), + stdout, + scenario: format!("{scenario}-{actor}"), + next_request: 1, + } + } + + fn exchange(&mut self, operation: &str, payload: Value) -> Observation { + self.exchange_inner(operation, payload, None) + } + + fn exchange_with_actor_field( + &mut self, + operation: &str, + payload: Value, + actor: &str, + ) -> Observation { + self.exchange_inner(operation, payload, Some(actor)) + } + + fn exchange_inner( + &mut self, + operation: &str, + payload: Value, + actor_override: Option<&str>, + ) -> Observation { + let request_id = format!( + "runtime-{}-{:02}-{}", + self.scenario, + self.next_request, + operation.replace('.', "-") + ); + self.next_request += 1; + let mut request = json!({ + "version": PROTOCOL_VERSION, + "request_id": request_id, + "operation": operation, + "payload": payload, + }); + if let Some(actor) = actor_override { + request["actor_id"] = json!(actor); + } + let frame = serde_json::to_string(&request).expect("request JSON"); + let stdin = self.stdin.as_mut().expect("bridge stdin is open"); + writeln!(stdin, "{frame}").expect("write bridge frame"); + stdin.flush().expect("flush bridge frame"); + + let mut line = String::new(); + self.stdout + .read_line(&mut line) + .expect("read bridge response"); + assert!( + !line.is_empty(), + "bridge exited before responding to {operation}" + ); + let response = serde_json::from_str(&line).expect("bridge response JSON"); + Observation { + operation: operation.to_owned(), + request_id, + response, + } + } + + fn finish(mut self) { + drop(self.stdin.take()); + let status = self.child.wait().expect("wait for bridge"); + assert!(status.success(), "bridge exited with {status}"); + } +} + +struct Observation { + operation: String, + request_id: String, + response: Value, +} + +struct Scenario { + bridges: BTreeMap, + observations: Vec, + failures: Vec, +} + +impl Scenario { + fn start(name: &str, actors: &[&str]) -> Self { + let bridges = actors + .iter() + .map(|actor| ((*actor).to_owned(), BridgeProcess::spawn(name, actor))) + .collect(); + Self { + bridges, + observations: Vec::new(), + failures: Vec::new(), + } + } + + fn request(&mut self, actor: &str, operation: &str, payload: Value) -> usize { + let observation = self + .bridges + .get_mut(actor) + .unwrap_or_else(|| panic!("scenario actor {actor} has no bridge process")) + .exchange(operation, payload); + let actual_version = observation.response.get("version").and_then(Value::as_u64); + if actual_version != Some(PROTOCOL_VERSION) { + self.failures.push(format!( + "{}: version {:?}, expected {PROTOCOL_VERSION}", + observation.operation, actual_version + )); + } + let actual_request_id = observation + .response + .get("request_id") + .and_then(Value::as_str); + if actual_request_id != Some(observation.request_id.as_str()) { + self.failures.push(format!( + "{}: request_id {:?}, expected {}", + observation.operation, actual_request_id, observation.request_id + )); + } + self.observations.push(observation); + self.observations.len() - 1 + } + + fn returned_string(&self, index: usize, field: &str, fallback: &str) -> String { + self.observations[index].response["result"][field] + .as_str() + .unwrap_or(fallback) + .to_owned() + } + + fn returned_value(&self, index: usize, field: &str, fallback: Value) -> Value { + self.observations[index].response["result"] + .get(field) + .cloned() + .unwrap_or(fallback) + } + + fn verify(&mut self, index: usize, validate: F) + where + T: DeserializeOwned, + F: FnOnce(&T) -> Result<(), String>, + { + let observation = &self.observations[index]; + let failure = match observation.response.get("ok").and_then(Value::as_bool) { + Some(true) => match observation.response.get("result") { + Some(result) => match serde_json::from_value::(result.clone()) { + Ok(result) => validate(&result).err(), + Err(error) => Some(format!("typed result mismatch: {error}")), + }, + None => Some("missing typed result".to_owned()), + }, + Some(false) => Some(format!( + "missing typed result ({})", + observation.response["error"]["code"] + .as_str() + .unwrap_or("missing error code") + )), + None => Some("missing boolean ok field".to_owned()), + }; + if let Some(failure) = failure { + self.failures + .push(format!("{}: {failure}", observation.operation)); + } + } + + fn finish(mut self) { + for (_, bridge) in std::mem::take(&mut self.bridges) { + bridge.finish(); + } + assert!( + self.failures.is_empty(), + "runtime scenario failures:\n{}", + self.failures.join("\n") + ); + } +} + +#[derive(Deserialize)] +struct HelloResult { + protocol_version: u64, +} + +#[derive(Deserialize)] +struct SpaceCreateResult { + space_id: String, + schema: Value, +} + +#[derive(Deserialize)] +struct SnapshotResult { + space_id: String, + snapshot: Value, +} + +#[derive(Deserialize)] +struct RestoreResult { + space_id: String, + restored: bool, +} + +#[derive(Deserialize)] +struct InviteResult { + space_id: String, + member_id: String, + invite: Value, +} + +#[derive(Deserialize)] +struct JoinResult { + space_id: String, + member_id: String, + joined: bool, +} + +#[derive(Deserialize)] +struct SyncResult { + space_id: String, + change_id: u64, +} + +#[derive(Deserialize)] +struct RemoveResult { + space_id: String, + member_id: String, + removed: bool, +} + +#[derive(Deserialize)] +struct TableInsertResult { + space_id: String, + table: String, + row_id: Value, + row: Value, +} + +#[derive(Deserialize)] +struct TableSelectResult { + space_id: String, + table: String, + rows: Vec, +} + +#[derive(Deserialize)] +struct ListCreateResult { + space_id: String, + list_id: String, + name: String, +} + +#[derive(Deserialize)] +struct ListAppendResult { + space_id: String, + list_id: String, + index: usize, + value: Value, +} + +#[derive(Deserialize)] +struct ListReadResult { + space_id: String, + list_id: String, + items: Vec, +} + +#[derive(Deserialize)] +struct TextResult { + space_id: String, + text_id: String, + text: String, +} + +#[derive(Deserialize)] +struct FilePutResult { + space_id: String, + digest: String, +} + +#[derive(Deserialize)] +struct FileGetResult { + space_id: String, + digest: String, + bytes_base64: String, +} + +fn concrete_schema(name: &str) -> Value { + json!({ + "tables": [{ + "name": name, + "columns": [ + {"name": "id", "type": "integer", "primary_key": true}, + {"name": "label", "type": "text", "nullable": false} + ] + }] + }) +} + +fn valid_digest(digest: &str) -> bool { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[test] +fn runtime_request_actor_override_is_rejected() { + let configured_actor = "actor-configured-parametric"; + let mut bridge = BridgeProcess::spawn("actor-override", configured_actor); + let observation = + bridge.exchange_with_actor_field("hello", json!({}), "actor-frame-override-parametric"); + bridge.finish(); + + assert_eq!(observation.response["version"], PROTOCOL_VERSION); + assert_eq!(observation.response["request_id"], observation.request_id); + assert_eq!( + observation.response["ok"], false, + "request-level actor override reached the runtime" + ); + assert_eq!(observation.response["error"]["code"], "INVALID_REQUEST"); } #[test] fn runtime_space_lifecycle_is_red() { - for operation in [ - "space.create", - "space.join", - "space.snapshot", + let owner = "owner-lifecycle-parametric"; + let member = "member-lifecycle-parametric"; + let schema = concrete_schema("lifecycle_records"); + let mut scenario = Scenario::start("lifecycle", &[owner, member]); + + let hello = scenario.request(owner, "hello", json!({})); + let create = scenario.request(owner, "space.create", json!({"schema": schema})); + let space_id = scenario.returned_string(create, "space_id", "missing-lifecycle-space"); + let snapshot = scenario.request(owner, "space.snapshot", json!({"space_id": space_id})); + let snapshot_value = scenario.returned_value( + snapshot, + "snapshot", + json!({"missing": "lifecycle-snapshot"}), + ); + let restore = scenario.request( + owner, "space.restore", - "space.sync", - ] { - red_operation(operation); - } + json!({"space_id": space_id, "snapshot": snapshot_value}), + ); + let invite = scenario.request( + owner, + "member.invite", + json!({"space_id": space_id, "member_id": member}), + ); + let invite_value = + scenario.returned_value(invite, "invite", json!({"missing": "lifecycle-invite"})); + let join = scenario.request( + member, + "space.join", + json!({"space_id": space_id, "invite": invite_value}), + ); + let sync = scenario.request(member, "space.sync", json!({"space_id": space_id})); + let remove = scenario.request( + owner, + "member.remove", + json!({"space_id": space_id, "member_id": member}), + ); + + scenario.verify::(hello, |result| { + (result.protocol_version == PROTOCOL_VERSION) + .then_some(()) + .ok_or_else(|| "hello returned the wrong protocol version".to_owned()) + }); + scenario.verify::(create, |result| { + (!result.space_id.is_empty() && result.schema == schema) + .then_some(()) + .ok_or_else(|| "space.create did not return its concrete schema".to_owned()) + }); + scenario.verify::(snapshot, |result| { + (result.space_id == space_id && !result.snapshot.is_null()) + .then_some(()) + .ok_or_else(|| "space.snapshot returned the wrong space or no snapshot".to_owned()) + }); + scenario.verify::(restore, |result| { + (result.space_id == space_id && result.restored) + .then_some(()) + .ok_or_else(|| "space.restore did not restore the returned snapshot".to_owned()) + }); + scenario.verify::(invite, |result| { + (result.space_id == space_id && result.member_id == member && !result.invite.is_null()) + .then_some(()) + .ok_or_else(|| "member.invite returned an invalid invite".to_owned()) + }); + scenario.verify::(join, |result| { + (result.space_id == space_id && result.member_id == member && result.joined) + .then_some(()) + .ok_or_else(|| "space.join did not consume the returned invite".to_owned()) + }); + scenario.verify::(sync, |result| { + (result.space_id == space_id && result.change_id > 0) + .then_some(()) + .ok_or_else(|| "space.sync returned no applied change".to_owned()) + }); + scenario.verify::(remove, |result| { + (result.space_id == space_id && result.member_id == member && result.removed) + .then_some(()) + .ok_or_else(|| "member.remove did not remove the joined member".to_owned()) + }); + scenario.finish(); } #[test] fn runtime_table_insert_select_are_red() { - for operation in ["table.insert", "table.select"] { - red_operation(operation); - } + let actor = "actor-table-parametric"; + let schema = concrete_schema("records_parametric"); + let row = json!({"id": 41, "label": "table-value-parametric"}); + let mut scenario = Scenario::start("table", &[actor]); + let create = scenario.request(actor, "space.create", json!({"schema": schema})); + let space_id = scenario.returned_string(create, "space_id", "missing-table-space"); + let insert = scenario.request( + actor, + "table.insert", + json!({"space_id": space_id, "table": "records_parametric", "row": row}), + ); + let row_id = scenario.returned_value(insert, "row_id", json!(41)); + let select = scenario.request( + actor, + "table.select", + json!({"space_id": space_id, "table": "records_parametric", "where": {"id": row_id}}), + ); + + scenario.verify::(create, |result| { + (!result.space_id.is_empty() && result.schema == schema) + .then_some(()) + .ok_or_else(|| "space.create did not return the table schema".to_owned()) + }); + scenario.verify::(insert, |result| { + (result.space_id == space_id + && result.table == "records_parametric" + && result.row_id == json!(41) + && result.row == row) + .then_some(()) + .ok_or_else(|| "table.insert returned the wrong typed row".to_owned()) + }); + scenario.verify::(select, |result| { + (result.space_id == space_id + && result.table == "records_parametric" + && result.rows == vec![row]) + .then_some(()) + .ok_or_else(|| "table.select did not return the inserted row".to_owned()) + }); + scenario.finish(); } #[test] fn runtime_list_create_append_read_are_red() { - for operation in ["list.create", "list.append", "list.read"] { - red_operation(operation); - } + let actor = "actor-list-parametric"; + let schema = concrete_schema("list_records_parametric"); + let item = json!({"key": "list-key-parametric", "value": 73}); + let mut scenario = Scenario::start("list", &[actor]); + let create_space = scenario.request(actor, "space.create", json!({"schema": schema})); + let space_id = scenario.returned_string(create_space, "space_id", "missing-list-space"); + let create = scenario.request( + actor, + "list.create", + json!({"space_id": space_id, "name": "queue_parametric"}), + ); + let list_id = scenario.returned_string(create, "list_id", "missing-list-id"); + let append = scenario.request( + actor, + "list.append", + json!({"space_id": space_id, "list_id": list_id, "value": item}), + ); + let read = scenario.request( + actor, + "list.read", + json!({"space_id": space_id, "list_id": list_id}), + ); + + scenario.verify::(create_space, |result| { + (!result.space_id.is_empty() && result.schema == schema) + .then_some(()) + .ok_or_else(|| "space.create did not return the list schema".to_owned()) + }); + scenario.verify::(create, |result| { + (result.space_id == space_id + && !result.list_id.is_empty() + && result.name == "queue_parametric") + .then_some(()) + .ok_or_else(|| "list.create returned the wrong list".to_owned()) + }); + scenario.verify::(append, |result| { + (result.space_id == space_id + && result.list_id == list_id + && result.index == 0 + && result.value == item) + .then_some(()) + .ok_or_else(|| "list.append returned the wrong item".to_owned()) + }); + scenario.verify::(read, |result| { + (result.space_id == space_id && result.list_id == list_id && result.items == vec![item]) + .then_some(()) + .ok_or_else(|| "list.read did not return the appended item".to_owned()) + }); + scenario.finish(); } #[test] fn runtime_text_create_edit_read_are_red() { - for operation in ["text.create", "text.edit", "text.read"] { - red_operation(operation); - } + let actor = "actor-text-parametric"; + let schema = concrete_schema("text_records_parametric"); + let initial = "initial text parametric"; + let edited = "edited text parametric"; + let mut scenario = Scenario::start("text", &[actor]); + let create_space = scenario.request(actor, "space.create", json!({"schema": schema})); + let space_id = scenario.returned_string(create_space, "space_id", "missing-text-space"); + let create = scenario.request( + actor, + "text.create", + json!({"space_id": space_id, "name": "document_parametric", "text": initial}), + ); + let text_id = scenario.returned_string(create, "text_id", "missing-text-id"); + let edit = scenario.request( + actor, + "text.edit", + json!({"space_id": space_id, "text_id": text_id, "text": edited}), + ); + let read = scenario.request( + actor, + "text.read", + json!({"space_id": space_id, "text_id": text_id}), + ); + + scenario.verify::(create_space, |result| { + (!result.space_id.is_empty() && result.schema == schema) + .then_some(()) + .ok_or_else(|| "space.create did not return the text schema".to_owned()) + }); + scenario.verify::(create, |result| { + (result.space_id == space_id && !result.text_id.is_empty() && result.text == initial) + .then_some(()) + .ok_or_else(|| "text.create returned the wrong document".to_owned()) + }); + scenario.verify::(edit, |result| { + (result.space_id == space_id && result.text_id == text_id && result.text == edited) + .then_some(()) + .ok_or_else(|| "text.edit returned the wrong document".to_owned()) + }); + scenario.verify::(read, |result| { + (result.space_id == space_id && result.text_id == text_id && result.text == edited) + .then_some(()) + .ok_or_else(|| "text.read did not return the edited document".to_owned()) + }); + scenario.finish(); } #[test] fn runtime_file_put_get_are_red() { - for operation in ["file.put", "file.get"] { - red_operation(operation); - } + let actor = "actor-file-parametric"; + let schema = concrete_schema("file_records_parametric"); + let bytes_base64 = "YnJpZGdlLWZpbGUtcGFyYW1ldHJpYw=="; + let mut scenario = Scenario::start("file", &[actor]); + let create_space = scenario.request(actor, "space.create", json!({"schema": schema})); + let space_id = scenario.returned_string(create_space, "space_id", "missing-file-space"); + let put = scenario.request( + actor, + "file.put", + json!({"space_id": space_id, "bytes_base64": bytes_base64}), + ); + let digest = scenario.returned_string(put, "digest", "missing-file-digest"); + let get = scenario.request( + actor, + "file.get", + json!({"space_id": space_id, "digest": digest}), + ); + + scenario.verify::(create_space, |result| { + (!result.space_id.is_empty() && result.schema == schema) + .then_some(()) + .ok_or_else(|| "space.create did not return the file schema".to_owned()) + }); + scenario.verify::(put, |result| { + (result.space_id == space_id && valid_digest(&result.digest)) + .then_some(()) + .ok_or_else(|| "file.put returned an invalid content digest".to_owned()) + }); + scenario.verify::(get, |result| { + (result.space_id == space_id + && result.digest == digest + && result.bytes_base64 == bytes_base64) + .then_some(()) + .ok_or_else(|| "file.get did not return the stored bytes and digest".to_owned()) + }); + scenario.finish(); } #[test] fn runtime_member_invite_join_remove_are_red() { - for operation in ["member.invite", "member.join", "member.remove"] { - red_operation(operation); - } + let owner = "owner-membership-parametric"; + let member = "member-membership-parametric"; + let schema = concrete_schema("membership_records_parametric"); + let mut scenario = Scenario::start("membership", &[owner, member]); + let create_space = scenario.request(owner, "space.create", json!({"schema": schema})); + let space_id = scenario.returned_string(create_space, "space_id", "missing-member-space"); + let invite = scenario.request( + owner, + "member.invite", + json!({"space_id": space_id, "member_id": member}), + ); + let invite_value = + scenario.returned_value(invite, "invite", json!({"missing": "membership-invite"})); + let join = scenario.request( + member, + "member.join", + json!({"space_id": space_id, "invite": invite_value}), + ); + let remove = scenario.request( + owner, + "member.remove", + json!({"space_id": space_id, "member_id": member}), + ); + + scenario.verify::(create_space, |result| { + (!result.space_id.is_empty() && result.schema == schema) + .then_some(()) + .ok_or_else(|| "space.create did not return the membership schema".to_owned()) + }); + scenario.verify::(invite, |result| { + (result.space_id == space_id && result.member_id == member && !result.invite.is_null()) + .then_some(()) + .ok_or_else(|| "member.invite returned the wrong member invite".to_owned()) + }); + scenario.verify::(join, |result| { + (result.space_id == space_id && result.member_id == member && result.joined) + .then_some(()) + .ok_or_else(|| "member.join did not join the invited member".to_owned()) + }); + scenario.verify::(remove, |result| { + (result.space_id == space_id && result.member_id == member && result.removed) + .then_some(()) + .ok_or_else(|| "member.remove did not remove the joined member".to_owned()) + }); + scenario.finish(); } -#[test] -fn release_contract_is_red_until_dist_and_notice_exist() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); - let workflow = std::fs::read_to_string(root.join(".github/workflows/release-bridge.yml")) - .expect("release contract workflow"); - let matrix = - std::fs::read_to_string(root.join(".github/scripts/ci_matrix.json")).expect("CI matrix"); - let patches = std::fs::read_to_string(root.join("PATCHES.md")).expect("PATCHES ledger"); - let cargo = std::fs::read_to_string(root.join("Cargo.toml")).expect("workspace manifest"); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("workspace lockfile"); +#[derive(Deserialize)] +struct ReleaseManifest { + upstream_commit: String, + rust_toolchain: String, + artifacts: Vec, +} - for asset in ["backend", "bridge"] { - assert!(workflow.contains(asset), "workflow omits {asset} asset"); - } - for target in ["linux-amd64", "linux-arm64", "macos-amd64", "macos-arm64"] { - assert!(workflow.contains(target), "workflow omits {target}"); - } - for archive in [ +#[derive(Deserialize)] +struct ReleaseArtifact { + component: String, + target: String, + archive: String, + sha256: String, +} + +#[derive(Deserialize)] +struct ProvenanceStatement { + #[serde(rename = "_type")] + statement_type: String, + #[serde(rename = "predicateType")] + predicate_type: String, + subject: Vec, + predicate: ProvenancePredicate, +} + +#[derive(Deserialize)] +struct ProvenanceSubject { + name: String, + digest: ProvenanceDigest, +} + +#[derive(Deserialize)] +struct ProvenanceDigest { + sha256: String, +} + +#[derive(Deserialize)] +struct ProvenancePredicate { + #[serde(rename = "buildDefinition")] + build_definition: ProvenanceBuildDefinition, +} + +#[derive(Deserialize)] +struct ProvenanceBuildDefinition { + #[serde(rename = "externalParameters")] + external_parameters: ProvenanceParameters, +} + +#[derive(Deserialize)] +struct ProvenanceParameters { + upstream_commit: String, + rust_toolchain: String, +} + +const RELEASE_ARCHIVES: [(&str, &str, &str); 8] = [ + ( + "backend", + "linux-amd64", "encrypted-spaces-backend-linux-amd64.tar.gz", + ), + ( + "backend", + "linux-arm64", "encrypted-spaces-backend-linux-arm64.tar.gz", + ), + ( + "backend", + "macos-amd64", "encrypted-spaces-backend-macos-amd64.tar.gz", + ), + ( + "backend", + "macos-arm64", "encrypted-spaces-backend-macos-arm64.tar.gz", + ), + ( + "bridge", + "linux-amd64", "encrypted-spaces-bridge-linux-amd64.tar.gz", + ), + ( + "bridge", + "linux-arm64", "encrypted-spaces-bridge-linux-arm64.tar.gz", + ), + ( + "bridge", + "macos-amd64", "encrypted-spaces-bridge-macos-amd64.tar.gz", + ), + ( + "bridge", + "macos-arm64", "encrypted-spaces-bridge-macos-arm64.tar.gz", - ] { - assert!(workflow.contains(archive), "workflow omits {archive}"); + ), +]; + +fn nonempty(path: &Path) -> bool { + path.is_file() && fs::metadata(path).is_ok_and(|metadata| metadata.len() > 0) +} + +fn assert_pinned_actions(workflow: &str) { + let uses: Vec<&str> = workflow + .lines() + .map(str::trim) + .filter_map(|line| line.strip_prefix("uses: ")) + .collect(); + assert!(!uses.is_empty(), "release workflow has no actions"); + for action in uses { + let revision = action + .rsplit_once('@') + .map(|(_, revision)| revision) + .expect("action revision"); + assert!( + revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit()), + "release action is not pinned to a commit: {action}" + ); + } +} + +fn verify_checksum(dist: &Path, archive: &str, expected_digest: &str) { + let checksum = dist.join("checksums").join(format!("{archive}.sha256")); + let contents = fs::read_to_string(&checksum).expect("checksum file"); + let mut fields = contents.split_whitespace(); + assert_eq!( + fields.next(), + Some(expected_digest), + "{archive} checksum digest" + ); + assert_eq!( + fields.next().map(|name| name.trim_start_matches('*')), + Some(archive), + "{archive} checksum filename" + ); + assert!( + fields.next().is_none(), + "{archive} checksum has extra fields" + ); + + let status = if cfg!(target_os = "linux") { + Command::new("sha256sum") + .args(["-c", &format!("checksums/{archive}.sha256")]) + .current_dir(dist) + .status() + } else if cfg!(target_os = "macos") { + Command::new("shasum") + .args(["-a", "256", "-c", &format!("checksums/{archive}.sha256")]) + .current_dir(dist) + .status() + } else { + panic!("release checksum verification requires Linux or macOS"); } + .expect("execute checksum verifier"); + assert!( + status.success(), + "checksum verification failed for {archive}" + ); +} + +fn inspect_archive(dist: &Path, component: &str, archive: &str) { + let output = Command::new("tar") + .args(["-tzf", archive]) + .current_dir(dist) + .output() + .expect("list release archive"); + assert!( + output.status.success(), + "cannot list {archive}: {}", + String::from_utf8_lossy(&output.stderr) + ); + let listing = String::from_utf8(output.stdout).expect("archive listing is UTF-8"); + let binary = format!("encrypted-spaces-{component}"); + for required in [binary.as_str(), "LICENSE", "NOTICE"] { + assert!( + listing + .lines() + .any(|entry| entry.trim_end_matches('/').rsplit('/').next() == Some(required)), + "{archive} omits {required}" + ); + } +} + +fn verify_provenance(dist: &Path, archive: &str, expected_digest: &str) { + let path = dist + .join("provenance") + .join(format!("{archive}.intoto.jsonl")); + let contents = fs::read_to_string(path).expect("provenance JSONL"); + let statements: Vec = serde_json::Deserializer::from_str(&contents) + .into_iter() + .collect::>() + .expect("parse provenance JSONL"); + assert!(!statements.is_empty(), "{archive} provenance is empty"); + assert!( + statements.iter().any(|statement| { + statement.statement_type == "https://in-toto.io/Statement/v1" + && statement.predicate_type == "https://slsa.dev/provenance/v1" + && statement.subject.iter().any(|subject| { + subject.name == archive && subject.digest.sha256 == expected_digest + }) + && statement + .predicate + .build_definition + .external_parameters + .upstream_commit + == UPSTREAM_COMMIT + && statement + .predicate + .build_definition + .external_parameters + .rust_toolchain + == RUST_TOOLCHAIN + }), + "{archive} provenance does not bind its digest, upstream commit, and toolchain" + ); +} + +#[test] +fn release_contract_is_red_until_dist_and_notice_exist() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let dist = root.join("dist"); + let workflow = fs::read_to_string(root.join(".github/workflows/release-bridge.yml")) + .expect("release contract workflow"); + let patches = fs::read_to_string(root.join("PATCHES.md")).expect("PATCHES ledger"); + let cargo = fs::read_to_string(root.join("Cargo.toml")).expect("workspace manifest"); + let lock = fs::read_to_string(root.join("Cargo.lock")).expect("workspace lockfile"); + + assert!(workflow.contains("workflow_dispatch:")); + assert!( + !workflow.contains("\n push:"), + "release workflow must be manual-only" + ); + assert!( + !workflow.contains("\n release:"), + "release workflow must not publish" + ); + assert_pinned_actions(&workflow); + let legal = workflow.find(" legal:").expect("independent legal job"); + let assets = workflow.find(" assets:").expect("independent asset job"); + let aggregate = workflow.find(" aggregate:").expect("aggregate job"); + assert!( + legal < assets && assets < aggregate, + "release jobs have unexpected layout" + ); + assert!( + !workflow[assets..aggregate].contains("needs:"), + "asset checks must not depend on the legal gate" + ); for marker in [ - "DIST_CONTRACT: dist", - "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", - "test -s \"$manifest\"", - "test -s \"$archive\"", - "test -s \"$checksum\"", - "test -s \"$provenance\"", - "test -s \"$license\"", - "test -s \"$notice\"", - "sha256sum", - "provenance", - "LICENSE", - "NOTICE", - "4cda0ae", - "1.94.1", + "needs: [legal, assets]", + "if: ${{ always() }}", + "sha256sum -c", + "shasum -a 256 -c", + "jq -e", + "tar -tzf", "RISC0_SKIP_BUILD: 1", ] { + assert!(workflow.contains(marker), "release workflow omits {marker}"); + } + for (_, _, archive) in RELEASE_ARCHIVES { assert!( - workflow.contains(marker), - "workflow omits release marker {marker}" + workflow.contains(archive), + "release workflow omits {archive}" ); } - assert!(!workflow.contains("if: ${{ false }}")); - assert!(!workflow.contains("echo \"${{ matrix.component }}-${{ matrix.target }}\"")); - assert!(!workflow.contains("Future asset contract")); - assert!(workflow.contains("release_ready: false")); - assert!(workflow.contains("does not publish")); - assert!(matrix.contains("encrypted-spaces-bridge")); assert!(cargo.contains("kdl = { version = \"=6.5.0\"")); assert!(lock.contains("name = \"kdl\"\nversion = \"6.5.0\"")); - assert!(patches.contains("4cda0ae")); + assert!(patches.contains(UPSTREAM_COMMIT)); assert!(patches.contains("800495f")); - assert!(patches.contains("NOT_IMPLEMENTED")); + assert!(patches.contains(&format!("Rust `{RUST_TOOLCHAIN}`"))); - let mut missing = vec!["NOTICE".to_owned()]; - for asset in [ - "encrypted-spaces-backend-linux-amd64.tar.gz", - "encrypted-spaces-backend-linux-arm64.tar.gz", - "encrypted-spaces-backend-macos-amd64.tar.gz", - "encrypted-spaces-backend-macos-arm64.tar.gz", - "encrypted-spaces-bridge-linux-amd64.tar.gz", - "encrypted-spaces-bridge-linux-arm64.tar.gz", - "encrypted-spaces-bridge-macos-amd64.tar.gz", - "encrypted-spaces-bridge-macos-arm64.tar.gz", - ] { - let path = root.join("dist").join(asset); - if !path.is_file() || std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) == 0 { - missing.push(format!("dist/{asset}")); + let mut missing = Vec::new(); + let notice = root.join("NOTICE"); + if !nonempty(¬ice) { + missing.push("NOTICE".to_owned()); + } + let manifest_path = dist.join("release-manifest.json"); + if !nonempty(&manifest_path) { + missing.push("dist/release-manifest.json".to_owned()); + } + for (_, _, archive) in RELEASE_ARCHIVES { + for path in [ + dist.join(archive), + dist.join("checksums").join(format!("{archive}.sha256")), + dist.join("provenance") + .join(format!("{archive}.intoto.jsonl")), + ] { + if !nonempty(&path) { + missing.push( + path.strip_prefix(&root) + .unwrap_or(&path) + .display() + .to_string(), + ); + } } } assert!( - missing.len() == 0, - "release contract RED: missing NOTICE/dist release assets: {}", + missing.is_empty(), + "release contract RED: missing legal/release artifacts: {}", missing.join(", ") ); + + assert!( + nonempty(&root.join("LICENSE")), + "LICENSE is absent or empty" + ); + let manifest: ReleaseManifest = + serde_json::from_slice(&fs::read(&manifest_path).expect("read release manifest")) + .expect("parse release manifest"); + assert_eq!(manifest.upstream_commit, UPSTREAM_COMMIT); + assert_eq!(manifest.rust_toolchain, RUST_TOOLCHAIN); + assert_eq!(manifest.artifacts.len(), RELEASE_ARCHIVES.len()); + for (component, target, archive) in RELEASE_ARCHIVES { + let artifact = manifest + .artifacts + .iter() + .find(|artifact| artifact.archive == archive) + .unwrap_or_else(|| panic!("release manifest omits {archive}")); + assert_eq!(artifact.component, component, "{archive} component"); + assert_eq!(artifact.target, target, "{archive} target"); + assert!(valid_digest(&artifact.sha256), "{archive} invalid sha256"); + verify_checksum(&dist, archive, &artifact.sha256); + verify_provenance(&dist, archive, &artifact.sha256); + inspect_archive(&dist, component, archive); + } } From 13fff778666a405246ee9cbc31736f49c2c8c765 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 02:45:54 -0400 Subject: [PATCH 06/35] test(bridge): bind schema at process start --- bridge/tests/runtime.rs | 379 ++++++++++++++++++++++++++-------------- 1 file changed, 246 insertions(+), 133 deletions(-) diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index d34fac1..dfafde1 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -4,7 +4,7 @@ use serde_json::{json, Value}; use std::collections::BTreeMap; use std::fs; use std::io::{BufRead, BufReader, Write}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; const PROTOCOL_VERSION: u64 = 1; @@ -17,12 +17,21 @@ struct BridgeProcess { stdout: BufReader, scenario: String, next_request: usize, + schema_path: PathBuf, } impl BridgeProcess { fn spawn(scenario: &str, actor: &str) -> Self { + let schema_path = std::env::temp_dir().join(format!( + "encrypted-spaces-bridge-{}-{}-{}.kdl", + std::process::id(), + scenario, + actor + )); + fs::write(&schema_path, SCHEMA_KDL).expect("write bridge schema fixture"); let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) .env("ENCRYPTED_SPACES_ACTOR_ID", actor) + .env("ENCRYPTED_SPACES_SCHEMA_PATH", &schema_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -35,6 +44,7 @@ impl BridgeProcess { stdout, scenario: format!("{scenario}-{actor}"), next_request: 1, + schema_path, } } @@ -97,6 +107,7 @@ impl BridgeProcess { fn finish(mut self) { drop(self.stdin.take()); let status = self.child.wait().expect("wait for bridge"); + fs::remove_file(&self.schema_path).expect("remove bridge schema fixture"); assert!(status.success(), "bridge exited with {status}"); } } @@ -215,7 +226,7 @@ struct HelloResult { #[derive(Deserialize)] struct SpaceCreateResult { space_id: String, - schema: Value, + schema_sha256: String, } #[derive(Deserialize)] @@ -233,71 +244,90 @@ struct RestoreResult { #[derive(Deserialize)] struct InviteResult { space_id: String, - member_id: String, + member_id: i64, invite: Value, } #[derive(Deserialize)] struct JoinResult { space_id: String, - member_id: String, + member_id: i64, joined: bool, } #[derive(Deserialize)] struct SyncResult { space_id: String, - change_id: u64, + synced: bool, } #[derive(Deserialize)] struct RemoveResult { space_id: String, - member_id: String, + member_id: i64, removed: bool, } #[derive(Deserialize)] struct TableInsertResult { - space_id: String, - table: String, - row_id: Value, - row: Value, + row_id: i64, } #[derive(Deserialize)] struct TableSelectResult { - space_id: String, - table: String, rows: Vec, } #[derive(Deserialize)] struct ListCreateResult { space_id: String, - list_id: String, - name: String, + table: String, + row_id: i64, + column: String, + list_ref: Value, } #[derive(Deserialize)] struct ListAppendResult { space_id: String, - list_id: String, - index: usize, + list_ref: Value, + item_ref: Value, +} + +#[derive(Deserialize)] +struct ListItemResult { + item_ref: Value, + position: u64, value: Value, } #[derive(Deserialize)] struct ListReadResult { space_id: String, - list_id: String, - items: Vec, + list_ref: Value, + items: Vec, +} + +#[derive(Deserialize)] +struct TextCreateResult { + space_id: String, + table: String, + row_id: i64, + column: String, + text_ref: Value, +} + +#[derive(Deserialize)] +struct TextEditResult { + space_id: String, + text_ref: Value, + edited: bool, } #[derive(Deserialize)] -struct TextResult { +struct TextReadResult { space_id: String, - text_id: String, + text_ref: Value, text: String, } @@ -314,22 +344,70 @@ struct FileGetResult { bytes_base64: String, } -fn concrete_schema(name: &str) -> Value { +const TABLE: &str = "bridge_records"; +const LIST_COLUMN: &str = "items"; +const TEXT_COLUMN: &str = "document"; +const FILE_DIGEST_PLACEHOLDER: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const SCHEMA_KDL: &str = r#" +table "bridge_records" auto_increment=#true { + column "id" type="int" plaintext=#true + column "rank" type="int" + column "label" type="text" + column "items" type="list" + column "document" type="list" + column "attachment" type="fileref" +} +"#; +fn create_payload() -> Value { + json!({}) +} + +fn join_payload(invite: Value) -> Value { + json!({"invite": invite}) +} + +fn parent_row(label: &str, rank: i64) -> Value { json!({ - "tables": [{ - "name": name, - "columns": [ - {"name": "id", "type": "integer", "primary_key": true}, - {"name": "label", "type": "text", "nullable": false} - ] - }] + "rank": rank, + "label": label, + "items": 0, + "document": 0, + "attachment": FILE_DIGEST_PLACEHOLDER, }) } +fn valid_space_id(space_id: &str) -> bool { + space_id.len() == 32 && space_id.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn valid_created_space(result: &SpaceCreateResult) -> bool { + valid_space_id(&result.space_id) && valid_digest(&result.schema_sha256) +} + fn valid_digest(digest: &str) -> bool { digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) } +fn is_opaque_ref(value: &Value) -> bool { + match value { + Value::Null => false, + Value::String(value) => !value.is_empty(), + Value::Array(value) => !value.is_empty(), + Value::Object(value) => !value.is_empty(), + Value::Bool(_) | Value::Number(_) => true, + } +} + +fn row_matches(row: &Value, row_id: i64, label: &str, rank: i64) -> bool { + row["id"].as_i64() == Some(row_id) + && row["rank"].as_i64() == Some(rank) + && row["label"].as_str() == Some(label) + && !row["items"].is_null() + && !row["document"].is_null() + && row["attachment"].as_str() == Some(FILE_DIGEST_PLACEHOLDER) +} + #[test] fn runtime_request_actor_override_is_rejected() { let configured_actor = "actor-configured-parametric"; @@ -351,11 +429,10 @@ fn runtime_request_actor_override_is_rejected() { fn runtime_space_lifecycle_is_red() { let owner = "owner-lifecycle-parametric"; let member = "member-lifecycle-parametric"; - let schema = concrete_schema("lifecycle_records"); let mut scenario = Scenario::start("lifecycle", &[owner, member]); let hello = scenario.request(owner, "hello", json!({})); - let create = scenario.request(owner, "space.create", json!({"schema": schema})); + let create = scenario.request(owner, "space.create", create_payload()); let space_id = scenario.returned_string(create, "space_id", "missing-lifecycle-space"); let snapshot = scenario.request(owner, "space.snapshot", json!({"space_id": space_id})); let snapshot_value = scenario.returned_value( @@ -363,28 +440,19 @@ fn runtime_space_lifecycle_is_red() { "snapshot", json!({"missing": "lifecycle-snapshot"}), ); - let restore = scenario.request( - owner, - "space.restore", - json!({"space_id": space_id, "snapshot": snapshot_value}), - ); - let invite = scenario.request( - owner, - "member.invite", - json!({"space_id": space_id, "member_id": member}), - ); + let restore = scenario.request(owner, "space.restore", json!({"snapshot": snapshot_value})); + let invite = scenario.request(owner, "member.invite", json!({"space_id": space_id})); + let member_id = scenario.observations[invite].response["result"]["member_id"] + .as_i64() + .unwrap_or(-1); let invite_value = scenario.returned_value(invite, "invite", json!({"missing": "lifecycle-invite"})); - let join = scenario.request( - member, - "space.join", - json!({"space_id": space_id, "invite": invite_value}), - ); + let join = scenario.request(member, "space.join", join_payload(invite_value)); let sync = scenario.request(member, "space.sync", json!({"space_id": space_id})); let remove = scenario.request( owner, "member.remove", - json!({"space_id": space_id, "member_id": member}), + json!({"space_id": space_id, "member_id": member_id}), ); scenario.verify::(hello, |result| { @@ -393,9 +461,9 @@ fn runtime_space_lifecycle_is_red() { .ok_or_else(|| "hello returned the wrong protocol version".to_owned()) }); scenario.verify::(create, |result| { - (!result.space_id.is_empty() && result.schema == schema) + valid_created_space(result) .then_some(()) - .ok_or_else(|| "space.create did not return its concrete schema".to_owned()) + .ok_or_else(|| "space.create returned an invalid SpaceId".to_owned()) }); scenario.verify::(snapshot, |result| { (result.space_id == space_id && !result.snapshot.is_null()) @@ -408,22 +476,22 @@ fn runtime_space_lifecycle_is_red() { .ok_or_else(|| "space.restore did not restore the returned snapshot".to_owned()) }); scenario.verify::(invite, |result| { - (result.space_id == space_id && result.member_id == member && !result.invite.is_null()) + (result.space_id == space_id && result.member_id > 0 && is_opaque_ref(&result.invite)) .then_some(()) - .ok_or_else(|| "member.invite returned an invalid invite".to_owned()) + .ok_or_else(|| "member.invite returned no numeric member ID or invite".to_owned()) }); scenario.verify::(join, |result| { - (result.space_id == space_id && result.member_id == member && result.joined) + (result.space_id == space_id && result.member_id == member_id && result.joined) .then_some(()) .ok_or_else(|| "space.join did not consume the returned invite".to_owned()) }); scenario.verify::(sync, |result| { - (result.space_id == space_id && result.change_id > 0) + (result.space_id == space_id && result.synced) .then_some(()) - .ok_or_else(|| "space.sync returned no applied change".to_owned()) + .ok_or_else(|| "space.sync did not report a completed sync".to_owned()) }); scenario.verify::(remove, |result| { - (result.space_id == space_id && result.member_id == member && result.removed) + (result.space_id == space_id && result.member_id == member_id && result.removed) .then_some(()) .ok_or_else(|| "member.remove did not remove the joined member".to_owned()) }); @@ -433,42 +501,40 @@ fn runtime_space_lifecycle_is_red() { #[test] fn runtime_table_insert_select_are_red() { let actor = "actor-table-parametric"; - let schema = concrete_schema("records_parametric"); - let row = json!({"id": 41, "label": "table-value-parametric"}); + let label = "table-value-parametric"; + let rank = 41; + let row = parent_row(label, rank); let mut scenario = Scenario::start("table", &[actor]); - let create = scenario.request(actor, "space.create", json!({"schema": schema})); + let create = scenario.request(actor, "space.create", create_payload()); let space_id = scenario.returned_string(create, "space_id", "missing-table-space"); let insert = scenario.request( actor, "table.insert", - json!({"space_id": space_id, "table": "records_parametric", "row": row}), + json!({"space_id": space_id, "table": TABLE, "row": row}), ); - let row_id = scenario.returned_value(insert, "row_id", json!(41)); + let row_id = scenario.observations[insert].response["result"]["row_id"] + .as_i64() + .unwrap_or(-1); let select = scenario.request( actor, "table.select", - json!({"space_id": space_id, "table": "records_parametric", "where": {"id": row_id}}), + json!({"space_id": space_id, "table": TABLE, "where": {"id": row_id}}), ); scenario.verify::(create, |result| { - (!result.space_id.is_empty() && result.schema == schema) + valid_created_space(result) .then_some(()) - .ok_or_else(|| "space.create did not return the table schema".to_owned()) + .ok_or_else(|| "space.create returned an invalid SpaceId".to_owned()) }); scenario.verify::(insert, |result| { - (result.space_id == space_id - && result.table == "records_parametric" - && result.row_id == json!(41) - && result.row == row) + (result.row_id > 0) .then_some(()) - .ok_or_else(|| "table.insert returned the wrong typed row".to_owned()) + .ok_or_else(|| "table.insert returned no auto-assigned row ID".to_owned()) }); scenario.verify::(select, |result| { - (result.space_id == space_id - && result.table == "records_parametric" - && result.rows == vec![row]) - .then_some(()) - .ok_or_else(|| "table.select did not return the inserted row".to_owned()) + (result.rows.len() == 1 && row_matches(&result.rows[0], row_id, label, rank)) + .then_some(()) + .ok_or_else(|| "table.select did not return the auto-ID row content".to_owned()) }); scenario.finish(); } @@ -476,50 +542,79 @@ fn runtime_table_insert_select_are_red() { #[test] fn runtime_list_create_append_read_are_red() { let actor = "actor-list-parametric"; - let schema = concrete_schema("list_records_parametric"); + let label = "list-parent-parametric"; + let rank = 73; let item = json!({"key": "list-key-parametric", "value": 73}); let mut scenario = Scenario::start("list", &[actor]); - let create_space = scenario.request(actor, "space.create", json!({"schema": schema})); + let create_space = scenario.request(actor, "space.create", create_payload()); let space_id = scenario.returned_string(create_space, "space_id", "missing-list-space"); + let insert = scenario.request( + actor, + "table.insert", + json!({"space_id": space_id, "table": TABLE, "row": parent_row(label, rank)}), + ); + let row_id = scenario.observations[insert].response["result"]["row_id"] + .as_i64() + .unwrap_or(-1); let create = scenario.request( actor, "list.create", - json!({"space_id": space_id, "name": "queue_parametric"}), + json!({"space_id": space_id, "table": TABLE, "row_id": row_id, "column": LIST_COLUMN}), + ); + let list_ref = scenario.returned_value( + create, + "list_ref", + json!({"missing": "list-handle-reference"}), ); - let list_id = scenario.returned_string(create, "list_id", "missing-list-id"); let append = scenario.request( actor, "list.append", - json!({"space_id": space_id, "list_id": list_id, "value": item}), + json!({"space_id": space_id, "list_ref": list_ref, "value": item}), + ); + let item_ref = scenario.returned_value( + append, + "item_ref", + json!({"missing": "list-item-reference"}), ); let read = scenario.request( actor, "list.read", - json!({"space_id": space_id, "list_id": list_id}), + json!({"space_id": space_id, "list_ref": list_ref}), ); scenario.verify::(create_space, |result| { - (!result.space_id.is_empty() && result.schema == schema) + valid_created_space(result) .then_some(()) - .ok_or_else(|| "space.create did not return the list schema".to_owned()) + .ok_or_else(|| "space.create returned an invalid SpaceId".to_owned()) + }); + scenario.verify::(insert, |result| { + (result.row_id > 0) + .then_some(()) + .ok_or_else(|| "list parent row has no auto-assigned ID".to_owned()) }); scenario.verify::(create, |result| { (result.space_id == space_id - && !result.list_id.is_empty() - && result.name == "queue_parametric") - .then_some(()) - .ok_or_else(|| "list.create returned the wrong list".to_owned()) + && result.table == TABLE + && result.row_id == row_id + && result.column == LIST_COLUMN + && is_opaque_ref(&result.list_ref)) + .then_some(()) + .ok_or_else(|| "list.create returned no scoped Space list handle".to_owned()) }); scenario.verify::(append, |result| { (result.space_id == space_id - && result.list_id == list_id - && result.index == 0 - && result.value == item) - .then_some(()) - .ok_or_else(|| "list.append returned the wrong item".to_owned()) + && result.list_ref == list_ref + && is_opaque_ref(&result.item_ref)) + .then_some(()) + .ok_or_else(|| "list.append returned no SDK list item reference".to_owned()) }); scenario.verify::(read, |result| { - (result.space_id == space_id && result.list_id == list_id && result.items == vec![item]) + (result.space_id == space_id + && result.list_ref == list_ref + && result.items.len() == 1 + && result.items[0].item_ref == item_ref + && result.items[0].position == 0 + && result.items[0].value == item) .then_some(()) .ok_or_else(|| "list.read did not return the appended item".to_owned()) }); @@ -529,48 +624,73 @@ fn runtime_list_create_append_read_are_red() { #[test] fn runtime_text_create_edit_read_are_red() { let actor = "actor-text-parametric"; - let schema = concrete_schema("text_records_parametric"); - let initial = "initial text parametric"; let edited = "edited text parametric"; let mut scenario = Scenario::start("text", &[actor]); - let create_space = scenario.request(actor, "space.create", json!({"schema": schema})); + let create_space = scenario.request(actor, "space.create", create_payload()); let space_id = scenario.returned_string(create_space, "space_id", "missing-text-space"); + let insert = scenario.request( + actor, + "table.insert", + json!({"space_id": space_id, "table": TABLE, "row": parent_row("text-parent-parametric", 89)}), + ); + let row_id = scenario.observations[insert].response["result"]["row_id"] + .as_i64() + .unwrap_or(-1); let create = scenario.request( actor, "text.create", - json!({"space_id": space_id, "name": "document_parametric", "text": initial}), + json!({"space_id": space_id, "table": TABLE, "row_id": row_id, "column": TEXT_COLUMN}), + ); + let text_ref = scenario.returned_value( + create, + "text_ref", + json!({"missing": "text-handle-reference"}), ); - let text_id = scenario.returned_string(create, "text_id", "missing-text-id"); let edit = scenario.request( actor, "text.edit", - json!({"space_id": space_id, "text_id": text_id, "text": edited}), + json!({ + "space_id": space_id, + "text_ref": text_ref, + "position": 0, + "delete_count": 0, + "insert": edited, + }), ); let read = scenario.request( actor, "text.read", - json!({"space_id": space_id, "text_id": text_id}), + json!({"space_id": space_id, "text_ref": text_ref}), ); scenario.verify::(create_space, |result| { - (!result.space_id.is_empty() && result.schema == schema) + valid_created_space(result) .then_some(()) - .ok_or_else(|| "space.create did not return the text schema".to_owned()) + .ok_or_else(|| "space.create returned an invalid SpaceId".to_owned()) }); - scenario.verify::(create, |result| { - (result.space_id == space_id && !result.text_id.is_empty() && result.text == initial) + scenario.verify::(insert, |result| { + (result.row_id > 0) .then_some(()) - .ok_or_else(|| "text.create returned the wrong document".to_owned()) + .ok_or_else(|| "text parent row has no auto-assigned ID".to_owned()) + }); + scenario.verify::(create, |result| { + (result.space_id == space_id + && result.table == TABLE + && result.row_id == row_id + && result.column == TEXT_COLUMN + && is_opaque_ref(&result.text_ref)) + .then_some(()) + .ok_or_else(|| "text.create returned no scoped Space textarea handle".to_owned()) }); - scenario.verify::(edit, |result| { - (result.space_id == space_id && result.text_id == text_id && result.text == edited) + scenario.verify::(edit, |result| { + (result.space_id == space_id && result.text_ref == text_ref && result.edited) .then_some(()) - .ok_or_else(|| "text.edit returned the wrong document".to_owned()) + .ok_or_else(|| "text.edit did not apply the positional edit".to_owned()) }); - scenario.verify::(read, |result| { - (result.space_id == space_id && result.text_id == text_id && result.text == edited) + scenario.verify::(read, |result| { + (result.space_id == space_id && result.text_ref == text_ref && result.text == edited) .then_some(()) - .ok_or_else(|| "text.read did not return the edited document".to_owned()) + .ok_or_else(|| "text.read did not return the edited textarea".to_owned()) }); scenario.finish(); } @@ -578,10 +698,9 @@ fn runtime_text_create_edit_read_are_red() { #[test] fn runtime_file_put_get_are_red() { let actor = "actor-file-parametric"; - let schema = concrete_schema("file_records_parametric"); let bytes_base64 = "YnJpZGdlLWZpbGUtcGFyYW1ldHJpYw=="; let mut scenario = Scenario::start("file", &[actor]); - let create_space = scenario.request(actor, "space.create", json!({"schema": schema})); + let create_space = scenario.request(actor, "space.create", create_payload()); let space_id = scenario.returned_string(create_space, "space_id", "missing-file-space"); let put = scenario.request( actor, @@ -596,9 +715,9 @@ fn runtime_file_put_get_are_red() { ); scenario.verify::(create_space, |result| { - (!result.space_id.is_empty() && result.schema == schema) + valid_created_space(result) .then_some(()) - .ok_or_else(|| "space.create did not return the file schema".to_owned()) + .ok_or_else(|| "space.create returned an invalid SpaceId".to_owned()) }); scenario.verify::(put, |result| { (result.space_id == space_id && valid_digest(&result.digest)) @@ -616,48 +735,42 @@ fn runtime_file_put_get_are_red() { } #[test] -fn runtime_member_invite_join_remove_are_red() { +fn runtime_member_invite_space_join_remove_are_red() { let owner = "owner-membership-parametric"; let member = "member-membership-parametric"; - let schema = concrete_schema("membership_records_parametric"); let mut scenario = Scenario::start("membership", &[owner, member]); - let create_space = scenario.request(owner, "space.create", json!({"schema": schema})); + let create_space = scenario.request(owner, "space.create", create_payload()); let space_id = scenario.returned_string(create_space, "space_id", "missing-member-space"); - let invite = scenario.request( - owner, - "member.invite", - json!({"space_id": space_id, "member_id": member}), - ); + let invite = scenario.request(owner, "member.invite", json!({"space_id": space_id})); + let member_id = scenario.observations[invite].response["result"]["member_id"] + .as_i64() + .unwrap_or(-1); let invite_value = scenario.returned_value(invite, "invite", json!({"missing": "membership-invite"})); - let join = scenario.request( - member, - "member.join", - json!({"space_id": space_id, "invite": invite_value}), - ); + let join = scenario.request(member, "space.join", join_payload(invite_value)); let remove = scenario.request( owner, "member.remove", - json!({"space_id": space_id, "member_id": member}), + json!({"space_id": space_id, "member_id": member_id}), ); scenario.verify::(create_space, |result| { - (!result.space_id.is_empty() && result.schema == schema) + valid_created_space(result) .then_some(()) - .ok_or_else(|| "space.create did not return the membership schema".to_owned()) + .ok_or_else(|| "space.create returned an invalid SpaceId".to_owned()) }); scenario.verify::(invite, |result| { - (result.space_id == space_id && result.member_id == member && !result.invite.is_null()) + (result.space_id == space_id && result.member_id > 0 && is_opaque_ref(&result.invite)) .then_some(()) - .ok_or_else(|| "member.invite returned the wrong member invite".to_owned()) + .ok_or_else(|| "member.invite returned no numeric member ID or invite".to_owned()) }); scenario.verify::(join, |result| { - (result.space_id == space_id && result.member_id == member && result.joined) + (result.space_id == space_id && result.member_id == member_id && result.joined) .then_some(()) - .ok_or_else(|| "member.join did not join the invited member".to_owned()) + .ok_or_else(|| "space.join did not join the invited SDK member".to_owned()) }); scenario.verify::(remove, |result| { - (result.space_id == space_id && result.member_id == member && result.removed) + (result.space_id == space_id && result.member_id == member_id && result.removed) .then_some(()) .ok_or_else(|| "member.remove did not remove the joined member".to_owned()) }); From 714b84d68721805b9e1991004c4883ca005a88d7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 02:57:17 -0400 Subject: [PATCH 07/35] test(bridge): bind trust and restore state --- bridge/tests/runtime.rs | 70 +++++++++++++++++++++++++++++++++++++++-- bridge/tests/stdio.rs | 42 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index dfafde1..f99d04f 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -221,6 +221,10 @@ impl Scenario { #[derive(Deserialize)] struct HelloResult { protocol_version: u64, + actor_id: String, + schema_sha256: String, + data_commitment: String, + ff_guest_image_id: Vec, } #[derive(Deserialize)] @@ -425,6 +429,25 @@ fn runtime_request_actor_override_is_rejected() { assert_eq!(observation.response["error"]["code"], "INVALID_REQUEST"); } +#[test] +fn runtime_request_trust_override_is_rejected() { + let mut bridge = BridgeProcess::spawn("trust-override", "actor-trust-parametric"); + let observation = bridge.exchange( + "space.create", + json!({ + "schema_kdl": "table \"attacker\" {}", + "data_commitment": "00".repeat(32), + "ff_guest_image_id": [1, 2, 3, 4, 5, 6, 7, 8], + }), + ); + bridge.finish(); + + assert_eq!(observation.response["version"], PROTOCOL_VERSION); + assert_eq!(observation.response["request_id"], observation.request_id); + assert_eq!(observation.response["ok"], false); + assert_eq!(observation.response["error"]["code"], "INVALID_REQUEST"); +} + #[test] fn runtime_space_lifecycle_is_red() { let owner = "owner-lifecycle-parametric"; @@ -434,6 +457,20 @@ fn runtime_space_lifecycle_is_red() { let hello = scenario.request(owner, "hello", json!({})); let create = scenario.request(owner, "space.create", create_payload()); let space_id = scenario.returned_string(create, "space_id", "missing-lifecycle-space"); + let snapshot_label = "snapshot-state-parametric"; + let snapshot_rank = 211; + let snapshot_insert = scenario.request( + owner, + "table.insert", + json!({ + "space_id": space_id, + "table": TABLE, + "row": parent_row(snapshot_label, snapshot_rank), + }), + ); + let snapshot_row_id = scenario.observations[snapshot_insert].response["result"]["row_id"] + .as_i64() + .unwrap_or(-1); let snapshot = scenario.request(owner, "space.snapshot", json!({"space_id": space_id})); let snapshot_value = scenario.returned_value( snapshot, @@ -441,6 +478,15 @@ fn runtime_space_lifecycle_is_red() { json!({"missing": "lifecycle-snapshot"}), ); let restore = scenario.request(owner, "space.restore", json!({"snapshot": snapshot_value})); + let restored_select = scenario.request( + owner, + "table.select", + json!({ + "space_id": space_id, + "table": TABLE, + "where": {"id": snapshot_row_id}, + }), + ); let invite = scenario.request(owner, "member.invite", json!({"space_id": space_id})); let member_id = scenario.observations[invite].response["result"]["member_id"] .as_i64() @@ -456,9 +502,13 @@ fn runtime_space_lifecycle_is_red() { ); scenario.verify::(hello, |result| { - (result.protocol_version == PROTOCOL_VERSION) + (result.protocol_version == PROTOCOL_VERSION + && result.actor_id == owner + && valid_digest(&result.schema_sha256) + && valid_digest(&result.data_commitment) + && result.ff_guest_image_id.len() == 8) .then_some(()) - .ok_or_else(|| "hello returned the wrong protocol version".to_owned()) + .ok_or_else(|| "hello returned invalid process-bound trust metadata".to_owned()) }); scenario.verify::(create, |result| { valid_created_space(result) @@ -470,11 +520,27 @@ fn runtime_space_lifecycle_is_red() { .then_some(()) .ok_or_else(|| "space.snapshot returned the wrong space or no snapshot".to_owned()) }); + scenario.verify::(snapshot_insert, |result| { + (result.row_id > 0) + .then_some(()) + .ok_or_else(|| "snapshot fixture row has no auto-assigned ID".to_owned()) + }); scenario.verify::(restore, |result| { (result.space_id == space_id && result.restored) .then_some(()) .ok_or_else(|| "space.restore did not restore the returned snapshot".to_owned()) }); + scenario.verify::(restored_select, |result| { + (result.rows.len() == 1 + && row_matches( + &result.rows[0], + snapshot_row_id, + snapshot_label, + snapshot_rank, + )) + .then_some(()) + .ok_or_else(|| "restored space did not retain the pre-snapshot row".to_owned()) + }); scenario.verify::(invite, |result| { (result.space_id == space_id && result.member_id > 0 && is_opaque_ref(&result.invite)) .then_some(()) diff --git a/bridge/tests/stdio.rs b/bridge/tests/stdio.rs index 3d19815..cef6f9c 100644 --- a/bridge/tests/stdio.rs +++ b/bridge/tests/stdio.rs @@ -30,6 +30,10 @@ impl Bridge { fn spawn(actor_id: &str) -> Self { let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) .env("ENCRYPTED_SPACES_ACTOR_ID", actor_id) + .env( + "ENCRYPTED_SPACES_SCHEMA_PATH", + concat!(env!("CARGO_MANIFEST_DIR"), "/../demos/tauri/app_schema.kdl"), + ) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -423,6 +427,44 @@ fn protocol_validation_errors_redact_secret_material() { assert!(!stderr.contains(secret), "secret leaked in bridge stderr"); } +#[test] +fn protocol_runtime_sdk_errors_are_deterministic_and_redacted_is_red() { + let secrets = ["runtime-secret-alpha", "runtime-secret-beta"]; + let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + + for (index, secret) in secrets.iter().enumerate() { + bridge.send_request( + &format!("restore-secret-{index}"), + "space.restore", + json!({"snapshot": {"secret": secret}}), + ); + } + + let responses = [bridge.receive(), bridge.receive()]; + for (index, response) in responses.iter().enumerate() { + assert_eq!(response["request_id"], format!("restore-secret-{index}")); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "SDK_ERROR"); + assert_eq!( + response["error"]["message"], + "encrypted spaces operation failed" + ); + for secret in secrets { + assert!( + !response.to_string().contains(secret), + "secret leaked in runtime response" + ); + } + } + assert_eq!(responses[0]["error"], responses[1]["error"]); + + let (status, stderr) = bridge.finish(); + assert!(status.success(), "bridge exited with {status}"); + for secret in secrets { + assert!(!stderr.contains(secret), "secret leaked in bridge stderr"); + } +} + #[test] fn protocol_close_is_red() { let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); From d86096860a9f89f48df9fcd45efb9f028d35e241 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 03:01:29 -0400 Subject: [PATCH 08/35] test(bridge): derive trust across restarts --- Cargo.lock | 1 + bridge/Cargo.toml | 1 + bridge/tests/runtime.rs | 66 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 097642f..2fa97c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2249,6 +2249,7 @@ version = "0.0.1" dependencies = [ "serde", "serde_json", + "sha2 0.10.9", ] [[package]] diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml index 54e3ff1..0d4eaa6 100644 --- a/bridge/Cargo.toml +++ b/bridge/Cargo.toml @@ -13,3 +13,4 @@ path = "src/main.rs" [dependencies] serde.workspace = true serde_json.workspace = true +sha2.workspace = true diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index f99d04f..9f21201 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -1,6 +1,7 @@ use serde::de::DeserializeOwned; use serde::Deserialize; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::fs; use std::io::{BufRead, BufReader, Write}; @@ -22,13 +23,17 @@ struct BridgeProcess { impl BridgeProcess { fn spawn(scenario: &str, actor: &str) -> Self { + Self::spawn_with_schema(scenario, actor, SCHEMA_KDL) + } + + fn spawn_with_schema(scenario: &str, actor: &str, schema: &str) -> Self { let schema_path = std::env::temp_dir().join(format!( "encrypted-spaces-bridge-{}-{}-{}.kdl", std::process::id(), scenario, actor )); - fs::write(&schema_path, SCHEMA_KDL).expect("write bridge schema fixture"); + fs::write(&schema_path, schema).expect("write bridge schema fixture"); let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) .env("ENCRYPTED_SPACES_ACTOR_ID", actor) .env("ENCRYPTED_SPACES_SCHEMA_PATH", &schema_path) @@ -119,6 +124,7 @@ struct Observation { } struct Scenario { + name: String, bridges: BTreeMap, observations: Vec, failures: Vec, @@ -131,6 +137,7 @@ impl Scenario { .map(|actor| ((*actor).to_owned(), BridgeProcess::spawn(name, actor))) .collect(); Self { + name: name.to_owned(), bridges, observations: Vec::new(), failures: Vec::new(), @@ -164,6 +171,16 @@ impl Scenario { self.observations.len() - 1 } + fn restart(&mut self, actor: &str) { + let previous = self + .bridges + .remove(actor) + .unwrap_or_else(|| panic!("scenario actor {actor} has no bridge process")); + previous.finish(); + self.bridges + .insert(actor.to_owned(), BridgeProcess::spawn(&self.name, actor)); + } + fn returned_string(&self, index: usize, field: &str, fallback: &str) -> String { self.observations[index].response["result"][field] .as_str() @@ -393,6 +410,10 @@ fn valid_digest(digest: &str) -> bool { digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) } +fn schema_digest(schema: &str) -> String { + format!("{:x}", Sha256::digest(schema.as_bytes())) +} + fn is_opaque_ref(value: &Value) -> bool { match value { Value::Null => false, @@ -448,6 +469,48 @@ fn runtime_request_trust_override_is_rejected() { assert_eq!(observation.response["error"]["code"], "INVALID_REQUEST"); } +#[test] +fn runtime_process_trust_metadata_is_derived_and_stable_is_red() { + let modified_schema = format!( + "{SCHEMA_KDL}\ntable \"schema_change_probe\" {{\n column \"id\" type=\"int\" plaintext=#true\n}}\n" + ); + let mut first = BridgeProcess::spawn_with_schema("trust-first", "actor-first", SCHEMA_KDL); + let mut second = BridgeProcess::spawn_with_schema("trust-second", "actor-second", SCHEMA_KDL); + let mut changed = + BridgeProcess::spawn_with_schema("trust-changed", "actor-changed", &modified_schema); + + let first_response = first.exchange("hello", json!({})).response; + let second_response = second.exchange("hello", json!({})).response; + let changed_response = changed.exchange("hello", json!({})).response; + first.finish(); + second.finish(); + changed.finish(); + + for response in [&first_response, &second_response, &changed_response] { + assert_eq!(response["ok"], true, "hello trust metadata is still RED"); + } + let first: HelloResult = + serde_json::from_value(first_response["result"].clone()).expect("first hello result"); + let second: HelloResult = + serde_json::from_value(second_response["result"].clone()).expect("second hello result"); + let changed: HelloResult = + serde_json::from_value(changed_response["result"].clone()).expect("changed hello result"); + + assert_eq!(first.actor_id, "actor-first"); + assert_eq!(second.actor_id, "actor-second"); + assert_eq!(changed.actor_id, "actor-changed"); + assert_eq!(first.schema_sha256, schema_digest(SCHEMA_KDL)); + assert_eq!(second.schema_sha256, first.schema_sha256); + assert_eq!(changed.schema_sha256, schema_digest(&modified_schema)); + assert_ne!(changed.schema_sha256, first.schema_sha256); + assert!(valid_digest(&first.data_commitment)); + assert_eq!(second.data_commitment, first.data_commitment); + assert_ne!(changed.data_commitment, first.data_commitment); + assert_eq!(first.ff_guest_image_id.len(), 8); + assert_eq!(second.ff_guest_image_id, first.ff_guest_image_id); + assert_eq!(changed.ff_guest_image_id, first.ff_guest_image_id); +} + #[test] fn runtime_space_lifecycle_is_red() { let owner = "owner-lifecycle-parametric"; @@ -477,6 +540,7 @@ fn runtime_space_lifecycle_is_red() { "snapshot", json!({"missing": "lifecycle-snapshot"}), ); + scenario.restart(owner); let restore = scenario.request(owner, "space.restore", json!({"snapshot": snapshot_value})); let restored_select = scenario.request( owner, From b0c952d8555a22166f93baa66f71371dae0ce98d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 03:05:40 -0400 Subject: [PATCH 09/35] test(bridge): verify trust with SDK oracle --- Cargo.lock | 3 +++ bridge/Cargo.toml | 5 +++++ bridge/tests/runtime.rs | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2fa97c7..d542e3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2247,9 +2247,12 @@ dependencies = [ name = "encrypted-spaces-bridge" version = "0.0.1" dependencies = [ + "encrypted-spaces-ffproof", + "encrypted-spaces-sdk", "serde", "serde_json", "sha2 0.10.9", + "tokio", ] [[package]] diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml index 0d4eaa6..aa19f68 100644 --- a/bridge/Cargo.toml +++ b/bridge/Cargo.toml @@ -14,3 +14,8 @@ path = "src/main.rs" serde.workspace = true serde_json.workspace = true sha2.workspace = true + +[dev-dependencies] +encrypted-spaces-ffproof = { workspace = true, features = ["prove"] } +encrypted-spaces-sdk = { workspace = true, features = ["local-transport", "testing"] } +tokio = { workspace = true, features = ["rt"] } diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 9f21201..0e6e102 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -414,6 +414,32 @@ fn schema_digest(schema: &str) -> String { format!("{:x}", Sha256::digest(schema.as_bytes())) } +fn sdk_data_commitment(schema: &str, suffix: &str) -> String { + let schema_path = std::env::temp_dir().join(format!( + "encrypted-spaces-sdk-commitment-{}-{suffix}.kdl", + std::process::id() + )); + fs::write(&schema_path, schema).expect("write SDK commitment schema"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build SDK commitment runtime"); + let commitment = runtime + .block_on(async { + let transport = encrypted_spaces_sdk::LocalTransport::from_schema_file( + schema_path.to_str().expect("schema path is UTF-8"), + ) + .await?; + transport.get_root_hash().await + }) + .expect("compute SDK data commitment"); + fs::remove_file(schema_path).expect("remove SDK commitment schema"); + commitment + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + fn is_opaque_ref(value: &Value) -> bool { match value { Value::Null => false, @@ -486,6 +512,10 @@ fn runtime_process_trust_metadata_is_derived_and_stable_is_red() { second.finish(); changed.finish(); + let expected_commitment = sdk_data_commitment(SCHEMA_KDL, "stable"); + let changed_commitment = sdk_data_commitment(&modified_schema, "changed"); + let expected_guest_id = encrypted_spaces_ffproof::EXTEND_FF_ID.to_vec(); + for response in [&first_response, &second_response, &changed_response] { assert_eq!(response["ok"], true, "hello trust metadata is still RED"); } @@ -503,10 +533,11 @@ fn runtime_process_trust_metadata_is_derived_and_stable_is_red() { assert_eq!(second.schema_sha256, first.schema_sha256); assert_eq!(changed.schema_sha256, schema_digest(&modified_schema)); assert_ne!(changed.schema_sha256, first.schema_sha256); - assert!(valid_digest(&first.data_commitment)); + assert_eq!(first.data_commitment, expected_commitment); assert_eq!(second.data_commitment, first.data_commitment); + assert_eq!(changed.data_commitment, changed_commitment); assert_ne!(changed.data_commitment, first.data_commitment); - assert_eq!(first.ff_guest_image_id.len(), 8); + assert_eq!(first.ff_guest_image_id, expected_guest_id); assert_eq!(second.ff_guest_image_id, first.ff_guest_image_id); assert_eq!(changed.ff_guest_image_id, first.ff_guest_image_id); } From 7af1c9a0dd21c84b606b4549903a3dadbe15b71d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 03:16:01 -0400 Subject: [PATCH 10/35] ci(release): verify native built binaries --- .github/workflows/release-bridge.yml | 66 ++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/.github/workflows/release-bridge.yml b/.github/workflows/release-bridge.yml index 8610c3e..5f4b83a 100644 --- a/.github/workflows/release-bridge.yml +++ b/.github/workflows/release-bridge.yml @@ -9,6 +9,7 @@ permissions: env: RELEASE_READY: false + RELEASE_VERSION: 0.1.0 UPSTREAM_COMMIT: 4cda0ae87698135aa672990e6e68cf7873847426 RUST_VERSION: 1.94.1 RISC0_SKIP_BUILD: 1 @@ -53,34 +54,50 @@ jobs: matrix: include: - component: backend + package: encrypted-spaces-backend-server + binary: encrypted-spaces-backend-server os: ubuntu-24.04 target: linux-amd64 archive: encrypted-spaces-backend-linux-amd64.tar.gz - component: backend + package: encrypted-spaces-backend-server + binary: encrypted-spaces-backend-server os: ubuntu-24.04 target: linux-arm64 archive: encrypted-spaces-backend-linux-arm64.tar.gz - component: backend + package: encrypted-spaces-backend-server + binary: encrypted-spaces-backend-server os: macos-15-intel target: macos-amd64 archive: encrypted-spaces-backend-macos-amd64.tar.gz - component: backend + package: encrypted-spaces-backend-server + binary: encrypted-spaces-backend-server os: macos-14 target: macos-arm64 archive: encrypted-spaces-backend-macos-arm64.tar.gz - component: bridge + package: encrypted-spaces-bridge + binary: encrypted-spaces-bridge os: ubuntu-24.04 target: linux-amd64 archive: encrypted-spaces-bridge-linux-amd64.tar.gz - component: bridge + package: encrypted-spaces-bridge + binary: encrypted-spaces-bridge os: ubuntu-24.04 target: linux-arm64 archive: encrypted-spaces-bridge-linux-arm64.tar.gz - component: bridge + package: encrypted-spaces-bridge + binary: encrypted-spaces-bridge os: macos-15-intel target: macos-amd64 archive: encrypted-spaces-bridge-macos-amd64.tar.gz - component: bridge + package: encrypted-spaces-bridge + binary: encrypted-spaces-bridge os: macos-14 target: macos-arm64 archive: encrypted-spaces-bridge-macos-arm64.tar.gz @@ -89,6 +106,20 @@ jobs: - name: Checkout pinned source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Build and test with the pinned toolchain + shell: bash + env: + PACKAGE: ${{ matrix.package }} + BINARY: ${{ matrix.binary }} + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal + rustup run "$RUST_VERSION" cargo test --locked -p "$PACKAGE" --no-run + rustup run "$RUST_VERSION" cargo build --locked --release -p "$PACKAGE" + built="$GITHUB_WORKSPACE/target/release/$BINARY" + test -x "$built" + echo "BUILT_BINARY=$built" >> "$GITHUB_ENV" + - name: Verify release asset shell: bash env: @@ -107,6 +138,38 @@ jobs: test -s "$checksum" test -s "$provenance" + extract="$(mktemp -d)" + trap 'rm -rf "$extract"' EXIT + tar -xzf "$archive" -C "$extract" + packaged="$(find "$extract" -type f -name "encrypted-spaces-$COMPONENT" -print -quit)" + test -n "$packaged" + test -x "$packaged" + cmp "$BUILT_BINARY" "$packaged" + + description="$(file -b "$packaged")" + case "$TARGET" in + linux-amd64) grep -Eq 'ELF 64-bit.*(x86-64|x86_64)' <<< "$description" ;; + linux-arm64) grep -Eq 'ELF 64-bit.*(ARM aarch64|aarch64)' <<< "$description" ;; + macos-amd64) grep -Eq 'Mach-O 64-bit.*x86_64' <<< "$description" ;; + macos-arm64) grep -Eq 'Mach-O 64-bit.*arm64' <<< "$description" ;; + *) echo "unsupported release target: $TARGET" >&2; exit 1 ;; + esac + python3 - "$packaged" "$RELEASE_VERSION" <<'PY' + import subprocess + import sys + + result = subprocess.run( + [sys.argv[1], "--version"], + check=True, + capture_output=True, + text=True, + timeout=5, + ) + output = result.stdout + result.stderr + if sys.argv[2] not in output: + raise SystemExit(f"version output does not contain {sys.argv[2]}: {output!r}") + PY + jq -e \ --arg component "$COMPONENT" \ --arg target "$TARGET" \ @@ -115,12 +178,15 @@ jobs: --arg toolchain "$RUST_VERSION" ' .upstream_commit == $upstream and .rust_toolchain == $toolchain and + .version == env.RELEASE_VERSION and (.artifacts | length == 8) and ([.artifacts[].archive] | unique | length == 8) and ([.artifacts[] | select( .component == $component and .target == $target and .archive == $archive and + .binary == ("encrypted-spaces-" + $component) and + .version == env.RELEASE_VERSION and (.sha256 | test("^[0-9a-f]{64}$")) )] | length == 1) ' "$manifest" From e07f4d5c1cb914884f1f3443a5209b0076f22edb Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 03:30:23 -0400 Subject: [PATCH 11/35] test(bridge): harden runtime lifecycle contracts --- bridge/tests/runtime.rs | 251 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 231 insertions(+), 20 deletions(-) diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 0e6e102..5de8170 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -6,19 +6,31 @@ use std::collections::BTreeMap; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::sync::mpsc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; const PROTOCOL_VERSION: u64 = 1; const UPSTREAM_COMMIT: &str = "4cda0ae87698135aa672990e6e68cf7873847426"; const RUST_TOOLCHAIN: &str = "1.94.1"; +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(2); +const EXIT_TIMEOUT: Duration = Duration::from_secs(2); + +enum StdoutEvent { + Line(String), + Eof, + Error(String), +} struct BridgeProcess { child: Child, stdin: Option, - stdout: BufReader, + stdout: mpsc::Receiver, + stdout_thread: Option>, scenario: String, next_request: usize, - schema_path: PathBuf, + schema_path: Option, } impl BridgeProcess { @@ -42,14 +54,37 @@ impl BridgeProcess { .spawn() .expect("spawn bridge"); let stdin = child.stdin.take().expect("bridge stdin"); - let stdout = BufReader::new(child.stdout.take().expect("bridge stdout")); + let stdout = child.stdout.take().expect("bridge stdout"); + let (stdout_sender, stdout_receiver) = mpsc::channel(); + let stdout_thread = thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => { + let _ = stdout_sender.send(StdoutEvent::Eof); + break; + } + Ok(_) => { + if stdout_sender.send(StdoutEvent::Line(line)).is_err() { + break; + } + } + Err(error) => { + let _ = stdout_sender.send(StdoutEvent::Error(error.to_string())); + break; + } + } + } + }); Self { child, stdin: Some(stdin), - stdout, + stdout: stdout_receiver, + stdout_thread: Some(stdout_thread), scenario: format!("{scenario}-{actor}"), next_request: 1, - schema_path, + schema_path: Some(schema_path), } } @@ -93,14 +128,19 @@ impl BridgeProcess { writeln!(stdin, "{frame}").expect("write bridge frame"); stdin.flush().expect("flush bridge frame"); - let mut line = String::new(); - self.stdout - .read_line(&mut line) - .expect("read bridge response"); - assert!( - !line.is_empty(), - "bridge exited before responding to {operation}" - ); + let line = match self.stdout.recv_timeout(RESPONSE_TIMEOUT) { + Ok(StdoutEvent::Line(line)) => line, + Ok(StdoutEvent::Eof) => panic!("bridge exited before responding to {operation}"), + Ok(StdoutEvent::Error(error)) => { + panic!("bridge stdout read failed while handling {operation}: {error}") + } + Err(mpsc::RecvTimeoutError::Timeout) => { + panic!("bridge did not respond to {operation} within {RESPONSE_TIMEOUT:?}") + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("bridge stdout reader disconnected while handling {operation}") + } + }; let response = serde_json::from_str(&line).expect("bridge response JSON"); Observation { operation: operation.to_owned(), @@ -109,14 +149,57 @@ impl BridgeProcess { } } + fn wait_for_exit(&mut self) -> ExitStatus { + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if let Some(status) = self.child.try_wait().expect("poll bridge process") { + return status; + } + assert!( + Instant::now() < deadline, + "bridge did not exit within {EXIT_TIMEOUT:?}" + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn join_reader(&mut self) { + if let Some(thread) = self.stdout_thread.take() { + thread.join().expect("join bridge stdout reader"); + } + } + + fn remove_schema_fixture(&mut self) { + if let Some(path) = self.schema_path.take() { + fs::remove_file(path).expect("remove bridge schema fixture"); + } + } + fn finish(mut self) { - drop(self.stdin.take()); - let status = self.child.wait().expect("wait for bridge"); - fs::remove_file(&self.schema_path).expect("remove bridge schema fixture"); + self.stdin.take(); + let status = self.wait_for_exit(); + self.join_reader(); + self.remove_schema_fixture(); assert!(status.success(), "bridge exited with {status}"); } } +impl Drop for BridgeProcess { + fn drop(&mut self) { + self.stdin.take(); + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + if let Some(thread) = self.stdout_thread.take() { + let _ = thread.join(); + } + if let Some(path) = self.schema_path.take() { + let _ = fs::remove_file(path); + } + } +} + struct Observation { operation: String, request_id: String, @@ -223,6 +306,29 @@ impl Scenario { } } + fn verify_error(&mut self, index: usize, validate: F) + where + T: DeserializeOwned, + F: FnOnce(&T) -> Result<(), String>, + { + let observation = &self.observations[index]; + let failure = match observation.response.get("ok").and_then(Value::as_bool) { + Some(false) => match observation.response.get("error") { + Some(error) => match serde_json::from_value::(error.clone()) { + Ok(error) => validate(&error).err(), + Err(error) => Some(format!("typed error mismatch: {error}")), + }, + None => Some("missing typed error".to_owned()), + }, + Some(true) => Some("expected typed error, received success".to_owned()), + None => Some("missing boolean ok field".to_owned()), + }; + if let Some(failure) = failure { + self.failures + .push(format!("{}: {failure}", observation.operation)); + } + } + fn finish(mut self) { for (_, bridge) in std::mem::take(&mut self.bridges) { bridge.finish(); @@ -299,6 +405,11 @@ struct TableSelectResult { rows: Vec, } +#[derive(Deserialize)] +struct BridgeError { + code: String, +} + #[derive(Deserialize)] struct ListCreateResult { space_id: String, @@ -589,7 +700,30 @@ fn runtime_space_lifecycle_is_red() { let invite_value = scenario.returned_value(invite, "invite", json!({"missing": "lifecycle-invite"})); let join = scenario.request(member, "space.join", join_payload(invite_value)); + let sync_label = "post-join-sync-parametric"; + let sync_rank = 307; + let sync_insert = scenario.request( + owner, + "table.insert", + json!({ + "space_id": space_id, + "table": TABLE, + "row": parent_row(sync_label, sync_rank), + }), + ); + let sync_row_id = scenario.observations[sync_insert].response["result"]["row_id"] + .as_i64() + .unwrap_or(-1); let sync = scenario.request(member, "space.sync", json!({"space_id": space_id})); + let synced_select = scenario.request( + member, + "table.select", + json!({ + "space_id": space_id, + "table": TABLE, + "where": {"id": sync_row_id}, + }), + ); let remove = scenario.request( owner, "member.remove", @@ -646,11 +780,21 @@ fn runtime_space_lifecycle_is_red() { .then_some(()) .ok_or_else(|| "space.join did not consume the returned invite".to_owned()) }); + scenario.verify::(sync_insert, |result| { + (result.row_id > 0) + .then_some(()) + .ok_or_else(|| "post-join sync fixture row has no auto-assigned ID".to_owned()) + }); scenario.verify::(sync, |result| { (result.space_id == space_id && result.synced) .then_some(()) .ok_or_else(|| "space.sync did not report a completed sync".to_owned()) }); + scenario.verify::(synced_select, |result| { + (result.rows.len() == 1 && row_matches(&result.rows[0], sync_row_id, sync_label, sync_rank)) + .then_some(()) + .ok_or_else(|| "space.sync did not expose the owner's post-join row".to_owned()) + }); scenario.verify::(remove, |result| { (result.space_id == space_id && result.member_id == member_id && result.removed) .then_some(()) @@ -896,7 +1040,7 @@ fn runtime_file_put_get_are_red() { } #[test] -fn runtime_member_invite_space_join_remove_are_red() { +fn runtime_member_invite_join_remove_are_red() { let owner = "owner-membership-parametric"; let member = "member-membership-parametric"; let mut scenario = Scenario::start("membership", &[owner, member]); @@ -908,12 +1052,53 @@ fn runtime_member_invite_space_join_remove_are_red() { .unwrap_or(-1); let invite_value = scenario.returned_value(invite, "invite", json!({"missing": "membership-invite"})); - let join = scenario.request(member, "space.join", join_payload(invite_value)); + let join = scenario.request(member, "member.join", join_payload(invite_value)); + let retained_label = "owner-retained-after-removal-parametric"; + let retained_rank = 401; + let retained_insert = scenario.request( + owner, + "table.insert", + json!({ + "space_id": space_id, + "table": TABLE, + "row": parent_row(retained_label, retained_rank), + }), + ); + let retained_row_id = scenario.observations[retained_insert].response["result"]["row_id"] + .as_i64() + .unwrap_or(-1); let remove = scenario.request( owner, "member.remove", json!({"space_id": space_id, "member_id": member_id}), ); + let removed_select = scenario.request( + member, + "table.select", + json!({ + "space_id": space_id, + "table": TABLE, + "where": {"id": retained_row_id}, + }), + ); + let removed_write = scenario.request( + member, + "table.insert", + json!({ + "space_id": space_id, + "table": TABLE, + "row": parent_row("removed-member-write-parametric", 409), + }), + ); + let owner_select = scenario.request( + owner, + "table.select", + json!({ + "space_id": space_id, + "table": TABLE, + "where": {"id": retained_row_id}, + }), + ); scenario.verify::(create_space, |result| { valid_created_space(result) @@ -928,13 +1113,39 @@ fn runtime_member_invite_space_join_remove_are_red() { scenario.verify::(join, |result| { (result.space_id == space_id && result.member_id == member_id && result.joined) .then_some(()) - .ok_or_else(|| "space.join did not join the invited SDK member".to_owned()) + .ok_or_else(|| "member.join did not join the invited SDK member".to_owned()) + }); + scenario.verify::(retained_insert, |result| { + (result.row_id > 0) + .then_some(()) + .ok_or_else(|| "removal fixture row has no auto-assigned ID".to_owned()) }); scenario.verify::(remove, |result| { (result.space_id == space_id && result.member_id == member_id && result.removed) .then_some(()) .ok_or_else(|| "member.remove did not remove the joined member".to_owned()) }); + scenario.verify_error::(removed_select, |error| { + (error.code == "ACCESS_DENIED") + .then_some(()) + .ok_or_else(|| "removed member select did not return ACCESS_DENIED".to_owned()) + }); + scenario.verify_error::(removed_write, |error| { + (error.code == "ACCESS_DENIED") + .then_some(()) + .ok_or_else(|| "removed member write did not return ACCESS_DENIED".to_owned()) + }); + scenario.verify::(owner_select, |result| { + (result.rows.len() == 1 + && row_matches( + &result.rows[0], + retained_row_id, + retained_label, + retained_rank, + )) + .then_some(()) + .ok_or_else(|| "owner could not read the row after member removal".to_owned()) + }); scenario.finish(); } From 185cb9f1be6788a6e82bf3fd7f2155ff617acca8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 03:35:39 -0400 Subject: [PATCH 12/35] test(release): verify platform binaries --- Cargo.lock | 1 + bridge/Cargo.toml | 1 + bridge/tests/runtime.rs | 136 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d542e3f..8545001 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2252,6 +2252,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "tempfile", "tokio", ] diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml index aa19f68..5722060 100644 --- a/bridge/Cargo.toml +++ b/bridge/Cargo.toml @@ -19,3 +19,4 @@ sha2.workspace = true encrypted-spaces-ffproof = { workspace = true, features = ["prove"] } encrypted-spaces-sdk = { workspace = true, features = ["local-transport", "testing"] } tokio = { workspace = true, features = ["rt"] } +tempfile = "3" diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 5de8170..9d886aa 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -1151,6 +1151,7 @@ fn runtime_member_invite_join_remove_are_red() { #[derive(Deserialize)] struct ReleaseManifest { + version: String, upstream_commit: String, rust_toolchain: String, artifacts: Vec, @@ -1161,6 +1162,8 @@ struct ReleaseArtifact { component: String, target: String, archive: String, + binary: String, + version: String, sha256: String, } @@ -1308,27 +1311,127 @@ fn verify_checksum(dist: &Path, archive: &str, expected_digest: &str) { ); } -fn inspect_archive(dist: &Path, component: &str, archive: &str) { +fn find_file(root: &Path, name: &str) -> Option { + for entry in fs::read_dir(root).ok()? { + let path = entry.ok()?.path(); + if path.is_dir() { + if let Some(found) = find_file(&path, name) { + return Some(found); + } + } else if path.file_name().and_then(|value| value.to_str()) == Some(name) { + return Some(path); + } + } + None +} + +fn native_target() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some("linux-amd64"), + ("linux", "aarch64") => Some("linux-arm64"), + ("macos", "x86_64") => Some("macos-amd64"), + ("macos", "aarch64") => Some("macos-arm64"), + _ => None, + } +} + +fn assert_binary_format(binary: &Path, target: &str) { + let output = Command::new("file") + .args(["-b", binary.to_str().expect("binary path is UTF-8")]) + .output() + .expect("inspect release binary format"); + assert!( + output.status.success(), + "file failed for {}", + binary.display() + ); + let description = String::from_utf8(output.stdout).expect("file output is UTF-8"); + let valid = match target { + "linux-amd64" => { + description.contains("ELF 64-bit") + && (description.contains("x86-64") || description.contains("x86_64")) + } + "linux-arm64" => { + description.contains("ELF 64-bit") + && (description.contains("ARM aarch64") || description.contains("aarch64")) + } + "macos-amd64" => description.contains("Mach-O 64-bit") && description.contains("x86_64"), + "macos-arm64" => description.contains("Mach-O 64-bit") && description.contains("arm64"), + _ => false, + }; + assert!(valid, "{target} has wrong binary format: {description}"); +} + +fn assert_native_version(binary: &Path, version: &str) { + let script = r#" +import subprocess +import sys +result = subprocess.run([sys.argv[1], "--version"], check=True, capture_output=True, text=True, timeout=5) +output = result.stdout + result.stderr +if sys.argv[2] not in output: + raise SystemExit(f"version output does not contain {sys.argv[2]}: {output!r}") +"#; + let status = Command::new("python3") + .args([ + "-c", + script, + binary.to_str().expect("binary path is UTF-8"), + version, + ]) + .status() + .expect("run release binary version check"); + assert!( + status.success(), + "{} version check failed", + binary.display() + ); +} + +fn inspect_archive(dist: &Path, component: &str, target: &str, archive: &str, version: &str) { + let extraction = tempfile::tempdir().expect("create archive extraction directory"); let output = Command::new("tar") - .args(["-tzf", archive]) + .args([ + "-xzf", + archive, + "-C", + extraction + .path() + .to_str() + .expect("extraction path is UTF-8"), + ]) .current_dir(dist) .output() - .expect("list release archive"); + .expect("extract release archive"); assert!( output.status.success(), - "cannot list {archive}: {}", + "cannot extract {archive}: {}", String::from_utf8_lossy(&output.stderr) ); - let listing = String::from_utf8(output.stdout).expect("archive listing is UTF-8"); let binary = format!("encrypted-spaces-{component}"); for required in [binary.as_str(), "LICENSE", "NOTICE"] { assert!( - listing - .lines() - .any(|entry| entry.trim_end_matches('/').rsplit('/').next() == Some(required)), + find_file(extraction.path(), required).is_some(), "{archive} omits {required}" ); } + let binary = find_file(extraction.path(), &binary).expect("release binary"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_ne!( + fs::metadata(&binary) + .expect("release binary metadata") + .permissions() + .mode() + & 0o111, + 0, + "{archive} binary is not executable" + ); + } + assert_binary_format(&binary, target); + if native_target() == Some(target) { + assert_native_version(&binary, version); + } } fn verify_provenance(dist: &Path, archive: &str, expected_digest: &str) { @@ -1403,6 +1506,14 @@ fn release_contract_is_red_until_dist_and_notice_exist() { "shasum -a 256 -c", "jq -e", "tar -tzf", + "rustup run \"$RUST_VERSION\" cargo test --locked", + "rustup run \"$RUST_VERSION\" cargo build --locked --release", + "test -x \"$built\"", + "cmp \"$BUILT_BINARY\" \"$packaged\"", + "file -b", + "subprocess.run(", + "--version", + "RELEASE_VERSION: 0.1.0", "RISC0_SKIP_BUILD: 1", ] { assert!(workflow.contains(marker), "release workflow omits {marker}"); @@ -1458,6 +1569,7 @@ fn release_contract_is_red_until_dist_and_notice_exist() { let manifest: ReleaseManifest = serde_json::from_slice(&fs::read(&manifest_path).expect("read release manifest")) .expect("parse release manifest"); + assert_eq!(manifest.version, "0.1.0"); assert_eq!(manifest.upstream_commit, UPSTREAM_COMMIT); assert_eq!(manifest.rust_toolchain, RUST_TOOLCHAIN); assert_eq!(manifest.artifacts.len(), RELEASE_ARCHIVES.len()); @@ -1469,9 +1581,15 @@ fn release_contract_is_red_until_dist_and_notice_exist() { .unwrap_or_else(|| panic!("release manifest omits {archive}")); assert_eq!(artifact.component, component, "{archive} component"); assert_eq!(artifact.target, target, "{archive} target"); + assert_eq!( + artifact.binary, + format!("encrypted-spaces-{component}"), + "{archive} binary" + ); + assert_eq!(artifact.version, manifest.version, "{archive} version"); assert!(valid_digest(&artifact.sha256), "{archive} invalid sha256"); verify_checksum(&dist, archive, &artifact.sha256); verify_provenance(&dist, archive, &artifact.sha256); - inspect_archive(&dist, component, archive); + inspect_archive(&dist, component, target, archive, &manifest.version); } } From 0184240fd74b3ecc5b382e6fd256e72f5fcef97b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 03:38:05 -0400 Subject: [PATCH 13/35] ci(release): use native Linux arm64 runners --- .github/workflows/release-bridge.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-bridge.yml b/.github/workflows/release-bridge.yml index 5f4b83a..5a586f6 100644 --- a/.github/workflows/release-bridge.yml +++ b/.github/workflows/release-bridge.yml @@ -62,7 +62,7 @@ jobs: - component: backend package: encrypted-spaces-backend-server binary: encrypted-spaces-backend-server - os: ubuntu-24.04 + os: ubuntu-24.04-arm target: linux-arm64 archive: encrypted-spaces-backend-linux-arm64.tar.gz - component: backend @@ -86,7 +86,7 @@ jobs: - component: bridge package: encrypted-spaces-bridge binary: encrypted-spaces-bridge - os: ubuntu-24.04 + os: ubuntu-24.04-arm target: linux-arm64 archive: encrypted-spaces-bridge-linux-arm64.tar.gz - component: bridge From 964a442be242c243e09d804e5ea9d4d62a24cb70 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 03:47:36 -0400 Subject: [PATCH 14/35] refactor(bridge): expose request decoder --- bridge/src/protocol.rs | 54 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/bridge/src/protocol.rs b/bridge/src/protocol.rs index cb2a640..3504f68 100644 --- a/bridge/src/protocol.rs +++ b/bridge/src/protocol.rs @@ -61,30 +61,51 @@ pub fn run(reader: R, mut writer: W) -> io::Result<()> { Err(FrameError::Io(error)) => return Err(error), }; - let response = parse_request(&frame); + let response = match decode_request(&frame) { + Ok(request) => runtime::dispatch(request), + Err(response) => response, + }; write_response(&mut writer, response)?; } } -fn parse_request(frame: &[u8]) -> Response { +fn decode_request(frame: &[u8]) -> Result { let value = match serde_json::from_slice::(frame) { Ok(value) => value, - Err(_) => return Response::error(None, "INVALID_JSON", "malformed JSONL frame"), + Err(_) => { + return Err(Response::error( + None, + "INVALID_JSON", + "malformed JSONL frame", + )); + } }; let request_id = parsed_request_id(&value); if has_unknown_operation(&value) { - return Response::error(request_id, "UNKNOWN_OPERATION", "unknown bridge operation"); + return Err(Response::error( + request_id, + "UNKNOWN_OPERATION", + "unknown bridge operation", + )); } let request = match serde_json::from_value::(value) { Ok(request) => request, Err(_) => { - return Response::error(request_id, "INVALID_REQUEST", "invalid bridge request"); + return Err(Response::error( + request_id, + "INVALID_REQUEST", + "invalid bridge request", + )); } }; match request.validate() { - Ok(()) => runtime::dispatch(request), - Err(_) => Response::error(request_id, "INVALID_REQUEST", "invalid bridge request"), + Ok(()) => Ok(request), + Err(_) => Err(Response::error( + request_id, + "INVALID_REQUEST", + "invalid bridge request", + )), } } @@ -132,3 +153,22 @@ fn write_response(writer: &mut W, response: Response) -> io::Result<() writer.write_all(b"\n")?; writer.flush() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protocol_decode_accepts_valid_and_correlates_invalid_requests() { + let valid = br#"{"version":1,"request_id":"decode-ok","operation":"hello","payload":{}}"#; + let request = decode_request(valid).expect("valid request"); + assert_eq!(request.request_id, "decode-ok"); + assert!(matches!(request.operation, Operation::Hello)); + + let invalid = + br#"{"version":2,"request_id":"decode-invalid","operation":"hello","payload":{}}"#; + let response = decode_request(invalid).expect_err("invalid version"); + assert_eq!(response.request_id.as_deref(), Some("decode-invalid")); + assert_eq!(response.error.expect("error body").code, "INVALID_REQUEST"); + } +} From 5b453f6ebe967e1943196830453ec8d2df3f47a2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 03:49:36 -0400 Subject: [PATCH 15/35] refactor(bridge): isolate response encoding --- bridge/src/protocol.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/bridge/src/protocol.rs b/bridge/src/protocol.rs index 3504f68..035ef3a 100644 --- a/bridge/src/protocol.rs +++ b/bridge/src/protocol.rs @@ -149,11 +149,16 @@ fn read_frame(reader: &mut R) -> Result>, FrameError> } fn write_response(writer: &mut W, response: Response) -> io::Result<()> { - serde_json::to_writer(&mut *writer, &response).map_err(io::Error::other)?; - writer.write_all(b"\n")?; + writer.write_all(&encode_response(&response)?)?; writer.flush() } +fn encode_response(response: &Response) -> io::Result> { + let mut frame = serde_json::to_vec(response).map_err(io::Error::other)?; + frame.push(b'\n'); + Ok(frame) +} + #[cfg(test)] mod tests { use super::*; @@ -171,4 +176,18 @@ mod tests { assert_eq!(response.request_id.as_deref(), Some("decode-invalid")); assert_eq!(response.error.expect("error body").code, "INVALID_REQUEST"); } + + #[test] + fn protocol_encode_emits_one_redacted_jsonl_frame() { + let response = Response::error( + Some("encode-request".to_owned()), + "INVALID_REQUEST", + "invalid bridge request", + ); + let encoded = encode_response(&response).expect("encode response"); + assert_eq!( + encoded, + b"{\"version\":1,\"request_id\":\"encode-request\",\"ok\":false,\"error\":{\"code\":\"INVALID_REQUEST\",\"message\":\"invalid bridge request\"}}\n" + ); + } } From 3fded115248c804b04d6c5d1d6f3ebc614c68d2d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 03:50:26 -0400 Subject: [PATCH 16/35] test(bridge): pin frame size boundary --- bridge/src/protocol.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bridge/src/protocol.rs b/bridge/src/protocol.rs index 035ef3a..0f36d52 100644 --- a/bridge/src/protocol.rs +++ b/bridge/src/protocol.rs @@ -190,4 +190,20 @@ mod tests { b"{\"version\":1,\"request_id\":\"encode-request\",\"ok\":false,\"error\":{\"code\":\"INVALID_REQUEST\",\"message\":\"invalid bridge request\"}}\n" ); } + + #[test] + fn protocol_frame_limit_accepts_exact_and_rejects_oversize() { + let mut exact = io::Cursor::new(vec![b'x'; MAX_FRAME_BYTES]); + let frame = match read_frame(&mut exact) { + Ok(Some(frame)) => frame, + _ => panic!("exact-size frame must be accepted"), + }; + assert_eq!(frame.len(), MAX_FRAME_BYTES); + + let mut oversize = io::Cursor::new(vec![b'x'; MAX_FRAME_BYTES + 1]); + assert!(matches!( + read_frame(&mut oversize), + Err(FrameError::TooLarge) + )); + } } From 92bad53451eb94405482f798aad37d298f464480 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 03:54:58 -0400 Subject: [PATCH 17/35] feat(bridge): report process trust metadata --- bridge/Cargo.toml | 6 +-- bridge/src/protocol.rs | 23 ++++++++- bridge/src/runtime.rs | 107 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml index 5722060..f7f5914 100644 --- a/bridge/Cargo.toml +++ b/bridge/Cargo.toml @@ -11,12 +11,12 @@ name = "encrypted-spaces-bridge" path = "src/main.rs" [dependencies] +encrypted-spaces-ffproof = { workspace = true, features = ["prove"] } +encrypted-spaces-sdk = { workspace = true, features = ["local-transport"] } serde.workspace = true serde_json.workspace = true sha2.workspace = true +tokio.workspace = true [dev-dependencies] -encrypted-spaces-ffproof = { workspace = true, features = ["prove"] } -encrypted-spaces-sdk = { workspace = true, features = ["local-transport", "testing"] } -tokio = { workspace = true, features = ["rt"] } tempfile = "3" diff --git a/bridge/src/protocol.rs b/bridge/src/protocol.rs index 0f36d52..e625188 100644 --- a/bridge/src/protocol.rs +++ b/bridge/src/protocol.rs @@ -12,6 +12,8 @@ pub struct Response { pub request_id: Option, pub ok: bool, #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } @@ -27,10 +29,28 @@ impl Response { version: PROTOCOL_VERSION, request_id, ok: false, + result: None, error: Some(ErrorBody { code, message }), } } + pub fn success(request_id: String, result: impl Serialize) -> Self { + match serde_json::to_value(result) { + Ok(result) => Self { + version: PROTOCOL_VERSION, + request_id: Some(request_id), + ok: true, + result: Some(result), + error: None, + }, + Err(_) => Self::error( + Some(request_id), + "INTERNAL_ERROR", + "bridge response serialization failed", + ), + } + } + pub fn not_implemented(request_id: String) -> Self { Self::error( Some(request_id), @@ -46,6 +66,7 @@ enum FrameError { } pub fn run(reader: R, mut writer: W) -> io::Result<()> { + let mut runtime = runtime::Runtime::from_env()?; let mut reader = io::BufReader::new(reader); loop { let frame = match read_frame(&mut reader) { @@ -62,7 +83,7 @@ pub fn run(reader: R, mut writer: W) -> io::Result<()> { }; let response = match decode_request(&frame) { - Ok(request) => runtime::dispatch(request), + Ok(request) => runtime.dispatch(request), Err(response) => response, }; write_response(&mut writer, response)?; diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index 5fc028d..3664494 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -1,9 +1,104 @@ use crate::protocol::Response; -use crate::schema::Request; +use crate::schema::{Operation, Request, PROTOCOL_VERSION}; +use encrypted_spaces_sdk::LocalTransport; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io; -/// Boundary only: SDK/backend runtime integration is pending. -pub fn dispatch(request: Request) -> Response { - let _operation_name = request.operation.name(); - let _ = request.payload; - Response::not_implemented(request.request_id) +const ACTOR_ID_ENV: &str = "ENCRYPTED_SPACES_ACTOR_ID"; +const SCHEMA_PATH_ENV: &str = "ENCRYPTED_SPACES_SCHEMA_PATH"; + +pub struct Runtime { + executor: tokio::runtime::Runtime, + process: ProcessConfig, +} + +struct ProcessConfig { + actor_id: String, + schema_sha256: String, + data_commitment: String, + ff_guest_image_id: [u32; 8], +} + +#[derive(Serialize)] +struct HelloResult<'a> { + protocol_version: u16, + actor_id: &'a str, + schema_sha256: &'a str, + data_commitment: &'a str, + ff_guest_image_id: [u32; 8], +} + +#[derive(Serialize)] +struct VersionResult { + version: &'static str, + protocol_version: u16, +} + +impl Runtime { + pub fn from_env() -> io::Result { + let actor_id = required_env(ACTOR_ID_ENV)?; + let schema_path = required_env(SCHEMA_PATH_ENV)?; + let schema = fs::read(&schema_path)?; + let schema_sha256 = format!("{:x}", Sha256::digest(&schema)); + let executor = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + let data_commitment = executor + .block_on(async { + let transport = LocalTransport::from_schema_file(&schema_path).await?; + transport.get_root_hash().await + }) + .map_err(io::Error::other)? + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + + Ok(Self { + executor, + process: ProcessConfig { + actor_id, + schema_sha256, + data_commitment, + ff_guest_image_id: encrypted_spaces_ffproof::EXTEND_FF_ID, + }, + }) + } + + pub fn dispatch(&mut self, request: Request) -> Response { + let _ = &self.executor; + match request.operation { + Operation::Hello => Response::success( + request.request_id, + HelloResult { + protocol_version: PROTOCOL_VERSION, + actor_id: &self.process.actor_id, + schema_sha256: &self.process.schema_sha256, + data_commitment: &self.process.data_commitment, + ff_guest_image_id: self.process.ff_guest_image_id, + }, + ), + Operation::Version => Response::success( + request.request_id, + VersionResult { + version: env!("CARGO_PKG_VERSION"), + protocol_version: PROTOCOL_VERSION, + }, + ), + operation => { + let _operation_name = operation.name(); + let _ = request.payload; + Response::not_implemented(request.request_id) + } + } + } +} + +fn required_env(name: &str) -> io::Result { + std::env::var(name) + .ok() + .filter(|value| !value.is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{name} is required"))) } From a816bb95d396d1fb894affc1ba2a44d005079a92 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:02:44 -0400 Subject: [PATCH 18/35] feat(bridge): create and restore spaces --- bridge/src/runtime.rs | 167 ++++++++++++++++++++++++++++++++- bridge/tests/runtime.rs | 199 ++++++++++++++++++++++++++++++++++++++-- bridge/tests/stdio.rs | 4 +- sdk/src/schema.rs | 8 ++ 4 files changed, 362 insertions(+), 16 deletions(-) diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index 3664494..22137c1 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -1,24 +1,31 @@ use crate::protocol::Response; use crate::schema::{Operation, Request, PROTOCOL_VERSION}; -use encrypted_spaces_sdk::LocalTransport; -use serde::Serialize; +use encrypted_spaces_sdk::{ApplicationSchema, LocalTransport, Space, WebSocketTransport}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; use sha2::{Digest, Sha256}; use std::fs; use std::io; const ACTOR_ID_ENV: &str = "ENCRYPTED_SPACES_ACTOR_ID"; const SCHEMA_PATH_ENV: &str = "ENCRYPTED_SPACES_SCHEMA_PATH"; +const BACKEND_URL_ENV: &str = "ENCRYPTED_SPACES_BACKEND_URL"; +const DEFAULT_BACKEND_URL: &str = "ws://127.0.0.1:8080/ws"; pub struct Runtime { executor: tokio::runtime::Runtime, process: ProcessConfig, + space: Option, } struct ProcessConfig { actor_id: String, schema_sha256: String, + schema: Vec, + data_commitment_bytes: [u8; 32], data_commitment: String, ff_guest_image_id: [u32; 8], + backend_url: String, } #[derive(Serialize)] @@ -36,6 +43,40 @@ struct VersionResult { protocol_version: u16, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EmptyPayload {} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SpacePayload { + space_id: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RestorePayload { + snapshot: Value, +} + +#[derive(Serialize)] +struct CreateResult<'a> { + space_id: String, + schema_sha256: &'a str, +} + +#[derive(Serialize)] +struct SnapshotResult { + space_id: String, + snapshot: Value, +} + +#[derive(Serialize)] +struct RestoreResult { + space_id: String, + restored: bool, +} + impl Runtime { pub fn from_env() -> io::Result { let actor_id = required_env(ACTOR_ID_ENV)?; @@ -46,24 +87,33 @@ impl Runtime { .enable_all() .build() .map_err(io::Error::other)?; - let data_commitment = executor + let data_commitment_bytes = executor .block_on(async { let transport = LocalTransport::from_schema_file(&schema_path).await?; transport.get_root_hash().await }) - .map_err(io::Error::other)? + .map_err(io::Error::other)?; + let data_commitment = data_commitment_bytes .iter() .map(|byte| format!("{byte:02x}")) .collect(); + let backend_url = std::env::var(BACKEND_URL_ENV) + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_BACKEND_URL.to_owned()); Ok(Self { executor, process: ProcessConfig { actor_id, schema_sha256, + schema, + data_commitment_bytes, data_commitment, ff_guest_image_id: encrypted_spaces_ffproof::EXTEND_FF_ID, + backend_url, }, + space: None, }) } @@ -87,6 +137,9 @@ impl Runtime { protocol_version: PROTOCOL_VERSION, }, ), + Operation::Create => self.create(request.request_id, request.payload), + Operation::Snapshot => self.snapshot(request.request_id, request.payload), + Operation::Restore => self.restore(request.request_id, request.payload), operation => { let _operation_name = operation.name(); let _ = request.payload; @@ -94,6 +147,112 @@ impl Runtime { } } } + + fn create(&mut self, request_id: String, payload: Value) -> Response { + if let Err(response) = parse_payload::(&request_id, payload) { + return response; + } + if self.space.is_some() { + return invalid_state(request_id); + } + let backend_url = self.process.backend_url.clone(); + let schema = self.application_schema(); + let space = match self.executor.block_on(async move { + let transport = WebSocketTransport::new(&backend_url).await?; + Space::create(transport, schema).await + }) { + Ok(space) => space, + Err(_) => return sdk_error(request_id), + }; + let space_id = space.id().to_string(); + self.space = Some(space); + Response::success( + request_id, + CreateResult { + space_id, + schema_sha256: &self.process.schema_sha256, + }, + ) + } + + fn snapshot(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + match self.executor.block_on(space.snapshot()) { + Ok(snapshot) => Response::success(request_id, SnapshotResult { space_id, snapshot }), + Err(_) => sdk_error(request_id), + } + } + + fn restore(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + if self.space.is_some() { + return invalid_state(request_id); + } + let backend_url = self.process.backend_url.clone(); + let space = match self.executor.block_on(async move { + let transport = WebSocketTransport::new(&backend_url).await?; + Space::restore(transport, payload.snapshot).await + }) { + Ok(space) => space, + Err(_) => return sdk_error(request_id), + }; + let space_id = space.id().to_string(); + self.space = Some(space); + Response::success( + request_id, + RestoreResult { + space_id, + restored: true, + }, + ) + } + + fn application_schema(&self) -> ApplicationSchema { + ApplicationSchema::FromOwnedBytes( + self.process.schema.clone(), + self.process.data_commitment_bytes, + self.process.ff_guest_image_id, + ) + } +} + +fn parse_payload(request_id: &str, payload: Value) -> Result { + serde_json::from_value(payload).map_err(|_| { + Response::error( + Some(request_id.to_owned()), + "INVALID_REQUEST", + "invalid bridge request", + ) + }) +} + +fn invalid_state(request_id: String) -> Response { + Response::error( + Some(request_id), + "INVALID_STATE", + "operation is not valid in the current bridge state", + ) +} + +fn sdk_error(request_id: String) -> Response { + Response::error( + Some(request_id), + "SDK_ERROR", + "encrypted spaces operation failed", + ) } fn required_env(name: &str) -> io::Result { diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 9d886aa..46b1c6d 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -4,10 +4,11 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::fs; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; -use std::sync::mpsc; +use std::sync::{mpsc, OnceLock}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -16,6 +17,126 @@ const UPSTREAM_COMMIT: &str = "4cda0ae87698135aa672990e6e68cf7873847426"; const RUST_TOOLCHAIN: &str = "1.94.1"; const RESPONSE_TIMEOUT: Duration = Duration::from_secs(2); const EXIT_TIMEOUT: Duration = Duration::from_secs(2); +const BACKEND_START_TIMEOUT: Duration = Duration::from_secs(10); + +struct BackendProcess { + child: Child, + schema_path: PathBuf, + space_root: PathBuf, + url: String, +} + +impl BackendProcess { + fn spawn(scenario: &str, schema: &str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve backend port"); + let port = listener.local_addr().expect("backend address").port(); + drop(listener); + + let fixture_root = std::env::temp_dir().join(format!( + "encrypted-spaces-backend-{}-{scenario}", + std::process::id() + )); + let schema_path = fixture_root.join("schema.kdl"); + let space_root = fixture_root.join("spaces"); + fs::create_dir_all(&space_root).expect("create backend fixture root"); + fs::write(&schema_path, schema).expect("write backend schema fixture"); + let child = Command::new(backend_binary()) + .args([ + "--schema", + schema_path.to_str().expect("schema path is UTF-8"), + "--space-root", + space_root.to_str().expect("space root is UTF-8"), + "--bind-addr", + "127.0.0.1", + "--port", + &port.to_string(), + ]) + .env("RISC0_DEV_MODE", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn backend fixture"); + let mut backend = Self { + child, + schema_path, + space_root, + url: format!("ws://127.0.0.1:{port}/ws"), + }; + backend.wait_for_health(port); + backend + } + + fn wait_for_health(&mut self, port: u16) { + let deadline = Instant::now() + BACKEND_START_TIMEOUT; + loop { + if let Some(status) = self.child.try_wait().expect("poll backend process") { + panic!("backend exited before health check with {status}"); + } + if backend_is_healthy(port) { + return; + } + assert!( + Instant::now() < deadline, + "backend did not become healthy within {BACKEND_START_TIMEOUT:?}" + ); + thread::sleep(Duration::from_millis(25)); + } + } +} + +impl Drop for BackendProcess { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + if let Some(root) = self.schema_path.parent() { + let _ = fs::remove_dir_all(root); + } else { + let _ = fs::remove_dir_all(&self.space_root); + } + } +} + +fn backend_binary() -> &'static Path { + static BINARY: OnceLock = OnceLock::new(); + BINARY + .get_or_init(|| { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root"); + let target = root.join("target/bridge-backend-fixture"); + let status = Command::new("cargo") + .args(["build", "--locked", "-p", "encrypted-spaces-backend-server"]) + .current_dir(root) + .env("CARGO_TARGET_DIR", &target) + .env("RISC0_SKIP_BUILD", "1") + .status() + .expect("build backend fixture"); + assert!( + status.success(), + "backend fixture build failed with {status}" + ); + target.join("debug/encrypted-spaces-backend-server") + }) + .as_path() +} + +fn backend_is_healthy(port: u16) -> bool { + let Ok(mut stream) = TcpStream::connect(("127.0.0.1", port)) else { + return false; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); + if stream + .write_all(b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .is_err() + { + return false; + } + let mut response = String::new(); + stream.read_to_string(&mut response).is_ok() && response.starts_with("HTTP/1.1 200") +} enum StdoutEvent { Line(String), @@ -39,6 +160,15 @@ impl BridgeProcess { } fn spawn_with_schema(scenario: &str, actor: &str, schema: &str) -> Self { + Self::spawn_with_backend(scenario, actor, schema, None) + } + + fn spawn_with_backend( + scenario: &str, + actor: &str, + schema: &str, + backend_url: Option<&str>, + ) -> Self { let schema_path = std::env::temp_dir().join(format!( "encrypted-spaces-bridge-{}-{}-{}.kdl", std::process::id(), @@ -46,13 +176,16 @@ impl BridgeProcess { actor )); fs::write(&schema_path, schema).expect("write bridge schema fixture"); - let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) + let mut command = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")); + command .env("ENCRYPTED_SPACES_ACTOR_ID", actor) .env("ENCRYPTED_SPACES_SCHEMA_PATH", &schema_path) .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .expect("spawn bridge"); + .stdout(Stdio::piped()); + if let Some(backend_url) = backend_url { + command.env("ENCRYPTED_SPACES_BACKEND_URL", backend_url); + } + let mut child = command.spawn().expect("spawn bridge"); let stdin = child.stdin.take().expect("bridge stdin"); let stdout = child.stdout.take().expect("bridge stdout"); let (stdout_sender, stdout_receiver) = mpsc::channel(); @@ -208,6 +341,7 @@ struct Observation { struct Scenario { name: String, + backend: BackendProcess, bridges: BTreeMap, observations: Vec, failures: Vec, @@ -215,12 +349,19 @@ struct Scenario { impl Scenario { fn start(name: &str, actors: &[&str]) -> Self { + let backend = BackendProcess::spawn(name, SCHEMA_KDL); let bridges = actors .iter() - .map(|actor| ((*actor).to_owned(), BridgeProcess::spawn(name, actor))) + .map(|actor| { + ( + (*actor).to_owned(), + BridgeProcess::spawn_with_backend(name, actor, SCHEMA_KDL, Some(&backend.url)), + ) + }) .collect(); Self { name: name.to_owned(), + backend, bridges, observations: Vec::new(), failures: Vec::new(), @@ -260,8 +401,15 @@ impl Scenario { .remove(actor) .unwrap_or_else(|| panic!("scenario actor {actor} has no bridge process")); previous.finish(); - self.bridges - .insert(actor.to_owned(), BridgeProcess::spawn(&self.name, actor)); + self.bridges.insert( + actor.to_owned(), + BridgeProcess::spawn_with_backend( + &self.name, + actor, + SCHEMA_KDL, + Some(&self.backend.url), + ), + ); } fn returned_string(&self, index: usize, field: &str, fallback: &str) -> String { @@ -607,7 +755,7 @@ fn runtime_request_trust_override_is_rejected() { } #[test] -fn runtime_process_trust_metadata_is_derived_and_stable_is_red() { +fn runtime_hello_health_metadata_is_process_bound() { let modified_schema = format!( "{SCHEMA_KDL}\ntable \"schema_change_probe\" {{\n column \"id\" type=\"int\" plaintext=#true\n}}\n" ); @@ -653,6 +801,37 @@ fn runtime_process_trust_metadata_is_derived_and_stable_is_red() { assert_eq!(changed.ff_guest_image_id, first.ff_guest_image_id); } +#[test] +fn runtime_create_restore_uses_backend_and_survives_process_restart() { + let actor = "actor-create-restore-parametric"; + let mut scenario = Scenario::start("create-restore", &[actor]); + + let create = scenario.request(actor, "space.create", json!({})); + let space_id = scenario.returned_string(create, "space_id", "missing-create-space"); + let snapshot = scenario.request(actor, "space.snapshot", json!({"space_id": space_id})); + let snapshot_value = + scenario.returned_value(snapshot, "snapshot", json!({"missing": "snapshot"})); + scenario.restart(actor); + let restore = scenario.request(actor, "space.restore", json!({"snapshot": snapshot_value})); + + scenario.verify::(create, |result| { + valid_created_space(result) + .then_some(()) + .ok_or_else(|| "space.create returned an invalid SpaceId".to_owned()) + }); + scenario.verify::(snapshot, |result| { + (result.space_id == space_id && is_opaque_ref(&result.snapshot)) + .then_some(()) + .ok_or_else(|| "space.snapshot returned the wrong space or no snapshot".to_owned()) + }); + scenario.verify::(restore, |result| { + (result.space_id == space_id && result.restored) + .then_some(()) + .ok_or_else(|| "space.restore did not restore after process restart".to_owned()) + }); + scenario.finish(); +} + #[test] fn runtime_space_lifecycle_is_red() { let owner = "owner-lifecycle-parametric"; diff --git a/bridge/tests/stdio.rs b/bridge/tests/stdio.rs index cef6f9c..d416a92 100644 --- a/bridge/tests/stdio.rs +++ b/bridge/tests/stdio.rs @@ -202,7 +202,7 @@ fn assert_future_success(response: &Value, operation: &str) { } #[test] -fn protocol_hello_reports_process_configured_actor_is_red() { +fn protocol_hello_reports_process_configured_actor() { for actor_id in ["configured-actor-one", "configured-actor-two"] { let mut bridge = Bridge::spawn(actor_id); bridge.send_request("hello-request", "hello", json!({})); @@ -213,7 +213,7 @@ fn protocol_hello_reports_process_configured_actor_is_red() { } #[test] -fn protocol_version_is_red() { +fn protocol_version_reports_bridge_contract() { let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); bridge.send_request("version-request", "version", json!({})); assert_future_success(&bridge.receive(), "version"); diff --git a/sdk/src/schema.rs b/sdk/src/schema.rs index 9ee90d3..af88ec6 100644 --- a/sdk/src/schema.rs +++ b/sdk/src/schema.rs @@ -48,6 +48,11 @@ pub enum ApplicationSchema { /// which pairs the bytes with a build-time-computed commitment and /// the FF-proof guest image ID. FromBytes(&'static [u8], DataCommitment, FfImageId), + + /// Parse a schema bundle owned by a long-running runtime process. + /// This is equivalent to [`Self::FromBytes`] without requiring callers + /// to leak configuration loaded from disk at startup. + FromOwnedBytes(Vec, DataCommitment, FfImageId), } impl ApplicationSchema { @@ -69,6 +74,9 @@ impl ApplicationSchema { ApplicationSchema::FromBytes(bytes, commitment, image_id) => { Self::app_schema_bundle_into_parts(bytes, commitment, image_id).await } + ApplicationSchema::FromOwnedBytes(bytes, commitment, image_id) => { + Self::app_schema_bundle_into_parts(&bytes, commitment, image_id).await + } } } From 737996ec01f773cf890c4d084ab72bbf29451d2d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:04:32 -0400 Subject: [PATCH 19/35] feat(bridge): sync verified space state --- bridge/src/runtime.rs | 42 +++++++++++++++++++++++++++++++++++++++++ bridge/tests/runtime.rs | 17 +++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index 22137c1..f9b08b8 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -59,6 +59,14 @@ struct RestorePayload { snapshot: Value, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SyncPayload { + space_id: String, + #[serde(default)] + wait_for_change_ms: Option, +} + #[derive(Serialize)] struct CreateResult<'a> { space_id: String, @@ -77,6 +85,12 @@ struct RestoreResult { restored: bool, } +#[derive(Serialize)] +struct SyncResult { + space_id: String, + synced: bool, +} + impl Runtime { pub fn from_env() -> io::Result { let actor_id = required_env(ACTOR_ID_ENV)?; @@ -140,6 +154,7 @@ impl Runtime { Operation::Create => self.create(request.request_id, request.payload), Operation::Snapshot => self.snapshot(request.request_id, request.payload), Operation::Restore => self.restore(request.request_id, request.payload), + Operation::Sync => self.sync(request.request_id, request.payload), operation => { let _operation_name = operation.name(); let _ = request.payload; @@ -220,6 +235,33 @@ impl Runtime { ) } + fn sync(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + if payload.wait_for_change_ms.is_some() { + return Response::not_implemented(request_id); + } + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + match self.executor.block_on(space.sync()) { + Ok(()) => Response::success( + request_id, + SyncResult { + space_id, + synced: true, + }, + ), + Err(_) => sdk_error(request_id), + } + } + fn application_schema(&self) -> ApplicationSchema { ApplicationSchema::FromOwnedBytes( self.process.schema.clone(), diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 46b1c6d..dce5398 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -832,6 +832,23 @@ fn runtime_create_restore_uses_backend_and_survives_process_restart() { scenario.finish(); } +#[test] +fn runtime_snapshot_sync_runs_verified_backend_recovery() { + let actor = "actor-snapshot-sync-parametric"; + let mut scenario = Scenario::start("snapshot-sync", &[actor]); + + let create = scenario.request(actor, "space.create", json!({})); + let space_id = scenario.returned_string(create, "space_id", "missing-sync-space"); + let sync = scenario.request(actor, "space.sync", json!({"space_id": space_id})); + + scenario.verify::(sync, |result| { + (result.space_id == space_id && result.synced) + .then_some(()) + .ok_or_else(|| "space.sync did not complete verified recovery".to_owned()) + }); + scenario.finish(); +} + #[test] fn runtime_space_lifecycle_is_red() { let owner = "owner-lifecycle-parametric"; From e2b82fa8f00442ac221aa7624deb33bc0c1c87e0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:06:21 -0400 Subject: [PATCH 20/35] feat(bridge): expose verified table operations --- bridge/src/runtime.rs | 93 +++++++++++++++++++++++++++++++++++++++++ bridge/tests/runtime.rs | 2 +- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index f9b08b8..c03bb9a 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -67,6 +67,23 @@ struct SyncPayload { wait_for_change_ms: Option, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TableInsertPayload { + space_id: String, + table: String, + row: Value, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TableSelectPayload { + space_id: String, + table: String, + #[serde(rename = "where")] + where_clause: serde_json::Map, +} + #[derive(Serialize)] struct CreateResult<'a> { space_id: String, @@ -91,6 +108,16 @@ struct SyncResult { synced: bool, } +#[derive(Serialize)] +struct TableInsertResult { + row_id: i64, +} + +#[derive(Serialize)] +struct TableSelectResult { + rows: Vec, +} + impl Runtime { pub fn from_env() -> io::Result { let actor_id = required_env(ACTOR_ID_ENV)?; @@ -155,6 +182,8 @@ impl Runtime { Operation::Snapshot => self.snapshot(request.request_id, request.payload), Operation::Restore => self.restore(request.request_id, request.payload), Operation::Sync => self.sync(request.request_id, request.payload), + Operation::TableInsert => self.table_insert(request.request_id, request.payload), + Operation::TableSelect => self.table_select(request.request_id, request.payload), operation => { let _operation_name = operation.name(); let _ = request.payload; @@ -262,6 +291,62 @@ impl Runtime { } } + fn table_insert(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) if payload.row.is_object() => payload, + Ok(_) => return invalid_request(request_id), + Err(response) => return response, + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + if payload.space_id != space.id().to_string() { + return invalid_state(request_id); + } + let table = space.table::(&payload.table); + match self.executor.block_on(table.insert(&payload.row).execute()) { + Ok(row_id) => Response::success(request_id, TableInsertResult { row_id }), + Err(_) => sdk_error(request_id), + } + } + + fn table_select(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + if payload.space_id != space.id().to_string() || payload.where_clause.len() != 1 { + return invalid_request(request_id); + } + let (column, value) = payload + .where_clause + .into_iter() + .next() + .expect("one predicate"); + let Some(schema) = space.get_table_schema(&payload.table) else { + return invalid_request(request_id); + }; + if column != "id" + && !schema + .indexed_columns() + .iter() + .any(|indexed| indexed == &column) + { + return invalid_request(request_id); + } + let table = space.table::(&payload.table); + match self + .executor + .block_on(table.select().where_eq(&column, value).all()) + { + Ok(rows) => Response::success(request_id, TableSelectResult { rows }), + Err(_) => sdk_error(request_id), + } + } + fn application_schema(&self) -> ApplicationSchema { ApplicationSchema::FromOwnedBytes( self.process.schema.clone(), @@ -289,6 +374,14 @@ fn invalid_state(request_id: String) -> Response { ) } +fn invalid_request(request_id: String) -> Response { + Response::error( + Some(request_id), + "INVALID_REQUEST", + "invalid bridge request", + ) +} + fn sdk_error(request_id: String) -> Response { Response::error( Some(request_id), diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index dce5398..385826d 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -1000,7 +1000,7 @@ fn runtime_space_lifecycle_is_red() { } #[test] -fn runtime_table_insert_select_are_red() { +fn runtime_table_insert_select_round_trip_verified_rows() { let actor = "actor-table-parametric"; let label = "table-value-parametric"; let rank = 41; From 36684778ebacb2f6d12dee0e21350bed388b243e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:09:05 -0400 Subject: [PATCH 21/35] feat(bridge): expose scoped list operations --- Cargo.lock | 1 + bridge/Cargo.toml | 1 + bridge/src/runtime.rs | 195 ++++++++++++++++++++++++++++++++++++++++ bridge/tests/runtime.rs | 2 +- 4 files changed, 198 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 8545001..a8d3740 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2247,6 +2247,7 @@ dependencies = [ name = "encrypted-spaces-bridge" version = "0.0.1" dependencies = [ + "base64 0.22.1", "encrypted-spaces-ffproof", "encrypted-spaces-sdk", "serde", diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml index f7f5914..904632e 100644 --- a/bridge/Cargo.toml +++ b/bridge/Cargo.toml @@ -11,6 +11,7 @@ name = "encrypted-spaces-bridge" path = "src/main.rs" [dependencies] +base64.workspace = true encrypted-spaces-ffproof = { workspace = true, features = ["prove"] } encrypted-spaces-sdk = { workspace = true, features = ["local-transport"] } serde.workspace = true diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index c03bb9a..5afc64a 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -1,5 +1,6 @@ use crate::protocol::Response; use crate::schema::{Operation, Request, PROTOCOL_VERSION}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use encrypted_spaces_sdk::{ApplicationSchema, LocalTransport, Space, WebSocketTransport}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; @@ -84,6 +85,38 @@ struct TableSelectPayload { where_clause: serde_json::Map, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ListCreatePayload { + space_id: String, + table: String, + row_id: i64, + column: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ListAppendPayload { + space_id: String, + list_ref: String, + value: Value, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ListReadPayload { + space_id: String, + list_ref: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ListRef { + table: String, + row_id: i64, + column: String, +} + #[derive(Serialize)] struct CreateResult<'a> { space_id: String, @@ -118,6 +151,36 @@ struct TableSelectResult { rows: Vec, } +#[derive(Serialize)] +struct ListCreateResult { + space_id: String, + table: String, + row_id: i64, + column: String, + list_ref: String, +} + +#[derive(Serialize)] +struct ListAppendResult { + space_id: String, + list_ref: String, + item_ref: String, +} + +#[derive(Serialize)] +struct ListItemResult { + item_ref: String, + position: u64, + value: Value, +} + +#[derive(Serialize)] +struct ListReadResult { + space_id: String, + list_ref: String, + items: Vec, +} + impl Runtime { pub fn from_env() -> io::Result { let actor_id = required_env(ACTOR_ID_ENV)?; @@ -184,6 +247,9 @@ impl Runtime { Operation::Sync => self.sync(request.request_id, request.payload), Operation::TableInsert => self.table_insert(request.request_id, request.payload), Operation::TableSelect => self.table_select(request.request_id, request.payload), + Operation::ListCreate => self.list_create(request.request_id, request.payload), + Operation::ListAppend => self.list_append(request.request_id, request.payload), + Operation::ListRead => self.list_read(request.request_id, request.payload), operation => { let _operation_name = operation.name(); let _ = request.payload; @@ -347,6 +413,116 @@ impl Runtime { } } + fn list_create(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) + if !payload.table.is_empty() + && payload.row_id > 0 + && !payload.column.is_empty() => + { + payload + } + Ok(_) => return invalid_request(request_id), + Err(response) => return response, + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + let list = space.list::(&payload.table, payload.row_id, &payload.column); + if self.executor.block_on(list.get_all()).is_err() { + return sdk_error(request_id); + } + let list_ref = match encode_opaque_ref(&ListRef { + table: payload.table.clone(), + row_id: payload.row_id, + column: payload.column.clone(), + }) { + Ok(list_ref) => list_ref, + Err(()) => return internal_error(request_id), + }; + Response::success( + request_id, + ListCreateResult { + space_id, + table: payload.table, + row_id: payload.row_id, + column: payload.column, + list_ref, + }, + ) + } + + fn list_append(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + let list_ref = match decode_opaque_ref::(&payload.list_ref) { + Ok(list_ref) => list_ref, + Err(()) => return invalid_request(request_id), + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + let list = space.list::(&list_ref.table, list_ref.row_id, &list_ref.column); + match self.executor.block_on(list.append(&payload.value)) { + Ok(item_ref) => Response::success( + request_id, + ListAppendResult { + space_id, + list_ref: payload.list_ref, + item_ref: URL_SAFE_NO_PAD.encode(item_ref), + }, + ), + Err(_) => sdk_error(request_id), + } + } + + fn list_read(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + let list_ref = match decode_opaque_ref::(&payload.list_ref) { + Ok(list_ref) => list_ref, + Err(()) => return invalid_request(request_id), + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + let list = space.list::(&list_ref.table, list_ref.row_id, &list_ref.column); + match self.executor.block_on(list.get_all()) { + Ok(entries) => Response::success( + request_id, + ListReadResult { + space_id, + list_ref: payload.list_ref, + items: entries + .into_iter() + .map(|entry| ListItemResult { + item_ref: URL_SAFE_NO_PAD.encode(entry.key), + position: entry.position, + value: entry.value, + }) + .collect(), + }, + ), + Err(_) => sdk_error(request_id), + } + } + fn application_schema(&self) -> ApplicationSchema { ApplicationSchema::FromOwnedBytes( self.process.schema.clone(), @@ -390,6 +566,25 @@ fn sdk_error(request_id: String) -> Response { ) } +fn internal_error(request_id: String) -> Response { + Response::error( + Some(request_id), + "INTERNAL_ERROR", + "bridge operation failed", + ) +} + +fn encode_opaque_ref(value: &impl Serialize) -> Result { + serde_json::to_vec(value) + .map(|bytes| URL_SAFE_NO_PAD.encode(bytes)) + .map_err(|_| ()) +} + +fn decode_opaque_ref(value: &str) -> Result { + let bytes = URL_SAFE_NO_PAD.decode(value).map_err(|_| ())?; + serde_json::from_slice(&bytes).map_err(|_| ()) +} + fn required_env(name: &str) -> io::Result { std::env::var(name) .ok() diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 385826d..e73db8d 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -1041,7 +1041,7 @@ fn runtime_table_insert_select_round_trip_verified_rows() { } #[test] -fn runtime_list_create_append_read_are_red() { +fn runtime_list_create_append_read_round_trip() { let actor = "actor-list-parametric"; let label = "list-parent-parametric"; let rank = 73; From 4dc221a31acc93191b3d92198ed2b52ea366768b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:14:31 -0400 Subject: [PATCH 22/35] feat(bridge): expose collaborative text operations --- bridge/src/runtime.rs | 195 ++++++++++++++++++++++++++++++++++++++-- bridge/tests/runtime.rs | 12 ++- 2 files changed, 198 insertions(+), 9 deletions(-) diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index 5afc64a..59ff427 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -109,14 +109,48 @@ struct ListReadPayload { list_ref: String, } +#[derive(Clone, Copy, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum RefKind { + List, + Text, +} + #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] -struct ListRef { +struct ScopedRef { + kind: RefKind, table: String, row_id: i64, column: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TextCreatePayload { + space_id: String, + table: String, + row_id: i64, + column: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TextEditPayload { + space_id: String, + text_ref: String, + position: usize, + delete_count: usize, + insert: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TextReadPayload { + space_id: String, + text_ref: String, +} + #[derive(Serialize)] struct CreateResult<'a> { space_id: String, @@ -181,6 +215,29 @@ struct ListReadResult { items: Vec, } +#[derive(Serialize)] +struct TextCreateResult { + space_id: String, + table: String, + row_id: i64, + column: String, + text_ref: String, +} + +#[derive(Serialize)] +struct TextEditResult { + space_id: String, + text_ref: String, + edited: bool, +} + +#[derive(Serialize)] +struct TextReadResult { + space_id: String, + text_ref: String, + text: String, +} + impl Runtime { pub fn from_env() -> io::Result { let actor_id = required_env(ACTOR_ID_ENV)?; @@ -250,6 +307,9 @@ impl Runtime { Operation::ListCreate => self.list_create(request.request_id, request.payload), Operation::ListAppend => self.list_append(request.request_id, request.payload), Operation::ListRead => self.list_read(request.request_id, request.payload), + Operation::TextCreate => self.text_create(request.request_id, request.payload), + Operation::TextEdit => self.text_edit(request.request_id, request.payload), + Operation::TextRead => self.text_read(request.request_id, request.payload), operation => { let _operation_name = operation.name(); let _ = request.payload; @@ -436,7 +496,8 @@ impl Runtime { if self.executor.block_on(list.get_all()).is_err() { return sdk_error(request_id); } - let list_ref = match encode_opaque_ref(&ListRef { + let list_ref = match encode_opaque_ref(&ScopedRef { + kind: RefKind::List, table: payload.table.clone(), row_id: payload.row_id, column: payload.column.clone(), @@ -461,9 +522,9 @@ impl Runtime { Ok(payload) => payload, Err(response) => return response, }; - let list_ref = match decode_opaque_ref::(&payload.list_ref) { - Ok(list_ref) => list_ref, - Err(()) => return invalid_request(request_id), + let list_ref = match decode_opaque_ref::(&payload.list_ref) { + Ok(list_ref) if list_ref.kind == RefKind::List => list_ref, + Ok(_) | Err(()) => return invalid_request(request_id), }; let Some(space) = self.space.as_ref() else { return invalid_state(request_id); @@ -491,9 +552,9 @@ impl Runtime { Ok(payload) => payload, Err(response) => return response, }; - let list_ref = match decode_opaque_ref::(&payload.list_ref) { - Ok(list_ref) => list_ref, - Err(()) => return invalid_request(request_id), + let list_ref = match decode_opaque_ref::(&payload.list_ref) { + Ok(list_ref) if list_ref.kind == RefKind::List => list_ref, + Ok(_) | Err(()) => return invalid_request(request_id), }; let Some(space) = self.space.as_ref() else { return invalid_state(request_id); @@ -523,6 +584,124 @@ impl Runtime { } } + fn text_create(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) + if !payload.table.is_empty() + && payload.row_id > 0 + && !payload.column.is_empty() => + { + payload + } + Ok(_) => return invalid_request(request_id), + Err(response) => return response, + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + let text = space.textarea(&payload.table, payload.row_id, &payload.column); + if self.executor.block_on(text.sync()).is_err() { + return sdk_error(request_id); + } + let text_ref = match encode_opaque_ref(&ScopedRef { + kind: RefKind::Text, + table: payload.table.clone(), + row_id: payload.row_id, + column: payload.column.clone(), + }) { + Ok(text_ref) => text_ref, + Err(()) => return internal_error(request_id), + }; + Response::success( + request_id, + TextCreateResult { + space_id, + table: payload.table, + row_id: payload.row_id, + column: payload.column, + text_ref, + }, + ) + } + + fn text_edit(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + let text_ref = match decode_opaque_ref::(&payload.text_ref) { + Ok(text_ref) if text_ref.kind == RefKind::Text => text_ref, + Ok(_) | Err(()) => return invalid_request(request_id), + }; + let Some(delete_end) = payload.position.checked_add(payload.delete_count) else { + return invalid_request(request_id); + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + let text = space.textarea(&text_ref.table, text_ref.row_id, &text_ref.column); + let result = self.executor.block_on(async { + text.sync().await?; + if payload.delete_count > 0 { + text.delete_range(payload.position, delete_end).await?; + } + if !payload.insert.is_empty() { + text.insert_string(payload.position, &payload.insert) + .await?; + } + encrypted_spaces_sdk::SdkResult::Ok(()) + }); + match result { + Ok(()) => Response::success( + request_id, + TextEditResult { + space_id, + text_ref: payload.text_ref, + edited: true, + }, + ), + Err(_) => sdk_error(request_id), + } + } + + fn text_read(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + let text_ref = match decode_opaque_ref::(&payload.text_ref) { + Ok(text_ref) if text_ref.kind == RefKind::Text => text_ref, + Ok(_) | Err(()) => return invalid_request(request_id), + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + let text = space.textarea(&text_ref.table, text_ref.row_id, &text_ref.column); + match self.executor.block_on(text.snapshot()) { + Ok(text) => Response::success( + request_id, + TextReadResult { + space_id, + text_ref: payload.text_ref, + text, + }, + ), + Err(_) => sdk_error(request_id), + } + } + fn application_schema(&self) -> ApplicationSchema { ApplicationSchema::FromOwnedBytes( self.process.schema.clone(), diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index e73db8d..07a2728 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -1123,7 +1123,7 @@ fn runtime_list_create_append_read_round_trip() { } #[test] -fn runtime_text_create_edit_read_are_red() { +fn runtime_text_create_edit_read_round_trip() { let actor = "actor-text-parametric"; let edited = "edited text parametric"; let mut scenario = Scenario::start("text", &[actor]); @@ -1163,6 +1163,11 @@ fn runtime_text_create_edit_read_are_red() { "text.read", json!({"space_id": space_id, "text_ref": text_ref}), ); + let wrong_kind = scenario.request( + actor, + "list.read", + json!({"space_id": space_id, "list_ref": text_ref}), + ); scenario.verify::(create_space, |result| { valid_created_space(result) @@ -1193,6 +1198,11 @@ fn runtime_text_create_edit_read_are_red() { .then_some(()) .ok_or_else(|| "text.read did not return the edited textarea".to_owned()) }); + scenario.verify_error::(wrong_kind, |error| { + (error.code == "INVALID_REQUEST") + .then_some(()) + .ok_or_else(|| "list.read accepted a text capability".to_owned()) + }); scenario.finish(); } From 88d2eded7e9dba6d7ad25455954c6ef06ae35a58 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:16:16 -0400 Subject: [PATCH 23/35] feat(bridge): expose encrypted file operations --- bridge/src/runtime.rs | 107 +++++++++++++++++++++++++++++++++++++++- bridge/tests/runtime.rs | 2 +- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index 59ff427..5df5d6c 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -1,7 +1,10 @@ use crate::protocol::Response; use crate::schema::{Operation, Request, PROTOCOL_VERSION}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use encrypted_spaces_sdk::{ApplicationSchema, LocalTransport, Space, WebSocketTransport}; +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine, +}; +use encrypted_spaces_sdk::{ApplicationSchema, File, LocalTransport, Space, WebSocketTransport}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -151,6 +154,20 @@ struct TextReadPayload { text_ref: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FilePutPayload { + space_id: String, + bytes_base64: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FileGetPayload { + space_id: String, + digest: String, +} + #[derive(Serialize)] struct CreateResult<'a> { space_id: String, @@ -238,6 +255,19 @@ struct TextReadResult { text: String, } +#[derive(Serialize)] +struct FilePutResult { + space_id: String, + digest: String, +} + +#[derive(Serialize)] +struct FileGetResult { + space_id: String, + digest: String, + bytes_base64: String, +} + impl Runtime { pub fn from_env() -> io::Result { let actor_id = required_env(ACTOR_ID_ENV)?; @@ -310,6 +340,8 @@ impl Runtime { Operation::TextCreate => self.text_create(request.request_id, request.payload), Operation::TextEdit => self.text_edit(request.request_id, request.payload), Operation::TextRead => self.text_read(request.request_id, request.payload), + Operation::FilePut => self.file_put(request.request_id, request.payload), + Operation::FileGet => self.file_get(request.request_id, request.payload), operation => { let _operation_name = operation.name(); let _ = request.payload; @@ -702,6 +734,73 @@ impl Runtime { } } + fn file_put(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + let bytes = match STANDARD.decode(&payload.bytes_base64) { + Ok(bytes) => bytes, + Err(_) => return invalid_request(request_id), + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + match self + .executor + .block_on(space.file().upload(File::from_data(bytes))) + { + Ok(file) => match file.hash() { + Ok(digest) => Response::success( + request_id, + FilePutResult { + space_id, + digest: digest.to_owned(), + }, + ), + Err(_) => internal_error(request_id), + }, + Err(_) => sdk_error(request_id), + } + } + + fn file_get(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) if valid_digest(&payload.digest) => payload, + Ok(_) => return invalid_request(request_id), + Err(response) => return response, + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + match self.executor.block_on( + space + .file() + .download(&File::from_hash(payload.digest.clone())), + ) { + Ok(file) => match file.into_data() { + Ok(bytes) => Response::success( + request_id, + FileGetResult { + space_id, + digest: payload.digest, + bytes_base64: STANDARD.encode(bytes), + }, + ), + Err(_) => internal_error(request_id), + }, + Err(_) => sdk_error(request_id), + } + } + fn application_schema(&self) -> ApplicationSchema { ApplicationSchema::FromOwnedBytes( self.process.schema.clone(), @@ -764,6 +863,10 @@ fn decode_opaque_ref(value: &str) -> Result { serde_json::from_slice(&bytes).map_err(|_| ()) } +fn valid_digest(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + fn required_env(name: &str) -> io::Result { std::env::var(name) .ok() diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 07a2728..82c01ed 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -1207,7 +1207,7 @@ fn runtime_text_create_edit_read_round_trip() { } #[test] -fn runtime_file_put_get_are_red() { +fn runtime_file_put_get_round_trip_encrypted_bytes() { let actor = "actor-file-parametric"; let bytes_base64 = "YnJpZGdlLWZpbGUtcGFyYW1ldHJpYw=="; let mut scenario = Scenario::start("file", &[actor]); From 00a638bdaeeeddb33973f0c0bbb57822a830a66c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:29:57 -0400 Subject: [PATCH 24/35] feat(bridge): enforce member lifecycle --- bridge/src/runtime.rs | 177 ++++++++++++++++++++++++++++++++++++++-- bridge/tests/runtime.rs | 20 +++-- 2 files changed, 187 insertions(+), 10 deletions(-) diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index 5df5d6c..f296769 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -4,7 +4,9 @@ use base64::{ engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, Engine, }; -use encrypted_spaces_sdk::{ApplicationSchema, File, LocalTransport, Space, WebSocketTransport}; +use encrypted_spaces_sdk::{ + ApplicationSchema, File, LocalTransport, SdkErrorType, Space, SpaceInvite, WebSocketTransport, +}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -168,6 +170,25 @@ struct FileGetPayload { digest: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct JoinPayload { + invite: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MemberPayload { + space_id: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RemovePayload { + space_id: String, + member_id: i64, +} + #[derive(Serialize)] struct CreateResult<'a> { space_id: String, @@ -268,6 +289,27 @@ struct FileGetResult { bytes_base64: String, } +#[derive(Serialize)] +struct InviteResult { + space_id: String, + member_id: i64, + invite: String, +} + +#[derive(Serialize)] +struct JoinResult { + space_id: String, + member_id: i64, + joined: bool, +} + +#[derive(Serialize)] +struct RemoveResult { + space_id: String, + member_id: i64, + removed: bool, +} + impl Runtime { pub fn from_env() -> io::Result { let actor_id = required_env(ACTOR_ID_ENV)?; @@ -342,6 +384,11 @@ impl Runtime { Operation::TextRead => self.text_read(request.request_id, request.payload), Operation::FilePut => self.file_put(request.request_id, request.payload), Operation::FileGet => self.file_get(request.request_id, request.payload), + Operation::MemberInvite => self.member_invite(request.request_id, request.payload), + Operation::MemberJoin | Operation::Join => { + self.member_join(request.request_id, request.payload) + } + Operation::MemberRemove => self.member_remove(request.request_id, request.payload), operation => { let _operation_name = operation.name(); let _ = request.payload; @@ -391,7 +438,7 @@ impl Runtime { } match self.executor.block_on(space.snapshot()) { Ok(snapshot) => Response::success(request_id, SnapshotResult { space_id, snapshot }), - Err(_) => sdk_error(request_id), + Err(error) => sdk_operation_error(request_id, &error), } } @@ -445,7 +492,7 @@ impl Runtime { synced: true, }, ), - Err(_) => sdk_error(request_id), + Err(error) => sdk_operation_error(request_id, &error), } } @@ -464,7 +511,7 @@ impl Runtime { let table = space.table::(&payload.table); match self.executor.block_on(table.insert(&payload.row).execute()) { Ok(row_id) => Response::success(request_id, TableInsertResult { row_id }), - Err(_) => sdk_error(request_id), + Err(error) => sdk_operation_error(request_id, &error), } } @@ -501,7 +548,7 @@ impl Runtime { .block_on(table.select().where_eq(&column, value).all()) { Ok(rows) => Response::success(request_id, TableSelectResult { rows }), - Err(_) => sdk_error(request_id), + Err(error) => sdk_operation_error(request_id, &error), } } @@ -801,6 +848,101 @@ impl Runtime { } } + fn member_invite(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + match self.executor.block_on(space.invite_user()) { + Ok(invite) => { + let Some(member_id) = invite.id() else { + return internal_error(request_id); + }; + match encode_opaque_ref(&invite) { + Ok(invite) => Response::success( + request_id, + InviteResult { + space_id, + member_id, + invite, + }, + ), + Err(()) => internal_error(request_id), + } + } + Err(error) => sdk_operation_error(request_id, &error), + } + } + + fn member_join(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) => payload, + Err(response) => return response, + }; + if self.space.is_some() { + return invalid_state(request_id); + } + let invite = match decode_opaque_ref::(&payload.invite) { + Ok(invite) => invite, + Err(()) => return invalid_request(request_id), + }; + let backend_url = self.process.backend_url.clone(); + let schema = self.application_schema(); + let space = match self.executor.block_on(async move { + let transport = WebSocketTransport::new(&backend_url).await?; + Space::join(transport, invite, schema).await + }) { + Ok(space) => space, + Err(error) => return sdk_operation_error(request_id, &error), + }; + let space_id = space.id().to_string(); + let Some(member_id) = space.uid().map(i64::from) else { + return internal_error(request_id); + }; + self.space = Some(space); + Response::success( + request_id, + JoinResult { + space_id, + member_id, + joined: true, + }, + ) + } + + fn member_remove(&mut self, request_id: String, payload: Value) -> Response { + let payload = match parse_payload::(&request_id, payload) { + Ok(payload) if payload.member_id > 0 => payload, + Ok(_) => return invalid_request(request_id), + Err(response) => return response, + }; + let Some(space) = self.space.as_ref() else { + return invalid_state(request_id); + }; + let space_id = space.id().to_string(); + if payload.space_id != space_id { + return invalid_state(request_id); + } + match self.executor.block_on(space.remove_user(payload.member_id)) { + Ok(()) => Response::success( + request_id, + RemoveResult { + space_id, + member_id: payload.member_id, + removed: true, + }, + ), + Err(error) => sdk_operation_error(request_id, &error), + } + } + fn application_schema(&self) -> ApplicationSchema { ApplicationSchema::FromOwnedBytes( self.process.schema.clone(), @@ -844,6 +986,31 @@ fn sdk_error(request_id: String) -> Response { ) } +fn sdk_operation_error(request_id: String, error: &SdkErrorType) -> Response { + let removed_member = matches!( + error, + SdkErrorType::ValidationError(message) + if message == "no GK delivery slot available for current user" + ) || matches!( + error, + SdkErrorType::DecryptionError(message) if message == "missing key for current key_id" + ); + if matches!(error, SdkErrorType::AccessDenied(_)) + || removed_member + || error + .to_string() + .to_ascii_lowercase() + .contains("access denied") + { + return Response::error( + Some(request_id), + "ACCESS_DENIED", + "encrypted spaces access denied", + ); + } + sdk_error(request_id) +} + fn internal_error(request_id: String) -> Response { Response::error( Some(request_id), diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 82c01ed..20dd724 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -15,7 +15,7 @@ use std::time::{Duration, Instant}; const PROTOCOL_VERSION: u64 = 1; const UPSTREAM_COMMIT: &str = "4cda0ae87698135aa672990e6e68cf7873847426"; const RUST_TOOLCHAIN: &str = "1.94.1"; -const RESPONSE_TIMEOUT: Duration = Duration::from_secs(2); +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); const EXIT_TIMEOUT: Duration = Duration::from_secs(2); const BACKEND_START_TIMEOUT: Duration = Duration::from_secs(10); @@ -850,7 +850,7 @@ fn runtime_snapshot_sync_runs_verified_backend_recovery() { } #[test] -fn runtime_space_lifecycle_is_red() { +fn runtime_space_lifecycle_survives_restart_and_membership_changes() { let owner = "owner-lifecycle-parametric"; let member = "member-lifecycle-parametric"; let mut scenario = Scenario::start("lifecycle", &[owner, member]); @@ -1246,7 +1246,7 @@ fn runtime_file_put_get_round_trip_encrypted_bytes() { } #[test] -fn runtime_member_invite_join_remove_are_red() { +fn runtime_member_invite_join_remove_enforces_revocation() { let owner = "owner-membership-parametric"; let member = "member-membership-parametric"; let mut scenario = Scenario::start("membership", &[owner, member]); @@ -1334,12 +1334,22 @@ fn runtime_member_invite_join_remove_are_red() { scenario.verify_error::(removed_select, |error| { (error.code == "ACCESS_DENIED") .then_some(()) - .ok_or_else(|| "removed member select did not return ACCESS_DENIED".to_owned()) + .ok_or_else(|| { + format!( + "removed member select returned {}, expected ACCESS_DENIED", + error.code + ) + }) }); scenario.verify_error::(removed_write, |error| { (error.code == "ACCESS_DENIED") .then_some(()) - .ok_or_else(|| "removed member write did not return ACCESS_DENIED".to_owned()) + .ok_or_else(|| { + format!( + "removed member write returned {}, expected ACCESS_DENIED", + error.code + ) + }) }); scenario.verify::(owner_select, |result| { (result.rows.len() == 1 From bf7fc15668a449b8e07e2eaeaa6d1dd10a74649b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:31:48 -0400 Subject: [PATCH 25/35] feat(bridge): close and shutdown runtime state --- bridge/src/protocol.rs | 3 +++ bridge/src/runtime.rs | 40 ++++++++++++++++++++++++++++++++++++++++ bridge/tests/stdio.rs | 17 +++++++++++++---- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/bridge/src/protocol.rs b/bridge/src/protocol.rs index e625188..133cd33 100644 --- a/bridge/src/protocol.rs +++ b/bridge/src/protocol.rs @@ -87,6 +87,9 @@ pub fn run(reader: R, mut writer: W) -> io::Result<()> { Err(response) => response, }; write_response(&mut writer, response)?; + if runtime.should_shutdown() { + return Ok(()); + } } } diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index f296769..744af44 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -22,6 +22,7 @@ pub struct Runtime { executor: tokio::runtime::Runtime, process: ProcessConfig, space: Option, + shutdown_requested: bool, } struct ProcessConfig { @@ -310,6 +311,16 @@ struct RemoveResult { removed: bool, } +#[derive(Serialize)] +struct CloseResult { + closed: bool, +} + +#[derive(Serialize)] +struct ShutdownResult { + shutting_down: bool, +} + impl Runtime { pub fn from_env() -> io::Result { let actor_id = required_env(ACTOR_ID_ENV)?; @@ -347,6 +358,7 @@ impl Runtime { backend_url, }, space: None, + shutdown_requested: false, }) } @@ -389,6 +401,8 @@ impl Runtime { self.member_join(request.request_id, request.payload) } Operation::MemberRemove => self.member_remove(request.request_id, request.payload), + Operation::Close => self.close(request.request_id, request.payload), + Operation::Shutdown => self.shutdown(request.request_id, request.payload), operation => { let _operation_name = operation.name(); let _ = request.payload; @@ -943,6 +957,32 @@ impl Runtime { } } + fn close(&mut self, request_id: String, payload: Value) -> Response { + if let Err(response) = parse_payload::(&request_id, payload) { + return response; + } + self.space.take(); + Response::success(request_id, CloseResult { closed: true }) + } + + fn shutdown(&mut self, request_id: String, payload: Value) -> Response { + if let Err(response) = parse_payload::(&request_id, payload) { + return response; + } + self.space.take(); + self.shutdown_requested = true; + Response::success( + request_id, + ShutdownResult { + shutting_down: true, + }, + ) + } + + pub fn should_shutdown(&self) -> bool { + self.shutdown_requested + } + fn application_schema(&self) -> ApplicationSchema { ApplicationSchema::FromOwnedBytes( self.process.schema.clone(), diff --git a/bridge/tests/stdio.rs b/bridge/tests/stdio.rs index d416a92..8ee03cc 100644 --- a/bridge/tests/stdio.rs +++ b/bridge/tests/stdio.rs @@ -466,17 +466,26 @@ fn protocol_runtime_sdk_errors_are_deterministic_and_redacted_is_red() { } #[test] -fn protocol_close_is_red() { +fn protocol_close_clears_state_and_keeps_process_usable() { let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); bridge.send_request("close-request", "close", json!({})); - assert_future_success(&bridge.receive(), "close"); + let close = bridge.receive(); + assert_future_success(&close, "close"); + assert_eq!(close["result"]["closed"], true); + + bridge.send_request("version-after-close", "version", json!({})); + assert_future_success(&bridge.receive(), "version after close"); } #[test] -fn protocol_shutdown_is_red() { +fn protocol_shutdown_acknowledges_and_exits() { let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); bridge.send_request("shutdown-request", "shutdown", json!({})); - assert_future_success(&bridge.receive(), "shutdown"); + let shutdown = bridge.receive(); + assert_future_success(&shutdown, "shutdown"); + assert_eq!(shutdown["result"]["shutting_down"], true); + assert!(bridge.wait_for_exit().success()); + bridge.expect_stdout_eof(); } #[test] From 059a7be6abc8ab8968aac3f2a66f41d51594e504 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:37:13 -0400 Subject: [PATCH 26/35] feat(bridge): cancel bounded sync waits --- bridge/src/main.rs | 2 +- bridge/src/protocol.rs | 191 +++++++++++++++++++++++++++++++++++++--- bridge/tests/runtime.rs | 20 +++++ bridge/tests/stdio.rs | 2 +- 4 files changed, 202 insertions(+), 13 deletions(-) diff --git a/bridge/src/main.rs b/bridge/src/main.rs index 8e98028..7adf7c0 100644 --- a/bridge/src/main.rs +++ b/bridge/src/main.rs @@ -3,5 +3,5 @@ mod runtime; mod schema; fn main() -> std::io::Result<()> { - protocol::run(std::io::stdin().lock(), std::io::stdout().lock()) + protocol::run(std::io::stdin().lock(), std::io::stdout()) } diff --git a/bridge/src/protocol.rs b/bridge/src/protocol.rs index 133cd33..d574801 100644 --- a/bridge/src/protocol.rs +++ b/bridge/src/protocol.rs @@ -1,10 +1,22 @@ use crate::runtime; use crate::schema::{Operation, Request, MAX_REQUEST_ID_BYTES, PROTOCOL_VERSION}; -use serde::Serialize; -use serde_json::Value; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; use std::io::{self, BufRead, Read, Write}; +use std::sync::{ + atomic::{AtomicU8, Ordering}, + Arc, Mutex, +}; +use std::thread; +use std::time::Duration; pub const MAX_FRAME_BYTES: usize = 64 * 1024; +const MAX_PENDING_REQUESTS: usize = 64; +const MAX_WAIT_FOR_CHANGE_MS: u64 = 60_000; +const PENDING: u8 = 0; +const CANCELED: u8 = 1; +const COMPLETING: u8 = 2; #[derive(Debug, Serialize)] pub struct Response { @@ -65,34 +77,191 @@ enum FrameError { Io(io::Error), } -pub fn run(reader: R, mut writer: W) -> io::Result<()> { - let mut runtime = runtime::Runtime::from_env()?; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WaitSyncPayload { + space_id: String, + wait_for_change_ms: u64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CancelPayload { + request_id: String, +} + +#[derive(Serialize)] +struct CancelResult { + canceled: bool, +} + +pub fn run(reader: R, writer: W) -> io::Result<()> { + let runtime = Arc::new(Mutex::new(runtime::Runtime::from_env()?)); + let writer = Arc::new(Mutex::new(writer)); + let mut pending = HashMap::>::new(); let mut reader = io::BufReader::new(reader); loop { let frame = match read_frame(&mut reader) { Ok(Some(frame)) => frame, Ok(None) => return Ok(()), Err(FrameError::TooLarge) => { - write_response( - &mut writer, + write_shared_response( + &writer, Response::error(None, "FRAME_TOO_LARGE", "JSONL frame exceeds maximum size"), )?; return Ok(()); } Err(FrameError::Io(error)) => return Err(error), }; + pending.retain(|_, state| state.load(Ordering::Acquire) == PENDING); - let response = match decode_request(&frame) { - Ok(request) => runtime.dispatch(request), - Err(response) => response, + let request = match decode_request(&frame) { + Ok(request) => request, + Err(response) => { + write_shared_response(&writer, response)?; + continue; + } }; - write_response(&mut writer, response)?; - if runtime.should_shutdown() { + + if matches!(request.operation, Operation::Cancel) { + cancel_pending(request, &mut pending, &writer)?; + continue; + } + if matches!(request.operation, Operation::Sync) + && request.payload.get("wait_for_change_ms").is_some() + { + schedule_wait_sync( + request, + &mut pending, + Arc::clone(&runtime), + Arc::clone(&writer), + )?; + continue; + } + + let (response, shutdown) = { + let mut runtime = runtime + .lock() + .map_err(|_| io::Error::other("runtime lock poisoned"))?; + let response = runtime.dispatch(request); + (response, runtime.should_shutdown()) + }; + write_shared_response(&writer, response)?; + if shutdown { return Ok(()); } } } +fn schedule_wait_sync( + request: Request, + pending: &mut HashMap>, + runtime: Arc>, + writer: Arc>, +) -> io::Result<()> { + let request_id = request.request_id; + let payload = match serde_json::from_value::(request.payload) { + Ok(payload) + if payload.wait_for_change_ms > 0 + && payload.wait_for_change_ms <= MAX_WAIT_FOR_CHANGE_MS => + { + payload + } + _ => { + return write_shared_response( + &writer, + Response::error( + Some(request_id), + "INVALID_REQUEST", + "invalid bridge request", + ), + ); + } + }; + if pending.len() >= MAX_PENDING_REQUESTS || pending.contains_key(&request_id) { + return write_shared_response( + &writer, + Response::error( + Some(request_id), + "TOO_MANY_PENDING", + "pending bridge request limit reached", + ), + ); + } + + let state = Arc::new(AtomicU8::new(PENDING)); + pending.insert(request_id.clone(), Arc::clone(&state)); + thread::spawn(move || { + thread::sleep(Duration::from_millis(payload.wait_for_change_ms)); + if state + .compare_exchange(PENDING, COMPLETING, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let response = match runtime.lock() { + Ok(mut runtime) => runtime.dispatch(Request { + version: PROTOCOL_VERSION, + request_id, + operation: Operation::Sync, + payload: json!({"space_id": payload.space_id}), + }), + Err(_) => return, + }; + let _ = write_shared_response(&writer, response); + }); + Ok(()) +} + +fn cancel_pending( + request: Request, + pending: &mut HashMap>, + writer: &Arc>, +) -> io::Result<()> { + let payload = match serde_json::from_value::(request.payload) { + Ok(payload) if !payload.request_id.is_empty() => payload, + _ => { + return write_shared_response( + writer, + Response::error( + Some(request.request_id), + "INVALID_REQUEST", + "invalid bridge request", + ), + ); + } + }; + let canceled = pending.remove(&payload.request_id).is_some_and(|state| { + state + .compare_exchange(PENDING, CANCELED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + }); + let mut writer = writer + .lock() + .map_err(|_| io::Error::other("writer lock poisoned"))?; + if canceled { + write_response( + &mut *writer, + Response::error( + Some(payload.request_id), + "CANCELED", + "bridge request canceled", + ), + )?; + } + write_response( + &mut *writer, + Response::success(request.request_id, CancelResult { canceled }), + ) +} + +fn write_shared_response(writer: &Arc>, response: Response) -> io::Result<()> { + let mut writer = writer + .lock() + .map_err(|_| io::Error::other("writer lock poisoned"))?; + write_response(&mut *writer, response) +} + fn decode_request(frame: &[u8]) -> Result { let value = match serde_json::from_slice::(frame) { Ok(value) => value, diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 20dd724..e11e603 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -849,6 +849,26 @@ fn runtime_snapshot_sync_runs_verified_backend_recovery() { scenario.finish(); } +#[test] +fn runtime_sync_wait_wakes_and_runs_verified_recovery() { + let actor = "actor-sync-wait-parametric"; + let mut scenario = Scenario::start("sync-wait", &[actor]); + let create = scenario.request(actor, "space.create", json!({})); + let space_id = scenario.returned_string(create, "space_id", "missing-wait-space"); + let sync = scenario.request( + actor, + "space.sync", + json!({"space_id": space_id, "wait_for_change_ms": 25}), + ); + + scenario.verify::(sync, |result| { + (result.space_id == space_id && result.synced) + .then_some(()) + .ok_or_else(|| "waited space.sync did not run verified recovery".to_owned()) + }); + scenario.finish(); +} + #[test] fn runtime_space_lifecycle_survives_restart_and_membership_changes() { let owner = "owner-lifecycle-parametric"; diff --git a/bridge/tests/stdio.rs b/bridge/tests/stdio.rs index 8ee03cc..2994429 100644 --- a/bridge/tests/stdio.rs +++ b/bridge/tests/stdio.rs @@ -372,7 +372,7 @@ fn protocol_unknown_operation_is_distinct_from_malformed_json() { } #[test] -fn protocol_cancellation_interrupts_sync_and_keeps_process_usable_is_red() { +fn protocol_cancellation_interrupts_sync_and_keeps_process_usable() { let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); bridge.send_request("hello-before-cancel", "hello", json!({})); assert_future_success(&bridge.receive(), "hello"); From 8580b1df1d726f778bf39141711bb3af31f4098b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:39:31 -0400 Subject: [PATCH 27/35] test(bridge): verify complete runtime surface --- bridge/tests/runtime.rs | 2 +- bridge/tests/stdio.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index e11e603..60168b8 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -776,7 +776,7 @@ fn runtime_hello_health_metadata_is_process_bound() { let expected_guest_id = encrypted_spaces_ffproof::EXTEND_FF_ID.to_vec(); for response in [&first_response, &second_response, &changed_response] { - assert_eq!(response["ok"], true, "hello trust metadata is still RED"); + assert_eq!(response["ok"], true, "hello trust metadata failed"); } let first: HelloResult = serde_json::from_value(first_response["result"].clone()).expect("first hello result"); diff --git a/bridge/tests/stdio.rs b/bridge/tests/stdio.rs index 2994429..eeec6ab 100644 --- a/bridge/tests/stdio.rs +++ b/bridge/tests/stdio.rs @@ -198,7 +198,7 @@ fn exact_size_request(size: usize) -> String { fn assert_future_success(response: &Value, operation: &str) { assert_eq!(response["version"], 1, "{operation} response version"); - assert_eq!(response["ok"], true, "{operation} is still RED: {response}"); + assert_eq!(response["ok"], true, "{operation} failed: {response}"); } #[test] @@ -269,7 +269,7 @@ fn protocol_accepts_a_frame_at_the_exact_limit() { bridge.send_raw(exact_size_request(MAX_FRAME_BYTES).as_bytes()); let response = bridge.receive(); assert_eq!(response["request_id"], "exact-max"); - assert_eq!(response["error"]["code"], "NOT_IMPLEMENTED"); + assert_eq!(response["error"]["code"], "INVALID_REQUEST"); } #[test] @@ -318,7 +318,7 @@ fn protocol_recovers_in_malformed_invalid_valid_order() { assert_eq!(invalid["request_id"], "invalid-request"); assert_eq!(invalid["error"]["code"], "INVALID_REQUEST"); assert_eq!(valid["request_id"], "valid-request"); - assert_eq!(valid["error"]["code"], "NOT_IMPLEMENTED"); + assert_eq!(valid["error"]["code"], "INVALID_REQUEST"); } #[test] @@ -428,7 +428,7 @@ fn protocol_validation_errors_redact_secret_material() { } #[test] -fn protocol_runtime_sdk_errors_are_deterministic_and_redacted_is_red() { +fn protocol_runtime_sdk_errors_are_deterministic_and_redacted() { let secrets = ["runtime-secret-alpha", "runtime-secret-beta"]; let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); From 230c1b8265d8e5121076c0f23bfec9a04cf93caf Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:49:33 -0400 Subject: [PATCH 28/35] ci(release): publish native encrypted spaces assets --- .github/workflows/release-bridge.yml | 314 ++++++++++++------- Cargo.lock | 42 +-- Cargo.toml | 2 +- NOTICE | 9 + PATCHES.md | 24 +- README.md | 36 +++ backend/server/src/app_config.rs | 2 +- bridge/src/main.rs | 6 + bridge/tests/runtime.rs | 453 ++++----------------------- rust-toolchain.toml | 4 +- 10 files changed, 347 insertions(+), 545 deletions(-) create mode 100644 NOTICE diff --git a/.github/workflows/release-bridge.yml b/.github/workflows/release-bridge.yml index 5a586f6..5841303 100644 --- a/.github/workflows/release-bridge.yml +++ b/.github/workflows/release-bridge.yml @@ -1,18 +1,19 @@ -name: Bridge Release Contract +name: Encrypted Spaces Release -# Manual validation only. This workflow does not publish release assets. on: workflow_dispatch: + push: + tags: + - 'v*' permissions: contents: read env: - RELEASE_READY: false + RELEASE_READY: true RELEASE_VERSION: 0.1.0 UPSTREAM_COMMIT: 4cda0ae87698135aa672990e6e68cf7873847426 RUST_VERSION: 1.94.1 - RISC0_SKIP_BUILD: 1 jobs: legal: @@ -24,11 +25,12 @@ jobs: with: fetch-depth: 0 - - name: Verify legal and source records + - name: Verify legal, version, and source records shell: bash run: | set -euo pipefail - test "$RELEASE_READY" = false + test "$RELEASE_READY" = true + test "$RELEASE_VERSION" = "${GITHUB_REF_NAME#v}" || test "$GITHUB_REF_TYPE" != tag git merge-base --is-ancestor "$UPSTREAM_COMMIT" "$GITHUB_SHA" test -s LICENSE test -s NOTICE @@ -36,16 +38,10 @@ jobs: grep -Fq "$UPSTREAM_COMMIT" PATCHES.md grep -Fq '800495f' PATCHES.md grep -Fq "Rust \`$RUST_VERSION\`" PATCHES.md + grep -Fq 'version = "0.1.0"' Cargo.toml grep -Fq 'kdl = { version = "=6.5.0"' Cargo.toml grep -Fq 'version = "6.5.0"' Cargo.lock - - - name: Verify pinned Rust toolchain - shell: bash - run: | - set -euo pipefail - rustup toolchain install "$RUST_VERSION" --profile minimal - rustup run "$RUST_VERSION" rustc --version | grep -Fq " $RUST_VERSION " - rustup run "$RUST_VERSION" rustdoc --version | grep -Fq " $RUST_VERSION " + grep -Fq 'channel = "1.94.1"' rust-toolchain.toml assets: name: ${{ matrix.archive }} @@ -106,138 +102,84 @@ jobs: - name: Checkout pinned source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - name: Build and test with the pinned toolchain + - name: Install pinned Rust and RISC Zero + shell: bash + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal + curl -L https://risczero.com/install | bash + export PATH="$HOME/.risc0/bin:$PATH" + echo "$HOME/.risc0/bin" >> "$GITHUB_PATH" + rzup install + + - name: Build real guest ELFs and native binary shell: bash env: PACKAGE: ${{ matrix.package }} BINARY: ${{ matrix.binary }} run: | set -euo pipefail - rustup toolchain install "$RUST_VERSION" --profile minimal + cargo risczero --version rustup run "$RUST_VERSION" cargo test --locked -p "$PACKAGE" --no-run rustup run "$RUST_VERSION" cargo build --locked --release -p "$PACKAGE" built="$GITHUB_WORKSPACE/target/release/$BINARY" test -x "$built" + methods="$(find target/release/build -path '*/out/methods.rs' -type f -print)" + test -n "$methods" + while IFS= read -r method; do + grep -Eq 'ELF:.*&\[[^]]' "$method" + if grep -Eq 'ID: \[u32; 8\] = \[0, 0, 0, 0, 0, 0, 0, 0\]' "$method"; then + echo "zero guest image ID in $method" >&2 + exit 1 + fi + done <<< "$methods" echo "BUILT_BINARY=$built" >> "$GITHUB_ENV" - - name: Verify release asset + - name: Package and inspect native asset shell: bash env: - DIST_CONTRACT: dist COMPONENT: ${{ matrix.component }} TARGET: ${{ matrix.target }} ARCHIVE: ${{ matrix.archive }} run: | set -euo pipefail - archive="$DIST_CONTRACT/$ARCHIVE" - checksum="$DIST_CONTRACT/checksums/$ARCHIVE.sha256" - provenance="$DIST_CONTRACT/provenance/$ARCHIVE.intoto.jsonl" - manifest="$DIST_CONTRACT/release-manifest.json" - test -s "$manifest" - test -s "$archive" - test -s "$checksum" - test -s "$provenance" - - extract="$(mktemp -d)" - trap 'rm -rf "$extract"' EXIT - tar -xzf "$archive" -C "$extract" - packaged="$(find "$extract" -type f -name "encrypted-spaces-$COMPONENT" -print -quit)" - test -n "$packaged" - test -x "$packaged" + root="$RUNNER_TEMP/package/encrypted-spaces-$COMPONENT-$TARGET" + mkdir -p "$root" "$RUNNER_TEMP/dist" + packaged="$root/encrypted-spaces-$COMPONENT" + cp "$BUILT_BINARY" "$packaged" + chmod +x "$packaged" + cp LICENSE NOTICE PATCHES.md "$root/" cmp "$BUILT_BINARY" "$packaged" description="$(file -b "$packaged")" case "$TARGET" in - linux-amd64) grep -Eq 'ELF 64-bit.*(x86-64|x86_64)' <<< "$description" ;; - linux-arm64) grep -Eq 'ELF 64-bit.*(ARM aarch64|aarch64)' <<< "$description" ;; - macos-amd64) grep -Eq 'Mach-O 64-bit.*x86_64' <<< "$description" ;; - macos-arm64) grep -Eq 'Mach-O 64-bit.*arm64' <<< "$description" ;; - *) echo "unsupported release target: $TARGET" >&2; exit 1 ;; + linux-amd64) grep -Eq 'ELF 64-bit.*(x86-64|x86_64)' <<< "$description" ;; + linux-arm64) grep -Eq 'ELF 64-bit.*(ARM aarch64|aarch64)' <<< "$description" ;; + macos-amd64) grep -Eq 'Mach-O 64-bit.*x86_64' <<< "$description" ;; + macos-arm64) grep -Eq 'Mach-O 64-bit.*arm64' <<< "$description" ;; + *) exit 1 ;; esac - python3 - "$packaged" "$RELEASE_VERSION" <<'PY' - import subprocess - import sys - - result = subprocess.run( - [sys.argv[1], "--version"], - check=True, - capture_output=True, - text=True, - timeout=5, - ) - output = result.stdout + result.stderr - if sys.argv[2] not in output: - raise SystemExit(f"version output does not contain {sys.argv[2]}: {output!r}") - PY - - jq -e \ - --arg component "$COMPONENT" \ - --arg target "$TARGET" \ - --arg archive "$ARCHIVE" \ - --arg upstream "$UPSTREAM_COMMIT" \ - --arg toolchain "$RUST_VERSION" ' - .upstream_commit == $upstream and - .rust_toolchain == $toolchain and - .version == env.RELEASE_VERSION and - (.artifacts | length == 8) and - ([.artifacts[].archive] | unique | length == 8) and - ([.artifacts[] | select( - .component == $component and - .target == $target and - .archive == $archive and - .binary == ("encrypted-spaces-" + $component) and - .version == env.RELEASE_VERSION and - (.sha256 | test("^[0-9a-f]{64}$")) - )] | length == 1) - ' "$manifest" - manifest_digest="$(jq -er \ - --arg component "$COMPONENT" \ - --arg target "$TARGET" \ - --arg archive "$ARCHIVE" ' - .artifacts[] | - select( - .component == $component and - .target == $target and - .archive == $archive - ) | - .sha256 - ' "$manifest")" + output="$("$packaged" --version)" + grep -Fq "$RELEASE_VERSION" <<< "$output" + tar -czf "$RUNNER_TEMP/dist/$ARCHIVE" -C "$RUNNER_TEMP/package" "$(basename "$root")" + listing="$(tar -tzf "$RUNNER_TEMP/dist/$ARCHIVE")" + for required in "encrypted-spaces-$COMPONENT" LICENSE NOTICE PATCHES.md; do + grep -Eq "/$required$" <<< "$listing" + done if [[ "$RUNNER_OS" == Linux ]]; then - actual_digest="$(sha256sum "$archive" | awk '{print $1}')" - (cd "$DIST_CONTRACT" && sha256sum -c "checksums/$ARCHIVE.sha256") - elif [[ "$RUNNER_OS" == macOS ]]; then - actual_digest="$(shasum -a 256 "$archive" | awk '{print $1}')" - (cd "$DIST_CONTRACT" && shasum -a 256 -c "checksums/$ARCHIVE.sha256") + sha256sum "$RUNNER_TEMP/dist/$ARCHIVE" else - echo "unsupported checksum platform: $RUNNER_OS" >&2 - exit 1 + shasum -a 256 "$RUNNER_TEMP/dist/$ARCHIVE" fi - test "$actual_digest" = "$manifest_digest" - - jq -se \ - --arg archive "$ARCHIVE" \ - --arg digest "$actual_digest" \ - --arg upstream "$UPSTREAM_COMMIT" \ - --arg toolchain "$RUST_VERSION" ' - length > 0 and any(.[]; - ._type == "https://in-toto.io/Statement/v1" and - .predicateType == "https://slsa.dev/provenance/v1" and - any(.subject[]?; - .name == $archive and .digest.sha256 == $digest - ) and - .predicate.buildDefinition.externalParameters.upstream_commit == $upstream and - .predicate.buildDefinition.externalParameters.rust_toolchain == $toolchain - ) - ' "$provenance" - listing="$(tar -tzf "$archive")" - for required in "encrypted-spaces-$COMPONENT" LICENSE NOTICE; do - awk -F/ -v required="$required" ' - $NF == required { found = 1 } - END { exit !found } - ' <<< "$listing" - done + - name: Upload native asset + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-${{ matrix.component }}-${{ matrix.target }} + path: ${{ runner.temp }}/dist/${{ matrix.archive }} + if-no-files-found: error + retention-days: 7 aggregate: name: Release contract result @@ -254,3 +196,141 @@ jobs: set -euo pipefail test "$LEGAL_RESULT" = success test "$ASSET_RESULT" = success + + - name: Download native assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: release-* + path: dist + merge-multiple: true + + - name: Generate checksums, manifest, and provenance + shell: bash + run: | + set -euo pipefail + mkdir -p dist/checksums dist/provenance + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + + dist = Path("dist") + version = os.environ["RELEASE_VERSION"] + upstream = os.environ["UPSTREAM_COMMIT"] + toolchain = os.environ["RUST_VERSION"] + artifacts = [] + for archive in sorted(dist.glob("*.tar.gz")): + parts = archive.name.removesuffix(".tar.gz").split("-") + component = parts[2] + target = "-".join(parts[3:]) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + (dist / "checksums" / f"{archive.name}.sha256").write_text( + f"{digest} {archive.name}\n" + ) + statement = { + "_type": "https://in-toto.io/Statement/v1", + "subject": [{"name": archive.name, "digest": {"sha256": digest}}], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": "https://github.com/GoCodeAlone/encrypted-spaces/actions", + "externalParameters": { + "upstream_commit": upstream, + "rust_toolchain": toolchain, + }, + "internalParameters": {"version": version}, + "resolvedDependencies": [], + }, + "runDetails": { + "builder": {"id": "https://github.com/GoCodeAlone/encrypted-spaces/actions"}, + "metadata": {}, + }, + }, + } + (dist / "provenance" / f"{archive.name}.intoto.jsonl").write_text( + json.dumps(statement, separators=(",", ":")) + "\n" + ) + artifacts.append({ + "component": component, + "target": target, + "archive": archive.name, + "binary": f"encrypted-spaces-{component}", + "version": version, + "sha256": digest, + }) + if len(artifacts) != 8: + raise SystemExit(f"expected 8 assets, found {len(artifacts)}") + manifest = { + "version": version, + "upstream_commit": upstream, + "rust_toolchain": toolchain, + "artifacts": artifacts, + } + (dist / "release-manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n" + ) + PY + + - name: Verify aggregate release contract + shell: bash + run: | + set -euo pipefail + for checksum in dist/checksums/*.sha256; do + (cd dist && sha256sum -c "checksums/$(basename "$checksum")") + done + test "$(find dist -maxdepth 1 -name '*.tar.gz' | wc -l | tr -d ' ')" = 8 + test "$(find dist/provenance -name '*.intoto.jsonl' | wc -l | tr -d ' ')" = 8 + jq -e ' + .version == env.RELEASE_VERSION and + .upstream_commit == env.UPSTREAM_COMMIT and + .rust_toolchain == env.RUST_VERSION and + (.artifacts | length == 8) and + ([.artifacts[].archive] | unique | length == 8) and + all(.artifacts[]; + (.sha256 | test("^[0-9a-f]{64}$")) and + (.binary == ("encrypted-spaces-" + .component)) + ) + ' dist/release-manifest.json + for archive in dist/*.tar.gz; do + listing="$(tar -tzf "$archive")" + grep -Eq '/LICENSE$' <<< "$listing" + grep -Eq '/NOTICE$' <<< "$listing" + done + + - name: Upload complete release + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-dist + path: dist + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish GitHub release + if: ${{ github.ref_type == 'tag' }} + needs: aggregate + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Download complete release + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: release-dist + path: dist + + - name: Publish immutable tag assets + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + gh release create "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --generate-notes \ + dist/*.tar.gz \ + dist/checksums/*.sha256 \ + dist/provenance/*.intoto.jsonl \ + dist/release-manifest.json diff --git a/Cargo.lock b/Cargo.lock index a8d3740..c512066 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2170,7 +2170,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-acl-types" -version = "0.0.1" +version = "0.1.0" dependencies = [ "pest", "pest_derive", @@ -2180,7 +2180,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-backend" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -2211,7 +2211,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-backend-server" -version = "0.0.1" +version = "0.1.0" dependencies = [ "base64 0.22.1", "clap 4.6.1", @@ -2245,7 +2245,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-bridge" -version = "0.0.1" +version = "0.1.0" dependencies = [ "base64 0.22.1", "encrypted-spaces-ffproof", @@ -2259,7 +2259,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-changelog-core" -version = "0.0.1" +version = "0.1.0" dependencies = [ "encrypted-spaces-acl-types", "encrypted-spaces-storage-encoding", @@ -2279,7 +2279,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-changelog-test-utils" -version = "0.0.1" +version = "0.1.0" dependencies = [ "base64 0.22.1", "encrypted-spaces-backend", @@ -2301,7 +2301,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-crypto" -version = "0.0.1" +version = "0.1.0" dependencies = [ "aes", "base64 0.22.1", @@ -2338,7 +2338,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-demo" -version = "0.0.1" +version = "0.1.0" dependencies = [ "anyhow", "chrono", @@ -2361,7 +2361,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-demo-test-harness" -version = "0.0.1" +version = "0.1.0" dependencies = [ "anyhow", "clap 4.6.1", @@ -2380,7 +2380,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-ff-test" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -2411,7 +2411,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-ffproof" -version = "0.0.1" +version = "0.1.0" dependencies = [ "base64 0.22.1", "bincode", @@ -2440,7 +2440,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-ffproof-methods" -version = "0.0.1" +version = "0.1.0" dependencies = [ "risc0-build", ] @@ -2454,7 +2454,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-key-manager" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "encrypted-spaces-changelog-core", @@ -2471,7 +2471,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-retention" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "encrypted-spaces-changelog-core", @@ -2490,7 +2490,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-sdk" -version = "0.0.1" +version = "0.1.0" dependencies = [ "aes", "async-trait", @@ -2533,7 +2533,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-sdk-codegen" -version = "0.0.1" +version = "0.1.0" dependencies = [ "encrypted-spaces-acl-types", "encrypted-spaces-backend", @@ -2545,7 +2545,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-sdk-fuzzer" -version = "0.0.1" +version = "0.1.0" dependencies = [ "encrypted-spaces-acl-types", "encrypted-spaces-backend", @@ -2557,7 +2557,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-storage-encoding" -version = "0.0.1" +version = "0.1.0" dependencies = [ "postcard", "serde", @@ -2567,7 +2567,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-zkp" -version = "0.0.1" +version = "0.1.0" dependencies = [ "bytesize", "criterion", @@ -2768,7 +2768,7 @@ dependencies = [ [[package]] name = "ffproof-tracer" -version = "0.0.1" +version = "0.1.0" dependencies = [ "clap 4.6.1", "ffproof-tracer-methods", @@ -2784,7 +2784,7 @@ dependencies = [ [[package]] name = "ffproof-tracer-methods" -version = "0.0.1" +version = "0.1.0" dependencies = [ "risc0-build", ] diff --git a/Cargo.toml b/Cargo.toml index c7bd274..b36f840 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.1" +version = "0.1.0" edition = "2021" license = "Apache-2.0" authors = [ diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..35d2d4a --- /dev/null +++ b/NOTICE @@ -0,0 +1,9 @@ +Encrypted Spaces +Copyright 2026 Encrypted Spaces Foundation and contributors +Copyright 2026 GoCodeAlone + +This product includes software developed by the Encrypted Spaces project +(https://encryptedspaces.org/) and modifications maintained by GoCodeAlone. + +Licensed under the Apache License, Version 2.0. See LICENSE for the complete +license terms. diff --git a/PATCHES.md b/PATCHES.md index 5fd63d2..605099e 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -8,16 +8,14 @@ This ledger records the intentional delta from upstream commit - `800495f` pins `kdl` to `=6.5.0` and repairs the matching lock entries for Rust `1.94.1`. - Adds `encrypted-spaces-bridge`, a typed versioned JSONL stdio boundary with - bounded frames and secret-redacted protocol errors. -- Adds boundary tests for the bridge lifecycle, data, membership, cancellation, - framing, process exit, and release contract surfaces. -- Adds a public-repository release contract workflow. It does not publish and - `RELEASE_READY=false` is intentional. - -## Pending Release Work - -- All SDK/backend runtime behavior. Every declared operation currently returns - `NOT_IMPLEMENTED`. -- Real Linux/macOS amd64/arm64 builds, checksums, and provenance attestations. -- The Apache `NOTICE` attribution file. The release workflow keeps this as a - failing legal-release gate until supplied. + bounded frames, secret-redacted errors, process-owned trust configuration, + cancellable waits, and SDK-backed space, table, list, text, file, and member + operations. +- Adds an owned schema-bytes SDK input for runtime-loaded KDL without leaking + configuration to obtain a static lifetime. +- Adds launched backend/bridge conformance tests covering restart restoration, + multi-process membership, verified synchronization, revocation, and every + bridge data primitive. +- Adds native Linux/macOS amd64/arm64 release automation with real RISC Zero + guest builds, checksums, in-toto provenance, Apache attribution, and tag-only + GitHub release publication. diff --git a/README.md b/README.md index 59d134f..7827d31 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,42 @@ synchronization with the backend behind the scenes. See [`sdk/README.md`](sdk/README.md) for an overview, code examples, and quickstart instructions. +## Runtime bridge + +The `encrypted-spaces-bridge` binary exposes the Rust SDK as bounded, +versioned JSONL RPC over standard input and output. It is intended for +non-Rust clients that need the prototype's actual encryption, verification, +storage, synchronization, and membership behavior without reimplementing +those algorithms. + +Each process owns one actor identity, one schema trust bundle, one backend +endpoint, and at most one active Space. Configure them before launch: + +```sh +export ENCRYPTED_SPACES_ACTOR_ID=local-client +export ENCRYPTED_SPACES_SCHEMA_PATH=/path/to/app-schema.kdl +export ENCRYPTED_SPACES_BACKEND_URL=ws://127.0.0.1:8080/ws +encrypted-spaces-bridge +``` + +Requests cannot override the actor, schema, data commitment, or fast-forward +guest image ID. `hello` reports those process-derived trust values. The +bridge supports Space create/join/snapshot/restore/sync, table insert/select, +scoped list and collaborative text operations, encrypted file put/get, +member invite/join/remove, cancellation, close, and shutdown. Request and +response payloads use protocol version `1`; frames larger than 64 KiB are +rejected. + +Invites and snapshots contain private client custody material and must be +stored as secrets. The backend remains untrusted and stores ciphertext and +proof material, but loss of a client snapshot can prevent that client from +recovering its Space state. This bridge does not add authentication to the +prototype server. + +Release archives contain native backend and bridge binaries for Linux and +macOS on amd64 and arm64, together with checksums, provenance, and Apache +attribution. Both binaries report their release with `--version`. + ## Prerequisites The Rust toolchain is pinned by [`rust-toolchain.toml`](rust-toolchain.toml) diff --git a/backend/server/src/app_config.rs b/backend/server/src/app_config.rs index 0ccc9c0..9bef9eb 100644 --- a/backend/server/src/app_config.rs +++ b/backend/server/src/app_config.rs @@ -8,7 +8,7 @@ use std::net::IpAddr; // --------------------------------------------------------------------------- #[derive(Parser)] -#[command(name = "encrypted-spaces-server")] +#[command(name = "encrypted-spaces-server", version)] #[command(about = "Encrypted Spaces backend server")] pub struct CliArgs { /// Path to a schema/seed file applied to every new space. diff --git a/bridge/src/main.rs b/bridge/src/main.rs index 7adf7c0..1c52a81 100644 --- a/bridge/src/main.rs +++ b/bridge/src/main.rs @@ -3,5 +3,11 @@ mod runtime; mod schema; fn main() -> std::io::Result<()> { + let mut args = std::env::args_os(); + let _program = args.next(); + if args.next().is_some_and(|arg| arg == "--version") && args.next().is_none() { + println!("encrypted-spaces-bridge {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } protocol::run(std::io::stdin().lock(), std::io::stdout()) } diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 60168b8..961de54 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -1385,104 +1385,15 @@ fn runtime_member_invite_join_remove_enforces_revocation() { scenario.finish(); } -#[derive(Deserialize)] -struct ReleaseManifest { - version: String, - upstream_commit: String, - rust_toolchain: String, - artifacts: Vec, -} - -#[derive(Deserialize)] -struct ReleaseArtifact { - component: String, - target: String, - archive: String, - binary: String, - version: String, - sha256: String, -} - -#[derive(Deserialize)] -struct ProvenanceStatement { - #[serde(rename = "_type")] - statement_type: String, - #[serde(rename = "predicateType")] - predicate_type: String, - subject: Vec, - predicate: ProvenancePredicate, -} - -#[derive(Deserialize)] -struct ProvenanceSubject { - name: String, - digest: ProvenanceDigest, -} - -#[derive(Deserialize)] -struct ProvenanceDigest { - sha256: String, -} - -#[derive(Deserialize)] -struct ProvenancePredicate { - #[serde(rename = "buildDefinition")] - build_definition: ProvenanceBuildDefinition, -} - -#[derive(Deserialize)] -struct ProvenanceBuildDefinition { - #[serde(rename = "externalParameters")] - external_parameters: ProvenanceParameters, -} - -#[derive(Deserialize)] -struct ProvenanceParameters { - upstream_commit: String, - rust_toolchain: String, -} - -const RELEASE_ARCHIVES: [(&str, &str, &str); 8] = [ - ( - "backend", - "linux-amd64", - "encrypted-spaces-backend-linux-amd64.tar.gz", - ), - ( - "backend", - "linux-arm64", - "encrypted-spaces-backend-linux-arm64.tar.gz", - ), - ( - "backend", - "macos-amd64", - "encrypted-spaces-backend-macos-amd64.tar.gz", - ), - ( - "backend", - "macos-arm64", - "encrypted-spaces-backend-macos-arm64.tar.gz", - ), - ( - "bridge", - "linux-amd64", - "encrypted-spaces-bridge-linux-amd64.tar.gz", - ), - ( - "bridge", - "linux-arm64", - "encrypted-spaces-bridge-linux-arm64.tar.gz", - ), - ( - "bridge", - "macos-amd64", - "encrypted-spaces-bridge-macos-amd64.tar.gz", - ), - ( - "bridge", - "macos-arm64", - "encrypted-spaces-bridge-macos-arm64.tar.gz", - ), +const RELEASE_ARCHIVES: [&str; 8] = [ + "encrypted-spaces-backend-linux-amd64.tar.gz", + "encrypted-spaces-backend-linux-arm64.tar.gz", + "encrypted-spaces-backend-macos-amd64.tar.gz", + "encrypted-spaces-backend-macos-arm64.tar.gz", + "encrypted-spaces-bridge-linux-amd64.tar.gz", + "encrypted-spaces-bridge-linux-arm64.tar.gz", + "encrypted-spaces-bridge-macos-amd64.tar.gz", + "encrypted-spaces-bridge-macos-arm64.tar.gz", ]; fn nonempty(path: &Path) -> bool { @@ -1508,324 +1419,88 @@ fn assert_pinned_actions(workflow: &str) { } } -fn verify_checksum(dist: &Path, archive: &str, expected_digest: &str) { - let checksum = dist.join("checksums").join(format!("{archive}.sha256")); - let contents = fs::read_to_string(&checksum).expect("checksum file"); - let mut fields = contents.split_whitespace(); - assert_eq!( - fields.next(), - Some(expected_digest), - "{archive} checksum digest" - ); - assert_eq!( - fields.next().map(|name| name.trim_start_matches('*')), - Some(archive), - "{archive} checksum filename" - ); - assert!( - fields.next().is_none(), - "{archive} checksum has extra fields" - ); - - let status = if cfg!(target_os = "linux") { - Command::new("sha256sum") - .args(["-c", &format!("checksums/{archive}.sha256")]) - .current_dir(dist) - .status() - } else if cfg!(target_os = "macos") { - Command::new("shasum") - .args(["-a", "256", "-c", &format!("checksums/{archive}.sha256")]) - .current_dir(dist) - .status() - } else { - panic!("release checksum verification requires Linux or macOS"); - } - .expect("execute checksum verifier"); - assert!( - status.success(), - "checksum verification failed for {archive}" - ); -} - -fn find_file(root: &Path, name: &str) -> Option { - for entry in fs::read_dir(root).ok()? { - let path = entry.ok()?.path(); - if path.is_dir() { - if let Some(found) = find_file(&path, name) { - return Some(found); - } - } else if path.file_name().and_then(|value| value.to_str()) == Some(name) { - return Some(path); - } - } - None -} - -fn native_target() -> Option<&'static str> { - match (std::env::consts::OS, std::env::consts::ARCH) { - ("linux", "x86_64") => Some("linux-amd64"), - ("linux", "aarch64") => Some("linux-arm64"), - ("macos", "x86_64") => Some("macos-amd64"), - ("macos", "aarch64") => Some("macos-arm64"), - _ => None, - } -} - -fn assert_binary_format(binary: &Path, target: &str) { - let output = Command::new("file") - .args(["-b", binary.to_str().expect("binary path is UTF-8")]) - .output() - .expect("inspect release binary format"); - assert!( - output.status.success(), - "file failed for {}", - binary.display() - ); - let description = String::from_utf8(output.stdout).expect("file output is UTF-8"); - let valid = match target { - "linux-amd64" => { - description.contains("ELF 64-bit") - && (description.contains("x86-64") || description.contains("x86_64")) - } - "linux-arm64" => { - description.contains("ELF 64-bit") - && (description.contains("ARM aarch64") || description.contains("aarch64")) - } - "macos-amd64" => description.contains("Mach-O 64-bit") && description.contains("x86_64"), - "macos-arm64" => description.contains("Mach-O 64-bit") && description.contains("arm64"), - _ => false, - }; - assert!(valid, "{target} has wrong binary format: {description}"); -} - -fn assert_native_version(binary: &Path, version: &str) { - let script = r#" -import subprocess -import sys -result = subprocess.run([sys.argv[1], "--version"], check=True, capture_output=True, text=True, timeout=5) -output = result.stdout + result.stderr -if sys.argv[2] not in output: - raise SystemExit(f"version output does not contain {sys.argv[2]}: {output!r}") -"#; - let status = Command::new("python3") - .args([ - "-c", - script, - binary.to_str().expect("binary path is UTF-8"), - version, - ]) - .status() - .expect("run release binary version check"); - assert!( - status.success(), - "{} version check failed", - binary.display() - ); -} - -fn inspect_archive(dist: &Path, component: &str, target: &str, archive: &str, version: &str) { - let extraction = tempfile::tempdir().expect("create archive extraction directory"); - let output = Command::new("tar") - .args([ - "-xzf", - archive, - "-C", - extraction - .path() - .to_str() - .expect("extraction path is UTF-8"), - ]) - .current_dir(dist) - .output() - .expect("extract release archive"); - assert!( - output.status.success(), - "cannot extract {archive}: {}", - String::from_utf8_lossy(&output.stderr) - ); - let binary = format!("encrypted-spaces-{component}"); - for required in [binary.as_str(), "LICENSE", "NOTICE"] { - assert!( - find_file(extraction.path(), required).is_some(), - "{archive} omits {required}" - ); - } - let binary = find_file(extraction.path(), &binary).expect("release binary"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_ne!( - fs::metadata(&binary) - .expect("release binary metadata") - .permissions() - .mode() - & 0o111, - 0, - "{archive} binary is not executable" - ); - } - assert_binary_format(&binary, target); - if native_target() == Some(target) { - assert_native_version(&binary, version); - } -} - -fn verify_provenance(dist: &Path, archive: &str, expected_digest: &str) { - let path = dist - .join("provenance") - .join(format!("{archive}.intoto.jsonl")); - let contents = fs::read_to_string(path).expect("provenance JSONL"); - let statements: Vec = serde_json::Deserializer::from_str(&contents) - .into_iter() - .collect::>() - .expect("parse provenance JSONL"); - assert!(!statements.is_empty(), "{archive} provenance is empty"); - assert!( - statements.iter().any(|statement| { - statement.statement_type == "https://in-toto.io/Statement/v1" - && statement.predicate_type == "https://slsa.dev/provenance/v1" - && statement.subject.iter().any(|subject| { - subject.name == archive && subject.digest.sha256 == expected_digest - }) - && statement - .predicate - .build_definition - .external_parameters - .upstream_commit - == UPSTREAM_COMMIT - && statement - .predicate - .build_definition - .external_parameters - .rust_toolchain - == RUST_TOOLCHAIN - }), - "{archive} provenance does not bind its digest, upstream commit, and toolchain" - ); -} - #[test] -fn release_contract_is_red_until_dist_and_notice_exist() { +fn release_contract_builds_and_publishes_native_assets() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); - let dist = root.join("dist"); let workflow = fs::read_to_string(root.join(".github/workflows/release-bridge.yml")) - .expect("release contract workflow"); + .expect("release workflow"); let patches = fs::read_to_string(root.join("PATCHES.md")).expect("PATCHES ledger"); let cargo = fs::read_to_string(root.join("Cargo.toml")).expect("workspace manifest"); let lock = fs::read_to_string(root.join("Cargo.lock")).expect("workspace lockfile"); + let toolchain = + fs::read_to_string(root.join("rust-toolchain.toml")).expect("pinned Rust toolchain"); + let bridge_main = fs::read_to_string(root.join("bridge/src/main.rs")).expect("bridge main"); + let backend_config = + fs::read_to_string(root.join("backend/server/src/app_config.rs")).expect("backend config"); assert!(workflow.contains("workflow_dispatch:")); - assert!( - !workflow.contains("\n push:"), - "release workflow must be manual-only" - ); - assert!( - !workflow.contains("\n release:"), - "release workflow must not publish" - ); + assert!(workflow.contains("tags:")); + assert!(workflow.contains("- 'v*'")); + assert!(workflow.contains(" publish:")); + assert!(workflow.contains("github.ref_type == 'tag'")); + assert!(!workflow.contains("RISC0_SKIP_BUILD")); assert_pinned_actions(&workflow); - let legal = workflow.find(" legal:").expect("independent legal job"); - let assets = workflow.find(" assets:").expect("independent asset job"); + + let legal = workflow.find(" legal:").expect("legal job"); + let assets = workflow.find(" assets:").expect("asset matrix"); let aggregate = workflow.find(" aggregate:").expect("aggregate job"); + let publish = workflow.find(" publish:").expect("publish job"); assert!( - legal < assets && assets < aggregate, + legal < assets && assets < aggregate && aggregate < publish, "release jobs have unexpected layout" ); - assert!( - !workflow[assets..aggregate].contains("needs:"), - "asset checks must not depend on the legal gate" - ); + for marker in [ - "needs: [legal, assets]", - "if: ${{ always() }}", - "sha256sum -c", - "shasum -a 256 -c", - "jq -e", - "tar -tzf", - "rustup run \"$RUST_VERSION\" cargo test --locked", - "rustup run \"$RUST_VERSION\" cargo build --locked --release", - "test -x \"$built\"", - "cmp \"$BUILT_BINARY\" \"$packaged\"", + "RELEASE_READY: true", + "RELEASE_VERSION: 0.1.0", + "UPSTREAM_COMMIT: 4cda0ae87698135aa672990e6e68cf7873847426", + "RUST_VERSION: 1.94.1", + "curl -L https://risczero.com/install | bash", + "rzup install", + "cargo risczero --version", + "cargo test --locked -p", + "cargo build --locked --release -p", + "test -x", + "cmp", "file -b", - "subprocess.run(", "--version", - "RELEASE_VERSION: 0.1.0", - "RISC0_SKIP_BUILD: 1", + "tar -czf", + "sha256sum", + "shasum -a 256", + "release-manifest.json", + "https://in-toto.io/Statement/v1", + "https://slsa.dev/provenance/v1", + "actions/upload-artifact@", + "actions/download-artifact@", + "gh release create", + "needs: [legal, assets]", + "if: ${{ always() }}", ] { assert!(workflow.contains(marker), "release workflow omits {marker}"); } - for (_, _, archive) in RELEASE_ARCHIVES { + for archive in RELEASE_ARCHIVES { assert!( workflow.contains(archive), "release workflow omits {archive}" ); } - assert!(cargo.contains("kdl = { version = \"=6.5.0\"")); - assert!(lock.contains("name = \"kdl\"\nversion = \"6.5.0\"")); - assert!(patches.contains(UPSTREAM_COMMIT)); - assert!(patches.contains("800495f")); - assert!(patches.contains(&format!("Rust `{RUST_TOOLCHAIN}`"))); - - let mut missing = Vec::new(); - let notice = root.join("NOTICE"); - if !nonempty(¬ice) { - missing.push("NOTICE".to_owned()); - } - let manifest_path = dist.join("release-manifest.json"); - if !nonempty(&manifest_path) { - missing.push("dist/release-manifest.json".to_owned()); - } - for (_, _, archive) in RELEASE_ARCHIVES { - for path in [ - dist.join(archive), - dist.join("checksums").join(format!("{archive}.sha256")), - dist.join("provenance") - .join(format!("{archive}.intoto.jsonl")), - ] { - if !nonempty(&path) { - missing.push( - path.strip_prefix(&root) - .unwrap_or(&path) - .display() - .to_string(), - ); - } - } - } - assert!( - missing.is_empty(), - "release contract RED: missing legal/release artifacts: {}", - missing.join(", ") - ); assert!( nonempty(&root.join("LICENSE")), "LICENSE is absent or empty" ); - let manifest: ReleaseManifest = - serde_json::from_slice(&fs::read(&manifest_path).expect("read release manifest")) - .expect("parse release manifest"); - assert_eq!(manifest.version, "0.1.0"); - assert_eq!(manifest.upstream_commit, UPSTREAM_COMMIT); - assert_eq!(manifest.rust_toolchain, RUST_TOOLCHAIN); - assert_eq!(manifest.artifacts.len(), RELEASE_ARCHIVES.len()); - for (component, target, archive) in RELEASE_ARCHIVES { - let artifact = manifest - .artifacts - .iter() - .find(|artifact| artifact.archive == archive) - .unwrap_or_else(|| panic!("release manifest omits {archive}")); - assert_eq!(artifact.component, component, "{archive} component"); - assert_eq!(artifact.target, target, "{archive} target"); - assert_eq!( - artifact.binary, - format!("encrypted-spaces-{component}"), - "{archive} binary" - ); - assert_eq!(artifact.version, manifest.version, "{archive} version"); - assert!(valid_digest(&artifact.sha256), "{archive} invalid sha256"); - verify_checksum(&dist, archive, &artifact.sha256); - verify_provenance(&dist, archive, &artifact.sha256); - inspect_archive(&dist, component, target, archive, &manifest.version); - } + assert!(nonempty(&root.join("NOTICE")), "NOTICE is absent or empty"); + assert!(cargo.contains("version = \"0.1.0\"")); + assert!(cargo.contains("kdl = { version = \"=6.5.0\"")); + assert!(lock.contains("name = \"kdl\"\nversion = \"6.5.0\"")); + assert!(toolchain.contains("channel = \"1.94.1\"")); + assert!(toolchain.contains("\"rustfmt\"")); + assert!(toolchain.contains("\"clippy\"")); + assert!(bridge_main.contains("--version")); + assert!(backend_config.contains("version")); + assert!(patches.contains(UPSTREAM_COMMIT)); + assert!(patches.contains("800495f")); + assert!(patches.contains(&format!("Rust `{RUST_TOOLCHAIN}`"))); + assert!(!patches.contains("Pending Release Work")); + assert!(!patches.contains("NOT_IMPLEMENTED")); } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index d0d069e..6f11d98 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,6 +1,4 @@ [toolchain] -channel = "nightly-2026-02-01" -# note: last (tested) working version for multithreading in wasm + plonky3 was: -# channel = "nightly-2025-05-05" +channel = "1.94.1" components = ["clippy", "rust-src", "rustfmt"] targets = ["wasm32-unknown-unknown"] From 872a38cc0fd6590d4b3232fec0a35e8581332c47 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 04:52:17 -0400 Subject: [PATCH 29/35] ci: open weekly upstream compatibility PRs --- .github/workflows/upstream-sync.yml | 80 +++++++++++++++++++++++++++++ bridge/tests/runtime.rs | 22 ++++++++ 2 files changed, 102 insertions(+) create mode 100644 .github/workflows/upstream-sync.yml diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 0000000..19bf6f9 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,80 @@ +name: Weekly Upstream Sync + +on: + schedule: + - cron: '17 9 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: upstream-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-24.04 + steps: + - name: Checkout fork main + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: main + fetch-depth: 0 + + - name: Fetch upstream + shell: bash + run: | + set -euo pipefail + git remote add upstream https://github.com/encrypted-spaces/prototype.git + git fetch upstream main + + - name: Open or reuse compatibility PR + id: pr + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if git merge-base --is-ancestor upstream/main HEAD; then + echo "Fork already contains upstream main" + echo "url=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + upstream_sha="$(git rev-parse upstream/main)" + branch="automation/upstream-sync-${upstream_sha:0:12}" + existing="$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --state open \ + --head "$branch" \ + --json url \ + --jq '.[0].url // empty')" + if [[ -n "$existing" ]]; then + echo "url=$existing" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git push origin "upstream/main:refs/heads/$branch" + url="$(gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$branch" \ + --title "chore: sync encrypted-spaces upstream" \ + --body "Automated weekly compatibility update to upstream commit \`$upstream_sha\`. Normal pull-request checks validate the fork delta and runtime bridge.")" + echo "url=$url" >> "$GITHUB_OUTPUT" + + - name: Enable squash automerge when compatible + if: steps.pr.outputs.url != '' + env: + GH_TOKEN: ${{ github.token }} + PR_URL: ${{ steps.pr.outputs.url }} + shell: bash + run: | + set -euo pipefail + if gh pr merge "$PR_URL" --auto --squash --delete-branch; then + echo "Automerge enabled for $PR_URL" + else + echo "$PR_URL remains open for conflicts, failing checks, or review" + fi diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 961de54..3efef84 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -1504,3 +1504,25 @@ fn release_contract_builds_and_publishes_native_assets() { assert!(!patches.contains("Pending Release Work")); assert!(!patches.contains("NOT_IMPLEMENTED")); } + +#[test] +fn upstream_sync_opens_ci_gated_automerge_prs() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let workflow = fs::read_to_string(root.join(".github/workflows/upstream-sync.yml")) + .expect("upstream sync workflow"); + + assert!(workflow.contains("schedule:")); + assert!(workflow.contains("cron:")); + assert!(workflow.contains("workflow_dispatch:")); + assert!(workflow.contains("contents: write")); + assert!(workflow.contains("pull-requests: write")); + assert!(workflow.contains("https://github.com/encrypted-spaces/prototype.git")); + assert!(workflow.contains("git fetch upstream main")); + assert!(workflow.contains("upstream/main")); + assert!(workflow.contains("gh pr create")); + assert!(workflow.contains("gh pr merge")); + assert!(workflow.contains("--auto")); + assert!(workflow.contains("--squash")); + assert!(workflow.contains("remains open")); + assert_pinned_actions(&workflow); +} From 95a8df29ca22c2b629f3ab5c8f1e401501ed92d4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 11 Jul 2026 05:27:11 -0400 Subject: [PATCH 30/35] fix(bridge): sync revocation before access Prevent removed members from reading stale cached rows or receiving ambiguous write errors before their removal is fast-forwarded. Allow runtime tests to launch the exact release binaries and trust ID. --- bridge/src/runtime.rs | 135 +++++++++++++++++++++++++++++-- bridge/tests/runtime.rs | 20 ++++- ffproof/methods/guest/Cargo.lock | 10 +-- 3 files changed, 153 insertions(+), 12 deletions(-) diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index 744af44..ed8a5bb 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -523,7 +523,10 @@ impl Runtime { return invalid_state(request_id); } let table = space.table::(&payload.table); - match self.executor.block_on(table.insert(&payload.row).execute()) { + match self.executor.block_on(async { + space.sync().await?; + table.insert(&payload.row).execute().await + }) { Ok(row_id) => Response::success(request_id, TableInsertResult { row_id }), Err(error) => sdk_operation_error(request_id, &error), } @@ -557,10 +560,10 @@ impl Runtime { return invalid_request(request_id); } let table = space.table::(&payload.table); - match self - .executor - .block_on(table.select().where_eq(&column, value).all()) - { + match self.executor.block_on(async { + space.sync().await?; + table.select().where_eq(&column, value).all().await + }) { Ok(rows) => Response::success(request_id, TableSelectResult { rows }), Err(error) => sdk_operation_error(request_id, &error), } @@ -1080,3 +1083,125 @@ fn required_env(name: &str) -> io::Result { .filter(|value| !value.is_empty()) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{name} is required"))) } + +#[cfg(test)] +mod tests { + use super::*; + use encrypted_spaces_sdk::{ColumnType, SchemaBuilder}; + use serde_json::json; + + fn revoked_member_runtime() -> (Runtime, String, i64) { + let executor = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let (member, space_id, row_id, data_commitment_bytes) = executor.block_on(async { + let schema = SchemaBuilder::new("records") + .column("id", ColumnType::Integer) + .plaintext_primary_key() + .column("label", ColumnType::Text) + .expect("label column") + .build() + .expect("records schema"); + let transport = LocalTransport::new(std::slice::from_ref(&schema), None, Some(1024)) + .await + .expect("local transport"); + let data_commitment = transport.get_root_hash().await.expect("initial root"); + let schema_bundle = || { + ApplicationSchema::WithDataCommitment( + vec![schema.clone()], + data_commitment, + encrypted_spaces_ffproof::EXTEND_FF_ID, + ) + }; + let owner = Space::create(transport.clone(), schema_bundle()) + .await + .expect("owner space"); + let invite = owner.invite_user().await.expect("member invite"); + let member = Space::join(transport.clone(), invite, schema_bundle()) + .await + .expect("member join"); + let member_id = member.uid().expect("member ID") as i64; + + owner.sync().await.expect("owner join sync"); + let row_id = owner + .table::("records") + .insert(&json!({"label": "cached-before-revocation"})) + .execute() + .await + .expect("owner insert"); + member.sync().await.expect("member row sync"); + let cached = member + .table::("records") + .select() + .where_eq("id", row_id) + .all() + .await + .expect("warm member cache"); + assert_eq!(cached.len(), 1, "member cache fixture"); + + owner.remove_user(member_id).await.expect("remove member"); + (member, owner.id().to_string(), row_id, data_commitment) + }); + let data_commitment = data_commitment_bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let runtime = Runtime { + executor, + process: ProcessConfig { + actor_id: "revoked-member".to_owned(), + schema_sha256: "test-schema".to_owned(), + schema: Vec::new(), + data_commitment_bytes, + data_commitment, + ff_guest_image_id: encrypted_spaces_ffproof::EXTEND_FF_ID, + backend_url: "local-test".to_owned(), + }, + space: Some(member), + shutdown_requested: false, + }; + + (runtime, space_id, row_id) + } + + #[test] + fn table_select_syncs_revocation_before_cache_access() { + let (mut runtime, space_id, row_id) = revoked_member_runtime(); + + let response = runtime.table_select( + "revoked-select".to_owned(), + json!({ + "space_id": space_id, + "table": "records", + "where": {"id": row_id}, + }), + ); + + assert!(!response.ok, "revoked member read stale cached row"); + assert_eq!( + response.error.as_ref().map(|error| error.code), + Some("ACCESS_DENIED") + ); + } + + #[test] + fn table_insert_syncs_revocation_before_write() { + let (mut runtime, space_id, _) = revoked_member_runtime(); + + let response = runtime.table_insert( + "revoked-insert".to_owned(), + json!({ + "space_id": space_id, + "table": "records", + "row": {"label": "write-after-revocation"}, + }), + ); + + assert!(!response.ok, "revoked member wrote after removal"); + assert_eq!( + response.error.as_ref().map(|error| error.code), + Some("ACCESS_DENIED") + ); + } +} diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 3efef84..cee43ec 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -103,6 +103,9 @@ fn backend_binary() -> &'static Path { static BINARY: OnceLock = OnceLock::new(); BINARY .get_or_init(|| { + if let Some(path) = std::env::var_os("ENCRYPTED_SPACES_BACKEND_TEST_BINARY") { + return PathBuf::from(path); + } let root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("workspace root"); @@ -176,7 +179,10 @@ impl BridgeProcess { actor )); fs::write(&schema_path, schema).expect("write bridge schema fixture"); - let mut command = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")); + let bridge_binary = std::env::var_os("ENCRYPTED_SPACES_BRIDGE_TEST_BINARY") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_encrypted-spaces-bridge"))); + let mut command = Command::new(bridge_binary); command .env("ENCRYPTED_SPACES_ACTOR_ID", actor) .env("ENCRYPTED_SPACES_SCHEMA_PATH", &schema_path) @@ -699,6 +705,16 @@ fn sdk_data_commitment(schema: &str, suffix: &str) -> String { .collect() } +fn expected_ff_guest_id() -> Vec { + match std::env::var("ENCRYPTED_SPACES_EXPECTED_FF_GUEST_ID") { + Ok(value) => value + .split(',') + .map(|word| word.parse::().expect("guest image ID word")) + .collect(), + Err(_) => encrypted_spaces_ffproof::EXTEND_FF_ID.to_vec(), + } +} + fn is_opaque_ref(value: &Value) -> bool { match value { Value::Null => false, @@ -773,7 +789,7 @@ fn runtime_hello_health_metadata_is_process_bound() { let expected_commitment = sdk_data_commitment(SCHEMA_KDL, "stable"); let changed_commitment = sdk_data_commitment(&modified_schema, "changed"); - let expected_guest_id = encrypted_spaces_ffproof::EXTEND_FF_ID.to_vec(); + let expected_guest_id = expected_ff_guest_id(); for response in [&first_response, &second_response, &changed_response] { assert_eq!(response["ok"], true, "hello trust metadata failed"); diff --git a/ffproof/methods/guest/Cargo.lock b/ffproof/methods/guest/Cargo.lock index 13cda8d..ffd98cc 100644 --- a/ffproof/methods/guest/Cargo.lock +++ b/ffproof/methods/guest/Cargo.lock @@ -915,7 +915,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-acl-types" -version = "0.0.1" +version = "0.1.0" dependencies = [ "pest", "pest_derive", @@ -925,7 +925,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-changelog-core" -version = "0.0.1" +version = "0.1.0" dependencies = [ "encrypted-spaces-acl-types", "encrypted-spaces-storage-encoding", @@ -954,7 +954,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-storage-encoding" -version = "0.0.1" +version = "0.1.0" dependencies = [ "postcard", "serde", @@ -1006,7 +1006,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "ffproof-tracer" -version = "0.0.1" +version = "0.1.0" dependencies = [ "clap", "ffproof-tracer-methods", @@ -1021,7 +1021,7 @@ dependencies = [ [[package]] name = "ffproof-tracer-methods" -version = "0.0.1" +version = "0.1.0" dependencies = [ "risc0-build", ] From 438d8a8b22c92f421b2f80ddd92c5303ce6953c8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 13 Jul 2026 04:47:58 -0400 Subject: [PATCH 31/35] feat(bridge): ship encrypted spaces runtime Bound native transport deadlines, preserve uncertain commit outcomes, and require trusted default-branch attestation before publishing release artifacts. --- .github/workflows/release-bridge.yml | 172 ++++-- .github/workflows/release-publish.yml | 175 ++++++ .github/workflows/upstream-compat.yml | 79 +++ .github/workflows/upstream-sync.yml | 114 +++- PATCHES.md | 17 +- README.md | 61 +- backend/src/error.rs | 5 + backend/src/schema.rs | 4 +- bridge/Cargo.toml | 4 + bridge/src/main.rs | 2 +- bridge/src/protocol.rs | 581 +++++++++++++++---- bridge/src/runtime.rs | 458 +++++++++++++-- bridge/tests/runtime.rs | 805 ++++++++++++++++++++++++-- bridge/tests/stdio.rs | 77 ++- ffproof/Cargo.toml | 2 +- sdk/src/broadcast.rs | 11 +- sdk/src/changelog.rs | 68 ++- sdk/src/lib.rs | 89 +++ sdk/src/state.rs | 47 +- sdk/src/websocket_transport.rs | 394 ++++++++++--- 20 files changed, 2745 insertions(+), 420 deletions(-) create mode 100644 .github/workflows/release-publish.yml create mode 100644 .github/workflows/upstream-compat.yml diff --git a/.github/workflows/release-bridge.yml b/.github/workflows/release-bridge.yml index 5841303..ebeb2d9 100644 --- a/.github/workflows/release-bridge.yml +++ b/.github/workflows/release-bridge.yml @@ -1,8 +1,9 @@ name: Encrypted Spaces Release on: - workflow_dispatch: push: + branches: + - main tags: - 'v*' @@ -14,6 +15,9 @@ env: RELEASE_VERSION: 0.1.0 UPSTREAM_COMMIT: 4cda0ae87698135aa672990e6e68cf7873847426 RUST_VERSION: 1.94.1 + RZUP_VERSION: 0.5.1 + RISC0_VERSION: 3.0.5 + RISC0_RUST_VERSION: 1.94.1 jobs: legal: @@ -42,9 +46,11 @@ jobs: grep -Fq 'kdl = { version = "=6.5.0"' Cargo.toml grep -Fq 'version = "6.5.0"' Cargo.lock grep -Fq 'channel = "1.94.1"' rust-toolchain.toml + grep -Fq 'real-proofs = ["encrypted-spaces-ffproof/real-proofs"]' bridge/Cargo.toml assets: name: ${{ matrix.archive }} + needs: legal strategy: fail-fast: false matrix: @@ -107,10 +113,10 @@ jobs: run: | set -euo pipefail rustup toolchain install "$RUST_VERSION" --profile minimal - curl -L https://risczero.com/install | bash - export PATH="$HOME/.risc0/bin:$PATH" - echo "$HOME/.risc0/bin" >> "$GITHUB_PATH" - rzup install + cargo install rzup --version "$RZUP_VERSION" --locked + rzup install cargo-risczero "$RISC0_VERSION" + rzup install r0vm "$RISC0_VERSION" + rzup install rust "$RISC0_RUST_VERSION" - name: Build real guest ELFs and native binary shell: bash @@ -120,8 +126,8 @@ jobs: run: | set -euo pipefail cargo risczero --version - rustup run "$RUST_VERSION" cargo test --locked -p "$PACKAGE" --no-run - rustup run "$RUST_VERSION" cargo build --locked --release -p "$PACKAGE" + rustup run "$RUST_VERSION" cargo test --locked -p "$PACKAGE" --features real-proofs --no-run + rustup run "$RUST_VERSION" cargo build --locked --release -p "$PACKAGE" --features real-proofs built="$GITHUB_WORKSPACE/target/release/$BINARY" test -x "$built" methods="$(find target/release/build -path '*/out/methods.rs' -type f -print)" @@ -135,6 +141,19 @@ jobs: done <<< "$methods" echo "BUILT_BINARY=$built" >> "$GITHUB_ENV" + - name: Launch release backend and bridge API suite + if: matrix.component == 'bridge' + shell: bash + env: + ENCRYPTED_SPACES_BRIDGE_TEST_BINARY: ${{ github.workspace }}/target/release/encrypted-spaces-bridge + ENCRYPTED_SPACES_BACKEND_TEST_BINARY: ${{ github.workspace }}/target/release/encrypted-spaces-backend-server + RISC0_DEV_MODE: 1 + run: | + set -euo pipefail + rustup run "$RUST_VERSION" cargo build --locked --release \ + -p encrypted-spaces-backend-server --features real-proofs + rustup run "$RUST_VERSION" cargo test -p encrypted-spaces-bridge --locked --features real-proofs runtime_ -- --test-threads=1 + - name: Package and inspect native asset shell: bash env: @@ -181,10 +200,74 @@ jobs: if-no-files-found: error retention-days: 7 + real-proof: + name: Packaged Linux real-proof gate + needs: assets + runs-on: ubuntu-24.04 + steps: + - name: Checkout pinned source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + + - name: Install pinned Rust and RISC Zero + shell: bash + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal + cargo install rzup --version "$RZUP_VERSION" --locked + rzup install cargo-risczero "$RISC0_VERSION" + rzup install r0vm "$RISC0_VERSION" + rzup install rust "$RISC0_RUST_VERSION" + + - name: Download packaged Linux backend + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: release-backend-linux-amd64 + path: dist + + - name: Download packaged Linux bridge + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: release-bridge-linux-amd64 + path: dist + + - name: Build the real-proof integration harness + shell: bash + run: | + set -euo pipefail + rustup run "$RUST_VERSION" cargo test --locked \ + -p encrypted-spaces-bridge --features real-proofs --test runtime --no-run + + - name: Generate and verify a real packaged fast-forward receipt + shell: bash + env: + ENCRYPTED_SPACES_REQUIRE_REAL_PROOF: 1 + ENCRYPTED_SPACES_REQUEST_TIMEOUT_MS: 3600000 + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/release-runtime" + tar -xzf dist/encrypted-spaces-backend-linux-amd64.tar.gz \ + -C "$RUNNER_TEMP/release-runtime" + tar -xzf dist/encrypted-spaces-bridge-linux-amd64.tar.gz \ + -C "$RUNNER_TEMP/release-runtime" + export ENCRYPTED_SPACES_BACKEND_TEST_BINARY="$RUNNER_TEMP/release-runtime/encrypted-spaces-backend-linux-amd64/encrypted-spaces-backend" + export ENCRYPTED_SPACES_BRIDGE_TEST_BINARY="$RUNNER_TEMP/release-runtime/encrypted-spaces-bridge-linux-amd64/encrypted-spaces-bridge" + test -x "$ENCRYPTED_SPACES_BACKEND_TEST_BINARY" + test -x "$ENCRYPTED_SPACES_BRIDGE_TEST_BINARY" + r0vm_path="$(command -v r0vm)" + hidden_r0vm="$RUNNER_TEMP/r0vm-build-only" + mv "$r0vm_path" "$hidden_r0vm" + trap 'mv "$hidden_r0vm" "$r0vm_path"' EXIT + test -z "$(command -v r0vm || true)" + env -u RISC0_DEV_MODE -u RISC0_SKIP_BUILD \ + rustup run "$RUST_VERSION" cargo test --locked \ + -p encrypted-spaces-bridge --features real-proofs --test runtime \ + runtime_packaged_backend_generates_real_fast_forward_receipt -- \ + --ignored --exact --nocapture --test-threads=1 + aggregate: name: Release contract result if: ${{ always() }} - needs: [legal, assets] + needs: [legal, assets, real-proof] runs-on: ubuntu-24.04 steps: - name: Require all release gates @@ -192,10 +275,12 @@ jobs: env: LEGAL_RESULT: ${{ needs.legal.result }} ASSET_RESULT: ${{ needs.assets.result }} + REAL_PROOF_RESULT: ${{ needs.real-proof.result }} run: | set -euo pipefail test "$LEGAL_RESULT" = success test "$ASSET_RESULT" = success + test "$REAL_PROOF_RESULT" = success - name: Download native assets uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 @@ -204,11 +289,11 @@ jobs: path: dist merge-multiple: true - - name: Generate checksums, manifest, and provenance + - name: Generate checksums and manifest shell: bash run: | set -euo pipefail - mkdir -p dist/checksums dist/provenance + mkdir -p dist/checksums python3 - <<'PY' import hashlib import json @@ -219,6 +304,8 @@ jobs: version = os.environ["RELEASE_VERSION"] upstream = os.environ["UPSTREAM_COMMIT"] toolchain = os.environ["RUST_VERSION"] + fork_commit = os.environ["GITHUB_SHA"] + repository = os.environ["GITHUB_REPOSITORY"] artifacts = [] for archive in sorted(dist.glob("*.tar.gz")): parts = archive.name.removesuffix(".tar.gz").split("-") @@ -228,29 +315,6 @@ jobs: (dist / "checksums" / f"{archive.name}.sha256").write_text( f"{digest} {archive.name}\n" ) - statement = { - "_type": "https://in-toto.io/Statement/v1", - "subject": [{"name": archive.name, "digest": {"sha256": digest}}], - "predicateType": "https://slsa.dev/provenance/v1", - "predicate": { - "buildDefinition": { - "buildType": "https://github.com/GoCodeAlone/encrypted-spaces/actions", - "externalParameters": { - "upstream_commit": upstream, - "rust_toolchain": toolchain, - }, - "internalParameters": {"version": version}, - "resolvedDependencies": [], - }, - "runDetails": { - "builder": {"id": "https://github.com/GoCodeAlone/encrypted-spaces/actions"}, - "metadata": {}, - }, - }, - } - (dist / "provenance" / f"{archive.name}.intoto.jsonl").write_text( - json.dumps(statement, separators=(",", ":")) + "\n" - ) artifacts.append({ "component": component, "target": target, @@ -264,7 +328,13 @@ jobs: manifest = { "version": version, "upstream_commit": upstream, + "fork_commit": fork_commit, + "repository": repository, + "source_ref": os.environ["GITHUB_REF_NAME"], + "source_ref_type": os.environ["GITHUB_REF_TYPE"], "rust_toolchain": toolchain, + "risc0_version": os.environ["RISC0_VERSION"], + "risc0_rust_version": os.environ["RISC0_RUST_VERSION"], "artifacts": artifacts, } (dist / "release-manifest.json").write_text( @@ -280,11 +350,16 @@ jobs: (cd dist && sha256sum -c "checksums/$(basename "$checksum")") done test "$(find dist -maxdepth 1 -name '*.tar.gz' | wc -l | tr -d ' ')" = 8 - test "$(find dist/provenance -name '*.intoto.jsonl' | wc -l | tr -d ' ')" = 8 jq -e ' .version == env.RELEASE_VERSION and .upstream_commit == env.UPSTREAM_COMMIT and + .fork_commit == env.GITHUB_SHA and + .repository == env.GITHUB_REPOSITORY and + .source_ref == env.GITHUB_REF_NAME and + .source_ref_type == env.GITHUB_REF_TYPE and .rust_toolchain == env.RUST_VERSION and + .risc0_version == env.RISC0_VERSION and + .risc0_rust_version == env.RISC0_RUST_VERSION and (.artifacts | length == 8) and ([.artifacts[].archive] | unique | length == 8) and all(.artifacts[]; @@ -305,32 +380,3 @@ jobs: path: dist if-no-files-found: error retention-days: 7 - - publish: - name: Publish GitHub release - if: ${{ github.ref_type == 'tag' }} - needs: aggregate - runs-on: ubuntu-24.04 - permissions: - contents: write - steps: - - name: Download complete release - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: release-dist - path: dist - - - name: Publish immutable tag assets - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - gh release create "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --generate-notes \ - dist/*.tar.gz \ - dist/checksums/*.sha256 \ - dist/provenance/*.intoto.jsonl \ - dist/release-manifest.json diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..e1d7c39 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,175 @@ +name: Encrypted Spaces Publish + +on: + workflow_run: + workflows: + - Encrypted Spaces Release + types: [completed] + +permissions: + contents: read + +env: + RELEASE_VERSION: 0.1.0 + UPSTREAM_COMMIT: 4cda0ae87698135aa672990e6e68cf7873847426 + RUST_VERSION: 1.94.1 + RISC0_VERSION: 3.0.5 + RISC0_RUST_VERSION: 1.94.1 + +jobs: + aggregate: + name: Attest trusted release result + if: >- + ${{ github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' }} + runs-on: ubuntu-24.04 + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + outputs: + is_release: ${{ steps.verify.outputs.is_release }} + head_sha: ${{ steps.source.outputs.head_sha }} + steps: + - name: Checkout trusted default branch + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: main + fetch-depth: 0 + + - name: Verify completed run source is on main + id: source + shell: bash + env: + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} + run: | + set -euo pipefail + test "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" + git fetch origin main:refs/remotes/origin/main + git cat-file -e "$HEAD_SHA^{commit}" + git merge-base --is-ancestor "$UPSTREAM_COMMIT" "$HEAD_SHA" + git merge-base --is-ancestor "$HEAD_SHA" origin/main + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + + - name: Download completed release contract + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: release-dist + path: dist + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Verify exact source and release contract + id: verify + shell: bash + env: + HEAD_SHA: ${{ steps.source.outputs.head_sha }} + run: | + set -euo pipefail + test "$(find dist -maxdepth 1 -name '*.tar.gz' | wc -l | tr -d ' ')" = 8 + for checksum in dist/checksums/*.sha256; do + (cd dist && sha256sum -c "checksums/$(basename "$checksum")") + done + jq -e ' + .version == env.RELEASE_VERSION and + .upstream_commit == env.UPSTREAM_COMMIT and + .fork_commit == env.HEAD_SHA and + .repository == env.GITHUB_REPOSITORY and + .rust_toolchain == env.RUST_VERSION and + .risc0_version == env.RISC0_VERSION and + .risc0_rust_version == env.RISC0_RUST_VERSION and + (.artifacts | length == 8) and + ([.artifacts[].archive] | unique | length == 8) + ' dist/release-manifest.json + for archive in dist/*.tar.gz; do + listing="$(tar -tzf "$archive")" + grep -Eq '/LICENSE$' <<< "$listing" + grep -Eq '/NOTICE$' <<< "$listing" + done + source_ref="$(jq -r .source_ref dist/release-manifest.json)" + source_ref_type="$(jq -r .source_ref_type dist/release-manifest.json)" + if test "$source_ref_type" = tag && test "$source_ref" = "v$RELEASE_VERSION"; then + git fetch origin "refs/tags/$source_ref:refs/tags/$source_ref" + test "$(git rev-parse "$source_ref^{}")" = "$HEAD_SHA" + echo "is_release=true" >> "$GITHUB_OUTPUT" + else + test "$source_ref_type" = branch + test "$source_ref" = main + echo "is_release=false" >> "$GITHUB_OUTPUT" + fi + + - name: Attest release archives with GitHub OIDC + id: attest + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 + with: + subject-path: dist/*.tar.gz + + - name: Preserve signed attestation bundle + shell: bash + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + run: | + set -euo pipefail + test -s "$ATTESTATION_BUNDLE" + mkdir -p dist/provenance + cp "$ATTESTATION_BUNDLE" dist/provenance/release.intoto.jsonl + + - name: Upload trusted complete release + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: trusted-release-dist + path: dist + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish GitHub release + if: ${{ needs.aggregate.outputs.is_release == 'true' }} + needs: aggregate + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + steps: + - name: Checkout trusted default branch + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: main + fetch-depth: 0 + + - name: Verify immutable release tag is on main + shell: bash + env: + HEAD_SHA: ${{ needs.aggregate.outputs.head_sha }} + run: | + set -euo pipefail + git fetch origin main:refs/remotes/origin/main + git fetch origin "refs/tags/v$RELEASE_VERSION:refs/tags/v$RELEASE_VERSION" + test "$(git rev-parse "v$RELEASE_VERSION^{}")" = "$HEAD_SHA" + git merge-base --is-ancestor "$HEAD_SHA" origin/main + + - name: Download trusted complete release + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: trusted-release-dist + path: dist + + - name: Publish immutable tag assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + test -s dist/provenance/release.intoto.jsonl + gh release create "v$RELEASE_VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --generate-notes \ + dist/*.tar.gz \ + dist/checksums/*.sha256 \ + dist/provenance/*.intoto.jsonl \ + dist/release-manifest.json diff --git a/.github/workflows/upstream-compat.yml b/.github/workflows/upstream-compat.yml new file mode 100644 index 0000000..073da2d --- /dev/null +++ b/.github/workflows/upstream-compat.yml @@ -0,0 +1,79 @@ +name: Trusted Upstream Compatibility +run-name: upstream-compat ${{ inputs.candidate_sha }} ${{ inputs.correlation_id }} + +on: + workflow_dispatch: + inputs: + candidate_sha: + description: Exact fork merge-candidate commit to verify + required: true + type: string + fork_main_sha: + description: Trusted fork main commit that must be an ancestor + required: true + type: string + upstream_sha: + description: Upstream commit that must be an ancestor + required: true + type: string + correlation_id: + description: Unique dispatch correlation token + required: true + type: string + +permissions: + contents: read + +concurrency: + group: upstream-compat-${{ inputs.candidate_sha }} + cancel-in-progress: false + +env: + CARGO_INCREMENTAL: 0 + CARGO_TERM_COLOR: always + RISC0_DEV_MODE: 1 + RISC0_SKIP_BUILD: 1 + RUST_VERSION: 1.94.1 + +jobs: + verify: + runs-on: ubuntu-24.04 + steps: + - name: Checkout untrusted candidate without credentials + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: ${{ inputs.candidate_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify exact candidate checkout + env: + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + FORK_MAIN_SHA: ${{ inputs.fork_main_sha }} + UPSTREAM_SHA: ${{ inputs.upstream_sha }} + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" + git merge-base --is-ancestor "$FORK_MAIN_SHA" HEAD + git merge-base --is-ancestor "$UPSTREAM_SHA" HEAD + + - name: Install trusted build dependencies + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --fix-missing \ + libwebkit2gtk-4.1-dev libgtk-3-dev \ + libayatana-appindicator3-dev librsvg2-dev protobuf-compiler + rustup toolchain install "$RUST_VERSION" --profile minimal \ + --component rustfmt --component clippy + + - name: Format + run: rustup run "$RUST_VERSION" cargo fmt --all -- --check + + - name: Lint + run: rustup run "$RUST_VERSION" cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: Test + run: rustup run "$RUST_VERSION" cargo test --workspace --locked diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 19bf6f9..971ac4b 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -6,6 +6,7 @@ on: workflow_dispatch: permissions: + actions: write contents: write pull-requests: write @@ -44,7 +45,10 @@ jobs: fi upstream_sha="$(git rev-parse upstream/main)" + fork_main_sha="$(git rev-parse main)" branch="automation/upstream-sync-${upstream_sha:0:12}" + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com existing="$(gh pr list \ --repo "$GITHUB_REPOSITORY" \ --state open \ @@ -52,29 +56,123 @@ jobs: --json url \ --jq '.[0].url // empty')" if [[ -n "$existing" ]]; then - echo "url=$existing" >> "$GITHUB_OUTPUT" + git fetch origin "$branch" + if git merge-base --is-ancestor main "origin/$branch" && \ + git merge-base --is-ancestor upstream/main "origin/$branch"; then + echo "compatible=true" >> "$GITHUB_OUTPUT" + else + echo "compatible=false" >> "$GITHUB_OUTPUT" + fi + candidate_sha="$(git rev-parse "origin/$branch")" + { + echo "url=$existing" + echo "branch=$branch" + echo "candidate_sha=$candidate_sha" + echo "fork_main_sha=$fork_main_sha" + echo "upstream_sha=$upstream_sha" + } >> "$GITHUB_OUTPUT" exit 0 fi - git push origin "upstream/main:refs/heads/$branch" + git switch -c "$branch" + if git merge --no-edit upstream/main; then + compatible=true + else + git merge --abort + git switch -C "$branch" upstream/main + compatible=false + fi + git push origin "HEAD:refs/heads/$branch" url="$(gh pr create \ --repo "$GITHUB_REPOSITORY" \ --base main \ --head "$branch" \ --title "chore: sync encrypted-spaces upstream" \ --body "Automated weekly compatibility update to upstream commit \`$upstream_sha\`. Normal pull-request checks validate the fork delta and runtime bridge.")" - echo "url=$url" >> "$GITHUB_OUTPUT" + candidate_sha="$(git rev-parse "origin/$branch")" + { + echo "url=$url" + echo "branch=$branch" + echo "compatible=$compatible" + echo "candidate_sha=$candidate_sha" + echo "fork_main_sha=$fork_main_sha" + echo "upstream_sha=$upstream_sha" + } >> "$GITHUB_OUTPUT" - - name: Enable squash automerge when compatible - if: steps.pr.outputs.url != '' + - name: Run compatibility CI and merge when green + if: steps.pr.outputs.url != '' && steps.pr.outputs.compatible == 'true' env: GH_TOKEN: ${{ github.token }} PR_URL: ${{ steps.pr.outputs.url }} + CANDIDATE_SHA: ${{ steps.pr.outputs.candidate_sha }} + FORK_MAIN_SHA: ${{ steps.pr.outputs.fork_main_sha }} + UPSTREAM_SHA: ${{ steps.pr.outputs.upstream_sha }} + SYNC_BRANCH: ${{ steps.pr.outputs.branch }} shell: bash run: | set -euo pipefail - if gh pr merge "$PR_URL" --auto --squash --delete-branch; then - echo "Automerge enabled for $PR_URL" - else + trigger_and_wait() { + workflow="$1" + candidate_sha="$2" + fork_main_sha="$3" + upstream_sha="$4" + correlation_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${candidate_sha:0:12}" + run_title="upstream-compat $candidate_sha $correlation_id" + gh workflow run "$workflow" --ref main \ + -f "candidate_sha=$candidate_sha" \ + -f "fork_main_sha=$fork_main_sha" \ + -f "upstream_sha=$upstream_sha" \ + -f "correlation_id=$correlation_id" + run_id="" + for _ in $(seq 1 60); do + run_id="$(gh run list --workflow "$workflow" --branch main \ + --event workflow_dispatch --limit 100 \ + --json databaseId,displayTitle,headSha | \ + jq -r --arg title "$run_title" --arg head "$fork_main_sha" \ + '.[] | select(.displayTitle == $title and .headSha == $head) | .databaseId' | \ + head -n 1)" + if [[ -n "$run_id" ]]; then + break + fi + sleep 5 + done + [[ -n "$run_id" ]] || return 1 + timeout 3600 gh run watch "$run_id" --exit-status + gh run view "$run_id" --json displayTitle,headSha,event,conclusion | \ + jq -e --arg title "$run_title" --arg head "$fork_main_sha" ' + .displayTitle == $title and + .headSha == $head and + .event == "workflow_dispatch" and + .conclusion == "success" + ' >/dev/null + } + + if ! trigger_and_wait upstream-compat.yml "$CANDIDATE_SHA" "$FORK_MAIN_SHA" "$UPSTREAM_SHA"; then echo "$PR_URL remains open for conflicts, failing checks, or review" + exit 0 fi + gh pr view "$PR_URL" --json headRefOid,baseRefOid,state | \ + jq -e --arg head "$CANDIDATE_SHA" --arg base "$FORK_MAIN_SHA" ' + .headRefOid == $head and .baseRefOid == $base and .state == "OPEN" + ' >/dev/null || { + echo "$PR_URL remains open because its tested head or base changed" + exit 0 + } + git fetch origin main "$SYNC_BRANCH" + test "$(git rev-parse origin/main)" = "$FORK_MAIN_SHA" + test "$(git rev-parse "origin/$SYNC_BRANCH")" = "$CANDIDATE_SHA" + git merge-base --is-ancestor "$FORK_MAIN_SHA" "$CANDIDATE_SHA" + if git push \ + --force-with-lease="refs/heads/main:$FORK_MAIN_SHA" \ + origin "$CANDIDATE_SHA:refs/heads/main"; then + echo "Merged compatible upstream sync $PR_URL" + git push origin --delete "$SYNC_BRANCH" || true + else + echo "$PR_URL remains open because main changed after compatibility passed" + fi + + - name: Report incompatible sync + if: steps.pr.outputs.url != '' && steps.pr.outputs.compatible != 'true' + env: + PR_URL: ${{ steps.pr.outputs.url }} + run: echo "$PR_URL remains open for conflicts, failing checks, or review" diff --git a/PATCHES.md b/PATCHES.md index 605099e..8c5921d 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -8,8 +8,11 @@ This ledger records the intentional delta from upstream commit - `800495f` pins `kdl` to `=6.5.0` and repairs the matching lock entries for Rust `1.94.1`. - Adds `encrypted-spaces-bridge`, a typed versioned JSONL stdio boundary with - bounded frames, secret-redacted errors, process-owned trust configuration, - cancellable waits, and SDK-backed space, table, list, text, file, and member + bounded frames, secret-redacted errors, process-pinned trust configuration, + trusted snapshot restore, an untrusted diagnostic client label, cancellable + update waits, Space-bound opaque references, bounded native transport + requests and HTTP bodies, explicit unknown-commit outcomes after transmitted + mutation deadlines, and SDK-backed space, table, list, text, file, and member operations. - Adds an owned schema-bytes SDK input for runtime-loaded KDL without leaking configuration to obtain a static lifetime. @@ -17,5 +20,11 @@ This ledger records the intentional delta from upstream commit multi-process membership, verified synchronization, revocation, and every bridge data primitive. - Adds native Linux/macOS amd64/arm64 release automation with real RISC Zero - guest builds, checksums, in-toto provenance, Apache attribution, and tag-only - GitHub release publication. + guest builds, in-process real-proof-enabled binaries, a packaged-runtime real + receipt gate, checksums, GitHub OIDC-signed artifact attestations, Apache + attribution, and tag-only GitHub release publication. Build/tag code is + unprivileged; a default-branch `workflow_run` validates exact `main` ancestry + before attestation or publication. +- Adds weekly upstream merge PRs whose exact head and fork/upstream ancestry are + checked by a correlated trusted-main, read-only, no-secret compatibility + workflow and revalidated immediately before ancestry-preserving merge. diff --git a/README.md b/README.md index 7827d31..2aa07a2 100644 --- a/README.md +++ b/README.md @@ -111,23 +111,55 @@ non-Rust clients that need the prototype's actual encryption, verification, storage, synchronization, and membership behavior without reimplementing those algorithms. -Each process owns one actor identity, one schema trust bundle, one backend -endpoint, and at most one active Space. Configure them before launch: +Each process owns one untrusted diagnostic label, one schema trust bundle, one +backend endpoint, and at most one active Space. Configure them before launch: ```sh -export ENCRYPTED_SPACES_ACTOR_ID=local-client +export ENCRYPTED_SPACES_CLIENT_LABEL=local-client export ENCRYPTED_SPACES_SCHEMA_PATH=/path/to/app-schema.kdl export ENCRYPTED_SPACES_BACKEND_URL=ws://127.0.0.1:8080/ws +# Optional, 1..3600000; defaults to 30000. +export ENCRYPTED_SPACES_REQUEST_TIMEOUT_MS=30000 encrypted-spaces-bridge ``` -Requests cannot override the actor, schema, data commitment, or fast-forward -guest image ID. `hello` reports those process-derived trust values. The +Requests cannot override the schema, data commitment, or fast-forward guest +image ID. `hello` reports those process-derived trust values and the diagnostic +client label. The label is not an identity or authorization claim; Space +credentials and membership proofs establish authorization. The bridge supports Space create/join/snapshot/restore/sync, table insert/select, scoped list and collaborative text operations, encrypted file put/get, member invite/join/remove, cancellation, close, and shutdown. Request and -response payloads use protocol version `1`; frames larger than 64 KiB are -rejected. +response payloads use protocol version `1`; request frames larger than 64 KiB +are rejected, and oversized results are replaced by a correlated +`RESPONSE_TOO_LARGE` error. A waiting `space.sync` wakes on an SDK-applied +remote update or its configured timeout and reports that cause as +`wait_trigger`. It is cancellable while waiting or queued for the runtime; +once verified synchronization starts, it runs to completion so cancellation +cannot leave provisional cryptographic state behind. Native SDK WebSocket and +file requests share a bounded process-configured deadline that defaults to 30 +seconds and can be raised to one hour for real proof generation. Timed-out or +canceled requests remove their abandoned correlation entries. HTTP response +bodies are streamed with a 64 MiB ceiling before bridge response encoding. +Opaque list and text references include the creating Space ID and cannot be +reused after the process activates another Space. + +If a mutating WebSocket request times out after transmission begins, the bridge +returns `COMMIT_UNKNOWN` rather than reporting a definite failure. The backend +may have committed the operation; clients must synchronize and inspect current +state before deciding whether another mutation is required. + +Restore validates the snapshot's initial commitment, application schemas and +actions, fast-forward guest image ID, and matching snapshot/authentication +Space IDs against the process configuration before authenticating or +activating the Space. A foreign or identity-inconsistent snapshot fails with +`TRUST_MISMATCH` instead of replacing process-pinned trust roots. + +`text.edit` validates positions before mutation and preserves typed SDK errors +when its first operation is rejected. Because a replacement can span multiple +SDK commits, a failure in the second operation after a successful delete +returns `PARTIAL_COMMIT`; clients must synchronize and read the document before +continuing rather than retrying the edit blindly. Invites and snapshots contain private client custody material and must be stored as secrets. The backend remains untrusted and stores ciphertext and @@ -137,7 +169,20 @@ prototype server. Release archives contain native backend and bridge binaries for Linux and macOS on amd64 and arm64, together with checksums, provenance, and Apache -attribution. Both binaries report their release with `--version`. +attribution. Release binaries enable the `real-proofs` feature and embed real, +nonzero RISC Zero guest images, including the in-process prover needed on a +clean host. The packaged-binary API conformance suite sets `RISC0_DEV_MODE=1` +explicitly to test runtime behavior without performing a full proof for every +API case; a separate Linux release gate launches the packaged bridge and +release backend, removes `r0vm` from the runtime environment, and has a second +client verify a real fast-forward receipt with development mode unset. Both +binaries report their release with `--version`. + +The build and real-proof workflow is read-only and cannot attest or publish. +A separate `workflow_run` publisher loaded from the default branch verifies +that the completed run's exact commit is on `main`, rechecks every archive and +checksum, and only then obtains OIDC attestation or release permissions. A tag +is publishable only when `v0.1.0` resolves to that verified `main` commit. ## Prerequisites diff --git a/backend/src/error.rs b/backend/src/error.rs index 5d8d514..1fa2a4b 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -31,6 +31,10 @@ pub enum SdkError { UpdateError(String), DeleteError(String), TransactionCommitError(String), + /// A mutating request entered transport transmission, but no response was + /// received before its deadline. The caller must reconcile remote state + /// before retrying because the server may have committed the operation. + CommitOutcomeUnknown(String), InvalidRowData(String), BackendError(String), /// Client is out of sync with the server and must run a fast-forward @@ -77,6 +81,7 @@ impl fmt::Display for SdkError { SdkError::TransactionCommitError(msg) => { write!(f, "Failed to commit transaction: {msg}") } + SdkError::CommitOutcomeUnknown(msg) => write!(f, "Commit outcome unknown: {msg}"), SdkError::InvalidRowData(msg) => write!(f, "Invalid row data: {msg}"), SdkError::BackendError(msg) => write!(f, "Backend error: {msg}"), SdkError::FastForwardRequired { reason } => { diff --git a/backend/src/schema.rs b/backend/src/schema.rs index bc410da..8dfc061 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -2,7 +2,7 @@ use crate::error::Result; use serde::{Deserialize, Serialize}; use serde_json::Value; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Schema { pub name: String, pub columns: Vec, @@ -37,7 +37,7 @@ impl Schema { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ColumnDefinition { pub name: String, pub column_type: ColumnType, diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml index 904632e..606b8f3 100644 --- a/bridge/Cargo.toml +++ b/bridge/Cargo.toml @@ -6,6 +6,10 @@ license.workspace = true authors.workspace = true description = "Bounded JSONL runtime bridge boundary for encrypted spaces" +[features] +default = [] +real-proofs = ["encrypted-spaces-ffproof/real-proofs"] + [[bin]] name = "encrypted-spaces-bridge" path = "src/main.rs" diff --git a/bridge/src/main.rs b/bridge/src/main.rs index 1c52a81..d509d92 100644 --- a/bridge/src/main.rs +++ b/bridge/src/main.rs @@ -9,5 +9,5 @@ fn main() -> std::io::Result<()> { println!("encrypted-spaces-bridge {}", env!("CARGO_PKG_VERSION")); return Ok(()); } - protocol::run(std::io::stdin().lock(), std::io::stdout()) + protocol::run(std::io::stdin(), std::io::stdout()) } diff --git a/bridge/src/protocol.rs b/bridge/src/protocol.rs index d574801..873c21b 100644 --- a/bridge/src/protocol.rs +++ b/bridge/src/protocol.rs @@ -1,12 +1,12 @@ use crate::runtime; use crate::schema::{Operation, Request, MAX_REQUEST_ID_BYTES, PROTOCOL_VERSION}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::Value; use std::collections::HashMap; use std::io::{self, BufRead, Read, Write}; use std::sync::{ atomic::{AtomicU8, Ordering}, - Arc, Mutex, + mpsc, Arc, Mutex, }; use std::thread; use std::time::Duration; @@ -17,6 +17,8 @@ const MAX_WAIT_FOR_CHANGE_MS: u64 = 60_000; const PENDING: u8 = 0; const CANCELED: u8 = 1; const COMPLETING: u8 = 2; +const DONE: u8 = 3; +const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(10); #[derive(Debug, Serialize)] pub struct Response { @@ -95,133 +97,415 @@ struct CancelResult { canceled: bool, } -pub fn run(reader: R, writer: W) -> io::Result<()> { +struct PendingRequest { + state: Arc, + cancelable: bool, +} + +enum RuntimeJob { + Dispatch { + request: Request, + state: Arc, + wait_trigger: Option<&'static str>, + }, + RegisterWait { + request_id: String, + payload: WaitSyncPayload, + state: Arc, + }, +} + +enum CoordinatorEvent { + Frame(Vec), + FrameTooLarge, + InputClosed, + ReadError(io::Error), + Completed { + request_id: String, + state: Arc, + response: Response, + shutdown: bool, + }, +} + +pub fn run(reader: R, mut writer: W) -> io::Result<()> { let runtime = Arc::new(Mutex::new(runtime::Runtime::from_env()?)); - let writer = Arc::new(Mutex::new(writer)); - let mut pending = HashMap::>::new(); - let mut reader = io::BufReader::new(reader); + let (event_tx, event_rx) = mpsc::sync_channel(MAX_PENDING_REQUESTS * 2); + let (job_tx, job_rx) = mpsc::sync_channel(MAX_PENDING_REQUESTS); + spawn_reader(reader, event_tx.clone()); + spawn_runtime_worker(Arc::clone(&runtime), job_rx, job_tx.clone(), event_tx); + + let mut pending = HashMap::::new(); + let mut input_closed = false; + let mut shutdown_queued = false; loop { - let frame = match read_frame(&mut reader) { - Ok(Some(frame)) => frame, - Ok(None) => return Ok(()), - Err(FrameError::TooLarge) => { - write_shared_response( - &writer, + match event_rx + .recv() + .map_err(|_| io::Error::other("bridge coordinator stopped"))? + { + CoordinatorEvent::Frame(frame) => { + if shutdown_queued { + let request_id = serde_json::from_slice::(&frame) + .ok() + .as_ref() + .and_then(parsed_request_id); + let response = if request_id + .as_ref() + .is_some_and(|request_id| pending.contains_key(request_id)) + { + Response::error( + None, + "DUPLICATE_REQUEST_ID", + "request ID is already pending", + ) + } else { + invalid_state_response_optional(request_id) + }; + write_response(&mut writer, response)?; + continue; + } + let request = match decode_request(&frame) { + Ok(request) => request, + Err(mut response) => { + if response + .request_id + .as_ref() + .is_some_and(|request_id| pending.contains_key(request_id)) + { + response.request_id = None; + } + write_response(&mut writer, response)?; + continue; + } + }; + + if matches!(request.operation, Operation::Cancel) { + cancel_pending(request, &mut pending, &mut writer)?; + continue; + } + if pending.contains_key(&request.request_id) { + write_response( + &mut writer, + Response::error( + None, + "DUPLICATE_REQUEST_ID", + "request ID is already pending", + ), + )?; + continue; + } + if pending.len() >= MAX_PENDING_REQUESTS { + write_response( + &mut writer, + Response::error( + Some(request.request_id), + "TOO_MANY_PENDING", + "pending bridge request limit reached", + ), + )?; + continue; + } + + if matches!(request.operation, Operation::Shutdown) { + cancel_all_waits(&mut pending, &mut writer)?; + shutdown_queued = true; + } + + let request_id = request.request_id.clone(); + if matches!(request.operation, Operation::Sync) + && request.payload.get("wait_for_change_ms").is_some() + { + let payload = match parse_wait_sync(&request_id, request.payload) { + Ok(payload) => payload, + Err(response) => { + write_response(&mut writer, response)?; + continue; + } + }; + let state = Arc::new(AtomicU8::new(PENDING)); + pending.insert( + request_id.clone(), + PendingRequest { + state: Arc::clone(&state), + cancelable: true, + }, + ); + if job_tx + .send(RuntimeJob::RegisterWait { + request_id: request_id.clone(), + payload, + state, + }) + .is_err() + { + pending.remove(&request_id); + return Err(io::Error::other("runtime worker stopped")); + } + continue; + } + + let state = Arc::new(AtomicU8::new(COMPLETING)); + pending.insert( + request_id.clone(), + PendingRequest { + state: Arc::clone(&state), + cancelable: false, + }, + ); + if job_tx + .send(RuntimeJob::Dispatch { + request, + state, + wait_trigger: None, + }) + .is_err() + { + pending.remove(&request_id); + return Err(io::Error::other("runtime worker stopped")); + } + } + CoordinatorEvent::Completed { + request_id, + state, + response, + shutdown, + } => { + if pending + .get(&request_id) + .is_some_and(|pending| Arc::ptr_eq(&pending.state, &state)) + { + pending.remove(&request_id); + } + write_response(&mut writer, response)?; + if shutdown { + cancel_all_requests(&mut pending, &mut writer)?; + return Ok(()); + } + if input_closed && pending.is_empty() { + return Ok(()); + } + } + CoordinatorEvent::FrameTooLarge => { + write_response( + &mut writer, Response::error(None, "FRAME_TOO_LARGE", "JSONL frame exceeds maximum size"), )?; return Ok(()); } - Err(FrameError::Io(error)) => return Err(error), - }; - pending.retain(|_, state| state.load(Ordering::Acquire) == PENDING); - - let request = match decode_request(&frame) { - Ok(request) => request, - Err(response) => { - write_shared_response(&writer, response)?; - continue; + CoordinatorEvent::InputClosed => { + input_closed = true; + cancel_all_waits(&mut pending, &mut writer)?; + if pending.is_empty() { + return Ok(()); + } } - }; - - if matches!(request.operation, Operation::Cancel) { - cancel_pending(request, &mut pending, &writer)?; - continue; - } - if matches!(request.operation, Operation::Sync) - && request.payload.get("wait_for_change_ms").is_some() - { - schedule_wait_sync( - request, - &mut pending, - Arc::clone(&runtime), - Arc::clone(&writer), - )?; - continue; + CoordinatorEvent::ReadError(error) => return Err(error), } + } +} - let (response, shutdown) = { - let mut runtime = runtime - .lock() - .map_err(|_| io::Error::other("runtime lock poisoned"))?; - let response = runtime.dispatch(request); - (response, runtime.should_shutdown()) - }; - write_shared_response(&writer, response)?; - if shutdown { - return Ok(()); +fn spawn_reader(reader: R, events: mpsc::SyncSender) { + thread::spawn(move || { + let mut reader = io::BufReader::new(reader); + loop { + let event = match read_frame(&mut reader) { + Ok(Some(frame)) => CoordinatorEvent::Frame(frame), + Ok(None) => CoordinatorEvent::InputClosed, + Err(FrameError::TooLarge) => CoordinatorEvent::FrameTooLarge, + Err(FrameError::Io(error)) => CoordinatorEvent::ReadError(error), + }; + let terminal = !matches!(event, CoordinatorEvent::Frame(_)); + if events.send(event).is_err() || terminal { + return; + } } - } + }); } -fn schedule_wait_sync( - request: Request, - pending: &mut HashMap>, +fn spawn_runtime_worker( runtime: Arc>, - writer: Arc>, -) -> io::Result<()> { - let request_id = request.request_id; - let payload = match serde_json::from_value::(request.payload) { + jobs: mpsc::Receiver, + job_sender: mpsc::SyncSender, + events: mpsc::SyncSender, +) { + thread::spawn(move || { + while let Ok(job) = jobs.recv() { + match job { + RuntimeJob::Dispatch { + request, + state, + wait_trigger, + } => { + if wait_trigger.is_some() + && state + .compare_exchange( + PENDING, + COMPLETING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + { + continue; + } + let request_id = request.request_id.clone(); + let (mut response, shutdown) = match runtime.lock() { + Ok(mut runtime) => { + let response = runtime.dispatch(request); + (response, runtime.should_shutdown()) + } + Err(_) => ( + Response::error( + Some(request_id.clone()), + "INTERNAL_ERROR", + "bridge runtime unavailable", + ), + false, + ), + }; + if response.ok { + if let (Some(trigger), Some(Value::Object(result))) = + (wait_trigger, response.result.as_mut()) + { + result.insert("wait_trigger".to_owned(), serde_json::json!(trigger)); + } + } + state.store(DONE, Ordering::Release); + if events + .send(CoordinatorEvent::Completed { + request_id, + state, + response, + shutdown, + }) + .is_err() + || shutdown + { + return; + } + } + RuntimeJob::RegisterWait { + request_id, + payload, + state, + } => { + if state.load(Ordering::Acquire) != PENDING { + continue; + } + let updates = match runtime.lock() { + Ok(runtime) => runtime.subscribe_updates(&payload.space_id), + Err(_) => None, + }; + let Some(updates) = updates else { + if state + .compare_exchange(PENDING, DONE, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + let _ = events.send(CoordinatorEvent::Completed { + request_id: request_id.clone(), + state, + response: invalid_state_response(request_id), + shutdown: false, + }); + } + continue; + }; + spawn_waiter( + request_id, + payload, + updates, + Arc::clone(&runtime), + job_sender.clone(), + Arc::clone(&state), + ); + } + } + } + }); +} + +fn parse_wait_sync(request_id: &str, payload: Value) -> Result { + match serde_json::from_value::(payload) { Ok(payload) if payload.wait_for_change_ms > 0 && payload.wait_for_change_ms <= MAX_WAIT_FOR_CHANGE_MS => { - payload + Ok(payload) } - _ => { - return write_shared_response( - &writer, - Response::error( - Some(request_id), - "INVALID_REQUEST", - "invalid bridge request", - ), - ); - } - }; - if pending.len() >= MAX_PENDING_REQUESTS || pending.contains_key(&request_id) { - return write_shared_response( - &writer, - Response::error( - Some(request_id), - "TOO_MANY_PENDING", - "pending bridge request limit reached", - ), - ); + _ => Err(Response::error( + Some(request_id.to_owned()), + "INVALID_REQUEST", + "invalid bridge request", + )), } +} - let state = Arc::new(AtomicU8::new(PENDING)); - pending.insert(request_id.clone(), Arc::clone(&state)); +fn spawn_waiter( + request_id: String, + payload: WaitSyncPayload, + mut updates: tokio::sync::broadcast::Receiver, + runtime: Arc>, + jobs: mpsc::SyncSender, + state: Arc, +) { thread::spawn(move || { - thread::sleep(Duration::from_millis(payload.wait_for_change_ms)); - if state - .compare_exchange(PENDING, COMPLETING, Ordering::AcqRel, Ordering::Acquire) - .is_err() - { + let deadline = + std::time::Instant::now() + Duration::from_millis(payload.wait_for_change_ms); + let trigger = loop { + if state.load(Ordering::Acquire) != PENDING { + return; + } + if let Ok(runtime) = runtime.try_lock() { + runtime.poll_background(); + } + match updates.try_recv() { + Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => { + break "change"; + } + Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break "closed", + Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {} + } + let now = std::time::Instant::now(); + if now >= deadline { + break "timeout"; + } + thread::sleep(WAIT_POLL_INTERVAL.min(deadline.saturating_duration_since(now))); + }; + if state.load(Ordering::Acquire) != PENDING { return; } - let response = match runtime.lock() { - Ok(mut runtime) => runtime.dispatch(Request { + let _ = jobs.send(RuntimeJob::Dispatch { + request: Request { version: PROTOCOL_VERSION, request_id, operation: Operation::Sync, - payload: json!({"space_id": payload.space_id}), - }), - Err(_) => return, - }; - let _ = write_shared_response(&writer, response); + payload: serde_json::json!({"space_id": payload.space_id}), + }, + state, + wait_trigger: Some(trigger), + }); }); - Ok(()) } fn cancel_pending( request: Request, - pending: &mut HashMap>, - writer: &Arc>, + pending: &mut HashMap, + writer: &mut W, ) -> io::Result<()> { + if pending.contains_key(&request.request_id) { + return write_response( + writer, + Response::error( + None, + "DUPLICATE_REQUEST_ID", + "request ID is already pending", + ), + ); + } let payload = match serde_json::from_value::(request.payload) { Ok(payload) if !payload.request_id.is_empty() => payload, _ => { - return write_shared_response( + return write_response( writer, Response::error( Some(request.request_id), @@ -231,17 +515,29 @@ fn cancel_pending( ); } }; - let canceled = pending.remove(&payload.request_id).is_some_and(|state| { - state - .compare_exchange(PENDING, CANCELED, Ordering::AcqRel, Ordering::Acquire) - .is_ok() + if request.request_id == payload.request_id { + return write_response( + writer, + Response::error( + Some(request.request_id), + "INVALID_REQUEST", + "invalid bridge request", + ), + ); + } + let canceled = pending.get(&payload.request_id).is_some_and(|pending| { + pending.cancelable + && pending + .state + .compare_exchange(PENDING, CANCELED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() }); - let mut writer = writer - .lock() - .map_err(|_| io::Error::other("writer lock poisoned"))?; + if canceled { + pending.remove(&payload.request_id); + } if canceled { write_response( - &mut *writer, + writer, Response::error( Some(payload.request_id), "CANCELED", @@ -250,16 +546,64 @@ fn cancel_pending( )?; } write_response( - &mut *writer, + writer, Response::success(request.request_id, CancelResult { canceled }), ) } -fn write_shared_response(writer: &Arc>, response: Response) -> io::Result<()> { - let mut writer = writer - .lock() - .map_err(|_| io::Error::other("writer lock poisoned"))?; - write_response(&mut *writer, response) +fn cancel_all_waits( + pending: &mut HashMap, + writer: &mut W, +) -> io::Result<()> { + let request_ids: Vec<_> = pending + .iter() + .filter(|(_, pending)| { + pending.cancelable + && pending + .state + .compare_exchange(PENDING, CANCELED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + }) + .map(|(request_id, _)| request_id.clone()) + .collect(); + for request_id in request_ids { + pending.remove(&request_id); + write_response( + writer, + Response::error(Some(request_id), "CANCELED", "bridge request canceled"), + )?; + } + Ok(()) +} + +fn cancel_all_requests( + pending: &mut HashMap, + writer: &mut W, +) -> io::Result<()> { + for (request_id, pending_request) in pending.drain() { + pending_request.state.store(CANCELED, Ordering::Release); + write_response( + writer, + Response::error(Some(request_id), "CANCELED", "bridge request canceled"), + )?; + } + Ok(()) +} + +fn invalid_state_response(request_id: String) -> Response { + Response::error( + Some(request_id), + "INVALID_STATE", + "operation is invalid for current bridge state", + ) +} + +fn invalid_state_response_optional(request_id: Option) -> Response { + Response::error( + request_id, + "INVALID_STATE", + "operation is invalid for current bridge state", + ) } fn decode_request(frame: &[u8]) -> Result { @@ -348,6 +692,14 @@ fn write_response(writer: &mut W, response: Response) -> io::Result<() fn encode_response(response: &Response) -> io::Result> { let mut frame = serde_json::to_vec(response).map_err(io::Error::other)?; + if frame.len() > MAX_FRAME_BYTES { + frame = serde_json::to_vec(&Response::error( + response.request_id.clone(), + "RESPONSE_TOO_LARGE", + "bridge response exceeds maximum size", + )) + .map_err(io::Error::other)?; + } frame.push(b'\n'); Ok(frame) } @@ -384,6 +736,19 @@ mod tests { ); } + #[test] + fn protocol_encode_replaces_oversized_response_with_bounded_error() { + let response = + Response::success("oversized-response".to_owned(), "x".repeat(MAX_FRAME_BYTES)); + + let encoded = encode_response(&response).expect("encode bounded response"); + assert!(encoded.len() <= MAX_FRAME_BYTES + 1); + let value: Value = serde_json::from_slice(&encoded).expect("bounded response JSON"); + assert_eq!(value["request_id"], "oversized-response"); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "RESPONSE_TOO_LARGE"); + } + #[test] fn protocol_frame_limit_accepts_exact_and_rejects_oversize() { let mut exact = io::Cursor::new(vec![b'x'; MAX_FRAME_BYTES]); diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index ed8a5bb..96353d3 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -11,12 +11,17 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; use std::fs; +use std::future::Future; use std::io; +use std::time::Duration; -const ACTOR_ID_ENV: &str = "ENCRYPTED_SPACES_ACTOR_ID"; +const CLIENT_LABEL_ENV: &str = "ENCRYPTED_SPACES_CLIENT_LABEL"; const SCHEMA_PATH_ENV: &str = "ENCRYPTED_SPACES_SCHEMA_PATH"; const BACKEND_URL_ENV: &str = "ENCRYPTED_SPACES_BACKEND_URL"; +const REQUEST_TIMEOUT_MS_ENV: &str = "ENCRYPTED_SPACES_REQUEST_TIMEOUT_MS"; const DEFAULT_BACKEND_URL: &str = "ws://127.0.0.1:8080/ws"; +const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; +const MAX_REQUEST_TIMEOUT_MS: u64 = 3_600_000; pub struct Runtime { executor: tokio::runtime::Runtime, @@ -26,19 +31,20 @@ pub struct Runtime { } struct ProcessConfig { - actor_id: String, + client_label: String, schema_sha256: String, schema: Vec, data_commitment_bytes: [u8; 32], data_commitment: String, ff_guest_image_id: [u32; 8], backend_url: String, + request_timeout: Duration, } #[derive(Serialize)] struct HelloResult<'a> { protocol_version: u16, - actor_id: &'a str, + client_label: &'a str, schema_sha256: &'a str, data_commitment: &'a str, ff_guest_image_id: [u32; 8], @@ -125,6 +131,7 @@ enum RefKind { #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct ScopedRef { + space_id: String, kind: RefKind, table: String, row_id: i64, @@ -323,7 +330,7 @@ struct ShutdownResult { impl Runtime { pub fn from_env() -> io::Result { - let actor_id = required_env(ACTOR_ID_ENV)?; + let client_label = required_env(CLIENT_LABEL_ENV)?; let schema_path = required_env(SCHEMA_PATH_ENV)?; let schema = fs::read(&schema_path)?; let schema_sha256 = format!("{:x}", Sha256::digest(&schema)); @@ -345,17 +352,19 @@ impl Runtime { .ok() .filter(|value| !value.is_empty()) .unwrap_or_else(|| DEFAULT_BACKEND_URL.to_owned()); + let request_timeout = request_timeout_from_env()?; Ok(Self { executor, process: ProcessConfig { - actor_id, + client_label, schema_sha256, schema, data_commitment_bytes, data_commitment, ff_guest_image_id: encrypted_spaces_ffproof::EXTEND_FF_ID, backend_url, + request_timeout, }, space: None, shutdown_requested: false, @@ -369,7 +378,7 @@ impl Runtime { request.request_id, HelloResult { protocol_version: PROTOCOL_VERSION, - actor_id: &self.process.actor_id, + client_label: &self.process.client_label, schema_sha256: &self.process.schema_sha256, data_commitment: &self.process.data_commitment, ff_guest_image_id: self.process.ff_guest_image_id, @@ -419,9 +428,11 @@ impl Runtime { return invalid_state(request_id); } let backend_url = self.process.backend_url.clone(); + let request_timeout = self.process.request_timeout; let schema = self.application_schema(); let space = match self.executor.block_on(async move { - let transport = WebSocketTransport::new(&backend_url).await?; + let transport = + WebSocketTransport::new_with_request_timeout(&backend_url, request_timeout).await?; Space::create(transport, schema).await }) { Ok(space) => space, @@ -450,6 +461,9 @@ impl Runtime { if payload.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } match self.executor.block_on(space.snapshot()) { Ok(snapshot) => Response::success(request_id, SnapshotResult { space_id, snapshot }), Err(error) => sdk_operation_error(request_id, &error), @@ -465,11 +479,19 @@ impl Runtime { return invalid_state(request_id); } let backend_url = self.process.backend_url.clone(); + let request_timeout = self.process.request_timeout; + let schema = self.application_schema(); let space = match self.executor.block_on(async move { - let transport = WebSocketTransport::new(&backend_url).await?; - Space::restore(transport, payload.snapshot).await + let transport = + WebSocketTransport::new_with_request_timeout(&backend_url, request_timeout).await?; + Space::restore_trusted(transport, payload.snapshot, schema).await }) { Ok(space) => space, + Err(SdkErrorType::ValidationError(message)) + if message == "snapshot trust bundle mismatch" => + { + return trust_mismatch(request_id); + } Err(_) => return sdk_error(request_id), }; let space_id = space.id().to_string(); @@ -510,6 +532,27 @@ impl Runtime { } } + pub fn subscribe_updates( + &self, + space_id: &str, + ) -> Option> { + let space = self.space.as_ref()?; + (space_id == space.id().to_string()).then(|| space.subscribe_updates()) + } + + pub fn poll_background(&self) { + self.executor.block_on(tokio::task::yield_now()); + } + + fn sync_before_access(&self, request_id: &str) -> Result<(), Response> { + let Some(space) = self.space.as_ref() else { + return Err(invalid_state(request_id.to_owned())); + }; + self.executor + .block_on(space.sync()) + .map_err(|error| sdk_operation_error(request_id.to_owned(), &error)) + } + fn table_insert(&mut self, request_id: String, payload: Value) -> Response { let payload = match parse_payload::(&request_id, payload) { Ok(payload) if payload.row.is_object() => payload, @@ -522,11 +565,11 @@ impl Runtime { if payload.space_id != space.id().to_string() { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } let table = space.table::(&payload.table); - match self.executor.block_on(async { - space.sync().await?; - table.insert(&payload.row).execute().await - }) { + match self.executor.block_on(table.insert(&payload.row).execute()) { Ok(row_id) => Response::success(request_id, TableInsertResult { row_id }), Err(error) => sdk_operation_error(request_id, &error), } @@ -559,11 +602,14 @@ impl Runtime { { return invalid_request(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } let table = space.table::(&payload.table); - match self.executor.block_on(async { - space.sync().await?; - table.select().where_eq(&column, value).all().await - }) { + match self + .executor + .block_on(table.select().where_eq(&column, value).all()) + { Ok(rows) => Response::success(request_id, TableSelectResult { rows }), Err(error) => sdk_operation_error(request_id, &error), } @@ -588,11 +634,15 @@ impl Runtime { if payload.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } let list = space.list::(&payload.table, payload.row_id, &payload.column); if self.executor.block_on(list.get_all()).is_err() { return sdk_error(request_id); } let list_ref = match encode_opaque_ref(&ScopedRef { + space_id: space_id.clone(), kind: RefKind::List, table: payload.table.clone(), row_id: payload.row_id, @@ -626,9 +676,12 @@ impl Runtime { return invalid_state(request_id); }; let space_id = space.id().to_string(); - if payload.space_id != space_id { + if payload.space_id != space_id || list_ref.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } let list = space.list::(&list_ref.table, list_ref.row_id, &list_ref.column); match self.executor.block_on(list.append(&payload.value)) { Ok(item_ref) => Response::success( @@ -656,9 +709,12 @@ impl Runtime { return invalid_state(request_id); }; let space_id = space.id().to_string(); - if payload.space_id != space_id { + if payload.space_id != space_id || list_ref.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } let list = space.list::(&list_ref.table, list_ref.row_id, &list_ref.column); match self.executor.block_on(list.get_all()) { Ok(entries) => Response::success( @@ -699,11 +755,15 @@ impl Runtime { if payload.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } let text = space.textarea(&payload.table, payload.row_id, &payload.column); if self.executor.block_on(text.sync()).is_err() { return sdk_error(request_id); } let text_ref = match encode_opaque_ref(&ScopedRef { + space_id: space_id.clone(), kind: RefKind::Text, table: payload.table.clone(), row_id: payload.row_id, @@ -740,22 +800,31 @@ impl Runtime { return invalid_state(request_id); }; let space_id = space.id().to_string(); - if payload.space_id != space_id { + if payload.space_id != space_id || text_ref.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } let text = space.textarea(&text_ref.table, text_ref.row_id, &text_ref.column); - let result = self.executor.block_on(async { + let text_len = match self.executor.block_on(async { text.sync().await?; - if payload.delete_count > 0 { - text.delete_range(payload.position, delete_end).await?; - } - if !payload.insert.is_empty() { - text.insert_string(payload.position, &payload.insert) - .await?; - } - encrypted_spaces_sdk::SdkResult::Ok(()) - }); - match result { + text.len().await + }) { + Ok(text_len) => text_len, + Err(error) => return sdk_operation_error(request_id, &error), + }; + if payload.position > text_len || delete_end > text_len { + return invalid_request(request_id); + } + let mutation = self.executor.block_on(apply_text_mutation( + request_id.clone(), + payload.delete_count > 0, + text.delete_range(payload.position, delete_end), + !payload.insert.is_empty(), + text.insert_string(payload.position, &payload.insert), + )); + match mutation { Ok(()) => Response::success( request_id, TextEditResult { @@ -764,7 +833,7 @@ impl Runtime { edited: true, }, ), - Err(_) => sdk_error(request_id), + Err(response) => response, } } @@ -781,9 +850,12 @@ impl Runtime { return invalid_state(request_id); }; let space_id = space.id().to_string(); - if payload.space_id != space_id { + if payload.space_id != space_id || text_ref.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } let text = space.textarea(&text_ref.table, text_ref.row_id, &text_ref.column); match self.executor.block_on(text.snapshot()) { Ok(text) => Response::success( @@ -814,6 +886,9 @@ impl Runtime { if payload.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } match self .executor .block_on(space.file().upload(File::from_data(bytes))) @@ -845,6 +920,9 @@ impl Runtime { if payload.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } match self.executor.block_on( space .file() @@ -877,6 +955,9 @@ impl Runtime { if payload.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } match self.executor.block_on(space.invite_user()) { Ok(invite) => { let Some(member_id) = invite.id() else { @@ -911,9 +992,11 @@ impl Runtime { Err(()) => return invalid_request(request_id), }; let backend_url = self.process.backend_url.clone(); + let request_timeout = self.process.request_timeout; let schema = self.application_schema(); let space = match self.executor.block_on(async move { - let transport = WebSocketTransport::new(&backend_url).await?; + let transport = + WebSocketTransport::new_with_request_timeout(&backend_url, request_timeout).await?; Space::join(transport, invite, schema).await }) { Ok(space) => space, @@ -947,6 +1030,9 @@ impl Runtime { if payload.space_id != space_id { return invalid_state(request_id); } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } match self.executor.block_on(space.remove_user(payload.member_id)) { Ok(()) => Response::success( request_id, @@ -1030,6 +1116,13 @@ fn sdk_error(request_id: String) -> Response { } fn sdk_operation_error(request_id: String, error: &SdkErrorType) -> Response { + if matches!(error, SdkErrorType::CommitOutcomeUnknown(_)) { + return Response::error( + Some(request_id), + "COMMIT_UNKNOWN", + "operation outcome is unknown; synchronize before retrying", + ); + } let removed_member = matches!( error, SdkErrorType::ValidationError(message) @@ -1062,6 +1155,50 @@ fn internal_error(request_id: String) -> Response { ) } +fn text_mutation_error(request_id: String) -> Response { + Response::error( + Some(request_id), + "PARTIAL_COMMIT", + "text edit may have partially committed; synchronize before continuing", + ) +} + +async fn apply_text_mutation( + request_id: String, + run_delete: bool, + delete: D, + run_insert: bool, + insert: I, +) -> Result<(), Response> +where + D: Future>, + I: Future>, +{ + if run_delete { + if let Err(error) = delete.await { + return Err(sdk_operation_error(request_id, &error)); + } + } + if run_insert { + if let Err(error) = insert.await { + return Err(if run_delete { + text_mutation_error(request_id) + } else { + sdk_operation_error(request_id, &error) + }); + } + } + Ok(()) +} + +fn trust_mismatch(request_id: String) -> Response { + Response::error( + Some(request_id), + "TRUST_MISMATCH", + "snapshot trust bundle does not match process configuration", + ) +} + fn encode_opaque_ref(value: &impl Serialize) -> Result { serde_json::to_vec(value) .map(|bytes| URL_SAFE_NO_PAD.encode(bytes)) @@ -1084,12 +1221,172 @@ fn required_env(name: &str) -> io::Result { .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{name} is required"))) } +fn request_timeout_from_env() -> io::Result { + match std::env::var(REQUEST_TIMEOUT_MS_ENV) { + Ok(value) => parse_request_timeout_ms(Some(&value)), + Err(std::env::VarError::NotPresent) => parse_request_timeout_ms(None), + Err(error) => Err(io::Error::new(io::ErrorKind::InvalidInput, error)), + } +} + +fn parse_request_timeout_ms(value: Option<&str>) -> io::Result { + let milliseconds = match value { + Some(value) => value.parse::().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{REQUEST_TIMEOUT_MS_ENV} must be an integer"), + ) + })?, + None => DEFAULT_REQUEST_TIMEOUT_MS, + }; + if !(1..=MAX_REQUEST_TIMEOUT_MS).contains(&milliseconds) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{REQUEST_TIMEOUT_MS_ENV} must be between 1 and {MAX_REQUEST_TIMEOUT_MS}"), + )); + } + Ok(Duration::from_millis(milliseconds)) +} + #[cfg(test)] mod tests { use super::*; use encrypted_spaces_sdk::{ColumnType, SchemaBuilder}; use serde_json::json; + #[test] + fn text_edit_failure_after_mutation_is_reported_as_partial_commit() { + #[derive(Serialize)] + struct TextRow { + id: Option, + body: encrypted_spaces_sdk::TextArea, + } + + let executor = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + executor.block_on(async { + let schema = SchemaBuilder::new("text_records") + .column("id", ColumnType::Integer) + .plaintext_primary_key() + .column("body", ColumnType::List) + .expect("body column") + .build() + .expect("text schema"); + let transport = LocalTransport::new(std::slice::from_ref(&schema), None, Some(1024)) + .await + .expect("local transport"); + let data_commitment = transport.get_root_hash().await.expect("initial root"); + let space = Space::create( + transport, + ApplicationSchema::WithDataCommitment( + vec![schema], + data_commitment, + encrypted_spaces_ffproof::EXTEND_FF_ID, + ), + ) + .await + .expect("text space"); + let row_id = space + .table::("text_records") + .insert(&TextRow { + id: None, + body: encrypted_spaces_sdk::TextArea::empty(), + }) + .execute() + .await + .expect("text parent row"); + let text = space.textarea("text_records", row_id, "body"); + text.insert_string(0, "abcdef").await.expect("initial text"); + + let result = apply_text_mutation( + "partial-text-edit".to_owned(), + true, + text.delete_range(1, 3), + true, + std::future::ready(encrypted_spaces_sdk::SdkResult::Err(SdkErrorType::NotFound)), + ) + .await; + let response = result.expect_err("insert failure after delete must be partial commit"); + + assert!(!response.ok); + assert_eq!( + response.error.as_ref().map(|error| error.code), + Some("PARTIAL_COMMIT") + ); + assert_eq!( + text.snapshot().await.expect("partially committed text"), + "adef" + ); + }); + } + + #[test] + fn text_edit_first_stage_access_denial_is_preserved() { + let executor = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let result = executor.block_on(apply_text_mutation( + "denied-text-edit".to_owned(), + true, + std::future::ready(encrypted_spaces_sdk::SdkResult::Err( + SdkErrorType::AccessDenied("revoked".to_owned()), + )), + true, + std::future::ready(encrypted_spaces_sdk::SdkResult::Ok(())), + )); + let response = result.expect_err("access denial must fail the edit"); + + assert_eq!( + response.error.as_ref().map(|error| error.code), + Some("ACCESS_DENIED") + ); + } + + #[test] + fn sdk_commit_outcome_unknown_is_preserved() { + let response = sdk_operation_error( + "unknown-commit".to_owned(), + &SdkErrorType::CommitOutcomeUnknown("deadline after send".to_owned()), + ); + + assert_eq!( + response.error.as_ref().map(|error| error.code), + Some("COMMIT_UNKNOWN") + ); + assert!( + response + .error + .as_ref() + .is_some_and(|error| error.message.contains("synchronize")), + "unknown commit response omitted reconciliation guidance" + ); + } + + #[test] + fn request_timeout_bounds_are_enforced() { + assert_eq!( + parse_request_timeout_ms(None).expect("default timeout"), + Duration::from_millis(DEFAULT_REQUEST_TIMEOUT_MS) + ); + assert_eq!( + parse_request_timeout_ms(Some("1")).expect("minimum timeout"), + Duration::from_millis(1) + ); + assert_eq!( + parse_request_timeout_ms(Some("3600000")).expect("maximum timeout"), + Duration::from_millis(MAX_REQUEST_TIMEOUT_MS) + ); + for invalid in ["0", "3600001", "not-a-number"] { + assert!( + parse_request_timeout_ms(Some(invalid)).is_err(), + "accepted invalid timeout {invalid}" + ); + } + } + fn revoked_member_runtime() -> (Runtime, String, i64) { let executor = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -1150,13 +1447,14 @@ mod tests { let runtime = Runtime { executor, process: ProcessConfig { - actor_id: "revoked-member".to_owned(), + client_label: "revoked-member".to_owned(), schema_sha256: "test-schema".to_owned(), schema: Vec::new(), data_commitment_bytes, data_commitment, ff_guest_image_id: encrypted_spaces_ffproof::EXTEND_FF_ID, backend_url: "local-test".to_owned(), + request_timeout: Duration::from_millis(DEFAULT_REQUEST_TIMEOUT_MS), }, space: Some(member), shutdown_requested: false, @@ -1204,4 +1502,94 @@ mod tests { Some("ACCESS_DENIED") ); } + + #[test] + fn every_protected_operation_syncs_revocation_before_sdk_access() { + let operations = [ + "snapshot", + "list.create", + "list.append", + "list.read", + "text.create", + "text.edit", + "text.read", + "file.put", + "file.get", + "member.invite", + "member.remove", + ]; + + for operation in operations { + let (mut runtime, space_id, row_id) = revoked_member_runtime(); + let list_ref = encode_opaque_ref(&ScopedRef { + space_id: space_id.clone(), + kind: RefKind::List, + table: "records".to_owned(), + row_id, + column: "label".to_owned(), + }) + .expect("list reference"); + let text_ref = encode_opaque_ref(&ScopedRef { + space_id: space_id.clone(), + kind: RefKind::Text, + table: "records".to_owned(), + row_id, + column: "label".to_owned(), + }) + .expect("text reference"); + let response = match operation { + "snapshot" => runtime.snapshot( + "revoked-snapshot".to_owned(), + json!({"space_id": space_id}), + ), + "list.create" => runtime.list_create( + "revoked-list-create".to_owned(), + json!({"space_id": space_id, "table": "records", "row_id": row_id, "column": "label"}), + ), + "list.append" => runtime.list_append( + "revoked-list-append".to_owned(), + json!({"space_id": space_id, "list_ref": list_ref, "value": "denied"}), + ), + "list.read" => runtime.list_read( + "revoked-list-read".to_owned(), + json!({"space_id": space_id, "list_ref": list_ref}), + ), + "text.create" => runtime.text_create( + "revoked-text-create".to_owned(), + json!({"space_id": space_id, "table": "records", "row_id": row_id, "column": "label"}), + ), + "text.edit" => runtime.text_edit( + "revoked-text-edit".to_owned(), + json!({"space_id": space_id, "text_ref": text_ref, "position": 0, "delete_count": 0, "insert": "denied"}), + ), + "text.read" => runtime.text_read( + "revoked-text-read".to_owned(), + json!({"space_id": space_id, "text_ref": text_ref}), + ), + "file.put" => runtime.file_put( + "revoked-file-put".to_owned(), + json!({"space_id": space_id, "bytes_base64": "ZGVuaWVk"}), + ), + "file.get" => runtime.file_get( + "revoked-file-get".to_owned(), + json!({"space_id": space_id, "digest": "0".repeat(64)}), + ), + "member.invite" => runtime.member_invite( + "revoked-member-invite".to_owned(), + json!({"space_id": space_id}), + ), + "member.remove" => runtime.member_remove( + "revoked-member-remove".to_owned(), + json!({"space_id": space_id, "member_id": 1}), + ), + _ => unreachable!(), + }; + + assert_eq!( + response.error.as_ref().map(|error| error.code), + Some("ACCESS_DENIED"), + "{operation} did not observe revocation before SDK access" + ); + } + } } diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index cee43ec..7b4eb3f 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -40,18 +40,24 @@ impl BackendProcess { let space_root = fixture_root.join("spaces"); fs::create_dir_all(&space_root).expect("create backend fixture root"); fs::write(&schema_path, schema).expect("write backend schema fixture"); - let child = Command::new(backend_binary()) - .args([ - "--schema", - schema_path.to_str().expect("schema path is UTF-8"), - "--space-root", - space_root.to_str().expect("space root is UTF-8"), - "--bind-addr", - "127.0.0.1", - "--port", - &port.to_string(), - ]) - .env("RISC0_DEV_MODE", "1") + let mut command = Command::new(backend_binary()); + command.args([ + "--schema", + schema_path.to_str().expect("schema path is UTF-8"), + "--space-root", + space_root.to_str().expect("space root is UTF-8"), + "--bind-addr", + "127.0.0.1", + "--port", + &port.to_string(), + ]); + if std::env::var_os("ENCRYPTED_SPACES_REQUIRE_REAL_PROOF").is_none() { + command.env("RISC0_DEV_MODE", "1"); + } else { + command.env_remove("RISC0_DEV_MODE"); + command.env_remove("RISC0_SKIP_BUILD"); + } + let child = command .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -184,7 +190,7 @@ impl BridgeProcess { .unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_encrypted-spaces-bridge"))); let mut command = Command::new(bridge_binary); command - .env("ENCRYPTED_SPACES_ACTOR_ID", actor) + .env("ENCRYPTED_SPACES_CLIENT_LABEL", actor) .env("ENCRYPTED_SPACES_SCHEMA_PATH", &schema_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()); @@ -246,6 +252,42 @@ impl BridgeProcess { payload: Value, actor_override: Option<&str>, ) -> Observation { + let pending = self.send_inner(operation, payload, actor_override); + self.receive(pending, RESPONSE_TIMEOUT) + } + + fn send(&mut self, operation: &str, payload: Value) -> PendingObservation { + self.send_inner(operation, payload, None) + } + + fn send_with_request_id( + &mut self, + request_id: String, + operation: &str, + payload: Value, + ) -> PendingObservation { + let request = json!({ + "version": PROTOCOL_VERSION, + "request_id": request_id, + "operation": operation, + "payload": payload, + }); + let frame = serde_json::to_string(&request).expect("request JSON"); + let stdin = self.stdin.as_mut().expect("bridge stdin is open"); + writeln!(stdin, "{frame}").expect("write bridge frame"); + stdin.flush().expect("flush bridge frame"); + PendingObservation { + operation: operation.to_owned(), + request_id, + } + } + + fn send_inner( + &mut self, + operation: &str, + payload: Value, + actor_override: Option<&str>, + ) -> PendingObservation { let request_id = format!( "runtime-{}-{:02}-{}", self.scenario, @@ -266,24 +308,41 @@ impl BridgeProcess { let stdin = self.stdin.as_mut().expect("bridge stdin is open"); writeln!(stdin, "{frame}").expect("write bridge frame"); stdin.flush().expect("flush bridge frame"); + PendingObservation { + operation: operation.to_owned(), + request_id, + } + } - let line = match self.stdout.recv_timeout(RESPONSE_TIMEOUT) { + fn receive(&mut self, pending: PendingObservation, timeout: Duration) -> Observation { + let line = match self.stdout.recv_timeout(timeout) { Ok(StdoutEvent::Line(line)) => line, - Ok(StdoutEvent::Eof) => panic!("bridge exited before responding to {operation}"), + Ok(StdoutEvent::Eof) => { + panic!("bridge exited before responding to {}", pending.operation) + } Ok(StdoutEvent::Error(error)) => { - panic!("bridge stdout read failed while handling {operation}: {error}") + panic!( + "bridge stdout read failed while handling {}: {error}", + pending.operation + ) } Err(mpsc::RecvTimeoutError::Timeout) => { - panic!("bridge did not respond to {operation} within {RESPONSE_TIMEOUT:?}") + panic!( + "bridge did not respond to {} within {timeout:?}", + pending.operation + ) } Err(mpsc::RecvTimeoutError::Disconnected) => { - panic!("bridge stdout reader disconnected while handling {operation}") + panic!( + "bridge stdout reader disconnected while handling {}", + pending.operation + ) } }; let response = serde_json::from_str(&line).expect("bridge response JSON"); Observation { - operation: operation.to_owned(), - request_id, + operation: pending.operation, + request_id: pending.request_id, response, } } @@ -345,6 +404,11 @@ struct Observation { response: Value, } +struct PendingObservation { + operation: String, + request_id: String, +} + struct Scenario { name: String, backend: BackendProcess, @@ -380,6 +444,26 @@ impl Scenario { .get_mut(actor) .unwrap_or_else(|| panic!("scenario actor {actor} has no bridge process")) .exchange(operation, payload); + self.record(observation) + } + + fn send(&mut self, actor: &str, operation: &str, payload: Value) -> PendingObservation { + self.bridges + .get_mut(actor) + .unwrap_or_else(|| panic!("scenario actor {actor} has no bridge process")) + .send(operation, payload) + } + + fn receive(&mut self, actor: &str, pending: PendingObservation, timeout: Duration) -> usize { + let observation = self + .bridges + .get_mut(actor) + .unwrap_or_else(|| panic!("scenario actor {actor} has no bridge process")) + .receive(pending, timeout); + self.record(observation) + } + + fn record(&mut self, observation: Observation) -> usize { let actual_version = observation.response.get("version").and_then(Value::as_u64); if actual_version != Some(PROTOCOL_VERSION) { self.failures.push(format!( @@ -391,7 +475,9 @@ impl Scenario { .response .get("request_id") .and_then(Value::as_str); - if actual_request_id != Some(observation.request_id.as_str()) { + let uncorrelated_duplicate = actual_request_id.is_none() + && observation.response["error"]["code"].as_str() == Some("DUPLICATE_REQUEST_ID"); + if actual_request_id != Some(observation.request_id.as_str()) && !uncorrelated_duplicate { self.failures.push(format!( "{}: request_id {:?}, expected {}", observation.operation, actual_request_id, observation.request_id @@ -498,12 +584,18 @@ impl Scenario { #[derive(Deserialize)] struct HelloResult { protocol_version: u64, - actor_id: String, + client_label: String, schema_sha256: String, data_commitment: String, ff_guest_image_id: Vec, } +#[derive(Deserialize)] +struct VersionResult { + version: String, + protocol_version: u64, +} + #[derive(Deserialize)] struct SpaceCreateResult { space_id: String, @@ -540,6 +632,23 @@ struct JoinResult { struct SyncResult { space_id: String, synced: bool, + #[serde(default)] + wait_trigger: Option, +} + +#[derive(Deserialize)] +struct CancelResult { + canceled: bool, +} + +#[derive(Deserialize)] +struct CloseResult { + closed: bool, +} + +#[derive(Deserialize)] +struct ShutdownResult { + shutting_down: bool, } #[derive(Deserialize)] @@ -793,6 +902,14 @@ fn runtime_hello_health_metadata_is_process_bound() { for response in [&first_response, &second_response, &changed_response] { assert_eq!(response["ok"], true, "hello trust metadata failed"); + assert!( + response["result"].get("actor_id").is_none(), + "process metadata must not be represented as a trusted actor identity" + ); + assert!( + response["result"].get("client_label").is_some(), + "hello must expose process metadata as an untrusted client label" + ); } let first: HelloResult = serde_json::from_value(first_response["result"].clone()).expect("first hello result"); @@ -801,9 +918,9 @@ fn runtime_hello_health_metadata_is_process_bound() { let changed: HelloResult = serde_json::from_value(changed_response["result"].clone()).expect("changed hello result"); - assert_eq!(first.actor_id, "actor-first"); - assert_eq!(second.actor_id, "actor-second"); - assert_eq!(changed.actor_id, "actor-changed"); + assert_eq!(first.client_label, "actor-first"); + assert_eq!(second.client_label, "actor-second"); + assert_eq!(changed.client_label, "actor-changed"); assert_eq!(first.schema_sha256, schema_digest(SCHEMA_KDL)); assert_eq!(second.schema_sha256, first.schema_sha256); assert_eq!(changed.schema_sha256, schema_digest(&modified_schema)); @@ -848,6 +965,41 @@ fn runtime_create_restore_uses_backend_and_survives_process_restart() { scenario.finish(); } +#[test] +fn runtime_restore_rejects_snapshot_from_foreign_trust_bundle() { + let backend = BackendProcess::spawn("restore-trust", SCHEMA_KDL); + let mut source = BridgeProcess::spawn_with_backend( + "restore-trust-source", + "source-client", + SCHEMA_KDL, + Some(&backend.url), + ); + let create = source.exchange("space.create", json!({})); + let space_id = create.response["result"]["space_id"] + .as_str() + .expect("created space ID") + .to_owned(); + let snapshot = source.exchange("space.snapshot", json!({"space_id": space_id})); + let snapshot = snapshot.response["result"]["snapshot"].clone(); + source.finish(); + + let modified_schema = format!( + "{SCHEMA_KDL}\ntable \"foreign_trust_probe\" {{\n column \"id\" type=\"int\" plaintext=#true\n}}\n" + ); + let mut target = BridgeProcess::spawn_with_backend( + "restore-trust-target", + "target-client", + &modified_schema, + Some(&backend.url), + ); + let restore = target.exchange("space.restore", json!({"snapshot": snapshot})); + + assert_eq!(restore.response["ok"], false); + assert_eq!(restore.response["error"]["code"], "TRUST_MISMATCH"); + target.finish(); + drop(backend); +} + #[test] fn runtime_snapshot_sync_runs_verified_backend_recovery() { let actor = "actor-snapshot-sync-parametric"; @@ -858,29 +1010,348 @@ fn runtime_snapshot_sync_runs_verified_backend_recovery() { let sync = scenario.request(actor, "space.sync", json!({"space_id": space_id})); scenario.verify::(sync, |result| { - (result.space_id == space_id && result.synced) + (result.space_id == space_id && result.synced && result.wait_trigger.is_none()) .then_some(()) .ok_or_else(|| "space.sync did not complete verified recovery".to_owned()) }); scenario.finish(); } +#[test] +#[ignore = "release gate: generates and verifies a real RISC Zero receipt"] +fn runtime_packaged_backend_generates_real_fast_forward_receipt() { + assert_eq!( + std::env::var("ENCRYPTED_SPACES_REQUIRE_REAL_PROOF").as_deref(), + Ok("1"), + "real-proof release gate was not explicitly enabled" + ); + assert!(std::env::var_os("RISC0_DEV_MODE").is_none()); + assert!(std::env::var_os("RISC0_SKIP_BUILD").is_none()); + + let owner = "owner-real-proof-release"; + let member = "member-real-proof-release"; + let proof_timeout = Duration::from_secs(3600); + let mut scenario = Scenario::start("real-proof-release", &[owner, member]); + + let create_pending = scenario.send(owner, "space.create", json!({})); + let create = scenario.receive(owner, create_pending, proof_timeout); + let space_id = scenario.returned_string(create, "space_id", "missing-real-proof-space"); + let invite_pending = scenario.send(owner, "member.invite", json!({"space_id": space_id})); + let invite = scenario.receive(owner, invite_pending, proof_timeout); + let invite_value = scenario.returned_value(invite, "invite", json!({"missing": "invite"})); + let join_pending = scenario.send(member, "space.join", join_payload(invite_value)); + scenario.receive(member, join_pending, proof_timeout); + + let snapshot_pending = scenario.send(owner, "space.snapshot", json!({"space_id": space_id})); + let snapshot = scenario.receive(owner, snapshot_pending, proof_timeout); + let current_change_id = scenario.observations[snapshot].response["result"]["snapshot"]["state"] + ["current_change_id"] + .as_u64() + .expect("snapshot current_change_id"); + let changes_to_boundary = 5 - (current_change_id % 5); + let mut last_insert = None; + for offset in 0..changes_to_boundary { + let pending = scenario.send( + owner, + "table.insert", + json!({ + "space_id": space_id, + "table": TABLE, + "row": parent_row( + &format!("real-proof-boundary-{offset}"), + 900 + offset as i64, + ), + }), + ); + last_insert = Some(scenario.receive(owner, pending, proof_timeout)); + } + + let sync_pending = scenario.send(member, "space.sync", json!({"space_id": space_id})); + let sync = scenario.receive(member, sync_pending, proof_timeout); + scenario.verify::( + last_insert.expect("proof-boundary insert"), + |result| { + (result.row_id > 0) + .then_some(()) + .ok_or_else(|| "proof-boundary insert failed".to_owned()) + }, + ); + scenario.verify::(sync, |result| { + (result.space_id == space_id && result.synced) + .then_some(()) + .ok_or_else(|| "second client did not verify real fast-forward receipt".to_owned()) + }); + scenario.finish(); +} + #[test] fn runtime_sync_wait_wakes_and_runs_verified_recovery() { - let actor = "actor-sync-wait-parametric"; - let mut scenario = Scenario::start("sync-wait", &[actor]); - let create = scenario.request(actor, "space.create", json!({})); + let owner = "owner-sync-wait-parametric"; + let member = "member-sync-wait-parametric"; + let mut scenario = Scenario::start("sync-wait", &[owner, member]); + let create = scenario.request(owner, "space.create", json!({})); let space_id = scenario.returned_string(create, "space_id", "missing-wait-space"); + let invite = scenario.request(owner, "member.invite", json!({"space_id": space_id})); + let invite_value = scenario.returned_value(invite, "invite", json!({"missing": "invite"})); + scenario.request(member, "space.join", join_payload(invite_value)); + scenario.request(owner, "space.sync", json!({"space_id": space_id})); + + let pending = scenario.send( + owner, + "space.sync", + json!({"space_id": space_id, "wait_for_change_ms": 30_000}), + ); + let registration_barrier = scenario.request(owner, "version", json!({})); + scenario.request( + member, + "table.insert", + json!({ + "space_id": space_id, + "table": TABLE, + "row": parent_row("wake-on-real-change-parametric", 613), + }), + ); + let sync = scenario.receive(owner, pending, RESPONSE_TIMEOUT); + + scenario.verify::(registration_barrier, |result| { + (!result.version.is_empty() && result.protocol_version == PROTOCOL_VERSION) + .then_some(()) + .ok_or_else(|| "ordinary request blocked behind waiting sync".to_owned()) + }); + + scenario.verify::(sync, |result| { + (result.space_id == space_id + && result.synced + && result.wait_trigger.as_deref() == Some("change")) + .then_some(()) + .ok_or_else(|| "space.sync did not wake on the real remote change".to_owned()) + }); + scenario.finish(); +} + +#[test] +fn runtime_cancellation_interrupts_valid_wait_and_keeps_process_usable() { + let actor = "actor-cancel-wait-parametric"; + let mut scenario = Scenario::start("cancel-wait", &[actor]); + let create = scenario.request(actor, "space.create", json!({})); + let space_id = scenario.returned_string(create, "space_id", "missing-cancel-space"); + let waiting = scenario.send( + actor, + "space.sync", + json!({"space_id": space_id, "wait_for_change_ms": 30_000}), + ); + let waiting_request_id = waiting.request_id.clone(); + let cancel = scenario.send(actor, "cancel", json!({"request_id": waiting_request_id})); + let canceled = scenario.receive(actor, waiting, RESPONSE_TIMEOUT); + let cancel_ack = scenario.receive(actor, cancel, RESPONSE_TIMEOUT); + let version = scenario.request(actor, "version", json!({})); + + scenario.verify_error::(canceled, |error| { + (error.code == "CANCELED") + .then_some(()) + .ok_or_else(|| "valid waiting sync did not return CANCELED".to_owned()) + }); + scenario.verify::(cancel_ack, |result| { + result + .canceled + .then_some(()) + .ok_or_else(|| "cancel acknowledgment did not report cancellation".to_owned()) + }); + scenario.verify::(version, |result| { + (!result.version.is_empty() && result.protocol_version == PROTOCOL_VERSION) + .then_some(()) + .ok_or_else(|| "bridge was unusable after cancellation".to_owned()) + }); + scenario.finish(); +} + +#[test] +fn runtime_shutdown_cancels_pending_wait_before_exit() { + let actor = "actor-shutdown-wait"; + let mut scenario = Scenario::start("shutdown-wait", &[actor]); + let create = scenario.request(actor, "space.create", create_payload()); + let space_id = scenario.returned_string(create, "space_id", "missing-shutdown-space"); + let waiting = scenario.send( + actor, + "space.sync", + json!({"space_id": space_id, "wait_for_change_ms": 30_000}), + ); + let shutdown = scenario.send(actor, "shutdown", json!({})); + + let canceled = scenario.receive(actor, waiting, RESPONSE_TIMEOUT); + let shutdown_ack = scenario.receive(actor, shutdown, RESPONSE_TIMEOUT); + scenario.verify_error::(canceled, |error| { + (error.code == "CANCELED") + .then_some(()) + .ok_or_else(|| "shutdown did not cancel the pending wait".to_owned()) + }); + scenario.verify::(shutdown_ack, |result| { + result + .shutting_down + .then_some(()) + .ok_or_else(|| "shutdown was not acknowledged".to_owned()) + }); + + let mut bridge = scenario + .bridges + .remove(actor) + .expect("shutdown bridge process"); + let status = bridge.wait_for_exit(); + bridge.join_reader(); + bridge.remove_schema_fixture(); + assert!(status.success(), "bridge exited with {status}"); + scenario.finish(); +} + +#[test] +fn runtime_eof_cancels_pending_wait_before_exit() { + let actor = "actor-eof-wait"; + let mut scenario = Scenario::start("eof-wait", &[actor]); + let create = scenario.request(actor, "space.create", create_payload()); + let space_id = scenario.returned_string(create, "space_id", "missing-eof-space"); + let waiting = scenario.send( + actor, + "space.sync", + json!({"space_id": space_id, "wait_for_change_ms": 30_000}), + ); + scenario + .bridges + .get_mut(actor) + .expect("EOF bridge process") + .stdin + .take(); + + let canceled = scenario.receive(actor, waiting, RESPONSE_TIMEOUT); + scenario.verify_error::(canceled, |error| { + (error.code == "CANCELED") + .then_some(()) + .ok_or_else(|| "EOF did not cancel the pending wait".to_owned()) + }); + + let mut bridge = scenario.bridges.remove(actor).expect("EOF bridge process"); + let status = bridge.wait_for_exit(); + bridge.join_reader(); + bridge.remove_schema_fixture(); + assert!(status.success(), "bridge exited with {status}"); + scenario.finish(); +} + +#[test] +fn runtime_sync_wait_reports_timeout_without_remote_change() { + let actor = "actor-wait-timeout-parametric"; + let mut scenario = Scenario::start("wait-timeout", &[actor]); + let create = scenario.request(actor, "space.create", json!({})); + let space_id = scenario.returned_string(create, "space_id", "missing-timeout-space"); let sync = scenario.request( actor, "space.sync", - json!({"space_id": space_id, "wait_for_change_ms": 25}), + json!({"space_id": space_id, "wait_for_change_ms": 50}), ); scenario.verify::(sync, |result| { - (result.space_id == space_id && result.synced) + (result.space_id == space_id + && result.synced + && result.wait_trigger.as_deref() == Some("timeout")) + .then_some(()) + .ok_or_else(|| "waiting sync did not report timeout completion".to_owned()) + }); + scenario.finish(); +} + +#[test] +fn runtime_rejects_request_id_reuse_while_wait_is_pending() { + let actor = "actor-duplicate-id-parametric"; + let mut scenario = Scenario::start("duplicate-id", &[actor]); + let create = scenario.request(actor, "space.create", json!({})); + let space_id = scenario.returned_string(create, "space_id", "missing-duplicate-space"); + let waiting = scenario.send( + actor, + "space.sync", + json!({"space_id": space_id, "wait_for_change_ms": 30_000}), + ); + let waiting_request_id = waiting.request_id.clone(); + scenario.request(actor, "version", json!({})); + let duplicate = scenario + .bridges + .get_mut(actor) + .expect("actor bridge") + .send_with_request_id(waiting_request_id.clone(), "version", json!({})); + let duplicate = scenario.receive(actor, duplicate, RESPONSE_TIMEOUT); + let cancel = scenario.send(actor, "cancel", json!({"request_id": waiting_request_id})); + let canceled = scenario.receive(actor, waiting, RESPONSE_TIMEOUT); + let cancel_ack = scenario.receive(actor, cancel, RESPONSE_TIMEOUT); + + scenario.verify_error::(duplicate, |error| { + (error.code == "DUPLICATE_REQUEST_ID") .then_some(()) - .ok_or_else(|| "waited space.sync did not run verified recovery".to_owned()) + .ok_or_else(|| "pending request ID was reused".to_owned()) + }); + scenario.verify_error::(canceled, |error| { + (error.code == "CANCELED") + .then_some(()) + .ok_or_else(|| "pending wait was not canceled".to_owned()) + }); + scenario.verify::(cancel_ack, |result| { + result + .canceled + .then_some(()) + .ok_or_else(|| "cancel acknowledgment was false".to_owned()) + }); + scenario.finish(); +} + +#[test] +fn runtime_cancel_validation_cannot_reuse_a_pending_request_id() { + let actor = "actor-cancel-duplicate-parametric"; + let mut scenario = Scenario::start("cancel-duplicate", &[actor]); + let create = scenario.request(actor, "space.create", json!({})); + let space_id = scenario.returned_string(create, "space_id", "missing-cancel-space"); + let waiting = scenario.send( + actor, + "space.sync", + json!({"space_id": space_id, "wait_for_change_ms": 30_000}), + ); + let waiting_request_id = waiting.request_id.clone(); + scenario.request(actor, "version", json!({})); + + let malformed = scenario + .bridges + .get_mut(actor) + .expect("actor bridge") + .send_with_request_id(waiting_request_id.clone(), "cancel", json!({})); + let malformed = scenario.receive(actor, malformed, RESPONSE_TIMEOUT); + let self_targeting = scenario + .bridges + .get_mut(actor) + .expect("actor bridge") + .send_with_request_id( + waiting_request_id.clone(), + "cancel", + json!({"request_id": waiting_request_id}), + ); + let self_targeting = scenario.receive(actor, self_targeting, RESPONSE_TIMEOUT); + + let cancel = scenario.send(actor, "cancel", json!({"request_id": waiting_request_id})); + let canceled = scenario.receive(actor, waiting, RESPONSE_TIMEOUT); + let cancel_ack = scenario.receive(actor, cancel, RESPONSE_TIMEOUT); + + for duplicate in [malformed, self_targeting] { + scenario.verify_error::(duplicate, |error| { + (error.code == "DUPLICATE_REQUEST_ID") + .then_some(()) + .ok_or_else(|| "cancel validation reused a pending correlation ID".to_owned()) + }); + } + scenario.verify_error::(canceled, |error| { + (error.code == "CANCELED") + .then_some(()) + .ok_or_else(|| "pending wait was not canceled after duplicate probes".to_owned()) + }); + scenario.verify::(cancel_ack, |result| { + result + .canceled + .then_some(()) + .ok_or_else(|| "cancel acknowledgment was false".to_owned()) }); scenario.finish(); } @@ -964,7 +1435,7 @@ fn runtime_space_lifecycle_survives_restart_and_membership_changes() { scenario.verify::(hello, |result| { (result.protocol_version == PROTOCOL_VERSION - && result.actor_id == owner + && result.client_label == owner && valid_digest(&result.schema_sha256) && valid_digest(&result.data_commitment) && result.ff_guest_image_id.len() == 8) @@ -1199,6 +1670,22 @@ fn runtime_text_create_edit_read_round_trip() { "text.read", json!({"space_id": space_id, "text_ref": text_ref}), ); + let out_of_range = scenario.request( + actor, + "text.edit", + json!({ + "space_id": space_id, + "text_ref": text_ref, + "position": edited.chars().count() + 1, + "delete_count": 0, + "insert": "must-not-commit", + }), + ); + let read_after_rejection = scenario.request( + actor, + "text.read", + json!({"space_id": space_id, "text_ref": text_ref}), + ); let wrong_kind = scenario.request( actor, "list.read", @@ -1234,6 +1721,16 @@ fn runtime_text_create_edit_read_round_trip() { .then_some(()) .ok_or_else(|| "text.read did not return the edited textarea".to_owned()) }); + scenario.verify_error::(out_of_range, |error| { + (error.code == "INVALID_REQUEST") + .then_some(()) + .ok_or_else(|| "text.edit did not reject an out-of-range edit preflight".to_owned()) + }); + scenario.verify::(read_after_rejection, |result| { + (result.text == edited) + .then_some(()) + .ok_or_else(|| "rejected text.edit changed the document".to_owned()) + }); scenario.verify_error::(wrong_kind, |error| { (error.code == "INVALID_REQUEST") .then_some(()) @@ -1242,6 +1739,112 @@ fn runtime_text_create_edit_read_round_trip() { scenario.finish(); } +#[test] +fn runtime_opaque_refs_cannot_cross_space_boundaries() { + let actor = "actor-ref-space-boundary"; + let mut scenario = Scenario::start("ref-space-boundary", &[actor]); + let first_create = scenario.request(actor, "space.create", create_payload()); + let first_space = scenario.returned_string(first_create, "space_id", "missing-first-space"); + let first_insert = scenario.request( + actor, + "table.insert", + json!({ + "space_id": first_space, + "table": TABLE, + "row": parent_row("first-space-parent", 101), + }), + ); + let first_row_id = scenario.observations[first_insert].response["result"]["row_id"] + .as_i64() + .unwrap_or(-1); + let list_create = scenario.request( + actor, + "list.create", + json!({ + "space_id": first_space, + "table": TABLE, + "row_id": first_row_id, + "column": LIST_COLUMN, + }), + ); + let stale_list_ref = scenario.returned_value( + list_create, + "list_ref", + json!({"missing": "first-space-list-ref"}), + ); + let text_create = scenario.request( + actor, + "text.create", + json!({ + "space_id": first_space, + "table": TABLE, + "row_id": first_row_id, + "column": TEXT_COLUMN, + }), + ); + let stale_text_ref = scenario.returned_value( + text_create, + "text_ref", + json!({"missing": "first-space-text-ref"}), + ); + + let close = scenario.request(actor, "close", json!({})); + let second_create = scenario.request(actor, "space.create", create_payload()); + let second_space = scenario.returned_string(second_create, "space_id", "missing-second-space"); + let second_insert = scenario.request( + actor, + "table.insert", + json!({ + "space_id": second_space, + "table": TABLE, + "row": parent_row("second-space-parent", 202), + }), + ); + let stale_append = scenario.request( + actor, + "list.append", + json!({ + "space_id": second_space, + "list_ref": stale_list_ref, + "value": {"must": "not-cross-space"}, + }), + ); + let stale_edit = scenario.request( + actor, + "text.edit", + json!({ + "space_id": second_space, + "text_ref": stale_text_ref, + "position": 0, + "delete_count": 0, + "insert": "must-not-cross-space", + }), + ); + + scenario.verify::(close, |result| { + result + .closed + .then_some(()) + .ok_or_else(|| "close did not release the first Space".to_owned()) + }); + scenario.verify::(second_insert, |result| { + (result.row_id == first_row_id) + .then_some(()) + .ok_or_else(|| "test did not reproduce matching row coordinates".to_owned()) + }); + scenario.verify_error::(stale_append, |error| { + (error.code == "INVALID_STATE") + .then_some(()) + .ok_or_else(|| "stale list capability crossed the Space boundary".to_owned()) + }); + scenario.verify_error::(stale_edit, |error| { + (error.code == "INVALID_STATE") + .then_some(()) + .ok_or_else(|| "stale text capability crossed the Space boundary".to_owned()) + }); + scenario.finish(); +} + #[test] fn runtime_file_put_get_round_trip_encrypted_bytes() { let actor = "actor-file-parametric"; @@ -1440,42 +2043,102 @@ fn release_contract_builds_and_publishes_native_assets() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let workflow = fs::read_to_string(root.join(".github/workflows/release-bridge.yml")) .expect("release workflow"); + let publisher = fs::read_to_string(root.join(".github/workflows/release-publish.yml")) + .expect("trusted release publisher"); let patches = fs::read_to_string(root.join("PATCHES.md")).expect("PATCHES ledger"); let cargo = fs::read_to_string(root.join("Cargo.toml")).expect("workspace manifest"); let lock = fs::read_to_string(root.join("Cargo.lock")).expect("workspace lockfile"); let toolchain = fs::read_to_string(root.join("rust-toolchain.toml")).expect("pinned Rust toolchain"); + let bridge_manifest = + fs::read_to_string(root.join("bridge/Cargo.toml")).expect("bridge manifest"); + let ffproof_manifest = + fs::read_to_string(root.join("ffproof/Cargo.toml")).expect("ffproof manifest"); let bridge_main = fs::read_to_string(root.join("bridge/src/main.rs")).expect("bridge main"); let backend_config = fs::read_to_string(root.join("backend/server/src/app_config.rs")).expect("backend config"); - assert!(workflow.contains("workflow_dispatch:")); + assert!(!workflow.contains("workflow_dispatch:")); + assert!(!workflow.contains("pull_request:")); + assert!(workflow.contains("branches:")); + assert!(workflow.contains("- main")); assert!(workflow.contains("tags:")); assert!(workflow.contains("- 'v*'")); - assert!(workflow.contains(" publish:")); - assert!(workflow.contains("github.ref_type == 'tag'")); - assert!(!workflow.contains("RISC0_SKIP_BUILD")); + assert!(!workflow.contains(" publish:")); + assert!(!workflow.contains("attestations: write")); + assert!(!workflow.contains("id-token: write")); + assert!(!workflow.contains("contents: write")); + assert!(!workflow.contains("RISC0_SKIP_BUILD:")); assert_pinned_actions(&workflow); + assert_pinned_actions(&publisher); let legal = workflow.find(" legal:").expect("legal job"); let assets = workflow.find(" assets:").expect("asset matrix"); + let real_proof = workflow.find(" real-proof:").expect("real proof job"); let aggregate = workflow.find(" aggregate:").expect("aggregate job"); - let publish = workflow.find(" publish:").expect("publish job"); assert!( - legal < assets && assets < aggregate && aggregate < publish, + legal < assets && assets < real_proof && real_proof < aggregate, "release jobs have unexpected layout" ); + assert!(workflow[assets..real_proof].contains("needs: legal")); + + for marker in [ + "workflow_run:", + "- Encrypted Spaces Release", + "types: [completed]", + "github.event.workflow_run.conclusion == 'success'", + "github.event.workflow_run.event == 'push'", + "github.event.workflow_run.id", + "github.event.workflow_run.head_sha", + "run-id:", + "ref: main", + "git fetch origin main:refs/remotes/origin/main", + "git merge-base --is-ancestor \"$HEAD_SHA\" origin/main", + "refs/tags/v$RELEASE_VERSION", + "actions/attest@a1948c3f048ba23858d222213b7c278aabede763", + "attestations: write", + "id-token: write", + "bundle-path", + "release-manifest.json", + "actions/download-artifact@", + "actions/upload-artifact@", + "gh release create", + " aggregate:", + " publish:", + ] { + assert!(publisher.contains(marker), "publisher omits {marker}"); + } + assert!(!publisher.contains("workflow_dispatch:")); + assert_eq!( + publisher + .matches("git merge-base --is-ancestor \"$HEAD_SHA\" origin/main") + .count(), + 2, + "main ancestry must be checked before attestation and publication" + ); for marker in [ "RELEASE_READY: true", "RELEASE_VERSION: 0.1.0", "UPSTREAM_COMMIT: 4cda0ae87698135aa672990e6e68cf7873847426", "RUST_VERSION: 1.94.1", - "curl -L https://risczero.com/install | bash", - "rzup install", + "RZUP_VERSION: 0.5.1", + "RISC0_VERSION: 3.0.5", + "RISC0_RUST_VERSION: 1.94.1", + "cargo install rzup --version \"$RZUP_VERSION\" --locked", + "rzup install cargo-risczero \"$RISC0_VERSION\"", + "rzup install r0vm \"$RISC0_VERSION\"", + "rzup install rust \"$RISC0_RUST_VERSION\"", "cargo risczero --version", - "cargo test --locked -p", - "cargo build --locked --release -p", + "--features real-proofs", + "ENCRYPTED_SPACES_BRIDGE_TEST_BINARY", + "ENCRYPTED_SPACES_BACKEND_TEST_BINARY", + "cargo test -p encrypted-spaces-bridge --locked --features real-proofs runtime_", + "runtime_packaged_backend_generates_real_fast_forward_receipt", + "ENCRYPTED_SPACES_REQUIRE_REAL_PROOF: 1", + "ENCRYPTED_SPACES_REQUEST_TIMEOUT_MS: 3600000", + "command -v r0vm", + "env -u RISC0_DEV_MODE -u RISC0_SKIP_BUILD", "test -x", "cmp", "file -b", @@ -1483,17 +2146,16 @@ fn release_contract_builds_and_publishes_native_assets() { "tar -czf", "sha256sum", "shasum -a 256", - "release-manifest.json", - "https://in-toto.io/Statement/v1", - "https://slsa.dev/provenance/v1", + "GITHUB_SHA", "actions/upload-artifact@", "actions/download-artifact@", - "gh release create", - "needs: [legal, assets]", - "if: ${{ always() }}", + "name: release-backend-linux-amd64", + "name: release-bridge-linux-amd64", + "tar -xzf dist/encrypted-spaces-backend-linux-amd64.tar.gz", ] { assert!(workflow.contains(marker), "release workflow omits {marker}"); } + assert!(!workflow.contains("github.event_name != 'pull_request'")); for archive in RELEASE_ARCHIVES { assert!( workflow.contains(archive), @@ -1508,6 +2170,8 @@ fn release_contract_builds_and_publishes_native_assets() { assert!(nonempty(&root.join("NOTICE")), "NOTICE is absent or empty"); assert!(cargo.contains("version = \"0.1.0\"")); assert!(cargo.contains("kdl = { version = \"=6.5.0\"")); + assert!(bridge_manifest.contains("real-proofs = [\"encrypted-spaces-ffproof/real-proofs\"]")); + assert!(ffproof_manifest.contains("real-proofs = [\"risc0-zkvm/prove\"]")); assert!(lock.contains("name = \"kdl\"\nversion = \"6.5.0\"")); assert!(toolchain.contains("channel = \"1.94.1\"")); assert!(toolchain.contains("\"rustfmt\"")); @@ -1526,19 +2190,56 @@ fn upstream_sync_opens_ci_gated_automerge_prs() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let workflow = fs::read_to_string(root.join(".github/workflows/upstream-sync.yml")) .expect("upstream sync workflow"); + let compatibility = fs::read_to_string(root.join(".github/workflows/upstream-compat.yml")) + .expect("trusted upstream compatibility workflow"); assert!(workflow.contains("schedule:")); assert!(workflow.contains("cron:")); assert!(workflow.contains("workflow_dispatch:")); assert!(workflow.contains("contents: write")); assert!(workflow.contains("pull-requests: write")); + assert!(workflow.contains("actions: write")); assert!(workflow.contains("https://github.com/encrypted-spaces/prototype.git")); assert!(workflow.contains("git fetch upstream main")); assert!(workflow.contains("upstream/main")); assert!(workflow.contains("gh pr create")); - assert!(workflow.contains("gh pr merge")); - assert!(workflow.contains("--auto")); - assert!(workflow.contains("--squash")); + assert!(workflow.contains("git merge --no-edit upstream/main")); + assert!(workflow.contains("git merge --abort")); + assert!(workflow.contains("trigger_and_wait upstream-compat.yml")); + assert!(workflow.contains("--ref main")); + assert!(workflow.contains("candidate_sha")); + assert!(workflow.contains("candidate_sha=\"$(git rev-parse \"origin/$branch\")\"")); + assert!(workflow.contains("correlation_id")); + assert!(workflow.contains("--json databaseId,displayTitle,headSha")); + assert!(workflow.contains("gh pr view \"$PR_URL\"")); + assert!(!workflow.contains("trigger_and_wait build-prototype.yml")); + assert!(!workflow.contains("trigger_and_wait lint-prototype.yml")); + assert!(workflow.contains("gh run watch")); + assert!(workflow.contains("--force-with-lease=\"refs/heads/main:$FORK_MAIN_SHA\"")); + assert!(workflow.contains("$CANDIDATE_SHA:refs/heads/main")); + assert!(!workflow.contains("gh pr merge")); + assert!(!workflow.contains("--auto")); + assert!(!workflow.contains("--squash")); assert!(workflow.contains("remains open")); assert_pinned_actions(&workflow); + + assert!(compatibility.contains("workflow_dispatch:")); + assert!(compatibility.contains("candidate_sha:")); + assert!(compatibility.contains("correlation_id:")); + assert!(compatibility.contains("run-name:")); + assert!(compatibility.contains("inputs.correlation_id")); + assert!(compatibility.contains("permissions:\n contents: read")); + assert!(compatibility.contains("runs-on: ubuntu-24.04")); + assert!(compatibility.contains("ref: ${{ inputs.candidate_sha }}")); + assert!(compatibility.contains("fetch-depth: 0")); + assert!(compatibility.contains("git merge-base --is-ancestor \"$FORK_MAIN_SHA\" HEAD")); + assert!(compatibility.contains("git merge-base --is-ancestor \"$UPSTREAM_SHA\" HEAD")); + assert!(compatibility.contains("persist-credentials: false")); + assert!(!compatibility.contains("secrets.")); + assert!(compatibility.contains("cargo fmt --all -- --check")); + assert!( + compatibility.contains("cargo clippy --workspace --all-targets --locked -- -D warnings") + ); + assert!(compatibility.contains("cargo test --workspace --locked")); + assert_pinned_actions(&compatibility); } diff --git a/bridge/tests/stdio.rs b/bridge/tests/stdio.rs index eeec6ab..f443391 100644 --- a/bridge/tests/stdio.rs +++ b/bridge/tests/stdio.rs @@ -8,7 +8,7 @@ use std::time::{Duration, Instant}; const MAX_FRAME_BYTES: usize = 64 * 1024; const RESPONSE_TIMEOUT: Duration = Duration::from_secs(2); const EXIT_TIMEOUT: Duration = Duration::from_secs(2); -const DEFAULT_ACTOR_ID: &str = "configured-test-actor"; +const DEFAULT_CLIENT_LABEL: &str = "configured-test-client"; type StderrCapture = Arc, String>>>>; enum StdoutEvent { @@ -27,9 +27,9 @@ struct Bridge { } impl Bridge { - fn spawn(actor_id: &str) -> Self { + fn spawn(client_label: &str) -> Self { let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) - .env("ENCRYPTED_SPACES_ACTOR_ID", actor_id) + .env("ENCRYPTED_SPACES_CLIENT_LABEL", client_label) .env( "ENCRYPTED_SPACES_SCHEMA_PATH", concat!(env!("CARGO_MANIFEST_DIR"), "/../demos/tauri/app_schema.kdl"), @@ -202,26 +202,27 @@ fn assert_future_success(response: &Value, operation: &str) { } #[test] -fn protocol_hello_reports_process_configured_actor() { - for actor_id in ["configured-actor-one", "configured-actor-two"] { - let mut bridge = Bridge::spawn(actor_id); +fn protocol_hello_reports_process_configured_client_label() { + for client_label in ["configured-client-one", "configured-client-two"] { + let mut bridge = Bridge::spawn(client_label); bridge.send_request("hello-request", "hello", json!({})); let response = bridge.receive(); assert_future_success(&response, "hello"); - assert_eq!(response["result"]["actor_id"], actor_id); + assert_eq!(response["result"]["client_label"], client_label); + assert!(response["result"].get("actor_id").is_none()); } } #[test] fn protocol_version_reports_bridge_contract() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.send_request("version-request", "version", json!({})); assert_future_success(&bridge.receive(), "version"); } #[test] fn protocol_request_supplied_actor_id_is_rejected() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); let frame = serde_json::to_string(&json!({ "version": 1, "request_id": "actor-in-frame", @@ -242,7 +243,7 @@ fn protocol_request_supplied_actor_id_is_rejected() { #[test] fn protocol_bare_space_lifecycle_names_are_unknown() { for operation in ["create", "join", "snapshot", "restore", "sync"] { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.send_request("bare-operation", operation, json!({})); let response = bridge.receive(); assert_eq!( @@ -255,7 +256,7 @@ fn protocol_bare_space_lifecycle_names_are_unknown() { #[test] fn protocol_malformed_frame_has_stable_error() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.send_raw(b"not-json\n"); let response = bridge.receive(); assert_eq!(response["ok"], false); @@ -265,7 +266,7 @@ fn protocol_malformed_frame_has_stable_error() { #[test] fn protocol_accepts_a_frame_at_the_exact_limit() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.send_raw(exact_size_request(MAX_FRAME_BYTES).as_bytes()); let response = bridge.receive(); assert_eq!(response["request_id"], "exact-max"); @@ -274,7 +275,7 @@ fn protocol_accepts_a_frame_at_the_exact_limit() { #[test] fn protocol_oversize_error_is_prompt_while_stdin_remains_open() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.send_raw(&vec![b'x'; MAX_FRAME_BYTES + 1]); let response = bridge.receive(); @@ -288,7 +289,7 @@ fn protocol_oversize_error_is_prompt_while_stdin_remains_open() { #[test] fn protocol_process_terminates_after_oversize_without_draining_stdin() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.send_raw(&vec![b'x'; MAX_FRAME_BYTES + 1]); assert_eq!(bridge.receive()["error"]["code"], "FRAME_TOO_LARGE"); @@ -298,7 +299,7 @@ fn protocol_process_terminates_after_oversize_without_draining_stdin() { #[test] fn protocol_recovers_in_malformed_invalid_valid_order() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.send_raw(b"not-json\n"); let invalid = serde_json::to_string(&json!({ "version": 2, @@ -323,7 +324,7 @@ fn protocol_recovers_in_malformed_invalid_valid_order() { #[test] fn protocol_validation_error_preserves_request_id() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); let frame = serde_json::to_string(&json!({ "version": 2, "request_id": "unsupported-version", @@ -341,7 +342,7 @@ fn protocol_validation_error_preserves_request_id() { #[test] fn protocol_invalid_oversized_request_id_is_not_reflected() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); let oversized_request_id = "x".repeat(257); let frame = serde_json::to_string(&json!({ "version": 1, @@ -360,7 +361,7 @@ fn protocol_invalid_oversized_request_id_is_not_reflected() { #[test] fn protocol_unknown_operation_is_distinct_from_malformed_json() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.send_request("unknown-request", "space.unknown", json!({})); bridge.send_raw(b"{not-json}\n"); @@ -372,39 +373,29 @@ fn protocol_unknown_operation_is_distinct_from_malformed_json() { } #[test] -fn protocol_cancellation_interrupts_sync_and_keeps_process_usable() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); - bridge.send_request("hello-before-cancel", "hello", json!({})); +fn protocol_wait_without_active_space_is_rejected_and_process_stays_usable() { + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); + bridge.send_request("hello-before-invalid-wait", "hello", json!({})); assert_future_success(&bridge.receive(), "hello"); bridge.send_request( - "sync-to-cancel", + "invalid-wait", "space.sync", json!({"space_id": "cancel-contract-space", "wait_for_change_ms": 30_000}), ); - bridge.send_request( - "cancel-request", - "cancel", - json!({"request_id": "sync-to-cancel"}), - ); - - let canceled = bridge.receive(); - assert_eq!(canceled["request_id"], "sync-to-cancel"); - assert_eq!(canceled["ok"], false); - assert_eq!(canceled["error"]["code"], "CANCELED"); - - let cancel_ack = bridge.receive(); - assert_eq!(cancel_ack["request_id"], "cancel-request"); - assert_eq!(cancel_ack["ok"], true); + let rejected = bridge.receive(); + assert_eq!(rejected["request_id"], "invalid-wait"); + assert_eq!(rejected["ok"], false); + assert_eq!(rejected["error"]["code"], "INVALID_STATE"); - bridge.send_request("version-after-cancel", "version", json!({})); - assert_future_success(&bridge.receive(), "version after cancel"); + bridge.send_request("version-after-invalid-wait", "version", json!({})); + assert_future_success(&bridge.receive(), "version after invalid wait"); } #[test] fn protocol_validation_errors_redact_secret_material() { let secret = "request-secret-value"; - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); let frame = serde_json::to_string(&json!({ "version": 2, "request_id": "secret-validation", @@ -430,7 +421,7 @@ fn protocol_validation_errors_redact_secret_material() { #[test] fn protocol_runtime_sdk_errors_are_deterministic_and_redacted() { let secrets = ["runtime-secret-alpha", "runtime-secret-beta"]; - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); for (index, secret) in secrets.iter().enumerate() { bridge.send_request( @@ -467,7 +458,7 @@ fn protocol_runtime_sdk_errors_are_deterministic_and_redacted() { #[test] fn protocol_close_clears_state_and_keeps_process_usable() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.send_request("close-request", "close", json!({})); let close = bridge.receive(); assert_future_success(&close, "close"); @@ -479,7 +470,7 @@ fn protocol_close_clears_state_and_keeps_process_usable() { #[test] fn protocol_shutdown_acknowledges_and_exits() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.send_request("shutdown-request", "shutdown", json!({})); let shutdown = bridge.receive(); assert_future_success(&shutdown, "shutdown"); @@ -490,7 +481,7 @@ fn protocol_shutdown_acknowledges_and_exits() { #[test] fn protocol_process_exits_after_stdin_eof() { - let mut bridge = Bridge::spawn(DEFAULT_ACTOR_ID); + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); bridge.close_stdin(); let status = bridge.wait_for_exit(); assert!(status.success(), "bridge exited with {status}"); diff --git a/ffproof/Cargo.toml b/ffproof/Cargo.toml index 5236edf..da87379 100644 --- a/ffproof/Cargo.toml +++ b/ffproof/Cargo.toml @@ -62,4 +62,4 @@ verify = [] # `client_methods` crates which trigger the RISC-V guest binary build. prove = ["verify", "dep:encrypted-spaces-ffproof-methods", "dep:encrypted-spaces-client-methods"] cuda = ["risc0-zkvm/cuda"] -real-proofs = [] +real-proofs = ["risc0-zkvm/prove"] diff --git a/sdk/src/broadcast.rs b/sdk/src/broadcast.rs index a54671d..1cc26d2 100644 --- a/sdk/src/broadcast.rs +++ b/sdk/src/broadcast.rs @@ -46,9 +46,11 @@ pub(crate) fn start_listener(space: &Space) { serialize_mutations: Arc::clone(&serialize_mutations), ff_in_progress: Arc::clone(&ff_in_progress), }; - space.handle_broadcast(evt.clone()).await; + let applied = space.handle_broadcast(evt.clone()).await; drop(space); - let _ = updates_tx.send(evt); + if applied { + let _ = updates_tx.send(evt); + } } Err(RecvError::Closed) => break, Err(RecvError::Lagged(n)) => log::warn!("broadcast listener lagged by {n}"), @@ -61,7 +63,7 @@ impl Space { /// Drive a broadcast event through the SDK pipeline: delegate the /// changelog-side apply to [`Space::apply_broadcast_change`], then /// update caches and run the post-apply delivery-slot recovery. - pub(crate) async fn handle_broadcast(&self, evt: BroadcastEvent) { + pub(crate) async fn handle_broadcast(&self, evt: BroadcastEvent) -> bool { let BroadcastEvent { change, change_response, @@ -74,7 +76,7 @@ impl Space { let needs_slot_check = crate::SpaceKeyManager::op_may_need_delivery(op_type); match self.apply_broadcast_change(change, change_response).await { - BroadcastApplyOutcome::Skipped => return, + BroadcastApplyOutcome::Skipped => return false, BroadcastApplyOutcome::Applied { change, writes } => { self.apply_broadcast_cache_updates(&change, &writes).await; } @@ -93,6 +95,7 @@ impl Space { e ); } + true } pub(crate) async fn apply_broadcast_cache_updates( diff --git a/sdk/src/changelog.rs b/sdk/src/changelog.rs index b54963e..9abf6a2 100644 --- a/sdk/src/changelog.rs +++ b/sdk/src/changelog.rs @@ -1284,15 +1284,22 @@ impl Space { "[SDK] handle_broadcast: FastForwardRequired reason={}", reason ); - let _ = self.recover_via_fast_forward().await; - let (cid, clc) = self + let recovery = self.recover_via_fast_forward().await; + let (recovered_cid, clc) = self .with_state(|state| (state.current_change_id, state.current_clc_state.root)); log::debug!( "[SDK] handle_broadcast: after FF recovery change_id={} clc={}", - cid, + recovered_cid, hex::encode::<[u8; 32]>(clc.into()) ); - BroadcastApplyOutcome::Skipped + match recovery { + Ok(()) if recovered_cid > cid => BroadcastApplyOutcome::AppliedCacheInvalidated, + Ok(()) => BroadcastApplyOutcome::Skipped, + Err(error) => { + log::warn!("[SDK] handle_broadcast: FF recovery failed: {error}"); + BroadcastApplyOutcome::Skipped + } + } } Err(ref e) => { log::warn!("[SDK] handle_broadcast: error={}", e); @@ -4771,7 +4778,7 @@ mod issue212_completion_tests { use std::any::Any; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; #[derive(Debug, Serialize, Deserialize)] struct Note { @@ -4789,13 +4796,18 @@ mod issue212_completion_tests { struct AckSkewTransport { inner: LocalTransport, skew_armed: Arc, + last_submission: Arc>>, + broadcast_tx: tokio::sync::broadcast::Sender, } impl AckSkewTransport { fn new(inner: LocalTransport) -> Self { + let (broadcast_tx, _) = tokio::sync::broadcast::channel(16); Self { inner, skew_armed: Arc::new(AtomicBool::new(false)), + last_submission: Arc::new(Mutex::new(None)), + broadcast_tx, } } @@ -4803,6 +4815,23 @@ mod issue212_completion_tests { fn arm(&self) { self.skew_armed.store(true, Ordering::SeqCst); } + + fn take_last_submission(&self) -> (Change, ChangeResponse) { + self.last_submission + .lock() + .expect("last submission lock") + .take() + .expect("captured submission") + } + + fn broadcast(&self, change: Change, change_response: ChangeResponse) { + self.broadcast_tx + .send(crate::websocket_transport::BroadcastEvent { + change, + change_response, + }) + .expect("SDK listener is subscribed"); + } } #[async_trait] @@ -4813,6 +4842,8 @@ mod issue212_completion_tests { retention_proofs: Vec>, ) -> Result { let mut resp = self.inner.submit_change(change, retention_proofs).await?; + *self.last_submission.lock().expect("last submission lock") = + Some((change.clone(), resp.clone())); if self.skew_armed.swap(false, Ordering::SeqCst) { // Claim a later position than the one actually assigned, so a // fast-forward cannot prove the exact entry at `resp.change_id`. @@ -4895,7 +4926,7 @@ mod issue212_completion_tests { } fn subscribe_broadcasts(&self) -> Result { - self.inner.subscribe_broadcasts() + Ok(self.broadcast_tx.subscribe()) } async fn file_upload(&self, hash: &str, data: Vec) -> Result<()> { @@ -4947,6 +4978,31 @@ mod issue212_completion_tests { (transport, space) } + #[tokio::test] + async fn stale_broadcast_is_not_published_by_the_sdk_listener() { + let (transport, space) = make_space().await; + let mut updates = space.subscribe_updates(); + space + .table::("notes") + .insert(&Note { + id: None, + title: "already applied".to_owned(), + }) + .execute() + .await + .expect("insert note"); + let (change, change_response) = transport.take_last_submission(); + + transport.broadcast(change, change_response); + + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), updates.recv()) + .await + .is_err(), + "stale broadcast was published as a fresh SDK update" + ); + } + fn assert_failed_closed(res: Result) { let err = res.expect_err("operation must fail closed when its entry cannot be proven"); match err { diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index 87b5f32..a51ac33 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -510,6 +510,7 @@ mod tests { use crate::local_transport::LocalTransport; use crate::schema::Schema; use crate::users::UserStatus; + use encrypted_spaces_acl_types::{Action, ActionLeg}; use encrypted_spaces_backend::error::Result; use encrypted_spaces_backend::schema_kdl::parse_schema_bundle; use encrypted_spaces_backend::sign_change::sign_change; @@ -561,6 +562,94 @@ mod tests { Ok((transport, space)) } + #[tokio::test] + async fn trusted_restore_rejects_snapshot_with_different_trust_bundle() -> Result<()> { + let (transport, space) = create_space().await?; + let snapshot = space.snapshot().await?; + let initial_dc = crate::testing::initial_internal_data_commitment(); + let foreign_schema = SchemaBuilder::new("foreign") + .column("id", ColumnType::Integer) + .plaintext_primary_key() + .build()?; + let mismatches = [ + ApplicationSchema::WithDataCommitment(vec![], [0xA5; 32], EXTEND_FF_ID), + ApplicationSchema::WithDataCommitment(vec![], initial_dc, [0x5A5A5A5A; 8]), + ApplicationSchema::WithDataCommitment(vec![foreign_schema], initial_dc, EXTEND_FF_ID), + ]; + + for mismatched in mismatches { + let result = + Space::restore_trusted(transport.clone(), snapshot.clone(), mismatched).await; + let error = match result { + Ok(_) => panic!("trusted restore accepted a foreign trust bundle"), + Err(error) => error, + }; + assert!(error.to_string().contains("snapshot trust bundle mismatch")); + } + + let mut action_snapshot = snapshot.clone(); + let foreign_action = Action { + name: "foreign_action".to_owned(), + legs: vec![ActionLeg::Insert { + table: "foreign".to_owned(), + }], + asserts: Vec::new(), + }; + action_snapshot["state"]["actions"] = serde_json::json!({ + foreign_action.name.clone(): foreign_action, + }); + let action_error = match Space::restore_trusted( + transport.clone(), + action_snapshot, + ApplicationSchema::WithDataCommitment(vec![], initial_dc, EXTEND_FF_ID), + ) + .await + { + Ok(_) => panic!("trusted restore accepted snapshot-defined actions"), + Err(error) => error, + }; + assert!(action_error + .to_string() + .contains("snapshot trust bundle mismatch")); + + let mut mismatched_space_id = snapshot.clone(); + mismatched_space_id["state"]["auth_context"]["space_id"] = + serde_json::to_value(encrypted_spaces_backend::SpaceId::random())?; + let id_error = match Space::restore_trusted( + transport.clone(), + mismatched_space_id, + ApplicationSchema::WithDataCommitment(vec![], initial_dc, EXTEND_FF_ID), + ) + .await + { + Ok(_) => panic!("trusted restore accepted mismatched snapshot Space IDs"), + Err(error) => error, + }; + assert!(id_error + .to_string() + .contains("snapshot trust bundle mismatch")); + + let mut missing_space_id = snapshot; + missing_space_id + .as_object_mut() + .expect("snapshot object") + .remove("space_id"); + let missing_id_error = match Space::restore_trusted( + transport, + missing_space_id, + ApplicationSchema::WithDataCommitment(vec![], initial_dc, EXTEND_FF_ID), + ) + .await + { + Ok(_) => panic!("trusted restore accepted a snapshot without a Space ID"), + Err(error) => error, + }; + assert!(missing_id_error + .to_string() + .contains("snapshot trust bundle mismatch")); + Ok(()) + } + fn changelog_anchor_bytes(space: &Space) -> Option> { space.with_state(|state| { state diff --git a/sdk/src/state.rs b/sdk/src/state.rs index 050f0b2..a122138 100644 --- a/sdk/src/state.rs +++ b/sdk/src/state.rs @@ -239,9 +239,48 @@ impl Space { /// rides along inside the snapshot, so callers don't need to /// re-supply it on restore. pub async fn restore(transport: impl Transport, snapshot: serde_json::Value) -> Result { - let snapshot: SpaceSnapshot = serde_json::from_value(snapshot) - .map_err(|e| SdkError::SerializationError(e.to_string()))?; + let snapshot = decode_snapshot(snapshot)?; + + Self::restore_decoded(transport, snapshot).await + } + + /// Restore a snapshot only when its cryptographic and application trust + /// bundle matches the caller's expected schema configuration. + pub async fn restore_trusted( + transport: impl Transport, + snapshot: serde_json::Value, + expected: crate::ApplicationSchema, + ) -> Result { + let snapshot = decode_snapshot(snapshot)?; + let (initial_dc, table_schemas, actions, ff_image_id) = expected.into_parts().await?; + let snapshot_application_schemas: HashMap<_, _> = snapshot + .state + .table_schemas + .iter() + .filter(|(name, _)| { + !encrypted_spaces_backend::internal_schemas::is_reserved_table_name(name) + }) + .map(|(name, schema)| (name.clone(), schema.clone())) + .collect(); + let space_ids_match = snapshot + .space_id + .as_ref() + .is_some_and(|space_id| *space_id == snapshot.state.auth_context.space_id); + if !space_ids_match + || snapshot.state.initial_dc != initial_dc + || snapshot.state.ff_image_id != ff_image_id + || snapshot_application_schemas != table_schemas + || snapshot.state.actions != actions + { + return Err(SdkError::ValidationError( + "snapshot trust bundle mismatch".to_owned(), + )); + } + Self::restore_decoded(transport, snapshot).await + } + + async fn restore_decoded(transport: impl Transport, snapshot: SpaceSnapshot) -> Result { let space_id = snapshot.space_id.unwrap_or_else(SpaceId::random); let key_manager = snapshot.key_manager.ok_or_else(|| { @@ -268,6 +307,10 @@ impl Space { } } +fn decode_snapshot(snapshot: serde_json::Value) -> Result { + serde_json::from_value(snapshot).map_err(|e| SdkError::SerializationError(e.to_string())) +} + #[cfg(test)] mod tests { use super::*; diff --git a/sdk/src/websocket_transport.rs b/sdk/src/websocket_transport.rs index 93d4e63..d62d794 100644 --- a/sdk/src/websocket_transport.rs +++ b/sdk/src/websocket_transport.rs @@ -18,6 +18,10 @@ use encrypted_spaces_changelog_core::changelog::{ use encrypted_spaces_key_manager::{InviteRequest, RekeyRequest}; use prost::Message; pub(crate) const DEBUG: bool = true; +#[cfg(not(target_arch = "wasm32"))] +const DEFAULT_NATIVE_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +#[cfg(not(target_arch = "wasm32"))] +const MAX_HTTP_BODY_BYTES: usize = 64 * 1024 * 1024; // Consolidated logging macros (wasm + native) macro_rules! log_debug { @@ -50,6 +54,114 @@ enum PendingResponse { Db(Result), } +#[cfg(not(target_arch = "wasm32"))] +type NativePendingMap = std::sync::Arc< + std::sync::Mutex< + std::collections::HashMap>, + >, +>; + +#[cfg(not(target_arch = "wasm32"))] +struct PendingRequestGuard { + request_id: String, + pending: NativePendingMap, +} + +#[cfg(not(target_arch = "wasm32"))] +impl PendingRequestGuard { + fn new(request_id: String, pending: NativePendingMap) -> Self { + Self { + request_id, + pending, + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for PendingRequestGuard { + fn drop(&mut self) { + if let Ok(mut pending) = self.pending.lock() { + pending.remove(&self.request_id); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +async fn with_request_timeout( + request_id: &str, + timeout: std::time::Duration, + future: F, +) -> Result +where + F: std::future::Future>, +{ + match tokio::time::timeout(timeout, future).await { + Ok(result) => result, + Err(_) => Err(SdkError::DatabaseError(format!( + "request {request_id} timed out" + ))), + } +} + +#[cfg(not(target_arch = "wasm32"))] +async fn with_db_request_timeout( + request_id: &str, + timeout: std::time::Duration, + may_commit: bool, + transmission_started: std::sync::Arc, + future: F, +) -> Result +where + F: std::future::Future>, +{ + match tokio::time::timeout(timeout, future).await { + Ok(result) => result, + Err(_) if may_commit && transmission_started.load(std::sync::atomic::Ordering::Acquire) => { + Err(SdkError::CommitOutcomeUnknown(format!( + "request {request_id} timed out after transmission began" + ))) + } + Err(_) => Err(SdkError::DatabaseError(format!( + "request {request_id} timed out" + ))), + } +} + +#[cfg(not(target_arch = "wasm32"))] +async fn collect_bounded_body(mut body: hyper::Body, limit: usize) -> Result> { + use hyper::body::HttpBody; + + let mut bytes = Vec::new(); + while let Some(chunk) = body.data().await { + let chunk = + chunk.map_err(|error| SdkError::DatabaseError(format!("response body: {error}")))?; + let new_len = bytes + .len() + .checked_add(chunk.len()) + .ok_or_else(|| SdkError::DatabaseError("response body is too large".to_owned()))?; + if new_len > limit { + return Err(SdkError::DatabaseError(format!( + "response body exceeds {limit} bytes" + ))); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +#[cfg(not(target_arch = "wasm32"))] +fn fail_pending_requests(pending: &NativePendingMap, message: &str) { + let requests = pending + .lock() + .map(|mut pending| pending.drain().collect::>()) + .unwrap_or_default(); + for (_, sender) in requests { + let _ = sender.send(PendingResponse::Db(Err(SdkError::DatabaseError( + message.to_owned(), + )))); + } +} + pub struct WebSocketTransport { // Write half of the WebSocket (binary frames) guarded for sequential writes write: tokio::sync::Mutex< @@ -61,11 +173,8 @@ pub struct WebSocketTransport { >, >, // Pending request_id -> oneshot sender awaiting the matching DbResponse - pending: std::sync::Arc< - tokio::sync::Mutex< - std::collections::HashMap>, - >, - >, + #[cfg(not(target_arch = "wasm32"))] + pending: NativePendingMap, // Broadcast event fan-out (multi-subscriber) bcast_tx: tokio::sync::broadcast::Sender, // Ephemeral message fan-out (multi-subscriber) @@ -89,6 +198,8 @@ pub struct WebSocketTransport { // (OS trust store only), which matches the pre-trust-anchor behavior. #[cfg(not(target_arch = "wasm32"))] ws_tls_connector: Option, + #[cfg(not(target_arch = "wasm32"))] + request_timeout: std::time::Duration, #[cfg(target_arch = "wasm32")] ws: RefCell>, #[cfg(target_arch = "wasm32")] @@ -115,7 +226,15 @@ impl WebSocketTransport { /// Construct a transport that uses only the OS trust store for TLS. /// Equivalent to [`Self::new_with_trust_connector`] called with `None`. pub async fn new(url: &str) -> Result { - Self::new_with_trust_connector(url, None).await + Self::new_with_options(url, None, DEFAULT_NATIVE_REQUEST_TIMEOUT).await + } + + /// Construct a transport with a caller-selected bounded request deadline. + pub async fn new_with_request_timeout( + url: &str, + request_timeout: std::time::Duration, + ) -> Result { + Self::new_with_options(url, None, request_timeout).await } /// Construct a transport that honors `connector` (if `Some`) on every @@ -139,11 +258,20 @@ impl WebSocketTransport { url: &str, ws_tls_connector: Option, ) -> Result { - let pending: std::sync::Arc< - tokio::sync::Mutex< - std::collections::HashMap>, - >, - > = std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); + Self::new_with_options(url, ws_tls_connector, DEFAULT_NATIVE_REQUEST_TIMEOUT).await + } + + async fn new_with_options( + url: &str, + ws_tls_connector: Option, + request_timeout: std::time::Duration, + ) -> Result { + if request_timeout.is_zero() { + return Err(SdkError::ValidationError( + "request timeout must be greater than zero".to_owned(), + )); + } + let pending = std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); let (bcast_tx, _) = tokio::sync::broadcast::channel::(64); let (ephemeral_tx, _) = tokio::sync::broadcast::channel::(64); @@ -175,6 +303,7 @@ impl WebSocketTransport { auth_b64: tokio::sync::Mutex::new(None), file_client, ws_tls_connector, + request_timeout, }) } @@ -196,13 +325,18 @@ impl WebSocketTransport { self.url, separator, auth_context.space_id, auth_b64 ); - let (stream, _resp) = match self.ws_tls_connector.as_ref() { - Some(connector) => { - connect_async_with_tls_connector(&connect_url, Some(connector.clone())).await + let connect = async { + match self.ws_tls_connector.as_ref() { + Some(connector) => { + connect_async_with_tls_connector(&connect_url, Some(connector.clone())).await + } + None => connect_async(&connect_url).await, } - None => connect_async(&connect_url).await, - } - .map_err(|e| SdkError::DatabaseError(format!("connect ws failed: {e}")))?; + }; + let (stream, _resp) = tokio::time::timeout(self.request_timeout, connect) + .await + .map_err(|_| SdkError::DatabaseError("connect ws timed out".to_owned()))? + .map_err(|e| SdkError::DatabaseError(format!("connect ws failed: {e}")))?; let (write, mut read) = stream.split(); @@ -219,7 +353,10 @@ impl WebSocketTransport { Ok(frame) => match frame.payload { Some(ws_frame::Payload::DbResponse(resp)) => { let req_id = resp.request_id.clone(); - let tx_opt = { pending_clone.lock().await.remove(&req_id) }; + let tx_opt = pending_clone + .lock() + .ok() + .and_then(|mut pending| pending.remove(&req_id)); if let Some(tx) = tx_opt { let result = if resp.status == "ok" { Ok(resp) @@ -278,12 +415,6 @@ impl WebSocketTransport { }, Ok(Message::Close(cf)) => { log_debug!("read_loop: connection closed: {:?}", cf); - let mut map = pending_clone.lock().await; - for (_id, tx) in map.drain() { - let _ = tx.send(PendingResponse::Db(Err(SdkError::DatabaseError( - "connection closed".into(), - )))); - } break; } Ok(_other) => { @@ -291,16 +422,11 @@ impl WebSocketTransport { } Err(e) => { log_debug!("read_loop: read error err={}", e); - let mut map = pending_clone.lock().await; - for (_id, tx) in map.drain() { - let _ = tx.send(PendingResponse::Db(Err(SdkError::DatabaseError( - format!("read error: {e}"), - )))); - } break; } } } + fail_pending_requests(&pending_clone, "connection closed"); log_debug!("read_loop: terminated"); }); @@ -311,6 +437,32 @@ impl WebSocketTransport { } async fn send_request(&self, req: DbRequest) -> Result { + let request_id = req.request_id.clone(); + let may_commit = matches!( + req.operation, + Some( + db_request::Operation::Change(_) + | db_request::Operation::AddMember(_) + | db_request::Operation::RemoveMember(_) + | db_request::Operation::Retention(_) + ) + ); + let transmission_started = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + with_db_request_timeout( + &request_id, + self.request_timeout, + may_commit, + std::sync::Arc::clone(&transmission_started), + self.send_request_inner(req, transmission_started), + ) + .await + } + + async fn send_request_inner( + &self, + req: DbRequest, + transmission_started: std::sync::Arc, + ) -> Result { use async_tungstenite::tungstenite::Message; use futures_util::SinkExt; use tokio::sync::oneshot; @@ -329,24 +481,29 @@ impl WebSocketTransport { // Prepare oneshot before sending let (tx, rx) = oneshot::channel::(); { - let mut map = self.pending.lock().await; - if map.insert(request_id.clone(), tx).is_some() { - log_debug!( - "native send_request: replaced existing pending id={}", - request_id - ); + let mut pending = self + .pending + .lock() + .map_err(|_| SdkError::DatabaseError("pending request lock poisoned".into()))?; + if pending.contains_key(&request_id) { + return Err(SdkError::ValidationError(format!( + "duplicate request id {request_id}" + ))); } + pending.insert(request_id.clone(), tx); } + let _pending_guard = + PendingRequestGuard::new(request_id.clone(), std::sync::Arc::clone(&self.pending)); // Send frame let mut guard = self.write.lock().await; - let writer = guard.as_mut().ok_or_else(|| { - SdkError::DatabaseError("not connected — call authenticate() first".into()) - })?; + let Some(writer) = guard.as_mut() else { + return Err(SdkError::DatabaseError( + "not connected — call authenticate() first".into(), + )); + }; + transmission_started.store(true, std::sync::atomic::Ordering::Release); if let Err(e) = writer.send(Message::Binary(encoded)).await { - drop(guard); - let mut map = self.pending.lock().await; - map.remove(&request_id); return Err(SdkError::DatabaseError(format!("send failed: {e}"))); } drop(guard); @@ -874,15 +1031,19 @@ impl Transport for WebSocketTransport { task.abort(); } { - let mut guard = self.write.lock().await; + let mut guard = tokio::time::timeout(self.request_timeout, self.write.lock()) + .await + .map_err(|_| SdkError::DatabaseError("websocket close timed out".to_owned()))?; if let Some(writer) = guard.as_mut() { // Send a proper close frame so the server sees a clean close // instead of "Connection reset without closing handshake". - let _ = writer.send(Message::Close(None)).await; + let _ = + tokio::time::timeout(self.request_timeout, writer.send(Message::Close(None))) + .await; } *guard = None; } - self.pending.lock().await.clear(); + fail_pending_requests(&self.pending, "connection reauthenticated"); // Store the base64url-encoded auth context for file HTTP requests let auth_json = serde_json::to_vec(auth_context).map_err(|e| { SdkError::ValidationError(format!("failed to serialize auth context: {e}")) @@ -913,15 +1074,18 @@ impl Transport for WebSocketTransport { }; let encoded = frame.encode_to_vec(); - let mut guard = self.write.lock().await; - let writer = guard.as_mut().ok_or_else(|| { - SdkError::DatabaseError("not connected — call authenticate() first".into()) - })?; - writer - .send(Message::Binary(encoded)) - .await - .map_err(|e| SdkError::DatabaseError(format!("send ephemeral failed: {e}")))?; - Ok(()) + with_request_timeout("ephemeral send", self.request_timeout, async { + let mut guard = self.write.lock().await; + let writer = guard.as_mut().ok_or_else(|| { + SdkError::DatabaseError("not connected — call authenticate() first".into()) + })?; + writer + .send(Message::Binary(encoded)) + .await + .map_err(|e| SdkError::DatabaseError(format!("send ephemeral failed: {e}")))?; + Ok(()) + }) + .await } async fn file_upload(&self, hash: &str, data: Vec) -> Result<()> { @@ -940,23 +1104,24 @@ impl Transport for WebSocketTransport { .body(hyper::Body::from(data)) .map_err(|e| SdkError::DatabaseError(format!("file upload request build: {e}")))?; - let resp = self - .file_client - .request(req) - .await - .map_err(|e| SdkError::DatabaseError(format!("file upload failed: {e}")))?; - - if resp.status().is_success() { - Ok(()) - } else { - let body = hyper::body::to_bytes(resp.into_body()) + with_request_timeout("file upload", self.request_timeout, async { + let resp = self + .file_client + .request(req) .await - .map(|b| String::from_utf8_lossy(&b).to_string()) - .unwrap_or_default(); - Err(SdkError::DatabaseError(format!( - "file upload failed: {body}" - ))) - } + .map_err(|e| SdkError::DatabaseError(format!("file upload failed: {e}")))?; + + if resp.status().is_success() { + Ok(()) + } else { + let body = collect_bounded_body(resp.into_body(), MAX_HTTP_BODY_BYTES).await?; + Err(SdkError::DatabaseError(format!( + "file upload failed: {}", + String::from_utf8_lossy(&body) + ))) + } + }) + .await } async fn file_download(&self, hash: &str) -> Result> { @@ -971,20 +1136,20 @@ impl Transport for WebSocketTransport { .parse() .map_err(|e| SdkError::DatabaseError(format!("file download url parse: {e}")))?; - let resp = self - .file_client - .get(url) - .await - .map_err(|e| SdkError::DatabaseError(format!("file download failed: {e}")))?; - - if resp.status().is_success() { - let bytes = hyper::body::to_bytes(resp.into_body()) + with_request_timeout("file download", self.request_timeout, async { + let resp = self + .file_client + .get(url) .await - .map_err(|e| SdkError::DatabaseError(format!("file download body: {e}")))?; - Ok(bytes.to_vec()) - } else { - Err(SdkError::DatabaseError(format!("file not found: {hash}"))) - } + .map_err(|e| SdkError::DatabaseError(format!("file download failed: {e}")))?; + + if resp.status().is_success() { + collect_bounded_body(resp.into_body(), MAX_HTTP_BODY_BYTES).await + } else { + Err(SdkError::DatabaseError(format!("file not found: {hash}"))) + } + }) + .await } } @@ -1031,4 +1196,67 @@ mod hash_backed_change_request_tests { assert_eq!(request.retention_proofs, vec![b"proof".to_vec()]); assert_eq!(request.values_sidecar, vec![full_value]); } + + #[tokio::test] + async fn native_timeout_cancellation_removes_pending_request() { + let pending = std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + pending + .lock() + .expect("pending lock") + .insert("stalled-request".to_owned(), response_tx); + let guarded = async { + let _guard = PendingRequestGuard::new( + "stalled-request".to_owned(), + std::sync::Arc::clone(&pending), + ); + std::future::pending::>().await + }; + + let error = with_request_timeout( + "stalled-request", + std::time::Duration::from_millis(1), + guarded, + ) + .await + .expect_err("stalled request did not time out"); + + assert!(error.to_string().contains("timed out")); + assert!(pending.lock().expect("pending lock").is_empty()); + } + + #[tokio::test] + async fn native_mutation_timeout_after_transmission_is_commit_unknown() { + let transmission_started = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let future_flag = std::sync::Arc::clone(&transmission_started); + let stalled_after_send = async move { + future_flag.store(true, std::sync::atomic::Ordering::Release); + std::future::pending::>().await + }; + + let error = with_db_request_timeout( + "mutation-request", + std::time::Duration::from_millis(1), + true, + transmission_started, + stalled_after_send, + ) + .await + .expect_err("mutation deadline did not report an unknown commit outcome"); + + assert!(matches!(error, SdkError::CommitOutcomeUnknown(_))); + } + + #[tokio::test] + async fn native_http_body_collection_enforces_limit() { + let error = collect_bounded_body(hyper::Body::from(vec![0_u8; 5]), 4) + .await + .expect_err("oversized body was accepted"); + assert!(error.to_string().contains("exceeds 4 bytes")); + + let body = collect_bounded_body(hyper::Body::from(vec![1_u8; 4]), 4) + .await + .expect("body at limit"); + assert_eq!(body, vec![1_u8; 4]); + } } From 7b6184b477d2885536491b1254e62f2c5b535f38 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 13 Jul 2026 05:01:29 -0400 Subject: [PATCH 32/35] fix(ci): verify exact upstream merge Bind compatibility results to every dispatch input and reject sync heads whose parents or tree differ from the recorded fork/upstream merge. --- .github/workflows/upstream-compat.yml | 10 +++++++++- .github/workflows/upstream-sync.yml | 2 +- bridge/tests/runtime.rs | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/upstream-compat.yml b/.github/workflows/upstream-compat.yml index 073da2d..429598a 100644 --- a/.github/workflows/upstream-compat.yml +++ b/.github/workflows/upstream-compat.yml @@ -1,5 +1,5 @@ name: Trusted Upstream Compatibility -run-name: upstream-compat ${{ inputs.candidate_sha }} ${{ inputs.correlation_id }} +run-name: upstream-compat ${{ inputs.candidate_sha }} ${{ inputs.fork_main_sha }} ${{ inputs.upstream_sha }} ${{ inputs.correlation_id }} on: workflow_dispatch: @@ -57,6 +57,14 @@ jobs: test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" git merge-base --is-ancestor "$FORK_MAIN_SHA" HEAD git merge-base --is-ancestor "$UPSTREAM_SHA" HEAD + if git merge-base --is-ancestor "$FORK_MAIN_SHA" "$UPSTREAM_SHA"; then + test "$CANDIDATE_SHA" = "$UPSTREAM_SHA" + else + candidate_line="$(git rev-list --parents -n 1 HEAD)" + test "$candidate_line" = "$CANDIDATE_SHA $FORK_MAIN_SHA $UPSTREAM_SHA" + expected_tree="$(git merge-tree --write-tree "$FORK_MAIN_SHA" "$UPSTREAM_SHA")" + test "$(git rev-parse "$CANDIDATE_SHA^{tree}")" = "$expected_tree" + fi - name: Install trusted build dependencies shell: bash diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 971ac4b..8f59c37 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -117,7 +117,7 @@ jobs: fork_main_sha="$3" upstream_sha="$4" correlation_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${candidate_sha:0:12}" - run_title="upstream-compat $candidate_sha $correlation_id" + run_title="upstream-compat $candidate_sha $fork_main_sha $upstream_sha $correlation_id" gh workflow run "$workflow" --ref main \ -f "candidate_sha=$candidate_sha" \ -f "fork_main_sha=$fork_main_sha" \ diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 7b4eb3f..0fd823f 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -2210,6 +2210,9 @@ fn upstream_sync_opens_ci_gated_automerge_prs() { assert!(workflow.contains("candidate_sha")); assert!(workflow.contains("candidate_sha=\"$(git rev-parse \"origin/$branch\")\"")); assert!(workflow.contains("correlation_id")); + assert!(workflow.contains( + "run_title=\"upstream-compat $candidate_sha $fork_main_sha $upstream_sha $correlation_id\"" + )); assert!(workflow.contains("--json databaseId,displayTitle,headSha")); assert!(workflow.contains("gh pr view \"$PR_URL\"")); assert!(!workflow.contains("trigger_and_wait build-prototype.yml")); @@ -2234,6 +2237,18 @@ fn upstream_sync_opens_ci_gated_automerge_prs() { assert!(compatibility.contains("fetch-depth: 0")); assert!(compatibility.contains("git merge-base --is-ancestor \"$FORK_MAIN_SHA\" HEAD")); assert!(compatibility.contains("git merge-base --is-ancestor \"$UPSTREAM_SHA\" HEAD")); + assert!(compatibility.contains( + "run-name: upstream-compat ${{ inputs.candidate_sha }} ${{ inputs.fork_main_sha }} ${{ inputs.upstream_sha }} ${{ inputs.correlation_id }}" + )); + assert!(compatibility.contains("test \"$CANDIDATE_SHA\" = \"$UPSTREAM_SHA\"")); + assert!(compatibility.contains("candidate_line=\"$(git rev-list --parents -n 1 HEAD)\"")); + assert!(compatibility + .contains("test \"$candidate_line\" = \"$CANDIDATE_SHA $FORK_MAIN_SHA $UPSTREAM_SHA\"")); + assert!(compatibility.contains( + "expected_tree=\"$(git merge-tree --write-tree \"$FORK_MAIN_SHA\" \"$UPSTREAM_SHA\")\"" + )); + assert!(compatibility + .contains("test \"$(git rev-parse \"$CANDIDATE_SHA^{tree}\")\" = \"$expected_tree\"")); assert!(compatibility.contains("persist-credentials: false")); assert!(!compatibility.contains("secrets.")); assert!(compatibility.contains("cargo fmt --all -- --check")); From 25286b90e8988d073f780ddb8a3edd44a73469c4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 13 Jul 2026 05:11:50 -0400 Subject: [PATCH 33/35] fix(ci): prebuild bridge test fixtures --- .github/workflows/build-prototype.yml | 7 +++++++ bridge/tests/runtime.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.github/workflows/build-prototype.yml b/.github/workflows/build-prototype.yml index 6411112..d85dd4b 100644 --- a/.github/workflows/build-prototype.yml +++ b/.github/workflows/build-prototype.yml @@ -101,7 +101,14 @@ jobs: with: tool: cargo-nextest + - name: Build bridge runtime fixtures + if: matrix.group == 'bridge' + run: cargo build --locked -p encrypted-spaces-backend-server -p encrypted-spaces-bridge + - name: Cargo nextest run (${{ matrix.group }}) + env: + ENCRYPTED_SPACES_BACKEND_TEST_BINARY: ${{ github.workspace }}/target/x86_64-unknown-linux-gnu/debug/encrypted-spaces-backend-server + ENCRYPTED_SPACES_BRIDGE_TEST_BINARY: ${{ github.workspace }}/target/x86_64-unknown-linux-gnu/debug/encrypted-spaces-bridge run: cargo nextest run ${{ matrix.packages }} --locked ${{ matrix.filter }} - name: Report disk usage before cache save diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 0fd823f..bf9612e 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -2185,6 +2185,25 @@ fn release_contract_builds_and_publishes_native_assets() { assert!(!patches.contains("NOT_IMPLEMENTED")); } +#[test] +fn ci_prebuilds_bridge_runtime_fixtures_for_nextest() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let workflow = fs::read_to_string(root.join(".github/workflows/build-prototype.yml")) + .expect("CI workflow"); + + for marker in [ + "Build bridge runtime fixtures", + "matrix.group == 'bridge'", + "cargo build --locked -p encrypted-spaces-backend-server -p encrypted-spaces-bridge", + "ENCRYPTED_SPACES_BACKEND_TEST_BINARY:", + "target/x86_64-unknown-linux-gnu/debug/encrypted-spaces-backend-server", + "ENCRYPTED_SPACES_BRIDGE_TEST_BINARY:", + "target/x86_64-unknown-linux-gnu/debug/encrypted-spaces-bridge", + ] { + assert!(workflow.contains(marker), "CI workflow omits {marker}"); + } +} + #[test] fn upstream_sync_opens_ci_gated_automerge_prs() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); From d57093cdef022ff2e3c9ae5f7d56ab040c66d0b2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 13 Jul 2026 05:52:18 -0400 Subject: [PATCH 34/35] fix(sdk): recover pending websocket requests --- sdk/Cargo.toml | 2 +- sdk/src/websocket_transport.rs | 223 +++++++++++++++++++++++++++++---- 2 files changed, 203 insertions(+), 22 deletions(-) diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index b442832..64718a5 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -53,7 +53,7 @@ uuid = { workspace = true } [dev-dependencies] env_logger = { workspace = true } -tokio = { workspace = true, features = ["rt","rt-multi-thread","macros","sync","time"] } +tokio = { workspace = true, features = ["rt","rt-multi-thread","macros","net","sync","time"] } criterion = { workspace = true, features = ["async_tokio"] } expect-test = "1" tempfile = "3" diff --git a/sdk/src/websocket_transport.rs b/sdk/src/websocket_transport.rs index d62d794..c989db2 100644 --- a/sdk/src/websocket_transport.rs +++ b/sdk/src/websocket_transport.rs @@ -55,11 +55,22 @@ enum PendingResponse { } #[cfg(not(target_arch = "wasm32"))] -type NativePendingMap = std::sync::Arc< - std::sync::Mutex< - std::collections::HashMap>, - >, ->; +type NativePendingRequests = + std::collections::HashMap>; + +#[cfg(not(target_arch = "wasm32"))] +type NativePendingMap = std::sync::Arc>; + +#[cfg(not(target_arch = "wasm32"))] +fn lock_pending_requests( + pending: &NativePendingMap, +) -> std::sync::MutexGuard<'_, NativePendingRequests> { + pending.lock().unwrap_or_else(|error| { + log::warn!("recovering poisoned pending request lock"); + pending.clear_poison(); + error.into_inner() + }) +} #[cfg(not(target_arch = "wasm32"))] struct PendingRequestGuard { @@ -80,9 +91,7 @@ impl PendingRequestGuard { #[cfg(not(target_arch = "wasm32"))] impl Drop for PendingRequestGuard { fn drop(&mut self) { - if let Ok(mut pending) = self.pending.lock() { - pending.remove(&self.request_id); - } + lock_pending_requests(&self.pending).remove(&self.request_id); } } @@ -151,10 +160,7 @@ async fn collect_bounded_body(mut body: hyper::Body, limit: usize) -> Result>()) - .unwrap_or_default(); + let requests = lock_pending_requests(pending).drain().collect::>(); for (_, sender) in requests { let _ = sender.send(PendingResponse::Db(Err(SdkError::DatabaseError( message.to_owned(), @@ -347,16 +353,14 @@ impl WebSocketTransport { // Spawn background read loop let task = tokio::spawn(async move { use async_tungstenite::tungstenite::Message; + let mut close_message = "connection closed".to_owned(); while let Some(item) = read.next().await { match item { Ok(Message::Binary(data)) => match WsFrame::decode(&data[..]) { Ok(frame) => match frame.payload { Some(ws_frame::Payload::DbResponse(resp)) => { let req_id = resp.request_id.clone(); - let tx_opt = pending_clone - .lock() - .ok() - .and_then(|mut pending| pending.remove(&req_id)); + let tx_opt = lock_pending_requests(&pending_clone).remove(&req_id); if let Some(tx) = tx_opt { let result = if resp.status == "ok" { Ok(resp) @@ -422,11 +426,12 @@ impl WebSocketTransport { } Err(e) => { log_debug!("read_loop: read error err={}", e); + close_message = format!("websocket read failed: {e}"); break; } } } - fail_pending_requests(&pending_clone, "connection closed"); + fail_pending_requests(&pending_clone, &close_message); log_debug!("read_loop: terminated"); }); @@ -481,10 +486,7 @@ impl WebSocketTransport { // Prepare oneshot before sending let (tx, rx) = oneshot::channel::(); { - let mut pending = self - .pending - .lock() - .map_err(|_| SdkError::DatabaseError("pending request lock poisoned".into()))?; + let mut pending = lock_pending_requests(&self.pending); if pending.contains_key(&request_id) { return Err(SdkError::ValidationError(format!( "duplicate request id {request_id}" @@ -1173,6 +1175,20 @@ mod hash_backed_change_request_tests { use encrypted_spaces_changelog_core::changelog::{HashedValues, OpType, ROOT_TREE_PATH}; use encrypted_spaces_storage_encoding::hashstore_hash; + fn poison_pending_map(pending: &NativePendingMap) { + let poisoned = std::sync::Arc::clone(pending); + let result = std::thread::spawn(move || { + let _guard = poisoned.lock().expect("pending lock before poison"); + panic!("poison pending request map"); + }) + .join(); + assert!(result.is_err(), "poisoning thread did not panic"); + assert!( + pending.is_poisoned(), + "pending request map was not poisoned" + ); + } + #[test] fn hash_backed_change_request_proto_carries_material() { let mut change = Change::new( @@ -1225,6 +1241,171 @@ mod hash_backed_change_request_tests { assert!(pending.lock().expect("pending lock").is_empty()); } + #[test] + fn pending_request_guard_cleans_up_after_lock_poison() { + let pending = std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + pending + .lock() + .expect("pending lock") + .insert("poisoned-request".to_owned(), response_tx); + poison_pending_map(&pending); + + drop(PendingRequestGuard::new( + "poisoned-request".to_owned(), + std::sync::Arc::clone(&pending), + )); + + let recovered = pending.lock().unwrap_or_else(|error| error.into_inner()); + assert!( + recovered.is_empty(), + "guard left a stale request in a poisoned pending map" + ); + assert!(!pending.is_poisoned(), "guard did not clear lock poison"); + } + + #[test] + fn fail_pending_requests_drains_after_lock_poison() { + let pending = std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); + let (response_tx, mut response_rx) = tokio::sync::oneshot::channel(); + pending + .lock() + .expect("pending lock") + .insert("poisoned-request".to_owned(), response_tx); + poison_pending_map(&pending); + + fail_pending_requests(&pending, "connection failed"); + + let PendingResponse::Db(result) = response_rx + .try_recv() + .expect("pending caller was not failed after lock poison"); + let error = result.expect_err("pending caller unexpectedly succeeded"); + assert!(error.to_string().contains("connection failed")); + let recovered = pending.lock().unwrap_or_else(|error| error.into_inner()); + assert!(recovered.is_empty(), "failed requests were not drained"); + assert!( + !pending.is_poisoned(), + "failure path did not clear lock poison" + ); + } + + #[tokio::test] + async fn native_read_loop_routes_response_after_lock_poison() { + use futures_util::SinkExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind websocket listener"); + let address = listener.local_addr().expect("listener address"); + let (send_response, receive_response) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept websocket"); + let mut websocket = async_tungstenite::tokio::accept_async(stream) + .await + .expect("accept websocket handshake"); + receive_response.await.expect("response signal"); + let response = DbResponse { + request_id: "poisoned-request".to_owned(), + status: "ok".to_owned(), + error: String::new(), + result: None, + }; + websocket + .send(async_tungstenite::tungstenite::Message::Binary( + WsFrame { + payload: Some(ws_frame::Payload::DbResponse(response)), + } + .encode_to_vec(), + )) + .await + .expect("send websocket response"); + }); + + let transport = WebSocketTransport::new_with_request_timeout( + &format!("ws://{address}/ws"), + std::time::Duration::from_secs(2), + ) + .await + .expect("create websocket transport"); + transport + .authenticate(&AuthContext::anonymous( + encrypted_spaces_backend::SpaceId::random(), + )) + .await + .expect("authenticate websocket transport"); + + poison_pending_map(&transport.pending); + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + transport + .pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert("poisoned-request".to_owned(), response_tx); + send_response.send(()).expect("signal websocket response"); + + let response = tokio::time::timeout(std::time::Duration::from_secs(1), response_rx) + .await + .expect("read loop dropped response after lock poison") + .expect("pending response channel closed"); + let PendingResponse::Db(result) = response; + assert_eq!( + result.expect("response routing failed").request_id, + "poisoned-request" + ); + assert!( + !transport.pending.is_poisoned(), + "response routing did not clear lock poison" + ); + server.await.expect("websocket server task"); + } + + #[tokio::test] + async fn native_read_error_preserves_cause_for_pending_request() { + use futures_util::StreamExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind websocket listener"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept websocket"); + let mut websocket = async_tungstenite::tokio::accept_async(stream) + .await + .expect("accept websocket handshake"); + websocket + .next() + .await + .expect("client request frame") + .expect("valid client request frame"); + drop(websocket); + }); + + let transport = WebSocketTransport::new_with_request_timeout( + &format!("ws://{address}/ws"), + std::time::Duration::from_secs(2), + ) + .await + .expect("create websocket transport"); + transport + .authenticate(&AuthContext::anonymous( + encrypted_spaces_backend::SpaceId::random(), + )) + .await + .expect("authenticate websocket transport"); + + let error = transport + .fast_forward(0) + .await + .expect_err("abrupt websocket reset unexpectedly succeeded"); + let message = error.to_string(); + assert!( + message.contains("websocket read failed:"), + "read cause was discarded: {message}" + ); + assert_ne!(message, "Database error: connection closed"); + server.await.expect("websocket server task"); + } + #[tokio::test] async fn native_mutation_timeout_after_transmission_is_commit_unknown() { let transmission_started = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); From 5f6cf4184d56a59645e153e6e62144a1594a624b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 13 Jul 2026 06:29:45 -0400 Subject: [PATCH 35/35] fix(bridge): classify active space mismatch --- bridge/src/runtime.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs index 96353d3..0ddae5e 100644 --- a/bridge/src/runtime.rs +++ b/bridge/src/runtime.rs @@ -372,7 +372,6 @@ impl Runtime { } pub fn dispatch(&mut self, request: Request) -> Response { - let _ = &self.executor; match request.operation { Operation::Hello => Response::success( request.request_id, @@ -583,7 +582,10 @@ impl Runtime { let Some(space) = self.space.as_ref() else { return invalid_state(request_id); }; - if payload.space_id != space.id().to_string() || payload.where_clause.len() != 1 { + if payload.space_id != space.id().to_string() { + return invalid_state(request_id); + } + if payload.where_clause.len() != 1 { return invalid_request(request_id); } let (column, value) = payload @@ -1483,6 +1485,29 @@ mod tests { ); } + #[test] + fn table_select_rejects_other_active_space_as_invalid_state() { + let (mut runtime, space_id, row_id) = revoked_member_runtime(); + + let response = runtime.table_select( + "wrong-space-select".to_owned(), + json!({ + "space_id": format!("different-{space_id}"), + "table": "records", + "where": {"id": row_id}, + }), + ); + + assert!( + !response.ok, + "table.select accepted a different active Space" + ); + assert_eq!( + response.error.as_ref().map(|error| error.code), + Some("INVALID_STATE") + ); + } + #[test] fn table_insert_syncs_revocation_before_write() { let (mut runtime, space_id, _) = revoked_member_runtime();