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/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/.github/workflows/release-bridge.yml b/.github/workflows/release-bridge.yml new file mode 100644 index 0000000..ebeb2d9 --- /dev/null +++ b/.github/workflows/release-bridge.yml @@ -0,0 +1,382 @@ +name: Encrypted Spaces Release + +on: + push: + branches: + - main + tags: + - 'v*' + +permissions: + contents: read + +env: + RELEASE_READY: true + 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: + name: Legal and source contract + runs-on: ubuntu-24.04 + steps: + - name: Checkout pinned source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + + - name: Verify legal, version, and source records + shell: bash + run: | + set -euo pipefail + 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 + test -s PATCHES.md + 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 + 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: + 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-arm + 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-arm + 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 + runs-on: ${{ matrix.os }} + 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: Build real guest ELFs and native binary + shell: bash + env: + PACKAGE: ${{ matrix.package }} + BINARY: ${{ matrix.binary }} + run: | + set -euo pipefail + cargo risczero --version + 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)" + 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: 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: + COMPONENT: ${{ matrix.component }} + TARGET: ${{ matrix.target }} + ARCHIVE: ${{ matrix.archive }} + run: | + set -euo pipefail + 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" ;; + *) exit 1 ;; + esac + + 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 + sha256sum "$RUNNER_TEMP/dist/$ARCHIVE" + else + shasum -a 256 "$RUNNER_TEMP/dist/$ARCHIVE" + fi + + - 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 + + 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, real-proof] + runs-on: ubuntu-24.04 + steps: + - name: Require all release gates + shell: bash + 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 + with: + pattern: release-* + path: dist + merge-multiple: true + + - name: Generate checksums and manifest + shell: bash + run: | + set -euo pipefail + mkdir -p dist/checksums + 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"] + 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("-") + 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" + ) + 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, + "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( + 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 + 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[]; + (.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 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..429598a --- /dev/null +++ b/.github/workflows/upstream-compat.yml @@ -0,0 +1,87 @@ +name: Trusted Upstream Compatibility +run-name: upstream-compat ${{ inputs.candidate_sha }} ${{ inputs.fork_main_sha }} ${{ inputs.upstream_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 + 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 + 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 new file mode 100644 index 0000000..8f59c37 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,178 @@ +name: Weekly Upstream Sync + +on: + schedule: + - cron: '17 9 * * 1' + workflow_dispatch: + +permissions: + actions: write + 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)" + 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 \ + --head "$branch" \ + --json url \ + --jq '.[0].url // empty')" + if [[ -n "$existing" ]]; then + 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 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.")" + 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: 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 + 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 $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" \ + -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/Cargo.lock b/Cargo.lock index d95159c..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", @@ -2243,9 +2243,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "encrypted-spaces-bridge" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "encrypted-spaces-ffproof", + "encrypted-spaces-sdk", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", +] + [[package]] name = "encrypted-spaces-changelog-core" -version = "0.0.1" +version = "0.1.0" dependencies = [ "encrypted-spaces-acl-types", "encrypted-spaces-storage-encoding", @@ -2265,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", @@ -2287,7 +2301,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-crypto" -version = "0.0.1" +version = "0.1.0" dependencies = [ "aes", "base64 0.22.1", @@ -2324,7 +2338,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-demo" -version = "0.0.1" +version = "0.1.0" dependencies = [ "anyhow", "chrono", @@ -2347,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", @@ -2366,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", @@ -2397,7 +2411,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-ffproof" -version = "0.0.1" +version = "0.1.0" dependencies = [ "base64 0.22.1", "bincode", @@ -2426,7 +2440,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-ffproof-methods" -version = "0.0.1" +version = "0.1.0" dependencies = [ "risc0-build", ] @@ -2440,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", @@ -2457,7 +2471,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-retention" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "encrypted-spaces-changelog-core", @@ -2476,7 +2490,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-sdk" -version = "0.0.1" +version = "0.1.0" dependencies = [ "aes", "async-trait", @@ -2519,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", @@ -2531,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", @@ -2543,7 +2557,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-storage-encoding" -version = "0.0.1" +version = "0.1.0" dependencies = [ "postcard", "serde", @@ -2553,7 +2567,7 @@ dependencies = [ [[package]] name = "encrypted-spaces-zkp" -version = "0.0.1" +version = "0.1.0" dependencies = [ "bytesize", "criterion", @@ -2754,7 +2768,7 @@ dependencies = [ [[package]] name = "ffproof-tracer" -version = "0.0.1" +version = "0.1.0" dependencies = [ "clap 4.6.1", "ffproof-tracer-methods", @@ -2770,7 +2784,7 @@ dependencies = [ [[package]] name = "ffproof-tracer-methods" -version = "0.0.1" +version = "0.1.0" dependencies = [ "risc0-build", ] @@ -4213,13 +4227,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 +4999,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 +5096,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 +10328,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..b36f840 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", @@ -23,7 +24,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.1" +version = "0.1.0" edition = "2021" license = "Apache-2.0" authors = [ @@ -97,7 +98,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" } 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 new file mode 100644 index 0000000..8c5921d --- /dev/null +++ b/PATCHES.md @@ -0,0 +1,30 @@ +# Upstream Delta and Release Ledger + +This ledger records the intentional delta from upstream commit +`4cda0ae87698135aa672990e6e68cf7873847426`. + +## 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, 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. +- 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, 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 59d134f..2aa07a2 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,87 @@ 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 untrusted diagnostic label, one schema trust bundle, one +backend endpoint, and at most one active Space. Configure them before launch: + +```sh +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 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`; 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 +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. 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 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/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 new file mode 100644 index 0000000..606b8f3 --- /dev/null +++ b/bridge/Cargo.toml @@ -0,0 +1,27 @@ +[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" + +[features] +default = [] +real-proofs = ["encrypted-spaces-ffproof/real-proofs"] + +[[bin]] +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 +serde_json.workspace = true +sha2.workspace = true +tokio.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/bridge/src/main.rs b/bridge/src/main.rs new file mode 100644 index 0000000..d509d92 --- /dev/null +++ b/bridge/src/main.rs @@ -0,0 +1,13 @@ +mod protocol; +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(), std::io::stdout()) +} diff --git a/bridge/src/protocol.rs b/bridge/src/protocol.rs new file mode 100644 index 0000000..873c21b --- /dev/null +++ b/bridge/src/protocol.rs @@ -0,0 +1,767 @@ +use crate::runtime; +use crate::schema::{Operation, Request, MAX_REQUEST_ID_BYTES, PROTOCOL_VERSION}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::io::{self, BufRead, Read, Write}; +use std::sync::{ + atomic::{AtomicU8, Ordering}, + mpsc, 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; +const DONE: u8 = 3; +const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(10); + +#[derive(Debug, Serialize)] +pub struct Response { + pub version: u16, + 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, +} + +#[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, + 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), + "NOT_IMPLEMENTED", + "runtime operation is not implemented", + ) + } +} + +enum FrameError { + TooLarge, + Io(io::Error), +} + +#[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, +} + +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 (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 { + 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(()); + } + CoordinatorEvent::InputClosed => { + input_closed = true; + cancel_all_waits(&mut pending, &mut writer)?; + if pending.is_empty() { + return Ok(()); + } + } + CoordinatorEvent::ReadError(error) => return Err(error), + } + } +} + +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 spawn_runtime_worker( + runtime: Arc>, + 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 => + { + Ok(payload) + } + _ => Err(Response::error( + Some(request_id.to_owned()), + "INVALID_REQUEST", + "invalid bridge request", + )), + } +} + +fn spawn_waiter( + request_id: String, + payload: WaitSyncPayload, + mut updates: tokio::sync::broadcast::Receiver, + runtime: Arc>, + jobs: mpsc::SyncSender, + state: Arc, +) { + thread::spawn(move || { + 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 _ = jobs.send(RuntimeJob::Dispatch { + request: Request { + version: PROTOCOL_VERSION, + request_id, + operation: Operation::Sync, + payload: serde_json::json!({"space_id": payload.space_id}), + }, + state, + wait_trigger: Some(trigger), + }); + }); +} + +fn cancel_pending( + request: Request, + 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_response( + writer, + Response::error( + Some(request.request_id), + "INVALID_REQUEST", + "invalid bridge request", + ), + ); + } + }; + 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() + }); + if canceled { + pending.remove(&payload.request_id); + } + if canceled { + write_response( + writer, + Response::error( + Some(payload.request_id), + "CANCELED", + "bridge request canceled", + ), + )?; + } + write_response( + writer, + Response::success(request.request_id, CancelResult { canceled }), + ) +} + +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 { + let value = match serde_json::from_slice::(frame) { + Ok(value) => value, + Err(_) => { + return Err(Response::error( + None, + "INVALID_JSON", + "malformed JSONL frame", + )); + } + }; + let request_id = parsed_request_id(&value); + if has_unknown_operation(&value) { + return Err(Response::error( + request_id, + "UNKNOWN_OPERATION", + "unknown bridge operation", + )); + } + + let request = match serde_json::from_value::(value) { + Ok(request) => request, + Err(_) => { + return Err(Response::error( + request_id, + "INVALID_REQUEST", + "invalid bridge request", + )); + } + }; + match request.validate() { + Ok(()) => Ok(request), + Err(_) => 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]; + 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 { + return Err(FrameError::TooLarge); + } + frame.push(byte[0]); + } +} + +fn write_response(writer: &mut W, response: Response) -> io::Result<()> { + 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)?; + 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) +} + +#[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"); + } + + #[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" + ); + } + + #[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]); + 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) + )); + } +} diff --git a/bridge/src/runtime.rs b/bridge/src/runtime.rs new file mode 100644 index 0000000..0ddae5e --- /dev/null +++ b/bridge/src/runtime.rs @@ -0,0 +1,1620 @@ +use crate::protocol::Response; +use crate::schema::{Operation, Request, PROTOCOL_VERSION}; +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine, +}; +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}; +use std::fs; +use std::future::Future; +use std::io; +use std::time::Duration; + +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, + process: ProcessConfig, + space: Option, + shutdown_requested: bool, +} + +struct ProcessConfig { + 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, + client_label: &'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, +} + +#[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(Deserialize)] +#[serde(deny_unknown_fields)] +struct SyncPayload { + space_id: String, + #[serde(default)] + 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(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(Clone, Copy, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum RefKind { + List, + Text, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ScopedRef { + space_id: String, + 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(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(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, + schema_sha256: &'a str, +} + +#[derive(Serialize)] +struct SnapshotResult { + space_id: String, + snapshot: Value, +} + +#[derive(Serialize)] +struct RestoreResult { + space_id: String, + restored: bool, +} + +#[derive(Serialize)] +struct SyncResult { + space_id: String, + synced: bool, +} + +#[derive(Serialize)] +struct TableInsertResult { + row_id: i64, +} + +#[derive(Serialize)] +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, +} + +#[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, +} + +#[derive(Serialize)] +struct FilePutResult { + space_id: String, + digest: String, +} + +#[derive(Serialize)] +struct FileGetResult { + space_id: String, + digest: String, + 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, +} + +#[derive(Serialize)] +struct CloseResult { + closed: bool, +} + +#[derive(Serialize)] +struct ShutdownResult { + shutting_down: bool, +} + +impl Runtime { + pub fn from_env() -> io::Result { + 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)); + let executor = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + 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)?; + 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()); + let request_timeout = request_timeout_from_env()?; + + Ok(Self { + executor, + process: ProcessConfig { + 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, + }) + } + + pub fn dispatch(&mut self, request: Request) -> Response { + match request.operation { + Operation::Hello => Response::success( + request.request_id, + HelloResult { + protocol_version: PROTOCOL_VERSION, + 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, + }, + ), + Operation::Version => Response::success( + request.request_id, + VersionResult { + version: env!("CARGO_PKG_VERSION"), + 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::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::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::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::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; + Response::not_implemented(request.request_id) + } + } + } + + 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 request_timeout = self.process.request_timeout; + let schema = self.application_schema(); + let space = match self.executor.block_on(async move { + let transport = + WebSocketTransport::new_with_request_timeout(&backend_url, request_timeout).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); + } + 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), + } + } + + 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 request_timeout = self.process.request_timeout; + let schema = self.application_schema(); + let space = match self.executor.block_on(async move { + 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(); + self.space = Some(space); + Response::success( + request_id, + RestoreResult { + space_id, + restored: true, + }, + ) + } + + 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(error) => sdk_operation_error(request_id, &error), + } + } + + 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, + 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); + } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } + 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(error) => sdk_operation_error(request_id, &error), + } + } + + 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() { + return invalid_state(request_id); + } + if 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); + } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } + 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(error) => sdk_operation_error(request_id, &error), + } + } + + 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); + } + 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, + 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) 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); + }; + let space_id = space.id().to_string(); + 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( + 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) 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); + }; + let space_id = space.id().to_string(); + 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( + 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 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); + } + 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, + 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 || 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 text_len = match self.executor.block_on(async { + text.sync().await?; + 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 { + space_id, + text_ref: payload.text_ref, + edited: true, + }, + ), + Err(response) => response, + } + } + + 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 || 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( + request_id, + TextReadResult { + space_id, + text_ref: payload.text_ref, + text, + }, + ), + Err(_) => sdk_error(request_id), + } + } + + 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); + } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } + 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); + } + if let Err(response) = self.sync_before_access(&request_id) { + return response; + } + 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 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); + } + 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 { + 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 request_timeout = self.process.request_timeout; + let schema = self.application_schema(); + let space = match self.executor.block_on(async move { + let transport = + WebSocketTransport::new_with_request_timeout(&backend_url, request_timeout).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); + } + 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, + RemoveResult { + space_id, + member_id: payload.member_id, + removed: true, + }, + ), + Err(error) => sdk_operation_error(request_id, &error), + } + } + + 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(), + 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 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), + "SDK_ERROR", + "encrypted spaces operation failed", + ) +} + +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) + 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), + "INTERNAL_ERROR", + "bridge operation failed", + ) +} + +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)) + .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 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() + .filter(|value| !value.is_empty()) + .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() + .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 { + 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, + }; + + (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_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(); + + 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") + ); + } + + #[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/src/schema.rs b/bridge/src/schema.rs new file mode 100644 index 0000000..18102f7 --- /dev/null +++ b/bridge/src/schema.rs @@ -0,0 +1,103 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const PROTOCOL_VERSION: u16 = 1; +pub const MAX_REQUEST_ID_BYTES: usize = 256; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Request { + pub version: u16, + pub request_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"); + } + Ok(()) + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Operation { + Hello, + Version, + Cancel, + #[serde(rename = "space.create")] + Create, + #[serde(rename = "space.join")] + Join, + #[serde(rename = "space.snapshot")] + Snapshot, + #[serde(rename = "space.restore")] + Restore, + #[serde(rename = "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 => "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", + 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..bf9612e --- /dev/null +++ b/bridge/tests/runtime.rs @@ -0,0 +1,2279 @@ +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, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::sync::{mpsc, OnceLock}; +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(60); +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 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()) + .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(|| { + 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"); + 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), + Eof, + Error(String), +} + +struct BridgeProcess { + child: Child, + stdin: Option, + stdout: mpsc::Receiver, + stdout_thread: Option>, + scenario: String, + next_request: usize, + schema_path: Option, +} + +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 { + 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(), + scenario, + actor + )); + fs::write(&schema_path, schema).expect("write bridge schema fixture"); + 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_CLIENT_LABEL", actor) + .env("ENCRYPTED_SPACES_SCHEMA_PATH", &schema_path) + .stdin(Stdio::piped()) + .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(); + 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_receiver, + stdout_thread: Some(stdout_thread), + scenario: format!("{scenario}-{actor}"), + next_request: 1, + schema_path: Some(schema_path), + } + } + + 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 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, + 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"); + PendingObservation { + operation: operation.to_owned(), + request_id, + } + } + + 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 {}", pending.operation) + } + Ok(StdoutEvent::Error(error)) => { + panic!( + "bridge stdout read failed while handling {}: {error}", + pending.operation + ) + } + Err(mpsc::RecvTimeoutError::Timeout) => { + panic!( + "bridge did not respond to {} within {timeout:?}", + pending.operation + ) + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!( + "bridge stdout reader disconnected while handling {}", + pending.operation + ) + } + }; + let response = serde_json::from_str(&line).expect("bridge response JSON"); + Observation { + operation: pending.operation, + request_id: pending.request_id, + response, + } + } + + 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) { + 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, + response: Value, +} + +struct PendingObservation { + operation: String, + request_id: String, +} + +struct Scenario { + name: String, + backend: BackendProcess, + bridges: BTreeMap, + observations: Vec, + failures: Vec, +} + +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_with_backend(name, actor, SCHEMA_KDL, Some(&backend.url)), + ) + }) + .collect(); + Self { + name: name.to_owned(), + backend, + 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); + 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!( + "{}: version {:?}, expected {PROTOCOL_VERSION}", + observation.operation, actual_version + )); + } + let actual_request_id = observation + .response + .get("request_id") + .and_then(Value::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 + )); + } + self.observations.push(observation); + 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_with_backend( + &self.name, + actor, + SCHEMA_KDL, + Some(&self.backend.url), + ), + ); + } + + 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 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(); + } + assert!( + self.failures.is_empty(), + "runtime scenario failures:\n{}", + self.failures.join("\n") + ); + } +} + +#[derive(Deserialize)] +struct HelloResult { + protocol_version: u64, + 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, + schema_sha256: String, +} + +#[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: i64, + invite: Value, +} + +#[derive(Deserialize)] +struct JoinResult { + space_id: String, + member_id: i64, + joined: bool, +} + +#[derive(Deserialize)] +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)] +struct RemoveResult { + space_id: String, + member_id: i64, + removed: bool, +} + +#[derive(Deserialize)] +struct TableInsertResult { + row_id: i64, +} + +#[derive(Deserialize)] +struct TableSelectResult { + rows: Vec, +} + +#[derive(Deserialize)] +struct BridgeError { + code: String, +} + +#[derive(Deserialize)] +struct ListCreateResult { + space_id: String, + table: String, + row_id: i64, + column: String, + list_ref: Value, +} + +#[derive(Deserialize)] +struct ListAppendResult { + space_id: String, + 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_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 TextReadResult { + space_id: String, + text_ref: Value, + text: String, +} + +#[derive(Deserialize)] +struct FilePutResult { + space_id: String, + digest: String, +} + +#[derive(Deserialize)] +struct FileGetResult { + space_id: String, + digest: String, + bytes_base64: String, +} + +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!({ + "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 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 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, + 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"; + 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_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_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" + ); + 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(); + + let expected_commitment = sdk_data_commitment(SCHEMA_KDL, "stable"); + let changed_commitment = sdk_data_commitment(&modified_schema, "changed"); + 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"); + 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"); + 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.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)); + assert_ne!(changed.schema_sha256, first.schema_sha256); + 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, 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); +} + +#[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_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"; + 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 && 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 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": 50}), + ); + + scenario.verify::(sync, |result| { + (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(|| "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(); +} + +#[test] +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]); + + 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, + "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, + "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() + .unwrap_or(-1); + 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", + json!({"space_id": space_id, "member_id": member_id}), + ); + + scenario.verify::(hello, |result| { + (result.protocol_version == PROTOCOL_VERSION + && result.client_label == 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 invalid process-bound trust metadata".to_owned()) + }); + 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 && !result.snapshot.is_null()) + .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(()) + .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_id && result.joined) + .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(()) + .ok_or_else(|| "member.remove did not remove the joined member".to_owned()) + }); + scenario.finish(); +} + +#[test] +fn runtime_table_insert_select_round_trip_verified_rows() { + let actor = "actor-table-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", 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": TABLE, "row": row}), + ); + 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": TABLE, "where": {"id": row_id}}), + ); + + scenario.verify::(create, |result| { + valid_created_space(result) + .then_some(()) + .ok_or_else(|| "space.create returned an invalid SpaceId".to_owned()) + }); + scenario.verify::(insert, |result| { + (result.row_id > 0) + .then_some(()) + .ok_or_else(|| "table.insert returned no auto-assigned row ID".to_owned()) + }); + scenario.verify::(select, |result| { + (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(); +} + +#[test] +fn runtime_list_create_append_read_round_trip() { + let actor = "actor-list-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", 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, "table": TABLE, "row_id": row_id, "column": LIST_COLUMN}), + ); + let list_ref = scenario.returned_value( + create, + "list_ref", + json!({"missing": "list-handle-reference"}), + ); + let append = scenario.request( + actor, + "list.append", + 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_ref": list_ref}), + ); + + scenario.verify::(create_space, |result| { + valid_created_space(result) + .then_some(()) + .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.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_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_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()) + }); + scenario.finish(); +} + +#[test] +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]); + 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, "table": TABLE, "row_id": row_id, "column": TEXT_COLUMN}), + ); + let text_ref = scenario.returned_value( + create, + "text_ref", + json!({"missing": "text-handle-reference"}), + ); + let edit = scenario.request( + actor, + "text.edit", + 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_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", + json!({"space_id": space_id, "list_ref": text_ref}), + ); + + scenario.verify::(create_space, |result| { + valid_created_space(result) + .then_some(()) + .ok_or_else(|| "space.create returned an invalid SpaceId".to_owned()) + }); + scenario.verify::(insert, |result| { + (result.row_id > 0) + .then_some(()) + .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_ref == text_ref && result.edited) + .then_some(()) + .ok_or_else(|| "text.edit did not apply the positional edit".to_owned()) + }); + 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 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(()) + .ok_or_else(|| "list.read accepted a text capability".to_owned()) + }); + 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"; + let bytes_base64 = "YnJpZGdlLWZpbGUtcGFyYW1ldHJpYw=="; + let mut scenario = Scenario::start("file", &[actor]); + 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, + "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| { + valid_created_space(result) + .then_some(()) + .ok_or_else(|| "space.create returned an invalid SpaceId".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_enforces_revocation() { + let owner = "owner-membership-parametric"; + let member = "member-membership-parametric"; + let mut scenario = Scenario::start("membership", &[owner, member]); + 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})); + 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", 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) + .then_some(()) + .ok_or_else(|| "space.create returned an invalid SpaceId".to_owned()) + }); + scenario.verify::(invite, |result| { + (result.space_id == space_id && result.member_id > 0 && is_opaque_ref(&result.invite)) + .then_some(()) + .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_id && result.joined) + .then_some(()) + .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(|| { + 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(|| { + format!( + "removed member write returned {}, expected ACCESS_DENIED", + error.code + ) + }) + }); + 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(); +} + +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 { + 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}" + ); + } +} + +#[test] +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("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("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"); + assert!( + 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", + "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", + "--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", + "--version", + "tar -czf", + "sha256sum", + "shasum -a 256", + "GITHUB_SHA", + "actions/upload-artifact@", + "actions/download-artifact@", + "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), + "release workflow omits {archive}" + ); + } + + assert!( + nonempty(&root.join("LICENSE")), + "LICENSE is absent or empty" + ); + 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\"")); + 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")); +} + +#[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(".."); + 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("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( + "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")); + 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( + "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")); + 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 new file mode 100644 index 0000000..f443391 --- /dev/null +++ b/bridge/tests/stdio.rs @@ -0,0 +1,489 @@ +use serde_json::{json, Value}; +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_CLIENT_LABEL: &str = "configured-test-client"; +type StderrCapture = Arc, 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(client_label: &str) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_encrypted-spaces-bridge")) + .env("ENCRYPTED_SPACES_CLIENT_LABEL", client_label) + .env( + "ENCRYPTED_SPACES_SCHEMA_PATH", + concat!(env!("CARGO_MANIFEST_DIR"), "/../demos/tauri/app_schema.kdl"), + ) + .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": request_id, + "operation": operation, + "payload": payload, + })) + .expect("request JSON") + + "\n" +} + +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) { + assert_eq!(response["version"], 1, "{operation} response version"); + assert_eq!(response["ok"], true, "{operation} failed: {response}"); +} + +#[test] +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"]["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_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_CLIENT_LABEL); + 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 mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); + 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"], "UNKNOWN_OPERATION"); + } +} + +#[test] +fn protocol_malformed_frame_has_stable_error() { + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); + 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_accepts_a_frame_at_the_exact_limit() { + 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"); + assert_eq!(response["error"]["code"], "INVALID_REQUEST"); +} + +#[test] +fn protocol_oversize_error_is_prompt_while_stdin_remains_open() { + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); + 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!( + response["error"]["message"], + "JSONL frame exceeds maximum size" + ); +} + +#[test] +fn protocol_process_terminates_after_oversize_without_draining_stdin() { + 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"); + + 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_CLIENT_LABEL); + 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"], "INVALID_REQUEST"); +} + +#[test] +fn protocol_validation_error_preserves_request_id() { + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); + 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_CLIENT_LABEL); + 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_CLIENT_LABEL); + 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_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( + "invalid-wait", + "space.sync", + json!({"space_id": "cancel-contract-space", "wait_for_change_ms": 30_000}), + ); + 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-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_CLIENT_LABEL); + 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 bridge response" + ); + let (status, stderr) = bridge.finish(); + assert!(status.success(), "bridge exited with {status}"); + assert!(!stderr.contains(secret), "secret leaked in bridge stderr"); +} + +#[test] +fn protocol_runtime_sdk_errors_are_deterministic_and_redacted() { + let secrets = ["runtime-secret-alpha", "runtime-secret-beta"]; + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); + + 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_clears_state_and_keeps_process_usable() { + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); + bridge.send_request("close-request", "close", json!({})); + 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_acknowledges_and_exits() { + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); + bridge.send_request("shutdown-request", "shutdown", json!({})); + 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] +fn protocol_process_exits_after_stdin_eof() { + let mut bridge = Bridge::spawn(DEFAULT_CLIENT_LABEL); + bridge.close_stdin(); + let status = bridge.wait_for_exit(); + assert!(status.success(), "bridge exited with {status}"); + bridge.expect_stdout_eof(); +} 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/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", ] 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"] 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/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/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 + } } } 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..c989db2 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,120 @@ enum PendingResponse { Db(Result), } +#[cfg(not(target_arch = "wasm32"))] +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 { + 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) { + lock_pending_requests(&self.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 = lock_pending_requests(pending).drain().collect::>(); + 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 +179,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 +204,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 +232,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 +264,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 +309,7 @@ impl WebSocketTransport { auth_b64: tokio::sync::Mutex::new(None), file_client, ws_tls_connector, + request_timeout, }) } @@ -196,13 +331,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(); @@ -213,13 +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().await.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) @@ -278,12 +419,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 +426,12 @@ 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}"), - )))); - } + close_message = format!("websocket read failed: {e}"); break; } } } + fail_pending_requests(&pending_clone, &close_message); log_debug!("read_loop: terminated"); }); @@ -311,6 +442,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 +486,26 @@ 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 = lock_pending_requests(&self.pending); + 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 +1033,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 +1076,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 +1106,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 +1138,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 } } @@ -1008,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( @@ -1031,4 +1212,232 @@ 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()); + } + + #[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)); + 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]); + } }