From 2d47b50088a5b2a10f77458278740edbec0871ba Mon Sep 17 00:00:00 2001 From: "Mr. Denman Mills" Date: Sun, 4 Oct 2026 18:18:50 -0500 Subject: [PATCH] ci: exact-tree validate ORESoftware/ores-orm-core#12 --- .cargo/config.toml | 5 - .gitattributes | 6 - .github/docker/Dockerfile | 12 - .github/rust-lib-pin-policy.json | 25 - .../workflows/beamscale-artifact-abi-cert.yml | 46 - .../workflows/beamscale-context-abi-cert.yml | 42 - .../beamscale-critical-section-proof.yml | 132 -- .../beamscale-supervisor-109-proof.yml | 92 - .github/workflows/cargo-workspace-e2e.yml | 258 --- .github/workflows/ci.yml | 174 ++ .../workflows/ores-compose-current-proof.yml | 57 - .github/workflows/ores-lint.yml | 60 - .../ores-proc-isolation-pr12-proof.yml | 153 -- .../registry-url-quality-candidate.yml | 97 - .github/workflows/rust-lib-main-ancestry.yml | 150 -- .../workflows/scintilla-context-abi-cert.yml | 35 - .../windows-file-registry-candidate.yml | 143 -- .github/workflows/zed-e2e.yml | 171 -- .../workflows/zed-latest-deps-consumer.yml | 43 - .gitignore | 14 +- .ores-lint/README.md | 218 -- .ores-lint/VERSION | 1 - .ores-lint/config.sh | 46 - .ores-lint/dart.sh | 114 - .ores-lint/eslint/base.mjs | 179 -- .ores-lint/eslint/formatter.mjs | 79 - .ores-lint/eslint/plugin.mjs | 361 ---- .ores-lint/gleam.sh | 98 - .ores-lint/install-git-hooks.sh | 19 - .ores-lint/js.sh | 108 - .ores-lint/lint.sh | 76 - .ores-lint/nested-repos.json | 1 - .ores-lint/require-send.mjs | 448 ---- .ores-lint/require-send.test.mjs | 40 - .ores-lint/required-tools.json | 30 - .ores-lint/rust.sh | 171 -- .ores-lint/selftest.sh | 96 - .zpkg.toml | 24 - AGENTS.md | 13 + CHANGELOG.md | 10 - Cargo.lock | 30 - Cargo.toml | 45 +- LICENSE | 1 - README.md | 232 +- agents.md | 18 - build.rs | 12 - cert/beamscale-critical-section/HEADS.lock | 7 - .../infra-runtime-host/Cargo.toml | 20 - .../infra-runtime-host/src/artifact_store.rs | 505 ----- .../src/bin/bmscl-runtime-identity-writer.rs | 388 ---- .../infra-runtime-host/src/lib.rs | 1908 ----------------- .../infra-runtime-host/src/main.rs | 1756 --------------- .../infra-runtime-host/src/runtime_auth.rs | 309 --- .../erlang/bmscl_module.erl | 9 - .../erlang/example_module.erl | 16 - .../gleam/module_contract.gleam | 32 - .../module-projections/manifest.json | 43 - .../interfaces/package.json | 16 - .../critical-section-deployment.schema.json | 46 - .../critical-section-operations.schema.json | 123 -- .../schema/deployment-control.schema.json | 240 --- ...host-security-incident-receipt.schema.json | 64 - .../schema/host-security-incident.schema.json | 199 -- .../schema/module-contract.schema.json | 98 - .../schema/tenant-runtime.schema.json | 103 - .../schema/worker-manifest.schema.json | 53 - .../tests/module-contract-parity.test.mjs | 69 - .../tests/module-projections.test.mjs | 78 - .../interfaces/typespec/main.tsp | 467 ---- .../interfaces/typespec/security.tsp | 95 - .../critical_section_formal_model.rs | 282 --- .../src/bmscl_critical_section_actor.erl | 213 -- .../src/bmscl_critical_section_registry.erl | 188 -- .../src/bmscl_critical_section_store.erl | 160 -- .../supervisor/src/bmscl_durable_store.erl | 91 - .../supervisor/src/bmscl_guest_control.erl | 256 --- .../supervisor/src/bmscl_guest_protocol.erl | 331 --- .../supervisor/src/bmscl_tenancy_policy.erl | 19 - ...scl_critical_section_persistence_tests.erl | 138 -- .../bmscl_critical_section_protocol_tests.erl | 59 - .../test/bmscl_critical_section_tests.erl | 28 - .../test/bmscl_test_durable_store.erl | 58 - .../compiler/Cargo.toml | 10 - .../compiler/build-source.rs | 636 ------ .../compiler/src/lib.rs | 236 -- .../supervisor/bmscl_artifact.erl | 456 ---- .../beamscale-context-abi/gleam/gleam.toml | 4 - .../gleam/src/bmscl.gleam | 213 -- .../gleam/src/bmscl/http_context.gleam | 80 - .../beamscale-context-abi/rust/Cargo.toml | 10 - .../beamscale-context-abi/rust/src/lib.rs | 499 ----- .../rust/src/module_contract.rs | 331 --- .../scintilla-context-abi/Cargo.toml | 28 - .../scintilla-context-abi/SOURCE.lock.json | 14 - .../scintilla-context-abi/src/capabilities.rs | 211 -- .../scintilla-context-abi/src/contracts.rs | 7 - .../scintilla-context-abi/src/dpm.rs | 236 -- .../scintilla-context-abi/src/lib.rs | 28 - .../src/module_contract.rs | 431 ---- .../scintilla-context-abi/tests/contracts.rs | 37 - .../tests/module_contract_fixture.rs | 13 - docs/architecture.md | 57 + docs/cargo-workspace-interop.md | 80 - docs/interop-matrix.md | 37 - examples/emit_runtime_fixture.rs | 68 + fixtures/cargo-workspace/.cargo/config.toml | 11 - fixtures/cargo-workspace/.zpkg.toml | 17 - fixtures/cargo-workspace/Cargo.lock | 38 - fixtures/cargo-workspace/Cargo.toml | 14 - .../cargo-workspace/crates/app/Cargo.toml | 18 - fixtures/cargo-workspace/crates/app/build.rs | 8 - .../cargo-workspace/crates/app/src/main.rs | 16 - .../crates/app/tests/renamed_dependency.rs | 7 - .../cargo-workspace/crates/support/Cargo.toml | 10 - .../cargo-workspace/crates/support/src/lib.rs | 21 - fixtures/diesel/schema.rs | 12 + fixtures/ores-orm.toml | 13 + fixtures/seaorm/users.rs | 14 + proc-isolation-proof/.cli-flags.toml | 58 - .../.ores-proc-isolation.yaml | 31 - proc-isolation-proof/Cargo.toml | 40 - proc-isolation-proof/PROOF.md | 7 - .../examples/beamscale-hosted-gleam.yaml | 36 - .../examples/loopback_fixture.rs | 32 - proc-isolation-proof/proof-source.json | 33 - .../scripts/linux/ores-proc-isolate.sh | 447 ---- .../scripts/macos/ores-proc-isolate.sh | 257 --- proc-isolation-proof/src/config.rs | 527 ----- proc-isolation-proof/src/error.rs | 58 - proc-isolation-proof/src/flags.rs | 248 --- proc-isolation-proof/src/lib.rs | 14 - proc-isolation-proof/src/main.rs | 20 - proc-isolation-proof/src/platform/linux.rs | 313 --- proc-isolation-proof/src/platform/macos.rs | 105 - proc-isolation-proof/src/platform/mod.rs | 258 --- proc-isolation-proof/src/runner.rs | 156 -- .../tests/beamscale_honeypot_profile.rs | 65 - .../tests/linux_helper_tripwire_contract.rs | 123 -- .../linux_network_helper_liveness_contract.rs | 24 - .../tests/nixos_trusted_helper_contract.rs | 46 - proc-isolation-proof/tests/policy.rs | 102 - .../vendor/ores-reactive-maps/Cargo.toml | 11 - .../vendor/ores-reactive-maps/LICENSE | 21 - .../vendor/ores-reactive-maps/UPSTREAM.md | 7 - .../vendor/ores-reactive-maps/src/lib.rs | 124 -- scripts/cargo-workspace-e2e.sh | 151 -- scripts/verify-rust-lib-main-ancestry.sh | 85 - scripts/zed-e2e.sh | 266 --- src/admission.rs | 888 ++++++++ src/descriptor.rs | 233 ++ src/diesel.rs | 259 +++ src/emit/bundle.rs | 386 ++++ src/emit/dart.rs | 246 +++ src/emit/gleam.rs | 208 ++ src/emit/json_schema.rs | 122 ++ src/emit/mod.rs | 7 + src/emit/naming.rs | 129 ++ src/emit/rust.rs | 209 ++ src/emit/typescript.rs | 97 + src/error.rs | 19 + src/ir.rs | 215 ++ src/lib.rs | 32 + src/main.rs | 18 - src/parity.rs | 265 +++ src/policy.rs | 273 +++ src/seaorm.rs | 336 +++ src/shapes.rs | 177 ++ tests/admission.rs | 465 ++++ tests/audit_v2.rs | 325 +++ tests/cargo_zed_interop.rs | 17 - tests/diesel_hardening.rs | 43 + tests/emitters.rs | 299 +++ tests/hardening.rs | 209 ++ tests/parity.rs | 135 ++ tests/policy_provenance.rs | 72 + tests/rust-lib-main-ancestry.sh | 87 - tests/wire_mapping_gates.rs | 68 + 177 files changed, 6248 insertions(+), 19829 deletions(-) delete mode 100644 .cargo/config.toml delete mode 100644 .gitattributes delete mode 100644 .github/docker/Dockerfile delete mode 100644 .github/rust-lib-pin-policy.json delete mode 100644 .github/workflows/beamscale-artifact-abi-cert.yml delete mode 100644 .github/workflows/beamscale-context-abi-cert.yml delete mode 100644 .github/workflows/beamscale-critical-section-proof.yml delete mode 100644 .github/workflows/beamscale-supervisor-109-proof.yml delete mode 100644 .github/workflows/cargo-workspace-e2e.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/ores-compose-current-proof.yml delete mode 100644 .github/workflows/ores-lint.yml delete mode 100644 .github/workflows/ores-proc-isolation-pr12-proof.yml delete mode 100644 .github/workflows/registry-url-quality-candidate.yml delete mode 100644 .github/workflows/rust-lib-main-ancestry.yml delete mode 100644 .github/workflows/scintilla-context-abi-cert.yml delete mode 100644 .github/workflows/windows-file-registry-candidate.yml delete mode 100644 .github/workflows/zed-e2e.yml delete mode 100644 .github/workflows/zed-latest-deps-consumer.yml delete mode 100644 .ores-lint/README.md delete mode 100644 .ores-lint/VERSION delete mode 100644 .ores-lint/config.sh delete mode 100755 .ores-lint/dart.sh delete mode 100644 .ores-lint/eslint/base.mjs delete mode 100644 .ores-lint/eslint/formatter.mjs delete mode 100644 .ores-lint/eslint/plugin.mjs delete mode 100755 .ores-lint/gleam.sh delete mode 100755 .ores-lint/install-git-hooks.sh delete mode 100755 .ores-lint/js.sh delete mode 100755 .ores-lint/lint.sh delete mode 100644 .ores-lint/nested-repos.json delete mode 100644 .ores-lint/require-send.mjs delete mode 100644 .ores-lint/require-send.test.mjs delete mode 100644 .ores-lint/required-tools.json delete mode 100755 .ores-lint/rust.sh delete mode 100755 .ores-lint/selftest.sh delete mode 100644 .zpkg.toml create mode 100644 AGENTS.md delete mode 100644 CHANGELOG.md delete mode 100644 Cargo.lock delete mode 100644 LICENSE delete mode 100644 agents.md delete mode 100644 build.rs delete mode 100644 cert/beamscale-critical-section/HEADS.lock delete mode 100644 cert/beamscale-critical-section/infra-runtime-host/Cargo.toml delete mode 100644 cert/beamscale-critical-section/infra-runtime-host/src/artifact_store.rs delete mode 100644 cert/beamscale-critical-section/infra-runtime-host/src/bin/bmscl-runtime-identity-writer.rs delete mode 100644 cert/beamscale-critical-section/infra-runtime-host/src/lib.rs delete mode 100644 cert/beamscale-critical-section/infra-runtime-host/src/main.rs delete mode 100644 cert/beamscale-critical-section/infra-runtime-host/src/runtime_auth.rs delete mode 100644 cert/beamscale-critical-section/interfaces/module-projections/erlang/bmscl_module.erl delete mode 100644 cert/beamscale-critical-section/interfaces/module-projections/erlang/example_module.erl delete mode 100644 cert/beamscale-critical-section/interfaces/module-projections/gleam/module_contract.gleam delete mode 100644 cert/beamscale-critical-section/interfaces/module-projections/manifest.json delete mode 100644 cert/beamscale-critical-section/interfaces/package.json delete mode 100644 cert/beamscale-critical-section/interfaces/schema/critical-section-deployment.schema.json delete mode 100644 cert/beamscale-critical-section/interfaces/schema/critical-section-operations.schema.json delete mode 100644 cert/beamscale-critical-section/interfaces/schema/deployment-control.schema.json delete mode 100644 cert/beamscale-critical-section/interfaces/schema/host-security-incident-receipt.schema.json delete mode 100644 cert/beamscale-critical-section/interfaces/schema/host-security-incident.schema.json delete mode 100644 cert/beamscale-critical-section/interfaces/schema/module-contract.schema.json delete mode 100644 cert/beamscale-critical-section/interfaces/schema/tenant-runtime.schema.json delete mode 100644 cert/beamscale-critical-section/interfaces/schema/worker-manifest.schema.json delete mode 100644 cert/beamscale-critical-section/interfaces/tests/module-contract-parity.test.mjs delete mode 100644 cert/beamscale-critical-section/interfaces/tests/module-projections.test.mjs delete mode 100644 cert/beamscale-critical-section/interfaces/typespec/main.tsp delete mode 100644 cert/beamscale-critical-section/interfaces/typespec/security.tsp delete mode 100644 cert/beamscale-critical-section/supervisor/conformance/critical_section_formal_model.rs delete mode 100644 cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_actor.erl delete mode 100644 cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_registry.erl delete mode 100644 cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_store.erl delete mode 100644 cert/beamscale-critical-section/supervisor/src/bmscl_durable_store.erl delete mode 100644 cert/beamscale-critical-section/supervisor/src/bmscl_guest_control.erl delete mode 100644 cert/beamscale-critical-section/supervisor/src/bmscl_guest_protocol.erl delete mode 100644 cert/beamscale-critical-section/supervisor/src/bmscl_tenancy_policy.erl delete mode 100644 cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_persistence_tests.erl delete mode 100644 cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_protocol_tests.erl delete mode 100644 cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_tests.erl delete mode 100644 cert/beamscale-critical-section/supervisor/test/bmscl_test_durable_store.erl delete mode 100644 certification/beamscale-artifact-abi/compiler/Cargo.toml delete mode 100644 certification/beamscale-artifact-abi/compiler/build-source.rs delete mode 100644 certification/beamscale-artifact-abi/compiler/src/lib.rs delete mode 100644 certification/beamscale-artifact-abi/supervisor/bmscl_artifact.erl delete mode 100644 certification/beamscale-context-abi/gleam/gleam.toml delete mode 100644 certification/beamscale-context-abi/gleam/src/bmscl.gleam delete mode 100644 certification/beamscale-context-abi/gleam/src/bmscl/http_context.gleam delete mode 100644 certification/beamscale-context-abi/rust/Cargo.toml delete mode 100644 certification/beamscale-context-abi/rust/src/lib.rs delete mode 100644 certification/beamscale-context-abi/rust/src/module_contract.rs delete mode 100644 certification/scintilla-context-abi/Cargo.toml delete mode 100644 certification/scintilla-context-abi/SOURCE.lock.json delete mode 100644 certification/scintilla-context-abi/src/capabilities.rs delete mode 100644 certification/scintilla-context-abi/src/contracts.rs delete mode 100644 certification/scintilla-context-abi/src/dpm.rs delete mode 100644 certification/scintilla-context-abi/src/lib.rs delete mode 100644 certification/scintilla-context-abi/src/module_contract.rs delete mode 100644 certification/scintilla-context-abi/tests/contracts.rs delete mode 100644 certification/scintilla-context-abi/tests/module_contract_fixture.rs create mode 100644 docs/architecture.md delete mode 100644 docs/cargo-workspace-interop.md delete mode 100644 docs/interop-matrix.md create mode 100644 examples/emit_runtime_fixture.rs delete mode 100644 fixtures/cargo-workspace/.cargo/config.toml delete mode 100644 fixtures/cargo-workspace/.zpkg.toml delete mode 100644 fixtures/cargo-workspace/Cargo.lock delete mode 100644 fixtures/cargo-workspace/Cargo.toml delete mode 100644 fixtures/cargo-workspace/crates/app/Cargo.toml delete mode 100644 fixtures/cargo-workspace/crates/app/build.rs delete mode 100644 fixtures/cargo-workspace/crates/app/src/main.rs delete mode 100644 fixtures/cargo-workspace/crates/app/tests/renamed_dependency.rs delete mode 100644 fixtures/cargo-workspace/crates/support/Cargo.toml delete mode 100644 fixtures/cargo-workspace/crates/support/src/lib.rs create mode 100644 fixtures/diesel/schema.rs create mode 100644 fixtures/ores-orm.toml create mode 100644 fixtures/seaorm/users.rs delete mode 100644 proc-isolation-proof/.cli-flags.toml delete mode 100644 proc-isolation-proof/.ores-proc-isolation.yaml delete mode 100644 proc-isolation-proof/Cargo.toml delete mode 100644 proc-isolation-proof/PROOF.md delete mode 100644 proc-isolation-proof/examples/beamscale-hosted-gleam.yaml delete mode 100644 proc-isolation-proof/examples/loopback_fixture.rs delete mode 100644 proc-isolation-proof/proof-source.json delete mode 100644 proc-isolation-proof/scripts/linux/ores-proc-isolate.sh delete mode 100644 proc-isolation-proof/scripts/macos/ores-proc-isolate.sh delete mode 100644 proc-isolation-proof/src/config.rs delete mode 100644 proc-isolation-proof/src/error.rs delete mode 100644 proc-isolation-proof/src/flags.rs delete mode 100644 proc-isolation-proof/src/lib.rs delete mode 100644 proc-isolation-proof/src/main.rs delete mode 100644 proc-isolation-proof/src/platform/linux.rs delete mode 100644 proc-isolation-proof/src/platform/macos.rs delete mode 100644 proc-isolation-proof/src/platform/mod.rs delete mode 100644 proc-isolation-proof/src/runner.rs delete mode 100644 proc-isolation-proof/tests/beamscale_honeypot_profile.rs delete mode 100644 proc-isolation-proof/tests/linux_helper_tripwire_contract.rs delete mode 100644 proc-isolation-proof/tests/linux_network_helper_liveness_contract.rs delete mode 100644 proc-isolation-proof/tests/nixos_trusted_helper_contract.rs delete mode 100644 proc-isolation-proof/tests/policy.rs delete mode 100644 proc-isolation-proof/vendor/ores-reactive-maps/Cargo.toml delete mode 100644 proc-isolation-proof/vendor/ores-reactive-maps/LICENSE delete mode 100644 proc-isolation-proof/vendor/ores-reactive-maps/UPSTREAM.md delete mode 100644 proc-isolation-proof/vendor/ores-reactive-maps/src/lib.rs delete mode 100755 scripts/cargo-workspace-e2e.sh delete mode 100755 scripts/verify-rust-lib-main-ancestry.sh delete mode 100644 scripts/zed-e2e.sh create mode 100644 src/admission.rs create mode 100644 src/descriptor.rs create mode 100644 src/diesel.rs create mode 100644 src/emit/bundle.rs create mode 100644 src/emit/dart.rs create mode 100644 src/emit/gleam.rs create mode 100644 src/emit/json_schema.rs create mode 100644 src/emit/mod.rs create mode 100644 src/emit/naming.rs create mode 100644 src/emit/rust.rs create mode 100644 src/emit/typescript.rs create mode 100644 src/error.rs create mode 100644 src/ir.rs create mode 100644 src/lib.rs delete mode 100644 src/main.rs create mode 100644 src/parity.rs create mode 100644 src/policy.rs create mode 100644 src/seaorm.rs create mode 100644 src/shapes.rs create mode 100644 tests/admission.rs create mode 100644 tests/audit_v2.rs delete mode 100644 tests/cargo_zed_interop.rs create mode 100644 tests/diesel_hardening.rs create mode 100644 tests/emitters.rs create mode 100644 tests/hardening.rs create mode 100644 tests/parity.rs create mode 100644 tests/policy_provenance.rs delete mode 100755 tests/rust-lib-main-ancestry.sh create mode 100644 tests/wire_mapping_gates.rs diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 26f927c..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,5 +0,0 @@ -[net] -retry = 2 - -[http] -timeout = 30 diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index df7fe73..0000000 --- a/.gitattributes +++ /dev/null @@ -1,6 +0,0 @@ -* text=auto -Cargo.lock text eol=lf -*.toml text eol=lf -*.rs text eol=lf -*.sh text eol=lf -*.yml text eol=lf diff --git a/.github/docker/Dockerfile b/.github/docker/Dockerfile deleted file mode 100644 index a78506f..0000000 --- a/.github/docker/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM rust:1.97-bookworm AS zed-builder - -WORKDIR /src -COPY zed-interfaces ./zed-interfaces -COPY zed-cli ./zed-cli -WORKDIR /src/zed-cli -RUN cargo build --release --locked --bin zed - -FROM rust:1.97-bookworm - -COPY --from=zed-builder /src/zed-cli/target/release/zed /usr/local/bin/zed -WORKDIR /work diff --git a/.github/rust-lib-pin-policy.json b/.github/rust-lib-pin-policy.json deleted file mode 100644 index bea87bd..0000000 --- a/.github/rust-lib-pin-policy.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "schema": "zed-pkg-test/rust-lib-pin-policy/v1", - "repository": "https://github.com/zed-pkg-test/rust-lib.git", - "pin_sets": [ - { - "id": "cargo-registry-interop-v1", - "purpose": "Current Cargo registry, feature, workspace, lock, and offline interoperability suites.", - "certified_ref": "f3c4e33cd0e5726b2c668c0306936f01674261fa", - "reviewed_merge_ref": "49e3a6bc36bf0f96e971a1edc0c183fd1dcfa55b", - "workflows": [ - ".github/workflows/cargo-workspace-e2e.yml", - ".github/workflows/zed-e2e.yml" - ] - }, - { - "id": "windows-file-registry-baseline-v1", - "purpose": "Historical Windows file-registry baseline at the reviewed rust-lib 1.0.0 merge.", - "certified_ref": "f2534ffafbc4f7e900075dfc8e48b9259aced2fe", - "reviewed_merge_ref": "f2534ffafbc4f7e900075dfc8e48b9259aced2fe", - "workflows": [ - ".github/workflows/windows-file-registry-candidate.yml" - ] - } - ] -} diff --git a/.github/workflows/beamscale-artifact-abi-cert.yml b/.github/workflows/beamscale-artifact-abi-cert.yml deleted file mode 100644 index eedaadd..0000000 --- a/.github/workflows/beamscale-artifact-abi-cert.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: BeamScale artifact ABI exact-head certification - -on: - pull_request: - paths: - - "certification/beamscale-artifact-abi/**" - - ".github/workflows/beamscale-artifact-abi-cert.yml" - -permissions: - contents: read - -jobs: - exact-head-artifact-abi: - runs-on: ubuntu-latest - env: - BMSCL_COMPILER_HEAD: "5672b7dccf37ccc06d1750a18582c351e54a38b7" - BMSCL_SUPERVISOR_HEAD: "eb32d88a3540150feba1d9f1dfd8e11a3a6b0704" - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: erlef/setup-beam@v1 - with: - otp-version: "29.1.1" - - name: Record exact source heads - run: | - printf 'bmscl-compiler=%s\n' "$BMSCL_COMPILER_HEAD" - printf 'bmscl-supervisor=%s\n' "$BMSCL_SUPERVISOR_HEAD" - - name: Compile compiler manifest model - working-directory: certification/beamscale-artifact-abi/compiler - run: | - cargo fmt --all -- --check - cargo clippy --all-targets -- -D warnings - cargo test --all-targets - - name: Prove compiler build wiring emits both ABI fields - working-directory: certification/beamscale-artifact-abi/compiler - run: | - grep -F 'module_contract_version: MODULE_CONTRACT_V1' build-source.rs - grep -F 'context_abi: CONTEXT_ABI_V1' build-source.rs - - name: Compile and run supervisor artifact ABI EUnit - working-directory: certification/beamscale-artifact-abi/supervisor - run: | - mkdir -p ebin - erlc -DTEST -o ebin bmscl_artifact.erl - erl -noshell -pa ebin -eval 'case eunit:test(bmscl_artifact, [verbose]) of ok -> halt(0); _ -> halt(1) end.' diff --git a/.github/workflows/beamscale-context-abi-cert.yml b/.github/workflows/beamscale-context-abi-cert.yml deleted file mode 100644 index b1bb1bc..0000000 --- a/.github/workflows/beamscale-context-abi-cert.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: BeamScale context ABI exact-head certification - -on: - pull_request: - paths: - - "certification/beamscale-context-abi/**" - - ".github/workflows/beamscale-context-abi-cert.yml" - -permissions: - contents: read - -jobs: - exact-head-contracts: - runs-on: ubuntu-latest - env: - BMSCL_LIB_CORE_HEAD: "1c0e5ba53234cf40caf170f96a7a5b91a95377f8" - BMSCL_PUB_LIB_CORE_HEAD: "3617c64c315729b8733c2f66706349ef6994876a" - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: erlef/setup-beam@v1 - with: - otp-version: "29.1.1" - gleam-version: "1.18.1" - - name: Record exact source heads - run: | - printf 'bmscl-lib-core=%s\n' "$BMSCL_LIB_CORE_HEAD" - printf 'bmscl-pub-lib-core=%s\n' "$BMSCL_PUB_LIB_CORE_HEAD" - - name: Test Rust admission contract - working-directory: certification/beamscale-context-abi/rust - run: | - cargo fmt --all -- --check - cargo clippy --all-targets -- -D warnings - cargo test --all-targets - - name: Test Gleam public SDK contract - working-directory: certification/beamscale-context-abi/gleam - run: | - gleam format --check src - gleam check --target erlang - gleam build --target erlang diff --git a/.github/workflows/beamscale-critical-section-proof.yml b/.github/workflows/beamscale-critical-section-proof.yml deleted file mode 100644 index 51f604b..0000000 --- a/.github/workflows/beamscale-critical-section-proof.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: BeamScale critical-section funded exact-head proof - -on: - pull_request: - paths: - - ".github/workflows/beamscale-critical-section-proof.yml" - - "cert/beamscale-critical-section/**" - workflow_dispatch: - -permissions: - contents: read - -jobs: - runtime-host: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Verify PR40 source identities - shell: bash - run: | - set -euo pipefail - root=cert/beamscale-critical-section/infra-runtime-host - test "$(git hash-object "$root/Cargo.toml")" = "2f13a3235f5acc68eb8bd4e90a8a194340366589" - test "$(git hash-object "$root/src/artifact_store.rs")" = "05484a58d4e3b9d10230ef993926053297058cb0" - test "$(git hash-object "$root/src/bin/bmscl-runtime-identity-writer.rs")" = "ab5a59b117f65bef4d3458d8f00146f106fbeb9f" - test "$(git hash-object "$root/src/lib.rs")" = "17e2fdf23f816b2333ee26027f6321df5dc6ee6b" - test "$(git hash-object "$root/src/main.rs")" = "9710df3812508ae87d26a7a19c2036be17506e29" - test "$(git hash-object "$root/src/runtime_auth.rs")" = "8d5a60f7ed5f39a7408bcd8e95286664bc9e9a9e" - - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 - with: - toolchain: stable - components: rustfmt, clippy - - name: Prove exact runtime-host PR40 crate - run: | - cargo fmt --manifest-path cert/beamscale-critical-section/infra-runtime-host/Cargo.toml -- --check - cargo clippy --manifest-path cert/beamscale-critical-section/infra-runtime-host/Cargo.toml --all-targets --all-features -- -D warnings - cargo test --manifest-path cert/beamscale-critical-section/infra-runtime-host/Cargo.toml --all-targets - - supervisor: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Verify PR96 source identities - shell: bash - run: | - set -euo pipefail - root=cert/beamscale-critical-section/supervisor - test "$(git hash-object "$root/conformance/critical_section_formal_model.rs")" = "883cac38f1d98aacc17f7f15d6681499eebefa17" - test "$(git hash-object "$root/src/bmscl_critical_section_actor.erl")" = "2905386b17430208a70a3d9a447e1d4d3ceba1f4" - test "$(git hash-object "$root/src/bmscl_critical_section_registry.erl")" = "64e629b14078197d2a00c950f236b7bcac7c6136" - test "$(git hash-object "$root/src/bmscl_critical_section_store.erl")" = "85bafff16ed945b171b12fdb24225be0db7efa2d" - test "$(git hash-object "$root/src/bmscl_durable_store.erl")" = "b9d2c0ee675aae185507729327cca94d1c24270e" - test "$(git hash-object "$root/src/bmscl_guest_control.erl")" = "9d45d0b8dc5f290e2c154c5387a112c5b4b7a6e6" - test "$(git hash-object "$root/src/bmscl_guest_protocol.erl")" = "b7db124a35870092f2db425c96dd9cf4483ac7df" - test "$(git hash-object "$root/src/bmscl_tenancy_policy.erl")" = "73384001b07a5935b8254b144967a3f54e5ceff9" - test "$(git hash-object "$root/test/bmscl_critical_section_persistence_tests.erl")" = "88c442f00ee5da36059a6bd0bbe82439bfc9143d" - test "$(git hash-object "$root/test/bmscl_critical_section_protocol_tests.erl")" = "b28c2ecc2e8c626cc4f249ae2a5e7906bd32f42c" - test "$(git hash-object "$root/test/bmscl_critical_section_tests.erl")" = "fe0911b2e5930035e5ef532153f409fbafad8c93" - test "$(git hash-object "$root/test/bmscl_test_durable_store.erl")" = "aab6eb222b2cabf5ea92e2497cdc08f9681bb60b" - - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 - with: - toolchain: stable - - name: Exhaust exact current failover model - run: | - rustc --edition=2021 -D warnings cert/beamscale-critical-section/supervisor/conformance/critical_section_formal_model.rs -o /tmp/model - /tmp/model - - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 - with: - otp-version: "27.3" - rebar3-version: "3.24.0" - - name: Run exact current EUnit suite - shell: bash - run: | - set -euo pipefail - root=cert/beamscale-critical-section/supervisor - tmp="$(mktemp -d)" - mkdir -p "$tmp/src" "$tmp/test" - cp "$root"/src/*.erl "$tmp/src/" - cp "$root"/test/*.erl "$tmp/test/" - cat > "$tmp/rebar.config" <<'EOF' - {erl_opts, [debug_info, warn_unused_import, warn_unused_vars]}. - {deps, [{jsx, "3.1.0"}]}. - {eunit_opts, [verbose]}. - EOF - cat > "$tmp/src/bmscl_supervisor.app.src" <<'EOF' - {application, bmscl_supervisor, - [{description, "critical-section certification harness"}, - {vsn, "0.0.0"}, - {applications, [kernel, stdlib, crypto, jsx]}, - {env, [{critical_section_max_lease_ms, 300000}]}, - {modules, []}]}. - EOF - cd "$tmp" - rebar3 eunit - - interfaces: - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Verify PR9 source identities - shell: bash - run: | - set -euo pipefail - root=cert/beamscale-critical-section/interfaces - test "$(git hash-object "$root/package.json")" = "b949358d4c6a0f48ea55726f5c14eaa117635953" - test "$(git hash-object "$root/typespec/main.tsp")" = "24a77ff8a5f5dd3fa1aa61fd55f4b6aaa0fb9b4f" - test "$(git hash-object "$root/typespec/security.tsp")" = "5004133981dc282c06cb6190efb4ea217da31a4b" - test "$(git hash-object "$root/schema/critical-section-deployment.schema.json")" = "4656122c534324e59abab12ca517bb89028c9d7a" - test "$(git hash-object "$root/schema/critical-section-operations.schema.json")" = "80cf48087eb248fb929d358c10de8deb8d3e2994" - test "$(git hash-object "$root/schema/module-contract.schema.json")" = "7a6ee6da67cfdae4e6b3977619a9b800c53057f6" - test "$(git hash-object "$root/schema/tenant-runtime.schema.json")" = "a750057d82eaa639bb476d857c8c0d33f2ad389d" - test "$(git hash-object "$root/schema/worker-manifest.schema.json")" = "92415066abd8830671ac2af300230792ba1ddb11" - test "$(git hash-object "$root/tests/module-contract-parity.test.mjs")" = "ec0c0687464c914e15f9edcd8f0f0899fd866a25" - test "$(git hash-object "$root/tests/module-projections.test.mjs")" = "7b4e3b6e938b873cd30d9e4e5a11fdeb601d981e" - test "$(git hash-object "$root/module-projections/manifest.json")" = "9b7b3d895bab58731fe1d5c329d539136a617ba9" - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - name: Prove exact current interface contracts - working-directory: cert/beamscale-critical-section/interfaces - run: | - npm install - npm run check diff --git a/.github/workflows/beamscale-supervisor-109-proof.yml b/.github/workflows/beamscale-supervisor-109-proof.yml deleted file mode 100644 index 9c54b7c..0000000 --- a/.github/workflows/beamscale-supervisor-109-proof.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: BeamScale supervisor #109 exact-head proof - -on: - pull_request: - push: - branches: - - proof/beamscale-supervisor-109-cowboy-20260926 - -permissions: - contents: read - -jobs: - prove: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - env: - TEST_FLEET_READ_TOKEN: ${{ secrets.TEST_FLEET_READ_TOKEN }} - SOURCE_SHA: a701fdada843424e00874e64ed209abd96442385 - steps: - - name: Require private-source read credential - shell: bash - run: | - set -euo pipefail - [[ -n "$TEST_FLEET_READ_TOKEN" ]] || { - echo 'TEST_FLEET_READ_TOKEN is required for exact private-source certification' >&2 - exit 78 - } - - name: Check out exact BeamScale supervisor head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: beamscale/bmscl-supervisor - ref: ${{ env.SOURCE_SHA }} - token: ${{ secrets.TEST_FLEET_READ_TOKEN }} - persist-credentials: false - path: source - - name: Verify immutable source head - shell: bash - run: | - set -euo pipefail - test "$(git -C source rev-parse HEAD)" = "$SOURCE_SHA" - - name: Set up OTP, Rebar3, and Gleam - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1 - with: - otp-version: '27.3' - rebar3-version: '3.24.0' - gleam-version: '1.18.0' - - name: Compile - working-directory: source - run: rebar3 compile - - name: EUnit - working-directory: source - run: rebar3 eunit - - name: Xref - working-directory: source - run: rebar3 xref - - name: Prove Hosted Gleam ABI fixture - working-directory: source - shell: bash - run: | - set -euo pipefail - cd test/fixtures/hosted_abi - gleam build - beam_dir="$(dirname "$(find build -type f -name worker.beam -print -quit)")" - test -n "$beam_dir" - erl -noshell -pa "$beam_dir" -eval ' - Request = {request, <<"POST">>, <<"/abi">>, - [{<<"content-type">>, <<"application/octet-stream">>}], - <<0,1,2>>}, - Context = {context, - {cluster_capability, <<"cluster">>}, - {some, {http_capability, <<"http">>}}, - {log_capability, <<"log">>}, - none, - 123}, - {response, 201, Headers, <<0,1,2>>} = worker:handle(Request, Context), - true = lists:member({<<"x-method">>, <<"POST">>}, Headers), - true = lists:member({<<"x-url">>, <<"/abi">>}, Headers), - true = lists:member({<<"x-capabilities">>, <<"http-only">>}, Headers), - true = lists:member({<<"x-deadline">>, <<"123">>}, Headers), - halt(0).' - - name: Build self-contained OTP release - working-directory: source - run: rebar3 release - - name: Smoke self-contained release files - working-directory: source - shell: bash - run: | - set -euo pipefail - rel=_build/default/rel/bmscl_supervisor - test -x "$rel/bin/bmscl_supervisor" - test -f "$rel/releases/0.1.0/start.boot" - test -n "$(find "$rel" -path '*/erts-*/bin/beam.smp' -type f -print -quit)" diff --git a/.github/workflows/cargo-workspace-e2e.yml b/.github/workflows/cargo-workspace-e2e.yml deleted file mode 100644 index b38ce75..0000000 --- a/.github/workflows/cargo-workspace-e2e.yml +++ /dev/null @@ -1,258 +0,0 @@ -name: Cargo workspace and Zed interoperability - -on: - pull_request: - paths: - - .github/docker/Dockerfile - - .github/workflows/cargo-workspace-e2e.yml - - docs/cargo-workspace-interop.md - - fixtures/cargo-workspace/** - - scripts/cargo-workspace-e2e.sh - push: - branches: [main] - paths: - - .github/docker/Dockerfile - - .github/workflows/cargo-workspace-e2e.yml - - docs/cargo-workspace-interop.md - - fixtures/cargo-workspace/** - - scripts/cargo-workspace-e2e.sh - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - ZED_INTERFACES_REF: d1bf8ef7c88a75292cbe8d697bfd269f30d62e2b - ZED_CLI_REF: de774cc9fc5b786d85e9b5ff19251675f0e9391b - RUST_LIB_REF: f3c4e33cd0e5726b2c668c0306936f01674261fa - -jobs: - workflow-contract: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Check out exact revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - show-progress: false - - - name: Validate workflow - uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 - with: - args: .github/workflows/cargo-workspace-e2e.yml - - - name: Validate executable suite - run: bash -n scripts/cargo-workspace-e2e.sh - - - name: Verify immutable references and manifest ownership - shell: bash - run: | - set -euo pipefail - for value in "$ZED_INTERFACES_REF" "$ZED_CLI_REF" "$RUST_LIB_REF"; do - [[ "$value" =~ ^[0-9a-f]{40}$ ]] - done - python3 - <<'PY' - import tomllib - from pathlib import Path - - root = Path("fixtures/cargo-workspace") - with (root / "Cargo.toml").open("rb") as handle: - cargo = tomllib.load(handle) - with (root / ".zpkg.toml").open("rb") as handle: - zed = tomllib.load(handle) - with (root / ".cargo/config.toml").open("rb") as handle: - config = tomllib.load(handle) - with (root / "Cargo.lock").open("rb") as handle: - lock = tomllib.load(handle) - - workspace = cargo["workspace"] - assert workspace["resolver"] == "2" - assert workspace["members"] == ["crates/app", "crates/support"] - assert workspace["package"] == { - "version": "0.1.0", - "edition": "2021", - "rust-version": "1.88", - "license": "MIT", - } - assert workspace["dependencies"]["zed-rust-lib"] == { - "package": "rust-lib", - "version": "=1.0.1", - "default-features": False, - } - assert workspace["dependencies"]["unicode-ident"] == "=1.0.24" - assert config["paths"] == [".vendor/.zed/zed-pkg-test/rust-lib"] - assert config["patch"]["crates-io"]["rust-lib"]["path"] == config["paths"][0] - assert zed["dependencies"] == {"zed-pkg-test/rust-lib": "^1.0.1"} - assert lock["version"] == 4 - assert {package["name"] for package in lock["package"]} == { - "itoa", - "rust-lib", - "unicode-ident", - "workspace-app", - "workspace-support", - } - assert not (root / ".zpkg.lock").exists() - PY - - cargo-matrix: - needs: workflow-contract - strategy: - fail-fast: false - matrix: - os: - - ubuntu-24.04 - - macos-15 - - windows-2025 - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - steps: - - name: Check out exact revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - show-progress: false - - - name: Install pinned Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: "3.12" - - - name: Install pinned Rust toolchain - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: "1.97.0" - components: rustfmt, clippy - - - name: Materialize exact Zed package fixture - shell: bash - run: | - set -euo pipefail - target=fixtures/cargo-workspace/.vendor/.zed/zed-pkg-test/rust-lib - mkdir -p "$(dirname "$target")" - git init -q "$target" - git -C "$target" remote add origin https://github.com/zed-pkg-test/rust-lib.git - git -C "$target" fetch --depth 1 origin "$RUST_LIB_REF" - git -C "$target" checkout -q --detach FETCH_HEAD - test "$(git -C "$target" rev-parse HEAD)" = "$RUST_LIB_REF" - - - name: Run locked Cargo workspace matrix - shell: bash - run: | - set -euo pipefail - cd fixtures/cargo-workspace - cargo_lock_hash="$(git hash-object Cargo.lock)" - cargo fmt --all -- --check - cargo clippy --locked --workspace --all-targets -- -D warnings - cargo test --locked --workspace --all-targets - cargo run --locked --quiet -p workspace-app - cargo metadata --locked --format-version 1 > cargo-metadata.json - cargo tree --locked -p workspace-app -e features > cargo-features.txt - cargo run --locked --offline --quiet -p workspace-app - cargo test --locked --offline --workspace --all-targets - test "$(git hash-object Cargo.lock)" = "$cargo_lock_hash" - test ! -e .zpkg.lock - - - name: Validate workspace, alias, target, and registry provenance - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - import json - from pathlib import Path - - root = Path("fixtures/cargo-workspace") - metadata = json.loads((root / "cargo-metadata.json").read_text()) - packages = {package["name"]: package for package in metadata["packages"]} - assert {"workspace-app", "workspace-support"} == { - package["name"] - for package in metadata["packages"] - if package["id"] in metadata["workspace_members"] - } - assert packages["rust-lib"]["version"] == "1.0.1" - assert packages["rust-lib"]["source"] is None - assert packages["itoa"]["version"] == "1.0.18" - assert packages["unicode-ident"]["version"] == "1.0.24" - assert packages["itoa"]["source"].startswith("registry+") - assert packages["unicode-ident"]["source"].startswith("registry+") - - renamed = [ - dependency - for dependency in packages["workspace-app"]["dependencies"] - if dependency["name"] == "rust-lib" - ] - assert {dependency["kind"] for dependency in renamed} == {"build", "dev"} - assert all(dependency["rename"] == "zed-rust-lib" for dependency in renamed) - build = next(dependency for dependency in renamed if dependency["kind"] == "build") - dev = next(dependency for dependency in renamed if dependency["kind"] == "dev") - assert build["uses_default_features"] is False - assert build["features"] == [] - assert dev["features"] == ["uppercase"] - - support = packages["workspace-support"]["dependencies"] - assert len(support) == 1 - assert support[0]["name"] == "rust-lib" - assert support[0]["rename"] == "zed-rust-lib" - assert support[0]["kind"] is None - assert support[0]["features"] == ["uppercase"] - assert support[0]["target"] == "cfg(any(unix, windows))" - PY - - - name: Upload Cargo workspace provenance - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: cargo-workspace-provenance-${{ matrix.os }} - path: | - fixtures/cargo-workspace/cargo-metadata.json - fixtures/cargo-workspace/cargo-features.txt - if-no-files-found: warn - retention-days: 7 - - - name: Verify source and lock cleanliness - if: ${{ always() }} - shell: bash - run: | - set -euo pipefail - rm -rf \ - fixtures/cargo-workspace/.vendor \ - fixtures/cargo-workspace/.zed \ - fixtures/cargo-workspace/.zpkg.lock \ - fixtures/cargo-workspace/target \ - fixtures/cargo-workspace/cargo-metadata.json \ - fixtures/cargo-workspace/cargo-features.txt - git diff --exit-code -- fixtures/cargo-workspace - test -z "$(git status --porcelain --untracked-files=all)" - - zed-registry-workspace-e2e: - needs: workflow-contract - runs-on: ubuntu-24.04 - timeout-minutes: 50 - steps: - - name: Check out exact revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - show-progress: false - - - name: Run pinned Zed adapter and Cargo workspace suite - shell: bash - run: | - set -o pipefail - bash scripts/cargo-workspace-e2e.sh 2>&1 | tee "$RUNNER_TEMP/cargo-workspace-e2e.log" - - - name: Upload interoperability diagnostics - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: cargo-workspace-e2e-log - path: ${{ runner.temp }}/cargo-workspace-e2e.log - if-no-files-found: warn - retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e86620e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,174 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + +jobs: + rust: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout exact revision + uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a + with: + ref: ${{ env.EXPECTED_SHA }} + persist-credentials: false + - name: Verify exact revision + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Rust toolchain + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de + with: + toolchain: 1.88.0 + components: rustfmt,clippy + - name: Formatting + run: cargo fmt --all -- --check + - name: Check + run: cargo check --all-targets + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + - name: Tests + run: cargo test --all-targets + + generated-runtime-surfaces: + needs: rust + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact revision + uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a + with: + ref: ${{ env.EXPECTED_SHA }} + persist-credentials: false + - name: Verify exact revision + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Rust toolchain + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de + with: + toolchain: 1.88.0 + - name: Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 22.16.0 + - name: Dart + uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d + with: + sdk: 3.13.3 + - name: Gleam and Erlang + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 + with: + otp-version: '29.1' + gleam-version: '1.18.1' + rebar3-version: '3' + - name: Emit exact runtime fixture + env: + ORES_ORM_RUNTIME_FIXTURE_DIR: ${{ runner.temp }}/ores-orm-generated + run: cargo run --quiet --example emit_runtime_fixture + - name: Compile generated Rust DTO + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-rust + run: | + mkdir -p "$WORK/src" + cp "$GEN"/rust/*.rs "$WORK/src/lib.rs" + cat > "$WORK/Cargo.toml" <<'TOML' + [package] + name = "ores-orm-generated-runtime-witness" + version = "0.0.0" + edition = "2024" + publish = false + + [dependencies] + serde = { version = "1.0.228", features = ["derive"] } + serde_json = "1.0.145" + TOML + cargo check --quiet --manifest-path "$WORK/Cargo.toml" + - name: Type-check generated TypeScript and Zod + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-typescript + run: | + mkdir -p "$WORK" + cp "$GEN"/typescript/*.ts "$WORK/index.ts" + cat > "$WORK/package.json" <<'JSON' + { + "private": true, + "type": "module", + "dependencies": { + "zod": "4.6.5" + }, + "devDependencies": { + "typescript": "7.0.2" + } + } + JSON + ( + cd "$WORK" + npm install --ignore-scripts --no-audit --no-fund + ./node_modules/.bin/tsc --noEmit --strict --skipLibCheck false --target ES2022 --module NodeNext --moduleResolution NodeNext index.ts + ) + - name: Analyze generated Dart validator + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-dart + run: | + mkdir -p "$WORK/lib" + cp "$GEN"/dart/*.dart "$WORK/lib/orm_derived.dart" + cat > "$WORK/pubspec.yaml" <<'YAML' + name: ores_orm_generated_runtime_witness + version: 0.0.0 + environment: + sdk: '>=3.13.0 <4.0.0' + YAML + ( + cd "$WORK" + dart pub get + dart analyze --fatal-infos --fatal-warnings + ) + - name: Compile generated Gleam decoder + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-gleam + run: | + mkdir -p "$WORK/src" + cp "$GEN"/gleam/*.gleam "$WORK/src/orm_derived.gleam" + cat > "$WORK/gleam.toml" <<'TOML' + name = "ores_orm_generated_runtime_witness" + version = "0.0.0" + target = "erlang" + + [dependencies] + gleam_stdlib = "1.0.5" + TOML + ( + cd "$WORK" + gleam deps download + gleam check + ) + - name: Parse generated Draft 2020-12 JSON Schema and manifest + env: + GEN: ${{ runner.temp }}/ores-orm-generated + run: | + node --input-type=module <<'NODE' + import { readFileSync, readdirSync } from 'node:fs'; + import { join } from 'node:path'; + const root = process.env.GEN; + const schemaName = readdirSync(join(root, 'json-schema')).find((name) => name.endsWith('.json')); + if (!schemaName) throw new Error('missing JSON Schema artifact'); + const schema = JSON.parse(readFileSync(join(root, 'json-schema', schemaName), 'utf8')); + const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); + if (schema.$schema !== 'https://json-schema.org/draft/2020-12/schema') throw new Error('wrong JSON Schema dialect'); + if (manifest.publication !== 'blocked_pending_contract_admission') throw new Error('public derivative must remain blocked pending TJSV admission'); + NODE diff --git a/.github/workflows/ores-compose-current-proof.yml b/.github/workflows/ores-compose-current-proof.yml deleted file mode 100644 index 202c37d..0000000 --- a/.github/workflows/ores-compose-current-proof.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: ores-compose current exact-head proof - -on: - pull_request: - -permissions: - contents: read - -jobs: - proof: - strategy: - fail-fast: false - matrix: - include: - - pr: 187 - sha: 2d5fbe9c7ef42d7112afc46f75cc184f521332f1 - - pr: 191 - sha: 17a5e9cf654447e6299872c383d056bcf9f1cdeb - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - TEST_FLEET_READ_TOKEN: ${{ secrets.TEST_FLEET_READ_TOKEN }} - steps: - - name: Require private-source read credential - shell: bash - run: | - set -euo pipefail - [[ -n "$TEST_FLEET_READ_TOKEN" ]] || { - echo 'TEST_FLEET_READ_TOKEN is required for exact private-source certification' >&2 - exit 78 - } - - name: Check out exact ores-compose head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ORESoftware/ores-compose - ref: ${{ matrix.sha }} - token: ${{ secrets.TEST_FLEET_READ_TOKEN }} - persist-credentials: false - path: source - - name: Verify immutable source head - shell: bash - run: | - set -euo pipefail - test "$(git -C source rev-parse HEAD)" = "${{ matrix.sha }}" - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: 1.88.0 - components: rustfmt, clippy - - name: Format - working-directory: source - run: cargo fmt --all --check - - name: Clippy - working-directory: source - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - - name: Test - working-directory: source - run: cargo test --workspace --all-targets --all-features --locked diff --git a/.github/workflows/ores-lint.yml b/.github/workflows/ores-lint.yml deleted file mode 100644 index 0bec94c..0000000 --- a/.github/workflows/ores-lint.yml +++ /dev/null @@ -1,60 +0,0 @@ -# Managed by .ores-lint/ - regenerated by the rollout script. -name: ores-lint - -on: - pull_request: - workflow_dispatch: - # Add `push:` here once this repo's lint debt is paid down. - -permissions: - contents: read - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - persist-credentials: false - show-progress: false - - - name: Detect project types - id: detect - run: | - { [ -f package.json ] || git ls-files '*.js' '*.mjs' '*.cjs' '*.ts' '*.tsx' | grep -q .; } \ - && echo "js=true" >> "$GITHUB_OUTPUT" || echo "js=false" >> "$GITHUB_OUTPUT" - { [ -f Cargo.toml ] || git ls-files '*.rs' | grep -q .; } \ - && echo "rs=true" >> "$GITHUB_OUTPUT" || echo "rs=false" >> "$GITHUB_OUTPUT" - { [ -f pubspec.yaml ] || git ls-files '*.dart' | grep -q .; } \ - && echo "dart=true" >> "$GITHUB_OUTPUT" || echo "dart=false" >> "$GITHUB_OUTPUT" - { [ -f gleam.toml ] || git ls-files '*.gleam' | grep -q .; } \ - && echo "gleam=true" >> "$GITHUB_OUTPUT" || echo "gleam=false" >> "$GITHUB_OUTPUT" - - # Node is the runner for ESLint and for the cross-language require-send - # scanner, so it is installed whenever any supported language is present. - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - if: steps.detect.outputs.js == 'true' || steps.detect.outputs.rs == 'true' || steps.detect.outputs.dart == 'true' || steps.detect.outputs.gleam == 'true' - with: - node-version: '22' - - # ESLint is a global tool here, not a repo dependency - so this job never - # runs `npm install` and never needs the repo's node_modules. - - name: Install eslint globally - if: steps.detect.outputs.js == 'true' - run: npm i -g eslint typescript-eslint - - - name: Install clippy - if: steps.detect.outputs.rs == 'true' - run: rustup component add clippy || true - - # Dart/Gleam SDKs are optional. When absent, dart.sh / gleam.sh skip - # with an actionable message; require-send.mjs still runs via Node. - - uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d # v1 - if: steps.detect.outputs.dart == 'true' - continue-on-error: true - - - name: Run ores-lint - run: sh .ores-lint/lint.sh - env: - # Warn-only. Set to 1 here to make lint findings fail this repo's CI. - ORES_LINT_STRICT: '0' diff --git a/.github/workflows/ores-proc-isolation-pr12-proof.yml b/.github/workflows/ores-proc-isolation-pr12-proof.yml deleted file mode 100644 index 8a8f299..0000000 --- a/.github/workflows/ores-proc-isolation-pr12-proof.yml +++ /dev/null @@ -1,153 +0,0 @@ -name: ores-proc-isolation-pr12-proof - -on: - push: - branches: [ores-proc-isolation-pr12-proof-20260925] - pull_request: - paths: - - "proc-isolation-proof/**" - - ".github/workflows/ores-proc-isolation-pr12-proof.yml" - -permissions: - contents: read - -jobs: - exact-head: - strategy: - fail-fast: false - matrix: - os: [ubuntu-24.04, macos-15] - runs-on: ${{ matrix.os }} - timeout-minutes: 20 - defaults: - run: - working-directory: proc-isolation-proof - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: 1.88.0 - components: rustfmt, clippy - - name: Verify exact source blobs - shell: bash - run: | - python3 - <<'PY' - import json, subprocess - from pathlib import Path - manifest=json.loads(Path("proof-source.json").read_text()) - assert manifest["sourceCommit"] == "221449a9815808128d30fdb2e58d3e3f0e5ab392" - for path, expected in manifest["files"].items(): - actual=subprocess.check_output(["git","hash-object",path], text=True).strip() - if actual != expected: - raise SystemExit(f"{path}: expected {expected} got {actual}") - print(f"exact blob proof OK: {len(manifest['files'])} files") - PY - - name: Install Linux helpers - if: runner.os == 'Linux' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y apparmor-profiles apparmor-utils bubblewrap slirp4netns iproute2 iptables shellcheck curl - if [[ "$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || printf '0')" == '1' ]]; then - profile=/usr/share/apparmor/extra-profiles/bwrap-userns-restrict - test -f "$profile" - sudo install -m 0644 "$profile" /etc/apparmor.d/bwrap-userns-restrict - sudo apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict - fi - - name: Static and Rust checks - shell: bash - run: | - bash -n scripts/linux/ores-proc-isolate.sh - bash -n scripts/macos/ores-proc-isolate.sh - if [[ "${RUNNER_OS}" == Linux ]]; then - shellcheck scripts/linux/ores-proc-isolate.sh scripts/macos/ores-proc-isolate.sh - fi - cargo fmt --all -- --check - cargo clippy --all-targets -- -D warnings - cargo test --all-targets - cargo run --quiet -- check --config .ores-proc-isolation.yaml - - name: Linux backend and smoke - if: runner.os == 'Linux' - shell: bash - run: | - cargo run --quiet -- doctor --config .ores-proc-isolation.yaml - cargo build --quiet --examples --bins - cat > /tmp/ores-pi-smoke.yaml <<'YAML' - version: 1 - defaults: - group: no-net - groups: - no-net: - network: - mode: none - external: - network: - mode: external - deny_loopback: true - deny_private_networks: true - processes: - true: - group: no-net - command: [/usr/bin/true] - passwd: - group: no-net - command: [/usr/bin/cat, /etc/passwd] - internet: - group: external - command: [/usr/bin/curl, --fail, --silent, --show-error, --head, https://example.com] - curl-flex: - group: external - command: [/usr/bin/curl, --fail, --silent, --show-error] - YAML - target/debug/ores-proc-isolation run true --config /tmp/ores-pi-smoke.yaml - if target/debug/ores-proc-isolation run passwd --config /tmp/ores-pi-smoke.yaml; then - echo "sandbox unexpectedly read /etc/passwd" >&2; exit 1 - fi - target/debug/ores-proc-isolation run internet --config /tmp/ores-pi-smoke.yaml - target/debug/examples/loopback_fixture 127.0.0.1:18080 >/tmp/ores-pi-http.log 2>&1 & - server=$! - trap 'kill "$server" 2>/dev/null || true' EXIT - sleep 1 - curl --fail --silent http://127.0.0.1:18080 >/dev/null - if target/debug/ores-proc-isolation run curl-flex --config /tmp/ores-pi-smoke.yaml -- http://127.0.0.1:18080; then - echo "sandbox unexpectedly reached host loopback" >&2; exit 1 - fi - - name: macOS backend and local-development smoke - if: runner.os == 'macOS' - shell: bash - run: | - set +e - cargo run --quiet -- doctor --config .ores-proc-isolation.yaml - rc=$? - set -e - test "$rc" -eq 2 - cargo build --quiet --examples --bins - cat > /tmp/ores-pi-smoke.yaml <<'YAML' - version: 1 - defaults: - group: local-dev - groups: - local-dev: - filesystem: - read_only: [/usr/bin, /bin] - read_write: [/tmp/ores-pi-workspace] - network: - mode: local - deny_loopback: false - deny_private_networks: false - processes: - identity: - group: local-dev - command: [/usr/bin/id, -u] - local-writer: - group: local-dev - command: [/bin/sh, -c, "printf allowed > inside.txt"] - working_directory: /tmp/ores-pi-workspace - YAML - rm -rf /tmp/ores-pi-workspace && mkdir -p /tmp/ores-pi-workspace - actual=$(target/debug/ores-proc-isolation run identity --config /tmp/ores-pi-smoke.yaml) - test "$actual" = "$(id -u)" - target/debug/ores-proc-isolation run local-writer --config /tmp/ores-pi-smoke.yaml - test "$(cat /tmp/ores-pi-workspace/inside.txt)" = allowed diff --git a/.github/workflows/registry-url-quality-candidate.yml b/.github/workflows/registry-url-quality-candidate.yml deleted file mode 100644 index 1ea1477..0000000 --- a/.github/workflows/registry-url-quality-candidate.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Registry URL candidate quality - -on: - pull_request: - paths: - - .github/workflows/registry-url-quality-candidate.yml - workflow_dispatch: - inputs: - zed_cli_ref: - description: Exact 40-character zed-pkg/zed-cli commit to certify - required: false - default: d9da1e3ccfd98ee8c250e7d35a18dd22f398d4dc - type: string - -permissions: - contents: read - -concurrency: - group: registry-url-quality-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -env: - ZED_CLI_REF: ${{ inputs.zed_cli_ref || 'd9da1e3ccfd98ee8c250e7d35a18dd22f398d4dc' }} - ZED_INTERFACES_REF: dc0e0a0620b9462817950b552d3d334a184b1cb1 - CARGO_TERM_COLOR: always - -jobs: - quality: - name: Registry URL quality / ${{ matrix.label }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-24.04 - label: Linux - - os: windows-2025 - label: Windows - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - defaults: - run: - shell: bash - steps: - - name: Check out certification repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - persist-credentials: false - show-progress: false - - - name: Check out exact Zed candidate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: zed-pkg/zed-cli - ref: ${{ env.ZED_CLI_REF }} - path: zed-cli - fetch-depth: 1 - persist-credentials: false - show-progress: false - - - name: Check out pinned Zed interfaces - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: zed-pkg/zed-interfaces - ref: ${{ env.ZED_INTERFACES_REF }} - path: zed-interfaces - fetch-depth: 1 - persist-credentials: false - show-progress: false - - - name: Verify immutable inputs - run: | - set -euo pipefail - [[ "$ZED_CLI_REF" =~ ^[0-9a-f]{40}$ ]] - [[ "$ZED_INTERFACES_REF" =~ ^[0-9a-f]{40}$ ]] - test "$(git -C zed-cli rev-parse HEAD)" = "$ZED_CLI_REF" - test "$(git -C zed-interfaces rev-parse HEAD)" = "$ZED_INTERFACES_REF" - - - name: Install stable Rust with lint/format components - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - rustc --version --verbose - cargo --version --verbose - - - name: Format registry URL candidate - working-directory: zed-cli - run: cargo fmt --all --check - - - name: Run focused registry tests - working-directory: zed-cli - run: cargo test --locked --lib registry::tests -- --nocapture - - - name: Run strict library Clippy - working-directory: zed-cli - run: cargo clippy --locked --lib -- -D warnings diff --git a/.github/workflows/rust-lib-main-ancestry.yml b/.github/workflows/rust-lib-main-ancestry.yml deleted file mode 100644 index 1722cc2..0000000 --- a/.github/workflows/rust-lib-main-ancestry.yml +++ /dev/null @@ -1,150 +0,0 @@ -name: rust-lib main ancestry ratchet - -on: - pull_request: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - verify: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Check out exact revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - show-progress: false - - - name: Validate workflow - uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 - with: - args: .github/workflows/rust-lib-main-ancestry.yml - - - name: Validate executable scripts - shell: bash - run: | - set -euo pipefail - bash -n scripts/verify-rust-lib-main-ancestry.sh - bash -n tests/rust-lib-main-ancestry.sh - - - name: Prove local negative and positive cases - run: bash tests/rust-lib-main-ancestry.sh - - - name: Validate and compile the reviewed pin policy - env: - POLICY_TSV: ${{ runner.temp }}/rust-lib-pin-policy.tsv - shell: bash - run: | - set -euo pipefail - python3 - "$POLICY_TSV" <<'PY' - import json - import re - import sys - from pathlib import Path - - policy_path = Path(".github/rust-lib-pin-policy.json") - policy = json.loads(policy_path.read_text(encoding="utf-8")) - assert set(policy) == {"schema", "repository", "pin_sets"} - assert policy["schema"] == "zed-pkg-test/rust-lib-pin-policy/v1" - assert policy["repository"] == "https://github.com/zed-pkg-test/rust-lib.git" - assert isinstance(policy["pin_sets"], list) and policy["pin_sets"] - - sha = re.compile(r"^[0-9a-f]{40}$") - identifier = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$") - declaration = re.compile(r"(?m)^ RUST_LIB_REF: ([0-9a-f]{40})$") - expected_fields = { - "id", - "purpose", - "certified_ref", - "reviewed_merge_ref", - "workflows", - } - - declared = {} - workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) - workflow_paths += sorted(Path(".github/workflows").glob("*.yaml")) - for path in workflow_paths: - matches = declaration.findall(path.read_text(encoding="utf-8")) - if matches: - assert len(matches) == 1, f"{path} declares multiple RUST_LIB_REF values" - declared[str(path)] = matches[0] - - ids = set() - policy_workflows = {} - rows = [] - for pin_set in policy["pin_sets"]: - assert isinstance(pin_set, dict) and set(pin_set) == expected_fields - assert identifier.fullmatch(pin_set["id"]) - assert pin_set["id"] not in ids - ids.add(pin_set["id"]) - assert isinstance(pin_set["purpose"], str) - assert 20 <= len(pin_set["purpose"]) <= 240 - assert sha.fullmatch(pin_set["certified_ref"]) - assert sha.fullmatch(pin_set["reviewed_merge_ref"]) - assert isinstance(pin_set["workflows"], list) and pin_set["workflows"] - assert pin_set["workflows"] == sorted(set(pin_set["workflows"])) - for workflow in pin_set["workflows"]: - assert workflow.startswith(".github/workflows/") - assert Path(workflow).is_file(), workflow - assert workflow not in policy_workflows, workflow - policy_workflows[workflow] = pin_set["certified_ref"] - rows.append( - ( - pin_set["id"], - pin_set["certified_ref"], - pin_set["reviewed_merge_ref"], - ) - ) - - assert set(declared) == set(policy_workflows), { - "undeclared_workflow_pins": sorted(set(declared) - set(policy_workflows)), - "missing_workflow_pins": sorted(set(policy_workflows) - set(declared)), - } - for workflow, expected in policy_workflows.items(): - assert declared[workflow] == expected, (workflow, declared[workflow], expected) - - output = Path(sys.argv[1]) - output.write_text( - "".join("\t".join(row) + "\n" for row in rows), - encoding="utf-8", - ) - PY - test -s "$POLICY_TSV" - - - name: Verify every certified pin reached main through its reviewed merge - env: - POLICY_TSV: ${{ runner.temp }}/rust-lib-pin-policy.tsv - EVIDENCE_DIR: ${{ runner.temp }}/rust-lib-main-ancestry - shell: bash - run: | - set -euo pipefail - mkdir -p "$EVIDENCE_DIR" - while IFS=$'\t' read -r id certified_ref reviewed_merge_ref; do - RUST_LIB_REF="$certified_ref" \ - RUST_LIB_REVIEWED_MERGE_REF="$reviewed_merge_ref" \ - scripts/verify-rust-lib-main-ancestry.sh \ - "$EVIDENCE_DIR/$id.json" - done < "$POLICY_TSV" - test "$(find "$EVIDENCE_DIR" -type f -name '*.json' | wc -l)" -gt 0 - - - name: Upload bounded ancestry evidence - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: rust-lib-main-ancestry - path: | - ${{ runner.temp }}/rust-lib-main-ancestry/*.json - ${{ runner.temp }}/rust-lib-pin-policy.tsv - .github/rust-lib-pin-policy.json - if-no-files-found: warn - retention-days: 7 diff --git a/.github/workflows/scintilla-context-abi-cert.yml b/.github/workflows/scintilla-context-abi-cert.yml deleted file mode 100644 index 4da25ff..0000000 --- a/.github/workflows/scintilla-context-abi-cert.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Scintilla context ABI exact-source certification - -on: - pull_request: - paths: - - "certification/scintilla-context-abi/**" - - ".github/workflows/scintilla-context-abi-cert.yml" - -permissions: - contents: read - -jobs: - certify: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - name: Verify exact source identity - shell: bash - run: | - set -euo pipefail - lock=certification/scintilla-context-abi/SOURCE.lock.json - test "$(jq -r '.source_head' "$lock")" = "3bae587243b581558c2e612f182d990ffd9034d9" - jq -r '.blobs | to_entries[] | [.key,.value] | @tsv' "$lock" | - while IFS=$'\t' read -r rel expected; do - test "$(git hash-object "certification/scintilla-context-abi/$rel")" = "$expected" - done - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - name: Prove Scintilla module-context contract - run: | - cargo fmt --manifest-path certification/scintilla-context-abi/Cargo.toml -- --check - cargo clippy --manifest-path certification/scintilla-context-abi/Cargo.toml --all-targets --all-features -- -D warnings - cargo test --manifest-path certification/scintilla-context-abi/Cargo.toml --all-targets --all-features diff --git a/.github/workflows/windows-file-registry-candidate.yml b/.github/workflows/windows-file-registry-candidate.yml deleted file mode 100644 index 8fcb0f7..0000000 --- a/.github/workflows/windows-file-registry-candidate.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: Windows file-registry candidate certification - -on: - pull_request: - paths: - - .github/workflows/windows-file-registry-candidate.yml - workflow_dispatch: - inputs: - zed_cli_ref: - description: Exact 40-character zed-pkg/zed-cli commit to certify - required: false - default: d9da1e3ccfd98ee8c250e7d35a18dd22f398d4dc - type: string - -permissions: - contents: read - -concurrency: - group: windows-file-registry-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -env: - ZED_CLI_REF: ${{ inputs.zed_cli_ref || 'd9da1e3ccfd98ee8c250e7d35a18dd22f398d4dc' }} - ZED_INTERFACES_REF: dc0e0a0620b9462817950b552d3d334a184b1cb1 - RUST_LIB_REF: f2534ffafbc4f7e900075dfc8e48b9259aced2fe - CARGO_TERM_COLOR: always - -jobs: - certify: - name: Exact candidate on Windows Server 2025 - runs-on: windows-2025 - timeout-minutes: 30 - defaults: - run: - shell: pwsh - steps: - - name: Check out certification repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - persist-credentials: false - show-progress: false - - - name: Check out exact Zed candidate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: zed-pkg/zed-cli - ref: ${{ env.ZED_CLI_REF }} - path: zed-cli - fetch-depth: 1 - persist-credentials: false - show-progress: false - - - name: Check out pinned Zed interfaces - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: zed-pkg/zed-interfaces - ref: ${{ env.ZED_INTERFACES_REF }} - path: zed-interfaces - fetch-depth: 1 - persist-credentials: false - show-progress: false - - - name: Check out exact test-org library fixture - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: zed-pkg-test/rust-lib - ref: ${{ env.RUST_LIB_REF }} - path: rust-lib-fixture - fetch-depth: 1 - persist-credentials: false - show-progress: false - - - name: Verify immutable inputs - run: | - foreach ($value in @($env:ZED_CLI_REF, $env:ZED_INTERFACES_REF, $env:RUST_LIB_REF)) { - if ($value -notmatch '^[0-9a-f]{40}$') { throw "mutable or invalid ref: $value" } - } - if ((git -C zed-cli rev-parse HEAD).Trim() -ne $env:ZED_CLI_REF) { throw 'zed-cli ref mismatch' } - if ((git -C zed-interfaces rev-parse HEAD).Trim() -ne $env:ZED_INTERFACES_REF) { throw 'zed-interfaces ref mismatch' } - if ((git -C rust-lib-fixture rev-parse HEAD).Trim() -ne $env:RUST_LIB_REF) { throw 'rust-lib ref mismatch' } - - - name: Install stable Rust and build exact candidate - run: | - rustup toolchain install stable --profile minimal - rustup default stable - cargo build --locked --manifest-path zed-cli/Cargo.toml --bin zed - - - name: Publish, inspect metadata, and install through canonical file URI - run: | - $root = Join-Path $env:RUNNER_TEMP 'zed-pkg-test file registry certification' - $registry = Join-Path $root 'registry with spaces' - $zedHome = Join-Path $root 'home' - $app = Join-Path $root 'consumer app' - Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Force $registry, $zedHome, $app | Out-Null - - $registryUri = python -c "from pathlib import Path; print(Path(r'$registry').resolve().as_uri())" - if (-not $registryUri.StartsWith('file:///')) { throw "unexpected registry URI: $registryUri" } - if ($registryUri -notmatch '%20') { throw "registry URI did not encode spaces: $registryUri" } - - $zed = (Resolve-Path zed-cli/target/debug/zed.exe).Path - Push-Location rust-lib-fixture - try { - & $zed publish --registry $registryUri --skip-vcs-checks - if ($LASTEXITCODE -ne 0) { throw "publish failed with exit $LASTEXITCODE" } - } finally { Pop-Location } - - $version = Join-Path $registry 'packages/zed-pkg-test/rust-lib/versions/1.0.0.json' - if (-not (Test-Path $version)) { throw "published version metadata missing: $version" } - $metadata = Get-Content -Raw $version | ConvertFrom-Json - $downloadUrl = [string]$metadata.download_url - if (-not $downloadUrl.StartsWith('file:///')) { throw "non-canonical download_url: $downloadUrl" } - if ($downloadUrl -notmatch '%20') { throw "download_url did not encode spaces: $downloadUrl" } - - @' - [package] - org = "zed-pkg-test" - name = "windows-file-registry-consumer" - version = "0.1.0" - license = "MIT" - - [package.repository] - vcs = "git" - url = "https://github.com/zed-pkg-test/rust-app" - - [install] - dir = ".vendor/.zed" - - [dependencies] - "zed-pkg-test/rust-lib" = "^1" - '@ | Set-Content -Encoding utf8 (Join-Path $app '.zpkg.toml') - - Push-Location $app - try { - & $zed install --registry $registryUri --home $zedHome --install-mode copy - if ($LASTEXITCODE -ne 0) { throw "install failed with exit $LASTEXITCODE" } - } finally { Pop-Location } - - $materialized = Join-Path $app '.vendor/.zed/zed-pkg-test/rust-lib' - if (-not (Test-Path $materialized -PathType Container)) { - throw "materialized package missing: $materialized" - } diff --git a/.github/workflows/zed-e2e.yml b/.github/workflows/zed-e2e.yml deleted file mode 100644 index 04a5b87..0000000 --- a/.github/workflows/zed-e2e.yml +++ /dev/null @@ -1,171 +0,0 @@ -name: Zed and Cargo interoperability - -on: - pull_request: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - ZED_INTERFACES_REF: a23cdd2ac509a39ca6fd6d21a3774fdd3a0f7660 - ZED_CLI_REF: bf1dc05e05900748a44ee02e7c507fd65bab4c7a - RUST_LIB_REF: f3c4e33cd0e5726b2c668c0306936f01674261fa - -jobs: - workflow-contract: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Check out exact revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Validate workflow - uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 - with: - args: .github/workflows/zed-e2e.yml - - - name: Validate executable test script - run: bash -n scripts/zed-e2e.sh - - - name: Verify immutable fixture references - shell: bash - run: | - set -euo pipefail - for value in \ - "$ZED_INTERFACES_REF" \ - "$ZED_CLI_REF" \ - "$RUST_LIB_REF" - do - [[ "$value" =~ ^[0-9a-f]{40}$ ]] - done - test -f Cargo.lock - test ! -e .zpkg.lock - - cargo-matrix: - needs: workflow-contract - strategy: - fail-fast: false - matrix: - os: - - ubuntu-24.04 - - macos-15 - - windows-2025 - runs-on: ${{ matrix.os }} - timeout-minutes: 25 - steps: - - name: Check out exact revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Install pinned Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: "3.12" - - - name: Install pinned Rust toolchain - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: "1.97.0" - components: rustfmt, clippy - - - name: Materialize exact Zed package fixture - shell: bash - run: | - set -euo pipefail - target=.vendor/.zed/zed-pkg-test/rust-lib - mkdir -p "$(dirname "$target")" - git init -q "$target" - git -C "$target" remote add origin https://github.com/zed-pkg-test/rust-lib.git - git -C "$target" fetch --depth 1 origin "$RUST_LIB_REF" - git -C "$target" checkout -q --detach FETCH_HEAD - test "$(git -C "$target" rev-parse HEAD)" = "$RUST_LIB_REF" - - - name: Run locked Cargo matrix - shell: bash - run: | - set -euo pipefail - cargo_lock_hash="$(git hash-object Cargo.lock)" - cargo fmt --all -- --check - cargo clippy --locked --all-targets -- -D warnings - cargo test --locked --all-targets - cargo run --locked --quiet - cargo metadata --locked --format-version 1 > cargo-metadata.json - cargo tree --locked -e features > cargo-features.txt - cargo run --locked --offline --quiet - cargo test --locked --offline --all-targets - test "$(git hash-object Cargo.lock)" = "$cargo_lock_hash" - test ! -e .zpkg.lock - - - name: Validate Cargo provenance - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - import json - from pathlib import Path - - metadata = json.loads(Path("cargo-metadata.json").read_text()) - packages = {package["name"]: package for package in metadata["packages"]} - assert packages["rust-app"]["version"] == "0.2.0" - assert packages["rust-lib"]["version"] == "1.0.1" - assert packages["rust-lib"]["source"] is None - assert packages["itoa"]["version"] == "1.0.18" - assert packages["unicode-ident"]["version"] == "1.0.24" - assert packages["itoa"]["source"].startswith("registry+") - assert packages["unicode-ident"]["source"].startswith("registry+") - PY - - - name: Upload Cargo provenance - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: cargo-provenance-${{ matrix.os }} - path: | - cargo-metadata.json - cargo-features.txt - if-no-files-found: warn - retention-days: 7 - - - name: Verify source and lock cleanliness - if: ${{ always() }} - shell: bash - run: | - set -euo pipefail - rm -rf .vendor target cargo-metadata.json cargo-features.txt .zed .zpkg.lock - git diff --exit-code -- Cargo.toml Cargo.lock .zpkg.toml build.rs src tests - test -z "$(git status --porcelain --untracked-files=all)" - - zed-registry-e2e: - needs: workflow-contract - runs-on: ubuntu-24.04 - timeout-minutes: 45 - steps: - - name: Check out exact revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Run pinned cross-container interoperability suite - shell: bash - run: | - set -o pipefail - bash scripts/zed-e2e.sh 2>&1 | tee "$RUNNER_TEMP/zed-cargo-e2e.log" - - - name: Upload interoperability diagnostics - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: zed-cargo-e2e-log - path: ${{ runner.temp }}/zed-cargo-e2e.log - if-no-files-found: warn - retention-days: 7 diff --git a/.github/workflows/zed-latest-deps-consumer.yml b/.github/workflows/zed-latest-deps-consumer.yml deleted file mode 100644 index 2ac62ef..0000000 --- a/.github/workflows/zed-latest-deps-consumer.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Zed latest deps Rust consumer proof -on: - pull_request: - workflow_dispatch: -permissions: - contents: read -jobs: - verify-rust-consumer: - runs-on: ubuntu-latest - timeout-minutes: 45 - env: - ZED_PKG_INTERACTIVE: "false" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: {path: source, fetch-depth: 0, persist-credentials: false} - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - repository: zed-pkg/zed-cli - ref: 611da9528940267235ed28391c3b05443380902d - path: zed-cli - persist-credentials: false - - name: Build exact Zed CLI - working-directory: zed-cli - run: | - cargo build --locked --bin zed - mkdir -p "$HOME/.local/bin" - install -m 0755 target/debug/zed "$HOME/.local/bin/zed" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Resolve and frozen-replay latest allowed Zed graph - working-directory: source - run: | - test ! -e .zpkg.lock - zed validate - zed install - zed validate --require-lock - zed install --frozen - test -e zed_modules/zed-pkg-test/rust-lib - - name: Compile and test Rust consumer - working-directory: source - run: zed develop --no-install -c 'cargo test --locked --all-targets' - - name: Pack consumer - working-directory: source - run: zed pack --out "$RUNNER_TEMP/zed-rust-consumer-proof" diff --git a/.gitignore b/.gitignore index 28fa66c..29ec2dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ -target/ -.vendor/ -.zed/ -.zpkg.lock -tmp -temp -tmp/worktrees -temp/worktrees +/target/ +/generated/ +.ores/ +*.profraw +tmp/ +temp/ diff --git a/.ores-lint/README.md b/.ores-lint/README.md deleted file mode 100644 index 6fe9cd4..0000000 --- a/.ores-lint/README.md +++ /dev/null @@ -1,218 +0,0 @@ -# ores-lint - -A vendored, dependency-free lint baseline for every JavaScript/TypeScript, -Rust, Dart/Flutter and Gleam repo in the org fleet. Everything it needs is in -this directory — there is nothing to install from a registry and nothing to -keep in version sync. - -Only universally accepted linters are used as hosts: - -| language | host linter | custom house rules | -|---|---|---| -| TypeScript / JS | ESLint 9+ (flat config) | `ores/require-send`, `ores/semi` | -| Rust | clippy | `implicit_return` house style; `#[must_use]` on log `Event`; require-send scanner | -| Dart / Flutter | `dart analyze` | require-send scanner | -| Gleam | `gleam format --check` + `gleam check` | require-send scanner | - -There is no ESLint-quality plugin host for Gleam, and Dart's `custom_lint` / -Rust's `dylint` would pull registry packages into every repo. The one house -rule that those hosts cannot express — **logger chains must end in `send()`** — -is implemented as a vendored ESLint rule for TS and a small Node scanner for -the other three languages. - -## Running it - -```sh -sh .ores-lint/lint.sh # lints whatever this repo contains -sh .ores-lint/selftest.sh # verifies the toolkit still works after a toolchain upgrade -``` - -For JS repos it is also wired into `npm run lint:ores`, and runs automatically -before `npm run build` (`prebuild`) and before `npm publish` (`prepublishOnly`). - -## What it enforces - -**JavaScript / TypeScript** — via ESLint (flat config): - -| rule | why | -|---|---| -| `semi` | house style: semicolons are required, missing ones warn | -| `ores/require-send` | a logging chain that reaches `.info()`/`.warn()`/… but never calls `.send()` or `.send(boolean)` builds an event that is never delivered | -| correctness set | `eqeqeq`, `no-unreachable`, `no-dupe-keys`, `use-isnan`, `valid-typeof`, `no-async-promise-executor`, and similar low-false-positive checks | - -**Rust** — via clippy, plus rustc `#[must_use]` on ores-otel `Event`: - -| lint | why | -|---|---| -| `clippy::implicit_return` | house style: prefer an explicit `return` at tail position | -| `clippy::correctness`, `clippy::suspicious` | real defects | -| `unwrap_used`, `expect_used`, `panic_in_result_fn`, `todo`, `dbg_macro` | things that should not reach a publish | - -**Dart / Flutter** — via `dart analyze` (or `flutter analyze`). If the repo has -no `analysis_options.yaml`, rollout drops a baseline of analyzer-shipped -correctness lints. Existing files are never overwritten. Prefer -`package:lints` / `package:flutter_lints` when the package already depends on -them — those are the Dart equivalents of `eslint:recommended`. - -**Gleam** — via `gleam format --check` and `gleam check`. Unused values already -catch many forgotten `send` calls; the scanner catches the rest (assigned -events, unfinished pipes). - -**require-send (all four languages).** The ores-otel logger builds an event -through method chaining (TS/Rust/Dart) or pipes (Gleam). Delivery is -`send()`, `send(boolean)`, or `send_with_store(...)`. Forgetting that call -means the event is built and then dropped — unless shutdown recovers it, which -is a fallback, not the API. Tests that deliberately build unsent events are -skipped unless `ORES_LINT_REQUIRE_SEND_INCLUDE_TESTS=1`. - -## Overriding a finding on one line - -This is expected. Some tests, shutdown-recovery fixtures, and rare control -flow should not call `send()`. Use the comment that matches the host linter, -or the unified ores-lint form which works in every language: - -``` -// ores-lint-disable-next-line require-send -// ores-lint-disable-line require-send -// ores-lint-disable-file require-send -``` - -Language-native equivalents, also honoured: - -| language | one-line override | -|---|---| -| TypeScript | `// eslint-disable-next-line ores/require-send` | -| Rust (`#[must_use]`) | `#[allow(unused_must_use)]` on the next statement | -| Dart analyzer | `// ignore: name_of_lint` (analyzer rules only; require-send uses the ores-lint form) | -| Gleam | the ores-lint form above | - -Do not disable the rule for a whole file unless the file is a generated -fixture. Prefer the next-line form. - -## The two things worth knowing - - -**1. Implicit-return findings are capped.** `clippy::implicit_return` fires once -per implicit return, which on a real crate is hundreds of identical lines. The -lint stays fully enabled so nothing is missed, but `rust.sh` collapses it into a -single warning showing at most 5 concrete locations plus `... and N more`. The -same cap applies to every ESLint rule via `eslint/formatter.mjs`. Change it with -`ORES_LINT_MAX_EXAMPLES`. - -**2. `clippy::needless_return` had to be disabled.** It ships enabled in -clippy's default `style` group and warns on exactly the explicit returns this -house style asks for. Enabling `implicit_return` without allowing -`needless_return` makes the two lints contradict each other on every function in -the crate. `selftest.sh` asserts this stays true. - -## Scope: sub-projects and repo boundaries - -The linter does **not** assume the repo root is the only project. - -- **Rust** — `rust.sh` finds every crate in the repo, including ones under - `apps/` or `clients/`. Crates that are workspace members of an already-linted - root are skipped (via `cargo metadata --no-deps`) so nothing is linted twice, - and findings from every crate are aggregated into **one** capped report. -- **JS/TS** — a flat config at the repo root makes `eslint .` reach nested - packages, so the config goes in even when the JS lives in a subdirectory. - -**Nested git repositories are a hard boundary.** A repo checked out inside -another repo gets its own ores-lint install; the parent must not lint it, or the -same findings get reported twice under the wrong repo name and the same -`package.json` gets wired with two conflicting relative paths. `rollout.mjs` -records those boundaries in `.ores-lint/nested-repos.json`, and both halves of -the linter read it. - -To exclude a repo entirely — vendored upstream forks, for instance — drop an -empty `.ores-lint-ignore` file at its root. - -## Legacy config migration - -ESLint 9+ reads flat config **only**. Three older mechanisms are silently -ignored, which means any repo still relying on them has not been linted at all: - -| legacy mechanism | status | -|---|---| -| `.eslintrc*` | ignored entirely; rules are dead | -| `eslintConfig` key in `package.json` | ignored entirely | -| `.eslintignore` | ignored, with a warning | - -`audit.mjs` reports every repo in each category. `.eslintignore` is ported -automatically into flat-config `ignores` by `base.mjs` (gitignore semantics -preserved), so its intent keeps applying. The other two need a human decision -and are migrated per repo — porting the rules that still make sense, and saying -in a comment which ones were dropped and why. - -## Warn-only, by design - -`lint.sh` exits 0 no matter what it finds. It is wired into build and publish -hooks across hundreds of repos, so it is built to be incapable of breaking one -unless a human opts in. - -To make findings blocking for a single repo, create `.ores-lint/local.sh`: - -```sh -ORES_LINT_STRICT=1 -``` - -`local.sh` is yours — the rollout script never overwrites it. Everything else in -this directory is managed and will be replaced on the next rollout. - -## Knobs - -| variable | default | meaning | -|---|---|---| -| `ORES_LINT_MAX_EXAMPLES` | `5` | example locations shown per rule | -| `ORES_LINT_STRICT` | `0` | `1` makes any finding exit non-zero | -| `ORES_LINT_SKIP_JS` / `ORES_LINT_SKIP_RUST` / `ORES_LINT_SKIP_DART` / `ORES_LINT_SKIP_GLEAM` | `0` | skip one language host | -| `ORES_LINT_SKIP_REQUIRE_SEND` | `0` | skip the cross-language send() scanner | -| `ORES_LINT_REQUIRE_SEND_INCLUDE_TESTS` | `0` | `1` also scans `test/` / `*_test.dart` / etc. | -| `ORES_LINT_RUST_ALL_TARGETS` | `0` | `1` also lints tests/benches/examples | -| `ORES_LINT_RUST_EXTRA` | — | extra flags appended to the clippy invocation | - -## Graceful degradation - -Nothing here is allowed to fail loudly for an environmental reason. ESLint not -installed, too old, clippy not installed, dart/flutter not installed, gleam not -installed, no TypeScript parser available, crate deps not fetchable — each is -reported as an actionable skip, not an error. -Repo-specific ESLint config that already existed is never overwritten. -The same is true of `analysis_options.yaml`. - -CI follows the same model: the workflow runs `npm i -g eslint typescript-eslint` -and never runs `npm install` for the repo itself, so linting a PR does not -require the repo's dependency tree to resolve. - -## Per-repo customisation - -`eslint.config.mjs` at the repo root takes options: - -```js -export default await oresConfig({ - requireSend: { loggerNames: ['myLogger'], terminalMethods: ['send', 'flush'] }, - rules: { 'no-console': 'warn' }, - ignores: ['**/generated/**'], -}); -``` - -Once you edit that file the rollout script leaves it alone. - ---- - -## Fleet operations (from the `codes` directory) - -```sh -node .ores-lint-toolkit/audit.mjs # report the fleet's lint posture -node .ores-lint-toolkit/audit.mjs --json out.json # ...as machine-readable data -node .ores-lint-toolkit/rollout.mjs --dry-run # preview -node .ores-lint-toolkit/rollout.mjs # install / re-install everywhere -node .ores-lint-toolkit/rollout.mjs --only ores-otel -node .ores-lint-toolkit/rollout.mjs --shard 0/8 # one slice of a fleet-wide run -node .ores-lint-toolkit/verify.mjs # assert every repo is correctly installed -``` - -A full rollout over ~900 repos takes a few minutes. `--shard k/n` splits it into -bounded chunks, which matters when the runner has a per-command time limit. - -Re-run the rollout after editing anything in `.ores-lint-toolkit/` — it is -idempotent and propagates the change to every repo. diff --git a/.ores-lint/VERSION b/.ores-lint/VERSION deleted file mode 100644 index f0bb29e..0000000 --- a/.ores-lint/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.3.0 diff --git a/.ores-lint/config.sh b/.ores-lint/config.sh deleted file mode 100644 index b920572..0000000 --- a/.ores-lint/config.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/sh -# ores-lint shared configuration. Sourced by lint.sh, js.sh and rust.sh. -# Every value can be overridden from the environment, or per repo in local.sh. - -# Maximum number of concrete example locations shown for any one rule. -: "${ORES_LINT_MAX_EXAMPLES:=5}" - -# Warn-only by default: lint.sh exits 0 no matter what it finds. -# Flip to 1 (per repo, or in CI) once a repo's debt is paid down. -: "${ORES_LINT_STRICT:=0}" - -: "${ORES_LINT_SKIP_RUST:=0}" -: "${ORES_LINT_SKIP_JS:=0}" -: "${ORES_LINT_SKIP_DART:=0}" -: "${ORES_LINT_SKIP_GLEAM:=0}" -: "${ORES_LINT_SKIP_REQUIRE_SEND:=0}" -: "${ORES_LINT_REQUIRE_SEND_INCLUDE_TESTS:=0}" - -# How deep to search for nested sub-projects (crates and packages). Repos here -# routinely hold crates under apps/ and clients/ that a root-only lint misses. -: "${ORES_LINT_DEPTH:=5}" - -# Include tests/benches/examples in the Rust pass. Off by default so the -# pre-publish signal is about shipped code. -: "${ORES_LINT_RUST_ALL_TARGETS:=0}" - -# Minimum ESLint major version. Flat config needs 9+. ESLint is expected to be -# installed GLOBALLY, once - see required-tools.json. Nothing is ever installed -# into a repo's node_modules. -: "${ORES_LINT_ESLINT_MIN_MAJOR:=9}" - -# The exact clippy diagnostic text for `clippy::implicit_return`. selftest.sh -# verifies this still matches, so a future clippy rewording surfaces as a test -# failure rather than as a silently empty report. -: "${ORES_LINT_IMPLICIT_RETURN_MSG:=missing \`return\` statement}" - -export ORES_LINT_MAX_EXAMPLES ORES_LINT_STRICT ORES_LINT_SKIP_RUST ORES_LINT_SKIP_JS -export ORES_LINT_SKIP_DART ORES_LINT_SKIP_GLEAM ORES_LINT_SKIP_REQUIRE_SEND -export ORES_LINT_REQUIRE_SEND_INCLUDE_TESTS -export ORES_LINT_DEPTH ORES_LINT_RUST_ALL_TARGETS ORES_LINT_ESLINT_MIN_MAJOR -export ORES_LINT_IMPLICIT_RETURN_MSG - -# Repo-local overrides, never overwritten by the rollout script. Sourced last so -# anything set here wins. -ORES_LINT_CFG_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -[ -f "$ORES_LINT_CFG_DIR/local.sh" ] && . "$ORES_LINT_CFG_DIR/local.sh" diff --git a/.ores-lint/dart.sh b/.ores-lint/dart.sh deleted file mode 100755 index bc3c7ec..0000000 --- a/.ores-lint/dart.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/sh -# ores-lint :: Dart / Flutter -# -# Uses the Dart analyzer (`dart analyze` / `flutter analyze`) - the universally -# accepted linter for the language, equivalent to ESLint for TypeScript. Custom -# house rules that the analyzer cannot express (require-send) live in -# require-send.mjs and run from lint.sh. -# -# Nothing is installed. Missing dart/flutter is an actionable skip. - -set -u -DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -. "$DIR/config.sh" -ROOT=${1:-.} -ROOT=$(CDPATH= cd -- "$ROOT" && pwd) - -[ "${ORES_LINT_SKIP_DART}" = "1" ] && { echo "ores-lint[dart]: skipped (ORES_LINT_SKIP_DART=1)"; exit 0; } - -has_dart=0 -if find "$ROOT" -maxdepth "$ORES_LINT_DEPTH" \ - \( -name node_modules -o -name target -o -name .git -o -name vendor -o -name .vendor -o -name build \) -prune -o \ - \( -type f -name pubspec.yaml -o -type f -name '*.dart' \) -print 2>/dev/null | head -1 | grep -q .; then - has_dart=1 -fi -[ "$has_dart" = "0" ] && exit 0 - -ANALYZE="" -if command -v dart >/dev/null 2>&1; then - ANALYZE="dart analyze" -elif command -v flutter >/dev/null 2>&1; then - ANALYZE="flutter analyze" -else - echo "ores-lint[dart]: dart/flutter not found on PATH - skipping" - echo " install the Dart SDK, or Flutter, then re-run" - exit 0 -fi - -# Nested git repos are someone else's analyzer run. -NESTED_FILE="$DIR/nested-repos.json" -EXCLUDE="" -if [ -f "$NESTED_FILE" ]; then - NESTED=$(grep -o '"[^"]*"' "$NESTED_FILE" 2>/dev/null | tr -d '"') - for nrepo in $NESTED; do - [ -n "$nrepo" ] && EXCLUDE="$EXCLUDE --fatal-infos=false" - done -fi - -OUT=$(mktemp) || exit 0 -RC=0 -( cd "$ROOT" && $ANALYZE --format=machine 2>/dev/null || $ANALYZE ) >"$OUT" 2>&1 || RC=$? - -if grep -q 'No issues found!' "$OUT"; then - echo "ores-lint[dart]: clean" - rm -f "$OUT" - exit 0 -fi - -if [ "$RC" -ne 0 ] && ! grep -qE 'error|warning|info|ERROR|WARNING' "$OUT"; then - echo "ores-lint[dart]: analyzer could not run in $ROOT (exit $RC). First lines:" - sed -n '1,6p' "$OUT" | sed 's/^/ | /' - rm -f "$OUT" - exit 0 -fi - -awk -v MAXEX="$ORES_LINT_MAX_EXAMPLES" ' -BEGIN { max = MAXEX + 0; if (max < 1) max = 1; n = 0; FS = "|" } -# machine format: SEVERITY|TYPE|FILE|LINE|COLUMN|LENGTH|CODE|MESSAGE -NF >= 8 && $1 ~ /^(ERROR|WARNING|INFO)$/ { - loc = $3 ":" $4 ":" $5 - msg = $7 ": " $8 - sev = tolower($1) - if (sev == "info") sev = "warning" - key = loc "|" msg - if (key in seen) next - seen[key] = 1 - if (!(msg in count)) { order[++n] = msg; sev_of[msg] = sev } - count[msg]++ - if (shown[msg] < max) { ex[msg] = ex[msg] (shown[msg]++ ? "\n" : "") " " loc } - next -} -# human format fallback: " warning - path:line:col - message - code" -{ - line = $0 - if (match(line, /(error|warning|info) • /) || match(line, /(error|warning|info) - /)) { - n++ - raw[++human] = line - } -} -END { - if (n == 0 && human == 0) { print "ores-lint[dart]: clean"; exit 0 } - if (n == 0) { - printf "ores-lint[dart]: %d finding(s)\n", human - limit = (human < max ? human : max) - for (i = 1; i <= limit; i++) print " " raw[i] - if (human > max) printf " ... and %d more\n", human - max - print "" - exit 0 - } - total = 0 - for (i = 1; i <= n; i++) total += count[order[i]] - printf "ores-lint[dart]: %d finding(s) across %d rule(s)\n", total, n - for (i = 1; i <= n; i++) { - msg = order[i] - printf "\n %s: %s\n", sev_of[msg], msg - printf " %d instance(s); showing %d:\n", count[msg], (count[msg] < max ? count[msg] : max) - print ex[msg] - if (count[msg] > max) printf " ... and %d more\n", count[msg] - max - } - print "" -} -' "$OUT" - -rm -f "$OUT" -exit 0 diff --git a/.ores-lint/eslint/base.mjs b/.ores-lint/eslint/base.mjs deleted file mode 100644 index 596f2f2..0000000 --- a/.ores-lint/eslint/base.mjs +++ /dev/null @@ -1,179 +0,0 @@ -/** - * ores-lint :: shared flat-config factory - * - * Everything here degrades gracefully. A repo missing TypeScript tooling gets - * its JS linted rather than an error; an ESLint that has dropped core `semi` - * falls back to the vendored rule. The point is that a lint config rolled out - * to hundreds of heterogeneous repos must never be the thing that breaks them. - */ - -import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; -import oresPlugin from './plugin.mjs'; - -const require_ = createRequire(import.meta.url); - -// ESLint and its optional TypeScript parser are expected to live in a single -// global install rather than in every repo's node_modules, so resolution has to -// look outside this file's own tree. js.sh passes the global root in. -const EXTRA_PATHS = [ - process.env.ORES_LINT_GLOBAL_ROOT, - ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(':') : []), -].filter(Boolean); - -function tryResolve(id) { - try { return require_.resolve(id); } catch { /* try the global root next */ } - if (EXTRA_PATHS.length) { - try { return require_.resolve(id, { paths: EXTRA_PATHS }); } catch { /* not installed */ } - } - return null; -} - -/** Is core `semi` still shipped by the installed ESLint? */ -async function coreSemiAvailable() { - for (const id of ['eslint/use-at-your-own-risk']) { - const resolved = tryResolve(id); - if (!resolved) continue; - try { - const { builtinRules } = await import(pathToFileURL(resolved).href); - return builtinRules.has('semi'); - } catch { /* fall through */ } - } - return true; // could not introspect: assume core rules are intact -} - -/** typescript-eslint, if the repo happens to have it. */ -async function loadTsSupport() { - for (const id of ['typescript-eslint', '@typescript-eslint/parser']) { - const resolved = tryResolve(id); - if (!resolved) continue; - try { - // Import by resolved path: a bare specifier would not find a global install. - const mod = await import(pathToFileURL(resolved).href); - const m = mod.default || mod; - if (id === 'typescript-eslint' && m.parser) return { parser: m.parser, source: id }; - if (m.parseForESLint || m.parse) return { parser: m, source: id }; - } catch { /* fall through to the next candidate */ } - } - return null; -} - -const JS_FILES = ['**/*.js', '**/*.mjs', '**/*.cjs', '**/*.jsx']; -const TS_FILES = ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.tsx']; - -/** - * Directories that are separate git repositories nested inside this one. They - * have their own ores-lint install and must not be linted from here, or their - * findings would be reported twice under the wrong repo. - */ -/** - * ESLint 10 dropped support for `.eslintignore` and merely warns that it is - * being ignored. Rather than let a repo's stated intent silently stop applying, - * port it into flat-config `ignores`. - */ -function legacyIgnoreFile() { - try { - const raw = readFileSync(new URL('../../.eslintignore', import.meta.url), 'utf8'); - return raw.split('\n') - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith('#') && !l.startsWith('!')) - // .eslintignore used gitignore semantics: a bare name matched anywhere. - .map((l) => (l.includes('/') ? l.replace(/^\/+/, '') : `**/${l}`)) - .map((l) => (l.endsWith('/') ? `${l}**` : l)); - } catch { return []; } -} - -function nestedRepoIgnores() { - try { - const raw = readFileSync(new URL('../nested-repos.json', import.meta.url), 'utf8'); - const dirs = JSON.parse(raw); - return Array.isArray(dirs) ? dirs.map((d) => `${d}/**`) : []; - } catch { return []; } -} - -const IGNORES = [ - '**/node_modules/**', '**/dist/**', '**/build/**', '**/out/**', '**/target/**', - '**/coverage/**', '**/.next/**', '**/vendor/**', '**/*.min.js', '**/*.bundle.js', - '**/.ores-lint/**', -]; - -/** - * @param {object} [opts] - * @param {object} [opts.requireSend] options forwarded to ores/require-send - * @param {object} [opts.rules] extra rules merged last (repo overrides) - * @param {string[]} [opts.ignores] extra ignore globs - */ -export default async function oresConfig(opts = {}) { - const useCoreSemi = await coreSemiAvailable(); - const ts = await loadTsSupport(); - - const semiRules = useCoreSemi - ? { semi: ['warn', 'always'], 'no-extra-semi': 'warn', 'semi-style': ['warn', 'last'] } - : { 'ores/semi': 'warn' }; - - // Correctness rules chosen for a near-zero false-positive rate, because this - // config lands in repos nobody is going to hand-tune afterwards. - const correctness = { - 'ores/require-send': ['warn', opts.requireSend || {}], - 'no-unused-vars': ['warn', { args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }], - eqeqeq: ['warn', 'smart'], - 'no-fallthrough': 'warn', - 'no-unreachable': 'warn', - 'no-dupe-keys': 'warn', - 'no-dupe-else-if': 'warn', - 'no-duplicate-case': 'warn', - 'no-self-compare': 'warn', - 'no-unsafe-negation': 'warn', - 'no-cond-assign': ['warn', 'always'], - 'no-constant-condition': ['warn', { checkLoops: false }], - 'no-async-promise-executor': 'warn', - 'no-promise-executor-return': 'warn', - 'no-compare-neg-zero': 'warn', - 'no-irregular-whitespace': 'warn', - 'no-template-curly-in-string': 'warn', - 'valid-typeof': 'warn', - 'use-isnan': 'warn', - 'no-debugger': 'warn', - // Deliberately NOT enabled: no-undef. Without a `globals` package it fires - // on console/process/window everywhere, and TypeScript already covers it. - }; - - const configs = [ - { ignores: [...IGNORES, ...nestedRepoIgnores(), ...legacyIgnoreFile(), ...(opts.ignores || [])] }, - { - files: JS_FILES, - plugins: { ores: oresPlugin }, - languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, - rules: { ...semiRules, ...correctness, ...(opts.rules || {}) }, - }, - ]; - - if (!ts) { - // No TypeScript parser anywhere. Globally ignore TS rather than leaving it - // merely unmatched: a repo-specific config block that happens to match - // `**/*.ts` would otherwise hand TS source to the JS parser and produce a - // wall of bogus "Parsing error" findings. Ignoring is honest; js.sh prints - // a note so the gap stays visible instead of looking like a clean repo. - configs.push({ ignores: TS_FILES }); - } - - if (ts) { - configs.push({ - files: TS_FILES, - plugins: { ores: oresPlugin }, - languageOptions: { parser: ts.parser, ecmaVersion: 'latest', sourceType: 'module' }, - rules: { - ...semiRules, - ...correctness, - // TypeScript's own compiler reports unused symbols with better fidelity. - 'no-unused-vars': 'off', - ...(opts.rules || {}), - }, - }); - } - - return configs; -} - -export const meta = { tsSupport: null }; diff --git a/.ores-lint/eslint/formatter.mjs b/.ores-lint/eslint/formatter.mjs deleted file mode 100644 index e4a9a52..0000000 --- a/.ores-lint/eslint/formatter.mjs +++ /dev/null @@ -1,79 +0,0 @@ -/** - * ores-lint :: capped ESLint formatter - * - * Same reporting contract as the Rust side: one block per rule, at most - * ORES_LINT_MAX_EXAMPLES concrete locations, then a count of the remainder. - * A rollout across hundreds of repos surfaces thousands of missing semicolons; - * printing every one of them buries the findings that actually matter. - */ - -const MAX = Math.max(1, Number(process.env.ORES_LINT_MAX_EXAMPLES || 5)); - -const LABELS = { - semi: 'missing semicolon (ores house style)', - 'ores/semi': 'missing semicolon (ores house style)', - 'ores/require-send': 'logging chain never delivered (ores custom rule)', -}; - -export default function oresFormatter(results) { - const byRule = new Map(); - let files = 0; - let errors = 0; - let warnings = 0; - const parseErrors = []; - - for (const result of results) { - if (!result.messages.length) continue; - files++; - const rel = (result.filePath || '').replace(`${process.cwd()}/`, ''); - for (const m of result.messages) { - if (m.severity === 2) errors++; else warnings++; - if (!m.ruleId) { parseErrors.push(`${rel}:${m.line || 0}: ${m.message}`); continue; } - let entry = byRule.get(m.ruleId); - if (!entry) { entry = { count: 0, examples: [], severity: m.severity, message: m.message }; byRule.set(m.ruleId, entry); } - entry.count++; - if (entry.examples.length < MAX) entry.examples.push(`${rel}:${m.line}:${m.column}`); - } - } - - // Report how many files were actually examined. "clean" and "nothing was - // linted" are otherwise indistinguishable, which makes a silent coverage gap - // look like a passing repo - the single most misleading thing a linter can do. - const examined = results.length; - if (!byRule.size && !parseErrors.length) { - return examined === 0 - ? 'ores-lint[js]: no lintable files matched (check ignores / file extensions)\n' - : `ores-lint[js]: clean (${examined} file${examined === 1 ? '' : 's'} linted)\n`; - } - - const out = []; - const total = errors + warnings; - out.push(`ores-lint[js]: ${total} finding(s) across ${byRule.size} rule(s) in ${files} of ${examined} file(s) linted`); - - // House rules first, then the rest by frequency. - const ordered = [...byRule.entries()].sort((a, b) => { - const ah = a[0] in LABELS ? 0 : 1; - const bh = b[0] in LABELS ? 0 : 1; - return ah - bh || b[1].count - a[1].count; - }); - - for (const [ruleId, entry] of ordered) { - const sev = entry.severity === 2 ? 'error' : 'warning'; - const label = LABELS[ruleId] || entry.message; - out.push(''); - out.push(` ${sev}: ${label} [${ruleId}]`); - out.push(` ${entry.count} instance(s); showing ${Math.min(entry.count, MAX)}:`); - for (const ex of entry.examples) out.push(` ${ex}`); - if (entry.count > MAX) out.push(` ... and ${entry.count - MAX} more`); - } - - if (parseErrors.length) { - out.push(''); - out.push(` note: ${parseErrors.length} file(s) could not be parsed (usually a missing parser, not a defect):`); - for (const p of parseErrors.slice(0, MAX)) out.push(` ${p}`); - if (parseErrors.length > MAX) out.push(` ... and ${parseErrors.length - MAX} more`); - } - - out.push(''); - return `${out.join('\n')}\n`; -} diff --git a/.ores-lint/eslint/plugin.mjs b/.ores-lint/eslint/plugin.mjs deleted file mode 100644 index b010e4d..0000000 --- a/.ores-lint/eslint/plugin.mjs +++ /dev/null @@ -1,361 +0,0 @@ -/** - * ores-lint :: vendored ESLint plugin - * - * Plain ESM. No build step, no registry dependencies. The only thing it needs - * is an `eslint` that already exists in the repo. - * - * Rules - * ores/require-send - a logging chain that reaches a level method must end - * in a terminal call (.send(), .send(true), ...). - * Generalised from the ores-otel next-loggers plugin. - * ores/semi - fallback semicolon rule, used only when core `semi` - * is unavailable. See base.mjs. - */ - -const DEFAULT_LEVEL_METHODS = ['trace', 'debug', 'info', 'log', 'warn', 'error', 'fatal']; -const DEFAULT_TERMINAL_METHODS = ['send', 'send_with_store']; - -const LOGGER_EXPORTS = new Set([ - 'logger', 'browserLogger', 'edgeLogger', 'cloudflareWorkerLogger', - 'nodeLogger', 'bunLogger', 'denoLogger', -]); -const FACTORY_EXPORTS = new Set([ - 'createLogger', 'createBrowserLogger', 'createEdgeLogger', - 'createCloudflareWorkerLogger', 'createNodeLogger', 'createBunLogger', - 'createDenoLogger', -]); -const CLASS_EXPORTS = new Set([ - 'BaseLogger', 'BrowserLogger', 'EdgeLogger', 'CloudflareWorkerLogger', - 'NodeLogger', 'BunLogger', 'DenoLogger', -]); - -function hasType(node, ...types) { - return Boolean(node && types.includes(String(node.type))); -} - -/** Strip wrappers that do not change the identity of the underlying expression. */ -function unwrap(node) { - let current = node; - while ( - current && - (hasType(current, 'ChainExpression', 'AwaitExpression', 'TSAsExpression', 'TSTypeAssertion', 'TSNonNullExpression') || - (current.type === 'UnaryExpression' && current.operator === 'void')) - ) { - current = current.expression || current.argument; - } - return current || undefined; -} - -function getPropertyName(node) { - if (!hasType(node, 'MemberExpression', 'OptionalMemberExpression')) return undefined; - const property = node.property; - if (!property) return undefined; - if (!node.computed && property.type === 'Identifier') return property.name; - if (node.computed && property.type === 'Literal' && typeof property.value === 'string') return property.value; - return undefined; -} - -function getQualifiedName(node) { - const current = unwrap(node); - if (!current) return undefined; - if (current.type === 'Identifier') return current.name; - if (current.type === 'ThisExpression') return 'this'; - if (hasType(current, 'MemberExpression', 'OptionalMemberExpression')) { - const objectName = getQualifiedName(current.object); - const propertyName = getPropertyName(current); - return objectName && propertyName ? `${objectName}.${propertyName}` : undefined; - } - return undefined; -} - -/** Walk a call chain back to its root, collecting method names left-to-right. */ -function collectCallChain(node, methods) { - const current = unwrap(node); - if (!current) return undefined; - if (hasType(current, 'CallExpression', 'OptionalCallExpression')) { - const callee = unwrap(current.callee); - if (callee && hasType(callee, 'MemberExpression', 'OptionalMemberExpression')) { - const root = collectCallChain(callee.object, methods); - const method = getPropertyName(callee); - if (method) methods.push(method); - return root; - } - return getQualifiedName(callee); - } - return getQualifiedName(current); -} - -function isTrackedModule(source, moduleNames) { - if (typeof source !== 'string') return false; - for (const moduleName of moduleNames) { - if (source === moduleName || source.startsWith(`${moduleName}/`)) return true; - } - return false; -} - -function inspectChain(node, knownLoggers, levelMethods, terminalMethods) { - const methods = []; - const root = collectCallChain(node, methods); - const levelIndex = methods.findIndex((method) => levelMethods.has(method)); - const delivered = levelIndex >= 0 && methods.slice(levelIndex + 1).some((method) => terminalMethods.has(method)); - const isEvent = Boolean(root && knownLoggers.has(root) && levelIndex >= 0); - return { methods, root, levelIndex, delivered, isEvent }; -} - -export const requireSendRule = { - meta: { - type: 'problem', - docs: { - description: 'require chainable logger events to call a terminal method such as send() or send(boolean)', - url: 'https://github.com/ores-otel/ores.otel.log', - }, - schema: [{ - type: 'object', - properties: { - loggerNames: { type: 'array', items: { type: 'string' }, uniqueItems: true }, - moduleNames: { type: 'array', items: { type: 'string' }, uniqueItems: true }, - levelMethods: { type: 'array', items: { type: 'string' }, uniqueItems: true }, - terminalMethods: { type: 'array', items: { type: 'string' }, uniqueItems: true }, - }, - additionalProperties: false, - }], - messages: { - missingSend: "Logging chain never calls {{terminal}} - this log event is built but never delivered. Override with // eslint-disable-next-line ores/require-send or // ores-lint-disable-next-line require-send", - }, - }, - - create(context) { - const options = context.options[0] || {}; - const knownLoggers = new Set(['log', 'logger', 'ddlog', ...(options.loggerNames || [])]); - const knownFactories = new Set(); - const knownClasses = new Set(); - const moduleNames = new Set(['@oresoftware/next-loggers', ...(options.moduleNames || [])]); - const levelMethods = new Set(options.levelMethods || DEFAULT_LEVEL_METHODS); - const terminalMethods = new Set(options.terminalMethods || DEFAULT_TERMINAL_METHODS); - const terminalLabel = [...terminalMethods].map((m) => `.${m}()`).join(' or '); - - const scopes = []; - const enterScope = () => scopes.push({ pending: new Map() }); - const exitScope = () => { - const scope = scopes.pop(); - if (!scope) return; - for (const node of scope.pending.values()) { - context.report({ node, messageId: 'missingSend', data: { terminal: terminalLabel } }); - } - }; - const markPending = (name, node) => { - if (!name || !scopes.length) return; - scopes[scopes.length - 1].pending.set(name, node); - }; - const clearPending = (name) => { - if (!name) return; - for (let i = scopes.length - 1; i >= 0; i--) { - if (scopes[i].pending.has(name)) { - scopes[i].pending.delete(name); - return; - } - } - }; - const isPending = (name) => { - if (!name) return false; - for (let i = scopes.length - 1; i >= 0; i--) { - if (scopes[i].pending.has(name)) return true; - } - return false; - }; - - const isLoggerProducer = (node) => { - const current = unwrap(node); - if (!current) return false; - const directName = getQualifiedName(current); - if (directName && knownLoggers.has(directName)) return true; - if (current.type === 'NewExpression') { - const className = getQualifiedName(current.callee); - return Boolean(className && knownClasses.has(className)); - } - if (hasType(current, 'CallExpression', 'OptionalCallExpression')) { - const calleeName = getQualifiedName(current.callee); - if (calleeName && knownFactories.has(calleeName)) return true; - const callee = unwrap(current.callee); - if (callee && hasType(callee, 'MemberExpression', 'OptionalMemberExpression')) { - const method = getPropertyName(callee); - const owner = getQualifiedName(callee.object); - return method === 'anew' && Boolean(owner && knownLoggers.has(owner)); - } - } - return false; - }; - - const consumeTerminalUse = (node) => { - const current = unwrap(node); - if (!current) return; - const chain = inspectChain(current, knownLoggers, levelMethods, terminalMethods); - if (chain.root && chain.methods.some((method) => terminalMethods.has(method))) { - clearPending(chain.root); - } - if (hasType(current, 'CallExpression', 'OptionalCallExpression')) { - for (const arg of current.arguments || []) { - const name = getQualifiedName(unwrap(arg)); - if (isPending(name)) clearPending(name); - } - } - }; - - const functionEnter = () => enterScope(); - const functionExit = () => exitScope(); - - return { - Program() { enterScope(); }, - 'Program:exit'() { exitScope(); }, - FunctionDeclaration: functionEnter, - 'FunctionDeclaration:exit': functionExit, - FunctionExpression: functionEnter, - 'FunctionExpression:exit': functionExit, - ArrowFunctionExpression(node) { - enterScope(); - if (node.body && node.body.type !== 'BlockStatement') { - const name = getQualifiedName(unwrap(node.body)); - if (isPending(name)) clearPending(name); - } - }, - 'ArrowFunctionExpression:exit': functionExit, - - ImportDeclaration(node) { - if (!isTrackedModule(node.source?.value, moduleNames)) return; - for (const specifier of node.specifiers || []) { - const localName = specifier.local?.name; - if (!localName) continue; - if (specifier.type === 'ImportDefaultSpecifier') { knownLoggers.add(localName); continue; } - if (specifier.type === 'ImportNamespaceSpecifier') { - for (const name of LOGGER_EXPORTS) knownLoggers.add(`${localName}.${name}`); - for (const name of FACTORY_EXPORTS) knownFactories.add(`${localName}.${name}`); - for (const name of CLASS_EXPORTS) knownClasses.add(`${localName}.${name}`); - continue; - } - const importedName = specifier.imported?.name || specifier.imported?.value; - if (typeof importedName !== 'string') continue; - if (LOGGER_EXPORTS.has(importedName)) knownLoggers.add(localName); - if (FACTORY_EXPORTS.has(importedName)) knownFactories.add(localName); - if (CLASS_EXPORTS.has(importedName)) knownClasses.add(localName); - } - }, - - VariableDeclarator(node) { - if (node.id?.type === 'Identifier' && node.id.name && isLoggerProducer(node.init)) { - knownLoggers.add(node.id.name); - } - if (node.id?.type !== 'Identifier' || !node.id.name) return; - const chain = inspectChain(node.init, knownLoggers, levelMethods, terminalMethods); - if (!chain.isEvent) return; - if (chain.delivered) return; - markPending(node.id.name, node); - }, - - AssignmentExpression(node) { - const assignedName = getQualifiedName(node.left); - if (assignedName && isLoggerProducer(node.right)) knownLoggers.add(assignedName); - const chain = inspectChain(node.right, knownLoggers, levelMethods, terminalMethods); - if (chain.isEvent && !chain.delivered && assignedName) { - markPending(assignedName, node); - return; - } - consumeTerminalUse(node.right); - }, - - ReturnStatement(node) { - if (!node.argument) return; - const name = getQualifiedName(unwrap(node.argument)); - if (isPending(name)) { - clearPending(name); - return; - } - const chain = inspectChain(node.argument, knownLoggers, levelMethods, terminalMethods); - if (chain.isEvent && !chain.delivered) return; - consumeTerminalUse(node.argument); - }, - - CallExpression(node) { - consumeTerminalUse(node); - }, - - ExpressionStatement(node) { - const chain = inspectChain(node.expression, knownLoggers, levelMethods, terminalMethods); - if (chain.isEvent && !chain.delivered) { - context.report({ node, messageId: 'missingSend', data: { terminal: terminalLabel } }); - return; - } - consumeTerminalUse(node.expression); - }, - }; - }, -}; - -/** - * Fallback semicolon rule. Only wired up when core `semi` is missing, so in - * practice this is dormant - it exists so the house style survives a future - * ESLint that drops its formatting rules. - */ -const NEEDS_SEMI = new Set([ - 'ExpressionStatement', 'ReturnStatement', 'ThrowStatement', 'BreakStatement', - 'ContinueStatement', 'DebuggerStatement', 'DoWhileStatement', 'ImportDeclaration', - 'ExportAllDeclaration', 'PropertyDefinition', 'TSTypeAliasDeclaration', - 'TSDeclareFunction', 'TSImportEqualsDeclaration', -]); - -export const semiRule = { - meta: { - type: 'layout', - fixable: 'code', - schema: [], - docs: { description: 'require semicolons at the end of statements (vendored fallback)' }, - messages: { missingSemi: 'Missing semicolon.' }, - }, - create(context) { - const sourceCode = context.sourceCode || context.getSourceCode(); - - const check = (node) => { - const lastToken = sourceCode.getLastToken(node); - if (!lastToken) return; - if (lastToken.type === 'Punctuator' && lastToken.value === ';') return; - context.report({ - node, - loc: lastToken.loc.end, - messageId: 'missingSemi', - fix: (fixer) => fixer.insertTextAfter(lastToken, ';'), - }); - }; - - const handlers = {}; - for (const type of NEEDS_SEMI) handlers[type] = check; - - // `for (let i = 0 ...)` heads and `for (const x of y)` must not get one. - handlers.VariableDeclaration = (node) => { - const parent = node.parent; - if (parent && ( - (parent.type === 'ForStatement' && parent.init === node) || - ((parent.type === 'ForInStatement' || parent.type === 'ForOfStatement') && parent.left === node) - )) return; - check(node); - }; - - // Only export forms that are expressions/re-exports need a semicolon; - // `export function f() {}` and `export class C {}` do not. - const checkExport = (node) => { - const decl = node.declaration; - if (decl && ['FunctionDeclaration', 'ClassDeclaration', 'TSInterfaceDeclaration', 'TSEnumDeclaration', 'TSModuleDeclaration'].includes(decl.type)) return; - check(node); - }; - handlers.ExportNamedDeclaration = checkExport; - handlers.ExportDefaultDeclaration = checkExport; - - return handlers; - }, -}; - -export const rules = { - 'require-send': requireSendRule, - semi: semiRule, -}; - -const plugin = { meta: { name: 'ores-lint', version: '1.3.0' }, rules }; -export default plugin; diff --git a/.ores-lint/gleam.sh b/.ores-lint/gleam.sh deleted file mode 100755 index f3ec2df..0000000 --- a/.ores-lint/gleam.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/sh -# ores-lint :: Gleam -# -# Uses the Gleam compiler toolchain - the universally accepted checker for the -# language (there is no ESLint-equivalent plugin host): -# gleam format --check formatting, analogous to rustfmt --check -# gleam check compiler warnings / unused values -# Custom house rules (require-send on logging pipes) live in require-send.mjs -# and run from lint.sh. -# -# Nothing is installed. Missing gleam is an actionable skip. - -set -u -DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -. "$DIR/config.sh" -ROOT=${1:-.} -ROOT=$(CDPATH= cd -- "$ROOT" && pwd) - -[ "${ORES_LINT_SKIP_GLEAM}" = "1" ] && { echo "ores-lint[gleam]: skipped (ORES_LINT_SKIP_GLEAM=1)"; exit 0; } - -TOMLS=$(cd "$ROOT" && find . -maxdepth "$ORES_LINT_DEPTH" \ - \( -name node_modules -o -name target -o -name .git -o -name build -o -name vendor -o -name .vendor -o -name .ores-lint \) -prune -o \ - -type f -name gleam.toml -print 2>/dev/null \ - | sed 's|^\./||; s|gleam\.toml$||; s|/$||; s|^$|.|' | sort) - -[ -z "$TOMLS" ] && exit 0 - -command -v gleam >/dev/null 2>&1 || { - echo "ores-lint[gleam]: gleam not found on PATH - skipping" - echo " install from https://gleam.run/getting-started/installing/" - exit 0 -} - -NESTED_FILE="$DIR/nested-repos.json" -if [ -f "$NESTED_FILE" ]; then - NESTED=$(grep -o '"[^"]*"' "$NESTED_FILE" 2>/dev/null | tr -d '"') - if [ -n "$NESTED" ]; then - KEPT="" - for c in $TOMLS; do - drop=0 - for nrepo in $NESTED; do - case "$c" in - "$nrepo"|"$nrepo"/*) drop=1; break ;; - esac - done - [ "$drop" = "0" ] && KEPT="$KEPT -$c" - done - TOMLS=$(printf '%s' "$KEPT" | sed '/^$/d') - fi -fi - -[ -z "$TOMLS" ] && { echo "ores-lint[gleam]: all packages belong to nested repos - nothing to do here"; exit 0; } - -RAW=$(mktemp) || exit 0 -RAN=0 -FAILED="" - -for c in $TOMLS; do - if [ "$c" = "." ]; then cdir="$ROOT"; else cdir="$ROOT/$c"; fi - OUT=$(mktemp) - RC=0 - FMT_ARGS="src" - [ -d "$cdir/test" ] && FMT_ARGS="$FMT_ARGS test" - ( cd "$cdir" && gleam format --check $FMT_ARGS 2>/dev/null; gleam check ) >"$OUT" 2>&1 || RC=$? - RAN=$((RAN + 1)) - if [ "$RC" -ne 0 ] && ! grep -qE 'error|warning|Which files to format' "$OUT"; then - FAILED="$FAILED - $c (exit $RC): $(sed -n '1,2p' "$OUT" | tr '\n' ' ' | cut -c1-140)" - rm -f "$OUT" - continue - fi - if [ "$c" = "." ]; then cat "$OUT" >> "$RAW"; else sed "s|^|$c/|" "$OUT" >> "$RAW"; fi - rm -f "$OUT" -done - -echo "ores-lint[gleam]: linted $RAN package(s)" -[ -n "$FAILED" ] && printf 'ores-lint[gleam]: gleam could not run in some packages:%s\n' "$FAILED" - -awk -v MAXEX="$ORES_LINT_MAX_EXAMPLES" ' -BEGIN { max = MAXEX + 0; if (max < 1) max = 1; n = 0 } -/error:|warning:/ { - msg = $0 - n++ - if (shown < max) { ex = ex (shown++ ? "\n" : "") " " msg; } - next -} -END { - if (n == 0) { print "ores-lint[gleam]: clean"; exit 0 } - printf "ores-lint[gleam]: %d finding(s)\n", n - print ex - if (n > max) printf " ... and %d more\n", n - max - print "" -} -' "$RAW" - -rm -f "$RAW" -exit 0 diff --git a/.ores-lint/install-git-hooks.sh b/.ores-lint/install-git-hooks.sh deleted file mode 100755 index c4dbce2..0000000 --- a/.ores-lint/install-git-hooks.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -# Optional: install a pre-push hook that runs ores-lint. -# Not installed automatically by the rollout - run this yourself per repo. -set -u -ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "not a git repo"; exit 1; } -HOOK="$ROOT/.git/hooks/pre-push" -if [ -e "$HOOK" ] && ! grep -q 'ores-lint' "$HOOK"; then - echo "refusing to clobber an existing pre-push hook: $HOOK" - exit 1 -fi -cat > "$HOOK" <<'INNER' -#!/bin/sh -# installed by .ores-lint/install-git-hooks.sh -[ -x "$(git rev-parse --show-toplevel)/.ores-lint/lint.sh" ] && \ - sh "$(git rev-parse --show-toplevel)/.ores-lint/lint.sh" -exit 0 -INNER -chmod +x "$HOOK" -echo "installed $HOOK" diff --git a/.ores-lint/js.sh b/.ores-lint/js.sh deleted file mode 100755 index ecbb076..0000000 --- a/.ores-lint/js.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/bin/sh -# ores-lint :: JavaScript / TypeScript -# -# ESLint is treated as a GLOBALLY INSTALLED TOOL, not a per-repo dependency. -# Nothing here installs anything and nothing reaches the network; hundreds of -# repos sharing one global eslint is the whole point. Resolution order: -# -# 1. a local node_modules/.bin/eslint, walking up for monorepos -# (respected if a repo genuinely pins its own) -# 2. eslint on PATH (npm i -g eslint) -# 3. the global npm root -# -# If none is found this prints an actionable skip and exits 0. - -set -u -DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -. "$DIR/config.sh" -ROOT=${1:-.} - -[ "${ORES_LINT_SKIP_JS}" = "1" ] && { echo "ores-lint[js]: skipped (ORES_LINT_SKIP_JS=1)"; exit 0; } - -find_local_eslint() { - d=$(CDPATH= cd -- "$1" && pwd) - while [ -n "$d" ] && [ "$d" != "/" ]; do - if [ -x "$d/node_modules/.bin/eslint" ]; then printf '%s\n' "$d/node_modules/.bin/eslint"; return 0; fi - d=$(dirname "$d") - done - return 1 -} - -GLOBAL_ROOT="" -for probe in "npm root -g" "pnpm root -g" "yarn global dir"; do - r=$($probe 2>/dev/null | head -1) - [ -n "$r" ] && [ -d "$r" ] && { GLOBAL_ROOT="$r"; break; } -done - -ESLINT="" -ESLINT_KIND="" -if ESLINT=$(find_local_eslint "$ROOT"); then - ESLINT_KIND="local" -elif command -v eslint >/dev/null 2>&1; then - ESLINT=$(command -v eslint); ESLINT_KIND="global (PATH)" -elif [ -n "$GLOBAL_ROOT" ] && [ -x "$GLOBAL_ROOT/.bin/eslint" ]; then - ESLINT="$GLOBAL_ROOT/.bin/eslint"; ESLINT_KIND="global" -else - echo "ores-lint[js]: no eslint found - skipping" - echo " install it once, globally: npm i -g eslint" - echo " (ores-lint never adds eslint to a repo's node_modules)" - exit 0 -fi - -# Version gate. Flat config needs ESLint 9+; an older one would fail confusingly. -VER=$("$ESLINT" --version 2>/dev/null | sed 's/^v//') -MAJOR=$(printf '%s' "$VER" | cut -d. -f1) -case "$MAJOR" in - ''|*[!0-9]*) : ;; # unparseable version: proceed rather than block - *) - if [ "$MAJOR" -lt "${ORES_LINT_ESLINT_MIN_MAJOR}" ]; then - echo "ores-lint[js]: found eslint $VER ($ESLINT_KIND) but flat config needs >=${ORES_LINT_ESLINT_MIN_MAJOR} - skipping" - echo " upgrade with: npm i -g eslint@latest" - exit 0 - fi - ;; -esac - -CONFIG="" -for c in eslint.config.mjs eslint.config.js eslint.config.cjs; do - [ -f "$ROOT/$c" ] && { CONFIG="$ROOT/$c"; break; } -done -[ -z "$CONFIG" ] && { echo "ores-lint[js]: no flat eslint config found - skipping"; exit 0; } - -# Let base.mjs find globally installed optional tooling (typescript-eslint). -[ -n "$GLOBAL_ROOT" ] && export ORES_LINT_GLOBAL_ROOT="$GLOBAL_ROOT" -[ -n "$GLOBAL_ROOT" ] && export NODE_PATH="${NODE_PATH:+$NODE_PATH:}$GLOBAL_ROOT" - -# Make the TypeScript gap visible. A repo with a tsconfig whose .ts files are -# being skipped looks identical to a clean repo otherwise. -if [ -f "$ROOT/tsconfig.json" ] || ls "$ROOT"/src/*.ts >/dev/null 2>&1; then - if ! node -e " - const {createRequire}=require('node:module'); - const r=createRequire('$ROOT/package.json'); - const paths=[process.env.ORES_LINT_GLOBAL_ROOT].filter(Boolean); - for (const id of ['typescript-eslint','@typescript-eslint/parser']) { - try { r.resolve(id); process.exit(0); } catch {} - if (paths.length) { try { r.resolve(id,{paths}); process.exit(0); } catch {} } - } - process.exit(1); - " 2>/dev/null; then - echo "ores-lint[js]: NOTE - this repo has TypeScript but no typescript-eslint parser;" - echo " .ts/.tsx files are being SKIPPED. Fix with: npm i -g typescript-eslint" - fi -fi - -OUT=$(mktemp) || exit 0 -RC=0 -( cd "$ROOT" && "$ESLINT" . \ - --no-error-on-unmatched-pattern \ - --format "$DIR/eslint/formatter.mjs" ) >"$OUT" 2>&1 || RC=$? - -if [ "$RC" -ne 0 ] && ! grep -q 'ores-lint\[js\]' "$OUT"; then - echo "ores-lint[js]: eslint $VER ($ESLINT_KIND) could not run in $ROOT (exit $RC). First lines:" - sed -n '1,6p' "$OUT" | sed 's/^/ | /' - rm -f "$OUT"; exit 0 -fi - -cat "$OUT" -rm -f "$OUT" -exit 0 diff --git a/.ores-lint/lint.sh b/.ores-lint/lint.sh deleted file mode 100755 index af39cca..0000000 --- a/.ores-lint/lint.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/sh -# ores-lint :: entry point -# -# Warn-only by default. This is wired into build and publish hooks across -# hundreds of repos, so it is designed to be incapable of breaking one unless a -# human explicitly sets ORES_LINT_STRICT=1. -# -# Both halves discover sub-projects rather than assuming the repo root is the -# only project: `eslint .` walks nested packages from the root config, and -# rust.sh finds every crate including ones buried under apps/ or clients/. - -set -u -DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -ROOT=$(dirname "$DIR") -. "$DIR/config.sh" - -echo "ores-lint v$(cat "$DIR/VERSION" 2>/dev/null || echo '?') :: $(basename "$ROOT")" - -FOUND=0 -LOG=$(mktemp) || exit 0 - -# JS: an eslint flat config at the repo root is the trigger. The rollout puts -# one there whenever the repo contains any JS/TS at all, so nested packages in -# a Rust-rooted repo still get linted. -for c in eslint.config.mjs eslint.config.js eslint.config.cjs; do - if [ -f "$ROOT/$c" ]; then - sh "$DIR/js.sh" "$ROOT" | tee -a "$LOG" - FOUND=1 - break - fi -done - -# Rust: any Cargo.toml anywhere in the repo, not just at the root. -if find "$ROOT" -maxdepth "$ORES_LINT_DEPTH" \ - \( -name node_modules -o -name target -o -name .git -o -name vendor -o -name .vendor \) -prune -o \ - -type f -name Cargo.toml -print 2>/dev/null | head -1 | grep -q .; then - sh "$DIR/rust.sh" "$ROOT" | tee -a "$LOG" - FOUND=1 -fi - -# Dart / Flutter: pubspec.yaml or any .dart source. -if find "$ROOT" -maxdepth "$ORES_LINT_DEPTH" \ - \( -name node_modules -o -name target -o -name .git -o -name vendor -o -name .vendor -o -name build \) -prune -o \ - \( -type f -name pubspec.yaml -o -type f -name '*.dart' \) -print 2>/dev/null | head -1 | grep -q .; then - sh "$DIR/dart.sh" "$ROOT" | tee -a "$LOG" - FOUND=1 -fi - -# Gleam: gleam.toml or any .gleam source. -if find "$ROOT" -maxdepth "$ORES_LINT_DEPTH" \ - \( -name node_modules -o -name target -o -name .git -o -name vendor -o -name .vendor -o -name build \) -prune -o \ - \( -type f -name gleam.toml -o -type f -name '*.gleam' \) -print 2>/dev/null | head -1 | grep -q .; then - sh "$DIR/gleam.sh" "$ROOT" | tee -a "$LOG" - FOUND=1 -fi - -# House rule shared across Rust / Dart / Gleam (TypeScript is ores/require-send). -if command -v node >/dev/null 2>&1 && [ -f "$DIR/require-send.mjs" ]; then - if find "$ROOT" -maxdepth "$ORES_LINT_DEPTH" \ - \( -name node_modules -o -name target -o -name .git -o -name vendor -o -name .vendor -o -name build \) -prune -o \ - -type f \( -name '*.rs' -o -name '*.dart' -o -name '*.gleam' \) -print 2>/dev/null | head -1 | grep -q .; then - node "$DIR/require-send.mjs" "$ROOT" | tee -a "$LOG" - FOUND=1 - fi -fi - -[ "$FOUND" = "0" ] && echo "ores-lint: no JS, Rust, Dart or Gleam project found in this repo - nothing to do" - -if [ "${ORES_LINT_STRICT}" = "1" ] && grep -q 'finding(s) across' "$LOG"; then - rm -f "$LOG" - echo "ores-lint: FAILING because ORES_LINT_STRICT=1" - exit 1 -fi - -rm -f "$LOG" -exit 0 diff --git a/.ores-lint/nested-repos.json b/.ores-lint/nested-repos.json deleted file mode 100644 index fe51488..0000000 --- a/.ores-lint/nested-repos.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/.ores-lint/require-send.mjs b/.ores-lint/require-send.mjs deleted file mode 100644 index 7662ddd..0000000 --- a/.ores-lint/require-send.mjs +++ /dev/null @@ -1,448 +0,0 @@ -#!/usr/bin/env node -/** - * ores-lint :: require-send (Rust, Dart, Gleam) - * - * House rule, same contract as ores/require-send in ESLint: a logging chain - * that reaches a level method must be delivered with send() / send(boolean) / - * send_with_store(...). TypeScript stays on ESLint; this file covers the - * languages ESLint cannot parse. - * - * Line-level overrides (any of these, on the finding line or the previous line): - * ores-lint-disable-next-line require-send - * ores-lint-disable-line require-send - * File-level: - * ores-lint-disable-file require-send - * - * Warn-only. Prints the same capped report format as rust.sh / js.sh. - */ -import { execFileSync } from 'node:child_process'; -import { readFileSync, existsSync } from 'node:fs'; -import { join, dirname } from 'node:path'; - -const ROOT = process.argv[2] ? process.argv[2] : process.cwd(); -const DIR = dirname(new URL(import.meta.url).pathname); -const MAX = Math.max(1, Number(process.env.ORES_LINT_MAX_EXAMPLES || 5)); -const INCLUDE_TESTS = process.env.ORES_LINT_REQUIRE_SEND_INCLUDE_TESTS === '1'; -const LEVEL = new Set(['trace', 'debug', 'info', 'log', 'warn', 'error', 'fatal']); -const TERMINAL = new Set(['send', 'send_with_store']); -const SKIP_DIR = new Set([ - 'node_modules', 'target', 'dist', 'build', 'out', 'vendor', 'coverage', - '.git', '.worktrees', '_to_delete', '.next', '.ores-lint', '.vendor', - 'deps', 'third_party', 'thirdparty', 'external', 'submodules', '.r2g', -]); -const TEST_RE = /(?:^|\/)(?:test|tests|spec)\/|_test\.(?:dart|gleam|rs)$|\.test\.|\.spec\./i; - -function nestedRepos() { - try { - const raw = JSON.parse(readFileSync(join(DIR, 'nested-repos.json'), 'utf8')); - return Array.isArray(raw) ? raw : []; - } catch { - return []; - } -} - -function trackedFiles() { - let output; - try { - output = execFileSync('git', ['-C', ROOT, 'ls-files', '-z'], { - encoding: 'buffer', - maxBuffer: 64 * 1024 * 1024, - }).toString('utf8'); - } catch { - return []; - } - const nested = nestedRepos(); - return output.split('\0').filter(Boolean).filter((rel) => { - if (!/\.(?:rs|dart|gleam)$/.test(rel)) return false; - const parts = rel.split('/'); - if (parts.some((p) => SKIP_DIR.has(p))) return false; - if (nested.some((n) => rel === n || rel.startsWith(`${n}/`))) return false; - if (!INCLUDE_TESTS && TEST_RE.test(rel)) return false; - return true; - }); -} - -function looksLikeLogger(name) { - if (!name) return false; - const base = String(name).split('.').pop(); - return ( - /^(?:log|logger|ddlog|telemetry|audit|self|this)$/i.test(base) - || /logger$/i.test(base) - || /(?:^|_)log$/i.test(base) - ); -} - -function isDisabled(suppressions, line) { - if (suppressions.file) return true; - if (suppressions.lines.has(line) || suppressions.lines.has(line - 1)) return true; - if (suppressions.next.has(line - 1)) return true; - return false; -} - -function collectSuppressions(source) { - const file = /ores-lint-disable-file\s+require-send/.test(source); - const lines = new Set(); - const next = new Set(); - const raw = source.split('\n'); - for (let i = 0; i < raw.length; i++) { - const line = raw[i]; - if (/ores-lint-disable-line\s+require-send/.test(line) || /eslint-disable-line\s+ores\/require-send/.test(line)) { - lines.add(i + 1); - } - if (/ores-lint-disable-next-line\s+require-send/.test(line) || /eslint-disable-next-line\s+ores\/require-send/.test(line)) { - next.add(i + 1); - } - } - return { file, lines, next }; -} - -function tokenize(source) { - const tokens = []; - const n = source.length; - let i = 0; - let line = 1; - let col = 1; - const push = (type, value, startLine, startCol) => { - tokens.push({ type, value, line: startLine, col: startCol }); - }; - const bump = (ch) => { - if (ch === '\n') { line += 1; col = 1; } else col += 1; - }; - - while (i < n) { - const ch = source[i]; - const startLine = line; - const startCol = col; - - if (ch === '/' && source[i + 1] === '/') { - while (i < n && source[i] !== '\n') { bump(source[i]); i += 1; } - continue; - } - if (ch === '/' && source[i + 1] === '*') { - i += 2; bump('/'); bump('*'); - while (i < n && !(source[i] === '*' && source[i + 1] === '/')) { bump(source[i]); i += 1; } - if (i < n) { bump('*'); bump('/'); i += 2; } - continue; - } - if (ch === '#') { - // Gleam does not use # comments; rust raw strings / dart interpolations - // are handled as identifiers or other. Treat # as other. - } - if (ch === '"' || ch === "'" || ch === '`') { - const q = ch; - bump(ch); i += 1; - while (i < n && source[i] !== q) { - if (source[i] === '\\' && i + 1 < n) { bump(source[i]); bump(source[i + 1]); i += 2; continue; } - if (source[i] === '\n' && q !== '`') break; - bump(source[i]); i += 1; - } - if (i < n && source[i] === q) { bump(q); i += 1; } - push('string', '', startLine, startCol); - continue; - } - if (/\s/.test(ch)) { - bump(ch); i += 1; - continue; - } - if (ch === '|' && source[i + 1] === '>') { - push('pipe', '|>', startLine, startCol); - bump('|'); bump('>'); i += 2; - continue; - } - if (ch === '=' && source[i + 1] === '>') { - push('arrow', '=>', startLine, startCol); - bump('='); bump('>'); i += 2; - continue; - } - if (/[A-Za-z_]/.test(ch)) { - let value = ''; - while (i < n && /[A-Za-z0-9_]/.test(source[i])) { value += source[i]; bump(source[i]); i += 1; } - push('ident', value, startLine, startCol); - continue; - } - const singles = { - '.': 'dot', '(': 'lparen', ')': 'rparen', '[': 'lbracket', ']': 'rbracket', - '{': 'lbrace', '}': 'rbrace', ';': 'semi', ',': 'comma', '=': 'eq', - }; - if (singles[ch]) { - push(singles[ch], ch, startLine, startCol); - bump(ch); i += 1; - continue; - } - bump(ch); i += 1; - } - return tokens; -} - -function skipBalanced(tokens, start, open, close) { - let depth = 0; - for (let i = start; i < tokens.length; i++) { - if (tokens[i].type === open) depth += 1; - else if (tokens[i].type === close) { - depth -= 1; - if (depth === 0) return i; - } - } - return tokens.length - 1; -} - -function qualifiedName(tokens, index) { - // Walk left across ident.ident - let i = index; - if (!tokens[i] || tokens[i].type !== 'ident') return { name: '', start: index }; - let name = tokens[i].value; - while (i >= 2 && tokens[i - 1].type === 'dot' && tokens[i - 2].type === 'ident') { - name = `${tokens[i - 2].value}.${name}`; - i -= 2; - } - return { name, start: i }; -} - -function countTopLevelArgs(tokens, lparenIndex) { - if (tokens[lparenIndex]?.type !== 'lparen') return 0; - let depth = 0; - let args = 0; - let seen = false; - for (let i = lparenIndex; i < tokens.length; i++) { - const t = tokens[i]; - if (t.type === 'lparen' || t.type === 'lbracket' || t.type === 'lbrace') depth += 1; - else if (t.type === 'rparen' || t.type === 'rbracket' || t.type === 'rbrace') { - depth -= 1; - if (depth === 0) return seen ? args + 1 : 0; - } else if (depth === 1 && t.type === 'comma') args += 1; - else if (depth === 1 && t.type !== 'comma') seen = true; - } - return seen ? args + 1 : 0; -} - -function walkMethodChain(tokens, start) { - // start at the root ident of `root.level(args).more(args)` - const methods = []; - let i = start; - if (!tokens[i] || tokens[i].type !== 'ident') return null; - while (i + 2 < tokens.length && tokens[i + 1].type === 'dot' && tokens[i + 2].type === 'ident' && tokens[i + 3]?.type !== 'lparen') { - if (LEVEL.has(tokens[i + 2].value) || TERMINAL.has(tokens[i + 2].value)) break; - i += 2; - } - const root = qualifiedName(tokens, i).name; - let firstArgCount = 0; - while (i + 2 < tokens.length && tokens[i + 1].type === 'dot' && tokens[i + 2].type === 'ident') { - const method = tokens[i + 2].value; - methods.push(method); - i += 2; - if (tokens[i + 1]?.type === 'lparen') { - if (methods.length === 1) firstArgCount = countTopLevelArgs(tokens, i + 1); - i = skipBalanced(tokens, i + 1, 'lparen', 'rparen'); - } - } - return { root, methods, end: i, line: tokens[start].line, firstArgCount }; -} - -function walkGleamPipe(tokens, start) { - // start at ident of a call: `logging.info(...)` or `info(...)` - if (!tokens[start] || tokens[start].type !== 'ident') return null; - const head = qualifiedName(tokens, start); - let i = start; - while (i + 1 < tokens.length && tokens[i + 1].type === 'dot' && tokens[i + 2]?.type === 'ident') i += 2; - const callee = qualifiedName(tokens, i).name; - const calleeBase = callee.split('.').pop(); - if (tokens[i + 1]?.type !== 'lparen') { - // Bare `|> send` / `|> logging.send` only — not type variables named `error`. - if (TERMINAL.has(calleeBase) && tokens[head.start - 1]?.type === 'pipe') { - return { callee, methods: [calleeBase], end: i, line: tokens[head.start].line }; - } - return null; - } - const methods = [calleeBase]; - i = skipBalanced(tokens, i + 1, 'lparen', 'rparen'); - while (tokens[i + 1]?.type === 'pipe') { - i += 1; - if (tokens[i + 1]?.type !== 'ident') break; - i += 1; - while (i + 1 < tokens.length && tokens[i + 1].type === 'dot' && tokens[i + 2]?.type === 'ident') i += 2; - const step = tokens[i].value; - methods.push(step); - if (tokens[i + 1]?.type === 'lparen') i = skipBalanced(tokens, i + 1, 'lparen', 'rparen'); - } - return { callee, methods, end: i, line: tokens[head.start].line }; -} - -function precedingAssignment(tokens, start) { - // `let name =` or `final name =` or `var name =` immediately before start - let i = start - 1; - if (tokens[i]?.type !== 'eq') return null; - i -= 1; - if (tokens[i]?.type !== 'ident') return null; - const name = tokens[i].value; - const prev = tokens[i - 1]?.value; - if (prev && /^(let|var|final|const|mut)$/.test(prev)) return name; - // dart `LogEvent event =` / rust `let mut event =` already handled via let - if (tokens[i - 1]?.type === 'ident') return name; - return name; -} - -function isReturnish(tokens, start) { - const prev = tokens[start - 1]; - if (!prev) return false; - if (prev.type === 'arrow') return true; - if (prev.type === 'ident' && prev.value === 'return') return true; - return false; -} - -function nextNonChainIsSemi(tokens, end) { - const t = tokens[end + 1]; - return t?.type === 'semi'; -} - -export function analyzeSource(source, language) { - const suppressions = collectSuppressions(source); - const tokens = tokenize(source); - const findings = []; - const pending = new Map(); - const scopeStack = [pending]; - - const current = () => scopeStack[scopeStack.length - 1]; - const mark = (name, finding) => { if (name) current().set(name, finding); }; - const clear = (name) => { - if (!name) return; - for (let i = scopeStack.length - 1; i >= 0; i--) { - if (scopeStack[i].has(name)) { scopeStack[i].delete(name); return; } - } - }; - const report = (finding) => { - if (isDisabled(suppressions, finding.line)) return; - findings.push(finding); - }; - const flushScope = (map) => { - for (const finding of map.values()) report(finding); - }; - - for (let i = 0; i < tokens.length; i++) { - const tok = tokens[i]; - if (tok.type === 'lbrace') { scopeStack.push(new Map()); continue; } - if (tok.type === 'rbrace') { - if (scopeStack.length > 1) flushScope(scopeStack.pop()); - continue; - } - - // `name.send(...)` or `send(name)` / `logging.send(name)` - if (tok.type === 'ident' && TERMINAL.has(tok.value)) { - const prev = tokens[i - 1]; - if (prev?.type === 'dot' && tokens[i - 2]?.type === 'ident') { - clear(qualifiedName(tokens, i - 2).name); - } - if (tokens[i + 1]?.type === 'lparen') { - const inner = tokens[i + 2]; - if (inner?.type === 'ident') clear(inner.value); - } - } - - if (tok.type !== 'ident') continue; - - if (language === 'gleam') { - const calleeBase = tok.value; - const isLevel = LEVEL.has(calleeBase); - if (!isLevel) continue; - if (tokens[i - 1]?.type === 'ident' && tokens[i - 1].value === 'fn') continue; - const qual = qualifiedName(tokens, i); - if (tokens[i + 1]?.type !== 'lparen' && tokens[qual.start - 1]?.type !== 'pipe') continue; - const pipe = walkGleamPipe(tokens, qual.start); - if (!pipe) continue; - if (!pipe.methods.some((m) => LEVEL.has(m))) continue; - if (pipe.methods.some((m) => TERMINAL.has(m))) { - i = pipe.end; - continue; - } - const assigned = precedingAssignment(tokens, qual.start); - const finding = { line: pipe.line, col: tok.col, message: 'logging chain never calls send()' }; - const prev = tokens[qual.start - 1]?.type; - const next = tokens[pipe.end + 1]?.type; - if (assigned) mark(assigned, finding); - else if (isReturnish(tokens, qual.start) || prev === 'lparen' || prev === 'comma' || next === 'rbrace') { /* handoff / tail return */ } - else report(finding); - i = pipe.end; - continue; - } - - // Rust / Dart method chains: look for `.level(` - if (tok.type === 'ident' && LEVEL.has(tok.value) && tokens[i - 1]?.type === 'dot' && tokens[i + 1]?.type === 'lparen') { - let rootIndex = i - 2; - while (rootIndex >= 2 && tokens[rootIndex]?.type === 'ident' && tokens[rootIndex - 1]?.type === 'dot' && tokens[rootIndex - 2]?.type === 'ident') { - rootIndex -= 2; - } - if (tokens[rootIndex]?.type !== 'ident') continue; - const chain = walkMethodChain(tokens, rootIndex); - if (!chain) continue; - if (!looksLikeLogger(chain.root) && chain.root !== 'self' && chain.root !== 'this') continue; - if (!chain.methods.some((m) => LEVEL.has(m))) continue; - if (chain.methods.some((m) => TERMINAL.has(m))) { - i = chain.end; - continue; - } - // Convenience emit: logger.log(level, msg, ctx, fields) already calls send() - // internally. The chainable API is one argument (or two in Dart). - if (chain.methods.length === 1 && (chain.firstArgCount || 0) >= 3) { - i = chain.end; - continue; - } - const assignName = precedingAssignment(tokens, rootIndex); - const finding = { line: chain.line, col: tokens[rootIndex].col, message: 'logging chain never calls send()' }; - const prev = tokens[rootIndex - 1]?.type; - if (assignName) mark(assignName, finding); - else if (isReturnish(tokens, rootIndex) || prev === 'lparen' || prev === 'comma') { /* handoff */ } - else if (language === 'rust' && !nextNonChainIsSemi(tokens, chain.end)) { /* rust tail expression / return */ } - else report(finding); - i = chain.end; - } - } - - while (scopeStack.length) flushScope(scopeStack.pop()); - return findings; -} - -export function formatReport(results) { - const all = []; - for (const { file, findings } of results) { - for (const f of findings) all.push({ ...f, file }); - } - if (!all.length) { - return results.length - ? `ores-lint[require-send]: clean (${results.length} file${results.length === 1 ? '' : 's'} scanned)\n` - : 'ores-lint[require-send]: no Rust/Dart/Gleam source to scan\n'; - } - const lines = [ - `ores-lint[require-send]: ${all.length} finding(s) across 1 rule(s) in ${results.filter((r) => r.findings.length).length} file(s)`, - '', - ' warning: logging chain never delivered (ores custom rule) [require-send]', - ` ${all.length} instance(s); showing ${Math.min(all.length, MAX)}:`, - ]; - for (const f of all.slice(0, MAX)) { - lines.push(` ${f.file}:${f.line}:${f.col}`); - } - if (all.length > MAX) lines.push(` ... and ${all.length - MAX} more`); - lines.push(''); - return `${lines.join('\n')}\n`; -} - -function main() { - if (process.env.ORES_LINT_SKIP_REQUIRE_SEND === '1') { - process.stdout.write('ores-lint[require-send]: skipped (ORES_LINT_SKIP_REQUIRE_SEND=1)\n'); - return; - } - const files = trackedFiles(); - const results = []; - for (const rel of files) { - const abs = join(ROOT, rel); - if (!existsSync(abs)) continue; - let source; - try { source = readFileSync(abs, 'utf8'); } catch { continue; } - const language = rel.endsWith('.gleam') ? 'gleam' : rel.endsWith('.dart') ? 'dart' : 'rust'; - results.push({ file: rel, findings: analyzeSource(source, language) }); - } - process.stdout.write(formatReport(results)); -} - -const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop()); -if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('require-send.mjs')) { - main(); -} diff --git a/.ores-lint/require-send.test.mjs b/.ores-lint/require-send.test.mjs deleted file mode 100644 index e586010..0000000 --- a/.ores-lint/require-send.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { analyzeSource } from './require-send.mjs'; - -const missingDelivery = [ - ['rust', 'fn emit(logger: Logger) { logger.info("ready"); }'], - ['dart', 'void emit(Logger logger) { logger.info("ready"); }'], - ['gleam', 'pub fn emit() {\n logging.info("ready")\n Nil\n}'], -]; - -for (const [language, source] of missingDelivery) { - test(`${language} reports an undelivered logging chain`, () => { - const findings = analyzeSource(source, language); - assert.equal(findings.length, 1); - assert.equal(findings[0].message, 'logging chain never calls send()'); - }); -} - -const delivered = [ - ['rust', 'fn emit(logger: Logger) { logger.info("ready").send(); }'], - ['dart', 'void emit(Logger logger) { logger.info("ready").send(); }'], - ['gleam', 'pub fn emit() { logging.info("ready") |> logging.send }'], -]; - -for (const [language, source] of delivered) { - test(`${language} accepts a delivered logging chain`, () => { - assert.deepEqual(analyzeSource(source, language), []); - }); -} - -test('the unified next-line suppression is honored', () => { - const source = [ - 'fn emit(logger: Logger) {', - ' // ores-lint-disable-next-line require-send', - ' logger.warn("expected test drop");', - '}', - ].join('\n'); - assert.deepEqual(analyzeSource(source, 'rust'), []); -}); diff --git a/.ores-lint/required-tools.json b/.ores-lint/required-tools.json deleted file mode 100644 index 65ace40..0000000 --- a/.ores-lint/required-tools.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "_comment": "Tool versions ores-lint expects. These are DECLARATIONS, not dependencies - nothing here is ever installed into a repo's node_modules. Install them once, globally. The rollout script mirrors this block into each package.json under the `oresLint` key so the requirement travels with the repo.", - "eslint": { - "minMajor": 9, - "range": ">=9", - "install": "npm i -g eslint", - "why": "flat config (eslint.config.mjs) requires ESLint 9 or newer" - }, - "typescript-eslint": { - "minMajor": 8, - "range": ">=8", - "install": "npm i -g typescript-eslint", - "why": "optional; without it .ts/.tsx files are skipped rather than linted" - }, - "clippy": { - "range": "any", - "install": "rustup component add clippy", - "why": "the Rust half is a clippy wrapper" - }, - "dart": { - "range": "any", - "install": "install the Dart SDK or Flutter", - "why": "optional; dart analyze is the Dart/Flutter linter. Without it Dart source is skipped." - }, - "gleam": { - "range": "any", - "install": "see https://gleam.run/getting-started/installing/", - "why": "optional; gleam format --check and gleam check. Without it Gleam source is skipped." - } -} diff --git a/.ores-lint/rust.sh b/.ores-lint/rust.sh deleted file mode 100755 index a0dec12..0000000 --- a/.ores-lint/rust.sh +++ /dev/null @@ -1,171 +0,0 @@ -#!/bin/sh -# ores-lint :: Rust -# -# Discovers every crate in the repo - not just one at the root - runs clippy on -# each, and aggregates ALL of them into a single report so the example cap -# applies per repo rather than per crate. -# -# Workspace handling: after linting a crate root, `cargo metadata --no-deps` -# tells us exactly which manifests that invocation already covered, so workspace -# members are not linted twice while genuinely independent nested crates still -# get their own run. -# -# The headline custom behaviour: `clippy::implicit_return` fires once per -# implicit return, which across a repo means hundreds of identical warnings. The -# lint stays enabled so nothing is missed, but it is reported as ONE warning -# carrying at most ORES_LINT_MAX_EXAMPLES locations plus a total count. -# -# Critical interaction, handled below: `clippy::needless_return` ships enabled -# in clippy's default `style` group and warns on exactly the explicit returns -# this house style asks for. Enabling implicit_return without allowing -# needless_return makes the two lints contradict each other on every function. - -set -u -DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -. "$DIR/config.sh" -ROOT=${1:-.} -ROOT=$(CDPATH= cd -- "$ROOT" && pwd) - -[ "${ORES_LINT_SKIP_RUST}" = "1" ] && { echo "ores-lint[rust]: skipped (ORES_LINT_SKIP_RUST=1)"; exit 0; } -command -v cargo >/dev/null 2>&1 || { echo "ores-lint[rust]: cargo not found on PATH - skipping"; exit 0; } -cargo clippy --version >/dev/null 2>&1 || { echo "ores-lint[rust]: clippy not installed (rustup component add clippy) - skipping"; exit 0; } - -LINTS=" --W clippy::implicit_return --A clippy::needless_return --A clippy::let_and_return --W clippy::correctness --W clippy::suspicious --W clippy::await_holding_lock --W clippy::unwrap_used --W clippy::expect_used --W clippy::panic_in_result_fn --W clippy::todo --W clippy::unimplemented --W clippy::dbg_macro --W clippy::mem_forget --W clippy::float_cmp --W clippy::lossy_float_literal -" -[ -n "${ORES_LINT_RUST_EXTRA:-}" ] && LINTS="$LINTS $ORES_LINT_RUST_EXTRA" -TARGETS="" -[ "${ORES_LINT_RUST_ALL_TARGETS}" = "1" ] && TARGETS="--all-targets" - -# --- discover crates -------------------------------------------------------- -CRATES=$(cd "$ROOT" && find . -maxdepth "${ORES_LINT_DEPTH}" \ - \( -name node_modules -o -name target -o -name .git -o -name dist -o -name build \ - -o -name vendor -o -name .vendor -o -name .worktrees -o -name _to_delete \ - -o -name .ores-lint \) -prune -o \ - -type f -name Cargo.toml -print 2>/dev/null \ - | sed 's|^\./||; s|Cargo\.toml$||; s|/$||; s|^$|.|' | sort) - -[ -z "$CRATES" ] && { echo "ores-lint[rust]: no Cargo.toml found - nothing to do"; exit 0; } - -# Drop crates that live inside a NESTED git repository. Those belong to a -# different repo with its own ores-lint install; linting them from here would -# report the same findings twice under the wrong repo name. -NESTED_FILE="$DIR/nested-repos.json" -if [ -f "$NESTED_FILE" ]; then - NESTED=$(grep -o '"[^"]*"' "$NESTED_FILE" 2>/dev/null | tr -d '"') - if [ -n "$NESTED" ]; then - KEPT="" - for c in $CRATES; do - drop=0 - for nrepo in $NESTED; do - case "$c" in - "$nrepo"|"$nrepo"/*) drop=1; break ;; - esac - done - [ "$drop" = "0" ] && KEPT="$KEPT -$c" - done - CRATES=$(printf '%s' "$KEPT" | sed '/^$/d') - fi -fi - -[ -z "$CRATES" ] && { echo "ores-lint[rust]: all crates belong to nested repos - nothing to do here"; exit 0; } - -RAW=$(mktemp) || exit 0 -COVERED=$(mktemp) || exit 0 -RAN=0 -SKIPPED_MEMBERS=0 -FAILED="" - -for c in $CRATES; do - if [ "$c" = "." ]; then cdir="$ROOT"; else cdir="$ROOT/$c"; fi - # Already covered by an earlier workspace invocation? - if [ -s "$COVERED" ] && grep -qxF "$cdir" "$COVERED"; then - SKIPPED_MEMBERS=$((SKIPPED_MEMBERS + 1)) - continue - fi - - RC=0 - OUT=$(mktemp) - # shellcheck disable=SC2086 - ( cd "$cdir" && cargo clippy --workspace $TARGETS --message-format=short -- $LINTS ) >"$OUT" 2>&1 || RC=$? - - if [ "$RC" -ne 0 ] && ! grep -q ': warning: \|: error: ' "$OUT"; then - FAILED="$FAILED - $c (exit $RC): $(sed -n '1,2p' "$OUT" | tr '\n' ' ' | cut -c1-140)" - rm -f "$OUT" - continue - fi - RAN=$((RAN + 1)) - - # Re-root diagnostic paths at the repo, so a repo-wide report stays navigable. - if [ "$c" = "." ]; then - cat "$OUT" >> "$RAW" - else - sed "s|^|$c/|" "$OUT" >> "$RAW" - fi - rm -f "$OUT" - - # Record which manifests this invocation covered (workspace members). - ( cd "$cdir" && cargo metadata --no-deps --offline --format-version 1 2>/dev/null ) \ - | grep -o '"manifest_path":"[^"]*"' \ - | sed 's/"manifest_path":"//; s/"$//; s|/Cargo\.toml$||' >> "$COVERED" 2>/dev/null || true -done - -echo "ores-lint[rust]: linted $RAN crate root(s)$([ "$SKIPPED_MEMBERS" -gt 0 ] && echo ", $SKIPPED_MEMBERS workspace member(s) already covered")" -[ -n "$FAILED" ] && printf 'ores-lint[rust]: clippy could not run in some crates:%s\n' "$FAILED" - -awk -v MAXEX="$ORES_LINT_MAX_EXAMPLES" -v TARGETMSG="$ORES_LINT_IMPLICIT_RETURN_MSG" ' -BEGIN { max = MAXEX + 0; if (max < 1) max = 1; n = 0 } -match($0, /: (warning|error): /) { - loc = substr($0, 1, RSTART - 1) - rest = substr($0, RSTART + 2) - ci = index(rest, ": ") - sev = substr(rest, 1, ci - 1) - msg = substr(rest, ci + 2) - key = loc "|" msg - if (key in seen) next # same finding reported by two crate runs - seen[key] = 1 - if (!(msg in count)) { order[++n] = msg; sev_of[msg] = sev } - count[msg]++ - if (shown[msg] < max) { ex[msg] = ex[msg] (shown[msg]++ ? "\n" : "") " " loc } - next -} -END { - if (n == 0) { print "ores-lint[rust]: clean"; exit 0 } - total = 0 - for (i = 1; i <= n; i++) total += count[order[i]] - printf "ores-lint[rust]: %d finding(s) across %d rule(s)\n", total, n - for (pass = 1; pass <= 2; pass++) { - for (i = 1; i <= n; i++) { - msg = order[i] - is_target = (msg == TARGETMSG) - if ((pass == 1) != is_target) continue - label = is_target ? "implicit return (ores house style)" : msg - printf "\n %s: %s\n", sev_of[msg], label - if (is_target) printf " prefer an explicit `return` at tail position\n" - printf " %d instance(s); showing %d:\n", count[msg], (count[msg] < max ? count[msg] : max) - print ex[msg] - if (count[msg] > max) printf " ... and %d more\n", count[msg] - max - } - } - print "" -} -' "$RAW" - -rm -f "$RAW" "$COVERED" -exit 0 diff --git a/.ores-lint/selftest.sh b/.ores-lint/selftest.sh deleted file mode 100755 index b76bc9f..0000000 --- a/.ores-lint/selftest.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/bin/sh -# ores-lint :: self-test -# -# Guards the two assumptions this toolkit rests on: -# 1. clippy still words `implicit_return` the way config.sh expects, and -# `needless_return` can still be silenced (otherwise the two lints fight). -# 2. the vendored ESLint plugin still loads and its rules still fire. -# -# Run after a toolchain upgrade. Exits non-zero if an assumption has broken - -# a silently empty lint report is far worse than a failing test. - -set -u -DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -. "$DIR/config.sh" -FAIL=0 -pass() { echo " ok - $1"; } -fail() { echo " FAIL - $1"; FAIL=1; } - -echo "ores-lint self-test" - -# --- Rust ------------------------------------------------------------------- -if command -v cargo >/dev/null 2>&1 && cargo clippy --version >/dev/null 2>&1; then - T=$(mktemp -d) - mkdir -p "$T/src" - cat > "$T/Cargo.toml" <<'EOF' -[package] -name = "ores_lint_selftest" -version = "0.0.0" -edition = "2021" -EOF - cat > "$T/src/lib.rs" <<'EOF' -pub fn implicit(x: i32) -> i32 { x } -pub fn explicit(x: i32) -> i32 { return x; } -EOF - OUT=$( cd "$T" && cargo clippy --message-format=short -- \ - -W clippy::implicit_return -A clippy::needless_return 2>&1 ) - - if printf '%s' "$OUT" | grep -qF "$ORES_LINT_IMPLICIT_RETURN_MSG"; then - pass "clippy implicit_return message matches config.sh" - else - fail "clippy implicit_return wording changed - update ORES_LINT_IMPLICIT_RETURN_MSG in config.sh" - printf '%s\n' "$OUT" | sed -n '1,4p' | sed 's/^/ /' - fi - - if printf '%s' "$OUT" | grep -q 'unneeded `return`'; then - fail "needless_return still fires despite -A; it contradicts the house style" - else - pass "needless_return correctly silenced" - fi - - N=$(printf '%s\n' "$OUT" | grep -cF "$ORES_LINT_IMPLICIT_RETURN_MSG") - [ "$N" = "1" ] && pass "exactly 1 implicit return detected in fixture" \ - || fail "expected 1 implicit return in fixture, saw $N" - rm -rf "$T" -else - echo " skip - cargo/clippy unavailable" -fi - -# --- JavaScript ------------------------------------------------------------- -if command -v node >/dev/null 2>&1; then - if node --input-type=module -e " - const p = await import('$DIR/eslint/plugin.mjs'); - const names = Object.keys(p.default.rules); - if (!names.includes('require-send') || !names.includes('semi')) process.exit(3); - " 2>/dev/null; then - pass "vendored eslint plugin loads with both rules" - else - fail "vendored eslint plugin failed to load" - fi - - if node "$DIR/require-send.test.mjs" >/dev/null 2>&1; then - pass "require-send scanner fixtures" - else - fail "require-send scanner fixtures failed" - node "$DIR/require-send.test.mjs" 2>&1 | sed -n '1,20p' | sed 's/^/ /' - fi -else - echo " skip - node unavailable" -fi - -# --- Dart ------------------------------------------------------------------- -if command -v dart >/dev/null 2>&1 || command -v flutter >/dev/null 2>&1; then - pass "dart/flutter available for analyzer pass" -else - echo " skip - dart/flutter unavailable (dart.sh will no-op)" -fi - -# --- Gleam ------------------------------------------------------------------ -if command -v gleam >/dev/null 2>&1; then - pass "gleam available for format/check pass" -else - echo " skip - gleam unavailable (gleam.sh will no-op)" -fi - -[ "$FAIL" = "0" ] && echo "self-test passed" || echo "self-test FAILED" -exit "$FAIL" diff --git a/.zpkg.toml b/.zpkg.toml deleted file mode 100644 index 0a0793b..0000000 --- a/.zpkg.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -org = "zed-pkg-test" -name = "rust-app" -version = "0.2.0" -description = "A Rust app combining Zed path packages with Cargo registry dependencies." -license = "MIT" - -[package.repository] -vcs = "git" -url = "https://github.com/zed-pkg-test/rust-app" - -# Zed owns acquisition and placement. Cargo owns crates.io resolution, feature -# selection, build dependencies, target compilation, and Cargo.lock. -[install] -dir = ".vendor/.zed" - -[dependencies] -"zed-pkg-test/rust-lib" = "^1.0.0" - -[publish] -exclude = ["target/**", ".vendor/**", ".zed/**"] - -[scripts] -test = "cargo test --locked --all-targets" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..060cc8e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# ores-orm-core agent notes + +This repository implements ORM-first derivative schema/type/validator tooling. + +- Durable codegen, checks, validators and repository scripts are Rust-first; do not add Python tooling. +- Human-authored TypeSpec and JSON Schema Draft 2020-12 remain independent peer authorities. ORM-derived artifacts are evidence, never a third authored authority. +- Parse Diesel and SeaORM independently. Do not resolve a mismatch by choosing one ORM as canonical. +- Public projections must be explicit allowlists and must be admitted against retained TJSV Contract IR evidence before publication. +- Preserve database/wire names exactly. Escaping a language reserved word must not change the serialized name. +- Fail closed on unsupported or ambiguous ORM constructs; do not silently widen validators. +- Generated output belongs under `generated/` and must carry reproducible provenance. + +For fleet-wide guidance also follow `ORESoftware/my-ai` `AGENTS.md` and `SHARED.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 27178d2..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,10 +0,0 @@ -# Changelog - -## 0.2.0 - -- compose a Zed-materialized Rust crate with direct and transitive crates.io dependencies; -- track the binary application's exact Cargo lock; -- certify Cargo resolver-v2 target/build feature separation; -- preserve Cargo and Zed lockfiles independently; -- add symlink, frozen-copy, fresh-container, offline, and Cargo-lock-drift canaries; -- run locked Cargo checks on Linux, macOS, and Windows. diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index a82bc9f..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,30 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "rust-app" -version = "0.2.0" -dependencies = [ - "rust-lib", - "unicode-ident", -] - -[[package]] -name = "rust-lib" -version = "1.0.1" -dependencies = [ - "itoa", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/Cargo.toml b/Cargo.toml index f119fb1..26d2a79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,36 @@ [package] -name = "rust-app" -version = "0.2.0" -edition = "2021" +name = "ores-orm-core" +version = "0.1.0" +edition = "2024" rust-version = "1.88" -resolver = "2" -description = "A Rust app combining Zed path packages with Cargo registry dependencies." -license = "MIT" +description = "ORM-first derivative schema and validator tooling for ORESoftware" +license = "UNLICENSED" publish = false -build = "build.rs" -# Zed materializes this package. Cargo then consumes the result as an ordinary -# path dependency with target-specific feature selection. +[lib] +name = "ores_orm_core" +path = "src/lib.rs" + [dependencies] -rust-lib = { path = ".vendor/.zed/zed-pkg-test/rust-lib", features = ["uppercase"] } -unicode-ident = "=1.0.24" +hex = "0.4" +proc-macro2 = "1" +quote = "1" +schemars = { version = "1.2", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +syn = { version = "2", features = ["full", "extra-traits", "parsing"] } +thiserror = "2" +toml = "0.9" +walkdir = "2" + +[dev-dependencies] +pretty_assertions = "1" + +[lints.rust] +unsafe_code = "forbid" -# Resolver v2 must keep this host build dependency's default feature set -# separate from the target dependency's `uppercase` feature. -[build-dependencies] -rust-lib = { path = ".vendor/.zed/zed-pkg-test/rust-lib", default-features = false } +[lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "warn", priority = -1 } +needless_return = "allow" diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a22a2da..0000000 --- a/LICENSE +++ /dev/null @@ -1 +0,0 @@ -MIT diff --git a/README.md b/README.md index 4a5c3a5..b81a200 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,156 @@ -# rust-app (`zed-pkg-test/rust-app`) +# ores-orm-core -`rust-app` is a deterministic interoperability fixture for **zed-pkg**. It is not a -production application. Its only purpose is to prove that a Rust/Cargo consumer can -source one crate through Zed while Cargo continues to own registry resolution, -features, build dependencies, and `Cargo.lock`. +ORM-first derivative schema and validator tooling for the ORESoftware fleet. -## Dependency graph +This repository fills the inverse/code-first lane that is intentionally different from `ORESoftware/ores-contracts`: ```text -Zed registry - └── zed-pkg-test/rust-lib 1.0.1 - └── itoa 1.0.18 (crates.io, resolved by Cargo) - -rust-app Cargo graph - ├── rust-lib path dependency + uppercase feature - ├── rust-lib build dependency + default features only - └── unicode-ident 1.0.24 (crates.io) +Diesel schema/model definitions -----> ORM IR_D --\ + +--> parity gate --> converged derivative ORM IR +SeaORM entity/model definitions -----> ORM IR_S --/ | + +--> exact policy-derived shapes + +--> Rust Serde types + +--> JSON Schema 2020-12 + +--> TypeScript validators/types + +--> Dart validators/types + +--> Gleam decoders/types + | + +--> TJSV admission before public promotion + | + +--> optional WIT/binding evidence downstream ``` -`.zpkg.toml` installs Zed-managed content beneath `.vendor/.zed`; `Cargo.toml` -consumes the installed package as the path dependency -`.vendor/.zed/zed-pkg-test/rust-lib`. Cargo then resolves that path package and its -crates.io dependency exactly as it would any local crate. - -## Certified boundaries - -These are the invariants the fixture currently proves: - -- `.zpkg.toml` contains only Zed package dependencies; -- `Cargo.toml` contains Cargo registry/path/build dependencies and features; -- `Cargo.lock` is tracked because this is an executable application; -- `.zpkg.lock` is generated by Zed and ignored by Git; -- Zed installation does not rewrite `Cargo.lock`; -- Cargo build/test does not rewrite `.zpkg.lock`; -- resolver v2 keeps build-dependency features separate from target features; -- symlink mode works while the Zed content store is mounted; -- frozen copy mode is portable to a fresh container; -- the copied project runs with `cargo --locked --offline` and no network; -- changing a Cargo requirement fails under `--locked` without changing the Zed lock. - -## Deterministic inputs and outputs - -Tracked package metadata, source, Zed manifest/lock data, and the exact `rust-lib` -resolution are the fixture inputs. For identical pinned inputs, a clean install must -reconstruct the same dependency graph and the same application/test output without -reaching outside the declared install root. - -The consuming assertion should install the fixture using the selected mode, -compile/test with Cargo, and execute the app-level assertion that calls the -Zed-provided crate. CI is expected to prove that the path dependency exists because -Zed installed it, not because an undeclared workspace or machine-local path happened -to be present. - -## Install-mode expectations - -The certified boundaries above record that symlink and frozen-copy modes work. These -are the requirements they have to keep satisfying: - -- **Local developer mode:** symlink semantics are allowed and preferred when the Zed - local mode supports them. -- **Docker/OCI mode:** the installed dependency must be a self-contained copy. - Container correctness must not rely on source-tree symlinks, host hardlinks, - caches, or paths outside the image/install root. -- **Filesystem boundary:** after a copy-mode install, removing the source/cache must - not invalidate the installed crate. -- **Integrity:** checksum/provenance mismatches must fail closed rather than silently - substituting content. - -The shared canary implementation for these semantics belongs to DEN-588/DEN-591; this -fixture consumes that contract instead of introducing a competing one. -## Local workflow - -Install the Zed dependency first, then run Cargo normally: - -```sh -zed install --install-mode copy -cargo run --locked -cargo test --locked --all-targets -cargo run --locked --offline +It is analogous in purpose to deriving Zod schemas from Drizzle tables, but it is deliberately stricter about provenance and authority. + +## Authority boundary + +Human-authored TypeSpec and human-authored JSON Schema Draft 2020-12 remain independent peer authorities in `*-interfaces` repositories. `ORESoftware/typespec-json-schema-validator` (TJSV) owns their generic semantic parity and Contract IR admission. `ORESoftware/ores-contracts` owns contract-first persistence/codegen convergence for contract families that declare persistence semantics. `ORESoftware/ores-wit` owns WIT validation/canonicalization/binding orchestration. + +`ores-orm-core` does **not** promote Diesel, SeaORM, Serde, generated JSON Schema, or a database catalog into a third authored authority. ORM-first output is derivative evidence. Public output may be published only when it is compatible with the exact admitted public contract surface and its retained Contract IR/receipts. + +Binding Contract IR and receipt bytes into a derivative manifest is provenance only. It does **not** certify admission. The generator always marks a public derivative as `blocked_pending_contract_admission`; a separate TJSV verification step owns the promotion decision. + +## Inputs + +The first-class input lanes are independent and must converge when both are configured: + +- Diesel `table!` schema output, normally produced by `diesel print-schema`, plus explicitly declared model metadata where needed. +- SeaORM entity/model definitions, including SeaORM 2 dense entities and generated entities from `sea-orm-cli generate entity`. +- Optional database catalog evidence for DB-first verification. Catalog evidence strengthens the proof but does not silently override either ORM lane. + +A single ORM lane may be used during bootstrap, but release/publication policy can require both lanes. Unsupported or ambiguous ORM constructs fail closed rather than being guessed. + +## Derived shape families + +Generation is shape-aware rather than emitting one struct for every purpose: + +- `row` / `select`: database-readable representation; every selected column is present even when its value is SQL `NULL`. +- `create` / `insert`: client-settable create fields; generated/default-only/immutable fields are excluded unless explicitly allowed. +- `update`: replace/update shape with field mutability policy applied. +- `patch`: partial update shape; all writable fields optional and the object itself must contain at least one field. +- `public_read`: explicitly public projection of a row. +- `public_create`, `public_update`, and `public_patch`: explicitly public write projections. + +Presence and nullability are separate dimensions. The emitters preserve required/non-null, required/nullable, optional/non-null, and optional/nullable semantics rather than treating `NULL` as “missing.” + +Public/private membership is explicit configuration/metadata. The generator must never decide that a field is secret or public from a name such as `password`, `token`, `_hidden`, or `internal`. + +## Exact lineage + +A derivative bundle is emitted only when all of these agree in one call: + +1. the supplied converged ORM IR validates; +2. the supplied policy validates against that entire IR, including stale/orphan table entries; +3. the evidence's ORM-IR digest matches the exact supplied IR bytes; +4. the supplied shape is re-derived from that exact IR + policy + `ShapeKind`; and +5. the re-derived shape is byte-for-byte semantically equal to the supplied shape. + +This prevents a hand-constructed or stale shape from inheriting provenance from an unrelated ORM snapshot. Artifact paths, type names, source identifiers, shape bytes, source inputs, generator options, and generated files all receive deterministic identities/digests. + +## Naming + +Wire and database names are preserved exactly. Generated language identifiers may escape reserved words, but escaping must not alter the serialized name. Fleet defaults prefer `snake_case`; this repository does not inherit older generators' automatic camelCase conversion. Any name transformation must be explicit, deterministic, and represented in provenance. + +Quoted database identifiers may contain punctuation or path-like characters, so generated artifact paths never interpolate raw table identities. Paths use deterministic collision-free encodings. Generated language identifiers use reversible escaping and fail before emission if a target-specific collision remains. + +## Validation targets + +The canonical derivative validation artifact is Draft 2020-12 JSON Schema plus a normalized ORM IR. Language packages consume the same admitted semantics: + +- Rust: strict `serde` DTOs with unknown-field rejection and explicit presence/nullability deserializers. The sibling Draft 2020-12 JSON Schema is canonical; the generated DTO intentionally does not infer a weaker schema from `Option`. +- TypeScript: generated types plus strict Zod runtime schemas. +- Dart: generated typed models plus presence-aware runtime validation. +- Gleam: generated record/custom types plus `gleam/dynamic` decoders/validators. + +The CI runtime witness compiles/type-checks the emitted Rust, TypeScript/Zod, Dart, and Gleam sources instead of treating source-string assertions as sufficient evidence. + +Cross-runtime domains fail closed when an ORM type does not by itself determine one portable wire representation. This currently includes unrestricted `int64`, `float32`, decimal, UUID, calendar date, date-time, bytes, and unresolved named database types. They require a reviewed admitted mapping before public emission. The deliberately small portable subset currently emitted without extra mapping evidence is Boolean, Int16, Int32, Float64, String, and generic JSON. + +Additional language adapters can be added without changing the ORM IR or authority model. + +## Relationship to product repositories + +Typical flow: + +```text +-interfaces authored TypeSpec + authored JSON Schema + | | + +-------------- TJSV ------------------+ + | + Contract IR + | + public-admission check + ^ + | +-orm-core -- ores-orm-core parity/shape/codegen --> generated derivative artifacts + | + +--> private server-only Serde/model surfaces + +--> admitted server DTOs in -lib-core + +--> admitted client-safe DTOs in -pub-lib-core / -clients ``` -The repository E2E workflow builds an exact Zed CLI candidate, publishes an exact -`rust-lib` candidate to a temporary file registry, and performs the full -symlink/copy/offline/lock-isolation matrix without persistent registry writes. +`*-orm-core` remains the private backend home for executable Diesel/SeaORM behavior. Public packages receive only explicitly admitted generated projections, never executable ORM code or database credentials/configuration. + +## CLI boundary + +Durable codegen, checks, validators, and audit tooling are Rust-first. The configuration contract is `.ores-orm.toml`. A production executable CLI must use the fleet `flags-2-env` argv boundary and `.cli-flags.toml`; ad-hoc argument parsing is not an accepted release path. + +Generated output lives under `generated/` with provenance receipts and is reproducible from exact source bytes, normalized ORM IR, policy/options, tool version/revision, and retained contract evidence. + +## Related repositories + +- `ORESoftware/orm-core-template.rs` +- `ORESoftware/ores-contracts` +- `ORESoftware/ores-interfaces` +- `ORESoftware/typespec-json-schema-validator` +- `ORESoftware/ores-wit` +- `ORESoftware/flags-2-env` + +## Derivative language packages + +The ORM-first lane derives the same shape algebra into five deterministic artifacts: + +- Rust Serde DTO source; +- TypeScript + Zod validators; +- Dart typed models with presence-aware JSON decoding; +- Gleam types plus dynamic decoders; +- the Draft 2020-12 JSON Schema witness. + +Public generation is fail-closed. A table must opt into `public_surface = true`, every storage column must be classified as public, private, or secret, and public shapes are assembled only from explicit `public_read`, `public_create`, and `public_update` allowlists. Serde capability by itself never implies public exposure. + +`emit::bundle` binds the language sources to the exact converged ORM IR, policy-derived shape, source digests, generator-option digest, and per-artifact SHA-256 values. A public bundle remains a **candidate** until TJSV verifies it against the exact owning TypeSpec + authored JSON Schema contract and retained Contract IR/receipt. Generation is not publication or admission. + +## TJSV public admission + +Digest-binding contract files to a public derivative is not semantic admission. The manifest keeps those states separate: -## Expected failures +- `required`: a public shape has no contract evidence attached; +- `evidence_bound`: exact Contract IR and receipt bytes are hashed into generation evidence, but the derivative remains blocked; +- `admitted`: the exact Contract IR, TJSV projection manifest, and self-digesting `projection-verification-receipt/v1` passed local integrity/binding checks, the trusted projection declares the expected contract scope and runtime-validator scope, and the verified output digests exactly equal this bundle's Rust/TypeScript/Dart/Gleam/JSON-Schema bytes; +- `not_applicable`: the shape is private/server-only. -A missing/incompatible `rust-lib`, invalid integrity metadata, an escaped filesystem -boundary, or a Cargo path that was not materialized by Zed must produce an actionable -failure. Cross-platform tests should assert stable failure classes/messages without -timing-sensitive sleeps. +`TjsvAdmissionBinding::verify` recomputes the Contract IR `irId`, projection `manifestId`, and projection `verificationId` using TJSV's canonical object-key ordering; checks the peer-authority model, mandatory contract-admission coverage, parity-run identity, source digests, declaration scope, projection identity, runtime-validator closure, output closure, and receipt summary; and retains exact byte digests for all three evidence artifacts. The canonical projection id is a bounded TJSV-safe identity derived from SHA-256 of the exact table name plus the shape kind. -## Ownership and security +`bundle::emit_admitted` preserves the current exact-lineage gate: it requires the same converged ORM IR and validated policy used to derive the shape, plus an in-process verified admission capability, and proves that the TJSV-verified output path/digest set exactly matches the newly generated five-artifact bundle. Deserializing an admission manifest clears that in-process capability, so stored or fabricated JSON cannot be replayed directly as authorization to publish. -The canonical owner is the `zed-pkg-test` GitHub organization. Repository transfers or -renames must preserve graph history and package identity until consumers are -repointed. See `agents.md` for automation rules. Sensitive security reports inherit the -organization security/contact policy and must not be posted publicly with credentials -or secrets. +The admitted manifest format is `ores.orm-core.derivative-manifest/v3`. TypeSpec and independently authored JSON Schema remain the only contract authorities; Contract IR, projection manifests, runtime evidence, projection receipts, ORM policy evidence, and ORM derivatives remain downstream evidence. diff --git a/agents.md b/agents.md deleted file mode 100644 index 75c64b0..0000000 --- a/agents.md +++ /dev/null @@ -1,18 +0,0 @@ -# Agent policy - -This repository is a deterministic zed-pkg interoperability fixture, not a production application. - -Automated contributors must preserve the minimal Cargo/Zed graph and the explicit ownership boundary: Zed provides `zed-pkg-test/rust-lib`; Cargo owns compilation and any ordinary Rust dependencies. Do not add deployment infrastructure, production credentials, network-dependent fixtures, or unrelated framework dependencies. - -Install behavior follows the shared conformance contract: local mode may use symlinks; Docker/OCI installs must be self-contained copies; checksum/provenance failures must fail closed; and tests must use deterministic assertions rather than sleep-based timing. - -Before changing package identity, repository ownership, install paths, or lock data, verify downstream consumers and preserve commit/graph history. Re-homing is allowed only after consumers are repointed without loss of package identity. - -Security and credential reports inherit the `zed-pkg-test` organization security/contact policy. Never commit secrets, tokens, private keys, production data, or credentials. Sensitive reports belong in the organization’s private reporting/contact path rather than public issues. - -## Repository-local Git worktrees - -- Create or use a Git worktree only when the human operator explicitly authorizes it for the current task. Concurrency or a dirty checkout is not permission by itself. -- Put every authorized worktree at `/tmp/worktrees/`; from the repository root, use `./tmp/worktrees/`. Never place worktrees beside repositories or organization directories. -- Keep `tmp`, `temp`, `tmp/worktrees`, and `temp/worktrees` ignored in the repository-root `.gitignore`. Do not commit files from those directories. -- Relocate or remove a worktree only when the operator explicitly requests it. Before removal, preserve and publish intended changes, verify its commit is represented on the target branch, and confirm there are no tracked, untracked, ignored-sensitive, or in-use files that must survive. Remove it with `git worktree remove ` without `--force`; never delete a worktree directory with `rm`. diff --git a/build.rs b/build.rs deleted file mode 100644 index 9d481ac..0000000 --- a/build.rs +++ /dev/null @@ -1,12 +0,0 @@ -fn main() { - println!("cargo:rerun-if-changed=Cargo.toml"); - - // Cargo resolver v2 must keep this host build dependency feature set - // independent from the target dependency's `uppercase` feature. - let greeting = rust_lib::greet("build-script"); - assert_eq!( - greeting, "hello build-script from zed-pkg-test/rust-lib", - "build dependency unexpectedly inherited target-only Cargo features" - ); - println!("cargo:rustc-env=ZED_BUILD_GREETING={greeting}"); -} diff --git a/cert/beamscale-critical-section/HEADS.lock b/cert/beamscale-critical-section/HEADS.lock deleted file mode 100644 index a02f481..0000000 --- a/cert/beamscale-critical-section/HEADS.lock +++ /dev/null @@ -1,7 +0,0 @@ -beamscale/bmscl-infra#40=ba3ba3dd7a8e01528ec3c175d24aaa60d423845d -beamscale/bmscl-supervisor#96=19c416e5cda1b7807b89703cfffa9e6126614581 -beamscale/bmscl-interfaces#9=90baedea4e3710761eeb03ef6c8da0d89b2ceb92 -source_harness=scintilla-run-test/gleam-lambda-runner-e2e@cert/beamscale-critical-section-chain-20260925 -runtime_main_blob=9710df3812508ae87d26a7a19c2036be17506e29 -supervisor_formal_model_blob=883cac38f1d98aacc17f7f15d6681499eebefa17 -interfaces_typespec_main_blob=24a77ff8a5f5dd3fa1aa61fd55f4b6aaa0fb9b4f diff --git a/cert/beamscale-critical-section/infra-runtime-host/Cargo.toml b/cert/beamscale-critical-section/infra-runtime-host/Cargo.toml deleted file mode 100644 index 2f13a32..0000000 --- a/cert/beamscale-critical-section/infra-runtime-host/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "bmscl-runtime-host" -version = "0.1.0" -edition = "2021" - -[dependencies] -axum = "0.8" -flate2 = "1" -libc = "0.2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -tar = "0.4" -tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } -thiserror = "2" - -[dev-dependencies] -tempfile = "3" diff --git a/cert/beamscale-critical-section/infra-runtime-host/src/artifact_store.rs b/cert/beamscale-critical-section/infra-runtime-host/src/artifact_store.rs deleted file mode 100644 index 05484a5..0000000 --- a/cert/beamscale-critical-section/infra-runtime-host/src/artifact_store.rs +++ /dev/null @@ -1,505 +0,0 @@ -use flate2::{write::GzEncoder, Compression, GzBuilder}; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use std::{ - io, - path::{Path, PathBuf}, -}; -use tar::{Builder, Header}; -use thiserror::Error; -use tokio::{fs, io::AsyncReadExt}; - -const ADMITTED_METADATA: [&str; 5] = [ - "manifest.json", - "admission-report.json", - "provenance.json", - "attestation.json", - "admission-receipt.json", -]; -const REQUIRED_WORKER_BEAM: &str = "beam/worker.beam"; - -#[derive(Clone, Debug)] -pub struct ArtifactStore { - root: PathBuf, - max_archive_bytes: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ResolvedArtifact { - pub build_sha256: String, - pub archive_name: String, - pub archive_sha256: String, - pub archive_bytes: u64, - pub archive_path: PathBuf, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AdmittedArtifact { - pub build_sha256: String, - pub archive_name: String, - pub archive_sha256: String, - pub archive_bytes: u64, - pub bytes: Vec, -} - -#[derive(Debug, Deserialize)] -struct DeploymentRecord { - build_sha256: String, - archive_sha256: String, - archive_name: String, - archive_bytes: u64, - verification_state: String, -} - -#[derive(Debug, Error)] -pub enum ArtifactStoreError { - #[error("invalid build sha256")] - InvalidDigest, - #[error("artifact not found")] - NotFound, - #[error("artifact registry entry is not a real directory")] - UnsafeRegistryEntry, - #[error("artifact archive is not a real file")] - UnsafeArchive, - #[error("artifact registry escaped configured root")] - RegistryEscape, - #[error("unsafe admitted artifact member: {0}")] - UnsafeAdmittedMember(String), - #[error("invalid deployment record: {0}")] - InvalidRecord(String), - #[error("artifact archive exceeds configured byte ceiling")] - ArchiveTooLarge, - #[error("artifact archive digest mismatch")] - ArchiveDigestMismatch, - #[error("artifact io error: {0}")] - Io(#[from] io::Error), - #[error("artifact record json error: {0}")] - Json(#[from] serde_json::Error), -} - -impl ArtifactStore { - pub fn new(root: impl Into, max_archive_bytes: u64) -> Self { - Self { - root: root.into(), - max_archive_bytes, - } - } - - pub async fn resolve( - &self, - build_sha256: &str, - ) -> Result { - validate_sha256(build_sha256)?; - let (root, canonical_dir, record) = self.resolve_directory(build_sha256).await?; - - let archive_path = canonical_dir.join(&record.archive_name); - let archive_meta = fs::symlink_metadata(&archive_path) - .await - .map_err(map_not_found)?; - if !archive_meta.file_type().is_file() || archive_meta.file_type().is_symlink() { - return Err(ArtifactStoreError::UnsafeArchive); - } - let canonical_archive = fs::canonicalize(&archive_path).await?; - if !canonical_archive.starts_with(&canonical_dir) || !canonical_archive.starts_with(&root) { - return Err(ArtifactStoreError::RegistryEscape); - } - if archive_meta.len() == 0 - || archive_meta.len() > self.max_archive_bytes - || archive_meta.len() != record.archive_bytes - { - return Err(ArtifactStoreError::ArchiveTooLarge); - } - - let actual_sha256 = hash_file(&canonical_archive, self.max_archive_bytes).await?; - if actual_sha256 != record.archive_sha256 { - return Err(ArtifactStoreError::ArchiveDigestMismatch); - } - - Ok(ResolvedArtifact { - build_sha256: record.build_sha256, - archive_name: record.archive_name, - archive_sha256: actual_sha256, - archive_bytes: archive_meta.len(), - archive_path: canonical_archive, - }) - } - - pub async fn resolve_admitted_bundle( - &self, - build_sha256: &str, - ) -> Result { - self.resolve(build_sha256).await?; - let (_root, canonical_dir, _record) = self.resolve_directory(build_sha256).await?; - - let mut entries = Vec::<(String, Vec)>::new(); - let mut total_uncompressed = 0u64; - for name in ADMITTED_METADATA { - let bytes = read_regular_member( - &canonical_dir, - &canonical_dir.join(name), - name, - self.max_archive_bytes, - &mut total_uncompressed, - ) - .await?; - entries.push((name.to_owned(), bytes)); - } - - let beam_dir = canonical_dir.join("beam"); - let beam_meta = fs::symlink_metadata(&beam_dir) - .await - .map_err(map_not_found)?; - if !beam_meta.file_type().is_dir() || beam_meta.file_type().is_symlink() { - return Err(ArtifactStoreError::UnsafeAdmittedMember("beam".into())); - } - let canonical_beam = fs::canonicalize(&beam_dir).await?; - if !canonical_beam.starts_with(&canonical_dir) || canonical_beam == canonical_dir { - return Err(ArtifactStoreError::RegistryEscape); - } - - let mut reader = fs::read_dir(&canonical_beam).await?; - while let Some(entry) = reader.next_entry().await? { - let file_name = entry.file_name().into_string().map_err(|_| { - ArtifactStoreError::UnsafeAdmittedMember("non-utf8 beam file".into()) - })?; - if file_name.is_empty() - || !file_name.ends_with(".beam") - || file_name.contains('/') - || file_name.contains('\\') - { - return Err(ArtifactStoreError::UnsafeAdmittedMember(format!( - "beam/{file_name}" - ))); - } - let relative = format!("beam/{file_name}"); - let bytes = read_regular_member( - &canonical_dir, - &entry.path(), - &relative, - self.max_archive_bytes, - &mut total_uncompressed, - ) - .await?; - entries.push((relative, bytes)); - } - entries.sort_by(|left, right| left.0.cmp(&right.0)); - if !entries.iter().any(|(name, _)| name == REQUIRED_WORKER_BEAM) { - return Err(ArtifactStoreError::InvalidRecord( - "admitted artifact is missing beam/worker.beam".into(), - )); - } - - let archive = tokio::task::spawn_blocking(move || build_admitted_tar_gz(entries)) - .await - .map_err(|err| { - ArtifactStoreError::InvalidRecord(format!( - "admitted artifact packaging task failed: {err}" - )) - })??; - let archive_bytes = archive.len() as u64; - if archive_bytes == 0 || archive_bytes > self.max_archive_bytes { - return Err(ArtifactStoreError::ArchiveTooLarge); - } - let archive_sha256 = format!("{:x}", Sha256::digest(&archive)); - - Ok(AdmittedArtifact { - build_sha256: build_sha256.to_owned(), - archive_name: "worker.tar.gz".into(), - archive_sha256, - archive_bytes, - bytes: archive, - }) - } - - async fn resolve_directory( - &self, - build_sha256: &str, - ) -> Result<(PathBuf, PathBuf, DeploymentRecord), ArtifactStoreError> { - validate_sha256(build_sha256)?; - let root = fs::canonicalize(&self.root).await.map_err(map_not_found)?; - let deployment_dir = self.root.join(build_sha256); - let dir_meta = fs::symlink_metadata(&deployment_dir) - .await - .map_err(map_not_found)?; - if !dir_meta.file_type().is_dir() || dir_meta.file_type().is_symlink() { - return Err(ArtifactStoreError::UnsafeRegistryEntry); - } - let canonical_dir = fs::canonicalize(&deployment_dir).await?; - if !canonical_dir.starts_with(&root) || canonical_dir == root { - return Err(ArtifactStoreError::RegistryEscape); - } - - let record_bytes = fs::read(canonical_dir.join(".deployment.json")) - .await - .map_err(map_not_found)?; - let record: DeploymentRecord = serde_json::from_slice(&record_bytes)?; - validate_record(build_sha256, &record, self.max_archive_bytes)?; - Ok((root, canonical_dir, record)) - } -} - -async fn read_regular_member( - deployment_dir: &Path, - path: &Path, - relative: &str, - max_bytes: u64, - total: &mut u64, -) -> Result, ArtifactStoreError> { - let meta = fs::symlink_metadata(path).await.map_err(map_not_found)?; - if !meta.file_type().is_file() || meta.file_type().is_symlink() { - return Err(ArtifactStoreError::UnsafeAdmittedMember(relative.into())); - } - let canonical = fs::canonicalize(path).await?; - if !canonical.starts_with(deployment_dir) || canonical == deployment_dir { - return Err(ArtifactStoreError::RegistryEscape); - } - *total = total - .checked_add(meta.len()) - .ok_or(ArtifactStoreError::ArchiveTooLarge)?; - if *total > max_bytes { - return Err(ArtifactStoreError::ArchiveTooLarge); - } - let bytes = fs::read(&canonical).await?; - if bytes.len() as u64 != meta.len() { - return Err(ArtifactStoreError::UnsafeAdmittedMember(format!( - "{relative} changed while packaging" - ))); - } - Ok(bytes) -} - -fn build_admitted_tar_gz(entries: Vec<(String, Vec)>) -> Result, ArtifactStoreError> { - let encoder: GzEncoder> = GzBuilder::new() - .mtime(0) - .write(Vec::new(), Compression::default()); - let mut archive = Builder::new(encoder); - for (path, bytes) in entries { - let mut header = Header::new_gnu(); - header.set_size(bytes.len() as u64); - header.set_mode(0o644); - header.set_uid(0); - header.set_gid(0); - header.set_mtime(0); - header.set_cksum(); - archive.append_data(&mut header, path, bytes.as_slice())?; - } - let encoder = archive.into_inner()?; - Ok(encoder.finish()?) -} - -fn validate_record( - requested_digest: &str, - record: &DeploymentRecord, - max_archive_bytes: u64, -) -> Result<(), ArtifactStoreError> { - validate_sha256(&record.build_sha256)?; - validate_sha256(&record.archive_sha256)?; - if record.build_sha256 != requested_digest { - return Err(ArtifactStoreError::InvalidRecord( - "registry key does not match build_sha256".into(), - )); - } - if record.verification_state != "verified" { - return Err(ArtifactStoreError::InvalidRecord( - "artifact is not in verified state".into(), - )); - } - if !matches!(record.archive_name.as_str(), "worker.zip" | "worker.tar.gz") { - return Err(ArtifactStoreError::InvalidRecord( - "archive_name is not canonical".into(), - )); - } - if record.archive_bytes == 0 || record.archive_bytes > max_archive_bytes { - return Err(ArtifactStoreError::ArchiveTooLarge); - } - Ok(()) -} - -fn validate_sha256(value: &str) -> Result<(), ArtifactStoreError> { - if value.len() == 64 - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(()) - } else { - Err(ArtifactStoreError::InvalidDigest) - } -} - -async fn hash_file(path: &Path, max_bytes: u64) -> Result { - let mut file = fs::File::open(path).await?; - let mut hasher = Sha256::new(); - let mut total = 0u64; - let mut buffer = vec![0u8; 64 * 1024]; - loop { - let read = file.read(&mut buffer).await?; - if read == 0 { - break; - } - total = total - .checked_add(read as u64) - .ok_or(ArtifactStoreError::ArchiveTooLarge)?; - if total > max_bytes { - return Err(ArtifactStoreError::ArchiveTooLarge); - } - hasher.update(&buffer[..read]); - } - Ok(format!("{:x}", hasher.finalize())) -} - -fn map_not_found(error: io::Error) -> ArtifactStoreError { - if error.kind() == io::ErrorKind::NotFound { - ArtifactStoreError::NotFound - } else { - ArtifactStoreError::Io(error) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use flate2::read::GzDecoder; - use std::fs as stdfs; - use tempfile::TempDir; - - fn digest(byte: u8) -> String { - format!("{:064x}", byte) - } - - fn archive_digest(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) - } - - fn write_fixture(root: &Path, build: &str, archive: &[u8]) { - let dir = root.join(build); - stdfs::create_dir_all(dir.join("beam")).unwrap(); - let archive_sha256 = archive_digest(archive); - stdfs::write(dir.join("worker.zip"), archive).unwrap(); - for (name, bytes) in [ - ("manifest.json", b"manifest".as_slice()), - ("admission-report.json", b"report".as_slice()), - ("provenance.json", b"provenance".as_slice()), - ("attestation.json", b"attestation".as_slice()), - ("admission-receipt.json", b"receipt".as_slice()), - ] { - stdfs::write(dir.join(name), bytes).unwrap(); - } - stdfs::write(dir.join("beam/worker.beam"), b"FOR1worker").unwrap(); - stdfs::write(dir.join("beam/helper.beam"), b"FOR1helper").unwrap(); - let record = serde_json::json!({ - "build_sha256": build, - "source_sha256": digest(9), - "runtime": "beam", - "language": "gleam", - "profile": "bmscl-hosted-gleam-v1", - "key_id": "test", - "archive_sha256": archive_sha256, - "archive_name": "worker.zip", - "archive_bytes": archive.len(), - "accepted_unix_seconds": 1, - "verification_state": "verified", - "activation_state": "not-connected" - }); - stdfs::write( - dir.join(".deployment.json"), - serde_json::to_vec(&record).unwrap(), - ) - .unwrap(); - } - - #[tokio::test] - async fn resolves_only_verified_digest_keyed_archives() { - let tmp = TempDir::new().unwrap(); - let build = digest(1); - write_fixture(tmp.path(), &build, b"zip-fixture"); - let store = ArtifactStore::new(tmp.path(), 1024); - let resolved = store.resolve(&build).await.unwrap(); - assert_eq!(resolved.build_sha256, build); - assert_eq!(resolved.archive_name, "worker.zip"); - assert_eq!(resolved.archive_bytes, 11); - } - - #[tokio::test] - async fn materializes_receipt_bearing_admitted_bundle_deterministically() { - let tmp = TempDir::new().unwrap(); - let build = digest(7); - write_fixture(tmp.path(), &build, b"zip-fixture"); - let store = ArtifactStore::new(tmp.path(), 1024 * 1024); - let first = store.resolve_admitted_bundle(&build).await.unwrap(); - let second = store.resolve_admitted_bundle(&build).await.unwrap(); - assert_eq!(first.archive_name, "worker.tar.gz"); - assert_eq!(first.archive_sha256, second.archive_sha256); - assert_eq!(first.bytes, second.bytes); - - let decoder = GzDecoder::new(first.bytes.as_slice()); - let mut archive = tar::Archive::new(decoder); - let names = archive - .entries() - .unwrap() - .map(|entry| { - entry - .unwrap() - .path() - .unwrap() - .to_string_lossy() - .into_owned() - }) - .collect::>(); - assert!(names.contains(&"admission-receipt.json".into())); - assert!(names.contains(&"beam/worker.beam".into())); - assert!(names.contains(&"beam/helper.beam".into())); - } - - #[tokio::test] - async fn admitted_bundle_fails_closed_without_receipt() { - let tmp = TempDir::new().unwrap(); - let build = digest(8); - write_fixture(tmp.path(), &build, b"zip-fixture"); - stdfs::remove_file(tmp.path().join(&build).join("admission-receipt.json")).unwrap(); - let store = ArtifactStore::new(tmp.path(), 1024 * 1024); - assert!(matches!( - store.resolve_admitted_bundle(&build).await, - Err(ArtifactStoreError::NotFound) - )); - } - - #[tokio::test] - async fn rejects_digest_mismatch_and_oversized_archives() { - let tmp = TempDir::new().unwrap(); - let build = digest(2); - write_fixture(tmp.path(), &build, b"too-large"); - let store = ArtifactStore::new(tmp.path(), 4); - assert!(matches!( - store.resolve(&build).await, - Err(ArtifactStoreError::ArchiveTooLarge) - )); - assert!(matches!( - store.resolve("../../escape").await, - Err(ArtifactStoreError::InvalidDigest) - )); - } - - #[tokio::test] - async fn rejects_tampered_archive_and_registry_symlink() { - let tmp = TempDir::new().unwrap(); - let build = digest(3); - write_fixture(tmp.path(), &build, b"original"); - stdfs::write(tmp.path().join(&build).join("worker.zip"), b"tampered").unwrap(); - let store = ArtifactStore::new(tmp.path(), 1024); - assert!(matches!( - store.resolve(&build).await, - Err(ArtifactStoreError::ArchiveDigestMismatch) - )); - - #[cfg(unix)] - { - let build2 = digest(4); - std::os::unix::fs::symlink(tmp.path().join(&build), tmp.path().join(&build2)).unwrap(); - assert!(matches!( - store.resolve(&build2).await, - Err(ArtifactStoreError::UnsafeRegistryEntry) - )); - } - } -} diff --git a/cert/beamscale-critical-section/infra-runtime-host/src/bin/bmscl-runtime-identity-writer.rs b/cert/beamscale-critical-section/infra-runtime-host/src/bin/bmscl-runtime-identity-writer.rs deleted file mode 100644 index ab5a59b..0000000 --- a/cert/beamscale-critical-section/infra-runtime-host/src/bin/bmscl-runtime-identity-writer.rs +++ /dev/null @@ -1,388 +0,0 @@ -use std::env; -use std::fs::{self, File, OpenOptions}; -use std::io::{ErrorKind, Read, Write}; -use std::os::unix::ffi::OsStrExt; -use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; -use std::path::{Component, Path, PathBuf}; - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -const DEFAULT_CGROUP_ROOT: &str = "/sys/fs/cgroup/beamscale"; -const DEFAULT_IDENTITY_ROOT: &str = "/run/bmscl/runtime-identities"; -const MAX_IDENTITY_BYTES: u64 = 16 * 1024; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct RuntimeIdentity { - runtime_id: String, - user_id: String, - tenant_id: String, - deployment_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - invocation_id: Option, -} - -#[derive(Debug)] -struct Args { - cgroup_root: PathBuf, - cgroup_path: PathBuf, - identity_root: PathBuf, - expected_runtime_id: Option, - user_id: String, - tenant_id: String, - deployment_id: String, - invocation_id: Option, -} - -fn main() { - match run(env::args().skip(1)) { - Ok(path) => println!("{}", path.display()), - Err(error) => { - eprintln!("bmscl-runtime-identity-writer: {error}"); - std::process::exit(2); - } - } -} - -fn run(args: I) -> Result -where - I: IntoIterator, -{ - let args = parse_args(args)?; - validate_subject("user_id", &args.user_id)?; - validate_subject("tenant_id", &args.tenant_id)?; - validate_subject("deployment_id", &args.deployment_id)?; - if let Some(invocation_id) = args.invocation_id.as_deref() { - validate_subject("invocation_id", invocation_id)?; - } - - let (cgroup_root, cgroup_path) = validate_managed_cgroup(&args.cgroup_root, &args.cgroup_path)?; - let runtime_id = runtime_id_for_cgroup(&cgroup_root, &cgroup_path)?; - if let Some(expected) = args.expected_runtime_id.as_deref() { - validate_runtime_id(expected)?; - if expected != runtime_id { - return Err( - "--runtime-id does not match deterministic managed-cgroup mapping".to_owned(), - ); - } - } - - let identity = RuntimeIdentity { - runtime_id, - user_id: args.user_id, - tenant_id: args.tenant_id, - deployment_id: args.deployment_id, - invocation_id: args.invocation_id, - }; - let identity_root = validate_identity_root(&args.identity_root)?; - let path = publish_identity_idempotently(&identity_root, &identity)?; - validate_published_file(&path)?; - Ok(path) -} - -fn parse_args(args: I) -> Result -where - I: IntoIterator, -{ - let mut cgroup_root = PathBuf::from(DEFAULT_CGROUP_ROOT); - let mut cgroup_path = None; - let mut identity_root = PathBuf::from(DEFAULT_IDENTITY_ROOT); - let mut expected_runtime_id = None; - let mut user_id = None; - let mut tenant_id = None; - let mut deployment_id = None; - let mut invocation_id = None; - - let mut args = args.into_iter(); - while let Some(flag) = args.next() { - let value = args - .next() - .ok_or_else(|| format!("{flag} requires a value"))?; - match flag.as_str() { - "--cgroup-root" => cgroup_root = PathBuf::from(value), - "--cgroup-path" => cgroup_path = Some(PathBuf::from(value)), - "--identity-root" => identity_root = PathBuf::from(value), - "--runtime-id" => expected_runtime_id = Some(value), - "--user-id" => user_id = Some(value), - "--tenant-id" => tenant_id = Some(value), - "--deployment-id" => deployment_id = Some(value), - "--invocation-id" => invocation_id = Some(value), - _ => return Err(format!("unknown argument {flag}")), - } - } - - Ok(Args { - cgroup_root, - cgroup_path: cgroup_path.ok_or_else(|| "--cgroup-path is required".to_owned())?, - identity_root, - expected_runtime_id, - user_id: user_id.ok_or_else(|| "--user-id is required".to_owned())?, - tenant_id: tenant_id.ok_or_else(|| "--tenant-id is required".to_owned())?, - deployment_id: deployment_id.ok_or_else(|| "--deployment-id is required".to_owned())?, - invocation_id, - }) -} - -fn validate_runtime_id(value: &str) -> Result<(), String> { - if value.is_empty() - || value.len() > 128 - || !value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) - { - return Err("runtime_id must be 1-128 ASCII letters, digits, '.', '_' or '-'".to_owned()); - } - Ok(()) -} - -fn validate_subject(label: &str, value: &str) -> Result<(), String> { - if value.is_empty() || value.len() > 256 || !value.bytes().all(|byte| byte.is_ascii_graphic()) { - return Err(format!( - "{label} must be 1-256 printable non-whitespace ASCII characters" - )); - } - Ok(()) -} - -fn validate_managed_cgroup(root: &Path, cgroup: &Path) -> Result<(PathBuf, PathBuf), String> { - if !root.is_absolute() || !cgroup.is_absolute() { - return Err("cgroup root and path must be absolute".to_owned()); - } - reject_non_normal_components(root, "cgroup root")?; - reject_non_normal_components(cgroup, "cgroup path")?; - - let root = fs::canonicalize(root) - .map_err(|error| format!("canonicalize cgroup root {}: {error}", root.display()))?; - let cgroup = fs::canonicalize(cgroup) - .map_err(|error| format!("canonicalize cgroup path {}: {error}", cgroup.display()))?; - if cgroup == root || !cgroup.starts_with(&root) { - return Err(format!( - "runtime cgroup {} is outside managed subtree {}", - cgroup.display(), - root.display() - )); - } - let metadata = fs::metadata(&cgroup) - .map_err(|error| format!("read cgroup metadata {}: {error}", cgroup.display()))?; - if !metadata.is_dir() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { - return Err("runtime cgroup must be root-owned and not group/world writable".to_owned()); - } - Ok((root, cgroup)) -} - -fn runtime_id_for_cgroup(root: &Path, cgroup: &Path) -> Result { - if cgroup == root || !cgroup.starts_with(root) { - return Err("cannot derive runtime id outside managed cgroup subtree".to_owned()); - } - let relative = cgroup - .strip_prefix(root) - .map_err(|_| "managed cgroup could not be made root-relative".to_owned())?; - if relative - .components() - .any(|component| !matches!(component, Component::Normal(_))) - { - return Err("managed cgroup relative path contains non-normal components".to_owned()); - } - let mut hasher = Sha256::new(); - hasher.update(relative.as_os_str().as_bytes()); - Ok(format!("rt_{:x}", hasher.finalize())) -} - -fn validate_identity_root(path: &Path) -> Result { - if !path.is_absolute() { - return Err("identity root must be absolute".to_owned()); - } - reject_non_normal_components(path, "identity root")?; - let metadata = fs::symlink_metadata(path) - .map_err(|error| format!("read identity root {}: {error}", path.display()))?; - if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { - return Err("identity root must be a real directory, not a symlink".to_owned()); - } - if metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { - return Err("identity root must be root-owned and not group/world writable".to_owned()); - } - let canonical = fs::canonicalize(path) - .map_err(|error| format!("canonicalize identity root {}: {error}", path.display()))?; - if canonical != path { - return Err("identity root must not traverse symlinked components".to_owned()); - } - Ok(canonical) -} - -fn reject_non_normal_components(path: &Path, label: &str) -> Result<(), String> { - if path - .components() - .any(|component| matches!(component, Component::ParentDir | Component::CurDir)) - { - return Err(format!("{label} may not contain '.' or '..' components")); - } - Ok(()) -} - -fn publish_identity_idempotently( - root: &Path, - identity: &RuntimeIdentity, -) -> Result { - let path = root.join(format!("{}.json", identity.runtime_id)); - match OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) - .open(&path) - { - Ok(mut file) => { - serde_json::to_writer_pretty(&mut file, identity) - .map_err(|error| format!("serialize identity {}: {error}", path.display()))?; - file.write_all(b"\n") - .map_err(|error| format!("finish identity {}: {error}", path.display()))?; - file.sync_all() - .map_err(|error| format!("fsync identity {}: {error}", path.display()))?; - File::open(root) - .and_then(|directory| directory.sync_all()) - .map_err(|error| format!("fsync identity root {}: {error}", root.display()))?; - Ok(path) - } - Err(error) if error.kind() == ErrorKind::AlreadyExists => { - let bytes = read_existing_identity(&path)?; - if identity_bytes_match(&bytes, identity)? { - Ok(path) - } else { - Err(format!( - "runtime identity {} already exists with conflicting immutable subjects", - path.display() - )) - } - } - Err(error) => Err(format!( - "create immutable identity {}: {error}", - path.display() - )), - } -} - -fn read_existing_identity(path: &Path) -> Result, String> { - let mut file = OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) - .open(path) - .map_err(|error| format!("open existing identity {}: {error}", path.display()))?; - let metadata = file - .metadata() - .map_err(|error| format!("inspect existing identity {}: {error}", path.display()))?; - if !metadata.is_file() { - return Err("published runtime identity must be a regular file".to_owned()); - } - if metadata.uid() != 0 || metadata.mode() & 0o077 != 0 { - return Err( - "published runtime identity must be root-owned with mode 0600 or stricter".to_owned(), - ); - } - if metadata.len() > MAX_IDENTITY_BYTES { - return Err("published runtime identity exceeds 16 KiB".to_owned()); - } - let mut bytes = Vec::new(); - (&mut file) - .take(MAX_IDENTITY_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|error| format!("read existing identity {}: {error}", path.display()))?; - if bytes.len() as u64 > MAX_IDENTITY_BYTES { - return Err("published runtime identity exceeds 16 KiB".to_owned()); - } - Ok(bytes) -} - -fn identity_bytes_match(bytes: &[u8], identity: &RuntimeIdentity) -> Result { - let existing: RuntimeIdentity = serde_json::from_slice(bytes) - .map_err(|parse_error| format!("parse existing runtime identity: {parse_error}"))?; - Ok(existing == *identity) -} - -fn validate_published_file(path: &Path) -> Result<(), String> { - let metadata = fs::symlink_metadata(path) - .map_err(|error| format!("read identity metadata {}: {error}", path.display()))?; - if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { - return Err("published runtime identity must be a regular file".to_owned()); - } - if metadata.uid() != 0 || metadata.mode() & 0o077 != 0 { - return Err( - "published runtime identity must be root-owned with mode 0600 or stricter".to_owned(), - ); - } - if metadata.len() > MAX_IDENTITY_BYTES { - return Err("published runtime identity exceeds 16 KiB".to_owned()); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn identity(runtime_id: &str) -> RuntimeIdentity { - RuntimeIdentity { - runtime_id: runtime_id.to_owned(), - user_id: "usr_test".to_owned(), - tenant_id: "ten_test".to_owned(), - deployment_id: "dep_test".to_owned(), - invocation_id: Some("inv_test".to_owned()), - } - } - - #[test] - fn runtime_id_is_safe_for_identity_filename() { - assert!(validate_runtime_id("rt_01k_test-1.2").is_ok()); - assert!(validate_runtime_id("../escape").is_err()); - assert!(validate_runtime_id("with/slash").is_err()); - assert!(validate_runtime_id("").is_err()); - } - - #[test] - fn runtime_id_hashes_full_relative_cgroup_path() { - let root = Path::new("/sys/fs/cgroup/beamscale"); - let a = runtime_id_for_cgroup(root, Path::new("/sys/fs/cgroup/beamscale/a/0/1")).unwrap(); - let b = runtime_id_for_cgroup(root, Path::new("/sys/fs/cgroup/beamscale/b/0/1")).unwrap(); - assert!(a.starts_with("rt_")); - assert_eq!(a.len(), 67); - assert_ne!(a, b); - assert_eq!( - a, - runtime_id_for_cgroup(root, Path::new("/sys/fs/cgroup/beamscale/a/0/1")).unwrap() - ); - } - - #[test] - fn exact_identity_content_is_idempotent_but_conflict_is_rejected() { - let temp = TempDir::new().unwrap(); - let first = identity("rt_test"); - let path = publish_identity_idempotently(temp.path(), &first).unwrap(); - let original = fs::read(&path).unwrap(); - assert!(identity_bytes_match(&original, &first).unwrap()); - - let mut conflicting = first; - conflicting.user_id = "usr_attacker".to_owned(); - assert!(!identity_bytes_match(&original, &conflicting).unwrap()); - assert_eq!(fs::read(&path).unwrap(), original); - } - - #[test] - fn parser_requires_host_cgroup_and_authenticated_subjects() { - let args = parse_args([ - "--cgroup-path".to_owned(), - "/sys/fs/cgroup/beamscale/ten/shard/1".to_owned(), - "--user-id".to_owned(), - "usr_test".to_owned(), - "--tenant-id".to_owned(), - "ten_test".to_owned(), - "--deployment-id".to_owned(), - "dep_test".to_owned(), - ]) - .unwrap(); - assert_eq!(args.user_id, "usr_test"); - assert_eq!(args.tenant_id, "ten_test"); - assert_eq!(args.deployment_id, "dep_test"); - assert!(args.expected_runtime_id.is_none()); - } -} diff --git a/cert/beamscale-critical-section/infra-runtime-host/src/lib.rs b/cert/beamscale-critical-section/infra-runtime-host/src/lib.rs deleted file mode 100644 index 17e2fdf..0000000 --- a/cert/beamscale-critical-section/infra-runtime-host/src/lib.rs +++ /dev/null @@ -1,1908 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::{ - collections::HashMap, - env, - path::{Path, PathBuf}, - process::Stdio, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; -use thiserror::Error; -use tokio::{ - fs, - io::{AsyncReadExt, AsyncWriteExt}, - net::UnixStream, - process::Command, - sync::RwLock, - time::{sleep, Duration, Instant}, -}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ExecutionClass { - Faas, - Phoenix, - DurableActor, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ExecutionBackend { - BareProcess, - Firecracker, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub struct ShardKey { - pub execution_class: ExecutionClass, - pub tenant_id: String, - pub shard_id: String, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum LifecycleState { - Cold, - Starting, - Hot, - WarmIdle, - Hibernated, - Draining, - Terminated, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimePolicy { - pub vcpu_count: u16, - pub memory_mib: u32, - #[serde(default = "default_true")] - pub snapshot_enabled: bool, - #[serde(default = "default_warm_idle_ms")] - pub warm_idle_ms: u64, - #[serde(default = "default_hibernate_ms")] - pub hibernate_after_ms: u64, - #[serde(default = "default_destroy_ms")] - pub destroy_after_ms: u64, -} - -fn default_true() -> bool { - true -} - -fn default_warm_idle_ms() -> u64 { - 10_000 -} - -fn default_hibernate_ms() -> u64 { - 60_000 -} - -fn default_destroy_ms() -> u64 { - 15 * 60_000 -} - -fn select_backend(requested: &str, allow_mock: bool) -> BackendKind { - match requested { - "mock" if allow_mock => BackendKind::Mock, - _ => BackendKind::Firecracker, - } -} - -impl Default for RuntimePolicy { - fn default() -> Self { - Self { - vcpu_count: 1, - memory_mib: 128, - snapshot_enabled: true, - warm_idle_ms: default_warm_idle_ms(), - hibernate_after_ms: default_hibernate_ms(), - destroy_after_ms: default_destroy_ms(), - } - } -} - -impl RuntimePolicy { - pub fn validate(&self) -> Result<(), HostError> { - if self.vcpu_count == 0 || self.vcpu_count > 32 { - return Err(HostError::InvalidPolicy("vcpu_count must be 1..=32".into())); - } - if self.memory_mib < 64 { - return Err(HostError::InvalidPolicy("memory_mib must be >= 64".into())); - } - if self.warm_idle_ms == 0 - || self.hibernate_after_ms <= self.warm_idle_ms - || self.destroy_after_ms <= self.hibernate_after_ms - { - return Err(HostError::InvalidPolicy( - "idle lifecycle thresholds must be strictly increasing".into(), - )); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EnsureShardRequest { - pub user_id: String, - pub tenant_id: String, - pub shard_id: String, - pub execution_class: ExecutionClass, - pub execution_backend: ExecutionBackend, - pub tenant_isolation: String, - pub runtime_epoch: u64, - pub root_deployment_id: String, - pub deployment_digest: String, - #[serde(default)] - pub policy: RuntimePolicy, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EpochRequest { - pub tenant_id: String, - pub shard_id: String, - pub execution_class: ExecutionClass, - pub execution_backend: ExecutionBackend, - pub runtime_epoch: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TouchRequest { - pub tenant_id: String, - pub shard_id: String, - pub execution_class: ExecutionClass, - pub execution_backend: ExecutionBackend, - pub runtime_epoch: u64, - #[serde(default)] - pub active_delta: i32, - #[serde(default)] - pub ingress_bytes: u64, - #[serde(default)] - pub egress_bytes: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ShardStatus { - pub tenant_id: String, - pub shard_id: String, - pub execution_class: ExecutionClass, - pub execution_backend: ExecutionBackend, - pub runtime_epoch: u64, - pub deployment_digest: String, - pub state: LifecycleState, - pub backend: String, - pub vcpu_count: u16, - pub memory_mib: u32, - pub active_invocations: u32, - pub last_active_unix_ms: u64, - pub process_pid: Option, - pub api_socket: Option, - pub vsock_path: Option, - pub last_error: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MeteringSample { - pub tenant_id: String, - pub shard_id: String, - pub execution_class: ExecutionClass, - pub execution_backend: ExecutionBackend, - pub runtime_epoch: u64, - pub request_count: u64, - pub cpu_ns: u64, - pub memory_current_bytes: u64, - pub memory_byte_ms: u128, - pub ingress_bytes: u64, - pub egress_bytes: u64, -} - -#[derive(Debug, Clone)] -struct ShardRecord { - key: ShardKey, - runtime_epoch: u64, - deployment_digest: String, - user_id: String, - root_deployment_id: String, - state: LifecycleState, - policy: RuntimePolicy, - active_invocations: u32, - last_active_unix_ms: u64, - process_pid: Option, - api_socket: Option, - vsock_path: Option, - last_error: Option, - request_count: u64, - ingress_bytes: u64, - egress_bytes: u64, - memory_byte_ms: u128, - last_meter_unix_ms: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BackendKind { - Firecracker, - Mock, -} - -impl BackendKind { - fn as_str(self) -> &'static str { - match self { - Self::Firecracker => "firecracker", - Self::Mock => "mock", - } - } -} - -#[derive(Debug, Clone)] -pub struct HostConfig { - pub backend: BackendKind, - pub firecracker_bin: PathBuf, - pub jailer_bin: PathBuf, - pub jailer_chroot_base: PathBuf, - pub jailer_uid: u32, - pub jailer_gid: u32, - pub ip_bin: PathBuf, - pub netns_root: PathBuf, - pub kernel_image: PathBuf, - pub rootfs_image: PathBuf, - pub runtime_dir: PathBuf, - pub cgroup_root: PathBuf, - pub identity_root: PathBuf, - pub identity_writer_bin: PathBuf, - pub vmm_overhead_mib: u32, - pub boot_args: String, - pub api_socket_timeout_ms: u64, -} - -impl HostConfig { - pub fn from_env() -> Self { - let requested_backend = - env::var("BMSCL_RUNTIME_BACKEND").unwrap_or_else(|_| "firecracker".into()); - let allow_mock = env::var("BMSCL_ALLOW_MOCK_BACKEND").as_deref() == Ok("1"); - let backend = select_backend(&requested_backend, allow_mock); - Self { - backend, - firecracker_bin: env::var_os("BMSCL_FIRECRACKER_BIN") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/usr/local/bin/firecracker")), - jailer_bin: env::var_os("BMSCL_JAILER_BIN") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/usr/local/bin/jailer")), - jailer_chroot_base: env::var_os("BMSCL_JAILER_CHROOT_BASE") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/srv/jailer")), - jailer_uid: env::var("BMSCL_JAILER_UID") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(65534), - jailer_gid: env::var("BMSCL_JAILER_GID") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(65534), - ip_bin: env::var_os("BMSCL_IP_BIN") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/run/current-system/sw/bin/ip")), - netns_root: env::var_os("BMSCL_NETNS_ROOT") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/var/run/netns")), - kernel_image: env::var_os("BMSCL_GUEST_KERNEL_IMAGE") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/opt/beamscale/guest/vmlinux")), - rootfs_image: env::var_os("BMSCL_GUEST_ROOTFS_IMAGE") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/opt/beamscale/guest/rootfs.ext4")), - runtime_dir: env::var_os("BMSCL_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/var/lib/beamscale/runtime")), - cgroup_root: env::var_os("BMSCL_CGROUP_ROOT") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/sys/fs/cgroup/beamscale")), - identity_root: env::var_os("BMSCL_RUNTIME_IDENTITY_ROOT") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/run/bmscl/runtime-identities")), - identity_writer_bin: env::var_os("BMSCL_RUNTIME_IDENTITY_WRITER") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/usr/local/bin/bmscl-runtime-identity-writer")), - vmm_overhead_mib: env::var("BMSCL_VMM_OVERHEAD_MIB") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(96), - boot_args: env::var("BMSCL_GUEST_BOOT_ARGS") - .unwrap_or_else(|_| "console=ttyS0 reboot=k panic=1 pci=off".into()), - api_socket_timeout_ms: env::var("BMSCL_FIRECRACKER_API_TIMEOUT_MS") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(5_000), - } - } -} - -#[derive(Debug, Error)] -pub enum HostError { - #[error("invalid runtime policy: {0}")] - InvalidPolicy(String), - #[error("unknown shard")] - UnknownShard, - #[error("stale runtime epoch: requested {requested}, current {current}")] - StaleEpoch { requested: u64, current: u64 }, - #[error("runtime epoch replacement requires terminated state")] - EpochReplacementConflict, - #[error("invalid lifecycle transition from {0:?}")] - InvalidState(LifecycleState), - #[error("firecracker API error: {0}")] - Firecracker(String), - #[error("cgroup error: {0}")] - Cgroup(String), - #[error("invalid runtime identity: {0}")] - InvalidIdentity(String), - #[error("runtime identity conflict: {0}")] - IdentityConflict(String), - #[error("invalid execution target: {0}")] - InvalidExecutionTarget(String), - #[error("runtime identity error: {0}")] - Identity(String), - #[error("io error: {0}")] - Io(#[from] std::io::Error), -} - -#[derive(Clone)] -pub struct RuntimeHost { - config: HostConfig, - shards: Arc>>, -} - -impl RuntimeHost { - pub fn new(config: HostConfig) -> Self { - Self { - config, - shards: Arc::new(RwLock::new(HashMap::new())), - } - } - - pub async fn ensure(&self, req: EnsureShardRequest) -> Result { - validate_execution_target( - req.execution_class, - req.execution_backend, - &req.tenant_isolation, - )?; - req.policy.validate()?; - validate_digest(&req.deployment_digest)?; - validate_resource_id("tenant_id", &req.tenant_id)?; - validate_resource_id("shard_id", &req.shard_id)?; - validate_runtime_subject("user_id", &req.user_id)?; - validate_runtime_subject("tenant_id", &req.tenant_id)?; - validate_runtime_subject("shard_id", &req.shard_id)?; - validate_runtime_subject("root_deployment_id", &req.root_deployment_id)?; - let key = ShardKey { - execution_class: req.execution_class, - tenant_id: req.tenant_id, - shard_id: req.shard_id, - }; - let mut shards = self.shards.write().await; - if let Some(existing) = shards.get_mut(&key) { - if req.runtime_epoch < existing.runtime_epoch { - return Err(HostError::StaleEpoch { - requested: req.runtime_epoch, - current: existing.runtime_epoch, - }); - } - if req.runtime_epoch > existing.runtime_epoch { - if existing.state != LifecycleState::Terminated { - return Err(HostError::EpochReplacementConflict); - } - *existing = ShardRecord::new( - key, - req.runtime_epoch, - req.deployment_digest, - req.user_id, - req.root_deployment_id, - req.policy, - ); - } else { - if existing.user_id != req.user_id - || existing.root_deployment_id != req.root_deployment_id - { - return Err(HostError::IdentityConflict( - "immutable runtime identity changed within the same epoch".into(), - )); - } - existing.deployment_digest = req.deployment_digest; - existing.policy = req.policy; - existing.last_active_unix_ms = now_ms(); - } - return Ok(self.status_from(existing)); - } - let record = ShardRecord::new( - key.clone(), - req.runtime_epoch, - req.deployment_digest, - req.user_id, - req.root_deployment_id, - req.policy, - ); - let status = self.status_from(&record); - shards.insert(key, record); - Ok(status) - } - - pub async fn start(&self, req: EpochRequest) -> Result { - validate_execution_request(req.execution_class, req.execution_backend)?; - let key = ShardKey { - execution_class: req.execution_class, - tenant_id: req.tenant_id, - shard_id: req.shard_id, - }; - let (snapshot, should_restore) = { - let mut shards = self.shards.write().await; - let record = shards.get_mut(&key).ok_or(HostError::UnknownShard)?; - check_epoch(record, req.runtime_epoch)?; - match record.state { - LifecycleState::Hot | LifecycleState::Starting => { - return Ok(self.status_from(record)); - } - LifecycleState::Cold | LifecycleState::Hibernated | LifecycleState::Terminated => { - let should_restore = record.state == LifecycleState::Hibernated; - record.state = LifecycleState::Starting; - record.last_error = None; - (record.clone(), should_restore) - } - state => return Err(HostError::InvalidState(state)), - } - }; - let restore = should_restore && snapshot_has_files(&self.config, &snapshot); - let result = if restore { - self.restore_vm(&snapshot).await - } else { - self.boot_vm(&snapshot).await - }; - let mut shards = self.shards.write().await; - let record = shards.get_mut(&key).ok_or(HostError::UnknownShard)?; - match result { - Ok(runtime) => { - record.state = LifecycleState::Hot; - record.process_pid = runtime.pid; - record.api_socket = runtime.api_socket; - record.vsock_path = runtime.vsock_path; - record.last_active_unix_ms = now_ms(); - record.last_meter_unix_ms = record.last_active_unix_ms; - Ok(self.status_from(record)) - } - Err(err) => { - record.state = LifecycleState::Cold; - record.last_error = Some(err.to_string()); - Err(err) - } - } - } - - pub async fn mark_warm_idle(&self, req: EpochRequest) -> Result { - validate_execution_request(req.execution_class, req.execution_backend)?; - let key = ShardKey { - execution_class: req.execution_class, - tenant_id: req.tenant_id, - shard_id: req.shard_id, - }; - let mut shards = self.shards.write().await; - let record = shards.get_mut(&key).ok_or(HostError::UnknownShard)?; - check_epoch(record, req.runtime_epoch)?; - if record.active_invocations != 0 { - return Err(HostError::InvalidState(record.state)); - } - match record.state { - LifecycleState::Hot | LifecycleState::WarmIdle => { - record.state = LifecycleState::WarmIdle; - } - state => return Err(HostError::InvalidState(state)), - } - Ok(self.status_from(record)) - } - - pub async fn touch(&self, req: TouchRequest) -> Result { - validate_execution_request(req.execution_class, req.execution_backend)?; - let key = ShardKey { - execution_class: req.execution_class, - tenant_id: req.tenant_id, - shard_id: req.shard_id, - }; - let mut shards = self.shards.write().await; - let record = shards.get_mut(&key).ok_or(HostError::UnknownShard)?; - check_epoch(record, req.runtime_epoch)?; - if req.active_delta >= 0 { - let delta = req.active_delta as u32; - record.active_invocations = record.active_invocations.saturating_add(delta); - record.request_count = record.request_count.saturating_add(delta as u64); - } else { - record.active_invocations = record - .active_invocations - .saturating_sub(req.active_delta.unsigned_abs()); - } - record.ingress_bytes = record.ingress_bytes.saturating_add(req.ingress_bytes); - record.egress_bytes = record.egress_bytes.saturating_add(req.egress_bytes); - record.last_active_unix_ms = now_ms(); - if record.active_invocations > 0 && record.state == LifecycleState::WarmIdle { - record.state = LifecycleState::Hot; - } - Ok(self.status_from(record)) - } - - pub async fn hibernate(&self, req: EpochRequest) -> Result { - validate_execution_request(req.execution_class, req.execution_backend)?; - let key = ShardKey { - execution_class: req.execution_class, - tenant_id: req.tenant_id.clone(), - shard_id: req.shard_id.clone(), - }; - let snapshot_enabled = { - let shards = self.shards.read().await; - let record = shards.get(&key).ok_or(HostError::UnknownShard)?; - check_epoch(record, req.runtime_epoch)?; - record.policy.snapshot_enabled - }; - if !snapshot_enabled { - return self.terminate(req).await; - } - let snapshot = { - let mut shards = self.shards.write().await; - let record = shards.get_mut(&key).ok_or(HostError::UnknownShard)?; - check_epoch(record, req.runtime_epoch)?; - if record.active_invocations != 0 { - return Err(HostError::InvalidState(record.state)); - } - match record.state { - LifecycleState::Hot | LifecycleState::WarmIdle => record.clone(), - LifecycleState::Hibernated => return Ok(self.status_from(record)), - state => return Err(HostError::InvalidState(state)), - } - }; - self.snapshot_and_stop(&snapshot).await?; - let mut shards = self.shards.write().await; - let record = shards.get_mut(&key).ok_or(HostError::UnknownShard)?; - record.state = LifecycleState::Hibernated; - record.process_pid = None; - record.api_socket = None; - record.vsock_path = None; - Ok(self.status_from(record)) - } - - pub async fn terminate(&self, req: EpochRequest) -> Result { - validate_execution_request(req.execution_class, req.execution_backend)?; - let key = ShardKey { - execution_class: req.execution_class, - tenant_id: req.tenant_id, - shard_id: req.shard_id, - }; - let record_snapshot = { - let mut shards = self.shards.write().await; - let record = shards.get_mut(&key).ok_or(HostError::UnknownShard)?; - check_epoch(record, req.runtime_epoch)?; - if record.state == LifecycleState::Terminated { - return Ok(self.status_from(record)); - } - record.state = LifecycleState::Draining; - record.clone() - }; - self.stop_vm(&record_snapshot).await?; - self.cleanup_terminated_jail(&record_snapshot).await?; - let mut shards = self.shards.write().await; - let record = shards.get_mut(&key).ok_or(HostError::UnknownShard)?; - record.state = LifecycleState::Terminated; - record.process_pid = None; - record.api_socket = None; - record.vsock_path = None; - Ok(self.status_from(record)) - } - - pub async fn get( - &self, - execution_class: ExecutionClass, - tenant_id: &str, - shard_id: &str, - ) -> Result { - let shards = self.shards.read().await; - let key = ShardKey { - execution_class, - tenant_id: tenant_id.to_owned(), - shard_id: shard_id.to_owned(), - }; - shards - .get(&key) - .map(|record| self.status_from(record)) - .ok_or(HostError::UnknownShard) - } - - pub async fn metering( - &self, - execution_class: ExecutionClass, - tenant_id: &str, - shard_id: &str, - ) -> Result { - let key = ShardKey { - execution_class, - tenant_id: tenant_id.to_owned(), - shard_id: shard_id.to_owned(), - }; - let mut shards = self.shards.write().await; - let record = shards.get_mut(&key).ok_or(HostError::UnknownShard)?; - let (cpu_ns, memory_current_bytes) = match self.config.backend { - BackendKind::Mock => (0, 0), - BackendKind::Firecracker => read_cgroup_metering(&self.config, record).await?, - }; - let now = now_ms(); - let delta_ms = now.saturating_sub(record.last_meter_unix_ms); - record.memory_byte_ms = record - .memory_byte_ms - .saturating_add((memory_current_bytes as u128).saturating_mul(delta_ms as u128)); - record.last_meter_unix_ms = now; - Ok(MeteringSample { - tenant_id: record.key.tenant_id.clone(), - shard_id: record.key.shard_id.clone(), - execution_class: record.key.execution_class, - execution_backend: ExecutionBackend::Firecracker, - runtime_epoch: record.runtime_epoch, - request_count: record.request_count, - cpu_ns, - memory_current_bytes, - memory_byte_ms: record.memory_byte_ms, - ingress_bytes: record.ingress_bytes, - egress_bytes: record.egress_bytes, - }) - } - - pub async fn sweep_once(&self) { - let now = now_ms(); - let actions = { - let shards = self.shards.read().await; - shards - .values() - .filter_map(|record| { - if record.active_invocations != 0 { - return None; - } - let idle = now.saturating_sub(record.last_active_unix_ms); - let req = EpochRequest { - tenant_id: record.key.tenant_id.clone(), - shard_id: record.key.shard_id.clone(), - execution_class: record.key.execution_class, - execution_backend: ExecutionBackend::Firecracker, - runtime_epoch: record.runtime_epoch, - }; - match record.state { - LifecycleState::Hot if idle >= record.policy.warm_idle_ms => { - Some((0u8, req)) - } - LifecycleState::WarmIdle if idle >= record.policy.hibernate_after_ms => { - Some((1u8, req)) - } - LifecycleState::Hibernated if idle >= record.policy.destroy_after_ms => { - Some((2u8, req)) - } - _ => None, - } - }) - .collect::>() - }; - for (kind, req) in actions { - let result = match kind { - 0 => self.mark_warm_idle(req).await, - 1 => self.hibernate(req).await, - _ => self.terminate(req).await, - }; - if let Err(err) = result { - tracing::warn!(error = %err, "tenant runtime lifecycle sweep action failed"); - } - } - } - - fn status_from(&self, record: &ShardRecord) -> ShardStatus { - ShardStatus { - tenant_id: record.key.tenant_id.clone(), - shard_id: record.key.shard_id.clone(), - execution_class: record.key.execution_class, - execution_backend: ExecutionBackend::Firecracker, - runtime_epoch: record.runtime_epoch, - deployment_digest: record.deployment_digest.clone(), - state: record.state, - backend: self.config.backend.as_str().into(), - vcpu_count: record.policy.vcpu_count, - memory_mib: record.policy.memory_mib, - active_invocations: record.active_invocations, - last_active_unix_ms: record.last_active_unix_ms, - process_pid: record.process_pid, - api_socket: record - .api_socket - .as_ref() - .map(|path| path.display().to_string()), - vsock_path: record - .vsock_path - .as_ref() - .map(|path| path.display().to_string()), - last_error: record.last_error.clone(), - } - } - - async fn boot_vm(&self, record: &ShardRecord) -> Result { - if self.config.backend == BackendKind::Mock { - return Ok(RuntimeHandles::mock()); - } - require_file(&self.config.kernel_image).await?; - require_file(&self.config.rootfs_image).await?; - let runtime_paths = RuntimePaths::new(&self.config.runtime_dir, record); - fs::create_dir_all(&runtime_paths.dir).await?; - let jailed = spawn_firecracker(&self.config, record).await?; - if let Err(err) = prepare_cgroup(&self.config, record, jailed.pid).await { - let _ = terminate_pid(jailed.pid).await; - let _ = cleanup_jail(&self.config, &jailed).await; - return Err(err); - } - if let Err(err) = publish_runtime_identity(&self.config, record).await { - let _ = terminate_pid(jailed.pid).await; - let _ = cleanup_jail(&self.config, &jailed).await; - return Err(err); - } - if let Err(err) = - wait_for_socket(&jailed.api_socket, self.config.api_socket_timeout_ms).await - { - let _ = terminate_pid(jailed.pid).await; - let _ = cleanup_jail(&self.config, &jailed).await; - return Err(err); - } - if let Err(err) = stage_guest_boot_files(&self.config, &jailed).await { - let _ = terminate_pid(jailed.pid).await; - let _ = cleanup_jail(&self.config, &jailed).await; - return Err(err); - } - let client = FcClient::new(jailed.api_socket.clone()); - let configured = async { - client - .put( - "/machine-config", - json!({ - "vcpu_count": record.policy.vcpu_count, - "mem_size_mib": record.policy.memory_mib, - "smt": false - }), - ) - .await?; - client - .put( - "/boot-source", - json!({ - "kernel_image_path": "/vmlinux", - "boot_args": guest_boot_args(&self.config, record) - }), - ) - .await?; - client - .put( - "/drives/rootfs", - json!({ - "drive_id": "rootfs", - "path_on_host": "/rootfs.ext4", - "is_root_device": true, - "is_read_only": true - }), - ) - .await?; - client - .put( - "/vsock", - json!({ - "vsock_id": "control", - "guest_cid": 3, - "uds_path": "/control.vsock" - }), - ) - .await?; - client - .put("/actions", json!({"action_type": "InstanceStart"})) - .await - } - .await; - if let Err(err) = configured { - let _ = terminate_pid(jailed.pid).await; - let _ = cleanup_jail(&self.config, &jailed).await; - return Err(err); - } - Ok(RuntimeHandles { - pid: Some(jailed.pid), - api_socket: Some(jailed.api_socket), - vsock_path: Some(jailed.vsock_path), - }) - } - - async fn restore_vm(&self, record: &ShardRecord) -> Result { - if self.config.backend == BackendKind::Mock { - return Ok(RuntimeHandles::mock()); - } - let runtime_paths = RuntimePaths::new(&self.config.runtime_dir, record); - if !runtime_paths.snapshot_state.exists() || !runtime_paths.snapshot_mem.exists() { - return self.boot_vm(record).await; - } - require_file(&self.config.rootfs_image).await?; - let jailed = spawn_firecracker(&self.config, record).await?; - if let Err(err) = prepare_cgroup(&self.config, record, jailed.pid).await { - let _ = terminate_pid(jailed.pid).await; - let _ = cleanup_jail(&self.config, &jailed).await; - return Err(err); - } - if let Err(err) = publish_runtime_identity(&self.config, record).await { - let _ = terminate_pid(jailed.pid).await; - let _ = cleanup_jail(&self.config, &jailed).await; - return Err(err); - } - if let Err(err) = - wait_for_socket(&jailed.api_socket, self.config.api_socket_timeout_ms).await - { - let _ = terminate_pid(jailed.pid).await; - let _ = cleanup_jail(&self.config, &jailed).await; - return Err(err); - } - if let Err(err) = stage_guest_restore_files(&self.config, &runtime_paths, &jailed).await { - let _ = terminate_pid(jailed.pid).await; - let _ = cleanup_jail(&self.config, &jailed).await; - return Err(err); - } - let client = FcClient::new(jailed.api_socket.clone()); - if let Err(err) = client - .put( - "/snapshot/load", - json!({ - "snapshot_path": "/snapshot/vm.state", - "mem_backend": { - "backend_type": "File", - "backend_path": "/snapshot/vm.mem" - }, - "track_dirty_pages": true, - "resume_vm": true, - "vsock_override": { - "uds_path": "/control.vsock" - } - }), - ) - .await - { - let _ = terminate_pid(jailed.pid).await; - let _ = cleanup_jail(&self.config, &jailed).await; - return Err(err); - } - Ok(RuntimeHandles { - pid: Some(jailed.pid), - api_socket: Some(jailed.api_socket), - vsock_path: Some(jailed.vsock_path), - }) - } - - async fn snapshot_and_stop(&self, record: &ShardRecord) -> Result<(), HostError> { - if self.config.backend == BackendKind::Mock { - return Ok(()); - } - let runtime_paths = RuntimePaths::new(&self.config.runtime_dir, record); - let jailed = - JailedRuntime::for_record(&self.config, record, record.process_pid.unwrap_or(0))?; - let api = record - .api_socket - .clone() - .unwrap_or_else(|| jailed.api_socket.clone()); - prepare_jailer_writable_dir( - &jailed.root.join("snapshot"), - self.config.jailer_uid, - self.config.jailer_gid, - ) - .await?; - let client = FcClient::new(api); - client.patch("/vm", json!({"state": "Paused"})).await?; - client - .put( - "/snapshot/create", - json!({ - "snapshot_type": "Full", - "snapshot_path": "/snapshot/vm.state", - "mem_file_path": "/snapshot/vm.mem" - }), - ) - .await?; - self.stop_vm(record).await?; - fs::create_dir_all(&runtime_paths.dir).await?; - copy_file_readonly(&jailed.snapshot_state, &runtime_paths.snapshot_state).await?; - copy_file_readonly(&jailed.snapshot_mem, &runtime_paths.snapshot_mem).await?; - cleanup_jail(&self.config, &jailed).await?; - Ok(()) - } - - async fn stop_vm(&self, record: &ShardRecord) -> Result<(), HostError> { - if self.config.backend == BackendKind::Mock { - return Ok(()); - } - if let Some(pid) = record.process_pid { - terminate_pid(pid).await?; - } - Ok(()) - } - - async fn cleanup_terminated_jail(&self, record: &ShardRecord) -> Result<(), HostError> { - if self.config.backend == BackendKind::Mock { - return Ok(()); - } - let jailed = JailedRuntime::for_record(&self.config, record, 0)?; - cleanup_jail(&self.config, &jailed).await - } -} - -impl ShardRecord {} - -impl ShardRecord { - fn new( - key: ShardKey, - runtime_epoch: u64, - deployment_digest: String, - user_id: String, - root_deployment_id: String, - policy: RuntimePolicy, - ) -> Self { - let now = now_ms(); - Self { - key, - runtime_epoch, - deployment_digest, - user_id, - root_deployment_id, - state: LifecycleState::Cold, - policy, - active_invocations: 0, - last_active_unix_ms: now, - process_pid: None, - api_socket: None, - vsock_path: None, - last_error: None, - request_count: 0, - ingress_bytes: 0, - egress_bytes: 0, - memory_byte_ms: 0, - last_meter_unix_ms: now, - } - } -} - -fn validate_resource_id(name: &str, value: &str) -> Result<(), HostError> { - if value.is_empty() - || value.len() > 128 - || !value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - return Err(HostError::InvalidPolicy(format!( - "{name} must contain 1..=128 ASCII alphanumeric, '-' or '_' characters" - ))); - } - Ok(()) -} - -fn validate_runtime_subject(name: &str, value: &str) -> Result<(), HostError> { - let valid = !value.is_empty() - && value.len() <= 128 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')); - if valid { - Ok(()) - } else { - Err(HostError::InvalidIdentity(format!( - "{name} must be 1..=128 ASCII chars of [A-Za-z0-9._-]" - ))) - } -} - -fn check_epoch(record: &ShardRecord, requested: u64) -> Result<(), HostError> { - if requested == record.runtime_epoch { - Ok(()) - } else { - Err(HostError::StaleEpoch { - requested, - current: record.runtime_epoch, - }) - } -} - -fn validate_execution_request( - execution_class: ExecutionClass, - execution_backend: ExecutionBackend, -) -> Result<(), HostError> { - if execution_backend != ExecutionBackend::Firecracker { - return Err(HostError::InvalidExecutionTarget( - "this runtime-host accepts execution_backend=firecracker only".into(), - )); - } - if !matches!( - execution_class, - ExecutionClass::Phoenix | ExecutionClass::DurableActor - ) { - return Err(HostError::InvalidExecutionTarget( - "Firecracker runtime-host accepts only phoenix or durable_actor".into(), - )); - } - Ok(()) -} - -fn validate_execution_target( - execution_class: ExecutionClass, - execution_backend: ExecutionBackend, - tenant_isolation: &str, -) -> Result<(), HostError> { - validate_execution_request(execution_class, execution_backend)?; - if tenant_isolation != "single_tenant_microvm" { - return Err(HostError::InvalidExecutionTarget( - "Firecracker runtime-host requires single_tenant_microvm".into(), - )); - } - Ok(()) -} - -fn validate_digest(value: &str) -> Result<(), HostError> { - let raw = value.strip_prefix("sha256:").unwrap_or(value); - if raw.len() == 64 - && raw - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) - { - Ok(()) - } else { - Err(HostError::InvalidPolicy( - "deployment_digest must be lowercase sha256".into(), - )) - } -} - -fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - -async fn require_file(path: &Path) -> Result<(), HostError> { - let metadata = fs::symlink_metadata(path).await.map_err(|err| { - HostError::Firecracker(format!("required file {}: {err}", path.display())) - })?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(HostError::Firecracker(format!( - "{} must be a regular non-symlink file", - path.display() - ))); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if metadata.permissions().mode() & 0o022 != 0 { - return Err(HostError::Firecracker(format!( - "{} must not be group- or world-writable", - path.display() - ))); - } - } - Ok(()) -} - -#[derive(Debug)] -struct RuntimePaths { - dir: PathBuf, - snapshot_state: PathBuf, - snapshot_mem: PathBuf, -} - -impl RuntimePaths { - fn new(root: &Path, record: &ShardRecord) -> Self { - let safe_class = execution_class_name(record.key.execution_class); - let safe_tenant = sanitize(&record.key.tenant_id); - let safe_shard = sanitize(&record.key.shard_id); - let dir = root - .join(safe_class) - .join(safe_tenant) - .join(safe_shard) - .join(record.runtime_epoch.to_string()); - Self { - snapshot_state: dir.join("vm.state"), - snapshot_mem: dir.join("vm.mem"), - dir, - } - } -} - -fn guest_boot_args(config: &HostConfig, record: &ShardRecord) -> String { - format!( - "{} bmscl.execution_class={} bmscl.execution_backend=firecracker bmscl.tenant_id={} bmscl.runtime_epoch={}", - config.boot_args, - execution_class_name(record.key.execution_class), - record.key.tenant_id, - record.runtime_epoch - ) -} - -fn execution_class_name(execution_class: ExecutionClass) -> &'static str { - match execution_class { - ExecutionClass::Faas => "faas", - ExecutionClass::Phoenix => "phoenix", - ExecutionClass::DurableActor => "durable_actor", - } -} - -fn cgroup_path(config: &HostConfig, record: &ShardRecord) -> PathBuf { - config - .cgroup_root - .join(execution_class_name(record.key.execution_class)) - .join(sanitize(&record.key.tenant_id)) - .join(sanitize(&record.key.shard_id)) - .join(record.runtime_epoch.to_string()) -} - -async fn prepare_cgroup( - config: &HostConfig, - record: &ShardRecord, - pid: u32, -) -> Result<(), HostError> { - let path = cgroup_path(config, record); - fs::create_dir_all(&path) - .await - .map_err(|err| HostError::Cgroup(format!("create {}: {err}", path.display())))?; - let memory_bytes = (record.policy.memory_mib as u64 + config.vmm_overhead_mib as u64) - .saturating_mul(1024 * 1024); - let cpu_quota = (record.policy.vcpu_count as u64).saturating_mul(100_000); - write_cgroup(&path, "memory.max", &memory_bytes.to_string()).await?; - write_cgroup(&path, "cpu.max", &format!("{cpu_quota} 100000")).await?; - write_cgroup(&path, "pids.max", "256").await?; - write_cgroup(&path, "cgroup.procs", &pid.to_string()).await?; - Ok(()) -} - -async fn publish_runtime_identity( - config: &HostConfig, - record: &ShardRecord, -) -> Result<(), HostError> { - let cgroup = cgroup_path(config, record); - let output = Command::new(&config.identity_writer_bin) - .arg("--cgroup-root") - .arg(&config.cgroup_root) - .arg("--cgroup-path") - .arg(&cgroup) - .arg("--identity-root") - .arg(&config.identity_root) - .arg("--user-id") - .arg(&record.user_id) - .arg("--tenant-id") - .arg(&record.key.tenant_id) - .arg("--deployment-id") - .arg(&record.root_deployment_id) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|error| { - HostError::Identity(format!( - "launch {}: {error}", - config.identity_writer_bin.display() - )) - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(HostError::Identity(format!( - "identity writer failed with {}: {}", - output.status, - stderr.trim() - ))); - } - Ok(()) -} - -async fn write_cgroup(path: &Path, name: &str, value: &str) -> Result<(), HostError> { - let file = path.join(name); - fs::write(&file, value) - .await - .map_err(|err| HostError::Cgroup(format!("write {}: {err}", file.display()))) -} - -async fn read_cgroup_metering( - config: &HostConfig, - record: &ShardRecord, -) -> Result<(u64, u64), HostError> { - let path = cgroup_path(config, record); - let cpu_stat = fs::read_to_string(path.join("cpu.stat")) - .await - .map_err(|err| HostError::Cgroup(format!("read cpu.stat: {err}")))?; - let usage_usec = cpu_stat - .lines() - .find_map(|line| { - let mut parts = line.split_whitespace(); - match (parts.next(), parts.next()) { - (Some("usage_usec"), Some(value)) => value.parse::().ok(), - _ => None, - } - }) - .ok_or_else(|| HostError::Cgroup("cpu.stat missing usage_usec".into()))?; - let memory_current = fs::read_to_string(path.join("memory.current")) - .await - .map_err(|err| HostError::Cgroup(format!("read memory.current: {err}")))? - .trim() - .parse::() - .map_err(|err| HostError::Cgroup(format!("parse memory.current: {err}")))?; - Ok((usage_usec.saturating_mul(1_000), memory_current)) -} - -fn sanitize(value: &str) -> String { - value - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect() -} - -fn snapshot_has_files(config: &HostConfig, record: &ShardRecord) -> bool { - let paths = RuntimePaths::new(&config.runtime_dir, record); - paths.snapshot_state.exists() && paths.snapshot_mem.exists() -} - -#[derive(Debug)] -struct RuntimeHandles { - pid: Option, - api_socket: Option, - vsock_path: Option, -} - -impl RuntimeHandles { - fn mock() -> Self { - Self { - pid: None, - api_socket: None, - vsock_path: None, - } - } -} - -#[derive(Debug, Clone)] -struct JailedRuntime { - pid: u32, - id: String, - instance_dir: PathBuf, - root: PathBuf, - api_socket: PathBuf, - vsock_path: PathBuf, - kernel: PathBuf, - rootfs: PathBuf, - snapshot_state: PathBuf, - snapshot_mem: PathBuf, - netns_name: String, - netns_path: PathBuf, -} - -impl JailedRuntime { - fn for_record(config: &HostConfig, record: &ShardRecord, pid: u32) -> Result { - let exec_name = config - .firecracker_bin - .file_name() - .and_then(|value| value.to_str()) - .ok_or_else(|| { - HostError::Firecracker("firecracker executable must have a UTF-8 file name".into()) - })?; - if !config.firecracker_bin.is_absolute() { - return Err(HostError::Firecracker( - "BMSCL_FIRECRACKER_BIN must be an absolute path when using the jailer".into(), - )); - } - if !config.jailer_bin.is_absolute() - || !config.jailer_chroot_base.is_absolute() - || !config.ip_bin.is_absolute() - || !config.netns_root.is_absolute() - { - return Err(HostError::Firecracker( - "jailer, ip, chroot base, and netns root must use absolute paths".into(), - )); - } - let id = jailer_id(record); - let instance_dir = config.jailer_chroot_base.join(exec_name).join(&id); - let root = instance_dir.join("root"); - let netns_name = id.clone(); - let netns_path = config.netns_root.join(&netns_name); - Ok(Self { - pid, - id, - api_socket: root.join("api.socket"), - vsock_path: root.join("control.vsock"), - kernel: root.join("vmlinux"), - rootfs: root.join("rootfs.ext4"), - snapshot_state: root.join("snapshot").join("vm.state"), - snapshot_mem: root.join("snapshot").join("vm.mem"), - netns_name, - netns_path, - instance_dir, - root, - }) - } -} - -fn jailer_id(record: &ShardRecord) -> String { - use sha2::{Digest, Sha256}; - let material = format!( - "{}\0{}\0{}\0{}", - execution_class_name(record.key.execution_class), - record.key.tenant_id, - record.key.shard_id, - record.runtime_epoch - ); - let digest = Sha256::digest(material.as_bytes()); - let short = digest[..16] - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - format!("bmscl-{short}") -} - -async fn spawn_firecracker( - config: &HostConfig, - record: &ShardRecord, -) -> Result { - require_file(&config.firecracker_bin).await?; - require_file(&config.jailer_bin).await?; - fs::create_dir_all(&config.jailer_chroot_base).await?; - let mut jailed = JailedRuntime::for_record(config, record, 0)?; - if jailed.instance_dir.exists() { - fs::remove_dir_all(&jailed.instance_dir).await?; - } - prepare_network_namespace(config, &jailed).await?; - let mut child = Command::new(&config.jailer_bin) - .arg("--id") - .arg(&jailed.id) - .arg("--exec-file") - .arg(&config.firecracker_bin) - .arg("--uid") - .arg(config.jailer_uid.to_string()) - .arg("--gid") - .arg(config.jailer_gid.to_string()) - .arg("--chroot-base-dir") - .arg(&config.jailer_chroot_base) - .arg("--netns") - .arg(&jailed.netns_path) - .arg("--cgroup-version") - .arg("2") - .arg("--new-pid-ns") - .arg("--") - .arg("--api-sock") - .arg("/api.socket") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|err| { - let _ = std::process::Command::new(&config.ip_bin) - .env("IP_NETNS_DIR", &config.netns_root) - .args(["netns", "delete", &jailed.netns_name]) - .status(); - HostError::Firecracker(format!( - "spawn jailer {}: {err}", - config.jailer_bin.display() - )) - })?; - let pid = child - .id() - .ok_or_else(|| HostError::Firecracker("jailer/firecracker process has no pid".into()))?; - jailed.pid = pid; - tokio::spawn(async move { - let _ = child.wait().await; - }); - Ok(jailed) -} - -async fn stage_guest_boot_files( - config: &HostConfig, - jailed: &JailedRuntime, -) -> Result<(), HostError> { - copy_file_readonly(&config.kernel_image, &jailed.kernel).await?; - copy_file_readonly(&config.rootfs_image, &jailed.rootfs).await?; - Ok(()) -} - -async fn stage_guest_restore_files( - config: &HostConfig, - runtime_paths: &RuntimePaths, - jailed: &JailedRuntime, -) -> Result<(), HostError> { - stage_guest_boot_files(config, jailed).await?; - prepare_jailer_writable_dir( - &jailed.root.join("snapshot"), - config.jailer_uid, - config.jailer_gid, - ) - .await?; - copy_file_readonly(&runtime_paths.snapshot_state, &jailed.snapshot_state).await?; - copy_file_readonly(&runtime_paths.snapshot_mem, &jailed.snapshot_mem).await?; - Ok(()) -} - -async fn prepare_jailer_writable_dir(path: &Path, uid: u32, gid: u32) -> Result<(), HostError> { - use std::{ - ffi::CString, - os::unix::{ffi::OsStrExt, fs::PermissionsExt}, - }; - - fs::create_dir_all(path).await?; - fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).await?; - let raw = CString::new(path.as_os_str().as_bytes()).map_err(|_| { - HostError::Firecracker(format!( - "jailer writable path contains an interior NUL: {}", - path.display() - )) - })?; - let result = unsafe { libc::chown(raw.as_ptr(), uid, gid) }; - if result != 0 { - return Err(HostError::Firecracker(format!( - "chown {} to {uid}:{gid}: {}", - path.display(), - std::io::Error::last_os_error() - ))); - } - Ok(()) -} - -async fn copy_file_readonly(source: &Path, destination: &Path) -> Result<(), HostError> { - use std::os::unix::fs::PermissionsExt; - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent).await?; - } - match fs::remove_file(destination).await { - Ok(()) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => return Err(err.into()), - } - fs::copy(source, destination).await?; - fs::set_permissions(destination, std::fs::Permissions::from_mode(0o444)).await?; - Ok(()) -} - -async fn cleanup_jail(config: &HostConfig, jailed: &JailedRuntime) -> Result<(), HostError> { - let jail_result = match fs::remove_dir_all(&jailed.instance_dir).await { - Ok(()) => Ok(()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(err) => Err(err.into()), - }; - let netns_result = delete_network_namespace(config, jailed).await; - jail_result.and(netns_result) -} - -async fn prepare_network_namespace( - config: &HostConfig, - jailed: &JailedRuntime, -) -> Result<(), HostError> { - let ip_bin = fs::canonicalize(&config.ip_bin).await.map_err(|err| { - HostError::Firecracker(format!( - "resolve ip binary {}: {err}", - config.ip_bin.display() - )) - })?; - require_file(&ip_bin).await?; - fs::create_dir_all(&config.netns_root).await?; - if jailed.netns_path.exists() { - delete_network_namespace(config, jailed).await?; - } - let status = Command::new(&ip_bin) - .env("IP_NETNS_DIR", &config.netns_root) - .args(["netns", "add", &jailed.netns_name]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .status() - .await - .map_err(|err| { - HostError::Firecracker(format!( - "create network namespace {}: {err}", - jailed.netns_name - )) - })?; - if status.success() { - Ok(()) - } else { - Err(HostError::Firecracker(format!( - "failed to create network namespace {}: {status}", - jailed.netns_name - ))) - } -} - -async fn delete_network_namespace( - config: &HostConfig, - jailed: &JailedRuntime, -) -> Result<(), HostError> { - if !jailed.netns_path.exists() { - return Ok(()); - } - let status = Command::new(&config.ip_bin) - .env("IP_NETNS_DIR", &config.netns_root) - .args(["netns", "delete", &jailed.netns_name]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .status() - .await - .map_err(|err| { - HostError::Firecracker(format!( - "delete network namespace {}: {err}", - jailed.netns_name - )) - })?; - if status.success() { - Ok(()) - } else { - Err(HostError::Firecracker(format!( - "failed to delete network namespace {}: {status}", - jailed.netns_name - ))) - } -} - -async fn terminate_pid(pid: u32) -> Result<(), HostError> { - let status = Command::new("kill") - .arg("-TERM") - .arg(pid.to_string()) - .status() - .await?; - if status.success() { - Ok(()) - } else { - Err(HostError::Firecracker(format!( - "failed to terminate firecracker pid {pid}: {status}" - ))) - } -} - -async fn wait_for_socket(path: &Path, timeout_ms: u64) -> Result<(), HostError> { - let deadline = Instant::now() + Duration::from_millis(timeout_ms); - loop { - if path.exists() { - return Ok(()); - } - if Instant::now() >= deadline { - return Err(HostError::Firecracker(format!( - "timed out waiting for API socket {}", - path.display() - ))); - } - sleep(Duration::from_millis(25)).await; - } -} - -#[derive(Debug, Clone)] -struct FcClient { - socket: PathBuf, -} - -impl FcClient { - fn new(socket: PathBuf) -> Self { - Self { socket } - } - - async fn put(&self, path: &str, body: Value) -> Result<(), HostError> { - self.request("PUT", path, body).await - } - - async fn patch(&self, path: &str, body: Value) -> Result<(), HostError> { - self.request("PATCH", path, body).await - } - - async fn request(&self, method: &str, path: &str, body: Value) -> Result<(), HostError> { - let body = - serde_json::to_vec(&body).map_err(|err| HostError::Firecracker(err.to_string()))?; - let mut stream = UnixStream::connect(&self.socket).await.map_err(|err| { - HostError::Firecracker(format!("connect {}: {err}", self.socket.display())) - })?; - let head = format!( - "{method} {path} HTTP/1.1\r\nHost: localhost\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - stream.write_all(head.as_bytes()).await?; - stream.write_all(&body).await?; - stream.shutdown().await?; - let mut response = Vec::new(); - stream.read_to_end(&mut response).await?; - let text = String::from_utf8_lossy(&response); - let status = text - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|value| value.parse::().ok()) - .ok_or_else(|| { - HostError::Firecracker(format!("malformed Firecracker response: {text}")) - })?; - if (200..300).contains(&status) { - Ok(()) - } else { - Err(HostError::Firecracker(format!( - "{method} {path} returned {status}: {text}" - ))) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn config() -> HostConfig { - HostConfig { - backend: BackendKind::Mock, - firecracker_bin: "/usr/local/bin/firecracker".into(), - jailer_bin: "/usr/local/bin/jailer".into(), - jailer_chroot_base: "/tmp/bmscl-jailer-test".into(), - jailer_uid: 65534, - jailer_gid: 65534, - ip_bin: "/run/current-system/sw/bin/ip".into(), - netns_root: "/var/run/netns".into(), - kernel_image: "kernel".into(), - rootfs_image: "rootfs".into(), - runtime_dir: "/tmp/bmscl-test".into(), - cgroup_root: "/tmp/bmscl-cgroup-test".into(), - identity_root: "/tmp/bmscl-identity-test".into(), - identity_writer_bin: "/usr/local/bin/bmscl-runtime-identity-writer".into(), - vmm_overhead_mib: 96, - boot_args: "console=ttyS0".into(), - api_socket_timeout_ms: 100, - } - } - - fn ensure(epoch: u64) -> EnsureShardRequest { - EnsureShardRequest { - user_id: "usr_test".into(), - tenant_id: "t1".into(), - shard_id: "0".into(), - execution_class: ExecutionClass::Phoenix, - execution_backend: ExecutionBackend::Firecracker, - tenant_isolation: "single_tenant_microvm".into(), - runtime_epoch: epoch, - root_deployment_id: "dep_test".into(), - deployment_digest: format!("sha256:{}", "a".repeat(64)), - policy: RuntimePolicy::default(), - } - } - - fn epoch(epoch: u64) -> EpochRequest { - EpochRequest { - tenant_id: "t1".into(), - shard_id: "0".into(), - execution_class: ExecutionClass::Phoenix, - execution_backend: ExecutionBackend::Firecracker, - runtime_epoch: epoch, - } - } - - #[test] - fn jailed_runtime_uses_dedicated_network_namespace() { - let request = ensure(17); - let record = ShardRecord::new( - ShardKey { - execution_class: request.execution_class, - tenant_id: request.tenant_id, - shard_id: request.shard_id, - }, - request.runtime_epoch, - request.deployment_digest, - request.user_id, - request.root_deployment_id, - request.policy, - ); - let jailed = JailedRuntime::for_record(&config(), &record, 0).unwrap(); - assert_eq!(jailed.netns_name, jailed.id); - assert_eq!( - jailed.netns_path, - PathBuf::from("/var/run/netns").join(&jailed.id) - ); - } - - #[test] - fn mock_backend_requires_explicit_opt_in() { - assert_eq!(select_backend("mock", false), BackendKind::Firecracker); - assert_eq!(select_backend("mock", true), BackendKind::Mock); - assert_eq!(select_backend("unexpected", true), BackendKind::Firecracker); - } - - #[test] - fn firecracker_host_rejects_standard_faas_and_bare_process_targets() { - assert!(matches!( - validate_execution_target( - ExecutionClass::Faas, - ExecutionBackend::Firecracker, - "single_tenant_microvm" - ), - Err(HostError::InvalidExecutionTarget(_)) - )); - assert!(matches!( - validate_execution_target( - ExecutionClass::Phoenix, - ExecutionBackend::BareProcess, - "single_tenant_microvm" - ), - Err(HostError::InvalidExecutionTarget(_)) - )); - } - - #[test] - fn firecracker_host_requires_single_tenant_microvm() { - assert!(validate_execution_target( - ExecutionClass::Phoenix, - ExecutionBackend::Firecracker, - "single_tenant_microvm" - ) - .is_ok()); - assert!(validate_execution_target( - ExecutionClass::DurableActor, - ExecutionBackend::Firecracker, - "single_tenant_microvm" - ) - .is_ok()); - assert!(validate_execution_target( - ExecutionClass::Phoenix, - ExecutionBackend::Firecracker, - "shared_host_restricted_process" - ) - .is_err()); - } - - #[test] - fn lifecycle_mutations_revalidate_backend() { - assert!( - validate_execution_request(ExecutionClass::Phoenix, ExecutionBackend::Firecracker) - .is_ok() - ); - assert!(validate_execution_request( - ExecutionClass::DurableActor, - ExecutionBackend::Firecracker - ) - .is_ok()); - assert!( - validate_execution_request(ExecutionClass::Phoenix, ExecutionBackend::BareProcess) - .is_err() - ); - assert!( - validate_execution_request(ExecutionClass::Faas, ExecutionBackend::Firecracker) - .is_err() - ); - } - - #[test] - fn guest_boot_args_bind_class_tenant_backend_and_epoch() { - let request = ensure(9); - let record = ShardRecord::new( - ShardKey { - execution_class: request.execution_class, - tenant_id: request.tenant_id, - shard_id: request.shard_id, - }, - request.runtime_epoch, - request.deployment_digest, - request.user_id, - request.root_deployment_id, - request.policy, - ); - let args = guest_boot_args(&config(), &record); - assert!(args.contains("bmscl.execution_class=phoenix")); - assert!(args.contains("bmscl.execution_backend=firecracker")); - assert!(args.contains("bmscl.tenant_id=t1")); - assert!(args.contains("bmscl.runtime_epoch=9")); - } - - #[test] - fn jailer_ids_are_tenant_epoch_scoped_and_path_safe() { - let request = ensure(7); - let record = ShardRecord::new( - ShardKey { - execution_class: request.execution_class, - tenant_id: request.tenant_id, - shard_id: request.shard_id, - }, - request.runtime_epoch, - request.deployment_digest, - request.user_id, - request.root_deployment_id, - request.policy, - ); - let id = jailer_id(&record); - assert!(id.starts_with("bmscl-")); - assert!(id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')); - let jailed = JailedRuntime::for_record(&config(), &record, 123).unwrap(); - assert_eq!(jailed.api_socket, jailed.root.join("api.socket")); - assert_eq!(jailed.vsock_path, jailed.root.join("control.vsock")); - assert!(jailed.root.starts_with(&config().jailer_chroot_base)); - } - - #[test] - fn runtime_resource_ids_are_path_safe_and_collision_free() { - for value in ["tenant_1", "shard-01", "A9"] { - assert!(validate_resource_id("id", value).is_ok()); - } - for value in [ - "", - "../tenant", - "tenant/name", - "tenant.name", - &"a".repeat(129), - ] { - assert!(validate_resource_id("id", value).is_err()); - } - } - - #[tokio::test] - async fn ensure_and_start_are_idempotent() { - let host = RuntimeHost::new(config()); - let status = host.ensure(ensure(1)).await.unwrap(); - assert_eq!(status.state, LifecycleState::Cold); - assert_eq!( - host.start(epoch(1)).await.unwrap().state, - LifecycleState::Hot - ); - assert_eq!( - host.start(epoch(1)).await.unwrap().state, - LifecycleState::Hot - ); - } - - #[tokio::test] - async fn stale_epoch_is_rejected() { - let host = RuntimeHost::new(config()); - host.ensure(ensure(2)).await.unwrap(); - let err = host.start(epoch(1)).await.unwrap_err(); - assert!(matches!(err, HostError::StaleEpoch { .. })); - } - - #[tokio::test] - async fn hibernate_and_restore_preserve_epoch() { - let host = RuntimeHost::new(config()); - host.ensure(ensure(3)).await.unwrap(); - host.start(epoch(3)).await.unwrap(); - host.mark_warm_idle(epoch(3)).await.unwrap(); - assert_eq!( - host.hibernate(epoch(3)).await.unwrap().state, - LifecycleState::Hibernated - ); - let restored = host.start(epoch(3)).await.unwrap(); - assert_eq!(restored.runtime_epoch, 3); - assert_eq!(restored.state, LifecycleState::Hot); - } - - #[tokio::test] - async fn cannot_replace_live_epoch() { - let host = RuntimeHost::new(config()); - host.ensure(ensure(1)).await.unwrap(); - host.start(epoch(1)).await.unwrap(); - assert!(matches!( - host.ensure(ensure(2)).await.unwrap_err(), - HostError::EpochReplacementConflict - )); - } - - #[tokio::test] - async fn touch_attributes_requests_and_network_bytes() { - let host = RuntimeHost::new(config()); - host.ensure(ensure(1)).await.unwrap(); - host.start(epoch(1)).await.unwrap(); - host.touch(TouchRequest { - tenant_id: "t1".into(), - shard_id: "0".into(), - execution_class: ExecutionClass::Phoenix, - execution_backend: ExecutionBackend::Firecracker, - runtime_epoch: 1, - active_delta: 2, - ingress_bytes: 100, - egress_bytes: 250, - }) - .await - .unwrap(); - let sample = host - .metering(ExecutionClass::Phoenix, "t1", "0") - .await - .unwrap(); - assert_eq!(sample.request_count, 2); - assert_eq!(sample.ingress_bytes, 100); - assert_eq!(sample.egress_bytes, 250); - } -} diff --git a/cert/beamscale-critical-section/infra-runtime-host/src/main.rs b/cert/beamscale-critical-section/infra-runtime-host/src/main.rs deleted file mode 100644 index 9710df3..0000000 --- a/cert/beamscale-critical-section/infra-runtime-host/src/main.rs +++ /dev/null @@ -1,1756 +0,0 @@ -mod artifact_store; -mod runtime_auth; - -use artifact_store::{AdmittedArtifact, ArtifactStore}; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - routing::{get, post}, - Json, Router, -}; -use bmscl_runtime_host::{ - EnsureShardRequest, EpochRequest, ExecutionBackend, ExecutionClass, HostConfig, HostError, - LifecycleState, RuntimeHost, ShardStatus, TouchRequest, -}; -use runtime_auth::{ExpectedRuntimeRequest, RuntimeContract, SignedRequest}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::{ - collections::hash_map::DefaultHasher, - collections::{BTreeSet, HashMap, HashSet}, - env, - hash::{Hash, Hasher}, - io, - path::Path as FsPath, - sync::Arc, -}; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::{TcpListener, UnixStream}, - sync::{Mutex, RwLock}, - time::{sleep, timeout, Duration}, -}; - -const MAX_CAPABILITY_REFS: usize = 64; -const MAX_CAPABILITY_NAME_BYTES: usize = 128; -const MAX_CAPABILITY_TOKEN_REF_BYTES: usize = 256; -const MAX_ACTIVATION_TIMEOUT_MS: u64 = 60_000; -const SHARD_GATE_STRIPES: usize = 256; - -#[derive(Clone)] -struct AppState { - host: Arc, - artifact_store: Arc, - guest_vsock_port: u32, - guest_max_frame_bytes: usize, - guest_max_artifact_chunk_bytes: usize, - lifecycle_barrier: Arc>, - shard_gates: Arc>>, - observed_activation: Arc>>, - runtime_control_secret: Arc>, - consumed_nonces: Arc>>, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -struct ShardEpochKey { - execution_class: ExecutionClass, - tenant_id: String, - shard_id: String, - runtime_epoch: u64, -} - -#[derive(Debug, Clone, Default)] -struct ObservedActivation { - root_active_deployment_id: Option, - loaded_deployment_ids: BTreeSet, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -struct CapabilityRef { - name: String, - token_ref: String, -} - -#[derive(Debug, Deserialize, Serialize)] -struct InvokeRequest { - tenant_id: String, - shard_id: String, - execution_class: ExecutionClass, - execution_backend: ExecutionBackend, - runtime_epoch: u64, - invocation_id: String, - deployment_id: String, - request: Value, - context: Value, - #[serde(default)] - capability_refs: Vec, - timeout_ms: u64, -} - -const MAX_CRITICAL_SECTION_SEQUENCE: u64 = 9_007_199_254_740_991; - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -enum CriticalSectionOperation { - Acquire, - Renew, - Release, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -struct CriticalSectionToken { - runtime_epoch: u64, - owner_epoch: u64, - sequence: u64, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -struct CriticalSectionRequest { - tenant_id: String, - shard_id: String, - execution_class: ExecutionClass, - execution_backend: ExecutionBackend, - runtime_epoch: u64, - deployment_id: String, - operation: CriticalSectionOperation, - namespace: String, - object_key: String, - holder: String, - #[serde(default)] - request_id: Option, - #[serde(default)] - lease_ms: Option, - #[serde(default)] - token: Option, - #[serde(default = "default_critical_section_timeout_ms")] - timeout_ms: u64, -} - -#[derive(Debug, Deserialize, Serialize)] -struct ActivateRequest { - tenant_id: String, - shard_id: String, - execution_class: ExecutionClass, - execution_backend: ExecutionBackend, - runtime_epoch: u64, - deployment_id: String, - #[serde(default = "default_activation_timeout_ms")] - timeout_ms: u64, -} - -trait RuntimeRequestIdentity { - fn tenant_id(&self) -> &str; - fn shard_id(&self) -> &str; - fn execution_class(&self) -> ExecutionClass; - fn execution_backend(&self) -> ExecutionBackend; - fn runtime_epoch(&self) -> u64; -} - -macro_rules! impl_runtime_request_identity { - ($type:ty) => { - impl RuntimeRequestIdentity for $type { - fn tenant_id(&self) -> &str { - &self.tenant_id - } - - fn shard_id(&self) -> &str { - &self.shard_id - } - - fn execution_class(&self) -> ExecutionClass { - self.execution_class - } - - fn execution_backend(&self) -> ExecutionBackend { - self.execution_backend - } - - fn runtime_epoch(&self) -> u64 { - self.runtime_epoch - } - } - }; -} - -impl_runtime_request_identity!(EnsureShardRequest); -impl_runtime_request_identity!(EpochRequest); -impl_runtime_request_identity!(TouchRequest); -impl_runtime_request_identity!(ActivateRequest); -impl_runtime_request_identity!(InvokeRequest); -impl_runtime_request_identity!(CriticalSectionRequest); - -#[derive(Debug, Serialize)] -struct ActivationStatus { - tenant_id: String, - shard_id: String, - execution_class: ExecutionClass, - execution_backend: ExecutionBackend, - runtime_epoch: u64, - placement_deployment_id: String, - observed: bool, - root_active_deployment_id: Option, - loaded_deployment_ids: Vec, -} - -#[derive(Debug, Serialize)] -struct ErrorBody { - error: String, -} - -type ApiError = (StatusCode, Json); - -#[tokio::main] -async fn main() { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let host = Arc::new(RuntimeHost::new(HostConfig::from_env())); - let runtime_control_secret = - Arc::new(runtime_auth::load_secret().expect("load BMSCL_RUNTIME_CONTROL_SECRET")); - let guest_vsock_port = env::var("BMSCL_GUEST_VSOCK_PORT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(5000); - let guest_max_frame_bytes = env::var("BMSCL_GUEST_MAX_FRAME_BYTES") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(16 * 1024 * 1024); - let guest_max_artifact_chunk_bytes = env::var("BMSCL_GUEST_MAX_ARTIFACT_CHUNK_BYTES") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(1024 * 1024) - .min(guest_max_frame_bytes); - let artifact_root = env::var("BMSCL_RUNTIME_ARTIFACT_ROOT") - .unwrap_or_else(|_| "/var/lib/beamscale/artifacts".into()); - let max_artifact_bytes = env::var("BMSCL_MAX_ARTIFACT_BYTES") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(64 * 1024 * 1024); - let state = AppState { - host, - artifact_store: Arc::new(ArtifactStore::new(artifact_root, max_artifact_bytes)), - guest_vsock_port, - guest_max_frame_bytes, - guest_max_artifact_chunk_bytes, - lifecycle_barrier: Arc::new(RwLock::new(())), - shard_gates: Arc::new((0..SHARD_GATE_STRIPES).map(|_| Mutex::new(())).collect()), - observed_activation: Arc::new(RwLock::new(HashMap::new())), - runtime_control_secret, - consumed_nonces: Arc::new(Mutex::new(HashMap::new())), - }; - - let sweeper_state = state.clone(); - tokio::spawn(async move { - loop { - sleep(Duration::from_secs(5)).await; - let _barrier = sweeper_state.lifecycle_barrier.write().await; - sweeper_state.host.sweep_once().await; - } - }); - - let app = Router::new() - .route("/healthz", get(|| async { "ok" })) - .route("/v1/shards/ensure", post(ensure)) - .route("/v1/shards/start", post(start)) - .route("/v1/shards/activate", post(activate)) - .route("/v1/shards/touch", post(touch)) - .route("/v1/shards/invoke", post(invoke)) - .route("/v1/shards/critical-section", post(critical_section)) - .route("/v1/shards/warm-idle", post(warm_idle)) - .route("/v1/shards/hibernate", post(hibernate)) - .route("/v1/shards/terminate", post(terminate)) - .route( - "/v1/shards/{execution_class}/{tenant_id}/{shard_id}/activation", - get(get_activation), - ) - .route( - "/v1/shards/{execution_class}/{tenant_id}/{shard_id}/metering", - get(get_metering), - ) - .route( - "/v1/shards/{execution_class}/{tenant_id}/{shard_id}", - get(get_shard), - ) - .with_state(state); - - let bind = env::var("BMSCL_RUNTIME_HOST_BIND").unwrap_or_else(|_| "127.0.0.1:9090".into()); - let listener = TcpListener::bind(&bind).await.expect("bind runtime host"); - tracing::info!(%bind, "BeamScale runtime host listening"); - axum::serve(listener, app) - .await - .expect("serve runtime host"); -} - -async fn ensure( - State(state): State, - Json(signed): Json>, -) -> Result { - let SignedRequest { - contract, - request: req, - } = signed; - authorize_runtime_request(&state, "ensure", &req, &contract).await?; - validate_shard_identity(&req.tenant_id, &req.shard_id)?; - let requested_digest = normalize_build_digest(&req.deployment_digest) - .map_err(|message| api_error(StatusCode::BAD_REQUEST, message))?; - let execution_class = req.execution_class; - let tenant_id = req.tenant_id.clone(); - let shard_id = req.shard_id.clone(); - let runtime_epoch = req.runtime_epoch; - - let _barrier = state.lifecycle_barrier.read().await; - let gate_index = shard_gate_index(execution_class, &tenant_id, &shard_id); - let _gate = state.shard_gates[gate_index].lock().await; - - let existing = match state.host.get(execution_class, &tenant_id, &shard_id).await { - Ok(status) => Some(status), - Err(HostError::UnknownShard) => None, - Err(err) => return Err(map_err(err)), - }; - let mut clear_observed = existing.is_none(); - if let Some(current) = existing.as_ref() { - let current_digest = normalize_build_digest(¤t.deployment_digest).map_err(|_| { - api_error( - StatusCode::INTERNAL_SERVER_ERROR, - "runtime host contains a non-canonical deployment digest", - ) - })?; - let digest_changed = requested_digest != current_digest; - if current.runtime_epoch == runtime_epoch - && digest_changed - && !matches!( - current.state, - LifecycleState::Cold | LifecycleState::Terminated - ) - { - return Err(api_error( - StatusCode::CONFLICT, - "live same-epoch deployment changes require /v1/shards/activate", - )); - } - clear_observed = current.runtime_epoch != runtime_epoch || digest_changed; - } - - let status = state.host.ensure(req).await.map_err(map_err)?; - if clear_observed { - clear_observed_for_shard(&state, execution_class, &tenant_id, &shard_id).await; - } - Ok(Json(status)) -} - -async fn start( - State(state): State, - Json(signed): Json>, -) -> Result { - let SignedRequest { - contract, - request: req, - } = signed; - authorize_runtime_request(&state, "start", &req, &contract).await?; - validate_shard_identity(&req.tenant_id, &req.shard_id)?; - let _barrier = state.lifecycle_barrier.read().await; - let gate_index = shard_gate_index(req.execution_class, &req.tenant_id, &req.shard_id); - let _gate = state.shard_gates[gate_index].lock().await; - state.host.start(req).await.map(Json).map_err(map_err) -} - -async fn activate( - State(state): State, - Json(signed): Json>, -) -> Result { - let SignedRequest { - contract, - request: req, - } = signed; - authorize_runtime_request(&state, "activate", &req, &contract).await?; - validate_shard_identity(&req.tenant_id, &req.shard_id)?; - validate_firecracker_target(req.execution_class, req.execution_backend)?; - if req.timeout_ms == 0 || req.timeout_ms > MAX_ACTIVATION_TIMEOUT_MS { - return Err(api_error( - StatusCode::BAD_REQUEST, - format!("timeout_ms must be 1..={MAX_ACTIVATION_TIMEOUT_MS}"), - )); - } - let deployment_id = normalize_build_digest(&req.deployment_id) - .map_err(|message| api_error(StatusCode::BAD_REQUEST, message))?; - - let preliminary = state - .host - .get(req.execution_class, &req.tenant_id, &req.shard_id) - .await - .map_err(map_err)?; - check_runtime_epoch(&preliminary, req.runtime_epoch)?; - let admitted_artifact = if preliminary.backend != "mock" { - Some( - state - .artifact_store - .resolve_admitted_bundle(&deployment_id) - .await - .map_err(|err| { - api_error( - StatusCode::SERVICE_UNAVAILABLE, - format!("admitted artifact preflight failed for {deployment_id}: {err}"), - ) - })?, - ) - } else { - None - }; - - let envelope = json!({ - "op": "activate_artifact", - "deployment_id": &deployment_id, - "execution_class": req.execution_class, - "execution_backend": req.execution_backend, - "tenant_id": &req.tenant_id, - "runtime_epoch": req.runtime_epoch - }); - let request_bytes = serde_json::to_vec(&envelope) - .map_err(|err| api_error(StatusCode::BAD_REQUEST, err.to_string()))?; - if request_bytes.len() > state.guest_max_frame_bytes { - return Err(api_error( - StatusCode::PAYLOAD_TOO_LARGE, - "activation frame too large", - )); - } - - let _barrier = state.lifecycle_barrier.read().await; - let gate_index = shard_gate_index(req.execution_class, &req.tenant_id, &req.shard_id); - let _gate = state.shard_gates[gate_index].lock().await; - let shard = state - .host - .get(req.execution_class, &req.tenant_id, &req.shard_id) - .await - .map_err(map_err)?; - check_runtime_epoch(&shard, req.runtime_epoch)?; - check_execution_target(&shard, req.execution_class, req.execution_backend)?; - if !matches!(shard.state, LifecycleState::Hot | LifecycleState::WarmIdle) { - return Err(api_error( - StatusCode::CONFLICT, - format!("shard is not activatable: {:?}", shard.state), - )); - } - - let response_bytes = if shard.backend == "mock" { - serde_json::to_vec(&json!({ - "op": "activate_artifact_result", - "ok": true, - "deployment_id": &deployment_id, - "active_deployment_id": &deployment_id - })) - .map_err(|err| api_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))? - } else { - let vsock_path = shard.vsock_path.as_deref().ok_or_else(|| { - api_error( - StatusCode::SERVICE_UNAVAILABLE, - "runnable shard is missing vsock path", - ) - })?; - let artifact = admitted_artifact.as_ref().ok_or_else(|| { - api_error( - StatusCode::INTERNAL_SERVER_ERROR, - "non-mock activation is missing admitted artifact bytes", - ) - })?; - put_artifact_over_vsock( - FsPath::new(vsock_path), - state.guest_vsock_port, - &shard, - artifact, - req.timeout_ms, - state.guest_max_frame_bytes, - state.guest_max_artifact_chunk_bytes, - ) - .await - .map_err(|message| api_error(StatusCode::SERVICE_UNAVAILABLE, message))?; - invoke_over_vsock( - FsPath::new(vsock_path), - state.guest_vsock_port, - &request_bytes, - req.timeout_ms, - state.guest_max_frame_bytes, - ) - .await - .map_err(|message| api_error(StatusCode::SERVICE_UNAVAILABLE, message))? - }; - validate_activation_response(&response_bytes, &deployment_id) - .map_err(|message| api_error(StatusCode::BAD_GATEWAY, message))?; - - let key = shard_epoch_key( - req.execution_class, - &req.tenant_id, - &req.shard_id, - req.runtime_epoch, - ); - let mut observed = state.observed_activation.write().await; - let activation = observed.entry(key).or_default(); - if let Ok(bootstrap) = normalize_build_digest(&shard.deployment_digest) { - activation.loaded_deployment_ids.insert(bootstrap); - } - activation - .loaded_deployment_ids - .insert(deployment_id.clone()); - activation.root_active_deployment_id = Some(deployment_id); - Ok(Json(activation_status(&shard, Some(activation)))) -} - -async fn touch( - State(state): State, - Json(signed): Json>, -) -> Result { - let SignedRequest { - contract, - request: req, - } = signed; - authorize_runtime_request(&state, "touch", &req, &contract).await?; - validate_shard_identity(&req.tenant_id, &req.shard_id)?; - if !(-1..=1).contains(&req.active_delta) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "active_delta must be -1, 0, or 1", - )); - } - let _barrier = state.lifecycle_barrier.read().await; - let gate_index = shard_gate_index(req.execution_class, &req.tenant_id, &req.shard_id); - let _gate = state.shard_gates[gate_index].lock().await; - state.host.touch(req).await.map(Json).map_err(map_err) -} - -async fn critical_section( - State(state): State, - Json(signed): Json>, -) -> Result { - let SignedRequest { - contract, - request: req, - } = signed; - authorize_runtime_request(&state, "critical-section", &req, &contract).await?; - validate_shard_identity(&req.tenant_id, &req.shard_id)?; - validate_firecracker_target(req.execution_class, req.execution_backend)?; - if req.execution_class != ExecutionClass::DurableActor { - return Err(api_error( - StatusCode::BAD_REQUEST, - "critical-section operations require execution_class=durable_actor", - )); - } - validate_critical_section_request(&req)?; - let deployment_id = normalize_build_digest(&req.deployment_id) - .map_err(|message| api_error(StatusCode::BAD_REQUEST, message))?; - let envelope = critical_section_envelope(&req); - let request_bytes = serde_json::to_vec(&envelope) - .map_err(|err| api_error(StatusCode::BAD_REQUEST, err.to_string()))?; - if request_bytes.len() > state.guest_max_frame_bytes { - return Err(api_error( - StatusCode::PAYLOAD_TOO_LARGE, - "critical-section frame too large", - )); - } - - let (backend, vsock_path) = { - let _barrier = state.lifecycle_barrier.read().await; - let gate_index = shard_gate_index(req.execution_class, &req.tenant_id, &req.shard_id); - let _gate = state.shard_gates[gate_index].lock().await; - let shard = state - .host - .get(req.execution_class, &req.tenant_id, &req.shard_id) - .await - .map_err(map_err)?; - check_runtime_epoch(&shard, req.runtime_epoch)?; - check_execution_target(&shard, req.execution_class, req.execution_backend)?; - if !matches!(shard.state, LifecycleState::Hot | LifecycleState::WarmIdle) { - return Err(api_error( - StatusCode::CONFLICT, - format!("shard is not runnable: {:?}", shard.state), - )); - } - if !deployment_allowed(&state, &shard, &deployment_id).await? { - return Err(api_error( - StatusCode::CONFLICT, - "critical-section deployment digest is not loaded for this shard epoch", - )); - } - state - .host - .touch(TouchRequest { - tenant_id: req.tenant_id.clone(), - shard_id: req.shard_id.clone(), - execution_class: req.execution_class, - execution_backend: req.execution_backend, - runtime_epoch: req.runtime_epoch, - active_delta: 1, - ingress_bytes: request_bytes.len() as u64, - egress_bytes: 0, - }) - .await - .map_err(map_err)?; - (shard.backend, shard.vsock_path) - }; - - let transport_result: Result, ApiError> = async { - if backend == "mock" { - let response = match req.operation { - CriticalSectionOperation::Release => json!({ - "op": "critical_section_result", - "operation": "release", - "ok": true - }), - CriticalSectionOperation::Acquire => json!({ - "op": "critical_section_result", - "operation": "acquire", - "ok": true, - "token": { - "runtime_epoch": req.runtime_epoch, - "owner_epoch": req.runtime_epoch, - "sequence": 1 - }, - "expires_at_ms": 1 - }), - CriticalSectionOperation::Renew => json!({ - "op": "critical_section_result", - "operation": "renew", - "ok": true, - "token": req.token, - "expires_at_ms": 1 - }), - }; - serde_json::to_vec(&response) - .map_err(|err| api_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) - } else { - let path = vsock_path.as_deref().ok_or_else(|| { - api_error( - StatusCode::SERVICE_UNAVAILABLE, - "runnable durable shard is missing vsock path", - ) - })?; - invoke_over_vsock( - FsPath::new(path), - state.guest_vsock_port, - &request_bytes, - req.timeout_ms, - state.guest_max_frame_bytes, - ) - .await - .map_err(|message| api_error(StatusCode::SERVICE_UNAVAILABLE, message)) - } - } - .await; - - let response_len = transport_result - .as_ref() - .map(|bytes| bytes.len()) - .unwrap_or(0); - let cleanup = state - .host - .touch(TouchRequest { - tenant_id: req.tenant_id.clone(), - shard_id: req.shard_id.clone(), - execution_class: req.execution_class, - execution_backend: req.execution_backend, - runtime_epoch: req.runtime_epoch, - active_delta: -1, - ingress_bytes: 0, - egress_bytes: response_len as u64, - }) - .await; - if let Err(err) = cleanup { - return Err(map_err(err)); - } - - let response_bytes = transport_result?; - let response = validate_critical_section_response(&response_bytes, req.operation) - .map_err(|message| api_error(StatusCode::BAD_GATEWAY, message))?; - Ok(Json(response)) -} - -async fn invoke( - State(state): State, - Json(signed): Json>, -) -> Result { - let SignedRequest { - contract, - request: req, - } = signed; - authorize_runtime_request(&state, "invoke", &req, &contract).await?; - validate_shard_identity(&req.tenant_id, &req.shard_id)?; - validate_firecracker_target(req.execution_class, req.execution_backend)?; - if req.invocation_id.is_empty() || req.invocation_id.len() > 128 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "invocation_id must be 1..=128 bytes", - )); - } - if req.timeout_ms == 0 || req.timeout_ms > 300_000 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "timeout_ms must be 1..=300000", - )); - } - if !req.request.is_object() || !req.context.is_object() { - return Err(api_error( - StatusCode::BAD_REQUEST, - "request and context must be JSON objects", - )); - } - validate_capability_refs(&req.capability_refs) - .map_err(|message| api_error(StatusCode::BAD_REQUEST, message))?; - let deployment_id = normalize_build_digest(&req.deployment_id) - .map_err(|message| api_error(StatusCode::BAD_REQUEST, message))?; - let envelope = guest_invocation_envelope(&req, &deployment_id); - let request_bytes = serde_json::to_vec(&envelope) - .map_err(|err| api_error(StatusCode::BAD_REQUEST, err.to_string()))?; - if request_bytes.len() > state.guest_max_frame_bytes { - return Err(api_error( - StatusCode::PAYLOAD_TOO_LARGE, - "invocation frame too large", - )); - } - - let (backend, vsock_path) = { - let _barrier = state.lifecycle_barrier.read().await; - let gate_index = shard_gate_index(req.execution_class, &req.tenant_id, &req.shard_id); - let _gate = state.shard_gates[gate_index].lock().await; - let shard = state - .host - .get(req.execution_class, &req.tenant_id, &req.shard_id) - .await - .map_err(map_err)?; - check_runtime_epoch(&shard, req.runtime_epoch)?; - check_execution_target(&shard, req.execution_class, req.execution_backend)?; - if !matches!(shard.state, LifecycleState::Hot | LifecycleState::WarmIdle) { - return Err(api_error( - StatusCode::CONFLICT, - format!("shard is not runnable: {:?}", shard.state), - )); - } - if !deployment_allowed(&state, &shard, &deployment_id).await? { - return Err(api_error( - StatusCode::CONFLICT, - "deployment digest is not observed as loaded for this shard epoch", - )); - } - state - .host - .touch(TouchRequest { - tenant_id: req.tenant_id.clone(), - shard_id: req.shard_id.clone(), - execution_class: req.execution_class, - execution_backend: req.execution_backend, - runtime_epoch: req.runtime_epoch, - active_delta: 1, - ingress_bytes: request_bytes.len() as u64, - egress_bytes: 0, - }) - .await - .map_err(map_err)?; - (shard.backend, shard.vsock_path) - }; - - let transport_result: Result, ApiError> = async { - if backend != "mock" { - state - .artifact_store - .resolve(&deployment_id) - .await - .map_err(|err| { - api_error( - StatusCode::SERVICE_UNAVAILABLE, - format!("artifact preflight failed for {deployment_id}: {err}"), - ) - })?; - } - if backend == "mock" { - serde_json::to_vec(&json!({ - "invocation_id": envelope["invocation_id"], - "ok": true, - "payload_encoding": "mock", - "payload_etf_base64": null - })) - .map_err(|err| api_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) - } else { - let path = vsock_path.as_deref().ok_or_else(|| { - api_error( - StatusCode::SERVICE_UNAVAILABLE, - "runnable shard is missing vsock path", - ) - })?; - invoke_over_vsock( - FsPath::new(path), - state.guest_vsock_port, - &request_bytes, - req.timeout_ms, - state.guest_max_frame_bytes, - ) - .await - .map_err(|message| api_error(StatusCode::SERVICE_UNAVAILABLE, message)) - } - } - .await; - - let response_len = transport_result - .as_ref() - .map(|bytes| bytes.len()) - .unwrap_or(0); - let cleanup = state - .host - .touch(TouchRequest { - tenant_id: req.tenant_id, - shard_id: req.shard_id, - execution_class: req.execution_class, - execution_backend: req.execution_backend, - runtime_epoch: req.runtime_epoch, - active_delta: -1, - ingress_bytes: 0, - egress_bytes: response_len as u64, - }) - .await; - if let Err(err) = cleanup { - return Err(map_err(err)); - } - - let response_bytes = transport_result?; - let response: Value = serde_json::from_slice(&response_bytes).map_err(|err| { - api_error( - StatusCode::BAD_GATEWAY, - format!("invalid guest response: {err}"), - ) - })?; - Ok(Json(response)) -} - -fn validate_capability_refs(refs: &[CapabilityRef]) -> Result<(), &'static str> { - if refs.len() > MAX_CAPABILITY_REFS { - return Err("capability_refs may contain at most 64 entries"); - } - - let mut names = HashSet::with_capacity(refs.len()); - for capability in refs { - if !valid_capability_name(&capability.name) { - return Err( - "capability_refs names must match ctx.[a-zA-Z0-9._-]+ and be at most 128 bytes", - ); - } - let token_ref_len = capability.token_ref.len(); - if token_ref_len == 0 || token_ref_len > MAX_CAPABILITY_TOKEN_REF_BYTES { - return Err("capability_refs token_ref must be 1..=256 bytes"); - } - if !names.insert(capability.name.as_str()) { - return Err("capability_refs names must be unique"); - } - } - Ok(()) -} - -fn valid_capability_name(name: &str) -> bool { - if name.len() <= 4 || name.len() > MAX_CAPABILITY_NAME_BYTES || !name.starts_with("ctx.") { - return false; - } - name.bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) -} - -fn validate_critical_section_request(req: &CriticalSectionRequest) -> Result<(), ApiError> { - if req.timeout_ms == 0 || req.timeout_ms > 60_000 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "timeout_ms must be 1..=60000", - )); - } - if !valid_runtime_identifier(&req.namespace) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "namespace must use 1..=128 ASCII letters, digits, '.', '_' or '-'", - )); - } - if req.object_key.is_empty() || req.object_key.len() > 4096 || req.object_key.contains('\0') { - return Err(api_error( - StatusCode::BAD_REQUEST, - "object_key must be 1..=4096 bytes and contain no NUL", - )); - } - if req.holder.is_empty() || req.holder.len() > 256 || req.holder.contains('\0') { - return Err(api_error( - StatusCode::BAD_REQUEST, - "holder must be 1..=256 bytes and contain no NUL", - )); - } - let lease_ok = req - .lease_ms - .is_some_and(|lease| (1..=300_000).contains(&lease)); - let request_id_ok = req.request_id.as_deref().is_some_and(|request_id| { - !request_id.is_empty() && request_id.len() <= 256 && !request_id.contains('\0') - }); - let token_ok = req.token.as_ref().is_some_and(|token| { - token.runtime_epoch > 0 - && token.owner_epoch > 0 - && token.sequence > 0 - && token.sequence <= MAX_CRITICAL_SECTION_SEQUENCE - }); - let valid = match req.operation { - CriticalSectionOperation::Acquire => lease_ok && request_id_ok && req.token.is_none(), - CriticalSectionOperation::Renew => lease_ok && req.request_id.is_none() && token_ok, - CriticalSectionOperation::Release => { - req.lease_ms.is_none() && req.request_id.is_none() && token_ok - } - }; - if valid { - Ok(()) - } else { - Err(api_error( - StatusCode::BAD_REQUEST, - "invalid acquire/renew/release arguments", - )) - } -} - -fn critical_section_envelope(req: &CriticalSectionRequest) -> Value { - json!({ - "op": "critical_section", - "operation": req.operation, - "execution_class": req.execution_class, - "execution_backend": req.execution_backend, - "tenant_id": &req.tenant_id, - "namespace": &req.namespace, - "object_key": &req.object_key, - "runtime_epoch": req.runtime_epoch, - "holder": &req.holder, - "request_id": req.request_id, - "lease_ms": req.lease_ms, - "token": req.token - }) -} - -fn validate_critical_section_response( - bytes: &[u8], - expected_operation: CriticalSectionOperation, -) -> Result { - let response: Value = serde_json::from_slice(bytes) - .map_err(|err| format!("invalid guest critical-section response: {err}"))?; - if response.get("op").and_then(Value::as_str) != Some("critical_section_result") { - return Err("guest critical-section response has wrong operation type".into()); - } - let expected = match expected_operation { - CriticalSectionOperation::Acquire => "acquire", - CriticalSectionOperation::Renew => "renew", - CriticalSectionOperation::Release => "release", - }; - if response.get("operation").and_then(Value::as_str) != Some(expected) { - return Err("guest critical-section response operation does not match request".into()); - } - if response.get("ok").and_then(Value::as_bool).is_none() { - return Err("guest critical-section response is missing ok".into()); - } - Ok(response) -} - -fn guest_invocation_envelope(req: &InvokeRequest, deployment_id: &str) -> Value { - json!({ - "op": "invoke", - "invocation_id": &req.invocation_id, - "deployment_id": deployment_id, - "execution_class": req.execution_class, - "execution_backend": req.execution_backend, - "tenant_id": &req.tenant_id, - "runtime_epoch": req.runtime_epoch, - "request": &req.request, - "context": &req.context, - "capability_refs": &req.capability_refs, - "timeout_ms": req.timeout_ms - }) -} - -fn validate_activation_response(bytes: &[u8], expected_deployment_id: &str) -> Result<(), String> { - let response: Value = serde_json::from_slice(bytes) - .map_err(|err| format!("invalid guest activation response: {err}"))?; - if response.get("op").and_then(Value::as_str) != Some("activate_artifact_result") { - return Err("guest activation response has wrong operation".into()); - } - if response.get("ok").and_then(Value::as_bool) != Some(true) { - return Err("guest rejected artifact activation".into()); - } - for field in ["deployment_id", "active_deployment_id"] { - let value = response - .get(field) - .and_then(Value::as_str) - .ok_or_else(|| format!("guest activation response is missing {field}"))?; - let normalized = normalize_build_digest(value) - .map_err(|_| format!("guest activation response has invalid {field}"))?; - if normalized != expected_deployment_id { - return Err(format!( - "guest activation response {field} does not match request" - )); - } - } - Ok(()) -} - -fn validate_put_artifact_response(bytes: &[u8], artifact: &AdmittedArtifact) -> Result<(), String> { - let response: Value = serde_json::from_slice(bytes) - .map_err(|err| format!("invalid guest artifact response: {err}"))?; - if response.get("op").and_then(Value::as_str) != Some("put_artifact_result") { - return Err("guest artifact response has wrong operation".into()); - } - if response.get("ok").and_then(Value::as_bool) != Some(true) { - return Err("guest rejected admitted artifact".into()); - } - if response.get("build_sha256").and_then(Value::as_str) != Some(&artifact.build_sha256) { - return Err("guest artifact response build_sha256 does not match request".into()); - } - if response.get("archive_sha256").and_then(Value::as_str) != Some(&artifact.archive_sha256) { - return Err("guest artifact response archive_sha256 does not match request".into()); - } - if response.get("archive_bytes").and_then(Value::as_u64) != Some(artifact.archive_bytes) { - return Err("guest artifact response archive_bytes does not match request".into()); - } - let state = response - .get("state") - .and_then(Value::as_str) - .ok_or_else(|| "guest artifact response is missing state".to_string())?; - if !matches!(state, "stored" | "already_present") { - return Err("guest artifact response has invalid state".into()); - } - Ok(()) -} - -async fn put_artifact_over_vsock( - vsock_path: &FsPath, - guest_port: u32, - shard: &ShardStatus, - artifact: &AdmittedArtifact, - timeout_ms: u64, - max_frame_bytes: usize, - chunk_bytes: usize, -) -> Result<(), String> { - if chunk_bytes == 0 || chunk_bytes > max_frame_bytes { - return Err("invalid host artifact chunk size".into()); - } - if artifact.archive_bytes != artifact.bytes.len() as u64 { - return Err("admitted artifact byte count changed before transport".into()); - } - let header = serde_json::to_vec(&json!({ - "op": "put_artifact", - "execution_class": shard.execution_class, - "execution_backend": shard.execution_backend, - "tenant_id": &shard.tenant_id, - "runtime_epoch": shard.runtime_epoch, - "build_sha256": artifact.build_sha256, - "archive_sha256": artifact.archive_sha256, - "archive_name": artifact.archive_name, - "archive_bytes": artifact.archive_bytes, - "chunk_bytes": chunk_bytes - })) - .map_err(|err| format!("serialize guest artifact header: {err}"))?; - - let operation = async { - let mut stream = connect_vsock_proxy(vsock_path, guest_port).await?; - write_frame(&mut stream, &header, max_frame_bytes).await?; - for chunk in artifact.bytes.chunks(chunk_bytes) { - write_frame(&mut stream, chunk, chunk_bytes).await?; - } - let response = read_frame(&mut stream, max_frame_bytes).await?; - Ok::, io::Error>(response) - }; - - let response = timeout(Duration::from_millis(timeout_ms), operation) - .await - .map_err(|_| "guest artifact transfer timed out".to_string())? - .map_err(|err| format!("guest artifact transfer failed: {err}"))?; - validate_put_artifact_response(&response, artifact) -} - -async fn connect_vsock_proxy(vsock_path: &FsPath, guest_port: u32) -> io::Result { - let mut stream = UnixStream::connect(vsock_path).await?; - stream - .write_all(format!("CONNECT {guest_port}\n").as_bytes()) - .await?; - - let mut ack = Vec::with_capacity(32); - while ack.len() < 128 { - let byte = stream.read_u8().await?; - ack.push(byte); - if byte == b'\n' { - break; - } - } - let ack_text = std::str::from_utf8(&ack) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8 vsock ack"))?; - if !ack_text.starts_with("OK ") || !ack_text.ends_with('\n') { - return Err(io::Error::new( - io::ErrorKind::ConnectionRefused, - format!("Firecracker vsock handshake failed: {ack_text:?}"), - )); - } - Ok(stream) -} - -async fn invoke_over_vsock( - vsock_path: &FsPath, - guest_port: u32, - request: &[u8], - timeout_ms: u64, - max_frame_bytes: usize, -) -> Result, String> { - let operation = async { - let mut stream = connect_vsock_proxy(vsock_path, guest_port).await?; - write_frame(&mut stream, request, max_frame_bytes).await?; - read_frame(&mut stream, max_frame_bytes).await - }; - - timeout(Duration::from_millis(timeout_ms), operation) - .await - .map_err(|_| "guest transport timed out".to_string())? - .map_err(|err| format!("guest vsock transport failed: {err}")) -} - -async fn read_frame(stream: &mut UnixStream, max_frame_bytes: usize) -> io::Result> { - let len = stream.read_u32().await? as usize; - if len == 0 || len > max_frame_bytes { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid guest frame length {len}"), - )); - } - let mut bytes = vec![0u8; len]; - stream.read_exact(&mut bytes).await?; - Ok(bytes) -} - -async fn write_frame( - stream: &mut UnixStream, - bytes: &[u8], - max_frame_bytes: usize, -) -> io::Result<()> { - if bytes.is_empty() || bytes.len() > max_frame_bytes || bytes.len() > u32::MAX as usize { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("invalid host frame length {}", bytes.len()), - )); - } - stream.write_u32(bytes.len() as u32).await?; - stream.write_all(bytes).await?; - stream.flush().await -} - -async fn warm_idle( - State(state): State, - Json(signed): Json>, -) -> Result { - let SignedRequest { - contract, - request: req, - } = signed; - authorize_runtime_request(&state, "warm_idle", &req, &contract).await?; - validate_shard_identity(&req.tenant_id, &req.shard_id)?; - let _barrier = state.lifecycle_barrier.read().await; - let gate_index = shard_gate_index(req.execution_class, &req.tenant_id, &req.shard_id); - let _gate = state.shard_gates[gate_index].lock().await; - state - .host - .mark_warm_idle(req) - .await - .map(Json) - .map_err(map_err) -} - -async fn hibernate( - State(state): State, - Json(signed): Json>, -) -> Result { - let SignedRequest { - contract, - request: req, - } = signed; - authorize_runtime_request(&state, "hibernate", &req, &contract).await?; - validate_shard_identity(&req.tenant_id, &req.shard_id)?; - let _barrier = state.lifecycle_barrier.read().await; - let gate_index = shard_gate_index(req.execution_class, &req.tenant_id, &req.shard_id); - let _gate = state.shard_gates[gate_index].lock().await; - state.host.hibernate(req).await.map(Json).map_err(map_err) -} - -async fn terminate( - State(state): State, - Json(signed): Json>, -) -> Result { - let SignedRequest { - contract, - request: req, - } = signed; - authorize_runtime_request(&state, "terminate", &req, &contract).await?; - validate_shard_identity(&req.tenant_id, &req.shard_id)?; - let execution_class = req.execution_class; - let tenant_id = req.tenant_id.clone(); - let shard_id = req.shard_id.clone(); - let _barrier = state.lifecycle_barrier.read().await; - let gate_index = shard_gate_index(execution_class, &tenant_id, &shard_id); - let _gate = state.shard_gates[gate_index].lock().await; - let current = state - .host - .get(execution_class, &tenant_id, &shard_id) - .await - .map_err(map_err)?; - check_runtime_epoch(¤t, req.runtime_epoch)?; - if current.active_invocations != 0 { - return Err(api_error( - StatusCode::CONFLICT, - "cannot terminate shard while invocations are active", - )); - } - let status = state.host.terminate(req).await.map_err(map_err)?; - clear_observed_for_shard(&state, execution_class, &tenant_id, &shard_id).await; - Ok(Json(status)) -} - -async fn get_activation( - Path((execution_class, tenant_id, shard_id)): Path<(ExecutionClass, String, String)>, - State(state): State, -) -> Result { - validate_shard_identity(&tenant_id, &shard_id)?; - let _barrier = state.lifecycle_barrier.read().await; - let gate_index = shard_gate_index(execution_class, &tenant_id, &shard_id); - let _gate = state.shard_gates[gate_index].lock().await; - let shard = state - .host - .get(execution_class, &tenant_id, &shard_id) - .await - .map_err(map_err)?; - let key = shard_epoch_key(execution_class, &tenant_id, &shard_id, shard.runtime_epoch); - let observed = state.observed_activation.read().await.get(&key).cloned(); - Ok(Json(activation_status(&shard, observed.as_ref()))) -} - -async fn get_metering( - Path((execution_class, tenant_id, shard_id)): Path<(ExecutionClass, String, String)>, - State(state): State, -) -> Result { - validate_shard_identity(&tenant_id, &shard_id)?; - state - .host - .metering(execution_class, &tenant_id, &shard_id) - .await - .map(Json) - .map_err(map_err) -} - -async fn get_shard( - Path((execution_class, tenant_id, shard_id)): Path<(ExecutionClass, String, String)>, - State(state): State, -) -> Result { - validate_shard_identity(&tenant_id, &shard_id)?; - state - .host - .get(execution_class, &tenant_id, &shard_id) - .await - .map(Json) - .map_err(map_err) -} - -fn default_critical_section_timeout_ms() -> u64 { - 10_000 -} - -fn default_activation_timeout_ms() -> u64 { - 30_000 -} - -fn shard_epoch_key( - execution_class: ExecutionClass, - tenant_id: &str, - shard_id: &str, - runtime_epoch: u64, -) -> ShardEpochKey { - ShardEpochKey { - execution_class, - tenant_id: tenant_id.to_owned(), - shard_id: shard_id.to_owned(), - runtime_epoch, - } -} - -fn shard_gate_index(execution_class: ExecutionClass, tenant_id: &str, shard_id: &str) -> usize { - let mut hasher = DefaultHasher::new(); - execution_class.hash(&mut hasher); - tenant_id.hash(&mut hasher); - shard_id.hash(&mut hasher); - (hasher.finish() as usize) % SHARD_GATE_STRIPES -} - -async fn clear_observed_for_shard( - state: &AppState, - execution_class: ExecutionClass, - tenant_id: &str, - shard_id: &str, -) { - state.observed_activation.write().await.retain(|key, _| { - key.execution_class != execution_class - || key.tenant_id != tenant_id - || key.shard_id != shard_id - }); -} - -async fn deployment_allowed( - state: &AppState, - shard: &ShardStatus, - requested_deployment_id: &str, -) -> Result { - let key = shard_epoch_key( - shard.execution_class, - &shard.tenant_id, - &shard.shard_id, - shard.runtime_epoch, - ); - if let Some(observed) = state.observed_activation.read().await.get(&key) { - return Ok(observed - .loaded_deployment_ids - .contains(requested_deployment_id)); - } - let placement = normalize_build_digest(&shard.deployment_digest).map_err(|_| { - api_error( - StatusCode::INTERNAL_SERVER_ERROR, - "runtime host contains a non-canonical deployment digest", - ) - })?; - Ok(placement == requested_deployment_id) -} - -fn activation_status( - shard: &ShardStatus, - observed: Option<&ObservedActivation>, -) -> ActivationStatus { - ActivationStatus { - tenant_id: shard.tenant_id.clone(), - shard_id: shard.shard_id.clone(), - execution_class: shard.execution_class, - execution_backend: shard.execution_backend, - runtime_epoch: shard.runtime_epoch, - placement_deployment_id: shard.deployment_digest.clone(), - observed: observed.is_some(), - root_active_deployment_id: observed - .and_then(|activation| activation.root_active_deployment_id.clone()), - loaded_deployment_ids: observed - .map(|activation| activation.loaded_deployment_ids.iter().cloned().collect()) - .unwrap_or_default(), - } -} - -fn normalize_build_digest(value: &str) -> Result { - let raw = value.strip_prefix("sha256:").unwrap_or(value); - if raw.len() == 64 - && raw - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(raw.to_owned()) - } else { - Err("deployment_id must be a lowercase 64-character SHA-256 digest") - } -} - -fn validate_shard_identity(tenant_id: &str, shard_id: &str) -> Result<(), ApiError> { - if !valid_runtime_identifier(tenant_id) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "tenant_id must use 1..=128 ASCII letters, digits, '.', '_' or '-'", - )); - } - if !valid_runtime_identifier(shard_id) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "shard_id must use 1..=128 ASCII letters, digits, '.', '_' or '-'", - )); - } - Ok(()) -} - -fn valid_runtime_identifier(value: &str) -> bool { - !value.is_empty() - && value.len() <= 128 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) -} - -fn execution_class_name(class: ExecutionClass) -> &'static str { - match class { - ExecutionClass::Faas => "faas", - ExecutionClass::Phoenix => "phoenix", - ExecutionClass::DurableActor => "durable_actor", - } -} - -fn execution_backend_name(backend: ExecutionBackend) -> &'static str { - match backend { - ExecutionBackend::BareProcess => "bare_process", - ExecutionBackend::Firecracker => "firecracker", - } -} - -async fn authorize_runtime_request( - state: &AppState, - operation: &str, - request: &T, - contract: &RuntimeContract, -) -> Result<(), ApiError> -where - T: Serialize + RuntimeRequestIdentity, -{ - let expected = ExpectedRuntimeRequest { - operation, - tenant_id: request.tenant_id(), - shard_id: request.shard_id(), - execution_class: execution_class_name(request.execution_class()), - execution_backend: execution_backend_name(request.execution_backend()), - runtime_epoch: request.runtime_epoch(), - }; - let expires_at = runtime_auth::verify( - state.runtime_control_secret.as_slice(), - &expected, - request, - contract, - ) - .map_err(|message| api_error(StatusCode::UNAUTHORIZED, message))?; - - let now = runtime_auth::now_unix() - .map_err(|message| api_error(StatusCode::INTERNAL_SERVER_ERROR, message))?; - let mut consumed = state.consumed_nonces.lock().await; - consume_runtime_nonce(&mut consumed, &contract.nonce, expires_at, now) - .map_err(|message| api_error(StatusCode::UNAUTHORIZED, message)) -} - -fn consume_runtime_nonce( - consumed: &mut HashMap, - nonce: &str, - expires_at: u64, - now: u64, -) -> Result<(), &'static str> { - consumed.retain(|_, expiry| *expiry >= now); - if consumed.insert(nonce.to_owned(), expires_at).is_some() { - Err("runtime control contract replay detected") - } else { - Ok(()) - } -} - -fn validate_firecracker_target( - execution_class: ExecutionClass, - execution_backend: ExecutionBackend, -) -> Result<(), ApiError> { - if execution_backend != ExecutionBackend::Firecracker { - return Err(api_error( - StatusCode::BAD_REQUEST, - "this runtime host accepts execution_backend=firecracker only", - )); - } - if !matches!( - execution_class, - ExecutionClass::Phoenix | ExecutionClass::DurableActor - ) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "Firecracker runtime host accepts only phoenix or durable_actor", - )); - } - Ok(()) -} - -fn check_execution_target( - shard: &ShardStatus, - execution_class: ExecutionClass, - execution_backend: ExecutionBackend, -) -> Result<(), ApiError> { - validate_firecracker_target(execution_class, execution_backend)?; - if shard.execution_class != execution_class || shard.execution_backend != execution_backend { - return Err(api_error( - StatusCode::CONFLICT, - "runtime shard execution class/backend does not match request", - )); - } - Ok(()) -} - -fn check_runtime_epoch(shard: &ShardStatus, requested: u64) -> Result<(), ApiError> { - if shard.runtime_epoch == requested { - Ok(()) - } else { - Err(api_error( - StatusCode::CONFLICT, - format!( - "stale runtime epoch: requested {}, current {}", - requested, shard.runtime_epoch - ), - )) - } -} - -fn map_err(err: HostError) -> ApiError { - let status = match &err { - HostError::UnknownShard => StatusCode::NOT_FOUND, - HostError::StaleEpoch { .. } - | HostError::EpochReplacementConflict - | HostError::InvalidState(_) - | HostError::IdentityConflict(_) => StatusCode::CONFLICT, - HostError::InvalidPolicy(_) - | HostError::InvalidIdentity(_) - | HostError::InvalidExecutionTarget(_) => StatusCode::BAD_REQUEST, - HostError::Firecracker(_) - | HostError::Cgroup(_) - | HostError::Identity(_) - | HostError::Io(_) => StatusCode::SERVICE_UNAVAILABLE, - }; - api_error(status, err.to_string()) -} - -fn api_error(status: StatusCode, message: impl Into) -> ApiError { - ( - status, - Json(ErrorBody { - error: message.into(), - }), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn request_with_capabilities(capability_refs: Vec) -> InvokeRequest { - InvokeRequest { - tenant_id: "tenant-1".into(), - shard_id: "shard-1".into(), - execution_class: ExecutionClass::Phoenix, - execution_backend: ExecutionBackend::Firecracker, - runtime_epoch: 7, - invocation_id: "invocation-1".into(), - deployment_id: "a".repeat(64), - request: json!({"method": "GET", "path": "/"}), - context: json!({"trace_id": "trace-1"}), - capability_refs, - timeout_ms: 5_000, - } - } - - #[test] - fn runtime_control_nonce_is_one_time_until_expiry() { - let mut consumed = HashMap::new(); - assert!(consume_runtime_nonce(&mut consumed, "nonce-1", 130, 100).is_ok()); - assert_eq!( - consume_runtime_nonce(&mut consumed, "nonce-1", 130, 101), - Err("runtime control contract replay detected") - ); - - assert!(consume_runtime_nonce(&mut consumed, "nonce-2", 200, 131).is_ok()); - assert!(!consumed.contains_key("nonce-1")); - assert!(consumed.contains_key("nonce-2")); - } - - #[test] - fn validates_critical_section_operation_shapes() { - let acquire = CriticalSectionRequest { - tenant_id: "tenant-1".into(), - shard_id: "deployment-1".into(), - execution_class: ExecutionClass::DurableActor, - execution_backend: ExecutionBackend::Firecracker, - runtime_epoch: 7, - deployment_id: "a".repeat(64), - operation: CriticalSectionOperation::Acquire, - namespace: "locks".into(), - object_key: "orders/42".into(), - holder: "worker-a".into(), - request_id: Some("request-1".into()), - lease_ms: Some(5_000), - token: None, - timeout_ms: 10_000, - }; - assert!(validate_critical_section_request(&acquire).is_ok()); - let envelope = critical_section_envelope(&acquire); - assert_eq!(envelope["execution_class"], "durable_actor"); - assert_eq!(envelope["execution_backend"], "firecracker"); - assert_eq!(envelope["tenant_id"], "tenant-1"); - assert_eq!(envelope["runtime_epoch"], 7); - - let mut missing_request_id = acquire.clone(); - missing_request_id.request_id = None; - assert!(validate_critical_section_request(&missing_request_id).is_err()); - - let mut invalid_release = acquire.clone(); - invalid_release.operation = CriticalSectionOperation::Release; - invalid_release.lease_ms = None; - invalid_release.token = Some(CriticalSectionToken { - runtime_epoch: 7, - owner_epoch: 3, - sequence: 11, - }); - assert!( - validate_critical_section_request(&invalid_release).is_err(), - "release must reject acquire request identity" - ); - - let mut release = invalid_release; - release.request_id = None; - assert!(validate_critical_section_request(&release).is_ok()); - } - - #[test] - fn firecracker_target_validation_rejects_faas_and_bare_process() { - assert!(validate_firecracker_target( - ExecutionClass::Phoenix, - ExecutionBackend::Firecracker - ) - .is_ok()); - assert!(validate_firecracker_target( - ExecutionClass::DurableActor, - ExecutionBackend::Firecracker - ) - .is_ok()); - assert!( - validate_firecracker_target(ExecutionClass::Faas, ExecutionBackend::Firecracker) - .is_err() - ); - assert!(validate_firecracker_target( - ExecutionClass::Phoenix, - ExecutionBackend::BareProcess - ) - .is_err()); - } - - #[test] - fn accepts_and_forwards_bounded_capability_refs() { - let refs = vec![CapabilityRef { - name: "ctx.fetch".into(), - token_ref: "opaque-token-ref-1".into(), - }]; - assert_eq!(validate_capability_refs(&refs), Ok(())); - - let request = request_with_capabilities(refs); - let envelope = guest_invocation_envelope(&request, &"a".repeat(64)); - assert_eq!(envelope["capability_refs"][0]["name"], json!("ctx.fetch")); - assert_eq!( - envelope["capability_refs"][0]["token_ref"], - json!("opaque-token-ref-1") - ); - } - - #[test] - fn rejects_duplicate_capability_names() { - let capability = CapabilityRef { - name: "ctx.kv".into(), - token_ref: "one".into(), - }; - let refs = vec![capability.clone(), capability]; - assert_eq!( - validate_capability_refs(&refs), - Err("capability_refs names must be unique") - ); - } - - #[test] - fn rejects_malformed_capability_name() { - let refs = vec![CapabilityRef { - name: "fetch".into(), - token_ref: "one".into(), - }]; - assert!(validate_capability_refs(&refs).is_err()); - } - - #[test] - fn rejects_empty_or_oversized_token_refs() { - let empty = vec![CapabilityRef { - name: "ctx.fetch".into(), - token_ref: String::new(), - }]; - assert!(validate_capability_refs(&empty).is_err()); - - let oversized = vec![CapabilityRef { - name: "ctx.fetch".into(), - token_ref: "x".repeat(MAX_CAPABILITY_TOKEN_REF_BYTES + 1), - }]; - assert!(validate_capability_refs(&oversized).is_err()); - } - - #[test] - fn rejects_more_than_sixty_four_capability_refs() { - let refs = (0..=MAX_CAPABILITY_REFS) - .map(|index| CapabilityRef { - name: format!("ctx.capability_{index}"), - token_ref: format!("token-{index}"), - }) - .collect::>(); - assert!(validate_capability_refs(&refs).is_err()); - } - - #[test] - fn canonicalizes_and_validates_build_digests() { - let digest = "a".repeat(64); - assert_eq!(normalize_build_digest(&digest).unwrap(), digest); - assert_eq!( - normalize_build_digest(&format!("sha256:{digest}")).unwrap(), - digest - ); - assert!(normalize_build_digest(&"A".repeat(64)).is_err()); - assert!(normalize_build_digest("../artifact").is_err()); - } - - #[test] - fn rejects_path_like_runtime_identifiers() { - assert!(valid_runtime_identifier("tenant-1")); - assert!(valid_runtime_identifier("shard_1.prod")); - assert!(!valid_runtime_identifier("../tenant")); - assert!(!valid_runtime_identifier("tenant/shard")); - assert!(!valid_runtime_identifier("")); - } - - #[test] - fn activation_response_must_ack_exact_digest() { - let digest = "b".repeat(64); - let good = serde_json::to_vec(&json!({ - "op": "activate_artifact_result", - "ok": true, - "deployment_id": digest, - "active_deployment_id": digest - })) - .unwrap(); - assert!(validate_activation_response(&good, &"b".repeat(64)).is_ok()); - - let wrong = serde_json::to_vec(&json!({ - "op": "activate_artifact_result", - "ok": true, - "deployment_id": "c".repeat(64), - "active_deployment_id": "c".repeat(64) - })) - .unwrap(); - assert!(validate_activation_response(&wrong, &"b".repeat(64)).is_err()); - } - - #[test] - fn artifact_response_must_ack_exact_admitted_bytes() { - let artifact = AdmittedArtifact { - build_sha256: "d".repeat(64), - archive_name: "worker.tar.gz".into(), - archive_sha256: "e".repeat(64), - archive_bytes: 123, - bytes: vec![1; 123], - }; - let good = serde_json::to_vec(&json!({ - "op": "put_artifact_result", - "ok": true, - "build_sha256": artifact.build_sha256, - "archive_sha256": artifact.archive_sha256, - "archive_bytes": artifact.archive_bytes, - "state": "stored" - })) - .unwrap(); - assert!(validate_put_artifact_response(&good, &artifact).is_ok()); - - let wrong = serde_json::to_vec(&json!({ - "op": "put_artifact_result", - "ok": true, - "build_sha256": artifact.build_sha256, - "archive_sha256": "f".repeat(64), - "archive_bytes": artifact.archive_bytes, - "state": "stored" - })) - .unwrap(); - assert!(validate_put_artifact_response(&wrong, &artifact).is_err()); - } -} diff --git a/cert/beamscale-critical-section/infra-runtime-host/src/runtime_auth.rs b/cert/beamscale-critical-section/infra-runtime-host/src/runtime_auth.rs deleted file mode 100644 index 8d5a60f..0000000 --- a/cert/beamscale-critical-section/infra-runtime-host/src/runtime_auth.rs +++ /dev/null @@ -1,309 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{ - env, - time::{SystemTime, UNIX_EPOCH}, -}; - -pub const CONTRACT_VERSION: &str = "bmscl.runtime-control.v1"; -const MAX_TTL_SECONDS: u64 = 60; -const CLOCK_SKEW_SECONDS: u64 = 5; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SignedRequest { - pub contract: RuntimeContract, - pub request: T, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct RuntimeContract { - pub version: String, - pub operation: String, - pub tenant_id: String, - pub shard_id: String, - pub execution_class: String, - pub execution_backend: String, - pub runtime_epoch: u64, - pub request_sha256: String, - pub issued_at_unix: u64, - pub expires_at_unix: u64, - pub nonce: String, - pub signature: String, -} - -#[derive(Debug, Clone, Copy)] -pub struct ExpectedRuntimeRequest<'a> { - pub operation: &'a str, - pub tenant_id: &'a str, - pub shard_id: &'a str, - pub execution_class: &'a str, - pub execution_backend: &'a str, - pub runtime_epoch: u64, -} - -pub fn load_secret() -> Result, String> { - let value = env::var("BMSCL_RUNTIME_CONTROL_SECRET") - .map_err(|_| "BMSCL_RUNTIME_CONTROL_SECRET is required".to_string())?; - let bytes = value.into_bytes(); - if bytes.len() < 32 { - return Err("BMSCL_RUNTIME_CONTROL_SECRET must be at least 32 bytes".into()); - } - Ok(bytes) -} - -pub fn verify( - secret: &[u8], - expected: &ExpectedRuntimeRequest<'_>, - request: &T, - contract: &RuntimeContract, -) -> Result { - if secret.len() < 32 { - return Err("runtime control secret is invalid".into()); - } - let now = now_unix()?; - if contract.version != CONTRACT_VERSION { - return Err("unsupported runtime control contract".into()); - } - if contract.operation != expected.operation - || contract.tenant_id != expected.tenant_id - || contract.shard_id != expected.shard_id - || contract.execution_class != expected.execution_class - || contract.execution_backend != expected.execution_backend - || contract.runtime_epoch != expected.runtime_epoch - { - return Err("runtime control contract identity mismatch".into()); - } - if contract.issued_at_unix > now.saturating_add(CLOCK_SKEW_SECONDS) - || contract.expires_at_unix < now - || contract.expires_at_unix <= contract.issued_at_unix - || contract - .expires_at_unix - .saturating_sub(contract.issued_at_unix) - > MAX_TTL_SECONDS - { - return Err("runtime control contract expired or invalid".into()); - } - if contract.nonce.len() < 16 - || contract.nonce.len() > 128 - || !contract - .nonce - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') - { - return Err("runtime control contract nonce is invalid".into()); - } - let request_sha256 = request_sha256(request)?; - if !constant_time_eq( - contract.request_sha256.as_bytes(), - request_sha256.as_bytes(), - ) { - return Err("runtime control request digest mismatch".into()); - } - let expected_signature = signature_for(secret, contract, &request_sha256); - if !constant_time_eq(contract.signature.as_bytes(), expected_signature.as_bytes()) { - return Err("runtime control signature mismatch".into()); - } - Ok(contract.expires_at_unix) -} - -pub fn request_sha256(request: &T) -> Result { - let bytes = serde_json::to_vec(request) - .map_err(|err| format!("serialize runtime control request: {err}"))?; - Ok(hex_lower(&Sha256::digest(bytes))) -} - -pub fn signature_for(secret: &[u8], contract: &RuntimeContract, request_sha256: &str) -> String { - let canonical = format!( - "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n", - contract.version, - contract.operation, - contract.tenant_id, - contract.shard_id, - contract.execution_class, - contract.execution_backend, - contract.runtime_epoch, - request_sha256, - contract.issued_at_unix, - contract.expires_at_unix, - contract.nonce - ); - hex_lower(&hmac_sha256(secret, canonical.as_bytes())) -} - -pub fn now_unix() -> Result { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .map_err(|_| "system clock is before UNIX epoch".into()) -} - -fn hmac_sha256(secret: &[u8], message: &[u8]) -> [u8; 32] { - const BLOCK: usize = 64; - let mut key = [0u8; BLOCK]; - if secret.len() > BLOCK { - let digest = Sha256::digest(secret); - key[..32].copy_from_slice(&digest); - } else { - key[..secret.len()].copy_from_slice(secret); - } - let mut ipad = [0x36u8; BLOCK]; - let mut opad = [0x5cu8; BLOCK]; - for index in 0..BLOCK { - ipad[index] ^= key[index]; - opad[index] ^= key[index]; - } - let mut inner = Sha256::new(); - inner.update(ipad); - inner.update(message); - let inner_digest = inner.finalize(); - - let mut outer = Sha256::new(); - outer.update(opad); - outer.update(inner_digest); - outer.finalize().into() -} - -fn hex_lower(bytes: &[u8]) -> String { - let mut out = String::with_capacity(bytes.len() * 2); - for byte in bytes { - use std::fmt::Write; - let _ = write!(out, "{byte:02x}"); - } - out -} - -fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { - if left.len() != right.len() { - return false; - } - let mut diff = 0u8; - for (a, b) in left.iter().zip(right) { - diff |= a ^ b; - } - diff == 0 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[derive(Serialize)] - struct Request { - value: u64, - } - - fn signed(secret: &[u8], request: &Request) -> RuntimeContract { - let issued = now_unix().unwrap(); - let request_sha256 = request_sha256(request).unwrap(); - let mut contract = RuntimeContract { - version: CONTRACT_VERSION.into(), - operation: "invoke".into(), - tenant_id: "tenant-a".into(), - shard_id: "0".into(), - execution_class: "phoenix".into(), - execution_backend: "firecracker".into(), - runtime_epoch: 7, - request_sha256: request_sha256.clone(), - issued_at_unix: issued, - expires_at_unix: issued + 30, - nonce: "nonce-1234567890".into(), - signature: String::new(), - }; - contract.signature = signature_for(secret, &contract, &request_sha256); - contract - } - - #[test] - fn verifies_exact_body_and_identity() { - let secret = b"0123456789abcdef0123456789abcdef"; - let request = Request { value: 42 }; - let contract = signed(secret, &request); - assert!(verify( - secret, - &ExpectedRuntimeRequest { - operation: "invoke", - tenant_id: "tenant-a", - shard_id: "0", - execution_class: "phoenix", - execution_backend: "firecracker", - runtime_epoch: 7, - }, - &request, - &contract - ) - .is_ok()); - } - - #[test] - fn rejects_body_tampering() { - let secret = b"0123456789abcdef0123456789abcdef"; - let request = Request { value: 42 }; - let contract = signed(secret, &request); - assert!(verify( - secret, - &ExpectedRuntimeRequest { - operation: "invoke", - tenant_id: "tenant-a", - shard_id: "0", - execution_class: "phoenix", - execution_backend: "firecracker", - runtime_epoch: 7, - }, - &Request { value: 43 }, - &contract - ) - .is_err()); - } - - #[test] - fn matches_cross_repo_runtime_control_v1_vector() { - let secret = b"0123456789abcdef0123456789abcdef"; - let request = Request { value: 42 }; - let request_sha256 = request_sha256(&request).unwrap(); - assert_eq!( - request_sha256, - "dc60e632a90329ccfd34fbe904d94704dbbb6669575185e26389854ff64139c3" - ); - let contract = RuntimeContract { - version: CONTRACT_VERSION.into(), - operation: "invoke".into(), - tenant_id: "tenant-a".into(), - shard_id: "0".into(), - execution_class: "phoenix".into(), - execution_backend: "firecracker".into(), - runtime_epoch: 7, - request_sha256: request_sha256.clone(), - issued_at_unix: 1_700_000_000, - expires_at_unix: 1_700_000_030, - nonce: "nonce-1234567890".into(), - signature: String::new(), - }; - assert_eq!( - signature_for(secret, &contract, &request_sha256), - "e55da196d53024c61f189b3aaaec564bf564c6394e991024a50e03543c08e7dd" - ); - } - - #[test] - fn rejects_cross_tenant_reuse() { - let secret = b"0123456789abcdef0123456789abcdef"; - let request = Request { value: 42 }; - let contract = signed(secret, &request); - assert!(verify( - secret, - &ExpectedRuntimeRequest { - operation: "invoke", - tenant_id: "tenant-b", - shard_id: "0", - execution_class: "phoenix", - execution_backend: "firecracker", - runtime_epoch: 7, - }, - &request, - &contract - ) - .is_err()); - } -} diff --git a/cert/beamscale-critical-section/interfaces/module-projections/erlang/bmscl_module.erl b/cert/beamscale-critical-section/interfaces/module-projections/erlang/bmscl_module.erl deleted file mode 100644 index e777e3c..0000000 --- a/cert/beamscale-critical-section/interfaces/module-projections/erlang/bmscl_module.erl +++ /dev/null @@ -1,9 +0,0 @@ --module(bmscl_module). - --callback handle(Context :: term(), Input :: term()) -> - {ok, Output :: term()} | {error, Reason :: term()}. - --callback module_kind() -> lambda | http | rpc | actor | worker. --callback module_name() -> binary(). - --optional_callbacks([module_name/0]). diff --git a/cert/beamscale-critical-section/interfaces/module-projections/erlang/example_module.erl b/cert/beamscale-critical-section/interfaces/module-projections/erlang/example_module.erl deleted file mode 100644 index 0d0e64b..0000000 --- a/cert/beamscale-critical-section/interfaces/module-projections/erlang/example_module.erl +++ /dev/null @@ -1,16 +0,0 @@ --module(example_module). --behaviour(bmscl_module). - --export([handle/2, module_kind/0, module_name/0]). - --spec module_kind() -> lambda. -module_kind() -> - lambda. - --spec module_name() -> binary(). -module_name() -> - <<"example">>. - --spec handle(term(), term()) -> {ok, term()} | {error, term()}. -handle(_Context, Input) -> - {ok, Input}. diff --git a/cert/beamscale-critical-section/interfaces/module-projections/gleam/module_contract.gleam b/cert/beamscale-critical-section/interfaces/module-projections/gleam/module_contract.gleam deleted file mode 100644 index 4e5acde..0000000 --- a/cert/beamscale-critical-section/interfaces/module-projections/gleam/module_contract.gleam +++ /dev/null @@ -1,32 +0,0 @@ -pub type ModuleKind { - Lambda - Http - Rpc - Actor - Worker -} - -pub opaque type Module(context, input, output) { - Module( - kind: ModuleKind, - name: String, - handle: fn(context, input) -> Result(output, String), - ) -} - -pub fn module( - kind: ModuleKind, - name: String, - handle: fn(context, input) -> Result(output, String), -) -> Module(context, input, output) { - Module(kind: kind, name: name, handle: handle) -} - -pub fn invoke( - module_export: Module(context, input, output), - context: context, - input: input, -) -> Result(output, String) { - let Module(_, _, handle) = module_export - handle(context, input) -} diff --git a/cert/beamscale-critical-section/interfaces/module-projections/manifest.json b/cert/beamscale-critical-section/interfaces/module-projections/manifest.json deleted file mode 100644 index 9b7b3d8..0000000 --- a/cert/beamscale-critical-section/interfaces/module-projections/manifest.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "version": "bmscl.module-projections/v1", - "runtime_family": "beam", - "rules": { - "direct_guest_languages": ["erlang", "gleam"], - "non_beam_projection_does_not_imply_guest_support": true, - "capabilities_are_runtime_grants_not_imports": true - }, - "languages": [ - { - "id": "erlang", - "role": "direct_guest", - "strategy": "behaviour_callbacks", - "enforcement": "compiler_plus_static_analysis", - "example": "module-projections/erlang/example_module.erl" - }, - { - "id": "gleam", - "role": "direct_guest", - "strategy": "opaque_typed_export", - "enforcement": "compile_time", - "example": "module-projections/gleam/module_contract.gleam" - }, - { - "id": "rust", - "role": "control_plane_sdk_only", - "strategy": "trait_impl", - "enforcement": "compile_time" - }, - { - "id": "typescript", - "role": "control_plane_sdk_only", - "strategy": "satisfies_export_object", - "enforcement": "compile_time" - }, - { - "id": "wit", - "role": "interop_metadata_only", - "strategy": "component_interface", - "enforcement": "component_link_time" - } - ] -} diff --git a/cert/beamscale-critical-section/interfaces/package.json b/cert/beamscale-critical-section/interfaces/package.json deleted file mode 100644 index b949358..0000000 --- a/cert/beamscale-critical-section/interfaces/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@beamscale/interfaces-contract-checks", - "private": true, - "version": "0.0.0", - "scripts": { - "check:typespec": "tsp compile typespec/main.tsp", - "check:json": "node -e \"for (const f of ['schema/worker-manifest.schema.json','schema/module-contract.schema.json','schema/tenant-runtime.schema.json','schema/deployment-control.schema.json','schema/host-security-incident.schema.json','schema/host-security-incident-receipt.schema.json','schema/critical-section-deployment.schema.json','schema/critical-section-operations.schema.json']) JSON.parse(require('fs').readFileSync(f,'utf8'))\"", - "check:module-parity": "node --test tests/module-contract-parity.test.mjs", - "check:module-projections": "node --test tests/module-projections.test.mjs", - "check": "npm run check:typespec && npm run check:json && npm run check:module-parity && npm run check:module-projections" - }, - "devDependencies": { - "@typespec/compiler": "1.16.0", - "@typespec/http": "1.16.0" - } -} diff --git a/cert/beamscale-critical-section/interfaces/schema/critical-section-deployment.schema.json b/cert/beamscale-critical-section/interfaces/schema/critical-section-deployment.schema.json deleted file mode 100644 index 4656122..0000000 --- a/cert/beamscale-critical-section/interfaces/schema/critical-section-deployment.schema.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://beamscale.dev/schema/critical-section-deployment.schema.json", - "title": "BeamScale critical-section deployment", - "type": "object", - "additionalProperties": false, - "required": [ - "tenant_id", - "namespace", - "language", - "build_sha256" - ], - "properties": { - "deployment_id": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._-]+$" - }, - "tenant_id": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._-]+$" - }, - "namespace": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._-]+$" - }, - "language": { - "enum": [ - "erlang", - "gleam" - ] - }, - "build_sha256": { - "type": "string", - "pattern": "^[0-9a-f]{64}$" - }, - "tenancy_class": { - "const": "tenant_dedicated" - } - } -} diff --git a/cert/beamscale-critical-section/interfaces/schema/critical-section-operations.schema.json b/cert/beamscale-critical-section/interfaces/schema/critical-section-operations.schema.json deleted file mode 100644 index 80cf480..0000000 --- a/cert/beamscale-critical-section/interfaces/schema/critical-section-operations.schema.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://beamscale.dev/schema/critical-section-operations.schema.json", - "title": "BeamScale critical-section operations", - "$defs": { - "token": { - "type": "object", - "additionalProperties": false, - "required": [ - "runtime_epoch", - "owner_epoch", - "sequence" - ], - "properties": { - "runtime_epoch": { - "type": "integer", - "minimum": 1 - }, - "owner_epoch": { - "type": "integer", - "minimum": 1 - }, - "sequence": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - } - } - }, - "identity": { - "type": "string", - "minLength": 1, - "maxLength": 256 - }, - "key": { - "type": "string", - "minLength": 1, - "maxLength": 4096 - }, - "request_id": { - "type": "string", - "minLength": 1, - "maxLength": 256 - } - }, - "oneOf": [ - { - "title": "acquire", - "type": "object", - "additionalProperties": false, - "required": [ - "key", - "holder", - "request_id", - "lease_ms" - ], - "properties": { - "key": { - "$ref": "#/$defs/key" - }, - "holder": { - "$ref": "#/$defs/identity" - }, - "request_id": { - "$ref": "#/$defs/request_id" - }, - "lease_ms": { - "type": "integer", - "minimum": 1, - "maximum": 300000 - } - } - }, - { - "title": "renew", - "type": "object", - "additionalProperties": false, - "required": [ - "key", - "holder", - "lease_ms", - "token" - ], - "properties": { - "key": { - "$ref": "#/$defs/key" - }, - "holder": { - "$ref": "#/$defs/identity" - }, - "lease_ms": { - "type": "integer", - "minimum": 1, - "maximum": 300000 - }, - "token": { - "$ref": "#/$defs/token" - } - } - }, - { - "title": "release", - "type": "object", - "additionalProperties": false, - "required": [ - "key", - "holder", - "token" - ], - "properties": { - "key": { - "$ref": "#/$defs/key" - }, - "holder": { - "$ref": "#/$defs/identity" - }, - "token": { - "$ref": "#/$defs/token" - } - } - } - ] -} diff --git a/cert/beamscale-critical-section/interfaces/schema/deployment-control.schema.json b/cert/beamscale-critical-section/interfaces/schema/deployment-control.schema.json deleted file mode 100644 index b2384de..0000000 --- a/cert/beamscale-critical-section/interfaces/schema/deployment-control.schema.json +++ /dev/null @@ -1,240 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://beamscale.dev/schema/deployment-control.schema.json", - "title": "BeamScale deployment control", - "$ref": "#/$defs/DeploymentPlanRequest", - "$defs": { - "sha256": { - "type": "string", - "pattern": "^[0-9a-f]{64}$" - }, - "project_segment": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._-]+$" - }, - "unit_name": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "pattern": "^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$" - }, - "git_commit": { - "type": "string", - "minLength": 7, - "maxLength": 64, - "pattern": "^[0-9a-f]+$" - }, - "DeploymentUnitKind": { - "type": "string", - "enum": ["lambda", "middleware"] - }, - "DeploymentUnitCandidate": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "name", "build_sha256", "source_sha256"], - "properties": { - "kind": { "$ref": "#/$defs/DeploymentUnitKind" }, - "name": { "$ref": "#/$defs/unit_name" }, - "build_sha256": { "$ref": "#/$defs/sha256" }, - "source_sha256": { "$ref": "#/$defs/sha256" } - } - }, - "DeploymentUnitTombstone": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "name"], - "properties": { - "kind": { "$ref": "#/$defs/DeploymentUnitKind" }, - "name": { "$ref": "#/$defs/unit_name" } - } - }, - "LegacyLambdaCandidate": { - "type": "object", - "additionalProperties": false, - "required": ["name", "build_sha256", "source_sha256"], - "properties": { - "name": { "$ref": "#/$defs/unit_name" }, - "build_sha256": { "$ref": "#/$defs/sha256" }, - "source_sha256": { "$ref": "#/$defs/sha256" } - } - }, - "DeploymentPlanRequest": { - "type": "object", - "additionalProperties": false, - "required": ["project", "environment"], - "properties": { - "project": { "$ref": "#/$defs/project_segment" }, - "environment": { "$ref": "#/$defs/project_segment" }, - "git_commit": { "$ref": "#/$defs/git_commit" }, - "force": { "type": "boolean", "default": false }, - "units": { - "type": "array", - "maxItems": 1000, - "items": { "$ref": "#/$defs/DeploymentUnitCandidate" } - }, - "tombstones": { - "type": "array", - "maxItems": 1000, - "items": { "$ref": "#/$defs/DeploymentUnitTombstone" } - }, - "lambdas": { - "type": "array", - "maxItems": 1000, - "items": { "$ref": "#/$defs/LegacyLambdaCandidate" } - } - }, - "anyOf": [ - { "required": ["units"], "properties": { "units": { "minItems": 1 } } }, - { "required": ["tombstones"], "properties": { "tombstones": { "minItems": 1 } } }, - { "required": ["lambdas"], "properties": { "lambdas": { "minItems": 1 } } } - ] - }, - "DeploymentApplyRequest": { - "type": "object", - "additionalProperties": false, - "required": ["project", "environment", "expected_revision"], - "properties": { - "project": { "$ref": "#/$defs/project_segment" }, - "environment": { "$ref": "#/$defs/project_segment" }, - "git_commit": { "$ref": "#/$defs/git_commit" }, - "force": { "type": "boolean", "default": false }, - "expected_revision": { "type": "integer", "minimum": 0 }, - "units": { - "type": "array", - "maxItems": 1000, - "items": { "$ref": "#/$defs/DeploymentUnitCandidate" } - }, - "tombstones": { - "type": "array", - "maxItems": 1000, - "items": { "$ref": "#/$defs/DeploymentUnitTombstone" } - }, - "lambdas": { - "type": "array", - "maxItems": 1000, - "items": { "$ref": "#/$defs/LegacyLambdaCandidate" } - } - }, - "anyOf": [ - { "required": ["units"], "properties": { "units": { "minItems": 1 } } }, - { "required": ["tombstones"], "properties": { "tombstones": { "minItems": 1 } } }, - { "required": ["lambdas"], "properties": { "lambdas": { "minItems": 1 } } } - ] - }, - "DeploymentPlanReason": { - "type": "string", - "enum": [ - "forced", - "not_deployed", - "build_changed", - "artifact_missing", - "up_to_date", - "deleted", - "already_deleted" - ] - }, - "PlannedDeploymentUnit": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "name", "tombstone", "artifact_present", "reason"], - "properties": { - "kind": { "$ref": "#/$defs/DeploymentUnitKind" }, - "name": { "$ref": "#/$defs/unit_name" }, - "tombstone": { "type": "boolean" }, - "build_sha256": { "$ref": "#/$defs/sha256" }, - "source_sha256": { "$ref": "#/$defs/sha256" }, - "current_build_sha256": { "$ref": "#/$defs/sha256" }, - "artifact_present": { "type": "boolean" }, - "reason": { "$ref": "#/$defs/DeploymentPlanReason" } - } - }, - "DeploymentPlanResponse": { - "type": "object", - "additionalProperties": false, - "required": ["project", "environment", "revision", "changed", "unchanged"], - "properties": { - "project": { "$ref": "#/$defs/project_segment" }, - "environment": { "$ref": "#/$defs/project_segment" }, - "revision": { "type": "integer", "minimum": 0 }, - "changed": { - "type": "array", - "items": { "$ref": "#/$defs/PlannedDeploymentUnit" } - }, - "unchanged": { - "type": "array", - "items": { "$ref": "#/$defs/PlannedDeploymentUnit" } - } - } - }, - "DeploymentApplyResponse": { - "type": "object", - "additionalProperties": false, - "required": ["project", "environment", "previous_revision", "revision", "applied", "unchanged"], - "properties": { - "project": { "$ref": "#/$defs/project_segment" }, - "environment": { "$ref": "#/$defs/project_segment" }, - "previous_revision": { "type": "integer", "minimum": 0 }, - "revision": { "type": "integer", "minimum": 0 }, - "applied": { - "type": "array", - "items": { "$ref": "#/$defs/PlannedDeploymentUnit" } - }, - "unchanged": { - "type": "array", - "items": { "$ref": "#/$defs/PlannedDeploymentUnit" } - } - } - }, - "DeploymentTarget": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "name", "build_sha256", "source_sha256", "updated_unix_seconds", "activation_state"], - "properties": { - "kind": { "$ref": "#/$defs/DeploymentUnitKind" }, - "name": { "$ref": "#/$defs/unit_name" }, - "build_sha256": { "$ref": "#/$defs/sha256" }, - "source_sha256": { "$ref": "#/$defs/sha256" }, - "git_commit": { "$ref": "#/$defs/git_commit" }, - "updated_unix_seconds": { "type": "integer", "minimum": 0 }, - "activation_state": { "type": "string" } - } - }, - "DeploymentTombstoneRecord": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "name", "updated_unix_seconds", "activation_state"], - "properties": { - "kind": { "$ref": "#/$defs/DeploymentUnitKind" }, - "name": { "$ref": "#/$defs/unit_name" }, - "git_commit": { "$ref": "#/$defs/git_commit" }, - "updated_unix_seconds": { "type": "integer", "minimum": 0 }, - "activation_state": { "type": "string" } - } - }, - "DeploymentTargetState": { - "type": "object", - "additionalProperties": false, - "required": ["format_version", "project", "environment", "revision", "lambdas", "middleware", "tombstones"], - "properties": { - "format_version": { "const": 1 }, - "project": { "$ref": "#/$defs/project_segment" }, - "environment": { "$ref": "#/$defs/project_segment" }, - "revision": { "type": "integer", "minimum": 0 }, - "lambdas": { - "type": "object", - "additionalProperties": { "$ref": "#/$defs/DeploymentTarget" } - }, - "middleware": { - "type": "object", - "additionalProperties": { "$ref": "#/$defs/DeploymentTarget" } - }, - "tombstones": { - "type": "object", - "additionalProperties": { "$ref": "#/$defs/DeploymentTombstoneRecord" } - } - } - } - } -} diff --git a/cert/beamscale-critical-section/interfaces/schema/host-security-incident-receipt.schema.json b/cert/beamscale-critical-section/interfaces/schema/host-security-incident-receipt.schema.json deleted file mode 100644 index bf38299..0000000 --- a/cert/beamscale-critical-section/interfaces/schema/host-security-incident-receipt.schema.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://beamscale.dev/schema/host-security-incident-receipt.schema.json", - "title": "BeamScale host security incident receipt", - "description": "Durable control-plane acknowledgment for a host-authenticated contained incident.", - "type": "object", - "additionalProperties": false, - "required": [ - "incident_id", - "body_sha256", - "host_id", - "received_unix_seconds", - "user_id", - "tenant_id", - "deployment_id", - "runtime_id", - "security_state", - "notification_state", - "duplicate" - ], - "properties": { - "incident_id": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "pattern": "^[A-Za-z0-9._:@+-]+$" - }, - "body_sha256": { - "type": "string", - "pattern": "^[0-9a-f]{64}$" - }, - "host_id": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "pattern": "^[A-Za-z0-9._:-]+$" - }, - "received_unix_seconds": { - "type": "integer", - "minimum": 0 - }, - "user_id": {"$ref": "#/$defs/subject_id"}, - "tenant_id": {"$ref": "#/$defs/subject_id"}, - "deployment_id": {"$ref": "#/$defs/subject_id"}, - "runtime_id": {"$ref": "#/$defs/subject_id"}, - "security_state": { - "const": "blocked" - }, - "notification_state": { - "const": "alert_pending" - }, - "duplicate": { - "type": "boolean" - } - }, - "$defs": { - "subject_id": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "pattern": "^[A-Za-z0-9._:@+-]+$" - } - } -} diff --git a/cert/beamscale-critical-section/interfaces/schema/host-security-incident.schema.json b/cert/beamscale-critical-section/interfaces/schema/host-security-incident.schema.json deleted file mode 100644 index 0a326da..0000000 --- a/cert/beamscale-critical-section/interfaces/schema/host-security-incident.schema.json +++ /dev/null @@ -1,199 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://beamscale.dev/schema/host-security-incident.schema.json", - "title": "BeamScale host security incident", - "description": "Host-derived runtime identity and breakout/quarantine incident contract. User, tenant, deployment and runtime identity are supplied by trusted host/control-plane state, never by tenant callback input.", - "type": "object", - "additionalProperties": false, - "required": [ - "incident_id", - "detected_at", - "sensor", - "report", - "attribution", - "namespaces", - "stage", - "frozen" - ], - "properties": { - "incident_id": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" - }, - "detected_at": { - "type": "string", - "format": "date-time", - "maxLength": 64 - }, - "sensor": { - "type": "string", - "enum": [ - "command-wrapper", - "kernel-exec", - "canary-file", - "firecracker-guest" - ] - }, - "report": { - "$ref": "#/$defs/report" - }, - "attribution": { - "$ref": "#/$defs/attribution" - }, - "namespaces": { - "type": "object", - "additionalProperties": false, - "properties": { - "mnt": { - "type": "string", - "maxLength": 256 - }, - "pid": { - "type": "string", - "maxLength": 256 - }, - "user": { - "type": "string", - "maxLength": 256 - }, - "net": { - "type": "string", - "maxLength": 256 - }, - "cgroup": { - "type": "string", - "maxLength": 256 - }, - "ipc": { - "type": "string", - "maxLength": 256 - }, - "uts": { - "type": "string", - "maxLength": 256 - } - } - }, - "frozen": { - "type": "boolean" - }, - "stage": { - "type": "string", - "enum": [ - "detected", - "contained" - ] - } - }, - "$defs": { - "subject_id": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "pattern": "^[A-Za-z0-9._:@+-]+$" - }, - "runtime_identity": { - "type": "object", - "additionalProperties": false, - "required": [ - "runtime_id", - "user_id", - "tenant_id", - "deployment_id" - ], - "properties": { - "runtime_id": { - "$ref": "#/$defs/subject_id" - }, - "user_id": { - "$ref": "#/$defs/subject_id" - }, - "tenant_id": { - "$ref": "#/$defs/subject_id" - }, - "deployment_id": { - "$ref": "#/$defs/subject_id" - }, - "invocation_id": { - "anyOf": [ - { - "$ref": "#/$defs/subject_id" - }, - { - "type": "null" - } - ] - } - } - }, - "peer": { - "type": "object", - "additionalProperties": false, - "required": [ - "pid", - "uid", - "gid" - ], - "properties": { - "pid": { - "type": "integer", - "minimum": 1 - }, - "uid": { - "type": "integer", - "minimum": 0, - "maximum": 4294967295 - }, - "gid": { - "type": "integer", - "minimum": 0, - "maximum": 4294967295 - } - } - }, - "attribution": { - "type": "object", - "additionalProperties": false, - "required": [ - "peer", - "cgroup_path", - "identity" - ], - "properties": { - "peer": { - "$ref": "#/$defs/peer" - }, - "cgroup_path": { - "type": "string", - "minLength": 1, - "maxLength": 1024, - "pattern": "^/sys/fs/cgroup/beamscale/.+$" - }, - "identity": { - "$ref": "#/$defs/runtime_identity" - } - } - }, - "report": { - "type": "object", - "additionalProperties": false, - "required": [ - "command", - "argc" - ], - "properties": { - "command": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[A-Za-z0-9._+\\-]+$" - }, - "argc": { - "type": "integer", - "minimum": 0, - "maximum": 1024 - } - } - } - } -} diff --git a/cert/beamscale-critical-section/interfaces/schema/module-contract.schema.json b/cert/beamscale-critical-section/interfaces/schema/module-contract.schema.json deleted file mode 100644 index 7a6ee6d..0000000 --- a/cert/beamscale-critical-section/interfaces/schema/module-contract.schema.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://beamscale.dev/schema/module-contract.schema.json", - "title": "BeamScale User Module Contract", - "type": "object", - "additionalProperties": false, - "required": [ - "contract_version", - "context_abi", - "kind", - "name", - "entrypoint", - "execution", - "capabilities" - ], - "properties": { - "contract_version": { "const": "bmscl-module-contract-v1" }, - "context_abi": { "const": "bmscl.context/v1" }, - "semantics_version": { "const": "bmscl.module-semantics/v2" }, - "kind": { - "enum": ["lambda", "http", "rpc", "actor", "worker"] - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_.-]+$" - }, - "entrypoint": { - "type": "string", - "pattern": "^[A-Za-z0-9_./-]+:[A-Za-z0-9_.-]+/[1-9][0-9]*$" - }, - "execution": { - "type": "object", - "additionalProperties": false, - "required": ["invocation", "concurrency", "cancellation"], - "properties": { - "invocation": { - "enum": ["request_response", "mailbox", "event"] - }, - "concurrency": { - "enum": ["isolated_invocation", "serialized_actor"] - }, - "cancellation": { - "enum": ["deadline", "cooperative"] - } - } - }, - "capabilities": { - "type": "array", - "uniqueItems": true, - "items": { - "enum": ["cluster_call", "log"] - } - } - }, - "allOf": [ - { - "if": { "properties": { "kind": { "const": "actor" } }, "required": ["kind"] }, - "then": { - "properties": { - "execution": { - "allOf": [ - { "properties": { "invocation": { "const": "mailbox" } } }, - { "properties": { "concurrency": { "const": "serialized_actor" } } }, - { "properties": { "cancellation": { "const": "cooperative" } } } - ] - } - } - } - }, - { - "if": { "properties": { "kind": { "enum": ["lambda", "http", "rpc"] } }, "required": ["kind"] }, - "then": { - "properties": { - "execution": { - "allOf": [ - { "properties": { "invocation": { "const": "request_response" } } }, - { "properties": { "concurrency": { "const": "isolated_invocation" } } } - ] - } - } - } - }, - { - "if": { "properties": { "kind": { "const": "worker" } }, "required": ["kind"] }, - "then": { - "properties": { - "execution": { - "properties": { - "invocation": { "enum": ["mailbox", "event"] } - } - } - } - } - } - ] -} diff --git a/cert/beamscale-critical-section/interfaces/schema/tenant-runtime.schema.json b/cert/beamscale-critical-section/interfaces/schema/tenant-runtime.schema.json deleted file mode 100644 index a750057..0000000 --- a/cert/beamscale-critical-section/interfaces/schema/tenant-runtime.schema.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://beamscale.dev/schema/tenant-runtime.schema.json", - "title": "BeamScale Tenant Runtime", - "type": "object", - "additionalProperties": false, - "required": ["identity", "policy", "state"], - "properties": { - "identity": { "$ref": "#/$defs/tenant_runtime_identity" }, - "policy": { "$ref": "#/$defs/tenant_runtime_policy" }, - "state": { "$ref": "#/$defs/tenant_runtime_state" }, - "deployment_id": { "type": "string", "minLength": 1 }, - "worker_version_id": { "type": "string", "minLength": 1 }, - "worker_version_digest": { "$ref": "#/$defs/sha256" }, - "host_id": { "type": "string", "minLength": 1 } - }, - "$defs": { - "sha256": { - "type": "string", - "pattern": "^[0-9a-f]{64}$" - }, - "tenant_runtime_identity": { - "type": "object", - "additionalProperties": false, - "required": ["tenant_id", "shard_id", "runtime_epoch"], - "properties": { - "tenant_id": { "type": "string", "minLength": 1 }, - "shard_id": { "type": "string", "minLength": 1 }, - "runtime_epoch": { "type": "integer", "minimum": 1 } - } - }, - "tenant_isolation": { - "type": "string", - "enum": ["micro_vm", "linux_container"] - }, - "microvm_backend": { - "type": "string", - "enum": ["firecracker", "cloud_hypervisor"] - }, - "tenant_runtime_state": { - "type": "string", - "enum": [ - "cold", - "starting", - "hot", - "warm_idle", - "hibernated", - "draining", - "terminated" - ] - }, - "tenant_runtime_policy": { - "type": "object", - "additionalProperties": false, - "required": [ - "policy_version", - "isolation", - "microvm_backend", - "vcpu_count", - "min_memory_bytes", - "max_memory_bytes", - "warm_idle_ms", - "hibernate_after_ms", - "destroy_after_ms", - "allow_snapshot_restore" - ], - "properties": { - "policy_version": { "const": "bmscl-tenant-runtime-v1" }, - "isolation": { "$ref": "#/$defs/tenant_isolation" }, - "microvm_backend": { "$ref": "#/$defs/microvm_backend" }, - "vcpu_count": { "type": "integer", "minimum": 1 }, - "min_memory_bytes": { "type": "integer", "minimum": 67108864 }, - "max_memory_bytes": { "type": "integer", "minimum": 67108864 }, - "warm_idle_ms": { "type": "integer", "minimum": 1 }, - "hibernate_after_ms": { "type": "integer", "minimum": 1 }, - "destroy_after_ms": { "type": "integer", "minimum": 1 }, - "allow_snapshot_restore": { "type": "boolean" } - } - }, - "metering_sample": { - "type": "object", - "additionalProperties": false, - "required": [ - "identity", - "request_count", - "cpu_ns", - "memory_byte_ms", - "ingress_bytes", - "egress_bytes", - "durable_storage_byte_ms" - ], - "properties": { - "identity": { "$ref": "#/$defs/tenant_runtime_identity" }, - "request_count": { "type": "integer", "minimum": 0 }, - "cpu_ns": { "type": "integer", "minimum": 0 }, - "memory_byte_ms": { "type": "integer", "minimum": 0 }, - "ingress_bytes": { "type": "integer", "minimum": 0 }, - "egress_bytes": { "type": "integer", "minimum": 0 }, - "durable_storage_byte_ms": { "type": "integer", "minimum": 0 } - } - } - } -} diff --git a/cert/beamscale-critical-section/interfaces/schema/worker-manifest.schema.json b/cert/beamscale-critical-section/interfaces/schema/worker-manifest.schema.json deleted file mode 100644 index 9241506..0000000 --- a/cert/beamscale-critical-section/interfaces/schema/worker-manifest.schema.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://beamscale.dev/schema/worker-manifest.schema.json", - "title": "BeamScale Worker Artifact Manifest", - "type": "object", - "additionalProperties": false, - "required": [ - "format_version", - "runtime", - "language", - "profile", - "source_sha256", - "build_sha256", - "entrypoint", - "capabilities", - "runtime_limits" - ], - "properties": { - "format_version": { "const": 1 }, - "runtime": { "const": "beam" }, - "language": { "const": "gleam" }, - "profile": { "type": "string", "minLength": 1 }, - "source_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "build_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "entrypoint": { "type": "string", "pattern": "^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*/[0-9]+$" }, - "capabilities": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["name"], - "properties": { - "name": { "type": "string", "pattern": "^ctx\\.[a-z][a-z0-9_.-]*$" }, - "scope": {} - } - } - }, - "runtime_limits": { - "type": "object", - "additionalProperties": false, - "required": ["max_wall_ms", "max_reductions", "max_heap_bytes", "max_processes"], - "properties": { - "max_wall_ms": { "type": "integer", "minimum": 1, "maximum": 300000 }, - "max_reductions": { "type": "integer", "minimum": 1 }, - "max_heap_bytes": { "type": "integer", "minimum": 1048576 }, - "max_processes": { "const": 1, "description": "Exactly one tenant-owned BEAM process exists per invocation; child concurrency is expressed as additional HTTP/cluster invocations." }, - "max_mailbox_bytes": { "type": "integer", "minimum": 1024 }, - "max_output_bytes": { "type": "integer", "minimum": 1 }, - "max_outbound_requests": { "type": "integer", "minimum": 0 } - } - } - } -} diff --git a/cert/beamscale-critical-section/interfaces/tests/module-contract-parity.test.mjs b/cert/beamscale-critical-section/interfaces/tests/module-contract-parity.test.mjs deleted file mode 100644 index ec0c068..0000000 --- a/cert/beamscale-critical-section/interfaces/tests/module-contract-parity.test.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import test from 'node:test'; - -const typespec = fs.readFileSync('typespec/main.tsp', 'utf8'); -const schema = JSON.parse(fs.readFileSync('schema/module-contract.schema.json', 'utf8')); - -const expectedKinds = ['lambda', 'http', 'rpc', 'actor', 'worker']; -const expectedCapabilities = ['cluster_call', 'log']; - -function enumValues(name) { - const match = typespec.match(new RegExp(`enum\\s+${name}\\s*\\{([\\s\\S]*?)\\}`)); - assert.ok(match, `missing TypeSpec enum ${name}`); - return match[1].split(',').map((value) => value.trim()).filter(Boolean); -} - -function requireTypeSpec(pattern, label) { - assert.match(typespec, pattern, label); -} - -test('peer authorities agree on identity and closed vocabularies', () => { - assert.equal(schema.$schema, 'https://json-schema.org/draft/2020-12/schema'); - assert.equal(schema.properties.contract_version.const, 'bmscl-module-contract-v1'); - assert.equal(schema.properties.context_abi.const, 'bmscl.context/v1'); - assert.equal(schema.properties.semantics_version.const, 'bmscl.module-semantics/v2'); - - assert.deepEqual(enumValues('ModuleKind'), expectedKinds); - assert.deepEqual(schema.properties.kind.enum, expectedKinds); - assert.deepEqual(enumValues('HostedCapability'), expectedCapabilities); - assert.deepEqual(schema.properties.capabilities.items.enum, expectedCapabilities); -}); - -test('TypeSpec makes request-response execution kind-constrained', () => { - requireTypeSpec(/model\s+RequestResponseExecution\s*\{[\s\S]*?invocation:\s*"request_response";[\s\S]*?concurrency:\s*"isolated_invocation";/, 'request-response execution must be fixed'); - for (const [model, kind] of [ - ['LambdaModuleDescriptor', 'lambda'], - ['HttpModuleDescriptor', 'http'], - ['RpcModuleDescriptor', 'rpc'], - ]) { - requireTypeSpec(new RegExp(`model\\s+${model}\\s+extends\\s+ModuleDescriptorBase\\s*\\{[\\s\\S]*?kind:\\s*"${kind}";[\\s\\S]*?execution:\\s*RequestResponseExecution;`), `${kind} must use request-response execution`); - } -}); - -test('actor semantics are fixed identically in both authorities', () => { - requireTypeSpec(/model\s+ActorExecution\s*\{[\s\S]*?invocation:\s*"mailbox";[\s\S]*?concurrency:\s*"serialized_actor";[\s\S]*?cancellation:\s*"cooperative";/, 'actor execution must be fixed'); - requireTypeSpec(/model\s+ActorModuleDescriptor\s+extends\s+ModuleDescriptorBase\s*\{[\s\S]*?kind:\s*"actor";[\s\S]*?execution:\s*ActorExecution;/, 'actor descriptor must use actor execution'); - - const actorRule = schema.allOf.find((rule) => rule?.if?.properties?.kind?.const === 'actor'); - assert.ok(actorRule, 'JSON Schema missing actor rule'); - const constraints = actorRule.then.properties.execution.allOf.map((rule) => rule.properties); - assert.deepEqual(constraints, [ - { invocation: { const: 'mailbox' } }, - { concurrency: { const: 'serialized_actor' } }, - { cancellation: { const: 'cooperative' } }, - ]); -}); - -test('worker invocation remains mailbox-or-event without widening capabilities', () => { - requireTypeSpec(/model\s+WorkerExecution\s*\{[\s\S]*?invocation:\s*"mailbox"\s*\|\s*"event";/, 'worker invocation must be mailbox or event'); - requireTypeSpec(/model\s+WorkerModuleDescriptor\s+extends\s+ModuleDescriptorBase\s*\{[\s\S]*?kind:\s*"worker";[\s\S]*?execution:\s*WorkerExecution;/, 'worker descriptor must use worker execution'); - - const workerRule = schema.allOf.find((rule) => rule?.if?.properties?.kind?.const === 'worker'); - assert.deepEqual(workerRule.then.properties.execution.properties.invocation.enum, ['mailbox', 'event']); - assert.deepEqual(schema.properties.capabilities.items.enum, expectedCapabilities); -}); - -test('ModuleDescriptor is a closed discriminated union in TypeSpec', () => { - requireTypeSpec(/union\s+ModuleDescriptor\s*\{[\s\S]*?lambda:\s*LambdaModuleDescriptor,[\s\S]*?http:\s*HttpModuleDescriptor,[\s\S]*?rpc:\s*RpcModuleDescriptor,[\s\S]*?actor:\s*ActorModuleDescriptor,[\s\S]*?worker:\s*WorkerModuleDescriptor,[\s\S]*?\}/, 'module descriptor union must enumerate every kind'); -}); diff --git a/cert/beamscale-critical-section/interfaces/tests/module-projections.test.mjs b/cert/beamscale-critical-section/interfaces/tests/module-projections.test.mjs deleted file mode 100644 index 7b4e3b6..0000000 --- a/cert/beamscale-critical-section/interfaces/tests/module-projections.test.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import test from 'node:test'; - -const manifest = JSON.parse(fs.readFileSync('module-projections/manifest.json', 'utf8')); -const directGuestLanguages = ['erlang', 'gleam']; - -function checkedExample(entry) { - assert.ok(entry.example, `${entry.id} must provide an example`); - assert.equal(path.isAbsolute(entry.example), false, `${entry.id}: example must be repository-relative`); - assert.equal(entry.example.split('/').includes('..'), false, `${entry.id}: example must not traverse parents`); - const normalized = path.normalize(entry.example); - assert.equal(normalized.startsWith(`..${path.sep}`), false, `${entry.id}: example escapes repository`); - return normalized; -} - -test('projection manifest keeps the direct guest boundary BEAM-only', () => { - assert.equal(manifest.version, 'bmscl.module-projections/v1'); - assert.equal(manifest.runtime_family, 'beam'); - assert.deepEqual(manifest.rules.direct_guest_languages, directGuestLanguages); - assert.equal(manifest.rules.non_beam_projection_does_not_imply_guest_support, true); - assert.equal(manifest.rules.capabilities_are_runtime_grants_not_imports, true); - - const ids = manifest.languages.map((entry) => entry.id); - assert.equal(new Set(ids).size, ids.length, 'projection ids must be unique'); - - const direct = manifest.languages - .filter((entry) => entry.role === 'direct_guest') - .map((entry) => entry.id) - .sort(); - - assert.deepEqual(direct, [...directGuestLanguages].sort()); -}); - -test('direct guest projections have confined non-empty examples', () => { - for (const entry of manifest.languages.filter((item) => item.role === 'direct_guest')) { - const example = checkedExample(entry); - assert.ok(fs.existsSync(example), `${entry.id}: missing ${example}`); - const stat = fs.statSync(example); - assert.ok(stat.isFile(), `${entry.id}: example must be a regular file`); - assert.ok(stat.size > 0, `${entry.id}: empty projection`); - } -}); - -test('Erlang projection retains behaviour and callback obligations', () => { - const behaviour = fs.readFileSync('module-projections/erlang/bmscl_module.erl', 'utf8'); - const example = fs.readFileSync('module-projections/erlang/example_module.erl', 'utf8'); - - assert.match(behaviour, /-callback\s+handle\([\s\S]*?\)\s*->/); - assert.match(behaviour, /-callback\s+module_kind\(\)\s*->\s*lambda\s*\|\s*http\s*\|\s*rpc\s*\|\s*actor\s*\|\s*worker\./); - assert.match(example, /-behaviour\(bmscl_module\)\./); - assert.match(example, /-export\(\[handle\/2,\s*module_kind\/0,\s*module_name\/0\]\)\./); -}); - -test('Gleam projection stays a valid expression-oriented typed export shape', () => { - const source = fs.readFileSync('module-projections/gleam/module_contract.gleam', 'utf8'); - - assert.match(source, /pub\s+opaque\s+type\s+Module\(context,\s*input,\s*output\)/); - assert.match(source, /handle:\s*fn\(context,\s*input\)\s*->\s*Result\(output,\s*String\)/); - assert.doesNotMatch(source, /\breturn\b/, 'Gleam has no return keyword; the final expression is returned'); -}); - -test('non-BEAM projections cannot claim direct guest execution', () => { - for (const entry of manifest.languages) { - if (!directGuestLanguages.includes(entry.id)) { - assert.notEqual(entry.role, 'direct_guest', `${entry.id} widened the guest boundary`); - } - } -}); - -test('projection metadata cannot widen runtime capabilities', () => { - assert.equal(manifest.rules.capabilities_are_runtime_grants_not_imports, true); - for (const entry of manifest.languages) { - assert.equal('capabilities' in entry, false, `${entry.id}: projection metadata must not grant capabilities`); - assert.equal('imports' in entry, false, `${entry.id}: projection metadata must not infer capabilities from imports`); - } -}); diff --git a/cert/beamscale-critical-section/interfaces/typespec/main.tsp b/cert/beamscale-critical-section/interfaces/typespec/main.tsp deleted file mode 100644 index 24a77ff..0000000 --- a/cert/beamscale-critical-section/interfaces/typespec/main.tsp +++ /dev/null @@ -1,467 +0,0 @@ -import "@typespec/http"; -import "./security.tsp"; - -using TypeSpec.Http; - -namespace BeamScale; - -scalar Sha256 extends string; -scalar CapabilityName extends string; -scalar DeploymentId extends string; -scalar WorkerVersionId extends string; -scalar TenantId extends string; -scalar ShardId extends string; -scalar RuntimeEpoch extends int64; -scalar DeploymentRevision extends int64; - -enum ModuleKind { - lambda, - http, - rpc, - actor, - worker, -} - -enum InvocationMode { - request_response, - mailbox, - event, -} - -enum ConcurrencyModel { - isolated_invocation, - serialized_actor, -} - -enum CancellationModel { - deadline, - cooperative, -} - -enum HostedCapability { - cluster_call, - log, -} - -model RequestResponseExecution { - invocation: "request_response"; - concurrency: "isolated_invocation"; - cancellation: CancellationModel; -} - -model ActorExecution { - invocation: "mailbox"; - concurrency: "serialized_actor"; - cancellation: "cooperative"; -} - -model WorkerExecution { - invocation: "mailbox" | "event"; - concurrency: ConcurrencyModel; - cancellation: CancellationModel; -} - -model ModuleDescriptorBase { - contract_version: "bmscl-module-contract-v1"; - context_abi: "bmscl.context/v1"; - semantics_version?: "bmscl.module-semantics/v2"; - name: string; - entrypoint: string; - capabilities: HostedCapability[]; -} - -model LambdaModuleDescriptor extends ModuleDescriptorBase { - kind: "lambda"; - execution: RequestResponseExecution; -} - -model HttpModuleDescriptor extends ModuleDescriptorBase { - kind: "http"; - execution: RequestResponseExecution; -} - -model RpcModuleDescriptor extends ModuleDescriptorBase { - kind: "rpc"; - execution: RequestResponseExecution; -} - -model ActorModuleDescriptor extends ModuleDescriptorBase { - kind: "actor"; - execution: ActorExecution; -} - -model WorkerModuleDescriptor extends ModuleDescriptorBase { - kind: "worker"; - execution: WorkerExecution; -} - -union ModuleDescriptor { - lambda: LambdaModuleDescriptor, - http: HttpModuleDescriptor, - rpc: RpcModuleDescriptor, - actor: ActorModuleDescriptor, - worker: WorkerModuleDescriptor, -} - -model RuntimeBudget { - max_wall_ms: int64; - max_reductions: int64; - max_heap_bytes: int64; - max_processes: 1; - max_mailbox_bytes?: int64; - max_output_bytes?: int64; - max_outbound_requests?: int32; -} - -model CapabilityGrant { - name: CapabilityName; - scope?: unknown; -} - -model ArtifactManifest { - format_version: int32; - runtime: "beam"; - language: "gleam"; - profile: string; - source_sha256: Sha256; - build_sha256: Sha256; - entrypoint: string; - capabilities: CapabilityGrant[]; - runtime_limits: RuntimeBudget; -} - -enum TenantIsolation { - micro_vm, - linux_container, -} - -enum MicroVmBackend { - firecracker, - cloud_hypervisor, -} - -enum TenantRuntimeState { - cold, - starting, - hot, - warm_idle, - hibernated, - draining, - terminated, -} - -model TenantRuntimeIdentity { - tenant_id: TenantId; - shard_id: ShardId; - runtime_epoch: RuntimeEpoch; -} - -model TenantRuntimePolicy { - policy_version: "bmscl-tenant-runtime-v1"; - isolation: TenantIsolation; - microvm_backend: MicroVmBackend; - vcpu_count: int32; - min_memory_bytes: int64; - max_memory_bytes: int64; - warm_idle_ms: int64; - hibernate_after_ms: int64; - destroy_after_ms: int64; - allow_snapshot_restore: boolean; -} - -model TenantRuntimeStatus { - identity: TenantRuntimeIdentity; - state: TenantRuntimeState; - deployment_id?: DeploymentId; - worker_version_id?: WorkerVersionId; - worker_version_digest?: Sha256; - host_id?: string; -} - -model MeteringSample { - identity: TenantRuntimeIdentity; - request_count: int64; - cpu_ns: int64; - memory_byte_ms: int64; - ingress_bytes: int64; - egress_bytes: int64; - durable_storage_byte_ms: int64; -} - -model InvocationRequest { - invocation_id: string; - root_invocation_id?: string; - parent_invocation_id?: string; - call_depth?: int32; - deployment_id: DeploymentId; - worker_version_id: WorkerVersionId; - method: string; - url: string; - headers: Record; - body_base64?: string; - deadline_unix_ms: int64; -} - -model InvocationResponse { - status: int32; - headers: Record; - body_base64?: string; - cpu_reductions: int64; - wall_ms: int64; - peak_heap_bytes?: int64; - termination_reason?: string; -} - -// Deployment desired state is intentionally typed by lifecycle ownership. -// lambda units are P3 worker generations. middleware units are P2/daddy inputs. -enum DeploymentUnitKind { - lambda, - middleware, -} - -model DeploymentUnitIdentity { - kind: DeploymentUnitKind; - name: string; -} - -model DeploymentUnitCandidate extends DeploymentUnitIdentity { - build_sha256: Sha256; - source_sha256: Sha256; -} - -model DeploymentUnitTombstone extends DeploymentUnitIdentity {} - -// Wire-compatibility input for the pre-typed v1 deployment clients. -// Implementations canonicalize these entries to kind=lambda. -model LegacyLambdaCandidate { - name: string; - build_sha256: Sha256; - source_sha256: Sha256; -} - -model DeploymentDesiredStateChange { - project: string; - environment: string; - git_commit?: string; - force?: boolean; - units?: DeploymentUnitCandidate[]; - tombstones?: DeploymentUnitTombstone[]; - lambdas?: LegacyLambdaCandidate[]; -} - -model DeploymentPlanRequest extends DeploymentDesiredStateChange {} - -model DeploymentApplyRequest extends DeploymentDesiredStateChange { - expected_revision: DeploymentRevision; -} - -enum DeploymentPlanReason { - forced, - not_deployed, - build_changed, - artifact_missing, - up_to_date, - deleted, - already_deleted, -} - -model PlannedDeploymentUnit extends DeploymentUnitIdentity { - tombstone: boolean; - build_sha256?: Sha256; - source_sha256?: Sha256; - current_build_sha256?: Sha256; - artifact_present: boolean; - reason: DeploymentPlanReason; -} - -model DeploymentPlanResponse { - project: string; - environment: string; - revision: DeploymentRevision; - changed: PlannedDeploymentUnit[]; - unchanged: PlannedDeploymentUnit[]; -} - -model DeploymentApplyResponse { - project: string; - environment: string; - previous_revision: DeploymentRevision; - revision: DeploymentRevision; - applied: PlannedDeploymentUnit[]; - unchanged: PlannedDeploymentUnit[]; -} - -model DeploymentTarget extends DeploymentUnitIdentity { - build_sha256: Sha256; - source_sha256: Sha256; - git_commit?: string; - updated_unix_seconds: int64; - activation_state: string; -} - -model DeploymentTombstoneRecord extends DeploymentUnitIdentity { - git_commit?: string; - updated_unix_seconds: int64; - activation_state: string; -} - -model DeploymentTargetState { - format_version: int32; - project: string; - environment: string; - revision: DeploymentRevision; - lambdas: Record; - middleware: Record; - tombstones: Record; -} - -enum DurableObjectLanguage { - erlang, - gleam, -} - -model CriticalSectionDeploymentRequest { - deployment_id?: DeploymentId; - tenant_id: TenantId; - `namespace`: string; - language: DurableObjectLanguage; - build_sha256: Sha256; - tenancy_class?: "tenant_dedicated"; -} - -@minValue(1) -@maxValue(9007199254740991) -scalar CriticalSectionSequence extends safeint; - -model CriticalSectionToken { - runtime_epoch: RuntimeEpoch; - owner_epoch: int64; - sequence: CriticalSectionSequence; -} - -@minLength(1) -@maxLength(256) -scalar CriticalSectionRequestId extends string; - -model CriticalSectionAcquireRequest { - key: string; - holder: string; - request_id: CriticalSectionRequestId; - lease_ms: int64; -} - -model CriticalSectionRenewRequest { - key: string; - holder: string; - lease_ms: int64; - token: CriticalSectionToken; -} - -model CriticalSectionReleaseRequest { - key: string; - holder: string; - token: CriticalSectionToken; -} - -model CriticalSectionGrant { - `op`: "critical_section_result"; - operation: "acquire" | "renew"; - ok: true; - token: CriticalSectionToken; - expires_at_ms: int64; -} - -model CriticalSectionReleased { - `op`: "critical_section_result"; - operation: "release"; - ok: true; -} - -model CriticalSectionRefused { - `op`: "critical_section_result"; - operation: "acquire" | "renew" | "release"; - ok: false; - error_code: string; - remaining_ms?: int64; -} - -union CriticalSectionResult { - grant: CriticalSectionGrant, - released: CriticalSectionReleased, - refused: CriticalSectionRefused, -} - -model CriticalSectionDeployment { - deployment_id: DeploymentId; - user_id: string; - tenant_id: TenantId; - `namespace`: string; - runtime: "beam"; - language: DurableObjectLanguage; - profile: "bmscl-critical-section-v1"; - build_sha256: Sha256; - tenancy_class: "tenant_dedicated"; - execution_class: "durable_actor"; - isolation_class: "firecracker"; - shard_id: ShardId; - runtime_host: string; - runtime_epoch: RuntimeEpoch; - runtime_state: string; -} - -@service(#{ title: "BeamScale API" }) -namespace Api { - @route("/v1/critical-sections/deployments") - interface CriticalSectionDeployments { - @post deploy(@body request: CriticalSectionDeploymentRequest): CriticalSectionDeployment; - } - - @route("/v1/critical-sections/{deployment_id}/acquire") - interface CriticalSectionAcquire { - @post acquire( - @path deployment_id: DeploymentId, - @body request: CriticalSectionAcquireRequest, - ): CriticalSectionResult; - } - - @route("/v1/critical-sections/{deployment_id}/renew") - interface CriticalSectionRenew { - @post renew( - @path deployment_id: DeploymentId, - @body request: CriticalSectionRenewRequest, - ): CriticalSectionResult; - } - - @route("/v1/critical-sections/{deployment_id}/release") - interface CriticalSectionRelease { - @post release( - @path deployment_id: DeploymentId, - @body request: CriticalSectionReleaseRequest, - ): CriticalSectionResult; - } - - @route("/v1/invocations/{deployment_id}") - interface Invocations { - @post invoke(@path deployment_id: string, @body request: InvocationRequest): InvocationResponse; - } -} - -namespace AdminApi { - @route("/v1/admin/deployment-plans") - interface DeploymentPlans { - @post plan(@body request: DeploymentPlanRequest): DeploymentPlanResponse; - } - - @route("/v1/admin/deployment-targets/{project}/{environment}") - interface DeploymentTargetStateRead { - @get get( - @path project: string, - @path environment: string, - ): DeploymentTargetState; - } - - @route("/v1/admin/deployment-targets/apply") - interface DeploymentTargets { - @post apply(@body request: DeploymentApplyRequest): DeploymentApplyResponse; - } -} diff --git a/cert/beamscale-critical-section/interfaces/typespec/security.tsp b/cert/beamscale-critical-section/interfaces/typespec/security.tsp deleted file mode 100644 index 5004133..0000000 --- a/cert/beamscale-critical-section/interfaces/typespec/security.tsp +++ /dev/null @@ -1,95 +0,0 @@ -import "@typespec/http"; - -using TypeSpec.Http; - -namespace BeamScale; - -scalar UserId extends string; -scalar RuntimeId extends string; -scalar IncidentId extends string; - -model HostRuntimeIdentity { - runtime_id: RuntimeId; - user_id: UserId; - tenant_id: TenantId; - deployment_id: DeploymentId; - invocation_id?: string | null; -} - -model HostPeerCredentials { - pid: int32; - uid: int64; - gid: int64; -} - -model HostAttribution { - peer: HostPeerCredentials; - cgroup_path: string; - identity: HostRuntimeIdentity; -} - -model HostTripwireReport { - command: string; - argc: int32; -} - -enum HostSecuritySensor { - command_wrapper: "command-wrapper", - kernel_exec: "kernel-exec", - canary_file: "canary-file", - firecracker_guest: "firecracker-guest", -} - -enum HostSecurityIncidentStage { - detected, - contained, -} - -model HostNamespaceEvidence { - mnt?: string; - pid?: string; - user?: string; - net?: string; - cgroup?: string; - ipc?: string; - uts?: string; -} - -model HostSecurityIncident { - incident_id: IncidentId; - detected_at: string; - sensor: HostSecuritySensor; - stage: HostSecurityIncidentStage; - report: HostTripwireReport; - attribution: HostAttribution; - namespaces: HostNamespaceEvidence; - frozen: boolean; -} - -model HostSecurityIncidentReceipt { - incident_id: IncidentId; - body_sha256: string; - host_id: string; - received_unix_seconds: int64; - user_id: UserId; - tenant_id: TenantId; - deployment_id: DeploymentId; - runtime_id: RuntimeId; - security_state: "blocked"; - notification_state: "alert_pending"; - duplicate: boolean; -} - -namespace AdminApi { - @route("/v1/admin/security/host-incidents") - interface HostSecurityIncidents { - @post ingest( - @header("x-bmscl-host-id") host_id: string, - @header("x-bmscl-timestamp") timestamp: string, - @header("x-bmscl-nonce") nonce: string, - @header("x-bmscl-body-sha256") body_sha256: string, - @header("x-bmscl-signature") signature: string, - @body incident: HostSecurityIncident, - ): HostSecurityIncidentReceipt; - } -} diff --git a/cert/beamscale-critical-section/supervisor/conformance/critical_section_formal_model.rs b/cert/beamscale-critical-section/supervisor/conformance/critical_section_formal_model.rs deleted file mode 100644 index 883cac3..0000000 --- a/cert/beamscale-critical-section/supervisor/conformance/critical_section_formal_model.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! Bounded model checker for durable critical-section failover. -//! The model focuses on fencing/lease handoff, not Redis implementation details. - -use std::collections::{HashSet, VecDeque}; - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -enum Holder { - None, - A, - B, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -enum RequestId { - None, - R1, - R2, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -struct State { - now: u8, - current_owner_epoch: u8, - lease_owner_epoch: u8, - sequence: u8, - holder: Holder, - request_id: RequestId, - expires_at: u8, - inherited: bool, -} - -#[derive(Clone, Copy, Debug)] -enum Action { - Tick, - Failover, - Acquire { holder: Holder, request_id: RequestId }, - Renew { holder: Holder, owner_epoch: u8, sequence: u8 }, - Release { holder: Holder, owner_epoch: u8, sequence: u8 }, -} - -const MAX_TIME: u8 = 4; -const MAX_OWNER_EPOCH: u8 = 3; -const MAX_SEQUENCE: u8 = 4; -const LEASE: u8 = 2; - -fn initial() -> State { - State { - now: 0, - current_owner_epoch: 1, - lease_owner_epoch: 0, - sequence: 0, - holder: Holder::None, - request_id: RequestId::None, - expires_at: 0, - inherited: false, - } -} - -fn expire(mut s: State) -> State { - if s.holder != Holder::None && s.now >= s.expires_at { - s.holder = Holder::None; - s.request_id = RequestId::None; - s.expires_at = 0; - s.inherited = false; - } - s -} - -fn valid_holder(holder: Holder) -> bool { - matches!(holder, Holder::A | Holder::B) -} - -fn owns(s: State, holder: Holder, owner_epoch: u8, sequence: u8) -> bool { - s.holder == holder - && !s.inherited - && s.lease_owner_epoch == owner_epoch - && s.current_owner_epoch == owner_epoch - && s.sequence == sequence - && s.now < s.expires_at -} - -fn invariant(s: State) -> bool { - if s.current_owner_epoch == 0 || s.current_owner_epoch > MAX_OWNER_EPOCH { - return false; - } - if s.sequence > MAX_SEQUENCE { - return false; - } - match s.holder { - Holder::None => s.expires_at == 0 && !s.inherited && s.request_id == RequestId::None, - Holder::A | Holder::B => { - s.expires_at > s.now - && s.request_id != RequestId::None - && s.lease_owner_epoch > 0 - && s.lease_owner_epoch <= s.current_owner_epoch - && (!s.inherited || s.lease_owner_epoch < s.current_owner_epoch) - && (s.inherited || s.lease_owner_epoch == s.current_owner_epoch) - } - } -} - -fn step(input: State, action: Action) -> Option { - let s = expire(input); - let next = match action { - Action::Tick if s.now < MAX_TIME => expire(State { now: s.now + 1, ..s }), - Action::Failover if s.current_owner_epoch < MAX_OWNER_EPOCH => { - let inherited = s.holder != Holder::None; - State { - current_owner_epoch: s.current_owner_epoch + 1, - inherited, - ..s - } - } - Action::Acquire { holder, request_id } - if valid_holder(holder) - && request_id != RequestId::None - && s.holder == Holder::None - && s.sequence < MAX_SEQUENCE => - { - State { - holder, - request_id, - lease_owner_epoch: s.current_owner_epoch, - sequence: s.sequence + 1, - expires_at: (s.now + LEASE).min(MAX_TIME + LEASE), - inherited: false, - ..s - } - } - Action::Acquire { holder, request_id } - if valid_holder(holder) - && request_id != RequestId::None - && !s.inherited - && s.holder == holder - && s.request_id == request_id => - { - // Idempotent replay: no new fence and no TTL extension. - s - } - Action::Renew { - holder, - owner_epoch, - sequence, - } if owns(s, holder, owner_epoch, sequence) => State { - expires_at: (s.now + LEASE).min(MAX_TIME + LEASE), - ..s - }, - Action::Release { - holder, - owner_epoch, - sequence, - } if owns(s, holder, owner_epoch, sequence) => State { - holder: Holder::None, - request_id: RequestId::None, - expires_at: 0, - inherited: false, - ..s - }, - _ => return None, - }; - assert!( - next.current_owner_epoch >= input.current_owner_epoch, - "owner epoch regressed: {:?} -> {:?}", input, next - ); - assert!( - invariant(next), - "invalid state: {:?} --{:?}--> {:?}", - input, - action, - next - ); - Some(next) -} - -fn actions() -> Vec { - let mut out = vec![Action::Tick, Action::Failover]; - for holder in [Holder::A, Holder::B] { - for request_id in [RequestId::R1, RequestId::R2] { - out.push(Action::Acquire { holder, request_id }); - } - for owner_epoch in 1..=MAX_OWNER_EPOCH { - for sequence in 1..=MAX_SEQUENCE { - out.push(Action::Renew { - holder, - owner_epoch, - sequence, - }); - out.push(Action::Release { - holder, - owner_epoch, - sequence, - }); - } - } - } - out -} - -fn main() { - let initial = initial(); - assert!(invariant(initial)); - let mut seen = HashSet::from([initial]); - let mut queue = VecDeque::from([initial]); - let mut transitions = 0usize; - - while let Some(state) = queue.pop_front() { - for action in actions() { - if let Some(next) = step(state, action) { - transitions += 1; - if seen.insert(next) { - queue.push_back(next); - } - } - } - } - - // Witness the key failover shape: an active old lease is inherited under - // a newer owner epoch and cannot become a fresh grant until it expires. - assert!(seen.iter().any(|s| { - s.inherited - && s.holder != Holder::None - && s.lease_owner_epoch < s.current_owner_epoch - })); - assert!(seen.iter().any(|s| { - s.current_owner_epoch >= 2 - && !s.inherited - && s.holder != Holder::None - && s.lease_owner_epoch == s.current_owner_epoch - })); - - // Same request can replay only while the same owner still owns the live - // lease. A failover marks the lease inherited, disabling replay until - // persisted expiry. - let granted = State { - now: 0, - current_owner_epoch: 1, - lease_owner_epoch: 1, - sequence: 1, - holder: Holder::A, - request_id: RequestId::R1, - expires_at: 2, - inherited: false, - }; - assert_eq!( - step( - granted, - Action::Acquire { - holder: Holder::A, - request_id: RequestId::R1, - } - ), - Some(granted), - ); - assert_eq!( - step( - granted, - Action::Acquire { - holder: Holder::A, - request_id: RequestId::R2, - } - ), - None, - ); - let failed_over = step(granted, Action::Failover).expect("failover"); - assert!(failed_over.inherited); - assert_eq!( - step( - failed_over, - Action::Acquire { - holder: Holder::A, - request_id: RequestId::R1, - } - ), - None, - ); - - println!( - "bmscl critical-section formal model: explored {} states / {} transitions; invariants hold", - seen.len(), - transitions - ); -} diff --git a/cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_actor.erl b/cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_actor.erl deleted file mode 100644 index 2905386..0000000 --- a/cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_actor.erl +++ /dev/null @@ -1,213 +0,0 @@ --module(bmscl_critical_section_actor). --behaviour(gen_server). - --define(MAX_FENCING_SEQUENCE, 9007199254740991). - --export([start/1, start/2, acquire/3, acquire/4, renew/4, release/3, status/1]). --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). - -%% Compatibility/test constructor. Production registry code uses start/2 with -%% an owner epoch allocated by the durable store. -start(RuntimeEpoch) when is_integer(RuntimeEpoch), RuntimeEpoch > 0 -> - start(RuntimeEpoch, RuntimeEpoch); -start(_) -> - {error, invalid_runtime_epoch}. - -start(RuntimeEpoch, OwnerEpoch) - when is_integer(RuntimeEpoch), RuntimeEpoch > 0, - is_integer(OwnerEpoch), OwnerEpoch > 0 -> - gen_server:start(?MODULE, {RuntimeEpoch, OwnerEpoch}, []); -start(RuntimeEpoch, Loaded) - when is_integer(RuntimeEpoch), RuntimeEpoch > 0, is_map(Loaded) -> - case maps:get(owner_epoch, Loaded, 0) of - OwnerEpoch when is_integer(OwnerEpoch), OwnerEpoch > 0 -> - gen_server:start(?MODULE, {RuntimeEpoch, Loaded}, []); - _ -> - {error, invalid_owner_epoch} - end; -start(_, _) -> - {error, invalid_owner_epoch}. - -acquire(Pid, Holder, LeaseMs) when is_pid(Pid) -> - acquire(Pid, Holder, undefined, LeaseMs); -acquire(_, _, _) -> - {error, invalid_actor}. - -acquire(Pid, Holder, RequestId, LeaseMs) when is_pid(Pid) -> - gen_server:call(Pid, {acquire, Holder, RequestId, LeaseMs}); -acquire(_, _, _, _) -> - {error, invalid_actor}. - -renew(Pid, Holder, Token, LeaseMs) when is_pid(Pid) -> - gen_server:call(Pid, {renew, Holder, Token, LeaseMs}); -renew(_, _, _, _) -> - {error, invalid_actor}. - -release(Pid, Holder, Token) when is_pid(Pid) -> - gen_server:call(Pid, {release, Holder, Token}); -release(_, _, _) -> - {error, invalid_actor}. - -status(Pid) when is_pid(Pid) -> - gen_server:call(Pid, status); -status(_) -> - {error, invalid_actor}. - -init({RuntimeEpoch, OwnerEpoch}) when is_integer(OwnerEpoch) -> - {ok, #{runtime_epoch => RuntimeEpoch, - owner_epoch => OwnerEpoch, - sequence => 0, - holder => undefined, - request_id => undefined, - expires_at_unix_ms => 0, - persistent => false}}; -init({RuntimeEpoch, Loaded}) when is_map(Loaded) -> - Inherited = maps:get(holder, Loaded, undefined) =/= undefined - andalso maps:get(expires_at_unix_ms, Loaded, 0) > now_unix_ms(), - {ok, Loaded#{runtime_epoch => RuntimeEpoch, - persistent => true, - inherited_lease => Inherited}}. - -handle_call({acquire, Holder, RequestId, LeaseMs}, _From, State0) -> - case valid_holder(Holder) - andalso valid_request_id(RequestId) - andalso valid_lease_ms(LeaseMs) of - false -> - {reply, {error, invalid_acquire}, State0}; - true -> - State = expire(State0), - case maps:get(holder, State) of - undefined -> - case maps:get(sequence, State) of - ?MAX_FENCING_SEQUENCE -> - {reply, {error, fencing_exhausted}, State}; - Sequence0 -> - Sequence = Sequence0 + 1, - ExpiresAt = now_unix_ms() + LeaseMs, - Candidate = State#{sequence => Sequence, - holder => Holder, - request_id => RequestId, - expires_at_unix_ms => ExpiresAt, - inherited_lease => false}, - case persist(Candidate) of - {ok, Next} -> {reply, {ok, grant(Next)}, Next}; - {error, Reason} -> - {reply, {error, {durable_commit_failed, Reason}}, State0} - end - end; - Holder -> - Replay = RequestId =/= undefined - andalso RequestId =:= maps:get(request_id, State, undefined), - case {maps:get(inherited_lease, State, false), Replay} of - {false, true} -> {reply, {ok, grant(State)}, State}; - _ -> {reply, {error, {busy, remaining_ms(State)}}, State} - end; - _Other -> - {reply, {error, {busy, remaining_ms(State)}}, State} - end - end; -handle_call({renew, Holder, Token, LeaseMs}, _From, State0) -> - case valid_holder(Holder) andalso valid_lease_ms(LeaseMs) of - false -> - {reply, {error, invalid_renew}, State0}; - true -> - State = expire(State0), - case owns(State, Holder, Token) of - true -> - Candidate = State#{expires_at_unix_ms => now_unix_ms() + LeaseMs}, - case persist(Candidate) of - {ok, Next} -> {reply, {ok, grant(Next)}, Next}; - {error, Reason} -> - {reply, {error, {durable_commit_failed, Reason}}, State0} - end; - false -> - {reply, {error, stale_or_not_owner}, State} - end - end; -handle_call({release, Holder, Token}, _From, State0) -> - State = expire(State0), - case owns(State, Holder, Token) of - true -> - Candidate = State#{holder => undefined, - request_id => undefined, - expires_at_unix_ms => 0}, - case persist(Candidate) of - {ok, Next} -> {reply, ok, Next}; - {error, Reason} -> - {reply, {error, {durable_commit_failed, Reason}}, State0} - end; - false -> - {reply, {error, stale_or_not_owner}, State} - end; -handle_call(status, _From, State0) -> - State = expire(State0), - {reply, State#{remaining_ms => remaining_ms(State)}, State}; -handle_call(_Request, _From, State) -> - {reply, {error, unsupported_call}, State}. - -handle_cast(_Message, State) -> {noreply, State}. -handle_info(_Message, State) -> {noreply, State}. -terminate(_Reason, _State) -> ok. -code_change(_OldVsn, State, _Extra) -> {ok, State}. - -grant(State) -> - #{token => #{runtime_epoch => maps:get(runtime_epoch, State), - owner_epoch => maps:get(owner_epoch, State), - sequence => maps:get(sequence, State)}, - expires_at_ms => maps:get(expires_at_unix_ms, State)}. - -owns(State, Holder, #{runtime_epoch := RuntimeEpoch, - owner_epoch := OwnerEpoch, - sequence := Sequence}) -> - maps:get(holder, State) =:= Holder - andalso maps:get(runtime_epoch, State) =:= RuntimeEpoch - andalso maps:get(owner_epoch, State) =:= OwnerEpoch - andalso maps:get(sequence, State) =:= Sequence - andalso not maps:get(inherited_lease, State, false) - andalso maps:get(expires_at_unix_ms, State) > now_unix_ms(); -owns(_, _, _) -> false. - -expire(State) -> - case maps:get(holder, State) of - undefined -> State; - _ -> - case maps:get(expires_at_unix_ms, State) =< now_unix_ms() of - true -> State#{holder => undefined, - request_id => undefined, - expires_at_unix_ms => 0, - inherited_lease => false}; - false -> State - end - end. - -remaining_ms(State) -> - erlang:max(0, maps:get(expires_at_unix_ms, State) - now_unix_ms()). - -valid_holder(Value) when is_binary(Value), - byte_size(Value) > 0, - byte_size(Value) =< 256 -> - binary:match(Value, <<0>>) =:= nomatch; -valid_holder(_) -> false. - -valid_request_id(undefined) -> true; -valid_request_id(Value) when is_binary(Value), - byte_size(Value) > 0, - byte_size(Value) =< 256 -> - binary:match(Value, <<0>>) =:= nomatch; -valid_request_id(_) -> false. - -valid_lease_ms(Value) when is_integer(Value), Value > 0 -> - Max = application:get_env(bmscl_supervisor, critical_section_max_lease_ms, 300000), - Value =< Max; -valid_lease_ms(_) -> false. - -persist(State = #{persistent := false}) -> - {ok, State}; -persist(State = #{persistent := true, - store_module := Store, - identity := Identity, - owner_scope := Scope, - owner_epoch := OwnerEpoch}) -> - bmscl_critical_section_store:commit(Store, Identity, Scope, OwnerEpoch, State). - -now_unix_ms() -> erlang:system_time(millisecond). diff --git a/cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_registry.erl b/cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_registry.erl deleted file mode 100644 index 64e629b..0000000 --- a/cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_registry.erl +++ /dev/null @@ -1,188 +0,0 @@ --module(bmscl_critical_section_registry). --behaviour(gen_server). - --export([start_link/0, acquire/6, acquire/7, renew/7, release/6, status/0]). --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). - --record(state, {tenant_id = undefined, actors = #{}, monitors = #{}}). - -start_link() -> - gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). - -acquire(TenantId, Namespace, ObjectKey, RuntimeEpoch, Holder, LeaseMs) -> - acquire(TenantId, Namespace, ObjectKey, RuntimeEpoch, Holder, undefined, LeaseMs). - -acquire(TenantId, Namespace, ObjectKey, RuntimeEpoch, Holder, RequestId, LeaseMs) -> - gen_server:call(?MODULE, - {acquire, TenantId, Namespace, ObjectKey, - RuntimeEpoch, Holder, RequestId, LeaseMs}, infinity). - -renew(TenantId, Namespace, ObjectKey, RuntimeEpoch, Holder, Token, LeaseMs) -> - gen_server:call(?MODULE, - {renew, TenantId, Namespace, ObjectKey, - RuntimeEpoch, Holder, Token, LeaseMs}, infinity). - -release(TenantId, Namespace, ObjectKey, RuntimeEpoch, Holder, Token) -> - gen_server:call(?MODULE, - {release, TenantId, Namespace, ObjectKey, - RuntimeEpoch, Holder, Token}, infinity). - -status() -> gen_server:call(?MODULE, status). - -init([]) -> {ok, #state{}}. - -handle_call({acquire, T, N, K, Epoch, Holder, RequestId, LeaseMs}, _From, State0) -> - with_actor(T, N, K, Epoch, State0, - fun(Pid, State1) -> - {reply, bmscl_critical_section_actor:acquire( - Pid, Holder, RequestId, LeaseMs), State1} - end); -handle_call({renew, T, N, K, Epoch, Holder, Token, LeaseMs}, _From, State0) -> - with_actor(T, N, K, Epoch, State0, - fun(Pid, State1) -> - {reply, bmscl_critical_section_actor:renew(Pid, Holder, Token, LeaseMs), State1} - end); -handle_call({release, T, N, K, Epoch, Holder, Token}, _From, State0) -> - with_actor(T, N, K, Epoch, State0, - fun(Pid, State1) -> - {reply, bmscl_critical_section_actor:release(Pid, Holder, Token), State1} - end); -handle_call(status, _From, State) -> - Actors = maps:map(fun(_Key, Actor) -> maps:without([monitor], Actor) end, - State#state.actors), - {reply, #{tenant_id => State#state.tenant_id, - actor_count => map_size(Actors), - actors => Actors}, State}; -handle_call(_Request, _From, State) -> - {reply, {error, unsupported_call}, State}. - -handle_cast(_Message, State) -> {noreply, State}. - -handle_info({'DOWN', Mon, process, _Pid, _Reason}, State0) -> - case maps:take(Mon, State0#state.monitors) of - error -> {noreply, State0}; - {Key, Monitors1} -> - {noreply, State0#state{ - monitors = Monitors1, - actors = maps:remove(Key, State0#state.actors) - }} - end; -handle_info(_Message, State) -> {noreply, State}. - -terminate(_Reason, State) -> - maps:foreach(fun(_Key, Actor) -> - _ = catch gen_server:stop(maps:get(pid, Actor), normal, 1000) - end, State#state.actors), - ok. - -code_change(_OldVsn, State, _Extra) -> {ok, State}. - -with_actor(T0, N0, K0, Epoch, State0, Fun) - when is_integer(Epoch), Epoch > 0, is_function(Fun, 2) -> - case {text(T0, 1024), namespace(N0), text(K0, 4096)} of - {{ok, T}, {ok, N}, {ok, K}} -> - %% This registry lives inside a tenant-dedicated BEAM VM. Bind the - %% process to the first admitted tenant and fail closed if any - %% cross-tenant call reaches it. - case bind_tenant(T, State0) of - {ok, BoundState} -> - Key = {T, N, K}, - case ensure_actor(Key, Epoch, BoundState) of - {ok, Pid, State1} -> Fun(Pid, State1); - {error, Reason, State1} -> {reply, {error, Reason}, State1} - end; - {error, Reason, State1} -> - {reply, {error, Reason}, State1} - end; - _ -> {reply, {error, invalid_critical_section_identity}, State0} - end; -with_actor(_, _, _, _, State, _Fun) -> - {reply, {error, invalid_runtime_epoch}, State}. - -bind_tenant(Tenant, State = #state{tenant_id = undefined}) -> - {ok, State#state{tenant_id = Tenant}}; -bind_tenant(Tenant, State = #state{tenant_id = Tenant}) -> - {ok, State}; -bind_tenant(_Tenant, State = #state{tenant_id = Existing}) -> - {error, {tenant_vm_mismatch, Existing}, State}. - -ensure_actor(Key, Epoch, State0) -> - case maps:find(Key, State0#state.actors) of - error -> start_actor(Key, Epoch, State0); - {ok, Actor} -> - Pid = maps:get(pid, Actor), - ExistingEpoch = maps:get(runtime_epoch, Actor), - case {is_process_alive(Pid), Epoch - ExistingEpoch} of - {false, _} -> - start_actor(Key, Epoch, remove_actor(Key, Actor, State0)); - {true, 0} -> - {ok, Pid, State0}; - {true, Delta} when Delta > 0 -> - State1 = remove_actor(Key, Actor, State0), - _ = catch gen_server:stop(Pid, normal, 1000), - start_actor(Key, Epoch, State1); - {true, _Negative} -> - {error, {stale_runtime_epoch, ExistingEpoch}, State0} - end - end. - -start_actor({Tenant, Namespace, ObjectKey} = Key, RuntimeEpoch, State0) -> - case bmscl_critical_section_store:claim_and_load( - Tenant, Namespace, ObjectKey) of - {error, Reason} -> - {error, Reason, State0}; - {ok, Loaded} -> - case bmscl_critical_section_actor:start(RuntimeEpoch, Loaded) of - {ok, Pid} -> - Mon = erlang:monitor(process, Pid), - Actor = #{pid => Pid, - monitor => Mon, - runtime_epoch => RuntimeEpoch, - owner_epoch => maps:get(owner_epoch, Loaded), - version => maps:get(version, Loaded)}, - {ok, Pid, State0#state{ - actors = maps:put(Key, Actor, State0#state.actors), - monitors = maps:put(Mon, Key, State0#state.monitors) - }}; - {error, Reason} -> - {error, {critical_section_actor_start_failed, Reason}, State0} - end - end. - -remove_actor(Key, Actor, State0) -> - Mon = maps:get(monitor, Actor), - erlang:demonitor(Mon, [flush]), - State0#state{ - actors = maps:remove(Key, State0#state.actors), - monitors = maps:remove(Mon, State0#state.monitors) - }. - -text(Value, Max) when is_binary(Value), byte_size(Value) > 0, byte_size(Value) =< Max -> - case binary:match(Value, <<0>>) of nomatch -> {ok, Value}; _ -> {error, invalid} end; -text(Value, Max) when is_list(Value) -> text(unicode:characters_to_binary(Value), Max); -text(_, _) -> {error, invalid}. - -namespace(Value) -> - case text(Value, 128) of - {ok, Bin} -> - case re:run(Bin, <<"^[A-Za-z0-9._-]+$">>, [{capture, none}]) of - match -> {ok, Bin}; - nomatch -> {error, invalid} - end; - Error -> Error - end. - - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -tenant_vm_binding_test() -> - S0 = #state{}, - {ok, S1} = bind_tenant(<<"tenant-a">>, S0), - ?assertEqual(<<"tenant-a">>, S1#state.tenant_id), - {ok, S2} = bind_tenant(<<"tenant-a">>, S1), - ?assertEqual(<<"tenant-a">>, S2#state.tenant_id), - ?assertMatch({error, {tenant_vm_mismatch, <<"tenant-a">>}, _}, - bind_tenant(<<"tenant-b">>, S2)). - --endif. diff --git a/cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_store.erl b/cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_store.erl deleted file mode 100644 index 85bafff..0000000 --- a/cert/beamscale-critical-section/supervisor/src/bmscl_critical_section_store.erl +++ /dev/null @@ -1,160 +0,0 @@ --module(bmscl_critical_section_store). - --export([claim_and_load/3, commit/5]). - --define(APPLICATION_ID, <<"critical-sections">>). --define(SCHEMA, <<"bmscl.critical-section.state/v1">>). --define(MAX_FENCING_SEQUENCE, 9007199254740991). - -claim_and_load(Tenant, Namespace, ObjectKey) -> - Store = store_module(), - Scope = owner_scope(Tenant, Namespace, ObjectKey), - Identity = identity(Tenant, Namespace, ObjectKey), - case safe_call(Store, claim_owner, [Scope]) of - {ok, OwnerEpoch} when is_integer(OwnerEpoch), OwnerEpoch > 0 -> - case safe_call(Store, load, [Identity, Scope]) of - {ok, not_found} -> - {ok, #{owner_epoch => OwnerEpoch, - version => 0, - sequence => 0, - holder => undefined, - request_id => undefined, - expires_at_unix_ms => 0, - identity => Identity, - owner_scope => Scope, - store_module => Store}}; - {ok, #{version := Version, state := Payload}} - when is_integer(Version), Version >= 0, is_binary(Payload) -> - case decode(Payload) of - {ok, Persisted} -> - {ok, Persisted#{ - owner_epoch => OwnerEpoch, - version => Version, - identity => Identity, - owner_scope => Scope, - store_module => Store - }}; - {error, Reason} -> - {error, {critical_section_state_decode_failed, Reason}} - end; - {error, Reason} -> - {error, {critical_section_state_load_failed, Reason}}; - Other -> - {error, {invalid_critical_section_load_result, Other}} - end; - {error, Reason} -> - {error, {critical_section_owner_claim_failed, Reason}}; - Other -> - {error, {invalid_critical_section_owner_claim, Other}} - end. - -commit(Store, Identity, Scope, OwnerEpoch, #{version := Version} = State) - when is_atom(Store), is_integer(OwnerEpoch), OwnerEpoch > 0, - is_integer(Version), Version >= 0 -> - Payload = encode(State), - case safe_call(Store, commit, - [Identity, Version, Scope, OwnerEpoch, Payload]) of - {ok, NextVersion} when is_integer(NextVersion), NextVersion =:= Version + 1 -> - {ok, State#{version => NextVersion}}; - {ok, NextVersion} -> - {error, {invalid_critical_section_next_version, NextVersion}}; - {error, Reason} -> - {error, Reason}; - Other -> - {error, {invalid_critical_section_commit_result, Other}} - end. - -identity(Tenant, Namespace, ObjectKey) -> - #{tenant_id => Tenant, - application_id => ?APPLICATION_ID, - namespace => Namespace, - object_key => ObjectKey}. - -owner_scope(Tenant, Namespace, ObjectKey) -> - Hash = crypto:hash( - sha256, - term_to_binary( - {<<"bmscl-critical-section-v1">>, Tenant, Namespace, ObjectKey}, - [deterministic])), - #{tenant_id => Tenant, - application_id => ?APPLICATION_ID, - namespace => Namespace, - virtual_shard => binary:decode_unsigned(Hash)}. - -store_module() -> - application:get_env( - bmscl_supervisor, durable_store_module, bmscl_durable_store_redis). - -encode(State) -> - Holder = nullable_binary(maps:get(holder, State, undefined)), - RequestId = nullable_binary(maps:get(request_id, State, undefined)), - jsx:encode(#{ - <<"schema">> => ?SCHEMA, - <<"sequence">> => maps:get(sequence, State), - <<"holder">> => Holder, - <<"request_id">> => RequestId, - <<"expires_at_unix_ms">> => maps:get(expires_at_unix_ms, State) - }). - -decode(Payload) -> - try jsx:decode(Payload, [return_maps]) of - #{<<"schema">> := ?SCHEMA, - <<"sequence">> := Sequence, - <<"holder">> := Holder0, - <<"request_id">> := RequestId0, - <<"expires_at_unix_ms">> := ExpiresAt} - when is_integer(Sequence), Sequence >= 0, - Sequence =< ?MAX_FENCING_SEQUENCE, - is_integer(ExpiresAt), ExpiresAt >= 0 -> - case {decode_bounded_optional(Holder0, 256), - decode_bounded_optional(RequestId0, 256)} of - {{ok, Holder}, {ok, RequestId}} -> - {ok, #{sequence => Sequence, - holder => Holder, - request_id => RequestId, - expires_at_unix_ms => ExpiresAt}}; - {error, _} -> {error, invalid_holder}; - {_, error} -> {error, invalid_request_id} - end; - _ -> - {error, invalid_payload} - catch - _:_ -> {error, invalid_json} - end. - -nullable_binary(undefined) -> null; -nullable_binary(Value) -> Value. - -decode_bounded_optional(null, _Max) -> {ok, undefined}; -decode_bounded_optional(Value, Max) - when is_binary(Value), byte_size(Value) > 0, byte_size(Value) =< Max -> - case binary:match(Value, <<0>>) of - nomatch -> {ok, Value}; - _ -> error - end; -decode_bounded_optional(_, _) -> error. - -safe_call(Module, Function, Args) -> - try apply(Module, Function, Args) of - Result -> Result - catch - Class:Reason -> {error, {store_exception, Class, Reason}} - end. - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -codec_round_trip_test() -> - State = #{sequence => 7, - holder => <<"holder-a">>, - request_id => <<"request-1">>, - expires_at_unix_ms => 123456, - version => 4}, - ?assertEqual( - {ok, #{sequence => 7, - holder => <<"holder-a">>, - request_id => <<"request-1">>, - expires_at_unix_ms => 123456}}, - decode(encode(State))). - --endif. diff --git a/cert/beamscale-critical-section/supervisor/src/bmscl_durable_store.erl b/cert/beamscale-critical-section/supervisor/src/bmscl_durable_store.erl deleted file mode 100644 index b9d2c0e..0000000 --- a/cert/beamscale-critical-section/supervisor/src/bmscl_durable_store.erl +++ /dev/null @@ -1,91 +0,0 @@ --module(bmscl_durable_store). - -%% Storage contract for BeamScale Durable Actors. -%% -%% Implementations are responsible for durable object state and stale-owner -%% rejection. The in-memory runtime registry is not a fencing authority. -%% -%% Durable state crosses process/node/release boundaries, so this contract uses -%% an opaque binary payload rather than arbitrary Erlang terms. Trusted runtime -%% codecs may encode structured values before storage, but PIDs, refs, funs, -%% ports, and other VM-local terms must never become part of the durable format. -%% -%% Fencing is scoped to a virtual shard, not to one object. That is essential: -%% after failover, a newly assigned shard owner must fence the stale owner from -%% every object in the shard even before the new owner has loaded each object. - --export_type([ - identity/0, - owner_scope/0, - owner_epoch/0, - version/0, - loaded/0, - claim_result/0, - commit_result/0 -]). - -%% Stable logical identity. Physical placement is deliberately excluded so -%% shard/bucket/node changes never change the durable object's key. --type identity() :: #{ - tenant_id := binary(), - application_id := binary(), - namespace := binary(), - object_key := binary() -}. - -%% Fencing scope for one virtual shard. Actor buckets may be regrouped later, -%% but a shard remains the minimum independently movable ownership unit in V1. --type owner_scope() :: #{ - tenant_id := binary(), - application_id := binary(), - namespace := binary(), - virtual_shard := non_neg_integer() -}. - --type owner_epoch() :: pos_integer(). --type version() :: non_neg_integer(). - --type loaded() :: #{ - version := version(), - state := binary() -}. - --type claim_result() :: - {ok, owner_epoch()} - | {error, term()}. - --type commit_result() :: - {ok, version()} - | {error, {stale_version, version()}} - | {error, {stale_owner_epoch, owner_epoch()}} - | {error, term()}. - -%% Load the latest durable object state. Loading state does not grant ownership. -%% OwnerScope is supplied so clustered stores can deliberately co-locate the -%% object row with its shard-owner fencing record for one atomic commit. --callback load(identity(), owner_scope()) -> - {ok, not_found} - | {ok, loaded()} - | {error, term()}. - -%% Atomically allocate and persist the next ownership epoch for a virtual shard. -%% -%% The caller MUST NOT choose the epoch. The authoritative backend allocates a -%% strictly increasing value so restarts, partitions, or buggy callers cannot -%% roll ownership backward or jump to an unverified caller-selected token. --callback claim_owner(owner_scope()) -> claim_result(). - -%% Atomically commit object state only when: -%% 1. ExpectedVersion still matches the object's durable version; and -%% 2. OwnerEpoch is the current authoritative epoch for OwnerScope. -%% -%% The ownership check MUST be against the shard ownership record, not against -%% an epoch copied only into the individual object row. Otherwise a stale owner -%% could still write an object that the new owner has not loaded yet. --callback commit( - identity(), - version(), - owner_scope(), - owner_epoch(), - binary() -) -> commit_result(). diff --git a/cert/beamscale-critical-section/supervisor/src/bmscl_guest_control.erl b/cert/beamscale-critical-section/supervisor/src/bmscl_guest_control.erl deleted file mode 100644 index 9d45d0b..0000000 --- a/cert/beamscale-critical-section/supervisor/src/bmscl_guest_control.erl +++ /dev/null @@ -1,256 +0,0 @@ --module(bmscl_guest_control). --behaviour(gen_server). - --export([start_link/0]). --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). - --record(state, { - listen_socket, - acceptor, - artifact_root, - connection_counter, - max_connections, - idle_timeout_ms -}). - -start_link() -> - gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). - -init([]) -> - process_flag(trap_exit, true), - Port = application:get_env(bmscl_supervisor, guest_control_port, 9101), - MaxFrame = application:get_env(bmscl_supervisor, guest_control_max_frame_bytes, 16 * 1024 * 1024), - MaxConnections = application:get_env(bmscl_supervisor, guest_control_max_connections, 64), - IdleTimeout = application:get_env(bmscl_supervisor, guest_control_idle_timeout_ms, 30000), - ArtifactRoot = application:get_env( - bmscl_supervisor, guest_artifact_root, "/var/lib/beamscale/artifacts"), - true = is_integer(Port) andalso Port > 0 andalso Port =< 65535, - true = is_integer(MaxFrame) andalso MaxFrame >= 1024 andalso MaxFrame =< 64 * 1024 * 1024, - true = is_integer(MaxConnections) andalso MaxConnections >= 1 andalso MaxConnections =< 4096, - true = is_integer(IdleTimeout) andalso IdleTimeout >= 100 andalso IdleTimeout =< 300000, - case bmscl_guest_artifact:artifact_path( - ArtifactRoot, - <<"0000000000000000000000000000000000000000000000000000000000000000">>) of - {ok, _} -> ok; - {error, RootReason} -> error({invalid_guest_artifact_root, RootReason}) - end, - ListenOpts = [binary, {packet, 4}, {packet_size, MaxFrame}, {active, false}, - {reuseaddr, true}, {ip, {127, 0, 0, 1}}, {backlog, 128}, - {send_timeout, IdleTimeout}, {send_timeout_close, true}], - Counter = atomics:new(1, [{signed, true}]), - case gen_tcp:listen(Port, ListenOpts) of - {ok, ListenSocket} -> - Acceptor = spawn_link( - fun() -> accept_loop(ListenSocket, ArtifactRoot, Counter, - MaxConnections, IdleTimeout) end), - {ok, #state{listen_socket = ListenSocket, - acceptor = Acceptor, - artifact_root = ArtifactRoot, - connection_counter = Counter, - max_connections = MaxConnections, - idle_timeout_ms = IdleTimeout}}; - {error, ListenReason} -> - {stop, {guest_control_listen_failed, ListenReason}} - end. - -handle_call(_Request, _From, State) -> - {reply, {error, unsupported}, State}. - -handle_cast(_Msg, State) -> - {noreply, State}. - -handle_info({'EXIT', Pid, Reason}, - State = #state{acceptor = Pid, - listen_socket = ListenSocket, - artifact_root = ArtifactRoot, - connection_counter = Counter, - max_connections = MaxConnections, - idle_timeout_ms = IdleTimeout}) -> - case Reason of - normal -> {noreply, State}; - shutdown -> {noreply, State}; - _ -> - NewAcceptor = spawn_link( - fun() -> accept_loop(ListenSocket, ArtifactRoot, Counter, - MaxConnections, IdleTimeout) end), - {noreply, State#state{acceptor = NewAcceptor}} - end; -handle_info(_Info, State) -> - {noreply, State}. - -terminate(_Reason, #state{listen_socket = ListenSocket}) -> - _ = gen_tcp:close(ListenSocket), - ok. - -code_change(_OldVsn, State, _Extra) -> - {ok, State}. - -accept_loop(ListenSocket, ArtifactRoot, Counter, MaxConnections, IdleTimeout) -> - case gen_tcp:accept(ListenSocket) of - {ok, Socket} -> - Current = atomics:add_get(Counter, 1, 1), - case Current =< MaxConnections of - true -> - Worker = spawn( - fun() -> connection_worker(Counter, ArtifactRoot, IdleTimeout) end), - case gen_tcp:controlling_process(Socket, Worker) of - ok -> - Worker ! {serve, Socket}; - {error, _} -> - _ = gen_tcp:close(Socket), - Worker ! stop - end; - false -> - _ = atomics:add_get(Counter, 1, -1), - _ = gen_tcp:close(Socket) - end, - accept_loop(ListenSocket, ArtifactRoot, Counter, - MaxConnections, IdleTimeout); - {error, closed} -> ok; - {error, AcceptReason} -> exit({guest_control_accept_failed, AcceptReason}) - end. - -connection_worker(Counter, ArtifactRoot, IdleTimeout) -> - receive - {serve, Socket} -> - try serve_loop(Socket, ArtifactRoot, IdleTimeout) - after - _ = gen_tcp:close(Socket), - _ = atomics:add_get(Counter, 1, -1) - end; - stop -> - _ = atomics:add_get(Counter, 1, -1), - ok - after 5000 -> - %% The acceptor failed after reserving a slot but before handoff. - _ = atomics:add_get(Counter, 1, -1), - ok - end. - -serve_loop(Socket, ArtifactRoot, IdleTimeout) -> - case gen_tcp:recv(Socket, 0, IdleTimeout) of - {ok, Frame} -> - Reply = handle_frame(Frame, ArtifactRoot), - case gen_tcp:send(Socket, Reply) of - ok -> serve_loop(Socket, ArtifactRoot, IdleTimeout); - {error, _} -> ok - end; - {error, closed} -> ok; - {error, timeout} -> ok; - {error, _} -> ok - end. - -handle_frame(Frame, ArtifactRoot) -> - case bmscl_guest_protocol:decode_request(Frame) of - {ok, #{op := invoke, - invocation_id := InvocationId, - deployment_id := DeploymentId, - request := Request, - context := Context, - capability_refs := CapabilityRefs, - timeout_ms := Timeout}} -> - invoke_exact(InvocationId, DeploymentId, Request, Context, CapabilityRefs, Timeout); - {ok, #{op := activate_artifact, deployment_id := DeploymentId}} -> - activate_exact(ArtifactRoot, DeploymentId); - {ok, #{op := critical_section, - operation := Operation, - tenant_id := TenantId, - namespace := Namespace, - object_key := ObjectKey, - runtime_epoch := RuntimeEpoch, - holder := Holder, - request_id := RequestId, - lease_ms := LeaseMs, - token := Token}} -> - critical_section_exact( - Operation, TenantId, Namespace, ObjectKey, - RuntimeEpoch, Holder, RequestId, LeaseMs, Token); - {error, DecodeReason} -> - bmscl_guest_protocol:encode_result(<<>>, {error, DecodeReason}) - end. - -activate_exact(ArtifactRoot, DeploymentId) -> - try bmscl_guest_artifact:activate(ArtifactRoot, DeploymentId) of - ok -> bmscl_guest_protocol:encode_activation_result(DeploymentId, ok); - {error, ActivateReason} -> - bmscl_guest_protocol:encode_activation_result(DeploymentId, {error, ActivateReason}) - catch - Class:CatchReason -> - bmscl_guest_protocol:encode_activation_result( - DeploymentId, {error, {artifact_activation_failed, Class, CatchReason}}) - end. - -critical_section_exact( - acquire, TenantId, Namespace, ObjectKey, RuntimeEpoch, Holder, - RequestId, LeaseMs, _Token) -> - Result = bmscl_critical_section_registry:acquire( - TenantId, Namespace, ObjectKey, RuntimeEpoch, - Holder, RequestId, LeaseMs), - bmscl_guest_protocol:encode_critical_section_result(acquire, Result); -critical_section_exact( - renew, TenantId, Namespace, ObjectKey, RuntimeEpoch, Holder, - _RequestId, LeaseMs, Token) -> - Result = bmscl_critical_section_registry:renew( - TenantId, Namespace, ObjectKey, RuntimeEpoch, Holder, Token, LeaseMs), - bmscl_guest_protocol:encode_critical_section_result(renew, Result); -critical_section_exact( - release, TenantId, Namespace, ObjectKey, RuntimeEpoch, Holder, - _RequestId, _LeaseMs, Token) -> - Result = bmscl_critical_section_registry:release( - TenantId, Namespace, ObjectKey, RuntimeEpoch, Holder, Token), - bmscl_guest_protocol:encode_critical_section_result(release, Result). - -invoke_exact(InvocationId, DeploymentId, Request, Context0, CapabilityRefs, Timeout) -> - case bmscl_deployment_manager:pin_deployment(DeploymentId) of - {ok, Pin} -> - Grants = maps:get(capabilities, Pin, []), - Allowed = bmscl_capability_scope:names(Grants), - case bmscl_guest_protocol:validate_capability_refs(CapabilityRefs, Allowed) of - ok -> - DeadlineUnixMs = erlang:system_time(millisecond) + Timeout, - WorkerContext = Context0#{ - <<"invocation_id">> => InvocationId, - <<"root_invocation_id">> => InvocationId, - <<"call_depth">> => 0, - <<"deployment_id">> => DeploymentId, - <<"capability_refs">> => CapabilityRefs, - <<"admitted_capabilities">> => Allowed, - <<"admitted_capability_grants">> => Grants, - <<"deadline_unix_ms">> => DeadlineUnixMs, - <<"hosted_abi_version">> => bmscl_hosted_abi:version() - }, - case bmscl_hosted_abi:validate_wire(Request, WorkerContext) of - ok -> dispatch_pinned(InvocationId, Pin, Request, WorkerContext, Timeout); - {error, AbiReason} -> - _ = bmscl_deployment_manager:release_pin(Pin), - bmscl_guest_protocol:encode_result( - InvocationId, - {error, {invalid_hosted_abi, AbiReason}}, - #{termination_reason => invalid_hosted_abi}) - end; - {error, CapabilityReason} -> - _ = bmscl_deployment_manager:release_pin(Pin), - bmscl_guest_protocol:encode_result( - InvocationId, - {error, CapabilityReason}, - #{termination_reason => capability_rejected}) - end; - {error, PinReason} -> - bmscl_guest_protocol:encode_result( - InvocationId, - {error, {deployment_pin_failed, PinReason}}, - #{termination_reason => deployment_pin_failed}) - end. - -dispatch_pinned(InvocationId, Pin, Request, WorkerContext, Timeout) -> - try bmscl_router:invoke_pinned_with_metrics(Pin, Request, WorkerContext, Timeout) of - {Result, Metrics} -> - bmscl_guest_protocol:encode_result(InvocationId, Result, Metrics) - catch - Class:DispatchReason -> - _ = bmscl_deployment_manager:release_pin(Pin), - bmscl_guest_protocol:encode_result( - InvocationId, - {error, {guest_dispatch_failed, Class, DispatchReason}}, - #{termination_reason => guest_dispatch_failed}) - end. diff --git a/cert/beamscale-critical-section/supervisor/src/bmscl_guest_protocol.erl b/cert/beamscale-critical-section/supervisor/src/bmscl_guest_protocol.erl deleted file mode 100644 index b7db124..0000000 --- a/cert/beamscale-critical-section/supervisor/src/bmscl_guest_protocol.erl +++ /dev/null @@ -1,331 +0,0 @@ --module(bmscl_guest_protocol). - --define(MAX_FENCING_SEQUENCE, 9007199254740991). - --export([ - decode_request/1, - encode_result/2, - encode_result/3, - encode_activation_result/2, - encode_critical_section_result/2, - normalize_deployment_id/1, - validate_capability_refs/2 -]). - --spec decode_request(binary()) -> {ok, map()} | {error, term()}. -decode_request(Bytes) when is_binary(Bytes) -> - try jsx:decode(Bytes, [return_maps]) of - #{<<"op">> := <<"invoke">>, - <<"deployment_id">> := DeploymentId0, - <<"request">> := Request, - <<"context">> := Context} = Envelope - when is_binary(DeploymentId0), is_map(Request), is_map(Context) -> - Timeout = maps:get(<<"timeout_ms">>, Envelope, 30000), - InvocationId = maps:get(<<"invocation_id">>, Envelope, <<>>), - DeploymentId = normalize_deployment_id(DeploymentId0), - CapabilityRefs = normalize_capability_refs( - maps:get(<<"capability_refs">>, Envelope, [])), - case valid_timeout(Timeout) andalso - is_binary(InvocationId) andalso byte_size(InvocationId) =< 128 andalso - valid_deployment_id(DeploymentId) andalso - CapabilityRefs =/= error of - true -> - {ok, #{ - op => invoke, - invocation_id => InvocationId, - deployment_id => DeploymentId, - request => Request, - context => Context, - capability_refs => CapabilityRefs, - timeout_ms => Timeout - }}; - false -> {error, invalid_invocation_envelope} - end; - #{<<"op">> := <<"activate_artifact">>, - <<"deployment_id">> := DeploymentId0} - when is_binary(DeploymentId0) -> - DeploymentId = normalize_deployment_id(DeploymentId0), - case valid_deployment_id(DeploymentId) of - true -> {ok, #{op => activate_artifact, deployment_id => DeploymentId}}; - false -> {error, invalid_activation_envelope} - end; - #{<<"op">> := <<"critical_section">>, - <<"operation">> := Operation0, - <<"tenant_id">> := TenantId, - <<"namespace">> := Namespace, - <<"object_key">> := ObjectKey, - <<"runtime_epoch">> := RuntimeEpoch, - <<"holder">> := Holder} = Envelope - when is_binary(Operation0), is_binary(TenantId), is_binary(Namespace), - is_binary(ObjectKey), is_integer(RuntimeEpoch), is_binary(Holder) -> - case normalize_critical_operation(Operation0) of - error -> - {error, invalid_critical_section_operation}; - Operation -> - LeaseMs = normalize_optional( - maps:get(<<"lease_ms">>, Envelope, undefined)), - RequestId = normalize_optional( - maps:get(<<"request_id">>, Envelope, undefined)), - Token = normalize_critical_token( - normalize_optional( - maps:get(<<"token">>, Envelope, undefined))), - case valid_critical_identity(TenantId, Namespace, ObjectKey, Holder) - andalso RuntimeEpoch > 0 - andalso valid_critical_arguments( - Operation, LeaseMs, Token, RequestId) of - true -> - {ok, #{ - op => critical_section, - operation => Operation, - tenant_id => TenantId, - namespace => Namespace, - object_key => ObjectKey, - runtime_epoch => RuntimeEpoch, - holder => Holder, - request_id => RequestId, - lease_ms => LeaseMs, - token => Token - }}; - false -> - {error, invalid_critical_section_envelope} - end - end; - _ -> - {error, invalid_invocation_envelope} - catch - _:_ -> {error, invalid_json} - end; -decode_request(_) -> - {error, invalid_frame}. - --spec encode_result(binary(), term()) -> binary(). -encode_result(InvocationId, Result) -> - encode_result(InvocationId, Result, #{}). - --spec encode_result(binary(), term(), map()) -> binary(). -encode_result(InvocationId, {ok, Value}, Metrics) when is_binary(InvocationId), is_map(Metrics) -> - Payload = term_to_binary(Value, [compressed]), - jsx:encode(with_metrics(#{ - <<"invocation_id">> => InvocationId, - <<"ok">> => true, - <<"payload_encoding">> => <<"erlang_external_term_base64">>, - <<"payload_etf_base64">> => base64:encode(Payload), - <<"payload_etf_bytes">> => byte_size(Payload) - }, Metrics)); -encode_result(InvocationId, {error, Reason}, Metrics) when is_binary(InvocationId), is_map(Metrics) -> - ErrorPayload = term_to_binary(Reason, [compressed]), - jsx:encode(with_metrics(#{ - <<"invocation_id">> => InvocationId, - <<"ok">> => false, - <<"error_encoding">> => <<"erlang_external_term_base64">>, - <<"error_etf_base64">> => base64:encode(ErrorPayload), - <<"error_etf_bytes">> => byte_size(ErrorPayload) - }, Metrics)); -encode_result(InvocationId, Other, Metrics) when is_binary(InvocationId), is_map(Metrics) -> - encode_result(InvocationId, {error, {invalid_router_result, Other}}, Metrics). - --spec encode_activation_result(binary(), ok | {error, term()}) -> binary(). -encode_activation_result(DeploymentId, ok) when is_binary(DeploymentId) -> - jsx:encode(#{ - <<"op">> => <<"activate_artifact_result">>, - <<"ok">> => true, - <<"deployment_id">> => DeploymentId, - <<"active_deployment_id">> => DeploymentId - }); -encode_activation_result(DeploymentId, {error, Reason}) when is_binary(DeploymentId) -> - jsx:encode(#{ - <<"op">> => <<"activate_artifact_result">>, - <<"ok">> => false, - <<"deployment_id">> => DeploymentId, - <<"error_encoding">> => <<"erlang_external_term_base64">>, - <<"error_etf_base64">> => base64:encode(term_to_binary(Reason, [compressed])) - }). - --spec encode_critical_section_result(atom(), term()) -> binary(). -encode_critical_section_result(Operation, {ok, Grant}) - when (Operation =:= acquire orelse Operation =:= renew), is_map(Grant) -> - Token = maps:get(token, Grant), - jsx:encode(#{ - <<"op">> => <<"critical_section_result">>, - <<"operation">> => atom_to_binary(Operation, utf8), - <<"ok">> => true, - <<"token">> => #{ - <<"runtime_epoch">> => maps:get(runtime_epoch, Token), - <<"owner_epoch">> => maps:get(owner_epoch, Token), - <<"sequence">> => maps:get(sequence, Token) - }, - <<"expires_at_ms">> => maps:get(expires_at_ms, Grant) - }); -encode_critical_section_result(release, ok) -> - jsx:encode(#{ - <<"op">> => <<"critical_section_result">>, - <<"operation">> => <<"release">>, - <<"ok">> => true - }); -encode_critical_section_result(Operation, {error, {busy, RemainingMs}}) - when is_integer(RemainingMs), RemainingMs >= 0 -> - jsx:encode(#{ - <<"op">> => <<"critical_section_result">>, - <<"operation">> => atom_to_binary(Operation, utf8), - <<"ok">> => false, - <<"error_code">> => <<"busy">>, - <<"remaining_ms">> => RemainingMs - }); -encode_critical_section_result(Operation, {error, stale_or_not_owner}) -> - jsx:encode(#{ - <<"op">> => <<"critical_section_result">>, - <<"operation">> => atom_to_binary(Operation, utf8), - <<"ok">> => false, - <<"error_code">> => <<"stale_or_not_owner">> - }); -encode_critical_section_result(Operation, {error, fencing_exhausted}) -> - jsx:encode(#{ - <<"op">> => <<"critical_section_result">>, - <<"operation">> => atom_to_binary(Operation, utf8), - <<"ok">> => false, - <<"error_code">> => <<"fencing_exhausted">> - }); -encode_critical_section_result(Operation, {error, Reason}) -> - jsx:encode(#{ - <<"op">> => <<"critical_section_result">>, - <<"operation">> => atom_to_binary(Operation, utf8), - <<"ok">> => false, - <<"error_code">> => <<"runtime_error">>, - <<"error_etf_base64">> => - base64:encode(term_to_binary(Reason, [compressed])) - }); -encode_critical_section_result(Operation, Other) -> - encode_critical_section_result( - Operation, {error, {invalid_critical_section_result, Other}}). - --spec normalize_deployment_id(binary()) -> binary(). -normalize_deployment_id(<<"sha256:", Digest/binary>>) -> Digest; -normalize_deployment_id(Digest) -> Digest. - --spec validate_capability_refs([map()], [binary()]) -> ok | {error, term()}. -validate_capability_refs(Refs, Allowed) when is_list(Refs), is_list(Allowed) -> - Names = [maps:get(<<"name">>, Ref) || Ref <- Refs], - case [Name || Name <- Names, not lists:member(Name, Allowed)] of - [] -> ok; - Denied -> {error, {capability_not_admitted, Denied}} - end; -validate_capability_refs(_, _) -> - {error, invalid_capability_refs}. - -normalize_capability_refs(Refs) when is_list(Refs), length(Refs) =< 64 -> - try - Normalized = [normalize_capability_ref(Ref) || Ref <- Refs], - Names = [maps:get(<<"name">>, Ref) || Ref <- Normalized], - case length(Names) =:= length(lists:usort(Names)) of - true -> Normalized; - false -> error - end - catch - _:_ -> error - end; -normalize_capability_refs(_) -> error. - -normalize_capability_ref(#{<<"name">> := Name, <<"token_ref">> := TokenRef}) - when is_binary(Name), is_binary(TokenRef), - byte_size(Name) > 4, byte_size(Name) =< 128, - byte_size(TokenRef) > 0, byte_size(TokenRef) =< 256 -> - case re:run(Name, <<"^ctx\\.[a-zA-Z0-9._-]+$">>, [{capture, none}]) of - match -> #{<<"name">> => Name, <<"token_ref">> => TokenRef}; - nomatch -> error(invalid_capability_name) - end; -normalize_capability_ref(_) -> error(invalid_capability_ref). - -with_metrics(Response, Metrics) -> - Response#{ - <<"wall_time_ms">> => nonnegative_metric(wall_time_ms, Metrics), - <<"reductions">> => nonnegative_metric(reductions, Metrics), - <<"output_bytes">> => nonnegative_metric(output_bytes, Metrics), - <<"executed_digest">> => executed_digest(Metrics), - <<"termination_reason">> => termination_reason(Metrics) - }. - -nonnegative_metric(Key, Metrics) -> - case maps:get(Key, Metrics, 0) of - Value when is_integer(Value), Value >= 0 -> Value; - _ -> 0 - end. - -executed_digest(Metrics) -> - case maps:get(executed_digest, Metrics, <<>>) of - Value when is_binary(Value), byte_size(Value) =:= 64 -> - case valid_deployment_id(Value) of - true -> Value; - false -> <<>> - end; - _ -> <<>> - end. - -termination_reason(Metrics) -> - case maps:get(termination_reason, Metrics, normal) of - Value when is_atom(Value) -> atom_to_binary(Value, utf8); - Value when is_binary(Value) -> Value; - _ -> <<"unknown">> - end. - -normalize_critical_operation(<<"acquire">>) -> acquire; -normalize_critical_operation(<<"renew">>) -> renew; -normalize_critical_operation(<<"release">>) -> release; -normalize_critical_operation(_) -> error. - -normalize_optional(null) -> undefined; -normalize_optional(undefined) -> undefined; -normalize_optional(Value) -> Value. - -normalize_critical_token(undefined) -> undefined; -normalize_critical_token(#{ - <<"runtime_epoch">> := RuntimeEpoch, - <<"owner_epoch">> := OwnerEpoch, - <<"sequence">> := Sequence -}) when is_integer(RuntimeEpoch), RuntimeEpoch > 0, - is_integer(OwnerEpoch), OwnerEpoch > 0, - is_integer(Sequence), Sequence > 0, - Sequence =< ?MAX_FENCING_SEQUENCE -> - #{runtime_epoch => RuntimeEpoch, owner_epoch => OwnerEpoch, sequence => Sequence}; -normalize_critical_token(_) -> error. - -valid_critical_arguments(acquire, LeaseMs, undefined, RequestId) -> - valid_critical_lease(LeaseMs) andalso valid_request_id(RequestId); -valid_critical_arguments(renew, LeaseMs, Token, undefined) -> - valid_critical_lease(LeaseMs) andalso is_map(Token); -valid_critical_arguments(release, undefined, Token, undefined) -> - is_map(Token); -valid_critical_arguments(_, _, _, _) -> - false. - -valid_request_id(Value) when is_binary(Value), - byte_size(Value) > 0, - byte_size(Value) =< 256 -> - binary:match(Value, <<0>>) =:= nomatch; -valid_request_id(_) -> false. - -valid_critical_lease(Value) -> - is_integer(Value) andalso Value > 0 andalso Value =< 300000. - -valid_critical_identity(TenantId, Namespace, ObjectKey, Holder) -> - valid_bounded_binary(TenantId, 1024) - andalso valid_namespace(Namespace) - andalso valid_bounded_binary(ObjectKey, 4096) - andalso valid_bounded_binary(Holder, 256). - -valid_namespace(Value) when is_binary(Value), byte_size(Value) > 0, - byte_size(Value) =< 128 -> - re:run(Value, <<"^[A-Za-z0-9._-]+$">>, [{capture, none}]) =:= match; -valid_namespace(_) -> false. - -valid_bounded_binary(Value, Max) when is_binary(Value), byte_size(Value) > 0, - byte_size(Value) =< Max -> - binary:match(Value, <<0>>) =:= nomatch; -valid_bounded_binary(_, _) -> false. - -valid_timeout(Timeout) -> - is_integer(Timeout) andalso Timeout > 0 andalso - Timeout =< bmscl_limits:platform_max_wall_ms(). - -valid_deployment_id(Digest) when is_binary(Digest), byte_size(Digest) =:= 64 -> - re:run(Digest, <<"^[0-9a-f]{64}$">>, [{capture, none}]) =:= match; -valid_deployment_id(_) -> false. diff --git a/cert/beamscale-critical-section/supervisor/src/bmscl_tenancy_policy.erl b/cert/beamscale-critical-section/supervisor/src/bmscl_tenancy_policy.erl deleted file mode 100644 index 7338400..0000000 --- a/cert/beamscale-critical-section/supervisor/src/bmscl_tenancy_policy.erl +++ /dev/null @@ -1,19 +0,0 @@ --module(bmscl_tenancy_policy). - --export([class/1, validate/2]). - -%% Durable actors/critical sections are single-tenant per BEAM OS process. -%% Lambda is the flagship FaaS product: free may multiplex tenants in one BEAM -%% process, while pro is tenant-dedicated. -class(durable_actor_v1) -> tenant_dedicated; -class(critical_section_v1) -> tenant_dedicated; -class(lambda_free_v1) -> mixed_tenants; -class(lambda_pro_v1) -> tenant_dedicated; -class(_) -> undefined. - -validate(Profile, Requested) -> - case class(Profile) of - undefined -> {error, {unknown_tenancy_profile, Profile}}; - Requested -> ok; - Required -> {error, {tenancy_class_mismatch, Required, Requested}} - end. diff --git a/cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_persistence_tests.erl b/cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_persistence_tests.erl deleted file mode 100644 index 88c442f..0000000 --- a/cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_persistence_tests.erl +++ /dev/null @@ -1,138 +0,0 @@ --module(bmscl_critical_section_persistence_tests). --include_lib("eunit/include/eunit.hrl"). - -restart_preserves_lease_and_fences_old_owner_test() -> - bmscl_test_durable_store:reset(), - application:set_env( - bmscl_supervisor, durable_store_module, bmscl_test_durable_store), - application:set_env( - bmscl_supervisor, critical_section_max_lease_ms, 5000), - - {ok, Loaded1} = bmscl_critical_section_store:claim_and_load( - <<"tenant-a">>, <<"orders">>, <<"lock-1">>), - ?assertEqual(1, maps:get(owner_epoch, Loaded1)), - ?assertEqual(0, maps:get(version, Loaded1)), - - {ok, Pid1} = bmscl_critical_section_actor:start(100, Loaded1), - {ok, Grant1} = bmscl_critical_section_actor:acquire( - Pid1, <<"holder-a">>, 1000), - Token1 = maps:get(token, Grant1), - ?assertEqual(1, maps:get(owner_epoch, Token1)), - ?assertEqual(1, maps:get(sequence, Token1)), - ok = gen_server:stop(Pid1), - - {ok, Loaded2} = bmscl_critical_section_store:claim_and_load( - <<"tenant-a">>, <<"orders">>, <<"lock-1">>), - ?assertEqual(2, maps:get(owner_epoch, Loaded2)), - ?assertEqual(1, maps:get(version, Loaded2)), - ?assertEqual(<<"holder-a">>, maps:get(holder, Loaded2)), - - {ok, Pid2} = bmscl_critical_section_actor:start(101, Loaded2), - ?assertMatch( - {error, {busy, _}}, - bmscl_critical_section_actor:acquire(Pid2, <<"holder-a">>, 1000)), - ?assertEqual( - {error, stale_or_not_owner}, - bmscl_critical_section_actor:renew( - Pid2, <<"holder-a">>, Token1, 1000)), - ?assertEqual( - {error, stale_or_not_owner}, - bmscl_critical_section_actor:release( - Pid2, <<"holder-a">>, Token1)), - ok = gen_server:stop(Pid2). - -expired_inherited_lease_allows_new_fenced_grant_test() -> - bmscl_test_durable_store:reset(), - application:set_env( - bmscl_supervisor, durable_store_module, bmscl_test_durable_store), - application:set_env( - bmscl_supervisor, critical_section_max_lease_ms, 5000), - - {ok, Loaded1} = bmscl_critical_section_store:claim_and_load( - <<"tenant-a">>, <<"orders">>, <<"lock-2">>), - {ok, Pid1} = bmscl_critical_section_actor:start(200, Loaded1), - {ok, _Grant1} = bmscl_critical_section_actor:acquire( - Pid1, <<"holder-a">>, 10), - ok = gen_server:stop(Pid1), - timer:sleep(25), - - {ok, Loaded2} = bmscl_critical_section_store:claim_and_load( - <<"tenant-a">>, <<"orders">>, <<"lock-2">>), - {ok, Pid2} = bmscl_critical_section_actor:start(201, Loaded2), - {ok, Grant2} = bmscl_critical_section_actor:acquire( - Pid2, <<"holder-b">>, 1000), - Token2 = maps:get(token, Grant2), - ?assertEqual(2, maps:get(owner_epoch, Token2)), - ?assertEqual(2, maps:get(sequence, Token2)), - ok = gen_server:stop(Pid2). - -stale_store_owner_fails_commit_test() -> - bmscl_test_durable_store:reset(), - application:set_env( - bmscl_supervisor, durable_store_module, bmscl_test_durable_store), - - {ok, Loaded1} = bmscl_critical_section_store:claim_and_load( - <<"tenant-a">>, <<"orders">>, <<"lock-3">>), - {ok, Pid1} = bmscl_critical_section_actor:start(300, Loaded1), - - %% A second claimant fences Pid1 before it can publish a grant. - {ok, _Loaded2} = bmscl_critical_section_store:claim_and_load( - <<"tenant-a">>, <<"orders">>, <<"lock-3">>), - ?assertMatch( - {error, {durable_commit_failed, {stale_owner_epoch, 2}}}, - bmscl_critical_section_actor:acquire( - Pid1, <<"holder-a">>, 1000)), - Status = bmscl_critical_section_actor:status(Pid1), - ?assertEqual(undefined, maps:get(holder, Status)), - ?assertEqual(0, maps:get(sequence, Status)), - ok = gen_server:stop(Pid1). - - -same_request_id_replays_without_minting_or_extending_test() -> - bmscl_test_durable_store:reset(), - application:set_env( - bmscl_supervisor, durable_store_module, bmscl_test_durable_store), - application:set_env( - bmscl_supervisor, critical_section_max_lease_ms, 5000), - - {ok, Loaded} = bmscl_critical_section_store:claim_and_load( - <<"tenant-a">>, <<"orders">>, <<"lock-replay">>), - {ok, Pid} = bmscl_critical_section_actor:start(400, Loaded), - {ok, Grant1} = bmscl_critical_section_actor:acquire( - Pid, <<"holder-a">>, <<"request-1">>, 1000), - timer:sleep(5), - {ok, Grant2} = bmscl_critical_section_actor:acquire( - Pid, <<"holder-a">>, <<"request-1">>, 1000), - ?assertEqual(maps:get(token, Grant1), maps:get(token, Grant2)), - ?assertEqual(maps:get(expires_at_ms, Grant1), maps:get(expires_at_ms, Grant2)), - ?assertMatch( - {error, {busy, _}}, - bmscl_critical_section_actor:acquire( - Pid, <<"holder-a">>, <<"request-2">>, 1000)), - ok = gen_server:stop(Pid). - - -fencing_sequence_exhaustion_fails_closed_test() -> - Max = 9007199254740991, - application:set_env( - bmscl_supervisor, critical_section_max_lease_ms, 5000), - {ok, Pid} = gen_server:start( - bmscl_critical_section_actor, - {500, #{owner_epoch => 7, - version => 1, - sequence => Max, - holder => undefined, - request_id => undefined, - expires_at_unix_ms => 0, - identity => #{}, - owner_scope => #{}, - store_module => bmscl_test_durable_store}}, - []), - ?assertEqual( - {error, fencing_exhausted}, - bmscl_critical_section_actor:acquire( - Pid, <<"holder-a">>, <<"request-max">>, 1000)), - Status = bmscl_critical_section_actor:status(Pid), - ?assertEqual(Max, maps:get(sequence, Status)), - ?assertEqual(undefined, maps:get(holder, Status)), - ok = gen_server:stop(Pid). diff --git a/cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_protocol_tests.erl b/cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_protocol_tests.erl deleted file mode 100644 index b28c2ec..0000000 --- a/cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_protocol_tests.erl +++ /dev/null @@ -1,59 +0,0 @@ --module(bmscl_critical_section_protocol_tests). --include_lib("eunit/include/eunit.hrl"). - -acquire_accepts_null_token_with_request_id_test() -> - Bytes = jsx:encode(#{ - <<"op">> => <<"critical_section">>, - <<"operation">> => <<"acquire">>, - <<"tenant_id">> => <<"tenant-a">>, - <<"namespace">> => <<"orders">>, - <<"object_key">> => <<"lock-1">>, - <<"runtime_epoch">> => 9, - <<"holder">> => <<"holder-a">>, - <<"request_id">> => <<"request-1">>, - <<"lease_ms">> => 1000, - <<"token">> => null - }), - {ok, Decoded} = bmscl_guest_protocol:decode_request(Bytes), - ?assertEqual(acquire, maps:get(operation, Decoded)), - ?assertEqual(<<"request-1">>, maps:get(request_id, Decoded)), - ?assertEqual(undefined, maps:get(token, Decoded)). - -release_accepts_null_optional_fields_test() -> - Bytes = jsx:encode(#{ - <<"op">> => <<"critical_section">>, - <<"operation">> => <<"release">>, - <<"tenant_id">> => <<"tenant-a">>, - <<"namespace">> => <<"orders">>, - <<"object_key">> => <<"lock-1">>, - <<"runtime_epoch">> => 9, - <<"holder">> => <<"holder-a">>, - <<"request_id">> => null, - <<"lease_ms">> => null, - <<"token">> => #{ - <<"runtime_epoch">> => 9, - <<"owner_epoch">> => 4, - <<"sequence">> => 7 - } - }), - {ok, Decoded} = bmscl_guest_protocol:decode_request(Bytes), - ?assertEqual(release, maps:get(operation, Decoded)), - ?assertEqual(undefined, maps:get(request_id, Decoded)), - ?assertEqual(undefined, maps:get(lease_ms, Decoded)). - -acquire_requires_nonempty_request_id_test() -> - Bytes = jsx:encode(#{ - <<"op">> => <<"critical_section">>, - <<"operation">> => <<"acquire">>, - <<"tenant_id">> => <<"tenant-a">>, - <<"namespace">> => <<"orders">>, - <<"object_key">> => <<"lock-1">>, - <<"runtime_epoch">> => 9, - <<"holder">> => <<"holder-a">>, - <<"request_id">> => null, - <<"lease_ms">> => 1000, - <<"token">> => null - }), - ?assertEqual( - {error, invalid_critical_section_envelope}, - bmscl_guest_protocol:decode_request(Bytes)). diff --git a/cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_tests.erl b/cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_tests.erl deleted file mode 100644 index fe0911b..0000000 --- a/cert/beamscale-critical-section/supervisor/test/bmscl_critical_section_tests.erl +++ /dev/null @@ -1,28 +0,0 @@ --module(bmscl_critical_section_tests). --include_lib("eunit/include/eunit.hrl"). - -fencing_sequence_and_epoch_test() -> - application:set_env(bmscl_supervisor, critical_section_max_lease_ms, 5000), - {ok, Pid} = bmscl_critical_section_actor:start(41), - {ok, Grant1} = bmscl_critical_section_actor:acquire(Pid, <<"a">>, 1000), - Token1 = maps:get(token, Grant1), - ?assertEqual(#{runtime_epoch => 41, owner_epoch => 41, sequence => 1}, Token1), - ?assertMatch({error, {busy, _}}, - bmscl_critical_section_actor:acquire(Pid, <<"b">>, 1000)), - ?assertEqual({error, stale_or_not_owner}, - bmscl_critical_section_actor:release(Pid, <<"b">>, Token1)), - ok = bmscl_critical_section_actor:release(Pid, <<"a">>, Token1), - {ok, Grant2} = bmscl_critical_section_actor:acquire(Pid, <<"b">>, 1000), - ?assertEqual( - #{runtime_epoch => 41, owner_epoch => 41, sequence => 2}, - maps:get(token, Grant2)), - ok = gen_server:stop(Pid). - -tenancy_policy_test() -> - ?assertEqual(mixed_tenants, bmscl_tenancy_policy:class(lambda_free_v1)), - ?assertEqual(tenant_dedicated, bmscl_tenancy_policy:class(lambda_pro_v1)), - ?assertEqual(tenant_dedicated, bmscl_tenancy_policy:class(durable_actor_v1)), - ?assertEqual(tenant_dedicated, bmscl_tenancy_policy:class(critical_section_v1)), - ?assertEqual(ok, bmscl_tenancy_policy:validate(durable_actor_v1, tenant_dedicated)), - ?assertMatch({error, {tenancy_class_mismatch, tenant_dedicated, mixed_tenants}}, - bmscl_tenancy_policy:validate(durable_actor_v1, mixed_tenants)). diff --git a/cert/beamscale-critical-section/supervisor/test/bmscl_test_durable_store.erl b/cert/beamscale-critical-section/supervisor/test/bmscl_test_durable_store.erl deleted file mode 100644 index aab6eb2..0000000 --- a/cert/beamscale-critical-section/supervisor/test/bmscl_test_durable_store.erl +++ /dev/null @@ -1,58 +0,0 @@ --module(bmscl_test_durable_store). - --export([reset/0, load/2, claim_owner/1, commit/5]). - --define(TABLE, bmscl_test_durable_store_table). - -reset() -> - case ets:whereis(?TABLE) of - undefined -> ok; - _ -> ets:delete(?TABLE), ok - end. - -claim_owner(Scope) -> - ensure_table(), - Epoch = ets:update_counter( - ?TABLE, {owner, Scope}, {2, 1}, {{owner, Scope}, 0}), - {ok, Epoch}. - -load(Identity, _Scope) -> - ensure_table(), - case ets:lookup(?TABLE, {object, Identity}) of - [] -> {ok, not_found}; - [{{object, Identity}, Version, Payload}] -> - {ok, #{version => Version, state => Payload}} - end. - -commit(Identity, ExpectedVersion, Scope, OwnerEpoch, Payload) -> - ensure_table(), - case ets:lookup(?TABLE, {owner, Scope}) of - [] -> {error, owner_missing}; - [{{owner, Scope}, CurrentOwner}] when CurrentOwner =/= OwnerEpoch -> - {error, {stale_owner_epoch, CurrentOwner}}; - [{{owner, Scope}, OwnerEpoch}] -> - CurrentVersion = case ets:lookup(?TABLE, {object, Identity}) of - [] -> 0; - [{{object, Identity}, Version, _}] -> Version - end, - case CurrentVersion =:= ExpectedVersion of - false -> {error, {stale_version, CurrentVersion}}; - true -> - Next = CurrentVersion + 1, - true = ets:insert( - ?TABLE, - {{object, Identity}, Next, Payload}), - {ok, Next} - end - end. - -ensure_table() -> - case ets:whereis(?TABLE) of - undefined -> - try ets:new(?TABLE, [named_table, public, set]) of - _ -> ok - catch - error:badarg -> ok - end; - _ -> ok - end. diff --git a/certification/beamscale-artifact-abi/compiler/Cargo.toml b/certification/beamscale-artifact-abi/compiler/Cargo.toml deleted file mode 100644 index ca65e71..0000000 --- a/certification/beamscale-artifact-abi/compiler/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "bmscl-artifact-abi-cert" -version = "0.0.0" -edition = "2021" -publish = false - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "0.8" diff --git a/certification/beamscale-artifact-abi/compiler/build-source.rs b/certification/beamscale-artifact-abi/compiler/build-source.rs deleted file mode 100644 index ef8ebd8..0000000 --- a/certification/beamscale-artifact-abi/compiler/build-source.rs +++ /dev/null @@ -1,636 +0,0 @@ -use crate::{ - analyze::check_project, - model::{ - ArtifactManifest, BuildProvenance, Policy, CONTEXT_ABI_V1, MODULE_CONTRACT_V1, - PROVENANCE_FORMAT_V1, - }, - policy::{capability_grants, effective_limits, load_worker_config}, - trusted_sdk, -}; -use anyhow::{bail, Context, Result}; -use flate2::{Compression, GzBuilder}; -use serde::Serialize; -use sha2::{Digest, Sha256}; -use std::{ - collections::BTreeMap, - env, - fs::{self, File}, - io::{Read, Write}, - path::{Path, PathBuf}, - process::Command, -}; -use walkdir::WalkDir; -use zip::write::SimpleFileOptions; - -#[derive(Debug, Serialize)] -struct PackageDigests { - format_version: u32, - worker_tar_gz_sha256: String, - worker_zip_sha256: String, -} - -pub fn build_project( - project: &Path, - out_dir: &Path, - policy: &Policy, - worker_config_path: Option<&Path>, - deny_cpu_loops: bool, -) -> Result<()> { - let report = check_project(project, policy, worker_config_path, deny_cpu_loops)?; - fs::create_dir_all(out_dir)?; - remove_if_exists(&out_dir.join("attestation.json"))?; - remove_if_exists(&out_dir.join("provenance.json"))?; - remove_if_exists(&out_dir.join("worker.tar.gz"))?; - remove_if_exists(&out_dir.join("worker.zip"))?; - remove_if_exists(&out_dir.join("package-digests.json"))?; - fs::write( - out_dir.join("admission-report.json"), - serde_json::to_vec_pretty(&report)?, - )?; - if !report.admitted { - bail!("worker rejected; see admission-report.json"); - } - - // Tenant admission is complete before any trusted SDK code is introduced. - // Build in an ephemeral copy so customer source and dependency evidence are - // never mutated by the compiler-managed SDK injection. - let prepared = trusted_sdk::prepare_project(project, policy)?; - let build_project = prepared.path(); - - let status = Command::new("gleam") - .args(["build", "--target", "erlang"]) - .current_dir(build_project) - .status() - .context("launch `gleam build --target erlang`; install/pin Gleam in the build image")?; - if !status.success() { - bail!("gleam build failed with {status}"); - } - - let package_name = package_name(build_project)?; - let package_root = build_project - .join("build") - .join("dev") - .join("erlang") - .join(&package_name); - let artefacts = package_root.join("_gleam_artefacts"); - if !artefacts.is_dir() { - bail!( - "Gleam build did not produce expected generated Erlang: {}", - artefacts.display() - ); - } - - verify_generated_erlang(&artefacts, policy)?; - let beam_dir = out_dir.join("beam"); - copy_worker_beams(&package_root, &beam_dir)?; - if prepared.trusted_sdk_sha256().is_some() { - let sdk_package_root = build_project - .join("build") - .join("dev") - .join("erlang") - .join("bmscl_sdk"); - append_trusted_sdk_beams(&sdk_package_root, &beam_dir)?; - } - verify_beam_imports(&beam_dir)?; - let build_sha256 = digest_tree(&beam_dir)?; - let (worker_config, _) = load_worker_config(project, worker_config_path)?; - - let provenance = collect_provenance( - project, - policy, - &report.source_sha256, - &build_sha256, - prepared.trusted_sdk_sha256(), - )?; - let provenance_bytes = serde_json::to_vec_pretty(&provenance)?; - let provenance_sha256 = sha256_bytes(&provenance_bytes); - fs::write(out_dir.join("provenance.json"), provenance_bytes)?; - - let manifest = ArtifactManifest { - format_version: 2, - runtime: "beam", - language: "gleam", - profile: policy.policy_version.clone(), - module_contract_version: MODULE_CONTRACT_V1, - context_abi: CONTEXT_ABI_V1, - source_sha256: report.source_sha256, - build_sha256, - provenance_sha256, - entrypoint: "worker:handle/2".into(), - capabilities: capability_grants(&worker_config), - runtime_limits: effective_limits(policy, &worker_config), - durable: worker_config.durable.clone(), - }; - fs::write( - out_dir.join("manifest.json"), - serde_json::to_vec_pretty(&manifest)?, - )?; - println!("admitted and built worker into {}", out_dir.display()); - Ok(()) -} - -fn collect_provenance( - project: &Path, - policy: &Policy, - source_sha256: &str, - build_sha256: &str, - trusted_sdk_sha256: Option<&str>, -) -> Result { - let policy_bytes = serde_json::to_vec(policy).context("serialize effective policy")?; - let dependency_lock_sha256 = optional_file_sha256(&project.join("manifest.toml"))?; - let gleam_raw = tool_version("gleam", &["--version"])?; - let gleam_version = gleam_raw - .strip_prefix("gleam ") - .unwrap_or(&gleam_raw) - .to_string(); - - Ok(BuildProvenance { - format: PROVENANCE_FORMAT_V1, - builder_id: env::var("BMSCL_BUILDER_ID").unwrap_or_else(|_| "local-untrusted".into()), - builder_image_digest: env::var("BMSCL_BUILDER_IMAGE_DIGEST") - .unwrap_or_else(|_| "local-untracked".into()), - compiler_version: env!("CARGO_PKG_VERSION").into(), - compiler_revision: env::var("BMSCL_COMPILER_REVISION") - .unwrap_or_else(|_| "local-untracked".into()), - gleam_version, - otp_release: erlang_system_info("otp_release")?, - erts_version: erlang_system_info("version")?, - policy_sha256: sha256_bytes(&policy_bytes), - dependency_lock_sha256, - trusted_sdk_sha256: trusted_sdk_sha256.map(str::to_string), - source_sha256: source_sha256.into(), - build_sha256: build_sha256.into(), - }) -} - -fn tool_version(program: &str, args: &[&str]) -> Result { - let output = Command::new(program) - .args(args) - .output() - .with_context(|| format!("launch `{program}` to record build provenance"))?; - if !output.status.success() { - bail!("`{program}` failed while collecting build provenance"); - } - Ok(String::from_utf8(output.stdout) - .context("tool version output was not UTF-8")? - .trim() - .to_string()) -} - -fn erlang_system_info(key: &str) -> Result { - let eval = format!("io:format(\"~s\", [erlang:system_info({key})]), halt(0)."); - let output = Command::new("erl") - .args(["-noshell", "-eval", &eval]) - .output() - .context("launch Erlang to record build provenance")?; - if !output.status.success() { - bail!("Erlang failed while collecting build provenance for `{key}`"); - } - Ok(String::from_utf8(output.stdout) - .context("Erlang provenance output was not UTF-8")? - .trim() - .to_string()) -} - -pub fn package_project(out_dir: &Path) -> Result<()> { - let entries = artifact_entries(out_dir)?; - let tar_path = out_dir.join("worker.tar.gz"); - let zip_path = out_dir.join("worker.zip"); - - write_tar_gz(&tar_path, &entries)?; - write_zip(&zip_path, &entries)?; - - let digests = PackageDigests { - format_version: 2, - worker_tar_gz_sha256: sha256_file(&tar_path)?, - worker_zip_sha256: sha256_file(&zip_path)?, - }; - fs::write( - out_dir.join("package-digests.json"), - serde_json::to_vec_pretty(&digests)?, - )?; - - println!("packaged {}", tar_path.display()); - println!("packaged {}", zip_path.display()); - println!("wrote {}", out_dir.join("package-digests.json").display()); - Ok(()) -} - -fn artifact_entries(out_dir: &Path) -> Result> { - let mut entries = vec![ - ( - "admission-report.json".to_string(), - out_dir.join("admission-report.json"), - ), - ("manifest.json".to_string(), out_dir.join("manifest.json")), - ( - "provenance.json".to_string(), - out_dir.join("provenance.json"), - ), - ]; - let attestation = out_dir.join("attestation.json"); - if attestation.is_file() { - entries.push(("attestation.json".to_string(), attestation)); - } - - for entry in WalkDir::new(out_dir.join("beam")) - .into_iter() - .filter_map(Result::ok) - .filter(|entry| entry.file_type().is_file()) - { - let name = entry.file_name().to_string_lossy(); - entries.push((format!("beam/{name}"), entry.into_path())); - } - entries.sort_by(|left, right| left.0.cmp(&right.0)); - for (archive_path, source) in &entries { - if !source.is_file() { - bail!( - "package input missing: {archive_path} ({})", - source.display() - ); - } - } - Ok(entries) -} - -fn write_tar_gz(path: &Path, entries: &[(String, PathBuf)]) -> Result<()> { - let file = File::create(path)?; - let encoder = GzBuilder::new().mtime(0).write(file, Compression::best()); - let mut tar = tar::Builder::new(encoder); - for (archive_path, source) in entries { - append_tar_file(&mut tar, source, Path::new(archive_path))?; - } - tar.finish()?; - Ok(()) -} - -fn write_zip(path: &Path, entries: &[(String, PathBuf)]) -> Result<()> { - let file = File::create(path)?; - let mut zip = zip::ZipWriter::new(file); - let options = SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated) - .unix_permissions(0o644) - .last_modified_time(zip::DateTime::default()); - for (archive_path, source) in entries { - zip.start_file(archive_path, options)?; - let bytes = fs::read(source)?; - zip.write_all(&bytes)?; - } - zip.finish()?; - Ok(()) -} - -fn package_name(project: &Path) -> Result { - let path = project.join("gleam.toml"); - let raw = fs::read_to_string(&path) - .with_context(|| format!("read package manifest {}", path.display()))?; - let doc: toml::Value = toml::from_str(&raw).context("parse gleam.toml")?; - doc.get("name") - .and_then(toml::Value::as_str) - .map(str::to_string) - .context("gleam.toml must contain a package name") -} - -fn is_launcher(path: &Path) -> bool { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.contains("@@main.")) -} - -fn verify_generated_erlang(artefacts: &Path, policy: &Policy) -> Result<()> { - for entry in WalkDir::new(artefacts).into_iter().filter_map(Result::ok) { - let path = entry.path(); - if is_launcher(path) || path.extension().and_then(|ext| ext.to_str()) != Some("erl") { - continue; - } - let source = fs::read_to_string(path)?; - for pattern in &policy.forbidden_erlang_patterns { - if source.contains(pattern) { - bail!( - "generated Erlang verification failed: {} contains forbidden `{}`", - path.display(), - pattern - ); - } - } - } - Ok(()) -} - -fn copy_worker_beams(package_root: &Path, destination: &Path) -> Result<()> { - if destination.exists() { - fs::remove_dir_all(destination)?; - } - fs::create_dir_all(destination)?; - let mut copied = 0usize; - for entry in WalkDir::new(package_root) - .into_iter() - .filter_map(Result::ok) - { - let path = entry.path(); - if is_launcher(path) || path.extension().and_then(|ext| ext.to_str()) != Some("beam") { - continue; - } - let name = path.file_name().context("beam file name")?; - fs::copy(path, destination.join(name))?; - copied += 1; - } - if copied == 0 { - bail!( - "no worker BEAM modules were produced beneath {}", - package_root.display() - ); - } - Ok(()) -} - -fn append_trusted_sdk_beams(package_root: &Path, destination: &Path) -> Result<()> { - if !package_root.is_dir() { - bail!( - "trusted SDK build output is missing: {}", - package_root.display() - ); - } - - let mut copied = 0usize; - let mut has_root_module = false; - for entry in WalkDir::new(package_root) - .into_iter() - .filter_map(Result::ok) - { - let path = entry.path(); - if is_launcher(path) || path.extension().and_then(|ext| ext.to_str()) != Some("beam") { - continue; - } - let name = path.file_name().context("trusted SDK beam file name")?; - if name == std::ffi::OsStr::new("bmscl.beam") { - has_root_module = true; - } - let target = destination.join(name); - if target.exists() { - bail!( - "trusted SDK module collides with worker artifact module: {}", - name.to_string_lossy() - ); - } - fs::copy(path, &target)?; - copied += 1; - } - if copied == 0 || !has_root_module { - bail!( - "trusted SDK output must contain bmscl.beam beneath {}", - package_root.display() - ); - } - Ok(()) -} - -pub(crate) fn verify_beam_imports(beam_dir: &Path) -> Result<()> { - let mut checked = 0usize; - for entry in WalkDir::new(beam_dir).into_iter().filter_map(Result::ok) { - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("beam") { - continue; - } - checked += 1; - let path_string = path - .to_string_lossy() - .replace('\\', "\\\\") - .replace('"', "\\\""); - let eval = format!( - "case beam_lib:chunks(\"{}\", [imports]) of {{ok,{{_,[{{imports,I}}]}}}} -> lists:foreach(fun({{M,F,A}}) -> io:format(\"~s:~s/~p~n\", [atom_to_list(M), atom_to_list(F), A]) end, I), halt(0); E -> io:format(\"ERROR ~p~n\", [E]), halt(2) end.", - path_string - ); - let output = Command::new("erl") - .args(["-noshell", "-eval", &eval]) - .output() - .with_context(|| format!("inspect BEAM imports for {}", path.display()))?; - if !output.status.success() { - bail!( - "beam_lib import inspection failed for {}: {}", - path.display(), - String::from_utf8_lossy(&output.stderr) - ); - } - for import in String::from_utf8_lossy(&output.stdout).lines() { - if forbidden_beam_import(import) { - bail!( - "BEAM import verification failed: {} imports forbidden `{}`", - path.display(), - import - ); - } - } - } - if checked == 0 { - bail!("artifact contains no BEAM modules"); - } - Ok(()) -} - -pub(crate) fn forbidden_beam_import(import: &str) -> bool { - [ - "os:", - "file:", - "code:", - "erl_ddll:", - "net_kernel:", - "rpc:", - "erpc:", - "global:", - "persistent_term:", - "ets:", - "dets:", - "mnesia:", - "disk_log:", - "application:", - "init:", - "sys:", - "proc_lib:", - "gen_server:", - "gen_statem:", - "supervisor:", - "timer:apply", - "gen_tcp:", - "gen_udp:", - "socket:", - "ssl:", - "httpc:", - "inets:", - "erlang:spawn", - "erlang:apply/", - "erlang:make_fun/", - "erlang:halt/", - "erlang:open_port/", - "erlang:port_command/", - "erlang:load_nif/", - "erlang:whereis/", - "erlang:register/", - "erlang:unregister/", - "erlang:processes/", - "erlang:process_info/", - "erlang:process_flag/", - "erlang:link/", - "erlang:unlink/", - "erlang:monitor/", - "erlang:demonitor/", - "erlang:exit/2", - "erlang:send/", - "erlang:send_after/", - "erlang:start_timer/", - "erlang:group_leader/", - "erlang:binary_to_atom/", - "erlang:list_to_atom/", - ] - .iter() - .any(|prefix| import.starts_with(prefix)) -} - -pub(crate) fn digest_tree(root: &Path) -> Result { - let mut entries: BTreeMap> = BTreeMap::new(); - for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) { - if !entry.file_type().is_file() { - continue; - } - let rel = entry - .path() - .strip_prefix(root)? - .to_string_lossy() - .replace('\\', "/"); - entries.insert(rel, fs::read(entry.path())?); - } - let mut hasher = Sha256::new(); - for (name, bytes) in entries { - hasher.update(name.as_bytes()); - hasher.update([0]); - hasher.update(bytes); - hasher.update([0]); - } - Ok(format!("{:x}", hasher.finalize())) -} - -fn append_tar_file( - tar: &mut tar::Builder, - source: &Path, - archive_path: &Path, -) -> Result<()> { - let mut file = File::open(source)?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes)?; - let mut header = tar::Header::new_gnu(); - header.set_size(bytes.len() as u64); - header.set_mode(0o644); - header.set_uid(0); - header.set_gid(0); - header.set_mtime(0); - header.set_cksum(); - tar.append_data(&mut header, archive_path, bytes.as_slice())?; - Ok(()) -} - -fn optional_file_sha256(path: &Path) -> Result> { - if path.is_file() { - Ok(Some(sha256_file(path)?)) - } else { - Ok(None) - } -} - -fn sha256_file(path: &Path) -> Result { - Ok(sha256_bytes(&fs::read(path)?)) -} - -fn sha256_bytes(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} - -fn remove_if_exists(path: &Path) -> Result<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error).with_context(|| format!("remove stale {}", path.display())), - } -} - -#[cfg(test)] -mod tests { - use super::{ - append_trusted_sdk_beams, digest_tree, forbidden_beam_import, package_project, sha256_bytes, - }; - use std::fs; - use tempfile::tempdir; - - #[test] - fn trusted_sdk_beams_are_bundled_and_collision_checked() { - let package = tempdir().unwrap(); - let destination = tempdir().unwrap(); - fs::write(package.path().join("bmscl.beam"), b"sdk-root").unwrap(); - fs::write(package.path().join("bmscl@cluster.beam"), b"sdk-cluster").unwrap(); - - append_trusted_sdk_beams(package.path(), destination.path()).unwrap(); - assert_eq!( - fs::read(destination.path().join("bmscl.beam")).unwrap(), - b"sdk-root" - ); - assert_eq!( - fs::read(destination.path().join("bmscl@cluster.beam")).unwrap(), - b"sdk-cluster" - ); - - let error = append_trusted_sdk_beams(package.path(), destination.path()).unwrap_err(); - assert!(error.to_string().contains("collides")); - } - - #[test] - fn rejects_privileged_beam_imports() { - assert!(forbidden_beam_import("file:read_file/1")); - assert!(forbidden_beam_import("erlang:open_port/2")); - assert!(forbidden_beam_import("erlang:spawn/1")); - assert!(forbidden_beam_import("erlang:spawn_link/1")); - assert!(forbidden_beam_import("erlang:spawn_monitor/1")); - assert!(forbidden_beam_import("erlang:spawn_opt/2")); - assert!(forbidden_beam_import("erlang:apply/3")); - assert!(forbidden_beam_import("erlang:whereis/1")); - assert!(forbidden_beam_import("erlang:send/2")); - assert!(forbidden_beam_import("proc_lib:spawn/1")); - assert!(forbidden_beam_import("gen_server:start_link/3")); - assert!(forbidden_beam_import("gen_statem:start/3")); - assert!(forbidden_beam_import("supervisor:start_child/2")); - assert!(forbidden_beam_import("gen_tcp:connect/3")); - assert!(!forbidden_beam_import("erlang:length/1")); - } - - #[test] - fn packaging_emits_tar_zip_and_digests() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join("beam")).unwrap(); - fs::write(dir.path().join("manifest.json"), b"{}\n").unwrap(); - fs::write(dir.path().join("admission-report.json"), b"{}\n").unwrap(); - fs::write(dir.path().join("provenance.json"), b"{}\n").unwrap(); - fs::write(dir.path().join("beam/worker.beam"), b"beam").unwrap(); - package_project(dir.path()).unwrap(); - assert!(dir.path().join("worker.tar.gz").is_file()); - assert!(dir.path().join("worker.zip").is_file()); - let digests = fs::read_to_string(dir.path().join("package-digests.json")).unwrap(); - assert!(digests.contains("worker_tar_gz_sha256")); - assert!(digests.contains("worker_zip_sha256")); - } - - #[test] - fn tree_digest_is_stable_by_relative_path() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("b.beam"), b"b").unwrap(); - fs::write(dir.path().join("a.beam"), b"a").unwrap(); - let first = digest_tree(dir.path()).unwrap(); - let second = digest_tree(dir.path()).unwrap(); - assert_eq!(first, second); - } - - #[test] - fn sha256_bytes_is_stable() { - assert_eq!( - sha256_bytes(b"beamscale"), - "eecabee7521c6fcce50b2484769b71497d1c10a8d05ff58349664fa35b893c76" - ); - } -} diff --git a/certification/beamscale-artifact-abi/compiler/src/lib.rs b/certification/beamscale-artifact-abi/compiler/src/lib.rs deleted file mode 100644 index 8090039..0000000 --- a/certification/beamscale-artifact-abi/compiler/src/lib.rs +++ /dev/null @@ -1,236 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::{BTreeMap, BTreeSet}; - -pub const HOSTED_PROFILE_V2: &str = "bmscl-hosted-gleam-v2-read-only"; -pub const HOSTED_PROFILE_V3_HTTP: &str = "bmscl-hosted-gleam-v3-http-capability"; -pub const DURABLE_ACTOR_PROFILE_V1: &str = "bmscl-hosted-gleam-durable-actor-v1"; -pub const ERLANG_CRITICAL_SECTION_PROFILE_V1: &str = "bmscl-critical-section-erlang-v1"; -pub const PROVENANCE_FORMAT_V1: &str = "bmscl-build-provenance-v1"; -pub const MODULE_CONTRACT_V1: &str = "bmscl-module-contract-v1"; -pub const CONTEXT_ABI_V1: &str = "bmscl.context/v1"; -pub const DEFAULT_TRUSTED_SDK_SHA256: &str = - "2c5818e5306d4bbcc02796a6a58cb3f1942eb0cbabb19a7dee6c3d7df178ea6b"; -pub const MAX_LAMBDA_WALL_MS: u64 = 90_000; - -/// Ambient machine authority that a hosted worker can never enable. -/// -/// `dynamic_eval` covers erl_eval/compiler/code-generation style execution and -/// `compile_time_code_execution` covers parse transforms / build hooks. Both -/// are explicit policy knobs so an artifact cannot attempt to opt back into -/// them through worker configuration. -pub const FORBIDDEN_SECURITY_KEYS: &[&str] = &[ - "filesystem", - "process_exec", - "process_creation", - "raw_sockets", - "native_code", - "code_loading", - "beam_distribution", - "global_ets", - "persistent_term", - "customer_ffi", - "dynamic_eval", - "compile_time_code_execution", -]; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct Policy { - pub policy_version: String, - pub allowed_dependencies: BTreeSet, - pub allowed_dependency_checksums: BTreeMap>, - pub trusted_sdk_sha256: String, - pub forbidden_import_prefixes: Vec, - pub forbidden_source_patterns: Vec, - pub forbidden_erlang_patterns: Vec, - pub max_wall_ms: u64, - pub max_reductions: u64, - pub max_heap_bytes: u64, - pub max_processes: u32, -} - -impl Default for Policy { - fn default() -> Self { - Self { - policy_version: HOSTED_PROFILE_V2.into(), - allowed_dependencies: ["gleam_stdlib"].into_iter().map(str::to_string).collect(), - allowed_dependency_checksums: BTreeMap::new(), - trusted_sdk_sha256: DEFAULT_TRUSTED_SDK_SHA256.into(), - forbidden_import_prefixes: vec![ - "gleam/erlang".into(), - "gleam/otp".into(), - "gleam_erlang".into(), - "gleam/io".into(), - "gleam/iterator".into(), - ], - forbidden_source_patterns: vec![ - "@external(erlang".into(), - "@external(elixir".into(), - "@external(javascript".into(), - ], - forbidden_erlang_patterns: vec![ - "os:".into(), - "file:".into(), - "filelib:".into(), - "prim_file:".into(), - "erl_prim_loader:".into(), - "erl_tar:".into(), - "zip:".into(), - "code:".into(), - "compile:".into(), - "epp:".into(), - "erl_eval:".into(), - "erl_scan:".into(), - "erl_parse:".into(), - "erl_ddll:".into(), - "net_kernel:".into(), - "rpc:".into(), - "erpc:".into(), - "persistent_term:".into(), - "ets:".into(), - "dets:".into(), - "mnesia:".into(), - "disk_log:".into(), - "proc_lib:".into(), - "gen_server:start".into(), - "gen_statem:start".into(), - "supervisor:start".into(), - "gen_tcp:".into(), - "gen_udp:".into(), - "socket:".into(), - "ssl:".into(), - "httpc:".into(), - "inets:".into(), - "inet:".into(), - "erlang:spawn".into(), - "erlang:apply".into(), - "erlang:make_fun".into(), - "erlang:halt".into(), - "erlang:open_port".into(), - "erlang:load_nif".into(), - "-compile({parse_transform".into(), - "-on_load(".into(), - ], - max_wall_ms: 30_000, - max_reductions: 50_000_000, - max_heap_bytes: 64 * 1024 * 1024, - max_processes: 1, - } - } -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct WorkerConfig { - pub security: BTreeMap, - pub permissions: BTreeMap>, - pub limits: WorkerLimits, - pub durable: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct DurableActorConfig { - pub namespace: String, - pub virtual_shards: u32, - pub shards_per_actor: u32, -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct WorkerLimits { - pub max_wall_ms: Option, - pub max_reductions: Option, - pub max_heap_bytes: Option, - pub max_processes: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct Finding { - pub severity: &'static str, - pub code: &'static str, - pub file: String, - pub line: Option, - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RuntimeLimits { - pub max_wall_ms: u64, - pub max_reductions: u64, - pub max_heap_bytes: u64, - pub max_processes: u32, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AdmissionReport { - pub admitted: bool, - pub policy_version: String, - pub source_sha256: String, - pub findings: Vec, - pub runtime_limits: RuntimeLimits, - #[serde(skip_serializing_if = "Option::is_none")] - pub durable: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct CapabilityGrant { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct BuildProvenance { - pub format: &'static str, - pub builder_id: String, - pub builder_image_digest: String, - pub compiler_version: String, - pub compiler_revision: String, - pub gleam_version: String, - pub otp_release: String, - pub erts_version: String, - pub policy_sha256: String, - pub dependency_lock_sha256: Option, - pub trusted_sdk_sha256: Option, - pub source_sha256: String, - pub build_sha256: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ArtifactManifest { - pub format_version: u32, - pub runtime: &'static str, - pub language: &'static str, - pub profile: String, - pub module_contract_version: &'static str, - pub context_abi: &'static str, - pub source_sha256: String, - pub build_sha256: String, - pub provenance_sha256: String, - pub entrypoint: String, - pub capabilities: Vec, - pub runtime_limits: RuntimeLimits, - #[serde(skip_serializing_if = "Option::is_none")] - pub durable: Option, -} - -#[cfg(test)] -mod durable_config_tests { - use super::WorkerConfig; - - #[test] - fn durable_config_rejects_unknown_fields() { - let raw = r#" -[durable] -namespace = "rooms" -virtual_shards = 4096 -shards_per_actor = 64 -shards_per_acotr = 64 -"#; - let error = - toml::from_str::(raw).expect_err("unknown durable keys must fail closed"); - assert!(error.to_string().contains("unknown field")); - } -} diff --git a/certification/beamscale-artifact-abi/supervisor/bmscl_artifact.erl b/certification/beamscale-artifact-abi/supervisor/bmscl_artifact.erl deleted file mode 100644 index e19ecc0..0000000 --- a/certification/beamscale-artifact-abi/supervisor/bmscl_artifact.erl +++ /dev/null @@ -1,456 +0,0 @@ --module(bmscl_artifact). - --export([verify/1, verify_provenance/2]). - --define(PROFILE_V2, <<"bmscl-hosted-gleam-v2-read-only">>). --define(PROFILE_V3_HTTP, <<"bmscl-hosted-gleam-v3-http-capability">>). --define(PROFILE_DURABLE_V1, <<"bmscl-hosted-gleam-durable-actor-v1">>). --define(PROFILE_CRITICAL_ERLANG_V1, <<"bmscl-critical-section-erlang-v1">>). --define(LEGACY_PROFILE, <<"bmscl-hosted-gleam-v1">>). --define(ENTRYPOINT, <<"worker:handle/2">>). --define(PROVENANCE_FORMAT, <<"bmscl-build-provenance-v1">>). --define(MODULE_CONTRACT_V1, <<"bmscl-module-contract-v1">>). --define(CONTEXT_ABI_V1, <<"bmscl.context/v1">>). - --spec verify(file:filename_all()) -> {ok, map()} | {error, term()}. -verify(ArtifactDir0) -> - ArtifactDir = filename:absname(ArtifactDir0), - try - ManifestPath = filename:join(ArtifactDir, "manifest.json"), - AdmissionPath = filename:join(ArtifactDir, "admission-report.json"), - ManifestBytes = read_required_file(ManifestPath), - AdmissionBytes = read_required_file(AdmissionPath), - ok = bmscl_attestation:verify(ArtifactDir, ManifestBytes, AdmissionBytes), - Manifest = decode_json_bytes(ManifestPath, ManifestBytes), - Admission = decode_json_bytes(AdmissionPath, AdmissionBytes), - ok = verify_manifest(Manifest), - ok = verify_admission(Manifest, Admission), - Provenance = verify_artifact_provenance(ArtifactDir, Manifest), - BeamDir = filename:join(ArtifactDir, "beam"), - {ok, ModuleFiles} = read_beams(BeamDir), - %% Preserve the existing signed-artifact error contract: integrity of - %% the exact BEAM bytes is checked before structural admission. The atom - %% gate still runs strictly before any tenant code can be loaded. - ok = verify_build_digest(Manifest, ModuleFiles), - {ok, StaticAtomCount} = bmscl_beam_static_limits:verify(ModuleFiles), - Names = [Name || {Name, _Path, _Bin} <- ModuleFiles], - ok = verify_module_names(Names), - ok = verify_trusted_sdk_modules(Names, Provenance), - true = lists:member(<<"worker">>, Names), - Limits = normalize_limits(maps:get(<<"runtime_limits">>, Manifest)), - Capabilities = normalize_capabilities(maps:get(<<"capabilities">>, Manifest, [])), - Profile = maps:get(<<"profile">>, Manifest), - Durable = normalize_durable(Profile, maps:get(<<"durable">>, Manifest, undefined)), - {ok, #{ - deployment_id => maps:get(<<"build_sha256">>, Manifest), - source_sha256 => maps:get(<<"source_sha256">>, Manifest), - module_files => ModuleFiles, - module_names => Names, - static_atom_count => StaticAtomCount, - entrypoint_module => <<"worker">>, - entrypoint_function => handle, - runtime_limits => Limits, - capabilities => Capabilities, - profile => Profile, - durable => Durable, - provenance => Provenance, - manifest => Manifest - }} - catch - error:{badmatch, false} -> {error, missing_worker_entrypoint}; - error:{badmatch, {error, Reason}} -> {error, Reason}; - error:{badkey, Key} -> {error, {missing_manifest_key, Key}}; - error:Reason -> {error, Reason}; - throw:Reason -> {error, Reason} - end. - -read_required_file(Path) -> - case file:read_file(Path) of - {ok, Bytes} -> Bytes; - {error, Reason} -> error({artifact_file_read_failed, Path, Reason}) - end. - -decode_json_bytes(Path, Bytes) -> - try jsx:decode(Bytes, [return_maps]) - catch _:_ -> error({invalid_json, Path}) - end. - -verify_manifest(M) -> - Version = maps:get(<<"format_version">>, M), - Require = application:get_env(bmscl_supervisor, require_attestation, true), - case {Require, Version} of - {true, 2} -> ok; - {true, _} -> error({legacy_artifact_forbidden, Version}); - {false, 1} -> ok; - {false, 2} -> ok; - {false, _} -> error({unsupported_artifact_format, Version}) - end, - require_equal(runtime, <<"beam">>, maps:get(<<"runtime">>, M)), - Language = maps:get(<<"language">>, M), - Profile = maps:get(<<"profile">>, M), - require_language_profile(Language, Profile), - require_profile(Profile), - ok = verify_module_context_contract(Version, M), - _Durable = normalize_durable(Profile, maps:get(<<"durable">>, M, undefined)), - require_equal(entrypoint, ?ENTRYPOINT, maps:get(<<"entrypoint">>, M)), - Source = maps:get(<<"source_sha256">>, M), - Build = maps:get(<<"build_sha256">>, M), - true = valid_sha256(Source), - true = valid_sha256(Build), - case Version of - 2 -> true = valid_sha256(maps:get(<<"provenance_sha256">>, M)); - 1 -> ok - end, - Limits = normalize_limits(maps:get(<<"runtime_limits">>, M)), - require_equal(max_processes, 1, maps:get(max_processes, Limits)), - ok = bmscl_limits:validate_runtime_limits(Limits), - Capabilities = normalize_capabilities(maps:get(<<"capabilities">>, M, [])), - CapabilityNames = lists:sort(bmscl_capability_scope:names(Capabilities)), - require_capabilities(Profile, CapabilityNames), - ok. - -verify_module_context_contract(1, _Manifest) -> - ok; -verify_module_context_contract(2, Manifest) -> - Contract = maps:get(<<"module_contract_version">>, Manifest, undefined), - ContextAbi = maps:get(<<"context_abi">>, Manifest, undefined), - Require = application:get_env( - bmscl_supervisor, require_module_context_contract, false), - case {Contract, ContextAbi, Require} of - {?MODULE_CONTRACT_V1, ?CONTEXT_ABI_V1, _} -> ok; - {undefined, undefined, false} -> ok; - {undefined, undefined, true} -> error(missing_module_context_contract); - {undefined, _, _} -> error(partial_module_context_contract); - {_, undefined, _} -> error(partial_module_context_contract); - _ -> error({unsupported_module_context_contract, Contract, ContextAbi}) - end. - -require_language_profile(<<"gleam">>, ?PROFILE_V2) -> ok; -require_language_profile(<<"gleam">>, ?PROFILE_V3_HTTP) -> ok; -require_language_profile(<<"gleam">>, ?PROFILE_DURABLE_V1) -> ok; -require_language_profile(<<"gleam">>, ?LEGACY_PROFILE) -> ok; -require_language_profile(<<"erlang">>, ?PROFILE_CRITICAL_ERLANG_V1) -> ok; -require_language_profile(Language, Profile) -> - error({unsupported_language_profile, Language, Profile}). - -require_profile(?PROFILE_V2) -> ok; -require_profile(?PROFILE_V3_HTTP) -> ok; -require_profile(?PROFILE_DURABLE_V1) -> ok; -require_profile(?PROFILE_CRITICAL_ERLANG_V1) -> ok; -require_profile(?LEGACY_PROFILE) -> - case legacy_dev_artifact_allowed() of - true -> ok; - false -> require_equal(profile, ?PROFILE_V2, ?LEGACY_PROFILE) - end; -require_profile(Actual) -> - error({unsupported_hosted_profile, Actual}). - -require_capabilities(?PROFILE_V2, CapabilityNames) -> - require_equal(capability_names, - lists:sort([<<"ctx.cluster">>, <<"ctx.log">>]), CapabilityNames); -require_capabilities(?PROFILE_V3_HTTP, CapabilityNames) -> - require_equal(capability_names, - lists:sort([<<"ctx.cluster">>, <<"ctx.http">>, <<"ctx.log">>]), - CapabilityNames); -require_capabilities(?PROFILE_DURABLE_V1, CapabilityNames) -> - require_equal(capability_names, - lists:sort([<<"ctx.cluster">>, <<"ctx.log">>]), CapabilityNames); -require_capabilities(?PROFILE_CRITICAL_ERLANG_V1, CapabilityNames) -> - require_equal(capability_names, - lists:sort([<<"ctx.cluster">>, <<"ctx.log">>]), CapabilityNames); -require_capabilities(?LEGACY_PROFILE, CapabilityNames) -> - true = legacy_dev_artifact_allowed(), - require_equal(capability_names, [], CapabilityNames). - -legacy_dev_artifact_allowed() -> - application:get_env(bmscl_supervisor, require_admission_receipt, true) =:= false andalso - application:get_env(bmscl_supervisor, require_attestation, true) =:= false andalso - application:get_env(bmscl_supervisor, require_trusted_build_provenance, true) =:= false. - -verify_admission(M, A) -> - require_equal(admitted, true, maps:get(<<"admitted">>, A)), - Profile = maps:get(<<"profile">>, M), - require_equal(policy_version, Profile, maps:get(<<"policy_version">>, A)), - require_equal(source_sha256, maps:get(<<"source_sha256">>, M), maps:get(<<"source_sha256">>, A)), - require_equal(runtime_limits, - normalize_limits(maps:get(<<"runtime_limits">>, M)), - normalize_limits(maps:get(<<"runtime_limits">>, A))), - require_equal(durable, - normalize_durable(Profile, maps:get(<<"durable">>, M, undefined)), - normalize_durable(Profile, maps:get(<<"durable">>, A, undefined))), - ok. - -verify_artifact_provenance(ArtifactDir, Manifest) -> - case maps:get(<<"format_version">>, Manifest) of - 1 -> undefined; - 2 -> - Path = filename:join(ArtifactDir, "provenance.json"), - Bytes = read_required_file(Path), - Expected = maps:get(<<"provenance_sha256">>, Manifest), - require_equal(provenance_sha256, Expected, sha256_hex(Bytes)), - Provenance = decode_json_bytes(Path, Bytes), - ok = verify_provenance(Manifest, Provenance), - Provenance - end. - --spec verify_provenance(map(), map()) -> ok | no_return(). -verify_provenance(Manifest, Provenance) -> - require_equal(provenance_format, ?PROVENANCE_FORMAT, - maps:get(<<"format">>, Provenance)), - require_equal(provenance_source_sha256, - maps:get(<<"source_sha256">>, Manifest), - maps:get(<<"source_sha256">>, Provenance)), - require_equal(provenance_build_sha256, - maps:get(<<"build_sha256">>, Manifest), - maps:get(<<"build_sha256">>, Provenance)), - BuilderId = maps:get(<<"builder_id">>, Provenance), - BuilderImage = maps:get(<<"builder_image_digest">>, Provenance), - CompilerVersion = maps:get(<<"compiler_version">>, Provenance), - CompilerRevision = maps:get(<<"compiler_revision">>, Provenance), - GleamVersion = maps:get(<<"gleam_version">>, Provenance), - OtpRelease = maps:get(<<"otp_release">>, Provenance), - ErtsVersion = maps:get(<<"erts_version">>, Provenance), - PolicySha = maps:get(<<"policy_sha256">>, Provenance), - DependencyLockSha = maps:get(<<"dependency_lock_sha256">>, Provenance, null), - TrustedSdkSha = maps:get(<<"trusted_sdk_sha256">>, Provenance, null), - true = valid_identifier(BuilderId), - true = valid_image_digest(BuilderImage), - true = valid_tool_version(CompilerVersion), - true = valid_git_revision(CompilerRevision), - true = valid_tool_version(GleamVersion), - true = valid_tool_version(OtpRelease), - true = valid_tool_version(ErtsVersion), - true = valid_sha256(PolicySha), - true = valid_optional_sha256(DependencyLockSha), - true = valid_optional_sha256(TrustedSdkSha), - RequireTrustedBuild = application:get_env( - bmscl_supervisor, - require_trusted_build_provenance, - application:get_env(bmscl_supervisor, require_attestation, true)), - case RequireTrustedBuild of - true -> - require_trusted(builder_id, BuilderId, trusted_builder_ids), - require_trusted(builder_image_digest, BuilderImage, trusted_builder_image_digests), - require_trusted(policy_sha256, PolicySha, trusted_policy_sha256), - require_trusted(compiler_revision, CompilerRevision, trusted_compiler_revisions), - case maps:get(<<"language">>, Manifest) of - <<"gleam">> -> - require_trusted(gleam_version, GleamVersion, trusted_gleam_versions); - <<"erlang">> -> - ok - end, - require_trusted(otp_release, OtpRelease, trusted_otp_releases), - require_trusted(erts_version, ErtsVersion, trusted_erts_versions), - case TrustedSdkSha of - null -> ok; - _ -> require_trusted(trusted_sdk_sha256, TrustedSdkSha, trusted_sdk_sha256) - end; - false -> ok - end, - ok. - -require_trusted(Field, Value, EnvKey) -> - Configured = application:get_env(bmscl_supervisor, EnvKey, []), - case is_list(Configured) andalso - lists:member(Value, [normalize_binary(V) || V <- Configured]) of - true -> ok; - false -> error({untrusted_provenance, Field, Value}) - end. - -normalize_limits(L) -> - Wall = positive_integer(max_wall_ms, maps:get(<<"max_wall_ms">>, L)), - Reductions = positive_integer(max_reductions, maps:get(<<"max_reductions">>, L)), - Heap = positive_integer(max_heap_bytes, maps:get(<<"max_heap_bytes">>, L)), - Processes = positive_integer(max_processes, maps:get(<<"max_processes">>, L)), - #{max_wall_ms => Wall, - max_reductions => Reductions, - max_heap_bytes => Heap, - max_processes => Processes}. - -normalize_capabilities(Capabilities) -> - bmscl_capability_scope:normalize_grants(Capabilities). - -normalize_durable(?PROFILE_DURABLE_V1, Durable) when is_map(Durable) -> - Namespace = maps:get(<<"namespace">>, Durable), - case valid_durable_namespace(Namespace) of - true -> ok; - false -> error({invalid_durable_namespace, Namespace}) - end, - VirtualShards = bounded_integer( - virtual_shards, maps:get(<<"virtual_shards">>, Durable), 64, 65536), - ShardsPerActor = bounded_integer( - shards_per_actor, maps:get(<<"shards_per_actor">>, Durable), 1, 4096), - case ShardsPerActor =< VirtualShards - andalso VirtualShards rem ShardsPerActor =:= 0 of - true -> - #{namespace => Namespace, - virtual_shards => VirtualShards, - shards_per_actor => ShardsPerActor}; - false -> - error({invalid_durable_shard_layout, VirtualShards, ShardsPerActor}) - end; -normalize_durable(?PROFILE_DURABLE_V1, Value) -> - error({invalid_durable_config, Value}); -normalize_durable(_Profile, undefined) -> undefined; -normalize_durable(_Profile, null) -> undefined; -normalize_durable(Profile, Value) -> - error({durable_config_forbidden, Profile, Value}). - -valid_durable_namespace(Value) when is_binary(Value), - byte_size(Value) >= 1, - byte_size(Value) =< 128 -> - re:run(Value, <<"^[A-Za-z0-9._-]+$">>, [{capture, none}]) =:= match; -valid_durable_namespace(_) -> false. - -bounded_integer(_Name, V, Min, Max) - when is_integer(V), V >= Min, V =< Max -> V; -bounded_integer(Name, V, Min, Max) -> - error({invalid_bounded_integer, Name, V, Min, Max}). - -positive_integer(_Name, V) when is_integer(V), V > 0 -> V; -positive_integer(Name, V) -> error({invalid_positive_limit, Name, V}). - -require_equal(_Name, V, V) -> ok; -require_equal(Name, Expected, Actual) -> error({artifact_contract_mismatch, Name, Expected, Actual}). - -valid_sha256(B) when is_binary(B), byte_size(B) =:= 64 -> - re:run(B, <<"^[0-9a-f]{64}$">>, [{capture, none}]) =:= match; -valid_sha256(_) -> false. -valid_optional_sha256(null) -> true; -valid_optional_sha256(Value) -> valid_sha256(Value). -valid_identifier(B) when is_binary(B), byte_size(B) >= 1, byte_size(B) =< 128 -> - re:run(B, <<"^[A-Za-z0-9._:/-]+$">>, [{capture, none}]) =:= match; -valid_identifier(_) -> false. -valid_image_digest(B) when is_binary(B) -> - re:run(B, <<"^sha256:[0-9a-f]{64}$">>, [{capture, none}]) =:= match; -valid_image_digest(_) -> false. -valid_git_revision(B) when is_binary(B) -> - re:run(B, <<"^[0-9a-f]{40,64}$">>, [{capture, none}]) =:= match; -valid_git_revision(_) -> false. -valid_tool_version(B) when is_binary(B), byte_size(B) >= 1, byte_size(B) =< 64 -> - re:run(B, <<"^[A-Za-z0-9._+:-]+$">>, [{capture, none}]) =:= match; -valid_tool_version(_) -> false. - -read_beams(BeamDir) -> - case file:list_dir(BeamDir) of - {ok, Entries0} -> - Entries = lists:sort([E || E <- Entries0, filename:extension(E) =:= ".beam"]), - case Entries of - [] -> {error, no_beam_modules}; - _ -> read_beams(BeamDir, Entries, []) - end; - {error, Reason} -> {error, {beam_dir_read_failed, BeamDir, Reason}} - end. -read_beams(_Dir, [], Acc) -> {ok, lists:reverse(Acc)}; -read_beams(Dir, [File | Rest], Acc) -> - Path = filename:join(Dir, File), - case file:read_file(Path) of - {ok, Bin} -> - Name = unicode:characters_to_binary(filename:rootname(File, ".beam")), - read_beams(Dir, Rest, [{Name, Path, Bin} | Acc]); - {error, Reason} -> {error, {beam_read_failed, Path, Reason}} - end. - -verify_build_digest(Manifest, ModuleFiles) -> - Ctx0 = crypto:hash_init(sha256), - Ctx = lists:foldl( - fun({Name, _Path, Bin}, C0) -> - Filename = <>, - C1 = crypto:hash_update(C0, Filename), - C2 = crypto:hash_update(C1, <<0>>), - C3 = crypto:hash_update(C2, Bin), - crypto:hash_update(C3, <<0>>) - end, Ctx0, ModuleFiles), - Actual = lower_hex(crypto:hash_final(Ctx)), - Expected = maps:get(<<"build_sha256">>, Manifest), - require_equal(build_sha256, Expected, Actual). - -sha256_hex(Bytes) -> lower_hex(crypto:hash(sha256, Bytes)). -lower_hex(Bin) -> - unicode:characters_to_binary(string:lowercase(binary_to_list(binary:encode_hex(Bin)))). -normalize_binary(Value) when is_binary(Value) -> Value; -normalize_binary(Value) when is_list(Value) -> unicode:characters_to_binary(Value); -normalize_binary(Value) -> error({invalid_trusted_value, Value}). - -verify_trusted_sdk_modules(Names, undefined) -> - case [Name || Name <- Names, is_trusted_sdk_module(Name)] of - [] -> ok; - _ -> error(missing_trusted_sdk_provenance) - end; -verify_trusted_sdk_modules(Names, Provenance) -> - SdkModules = [Name || Name <- Names, is_trusted_sdk_module(Name)], - TrustedSdkSha = maps:get(<<"trusted_sdk_sha256">>, Provenance, null), - case {SdkModules, TrustedSdkSha} of - {[], null} -> ok; - {[], _} -> error({trusted_sdk_provenance_without_modules, TrustedSdkSha}); - {_, null} -> error(missing_trusted_sdk_provenance); - {_, _} -> - true = lists:member(<<"bmscl">>, SdkModules), - ok - end. - -is_trusted_sdk_module(<<"bmscl">>) -> true; -is_trusted_sdk_module(<<"bmscl@", _/binary>>) -> true; -is_trusted_sdk_module(_) -> false. - -verify_module_names(Names) -> - Max = application:get_env(bmscl_supervisor, max_modules_per_artifact, 128), - case length(Names) =< Max of - false -> error({too_many_modules, length(Names), Max}); - true -> ok - end, - case length(lists:usort(Names)) =:= length(Names) of - false -> error(duplicate_module_names); - true -> ok - end, - lists:foreach(fun verify_module_name/1, Names), ok. -verify_module_name(Name) when is_binary(Name), byte_size(Name) > 0, byte_size(Name) =< 230 -> - case re:run(Name, <<"^[a-z][a-zA-Z0-9_@]*$">>, [{capture, none}]) of - match -> ok; - nomatch -> error({invalid_tenant_module_name, Name}) - end; -verify_module_name(Name) -> error({invalid_tenant_module_name, Name}). - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -module_context_contract_rollout_test() -> - Manifest = #{<<"module_contract_version">> => ?MODULE_CONTRACT_V1, - <<"context_abi">> => ?CONTEXT_ABI_V1}, - ?assertEqual(ok, verify_module_context_contract(2, Manifest)), - ?assertError( - {unsupported_module_context_contract, <<"bad">>, ?CONTEXT_ABI_V1}, - verify_module_context_contract( - 2, Manifest#{<<"module_contract_version">> => <<"bad">>})), - ?assertError( - partial_module_context_contract, - verify_module_context_contract(2, maps:remove(<<"context_abi">>, Manifest))). - -critical_erlang_profile_binding_test() -> - ?assertEqual(ok, - require_language_profile( - <<"erlang">>, ?PROFILE_CRITICAL_ERLANG_V1)), - ?assertError( - {unsupported_language_profile, <<"erlang">>, ?PROFILE_DURABLE_V1}, - require_language_profile(<<"erlang">>, ?PROFILE_DURABLE_V1)), - ?assertEqual( - ok, - require_capabilities( - ?PROFILE_CRITICAL_ERLANG_V1, - lists:sort([<<"ctx.cluster">>, <<"ctx.log">>]))). - -trusted_sdk_module_binding_test() -> - Sha = binary:copy(<<"aa">>, 32), - Provenance = #{<<"trusted_sdk_sha256">> => Sha}, - ?assertEqual(ok, verify_trusted_sdk_modules( - [<<"worker">>, <<"bmscl">>, <<"bmscl@cluster">>], - Provenance)), - ?assertError(missing_trusted_sdk_provenance, - verify_trusted_sdk_modules([<<"worker">>, <<"bmscl">>], #{})), - ?assertError({trusted_sdk_provenance_without_modules, Sha}, - verify_trusted_sdk_modules([<<"worker">>], Provenance)), - ?assertError({badmatch, false}, - verify_trusted_sdk_modules([<<"worker">>, <<"bmscl@cluster">>], - Provenance)). - --endif. diff --git a/certification/beamscale-context-abi/gleam/gleam.toml b/certification/beamscale-context-abi/gleam/gleam.toml deleted file mode 100644 index 20ae4cb..0000000 --- a/certification/beamscale-context-abi/gleam/gleam.toml +++ /dev/null @@ -1,4 +0,0 @@ -name = "bmscl_context_abi_cert" -version = "0.0.0" -target = "erlang" -gleam = ">= 1.18.0" diff --git a/certification/beamscale-context-abi/gleam/src/bmscl.gleam b/certification/beamscale-context-abi/gleam/src/bmscl.gleam deleted file mode 100644 index 1e14968..0000000 --- a/certification/beamscale-context-abi/gleam/src/bmscl.gleam +++ /dev/null @@ -1,213 +0,0 @@ -pub const context_abi_version = "bmscl.context/v1" - -pub const module_contract_version = "bmscl-module-contract-v1" - -pub const module_semantics_version = "bmscl.module-semantics/v2" - -pub type Request { - Request( - method: String, - url: String, - headers: List(#(String, String)), - body: BitArray, - ) -} - -pub type Response { - Response(status: Int, headers: List(#(String, String)), body: BitArray) -} - -pub opaque type ClusterCapability { - ClusterCapability(token: BitArray) -} - -pub opaque type LogCapability { - LogCapability(token: BitArray) -} - -/// Hosted v2 intentionally exposes no filesystem, process, arbitrary network, -/// KV, database, queue, env, or secret capability. Tenant code receives only -/// signed same-cluster and observability capabilities through Context. -pub type Context { - Context(cluster: ClusterCapability, log: LogCapability, deadline_unix_ms: Int) -} - -/// Canonical HTTP/lambda entrypoint shape for user code. -/// -/// Giving an exported function this type (or passing it to `lambda`) makes the -/// compiler enforce the BeamScale request/context/response boundary. -pub type LambdaEntrypoint = - fn(Request, Context) -> Response - -/// Stable semantic kinds understood by the compiler and runtime. -pub type ModuleKind { - Lambda - Http - Rpc - Actor - Worker -} - -/// Declarative capability requirements. These are requests consumed by build -/// admission; opaque signed capability tokens remain the actual authority. -pub type HostedCapability { - ClusterCall - HttpClient - Log -} - -pub type InvocationMode { - RequestResponse - Mailbox - Event -} - -pub type ConcurrencyModel { - IsolatedInvocation - SerializedActor -} - -pub type CancellationModel { - Deadline - Cooperative -} - -pub type ModulePolicy { - ModulePolicy( - invocation: InvocationMode, - concurrency: ConcurrencyModel, - cancellation: CancellationModel, - ) -} - -/// A typed user module export. The constructor is opaque so callers cannot -/// manufacture a module without providing a handler of the declared type. -pub opaque type Module(input, output) { - Module( - kind: ModuleKind, - policy: ModulePolicy, - capabilities: List(HostedCapability), - entrypoint: fn(input, Context) -> output, - ) -} - -pub fn default_policy(kind: ModuleKind) -> ModulePolicy { - case kind { - Actor -> - ModulePolicy( - invocation: Mailbox, - concurrency: SerializedActor, - cancellation: Cooperative, - ) - Worker -> - ModulePolicy( - invocation: Event, - concurrency: IsolatedInvocation, - cancellation: Cooperative, - ) - Lambda | Http | Rpc -> - ModulePolicy( - invocation: RequestResponse, - concurrency: IsolatedInvocation, - cancellation: Deadline, - ) - } -} - -/// Backward-compatible constructor using the canonical policy for `kind` and -/// no declared capability requirements. -pub fn module( - kind: ModuleKind, - entrypoint: fn(input, Context) -> output, -) -> Module(input, output) { - Module( - kind: kind, - policy: default_policy(kind), - capabilities: [], - entrypoint: entrypoint, - ) -} - -/// Explicit constructor used when an export needs cluster-call/log capability -/// declarations or a non-default worker policy admitted by the build profile. -pub fn module_with_policy( - kind: ModuleKind, - policy: ModulePolicy, - capabilities: List(HostedCapability), - entrypoint: fn(input, Context) -> output, -) -> Module(input, output) { - Module( - kind: kind, - policy: policy, - capabilities: capabilities, - entrypoint: entrypoint, - ) -} - -/// Wrap the standard BeamScale HTTP/lambda handler contract. -pub fn lambda(entrypoint: LambdaEntrypoint) -> Module(Request, Response) { - module(Lambda, entrypoint) -} - -/// `http` is an explicit alias for applications that distinguish an HTTP -/// module from another lambda in their source tree. -pub fn http(entrypoint: LambdaEntrypoint) -> Module(Request, Response) { - module(Http, entrypoint) -} - -/// Wrap an RPC dispatcher while preserving its input/output types. -pub fn rpc(entrypoint: fn(input, Context) -> output) -> Module(input, output) { - module(Rpc, entrypoint) -} - -/// Actor exports are mailbox-driven and serialized by construction. -pub fn actor( - entrypoint: fn(input, Context) -> output, -) -> Module(input, output) { - module(Actor, entrypoint) -} - -/// Worker exports default to event invocation with isolated BEAM processes. -pub fn worker( - entrypoint: fn(input, Context) -> output, -) -> Module(input, output) { - module(Worker, entrypoint) -} - -pub fn module_kind(module_export: Module(input, output)) -> ModuleKind { - let Module(kind, _, _, _) = module_export - kind -} - -pub fn module_policy(module_export: Module(input, output)) -> ModulePolicy { - let Module(_, policy, _, _) = module_export - policy -} - -pub fn module_capabilities( - module_export: Module(input, output), -) -> List(HostedCapability) { - let Module(_, _, capabilities, _) = module_export - capabilities -} - -/// Runtime/scaffold adapters use this helper rather than reaching through the -/// opaque module representation. -pub fn invoke_module( - module_export: Module(input, output), - input: input, - context: Context, -) -> output { - let Module(_, _, _, entrypoint) = module_export - entrypoint(input, context) -} - -pub fn text(status: Int, body: String) -> Response { - Response( - status: status, - headers: [ - #("content-type", "text/plain; charset=utf-8"), - ], - body: <>, - ) -} diff --git a/certification/beamscale-context-abi/gleam/src/bmscl/http_context.gleam b/certification/beamscale-context-abi/gleam/src/bmscl/http_context.gleam deleted file mode 100644 index dc9485b..0000000 --- a/certification/beamscale-context-abi/gleam/src/bmscl/http_context.gleam +++ /dev/null @@ -1,80 +0,0 @@ -import bmscl.{ - type ClusterCapability, type HostedCapability, type LogCapability, - type ModuleKind, type ModulePolicy, type Request, type Response, Http, - HttpClient, default_policy, -} - -/// Opaque outbound-HTTP authority minted only by the trusted Hosted v3 adapter. -/// Hosted v2 handlers continue to use `bmscl.Context` and never receive this. -pub opaque type HttpCapability { - HttpCapability(token: BitArray) -} - -/// Hosted v3 context: read-only same-cluster calls, scoped outbound HTTP, -/// non-authoritative logging, and an invocation deadline. -pub type HttpContext { - HttpContext( - cluster: ClusterCapability, - http: HttpCapability, - log: LogCapability, - deadline_unix_ms: Int, - ) -} - -/// Canonical Hosted v3 handler shape. Unlike the v2 `bmscl.Module`, this -/// explicitly requires an HttpContext so outbound HTTP authority cannot be -/// accidentally exposed to a handler compiled against the minimal context. -pub type HttpLambdaEntrypoint = - fn(Request, HttpContext) -> Response - -/// Typed v3 module wrapper. The HTTP capability request is explicit metadata; -/// the opaque HttpCapability inside HttpContext remains the actual authority. -pub opaque type HttpModule(input, output) { - HttpModule( - kind: ModuleKind, - policy: ModulePolicy, - capabilities: List(HostedCapability), - entrypoint: fn(input, HttpContext) -> output, - ) -} - -pub fn module( - entrypoint: fn(input, HttpContext) -> output, -) -> HttpModule(input, output) { - HttpModule( - kind: Http, - policy: default_policy(Http), - capabilities: [HttpClient], - entrypoint: entrypoint, - ) -} - -pub fn http(entrypoint: HttpLambdaEntrypoint) -> HttpModule(Request, Response) { - module(entrypoint) -} - -pub fn module_kind(module_export: HttpModule(input, output)) -> ModuleKind { - let HttpModule(kind, _, _, _) = module_export - kind -} - -pub fn module_policy(module_export: HttpModule(input, output)) -> ModulePolicy { - let HttpModule(_, policy, _, _) = module_export - policy -} - -pub fn module_capabilities( - module_export: HttpModule(input, output), -) -> List(HostedCapability) { - let HttpModule(_, _, capabilities, _) = module_export - capabilities -} - -pub fn invoke_module( - module_export: HttpModule(input, output), - input: input, - context: HttpContext, -) -> output { - let HttpModule(_, _, _, entrypoint) = module_export - entrypoint(input, context) -} diff --git a/certification/beamscale-context-abi/rust/Cargo.toml b/certification/beamscale-context-abi/rust/Cargo.toml deleted file mode 100644 index 3316cdf..0000000 --- a/certification/beamscale-context-abi/rust/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "bmscl-context-contract-cert" -version = "0.0.0" -edition = "2021" -publish = false - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -thiserror = "2" diff --git a/certification/beamscale-context-abi/rust/src/lib.rs b/certification/beamscale-context-abi/rust/src/lib.rs deleted file mode 100644 index 211a471..0000000 --- a/certification/beamscale-context-abi/rust/src/lib.rs +++ /dev/null @@ -1,499 +0,0 @@ -pub mod module_contract; - -pub use module_contract::{ - parse_entrypoint, CancellationModel, ConcurrencyModel, HostedCapability, InvocationMode, - ModuleContractError, ModuleDescriptor, ModuleExecution, ModuleKind, CONTEXT_ABI_V1, - MODULE_CONTRACT_V1, MODULE_SEMANTICS_V2, -}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashSet; -use thiserror::Error; - -pub const HOSTED_PROFILE_V1: &str = "bmscl-hosted-gleam-v1"; -pub const HOSTED_PROFILE_V2: &str = "bmscl-hosted-gleam-v2-read-only"; -pub const HOSTED_PROFILE_V3_HTTP: &str = "bmscl-hosted-gleam-v3-http-capability"; -pub const DURABLE_ACTOR_PROFILE_V1: &str = "bmscl-hosted-gleam-durable-actor-v1"; -pub const ERLANG_CRITICAL_SECTION_PROFILE_V1: &str = "bmscl-critical-section-erlang-v1"; -pub const TENANT_RUNTIME_POLICY_V1: &str = "bmscl-tenant-runtime-v1"; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeBudget { - pub max_wall_ms: u64, - pub max_reductions: u64, - pub max_heap_bytes: u64, - pub max_processes: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct CapabilityGrant { - pub name: String, - #[serde(default)] - pub scope: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ArtifactManifest { - pub format_version: u32, - pub runtime: String, - pub language: String, - pub profile: String, - pub source_sha256: String, - pub build_sha256: String, - pub entrypoint: String, - pub capabilities: Vec, - pub runtime_limits: RuntimeBudget, -} - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum ManifestError { - #[error("unsupported manifest version")] - Version, - #[error("artifact language/runtime does not match the hosted profile")] - Runtime, - #[error("compiler profile is not admitted")] - Profile, - #[error("invalid sha256 digest")] - Digest, - #[error("invalid or duplicate capability grant")] - Capability, - #[error("artifact capability grants differ from module capability requests")] - CapabilityParity, - #[error("artifact entrypoint differs from admitted module entrypoint")] - EntrypointParity, - #[error("invalid runtime budget")] - Budget, - #[error(transparent)] - ModuleContract(#[from] ModuleContractError), -} - -impl ArtifactManifest { - pub fn validate_shared_tier(&self) -> Result<(), ManifestError> { - if self.format_version != 1 { - return Err(ManifestError::Version); - } - if self.runtime != "beam" { - return Err(ManifestError::Runtime); - } - match self.profile.as_str() { - HOSTED_PROFILE_V1 - | HOSTED_PROFILE_V2 - | HOSTED_PROFILE_V3_HTTP - | DURABLE_ACTOR_PROFILE_V1 - if self.language == "gleam" => {} - ERLANG_CRITICAL_SECTION_PROFILE_V1 if self.language == "erlang" => {} - HOSTED_PROFILE_V1 - | HOSTED_PROFILE_V2 - | HOSTED_PROFILE_V3_HTTP - | DURABLE_ACTOR_PROFILE_V1 - | ERLANG_CRITICAL_SECTION_PROFILE_V1 => return Err(ManifestError::Runtime), - _ => return Err(ManifestError::Profile), - } - if !is_sha256(&self.source_sha256) || !is_sha256(&self.build_sha256) { - return Err(ManifestError::Digest); - } - - let mut names = HashSet::new(); - if self.capabilities.iter().any(|grant| { - hosted_capability_from_grant_name(&grant.name).is_none() - || !names.insert(grant.name.as_str()) - }) { - return Err(ManifestError::Capability); - } - - let actual = self - .capabilities - .iter() - .filter_map(|grant| hosted_capability_from_grant_name(&grant.name)) - .collect::>(); - let required = match self.profile.as_str() { - HOSTED_PROFILE_V2 | DURABLE_ACTOR_PROFILE_V1 | ERLANG_CRITICAL_SECTION_PROFILE_V1 => { - HashSet::from([HostedCapability::ClusterCall, HostedCapability::Log]) - } - HOSTED_PROFILE_V3_HTTP => HashSet::from([ - HostedCapability::ClusterCall, - HostedCapability::Http, - HostedCapability::Log, - ]), - // Legacy v1 is dev-only compatibility. Keep its old capability - // shape readable without widening any current production profile. - HOSTED_PROFILE_V1 => actual.clone(), - _ => unreachable!("profile was validated above"), - }; - if actual != required { - return Err(ManifestError::Capability); - } - - // Hosted Gleam owns exactly one BEAM process per admitted invocation. - // Trusted runtime processes (supervisors, pools, telemetry, routing) are - // platform-owned and are not counted in this tenant limit. - if self.runtime_limits.max_wall_ms == 0 - || self.runtime_limits.max_reductions == 0 - || self.runtime_limits.max_heap_bytes < 1024 * 1024 - || self.runtime_limits.max_processes != 1 - { - return Err(ManifestError::Budget); - } - Ok(()) - } - - /// Validate both sides of the hosted capability boundary: the public module - /// declares what it needs, and the build artifact records exactly what the - /// runtime will grant. Neither side may silently widen the other. - pub fn validate_shared_tier_for_module( - &self, - descriptor: &ModuleDescriptor, - ) -> Result<(), ManifestError> { - self.validate_shared_tier()?; - descriptor.validate()?; - if self.entrypoint != descriptor.entrypoint { - return Err(ManifestError::EntrypointParity); - } - - let requested = descriptor - .capabilities - .iter() - .copied() - .collect::>(); - let granted = self - .capabilities - .iter() - .filter_map(|grant| hosted_capability_from_grant_name(&grant.name)) - .collect::>(); - if requested != granted { - return Err(ManifestError::CapabilityParity); - } - Ok(()) - } -} - -pub const fn hosted_capability_grant_name(capability: HostedCapability) -> &'static str { - match capability { - HostedCapability::ClusterCall => "ctx.cluster", - HostedCapability::Http => "ctx.http", - HostedCapability::Log => "ctx.log", - } -} - -pub fn hosted_capability_from_grant_name(value: &str) -> Option { - match value { - "ctx.cluster" => Some(HostedCapability::ClusterCall), - "ctx.http" => Some(HostedCapability::Http), - "ctx.log" => Some(HostedCapability::Log), - _ => None, - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum TenantIsolation { - /// Production default: one hardware-virtualized microVM per tenant shard. - MicroVm, - /// Development/testing only unless an explicit stronger policy allows it. - LinuxContainer, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum MicroVmBackend { - Firecracker, - CloudHypervisor, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum TenantRuntimeState { - Cold, - Starting, - Hot, - WarmIdle, - Hibernated, - Draining, - Terminated, -} - -impl TenantRuntimeState { - pub fn can_transition_to(self, next: Self) -> bool { - use TenantRuntimeState::*; - matches!( - (self, next), - (Cold, Starting) - | (Starting, Hot) - | (Starting, Terminated) - | (Hot, WarmIdle) - | (Hot, Draining) - | (WarmIdle, Hot) - | (WarmIdle, Hibernated) - | (WarmIdle, Draining) - | (Hibernated, Starting) - | (Hibernated, Terminated) - | (Draining, Terminated) - | (Terminated, Cold) - ) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TenantRuntimePolicy { - pub policy_version: String, - pub isolation: TenantIsolation, - #[serde(default = "default_microvm_backend")] - pub microvm_backend: MicroVmBackend, - pub vcpu_count: u16, - pub min_memory_bytes: u64, - pub max_memory_bytes: u64, - pub warm_idle_ms: u64, - pub hibernate_after_ms: u64, - pub destroy_after_ms: u64, - pub allow_snapshot_restore: bool, -} - -fn default_microvm_backend() -> MicroVmBackend { - MicroVmBackend::Firecracker -} - -impl Default for TenantRuntimePolicy { - fn default() -> Self { - Self { - policy_version: TENANT_RUNTIME_POLICY_V1.into(), - isolation: TenantIsolation::MicroVm, - microvm_backend: MicroVmBackend::Firecracker, - vcpu_count: 1, - min_memory_bytes: 128 * 1024 * 1024, - max_memory_bytes: 1024 * 1024 * 1024, - warm_idle_ms: 10_000, - hibernate_after_ms: 60_000, - destroy_after_ms: 15 * 60_000, - allow_snapshot_restore: true, - } - } -} - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum TenantRuntimePolicyError { - #[error("unsupported tenant runtime policy version")] - Version, - #[error("production tenant runtime must use a microVM")] - Isolation, - #[error("invalid vCPU allocation")] - Cpu, - #[error("invalid memory allocation")] - Memory, - #[error("invalid idle/hibernation/destruction lifecycle ordering")] - Lifecycle, -} - -impl TenantRuntimePolicy { - pub fn validate_production(&self) -> Result<(), TenantRuntimePolicyError> { - if self.policy_version != TENANT_RUNTIME_POLICY_V1 { - return Err(TenantRuntimePolicyError::Version); - } - if self.isolation != TenantIsolation::MicroVm { - return Err(TenantRuntimePolicyError::Isolation); - } - if self.vcpu_count == 0 { - return Err(TenantRuntimePolicyError::Cpu); - } - if self.min_memory_bytes < 64 * 1024 * 1024 || self.max_memory_bytes < self.min_memory_bytes - { - return Err(TenantRuntimePolicyError::Memory); - } - if self.warm_idle_ms == 0 - || self.hibernate_after_ms <= self.warm_idle_ms - || self.destroy_after_ms <= self.hibernate_after_ms - { - return Err(TenantRuntimePolicyError::Lifecycle); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct MeteringSample { - /// Number of admitted root/child invocations completed in the sample. - pub request_count: u64, - /// CPU consumed by the tenant microVM during the sample. - pub cpu_ns: u64, - /// Integral of resident/charged memory over time, in byte-milliseconds. - pub memory_byte_ms: u128, - pub ingress_bytes: u64, - pub egress_bytes: u64, - pub durable_storage_byte_ms: u128, -} - -impl MeteringSample { - pub fn merge(&mut self, other: &Self) { - self.request_count = self.request_count.saturating_add(other.request_count); - self.cpu_ns = self.cpu_ns.saturating_add(other.cpu_ns); - self.memory_byte_ms = self.memory_byte_ms.saturating_add(other.memory_byte_ms); - self.ingress_bytes = self.ingress_bytes.saturating_add(other.ingress_bytes); - self.egress_bytes = self.egress_bytes.saturating_add(other.egress_bytes); - self.durable_storage_byte_ms = self - .durable_storage_byte_ms - .saturating_add(other.durable_storage_byte_ms); - } -} - -fn is_sha256(value: &str) -> bool { - value.len() == 64 - && value - .bytes() - .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn valid_manifest() -> ArtifactManifest { - ArtifactManifest { - format_version: 1, - runtime: "beam".into(), - language: "gleam".into(), - profile: HOSTED_PROFILE_V2.into(), - source_sha256: "a".repeat(64), - build_sha256: "b".repeat(64), - entrypoint: "worker:handle/2".into(), - capabilities: vec![ - CapabilityGrant { - name: "ctx.log".into(), - scope: None, - }, - CapabilityGrant { - name: "ctx.cluster".into(), - scope: Some(json!({"actors":["catalog"]})), - }, - ], - runtime_limits: RuntimeBudget { - max_wall_ms: 30_000, - max_reductions: 50_000_000, - max_heap_bytes: 64 * 1024 * 1024, - max_processes: 1, - }, - } - } - - fn valid_descriptor() -> ModuleDescriptor { - ModuleDescriptor { - contract_version: MODULE_CONTRACT_V1.into(), - context_abi: CONTEXT_ABI_V1.into(), - semantics_version: Some(MODULE_SEMANTICS_V2.into()), - kind: ModuleKind::Worker, - name: "catalog_worker".into(), - entrypoint: "worker:handle/2".into(), - execution: ModuleExecution { - invocation: InvocationMode::Event, - concurrency: ConcurrencyModel::IsolatedInvocation, - cancellation: CancellationModel::Cooperative, - }, - capabilities: vec![HostedCapability::Log, HostedCapability::ClusterCall], - } - } - - #[test] - fn validates_hosted_manifest_and_module_capability_parity() { - assert_eq!( - valid_manifest().validate_shared_tier_for_module(&valid_descriptor()), - Ok(()) - ); - } - - #[test] - fn rejects_artifact_entrypoint_that_differs_from_typed_module() { - let mut manifest = valid_manifest(); - manifest.entrypoint = "other:handle/2".into(); - assert_eq!( - manifest.validate_shared_tier_for_module(&valid_descriptor()), - Err(ManifestError::EntrypointParity) - ); - } - - #[test] - fn admits_current_v3_http_profile_and_capability_name() { - let mut manifest = valid_manifest(); - manifest.profile = HOSTED_PROFILE_V3_HTTP.into(); - manifest.capabilities.push(CapabilityGrant { - name: "ctx.http".into(), - scope: Some(json!({"origins":["https://api.example.com"]})), - }); - let mut descriptor = valid_descriptor(); - descriptor.kind = ModuleKind::Http; - descriptor.execution = ModuleExecution::default(); - descriptor.capabilities.push(HostedCapability::Http); - assert_eq!( - manifest.validate_shared_tier_for_module(&descriptor), - Ok(()) - ); - } - - #[test] - fn rejects_legacy_cluster_call_capability_alias() { - let mut manifest = valid_manifest(); - manifest.capabilities[1].name = "ctx.cluster_call".into(); - assert_eq!( - manifest.validate_shared_tier(), - Err(ManifestError::Capability) - ); - } - - #[test] - fn v2_rejects_http_capability_widening() { - let mut manifest = valid_manifest(); - manifest.capabilities.push(CapabilityGrant { - name: "ctx.http".into(), - scope: None, - }); - assert_eq!( - manifest.validate_shared_tier(), - Err(ManifestError::Capability) - ); - } - - #[test] - fn rejects_unknown_hosted_capability_names() { - let mut manifest = valid_manifest(); - manifest.capabilities.push(CapabilityGrant { - name: "ctx.secrets".into(), - scope: None, - }); - assert_eq!( - manifest.validate_shared_tier(), - Err(ManifestError::Capability) - ); - } - - #[test] - fn rejects_capability_widening_or_omission() { - let mut descriptor = valid_descriptor(); - descriptor.capabilities.pop(); - assert_eq!( - valid_manifest().validate_shared_tier_for_module(&descriptor), - Err(ManifestError::CapabilityParity) - ); - } - - #[test] - fn shared_tier_rejects_more_than_one_tenant_process() { - let mut manifest = valid_manifest(); - manifest.runtime_limits.max_processes = 2; - assert_eq!(manifest.validate_shared_tier(), Err(ManifestError::Budget)); - } - - #[test] - fn production_runtime_defaults_to_microvm() { - let policy = TenantRuntimePolicy::default(); - assert_eq!(policy.validate_production(), Ok(())); - assert_eq!(policy.isolation, TenantIsolation::MicroVm); - assert_eq!(policy.microvm_backend, MicroVmBackend::Firecracker); - } - - #[test] - fn lifecycle_is_explicit() { - assert!(TenantRuntimeState::Cold.can_transition_to(TenantRuntimeState::Starting)); - assert!(TenantRuntimeState::Hot.can_transition_to(TenantRuntimeState::WarmIdle)); - assert!(TenantRuntimeState::WarmIdle.can_transition_to(TenantRuntimeState::Hibernated)); - assert!(!TenantRuntimeState::Hot.can_transition_to(TenantRuntimeState::Cold)); - } -} diff --git a/certification/beamscale-context-abi/rust/src/module_contract.rs b/certification/beamscale-context-abi/rust/src/module_contract.rs deleted file mode 100644 index d849146..0000000 --- a/certification/beamscale-context-abi/rust/src/module_contract.rs +++ /dev/null @@ -1,331 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use thiserror::Error; - -/// Versioned semantic contract used by the compiler/runtime when admitting -/// user-exported modules. -pub const MODULE_CONTRACT_V1: &str = "bmscl-module-contract-v1"; -pub const MODULE_SEMANTICS_V2: &str = "bmscl.module-semantics/v2"; -pub const CONTEXT_ABI_V1: &str = "bmscl.context/v1"; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ModuleKind { - Lambda, - Http, - Rpc, - Actor, - Worker, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum InvocationMode { - RequestResponse, - Mailbox, - Event, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ConcurrencyModel { - /// One short-lived BEAM process owns one admitted invocation. - IsolatedInvocation, - /// One actor processes messages serially through its mailbox. - SerializedActor, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum CancellationModel { - Deadline, - Cooperative, -} - -/// Hosted BeamScale deliberately exposes a very small capability vocabulary. -/// The tokens themselves remain opaque in the public SDK. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum HostedCapability { - ClusterCall, - Http, - Log, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub struct ModuleExecution { - pub invocation: InvocationMode, - pub concurrency: ConcurrencyModel, - pub cancellation: CancellationModel, -} - -impl Default for ModuleExecution { - fn default() -> Self { - Self { - invocation: InvocationMode::RequestResponse, - concurrency: ConcurrencyModel::IsolatedInvocation, - cancellation: CancellationModel::Deadline, - } - } -} - -/// Language-neutral description of one user-exported module. -/// -/// The public SDKs provide language-native types that make the handler shape a -/// compile-time contract. This descriptor is the runtime/compiler projection -/// used for admission and artifact manifests. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ModuleDescriptor { - pub contract_version: String, - pub context_abi: String, - #[serde(default)] - pub semantics_version: Option, - pub kind: ModuleKind, - pub name: String, - /// Runtime-qualified entrypoint, for example `worker:handle/2`. - pub entrypoint: String, - pub execution: ModuleExecution, - #[serde(default)] - pub capabilities: Vec, -} - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum ModuleContractError { - #[error("unsupported module contract version")] - Version, - #[error("unsupported context ABI")] - ContextAbi, - #[error("unsupported module semantics version")] - SemanticsVersion, - #[error("module name must be a non-empty portable identifier")] - Name, - #[error("entrypoint must use :/")] - Entrypoint, - #[error("typed BeamScale module entrypoints must have arity 2: (input, Context)")] - EntrypointArity, - #[error("module execution semantics do not match its kind")] - Execution, - #[error("module capability list contains duplicates")] - DuplicateCapability, -} - -impl ModuleDescriptor { - pub fn validate(&self) -> Result<(), ModuleContractError> { - if self.contract_version != MODULE_CONTRACT_V1 { - return Err(ModuleContractError::Version); - } - if self.context_abi != CONTEXT_ABI_V1 { - return Err(ModuleContractError::ContextAbi); - } - if self - .semantics_version - .as_deref() - .is_some_and(|version| version != MODULE_SEMANTICS_V2) - { - return Err(ModuleContractError::SemanticsVersion); - } - if !is_portable_ident(&self.name) { - return Err(ModuleContractError::Name); - } - let (_, _, arity) = - parse_entrypoint(&self.entrypoint).ok_or(ModuleContractError::Entrypoint)?; - if arity != 2 { - return Err(ModuleContractError::EntrypointArity); - } - - if !execution_matches_kind(self.kind, self.execution) { - return Err(ModuleContractError::Execution); - } - - let mut unique = HashSet::with_capacity(self.capabilities.len()); - if self - .capabilities - .iter() - .any(|capability| !unique.insert(*capability)) - { - return Err(ModuleContractError::DuplicateCapability); - } - Ok(()) - } -} - -fn execution_matches_kind(kind: ModuleKind, execution: ModuleExecution) -> bool { - match kind { - ModuleKind::Actor => { - execution.invocation == InvocationMode::Mailbox - && execution.concurrency == ConcurrencyModel::SerializedActor - && execution.cancellation == CancellationModel::Cooperative - } - ModuleKind::Lambda | ModuleKind::Http | ModuleKind::Rpc => { - execution.invocation == InvocationMode::RequestResponse - && execution.concurrency == ConcurrencyModel::IsolatedInvocation - && execution.cancellation == CancellationModel::Deadline - } - ModuleKind::Worker => { - matches!( - execution.invocation, - InvocationMode::Mailbox | InvocationMode::Event - ) - } - } -} - -/// Parse a Beam-style module/function/arity entrypoint without imposing -/// transport semantics. Signature/type conformance is enforced by the public -/// SDK at compile time. -pub fn parse_entrypoint(value: &str) -> Option<(&str, &str, u16)> { - let (module, rest) = value.split_once(':')?; - let (function, arity) = rest.rsplit_once('/')?; - if !is_portable_path(module) || !is_portable_ident(function) { - return None; - } - let arity = arity.parse::().ok()?; - if arity == 0 { - return None; - } - Some((module, function, arity)) -} - -fn is_portable_ident(value: &str) -> bool { - !value.is_empty() - && value - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')) -} - -fn is_portable_path(value: &str) -> bool { - !value.is_empty() - && value - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/')) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn accepts_request_response_module_v2() { - let descriptor = ModuleDescriptor { - contract_version: MODULE_CONTRACT_V1.into(), - context_abi: CONTEXT_ABI_V1.into(), - semantics_version: Some(MODULE_SEMANTICS_V2.into()), - kind: ModuleKind::Lambda, - name: "hello_world".into(), - entrypoint: "worker:handle/2".into(), - execution: ModuleExecution::default(), - capabilities: vec![HostedCapability::Log], - }; - - assert_eq!(descriptor.validate(), Ok(())); - assert_eq!( - parse_entrypoint(&descriptor.entrypoint), - Some(("worker", "handle", 2)) - ); - } - - #[test] - fn rejects_entrypoint_arity_that_cannot_match_public_context_abi() { - let descriptor = ModuleDescriptor { - contract_version: MODULE_CONTRACT_V1.into(), - context_abi: CONTEXT_ABI_V1.into(), - semantics_version: Some(MODULE_SEMANTICS_V2.into()), - kind: ModuleKind::Lambda, - name: "bad_arity".into(), - entrypoint: "worker:handle/1".into(), - execution: ModuleExecution::default(), - capabilities: Vec::new(), - }; - assert_eq!( - descriptor.validate(), - Err(ModuleContractError::EntrypointArity) - ); - } - - #[test] - fn actor_requires_mailbox_serialization() { - let mut descriptor = ModuleDescriptor { - contract_version: MODULE_CONTRACT_V1.into(), - context_abi: CONTEXT_ABI_V1.into(), - semantics_version: Some(MODULE_SEMANTICS_V2.into()), - kind: ModuleKind::Actor, - name: "counter".into(), - entrypoint: "counter:handle/2".into(), - execution: ModuleExecution::default(), - capabilities: vec![HostedCapability::ClusterCall, HostedCapability::Log], - }; - assert_eq!(descriptor.validate(), Err(ModuleContractError::Execution)); - - descriptor.execution = ModuleExecution { - invocation: InvocationMode::Mailbox, - concurrency: ConcurrencyModel::SerializedActor, - cancellation: CancellationModel::Cooperative, - }; - assert_eq!(descriptor.validate(), Ok(())); - } - - #[test] - fn request_response_modules_require_deadline_cancellation() { - let descriptor = ModuleDescriptor { - contract_version: MODULE_CONTRACT_V1.into(), - context_abi: CONTEXT_ABI_V1.into(), - semantics_version: Some(MODULE_SEMANTICS_V2.into()), - kind: ModuleKind::Http, - name: "http_worker".into(), - entrypoint: "worker:handle/2".into(), - execution: ModuleExecution { - invocation: InvocationMode::RequestResponse, - concurrency: ConcurrencyModel::IsolatedInvocation, - cancellation: CancellationModel::Cooperative, - }, - capabilities: vec![HostedCapability::Http, HostedCapability::Log], - }; - assert_eq!(descriptor.validate(), Err(ModuleContractError::Execution)); - } - - #[test] - fn rejects_duplicate_capability_requests() { - let descriptor = ModuleDescriptor { - contract_version: MODULE_CONTRACT_V1.into(), - context_abi: CONTEXT_ABI_V1.into(), - semantics_version: Some(MODULE_SEMANTICS_V2.into()), - kind: ModuleKind::Rpc, - name: "lookup".into(), - entrypoint: "rpc:lookup/2".into(), - execution: ModuleExecution::default(), - capabilities: vec![HostedCapability::Log, HostedCapability::Log], - }; - assert_eq!( - descriptor.validate(), - Err(ModuleContractError::DuplicateCapability) - ); - } - - #[test] - fn omitted_semantics_version_does_not_bypass_kind_execution_rules() { - let descriptor = ModuleDescriptor { - contract_version: MODULE_CONTRACT_V1.into(), - context_abi: CONTEXT_ABI_V1.into(), - semantics_version: None, - kind: ModuleKind::Actor, - name: "actor_without_semantics_tag".into(), - entrypoint: "actor:handle/2".into(), - execution: ModuleExecution::default(), - capabilities: Vec::new(), - }; - assert_eq!(descriptor.validate(), Err(ModuleContractError::Execution)); - } - - #[test] - fn descriptor_deserialization_requires_explicit_execution() { - let json = r#"{ - "contract_version":"bmscl-module-contract-v1", - "context_abi":"bmscl.context/v1", - "kind":"lambda", - "name":"hello", - "entrypoint":"worker:handle/2", - "capabilities":[] - }"#; - assert!(serde_json::from_str::(json).is_err()); - } -} diff --git a/certification/scintilla-context-abi/Cargo.toml b/certification/scintilla-context-abi/Cargo.toml deleted file mode 100644 index 9e34787..0000000 --- a/certification/scintilla-context-abi/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "scintilla-lib-core" -version = "0.1.0" -edition = "2021" -rust-version = "1.88" -license = "MIT" -description = "Canonical Scintilla persistence contracts, capability profiles, and DPM planning" -repository = "https://github.com/scintilla-run/scintilla-lib-core" - -[features] -default = ["contracts", "read"] -contracts = [] -read = ["dep:sea-orm"] -write = ["read"] -migrator = ["contracts", "dep:tokio"] - -[dependencies] -sea-orm = { version = "1.1", optional = true, default-features = false, features = ["runtime-tokio-rustls", "sqlx-postgres"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -thiserror = "2" -tokio = { version = "1", optional = true, features = ["io-util", "macros", "process", "time"] } - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -all = "warn" diff --git a/certification/scintilla-context-abi/SOURCE.lock.json b/certification/scintilla-context-abi/SOURCE.lock.json deleted file mode 100644 index e49b7f7..0000000 --- a/certification/scintilla-context-abi/SOURCE.lock.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schema_version": "scintilla.context-abi-cert/v1", - "source_head": "3bae587243b581558c2e612f182d990ffd9034d9", - "blobs": { - "Cargo.toml": "9e34787f83c4f7679fc4155863bffe868e19a04e", - "src/capabilities.rs": "058a1b09c6cb6c16461753803f660320668f78a1", - "src/contracts.rs": "e1f860da03530f530d2597150efb6cd550d4a55b", - "src/dpm.rs": "e4dff937f60f60892e2fa5e2b537cc743f98d8ab", - "src/lib.rs": "4dbff7fabc7473f04cec0d7b265a19c588178900", - "src/module_contract.rs": "24cf89086bc1b8e884704143a2be67c6baa2d06c", - "tests/contracts.rs": "c4465d43087e614b05a60202c373d99d3157295d", - "tests/module_contract_fixture.rs": "3aaed141b1d9c4710ac6bf413ae12e2ff6b716b0" - } -} diff --git a/certification/scintilla-context-abi/src/capabilities.rs b/certification/scintilla-context-abi/src/capabilities.rs deleted file mode 100644 index 058a1b0..0000000 --- a/certification/scintilla-context-abi/src/capabilities.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! Opaque database capability contexts. -//! -//! The web tier selects the read feature and a database-enforced read-only -//! principal. Only the API or explicitly delegated workers select write. - -use std::sync::Arc; - -use sea_orm::{ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, DbErr, Statement}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum DatabaseFlavor { - PostgreSql, - CockroachDb, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ActorContext(String); - -impl ActorContext { - pub fn new(value: impl Into) -> Result { - bounded_context(value.into(), "actor").map(Self) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TenantContext(String); - -impl TenantContext { - pub fn new(value: impl Into) -> Result { - bounded_context(value.into(), "tenant").map(Self) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AccessScope { - actor: ActorContext, - tenant: TenantContext, -} - -impl AccessScope { - pub fn new(actor: ActorContext, tenant: TenantContext) -> Self { - Self { actor, tenant } - } - - pub fn actor(&self) -> &ActorContext { - &self.actor - } - - pub fn tenant(&self) -> &TenantContext { - &self.tenant - } -} - -#[derive(Debug, Error)] -pub enum CapabilityError { - #[error("{0} context must contain 1..160 printable characters")] - InvalidContext(&'static str), - #[error("database operation failed")] - Database(#[from] DbErr), - #[error("database did not enforce a read-only transaction default")] - ReadOnlyNotEnforced, - #[error("database URL is not PostgreSQL-compatible")] - UnsupportedDatabase, -} - -#[derive(Clone)] -pub struct ReadContext { - connection: Arc, - scope: AccessScope, - flavor: DatabaseFlavor, -} - -impl ReadContext { - pub async fn connect(database_url: &str, scope: AccessScope) -> Result { - let flavor = database_flavor(database_url)?; - let connection = Database::connect(database_url).await?; - connection - .execute(Statement::from_string( - DatabaseBackend::Postgres, - "set default_transaction_read_only = on".to_owned(), - )) - .await?; - let result = connection - .query_one(Statement::from_string( - DatabaseBackend::Postgres, - "show default_transaction_read_only".to_owned(), - )) - .await? - .ok_or(CapabilityError::ReadOnlyNotEnforced)?; - let setting: String = result.try_get("", "default_transaction_read_only")?; - if !matches!(setting.as_str(), "on" | "true") { - return Err(CapabilityError::ReadOnlyNotEnforced); - } - Ok(Self { - connection: Arc::new(connection), - scope, - flavor, - }) - } - - pub fn scope(&self) -> &AccessScope { - &self.scope - } - - pub fn flavor(&self) -> DatabaseFlavor { - self.flavor - } - - pub async fn health_check(&self) -> Result<(), CapabilityError> { - self.connection - .execute(Statement::from_string( - DatabaseBackend::Postgres, - "select 1".to_owned(), - )) - .await?; - Ok(()) - } -} - -#[cfg(feature = "write")] -#[derive(Clone)] -pub struct WriteContext { - connection: Arc, - scope: AccessScope, - flavor: DatabaseFlavor, -} - -#[cfg(feature = "write")] -impl WriteContext { - pub async fn connect(database_url: &str, scope: AccessScope) -> Result { - let flavor = database_flavor(database_url)?; - let connection = Database::connect(database_url).await?; - Ok(Self { - connection: Arc::new(connection), - scope, - flavor, - }) - } - - pub fn scope(&self) -> &AccessScope { - &self.scope - } - - pub fn flavor(&self) -> DatabaseFlavor { - self.flavor - } - - pub async fn health_check(&self) -> Result<(), CapabilityError> { - self.connection - .execute(Statement::from_string( - DatabaseBackend::Postgres, - "select 1".to_owned(), - )) - .await?; - Ok(()) - } -} - -pub fn database_flavor(database_url: &str) -> Result { - let normalized = database_url.to_ascii_lowercase(); - if !(normalized.starts_with("postgres://") || normalized.starts_with("postgresql://")) { - return Err(CapabilityError::UnsupportedDatabase); - } - if normalized.contains("cockroach") { - Ok(DatabaseFlavor::CockroachDb) - } else { - Ok(DatabaseFlavor::PostgreSql) - } -} - -fn bounded_context(value: String, label: &'static str) -> Result { - let valid = !value.is_empty() - && value.len() <= 160 - && value.chars().all(|character| !character.is_control()); - valid - .then_some(value) - .ok_or(CapabilityError::InvalidContext(label)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn contexts_fail_closed() { - assert!(ActorContext::new("").is_err()); - assert!(TenantContext::new("tenant\nother").is_err()); - } - - #[test] - fn classifies_both_supported_engines() { - assert_eq!( - database_flavor("postgresql://db/product").unwrap(), - DatabaseFlavor::PostgreSql - ); - assert_eq!( - database_flavor("postgresql://cockroachdb/product").unwrap(), - DatabaseFlavor::CockroachDb - ); - } -} diff --git a/certification/scintilla-context-abi/src/contracts.rs b/certification/scintilla-context-abi/src/contracts.rs deleted file mode 100644 index e1f860d..0000000 --- a/certification/scintilla-context-abi/src/contracts.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Compile-time access to the immutable persistence contract bundle. - -pub const PERSISTENCE_SCHEMA_JSON: &str = include_str!("../contracts/database/schema.json"); -pub const DESIRED_SQL: &str = include_str!("../contracts/database/desired.sql"); -pub const COMPATIBILITY_JSON: &str = include_str!("../contracts/database/compatibility.json"); -pub const SOURCE_PINS_JSON: &str = include_str!("../contracts/database/sources.json"); -pub const GENERATED_PROVENANCE_JSON: &str = include_str!("../generated/provenance.json"); diff --git a/certification/scintilla-context-abi/src/dpm.rs b/certification/scintilla-context-abi/src/dpm.rs deleted file mode 100644 index e4dff93..0000000 --- a/certification/scintilla-context-abi/src/dpm.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Bounded integration with declarative-postgres-migrate. -//! -//! Commands are spawned directly without a shell. Only plan, verification, and -//! bootstrap operations exist; apply is deliberately not representable. - -use std::ffi::{OsStr, OsString}; -use std::fmt::{Debug, Formatter}; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::time::Duration; - -use thiserror::Error; -use tokio::io::{AsyncRead, AsyncReadExt}; -use tokio::process::Command; - -const DEFAULT_OUTPUT_LIMIT: usize = 4 * 1024 * 1024; -const MAX_TIMEOUT: Duration = Duration::from_secs(300); - -#[derive(Clone, PartialEq, Eq)] -pub struct SecretArg(String); - -impl SecretArg { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - if value.is_empty() || value.contains('\0') { - return Err(DpmError::InvalidSecret); - } - Ok(Self(value)) - } - - fn expose(&self) -> &str { - &self.0 - } -} - -impl Debug for SecretArg { - fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { - formatter.write_str("SecretArg([REDACTED])") - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DpmOperation { - Diff, - Verify, - Bootstrap, -} - -#[derive(Clone)] -pub struct DpmRequest { - pub operation: DpmOperation, - pub source: PathBuf, - pub target: Option, - pub shadow: SecretArg, -} - -impl Debug for DpmRequest { - fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("DpmRequest") - .field("operation", &self.operation) - .field("source", &self.source) - .field("target", &self.target) - .field("shadow", &self.shadow) - .finish() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DpmOutput { - pub stdout: String, -} - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum DpmError { - #[error("DPM source must be a readable regular file")] - InvalidSource, - #[error("DPM diff and verify require a target")] - MissingTarget, - #[error("DPM secret argument is empty or invalid")] - InvalidSecret, - #[error("DPM timeout must be between one millisecond and five minutes")] - InvalidTimeout, - #[error("failed to spawn DPM")] - Spawn, - #[error("DPM timed out")] - Timeout, - #[error("DPM output exceeded the configured limit")] - OutputLimit, - #[error("DPM output was not UTF-8")] - InvalidOutput, - #[error("DPM exited unsuccessfully with code {code:?}")] - NonZero { code: Option }, -} - -#[derive(Debug, Clone)] -pub struct DpmCli { - binary: PathBuf, - timeout: Duration, - output_limit: usize, -} - -impl DpmCli { - pub fn new(binary: impl Into) -> Self { - Self { - binary: binary.into(), - timeout: Duration::from_secs(30), - output_limit: DEFAULT_OUTPUT_LIMIT, - } - } - - pub fn with_limits(mut self, timeout: Duration, output_limit: usize) -> Result { - if timeout.is_zero() || timeout > MAX_TIMEOUT { - return Err(DpmError::InvalidTimeout); - } - if output_limit == 0 { - return Err(DpmError::OutputLimit); - } - self.timeout = timeout; - self.output_limit = output_limit; - Ok(self) - } - - pub async fn run(&self, request: &DpmRequest) -> Result { - let args = validated_args(request)?; - let mut command = Command::new(&self.binary); - command - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - let mut child = command.spawn().map_err(|_| DpmError::Spawn)?; - let stdout = child.stdout.take().ok_or(DpmError::Spawn)?; - let stderr = child.stderr.take().ok_or(DpmError::Spawn)?; - let operation = async { - tokio::try_join!( - read_limited(stdout, self.output_limit), - read_limited(stderr, self.output_limit), - async { child.wait().await.map_err(|_| DpmError::Spawn) } - ) - }; - let (stdout, _stderr, status) = match tokio::time::timeout(self.timeout, operation).await { - Ok(Ok(result)) => result, - Ok(Err(error)) => { - let _ = child.kill().await; - return Err(error); - } - Err(_) => { - let _ = child.kill().await; - return Err(DpmError::Timeout); - } - }; - if !status.success() { - return Err(DpmError::NonZero { - code: status.code(), - }); - } - let stdout = String::from_utf8(stdout).map_err(|_| DpmError::InvalidOutput)?; - Ok(DpmOutput { stdout }) - } -} - -async fn read_limited(reader: impl AsyncRead + Unpin, limit: usize) -> Result, DpmError> { - let mut output = Vec::new(); - reader - .take((limit + 1) as u64) - .read_to_end(&mut output) - .await - .map_err(|_| DpmError::Spawn)?; - if output.len() > limit { - return Err(DpmError::OutputLimit); - } - Ok(output) -} - -fn validated_args(request: &DpmRequest) -> Result, DpmError> { - if !request.source.is_file() { - return Err(DpmError::InvalidSource); - } - let mut args = vec![OsString::from(match request.operation { - DpmOperation::Diff => "diff", - DpmOperation::Verify => "verify", - DpmOperation::Bootstrap => "bootstrap", - })]; - push_pair(&mut args, "--source", request.source.as_os_str()); - if matches!(request.operation, DpmOperation::Diff | DpmOperation::Verify) { - let target = request.target.as_ref().ok_or(DpmError::MissingTarget)?; - push_pair(&mut args, "--target", OsStr::new(target.expose())); - } - push_pair(&mut args, "--shadow", OsStr::new(request.shadow.expose())); - push_pair(&mut args, "--format", OsStr::new("json")); - Ok(args) -} - -fn push_pair(args: &mut Vec, flag: &str, value: &OsStr) { - args.push(OsString::from(flag)); - args.push(value.to_owned()); -} - -pub fn dpm_binary_from_env() -> PathBuf { - std::env::var_os("SCINTILLA_DPM_BIN") - .map(PathBuf::from) - .unwrap_or_else(|| Path::new("dpm").to_owned()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn request_debug_redacts_database_urls() { - let request = DpmRequest { - operation: DpmOperation::Verify, - source: PathBuf::from("contracts/database/desired.sql"), - target: Some(SecretArg::new("postgres://user:password@db/target").unwrap()), - shadow: SecretArg::new("postgres://user:password@db/shadow").unwrap(), - }; - let debug = format!("{request:?}"); - assert!(!debug.contains("password")); - assert!(debug.contains("[REDACTED]")); - } - - #[test] - fn apply_is_not_representable() { - assert_eq!( - [ - DpmOperation::Diff, - DpmOperation::Verify, - DpmOperation::Bootstrap - ] - .len(), - 3 - ); - } -} diff --git a/certification/scintilla-context-abi/src/lib.rs b/certification/scintilla-context-abi/src/lib.rs deleted file mode 100644 index 4dbff7f..0000000 --- a/certification/scintilla-context-abi/src/lib.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Canonical Scintilla persistence and user-module boundaries. -//! -//! This crate separates contracts, bounded reads, API-owned writes, and -//! operator-owned migration planning. It never runs schema changes at service -//! startup and never exports a raw SeaORM connection. - -pub mod module_contract; - -#[cfg(feature = "read")] -pub mod capabilities; -#[cfg(feature = "contracts")] -pub mod contracts; -#[cfg(feature = "migrator")] -pub mod dpm; - -pub use module_contract::{ - assert_lambda_entrypoint, assert_module_export, descriptor_for, CancellationModel, Capability, - ConcurrencyModel, ExecutionProfile, InvocationContext, InvocationMode, IsolationRequirement, - LambdaEntrypoint, ModuleContext, ModuleContractError, ModuleDescriptor, ModuleExport, - ModuleKind, CONTEXT_ABI_V1, MODULE_CONTRACT_V1, MODULE_SEMANTICS_V2, -}; - -#[cfg(feature = "write")] -pub use capabilities::WriteContext; -#[cfg(feature = "read")] -pub use capabilities::{ - AccessScope, ActorContext, CapabilityError, DatabaseFlavor, ReadContext, TenantContext, -}; diff --git a/certification/scintilla-context-abi/src/module_contract.rs b/certification/scintilla-context-abi/src/module_contract.rs deleted file mode 100644 index 24cf890..0000000 --- a/certification/scintilla-context-abi/src/module_contract.rs +++ /dev/null @@ -1,431 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use thiserror::Error; - -pub const MODULE_CONTRACT_V1: &str = "scintilla-module-contract-v1"; -pub const MODULE_SEMANTICS_V2: &str = "scintilla.module-semantics/v2"; -pub const CONTEXT_ABI_V1: &str = "scintilla.context/v1"; - -/// Small platform-owned invocation metadata shared by every user context. -/// -/// Runtimes may construct a richer user context through middleware/DI, but -/// exported modules must always be able to recover this stable Scintilla base. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InvocationContext { - pub request_id: String, - pub deadline_unix_ms: Option, - pub attempt: u32, -} - -pub trait ModuleContext: Send + Sync { - fn invocation(&self) -> &InvocationContext; -} - -impl ModuleContext for InvocationContext { - fn invocation(&self) -> &InvocationContext { - self - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ModuleKind { - Lambda, - Http, - Rpc, - Worker, - Actor, - Container, -} - -impl ModuleKind { - pub const fn interface(self) -> &'static str { - match self { - Self::Lambda => "scintilla.lambda.v1", - Self::Http => "scintilla.http.v1", - Self::Rpc => "scintilla.rpc.v1", - Self::Worker => "scintilla.worker.v1", - Self::Actor => "scintilla.actor.v1", - Self::Container => "scintilla.container.v1", - } - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum InvocationMode { - RequestResponse, - Stream, - Event, - LongLived, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ConcurrencyModel { - Concurrent, - Serial, - KeyedSerial, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum CancellationModel { - Deadline, - Cooperative, - ProcessTermination, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum IsolationRequirement { - RuntimeDefault, - Process, - Container, - MicroVm, -} - -/// Provider-neutral authority requested by an exported module. Deployment -/// profiles decide whether and how each capability is granted on Scintilla, -/// AWS, GCP, or another compatible target. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum Capability { - Clock, - Random, - NetworkOutbound, - NetworkListen, - FilesystemRead, - FilesystemWrite, - ProcessSpawn, - EnvRead, - SecretsRead, - DatabaseRead, - DatabaseWrite, - KeyValueRead, - KeyValueWrite, - QueuePublish, - QueueConsume, - ObjectStorageRead, - ObjectStorageWrite, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub struct ExecutionProfile { - pub invocation: InvocationMode, - pub concurrency: ConcurrencyModel, - pub cancellation: CancellationModel, - pub isolation: IsolationRequirement, - /// Zero delegates timeout selection to the deployment target. - pub default_timeout_ms: u64, - /// `None` delegates the cap to the deployment target. - pub max_concurrency: Option, - pub idempotent: bool, - pub retry_safe: bool, -} - -impl Default for ExecutionProfile { - fn default() -> Self { - Self { - invocation: InvocationMode::RequestResponse, - concurrency: ConcurrencyModel::Concurrent, - cancellation: CancellationModel::Deadline, - isolation: IsolationRequirement::RuntimeDefault, - default_timeout_ms: 0, - max_concurrency: None, - idempotent: false, - retry_safe: false, - } - } -} - -/// Language-neutral projection emitted into build/deploy metadata. -/// -/// Language SDKs should expose native interfaces/traits whose implementations -/// project to this descriptor rather than treating JSON metadata as the source -/// of handler semantics. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ModuleDescriptor { - pub contract_version: String, - #[serde(default)] - pub semantics_version: Option, - #[serde(default)] - pub context_abi: Option, - pub kind: ModuleKind, - pub name: String, - pub entrypoint: String, - pub interface: String, - #[serde(default)] - pub execution: ExecutionProfile, - #[serde(default)] - pub capabilities: Vec, -} - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum ModuleContractError { - #[error("unsupported module contract version")] - Version, - #[error("unsupported module semantics version")] - SemanticsVersion, - #[error("unsupported module context ABI")] - ContextAbi, - #[error("module name must be a non-empty portable identifier")] - Name, - #[error("entrypoint must be non-empty and contain no whitespace")] - Entrypoint, - #[error("module interface does not match its semantic kind")] - Interface, - #[error("module execution profile is invalid")] - Execution, - #[error("module capability list contains duplicates")] - DuplicateCapability, -} - -impl ModuleDescriptor { - pub fn validate(&self) -> Result<(), ModuleContractError> { - if self.contract_version != MODULE_CONTRACT_V1 { - return Err(ModuleContractError::Version); - } - if self - .semantics_version - .as_deref() - .is_some_and(|version| version != MODULE_SEMANTICS_V2) - { - return Err(ModuleContractError::SemanticsVersion); - } - if self - .context_abi - .as_deref() - .is_some_and(|version| version != CONTEXT_ABI_V1) - { - return Err(ModuleContractError::ContextAbi); - } - if !portable_ident(&self.name) { - return Err(ModuleContractError::Name); - } - if self.entrypoint.is_empty() || self.entrypoint.chars().any(char::is_whitespace) { - return Err(ModuleContractError::Entrypoint); - } - if self.interface != self.kind.interface() { - return Err(ModuleContractError::Interface); - } - if self.execution.max_concurrency == Some(0) { - return Err(ModuleContractError::Execution); - } - - let mut unique = HashSet::with_capacity(self.capabilities.len()); - if self - .capabilities - .iter() - .any(|capability| !unique.insert(*capability)) - { - return Err(ModuleContractError::DuplicateCapability); - } - Ok(()) - } -} - -/// Base interface implemented by every Rust module exported to Scintilla. -/// Other language SDKs should project equivalent interfaces to the same -/// `ModuleDescriptor` shape. -pub trait ModuleExport { - const KIND: ModuleKind; - const NAME: &'static str; - const INTERFACE: &'static str; - const EXECUTION: ExecutionProfile = ExecutionProfile { - invocation: InvocationMode::RequestResponse, - concurrency: ConcurrencyModel::Concurrent, - cancellation: CancellationModel::Deadline, - isolation: IsolationRequirement::RuntimeDefault, - default_timeout_ms: 0, - max_concurrency: None, - idempotent: false, - retry_safe: false, - }; - const CAPABILITIES: &'static [Capability] = &[]; -} - -/// Compile-time contract for Rust lambda entrypoints. -/// -/// `async fn` is intentional here: callers implement the natural async handler -/// shape while provider adapters remain free to choose their executor/runtime. -#[allow(async_fn_in_trait)] -pub trait LambdaEntrypoint: ModuleExport + Send + Sync { - type Input: Send; - type Output: Send; - type Error: Send; - type Context: ModuleContext; - - async fn invoke( - &self, - context: &Self::Context, - input: Self::Input, - ) -> Result; -} - -/// Zero-cost assertions used by generated/scaffolded entrypoint modules. -pub const fn assert_module_export() {} -pub const fn assert_lambda_entrypoint() {} - -pub fn descriptor_for(entrypoint: impl Into) -> ModuleDescriptor { - ModuleDescriptor { - contract_version: MODULE_CONTRACT_V1.to_owned(), - semantics_version: Some(MODULE_SEMANTICS_V2.to_owned()), - context_abi: Some(CONTEXT_ABI_V1.to_owned()), - kind: T::KIND, - name: T::NAME.to_owned(), - entrypoint: entrypoint.into(), - interface: T::INTERFACE.to_owned(), - execution: T::EXECUTION, - capabilities: T::CAPABILITIES.to_vec(), - } -} - -fn portable_ident(value: &str) -> bool { - !value.is_empty() - && value - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')) -} - -#[cfg(test)] -mod tests { - use super::*; - - struct Echo; - - #[derive(Debug)] - struct AppContext { - platform: InvocationContext, - db_pool_name: String, - } - - impl ModuleContext for AppContext { - fn invocation(&self) -> &InvocationContext { - &self.platform - } - } - - impl ModuleExport for Echo { - const KIND: ModuleKind = ModuleKind::Lambda; - const NAME: &'static str = "echo"; - const INTERFACE: &'static str = "scintilla.lambda.v1"; - const EXECUTION: ExecutionProfile = ExecutionProfile { - invocation: InvocationMode::RequestResponse, - concurrency: ConcurrencyModel::Concurrent, - cancellation: CancellationModel::Deadline, - isolation: IsolationRequirement::Process, - default_timeout_ms: 30_000, - max_concurrency: Some(64), - idempotent: true, - retry_safe: true, - }; - const CAPABILITIES: &'static [Capability] = - &[Capability::Clock, Capability::NetworkOutbound]; - } - - struct BadEcho; - - impl ModuleExport for BadEcho { - const KIND: ModuleKind = ModuleKind::Lambda; - const NAME: &'static str = "bad_echo"; - const INTERFACE: &'static str = "scintilla.rpc.v1"; - } - - impl LambdaEntrypoint for Echo { - type Input = String; - type Output = String; - type Error = (); - type Context = AppContext; - - async fn invoke( - &self, - _context: &Self::Context, - input: Self::Input, - ) -> Result { - Ok(input) - } - } - - #[test] - fn rust_exports_project_execution_semantics() { - assert_module_export::(); - assert_lambda_entrypoint::(); - let descriptor = descriptor_for::("crate::lambda::Echo"); - assert_eq!(descriptor.validate(), Ok(())); - assert_eq!(descriptor.kind, ModuleKind::Lambda); - assert_eq!(descriptor.interface, ModuleKind::Lambda.interface()); - assert_eq!( - descriptor.semantics_version.as_deref(), - Some(MODULE_SEMANTICS_V2) - ); - assert_eq!(descriptor.context_abi.as_deref(), Some(CONTEXT_ABI_V1)); - assert_eq!( - descriptor.execution.isolation, - IsolationRequirement::Process - ); - assert_eq!(descriptor.capabilities.len(), 2); - } - - #[test] - fn user_context_can_extend_platform_context_without_framework_lock_in() { - let context = AppContext { - platform: InvocationContext { - request_id: "req-1".into(), - deadline_unix_ms: Some(42), - attempt: 2, - }, - db_pool_name: "primary".into(), - }; - assert_eq!(context.invocation().request_id, "req-1"); - assert_eq!(context.invocation().deadline_unix_ms, Some(42)); - assert_eq!(context.db_pool_name, "primary"); - } - - #[test] - fn rejects_unknown_context_abi_when_present() { - let mut descriptor = descriptor_for::("crate::lambda::Echo"); - descriptor.context_abi = Some("scintilla.context/v0".into()); - assert_eq!(descriptor.validate(), Err(ModuleContractError::ContextAbi)); - } - - #[test] - fn declared_interface_drift_survives_projection_and_is_rejected() { - let descriptor = descriptor_for::("crate::lambda::BadEcho"); - assert_eq!(descriptor.interface, "scintilla.rpc.v1"); - assert_eq!(descriptor.validate(), Err(ModuleContractError::Interface)); - } - - #[test] - fn rejects_kind_interface_drift() { - let mut descriptor = descriptor_for::("crate::lambda::Echo"); - descriptor.interface = "scintilla.rpc.v1".into(); - assert_eq!(descriptor.validate(), Err(ModuleContractError::Interface)); - } - - #[test] - fn rejects_duplicate_capabilities() { - let mut descriptor = descriptor_for::("crate::lambda::Echo"); - descriptor.capabilities.push(Capability::Clock); - assert_eq!( - descriptor.validate(), - Err(ModuleContractError::DuplicateCapability) - ); - } - - #[test] - fn legacy_descriptor_remains_valid_without_v2_semantics() { - let descriptor = ModuleDescriptor { - contract_version: MODULE_CONTRACT_V1.into(), - semantics_version: None, - context_abi: None, - kind: ModuleKind::Container, - name: "legacy".into(), - entrypoint: "legacy::main".into(), - interface: "scintilla.container.v1".into(), - execution: ExecutionProfile::default(), - capabilities: Vec::new(), - }; - assert_eq!(descriptor.validate(), Ok(())); - } -} diff --git a/certification/scintilla-context-abi/tests/contracts.rs b/certification/scintilla-context-abi/tests/contracts.rs deleted file mode 100644 index c4465d4..0000000 --- a/certification/scintilla-context-abi/tests/contracts.rs +++ /dev/null @@ -1,37 +0,0 @@ -use serde_json::Value; - -#[test] -fn contract_bundle_is_parseable_and_complete() { - let schema: Value = - serde_json::from_str(scintilla_lib_core::contracts::PERSISTENCE_SCHEMA_JSON).unwrap(); - let sources: Value = - serde_json::from_str(scintilla_lib_core::contracts::SOURCE_PINS_JSON).unwrap(); - let compatibility: Value = - serde_json::from_str(scintilla_lib_core::contracts::COMPATIBILITY_JSON).unwrap(); - let provenance: Value = - serde_json::from_str(scintilla_lib_core::contracts::GENERATED_PROVENANCE_JSON).unwrap(); - - assert_eq!( - schema["$schema"], - "https://json-schema.org/draft/2020-12/schema" - ); - assert!(sources["sources"] - .as_array() - .is_some_and(|items| !items.is_empty())); - assert_eq!(compatibility["startupMigrations"], "forbidden"); - assert_eq!(provenance["format"], 1); -} - -#[test] -fn desired_sql_matches_the_declared_table_inventory() { - let schema: Value = - serde_json::from_str(scintilla_lib_core::contracts::PERSISTENCE_SCHEMA_JSON).unwrap(); - let desired = scintilla_lib_core::contracts::DESIRED_SQL.to_ascii_lowercase(); - for table in schema["x-lib-core"]["tables"].as_array().unwrap() { - let table = table.as_str().unwrap(); - assert!( - desired.contains(&format!("create table if not exists {table}")), - "desired SQL is missing {table}" - ); - } -} diff --git a/certification/scintilla-context-abi/tests/module_contract_fixture.rs b/certification/scintilla-context-abi/tests/module_contract_fixture.rs deleted file mode 100644 index 3aaed14..0000000 --- a/certification/scintilla-context-abi/tests/module_contract_fixture.rs +++ /dev/null @@ -1,13 +0,0 @@ -use scintilla_lib_core::ModuleDescriptor; - -#[test] -fn canonical_v2_fixture_matches_rust_admission() { - let descriptor: ModuleDescriptor = serde_json::from_str(include_str!( - "../contracts/fixtures/module-descriptor.v2.json" - )) - .expect("canonical module descriptor fixture must deserialize"); - - descriptor - .validate() - .expect("canonical module descriptor fixture must pass Rust admission"); -} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..18c92e1 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,57 @@ +# Architecture + +## The two directions are complementary + +There are two different derivation directions in the fleet and they must not be collapsed into one source-of-truth story. + +### Contract-first + +`*-interfaces` owns independently authored TypeSpec and JSON Schema. TJSV proves semantic convergence. `ores-contracts` can then project persistence/codegen evidence such as SQL, Diesel, SeaORM and language types. + +### ORM-first + +`*-orm-core` contains executable/private ORM definitions. `ores-orm-core` independently normalizes Diesel and SeaORM into `ores.orm-core.ir/v1`, requires the common structural semantics to converge, then emits derivative candidates such as strict JSON Schema and language validators. + +The ORM-first lane is useful for the same reason `drizzle-zod` is useful: database model changes can mechanically produce matching data-shape validators and DTOs. The difference is that an ORESoftware public artifact has an additional admission boundary. + +## Public vs private output + +Private/server-only derivatives may be produced from converged ORM evidence alone. Public derivatives are candidates until they have been checked against the exact TJSV Contract IR and retained parity receipt for the owning `*-interfaces` contract. + +This prevents a private database column from becoming public by accident and prevents an ORM's weaker type system from erasing business constraints such as patterns, min/max lengths, enum membership, tagged unions, or semantic formats. + +A public-admission lane should compare the ORM-derived Draft 2020-12 schema to the mapped Contract IR assertion schema. Where the contract is stricter than ORM evidence, the public runtime validator must retain the contract constraint; the ORM lane may never weaken it. + +## Diesel and SeaORM are peer witnesses + +Neither ORM wins. The initial structural comparison covers the common subset both lanes can prove reliably: schema/table name, column/storage name, scalar/array/named logical type, nullability and primary-key membership. + +Defaults, generated expressions, checks, indexes, RLS, grants, extensions and vector dimensions are not guessed from ORM source. Use explicit `.ores-orm.toml` policy and/or database-catalog evidence, and retain the evidence source in receipts. + +## Shape algebra + +Presence and nullability are independent axes. For a create field, a non-null field without a default is required; a nullable field may be omitted and may explicitly be null; a defaulted field may be omitted without becoming nullable. + +For a row/read shape every selected property is required, even when its value is nullable. This matters in Rust because plain `Option` often conflates a missing field with a present JSON `null`. Rust DTO generation therefore needs presence-aware helper wrappers or mandatory schema validation before deserialization. + +`update` is replacement-style for writable fields. `patch` is partial. Generated fields are excluded. Immutable fields may be accepted on create when explicitly exposed but are excluded from subsequent writes. + +## Explicit exposure + +The generator does not infer exposure from field names. Public projections are allowlists in policy/contract mapping. An empty allowlist means nothing is public. + +## Naming + +Database/wire names are preserved exactly by default. Language identifiers may be escaped for reserved words, but serialization must keep the original name. The current fleet preference is snake_case; automatic camelCase conversion from older experiments is not inherited here. + +## Language adapters + +The normalized shape IR is the shared semantic input. Adapters must have golden positive/negative fixtures and prove equivalent acceptance/rejection behavior. + +- Rust: Serde DTOs plus strict presence/null handling; Schemars may emit a comparison witness but is not authority. +- TypeScript: Zod schemas/types or a pinned equivalent runtime validator, checked against the canonical Draft 2020-12 derivative schema. +- Dart: generated models plus explicit key-presence/type/format validation. +- Gleam: generated types and `gleam/dynamic` decoders with equivalent missing/null behavior. +- WIT: downstream ABI/interface compatibility evidence through `ores-wit`; WIT does not replace JSON validation constraints. + +Public language emission must bind to admitted contract evidence so DB-only semantics cannot silently redefine the wire contract. diff --git a/docs/cargo-workspace-interop.md b/docs/cargo-workspace-interop.md deleted file mode 100644 index db05933..0000000 --- a/docs/cargo-workspace-interop.md +++ /dev/null @@ -1,80 +0,0 @@ -# Cargo workspace and Zed package interoperability - -The root `rust-app` fixture certifies one Cargo package consuming one -Zed-installed Rust crate. `fixtures/cargo-workspace` extends that boundary to a -virtual Cargo workspace without changing either package manager's ownership. - -## Certified graph - -```text -cargo-workspace-consumer (.zpkg.toml) -└── zed-pkg-test/rust-lib ^1.0.1 Zed resolution - -Cargo virtual workspace (resolver = "2") -├── workspace-app -│ ├── workspace-support path/workspace dependency -│ ├── unicode-ident = 1.0.24 crates.io dependency -│ ├── zed-rust-lib -> rust-lib = 1.0.1 inherited build dependency -│ └── zed-rust-lib -> rust-lib = 1.0.1 inherited dev dependency -└── workspace-support - └── zed-rust-lib -> rust-lib = 1.0.1 target-specific dependency - └── itoa = 1.0.18 transitive crates.io dependency -``` - -The `zed-rust-lib` key is an ordinary Cargo dependency rename. The package -identity remains `rust-lib`; Cargo metadata must retain `rename = -"zed-rust-lib"` for normal, build, and dev dependency kinds. - -## Adapter boundary - -Zed installs the exact `rust-lib` artifact beneath: - -```text -.vendor/.zed/zed-pkg-test/rust-lib -``` - -The Rust adapter emits `.zed/cargo-paths.toml` with: - -- a Cargo `paths` override; and -- a `[patch.crates-io]` entry for the installed crate's real Cargo package name. - -The fixture's `.cargo/config.toml` is the checked-in, consumer-owned merge of -that generated fragment. The E2E suite parses both files and requires semantic -equality. This proves that Cargo can retain a normal version dependency and -resolve it to Zed-installed source without Zed rewriting `Cargo.toml`. - -The generated fragment remains advisory because Cargo has no environment-only -path injection mechanism. Zed owns deterministic generation; the consumer owns -merging the fragment into Cargo configuration. - -## Resolver and scope coverage - -Cargo resolver v2 must keep these graphs separate: - -- target-specific `workspace-support` dependency with feature `uppercase`; -- host build dependency with default features disabled; and -- test-only renamed dependency with feature `uppercase`. - -The build script asserts that target/dev features do not leak into the host -build graph. Runtime and integration tests assert that the target and dev graphs -do receive the requested feature. - -## Lock and portability guarantees - -The suite requires: - -- a tracked workspace `Cargo.lock` containing direct and transitive crates.io - checksums; -- a generated `.zpkg.lock` containing only the Zed package resolution; -- neither package manager changing the other's lockfile; -- locked and locked/offline execution on Linux, macOS, and Windows; -- Zed symlink mode failing without its store, as designed; -- frozen copy mode working with no Zed home, registry, or network; -- Cargo requirement drift failing under `--locked`; and -- repository cleanup restoring the exact reviewed source state. - -## Scope boundary - -This fixture does not make Zed a Cargo registry, publish to crates.io, or teach -Cargo to resolve Zed package versions. It tests the intentional composition -point: immutable Zed acquisition plus ordinary Cargo workspace semantics. diff --git a/docs/interop-matrix.md b/docs/interop-matrix.md deleted file mode 100644 index 651d3b6..0000000 --- a/docs/interop-matrix.md +++ /dev/null @@ -1,37 +0,0 @@ -# Cargo and Zed interoperability matrix - -This fixture treats Zed and Cargo as composable package-management layers rather -than competing resolvers. - -| Concern | Owner | Certified behavior | -| --- | --- | --- | -| package acquisition | Zed | exact `rust-lib` artifact is published to a disposable registry and materialized under `.vendor/.zed` | -| Zed dependency version | `.zpkg.toml` / `.zpkg.lock` | `zed-pkg-test/rust-lib ^1.0.1` resolves once and frozen copy mode reuses the exact lock | -| Cargo path package | `Cargo.toml` / `Cargo.lock` | Cargo resolves the Zed materialization as `rust-lib 1.0.1` with no registry source | -| transitive Cargo registry dependency | Cargo | `rust-lib` retains `itoa 1.0.18` from crates.io | -| direct Cargo registry dependency | Cargo | `rust-app` retains `unicode-ident 1.0.24` from crates.io | -| Cargo features | Cargo resolver v2 | target dependency receives `uppercase`; build dependency remains default-feature-only | -| binary reproducibility | Cargo | tracked `Cargo.lock` is unchanged by Zed install and supports `--locked --offline` | -| Zed reproducibility | Zed | `.zpkg.lock` is unchanged by Cargo run, test, metadata, and rejected Cargo-lock drift | -| development layout | Zed symlink mode | works while the Zed home/store is mounted and fails when that store is absent | -| portable layout | Zed copy mode | works in a fresh network-disabled Rust container with no Zed home or registry | -| host coverage | GitHub Actions | locked Cargo build, lint, test, run, metadata, and offline replay on Linux, macOS, and Windows | - -## Deliberate non-goals - -Zed does not rewrite Cargo manifests, publish crates to crates.io, or encode -crates.io dependencies in the Zed dependency table. Cargo does not resolve Zed -versions or modify `.zpkg.lock`. Integration occurs through a normal Cargo path -dependency at the deterministic Zed install location. - -## Failure canaries - -The suite must fail when: - -- a Zed package is absent from the expected install path; -- the symlink store is not mounted; -- target-only Cargo features leak into the build-dependency graph; -- `Cargo.lock` would need to change under `--locked`; -- either lockfile changes while the other package manager runs; -- an exact source commit does not match the configured immutable reference; or -- a supposedly offline copied project attempts network access. diff --git a/examples/emit_runtime_fixture.rs b/examples/emit_runtime_fixture.rs new file mode 100644 index 0000000..2f29d83 --- /dev/null +++ b/examples/emit_runtime_fixture.rs @@ -0,0 +1,68 @@ +use ores_orm_core::diesel; +use ores_orm_core::emit::bundle::{self, GenerationEvidence}; +use ores_orm_core::parity; +use ores_orm_core::policy::Policy; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{ShapeKind, derive_shape}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +fn main() -> Result<(), Box> { + let output = std::env::var_os("ORES_ORM_RUNTIME_FIXTURE_DIR") + .map(PathBuf::from) + .ok_or_else(|| std::io::Error::other("ORES_ORM_RUNTIME_FIXTURE_DIR is required"))?; + + let diesel_ir = diesel::parse_schema(DIESEL)?; + let seaorm_ir = seaorm::parse_source(SEAORM)?; + let ir = parity::converge(&diesel_ir, &seaorm_ir)?; + let policy = Policy::from_toml(POLICY)?; + policy.validate_ir(&ir)?; + let table = ir + .table("users") + .ok_or_else(|| std::io::Error::other("users table missing"))?; + let shape = derive_shape(table, &policy, ShapeKind::PublicCreate)?; + let evidence = GenerationEvidence::from_bytes( + &ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"runtime-fixture-public-create-v1", + None, + )?; + let generated = bundle::emit(&ir, &policy, &shape, &evidence)?; + + if output.exists() { + fs::remove_dir_all(&output)?; + } + fs::create_dir_all(&output)?; + for artifact in &generated.artifacts { + let destination = safe_destination(&output, &artifact.path)?; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::write(destination, artifact.content.as_bytes())?; + } + fs::write(output.join("manifest.json"), generated.manifest_json()?)?; + Ok(()) +} + +fn safe_destination(root: &Path, relative: &str) -> Result> { + let path = Path::new(relative); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(std::io::Error::other(format!( + "unsafe generated artifact path: {relative}" + )) + .into()); + } + Ok(root.join(path)) +} diff --git a/fixtures/cargo-workspace/.cargo/config.toml b/fixtures/cargo-workspace/.cargo/config.toml deleted file mode 100644 index 978b00f..0000000 --- a/fixtures/cargo-workspace/.cargo/config.toml +++ /dev/null @@ -1,11 +0,0 @@ -# Consumer-owned merge of the fragment emitted by `zed install`. -paths = [".vendor/.zed/zed-pkg-test/rust-lib"] - -[patch.crates-io] -rust-lib = { path = ".vendor/.zed/zed-pkg-test/rust-lib" } - -[net] -retry = 2 - -[http] -timeout = 30 diff --git a/fixtures/cargo-workspace/.zpkg.toml b/fixtures/cargo-workspace/.zpkg.toml deleted file mode 100644 index cd21ee4..0000000 --- a/fixtures/cargo-workspace/.zpkg.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -org = "zed-pkg-test" -name = "cargo-workspace-consumer" -version = "0.1.0" -description = "Cargo workspace fixture consuming a Zed package through inherited dependencies." -license = "MIT" -language = "rust" - -[package.repository] -vcs = "git" -url = "https://github.com/zed-pkg-test/rust-app" - -[install] -dir = ".vendor/.zed" - -[dependencies] -"zed-pkg-test/rust-lib" = "^1.0.1" diff --git a/fixtures/cargo-workspace/Cargo.lock b/fixtures/cargo-workspace/Cargo.lock deleted file mode 100644 index 3a001b5..0000000 --- a/fixtures/cargo-workspace/Cargo.lock +++ /dev/null @@ -1,38 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "rust-lib" -version = "1.0.1" -dependencies = [ - "itoa", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "workspace-app" -version = "0.1.0" -dependencies = [ - "rust-lib", - "unicode-ident", - "workspace-support", -] - -[[package]] -name = "workspace-support" -version = "0.1.0" -dependencies = [ - "rust-lib", -] diff --git a/fixtures/cargo-workspace/Cargo.toml b/fixtures/cargo-workspace/Cargo.toml deleted file mode 100644 index f1941f8..0000000 --- a/fixtures/cargo-workspace/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[workspace] -members = ["crates/app", "crates/support"] -resolver = "2" - -[workspace.package] -version = "0.1.0" -edition = "2021" -rust-version = "1.88" -license = "MIT" - -[workspace.dependencies] -zed-rust-lib = { package = "rust-lib", version = "=1.0.1", default-features = false } -unicode-ident = "=1.0.24" -workspace-support = { path = "crates/support" } diff --git a/fixtures/cargo-workspace/crates/app/Cargo.toml b/fixtures/cargo-workspace/crates/app/Cargo.toml deleted file mode 100644 index bf69058..0000000 --- a/fixtures/cargo-workspace/crates/app/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "workspace-app" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish = false -build = "build.rs" - -[dependencies] -workspace-support.workspace = true -unicode-ident.workspace = true - -[build-dependencies] -zed-rust-lib.workspace = true - -[dev-dependencies] -zed-rust-lib = { workspace = true, features = ["uppercase"] } diff --git a/fixtures/cargo-workspace/crates/app/build.rs b/fixtures/cargo-workspace/crates/app/build.rs deleted file mode 100644 index 7a75507..0000000 --- a/fixtures/cargo-workspace/crates/app/build.rs +++ /dev/null @@ -1,8 +0,0 @@ -fn main() { - let greeting = zed_rust_lib::greet("workspace-build"); - assert_eq!( - greeting, "hello workspace-build from zed-pkg-test/rust-lib", - "resolver v2 leaked target/dev features into the host build graph" - ); - println!("cargo:rustc-env=ZED_WORKSPACE_BUILD_GREETING={greeting}"); -} diff --git a/fixtures/cargo-workspace/crates/app/src/main.rs b/fixtures/cargo-workspace/crates/app/src/main.rs deleted file mode 100644 index 687b6cd..0000000 --- a/fixtures/cargo-workspace/crates/app/src/main.rs +++ /dev/null @@ -1,16 +0,0 @@ -fn main() { - assert_eq!( - workspace_support::greeting(), - "HELLO WORKSPACE-SUPPORT FROM ZED-PKG-TEST/RUST-LIB" - ); - assert_eq!( - env!("ZED_WORKSPACE_BUILD_GREETING"), - "hello workspace-build from zed-pkg-test/rust-lib" - ); - assert_eq!(workspace_support::formatted_count(10_000_001), "10000001"); - assert!(unicode_ident::is_xid_start('λ')); - - println!("{}", workspace_support::greeting()); - println!("{}", env!("ZED_WORKSPACE_BUILD_GREETING")); - println!("OK: Cargo workspace inheritance + Zed path package"); -} diff --git a/fixtures/cargo-workspace/crates/app/tests/renamed_dependency.rs b/fixtures/cargo-workspace/crates/app/tests/renamed_dependency.rs deleted file mode 100644 index 84a4c71..0000000 --- a/fixtures/cargo-workspace/crates/app/tests/renamed_dependency.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[test] -fn dev_dependency_alias_preserves_package_identity_and_features() { - assert_eq!( - zed_rust_lib::greet("workspace-test"), - "HELLO WORKSPACE-TEST FROM ZED-PKG-TEST/RUST-LIB" - ); -} diff --git a/fixtures/cargo-workspace/crates/support/Cargo.toml b/fixtures/cargo-workspace/crates/support/Cargo.toml deleted file mode 100644 index 3aeb602..0000000 --- a/fixtures/cargo-workspace/crates/support/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "workspace-support" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish = false - -[target.'cfg(any(unix, windows))'.dependencies] -zed-rust-lib = { workspace = true, features = ["uppercase"] } diff --git a/fixtures/cargo-workspace/crates/support/src/lib.rs b/fixtures/cargo-workspace/crates/support/src/lib.rs deleted file mode 100644 index 0deb698..0000000 --- a/fixtures/cargo-workspace/crates/support/src/lib.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub fn greeting() -> String { - zed_rust_lib::greet("workspace-support") -} - -pub fn formatted_count(value: u64) -> String { - zed_rust_lib::format_count(value) -} - -#[cfg(test)] -mod tests { - use super::{formatted_count, greeting}; - - #[test] - fn target_specific_alias_receives_workspace_feature() { - assert_eq!( - greeting(), - "HELLO WORKSPACE-SUPPORT FROM ZED-PKG-TEST/RUST-LIB" - ); - assert_eq!(formatted_count(10_000_001), "10000001"); - } -} diff --git a/fixtures/diesel/schema.rs b/fixtures/diesel/schema.rs new file mode 100644 index 0000000..bc5f22e --- /dev/null +++ b/fixtures/diesel/schema.rs @@ -0,0 +1,12 @@ +diesel::table! { + use diesel::sql_types::*; + + users (id) { + id -> Uuid, + email -> Text, + display_name -> Nullable, + password_hash -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} diff --git a/fixtures/ores-orm.toml b/fixtures/ores-orm.toml new file mode 100644 index 0000000..978e6b3 --- /dev/null +++ b/fixtures/ores-orm.toml @@ -0,0 +1,13 @@ +version = 1 +require_diesel = true +require_seaorm = true + +[table.users] +public_surface = true +generated = ["id", "created_at", "updated_at"] +defaulted = ["created_at", "updated_at"] +immutable = ["id", "created_at"] +secret = ["password_hash"] +public_read = ["id", "email", "display_name", "created_at", "updated_at"] +public_create = ["email", "display_name"] +public_update = ["email", "display_name"] diff --git a/fixtures/seaorm/users.rs b/fixtures/seaorm/users.rs new file mode 100644 index 0000000..15408b4 --- /dev/null +++ b/fixtures/seaorm/users.rs @@ -0,0 +1,14 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + #[sea_orm(unique)] + pub email: String, + pub display_name: Option, + pub password_hash: String, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} diff --git a/proc-isolation-proof/.cli-flags.toml b/proc-isolation-proof/.cli-flags.toml deleted file mode 100644 index b573264..0000000 --- a/proc-isolation-proof/.cli-flags.toml +++ /dev/null @@ -1,58 +0,0 @@ -[help] -url = "https://github.com/ORESoftware/ores-proc-isolation-cli#usage" -columns = ["options", "env", "type", "default", "description"] - -[env] -files = [] - -[parse] -command_env = "ORES_PI_COMMAND" -positionals_env = "ORES_PI_POSITIONALS" -unknown_options_env = "ORES_PI_UNKNOWN_OPTIONS" -errors_env = "ORES_PI_PARSE_ERRORS" -allow_unknown = false - -[flags.config] -env = "ORES_PI_CONFIG" -aliases = ["config", "config-path"] -short = "c" -type = "string" -default = ".ores-proc-isolation.yaml" -help = "Path to the process isolation policy file." - -[flags.dry-run] -env = "ORES_PI_DRY_RUN" -aliases = ["dry-run"] -type = "bool" -default = "false" -help = "Resolve and print the sandbox plan without launching the target process." - -[flags.json] -env = "ORES_PI_JSON" -aliases = ["json"] -type = "bool" -default = "false" -help = "Emit machine-readable JSON for check, doctor, explain, and dry-run output." - -[flags.beamscale-honeypot] -env = "ORES_PI_BEAMSCALE_HONEYPOT" -aliases = ["beamscale-honeypot"] -type = "bool" -default = "false" -help = "Enable the fixed BeamScale host escape tripwire mount/PATH contract on Linux." - -[commands.check] -help = "Validate .ores-proc-isolation.yaml and all process/group mappings without launching anything." -env = "ORES_PI_COMMAND_CHECK" - -[commands.doctor] -help = "Validate configuration and report host sandbox dependencies for the current platform." -env = "ORES_PI_COMMAND_DOCTOR" - -[commands.explain] -help = "Resolve one configured process to its group and effective sandbox policy. Usage: explain ." -env = "ORES_PI_COMMAND_EXPLAIN" - -[commands.run] -help = "Run one configured process in the fail-closed sandbox. Usage: run -- [extra args...]." -env = "ORES_PI_COMMAND_RUN" diff --git a/proc-isolation-proof/.ores-proc-isolation.yaml b/proc-isolation-proof/.ores-proc-isolation.yaml deleted file mode 100644 index d7268c5..0000000 --- a/proc-isolation-proof/.ores-proc-isolation.yaml +++ /dev/null @@ -1,31 +0,0 @@ -version: 1 - -defaults: - group: external-only - -groups: - external-only: - # Membership can be declared here, on the process, or both. If both are - # present they must agree; ambiguity fails closed. - members: - - example - filesystem: - # The host root/home are never mounted into the Linux sandbox. On macOS, - # Seatbelt starts deny-by-default and only grants system runtime reads, - # the executable, and these explicit paths. - read_only: [] - network: - mode: external - deny_loopback: true - deny_private_networks: true - limits: - max_open_files: 128 - cpu_seconds: 300 - environment: {} - -processes: - example: - group: external-only - command: - - /usr/bin/true - environment: {} diff --git a/proc-isolation-proof/Cargo.toml b/proc-isolation-proof/Cargo.toml deleted file mode 100644 index 75c1a02..0000000 --- a/proc-isolation-proof/Cargo.toml +++ /dev/null @@ -1,40 +0,0 @@ -[package] -name = "ores-proc-isolation-cli" -version = "0.1.0" -edition = "2024" -rust-version = "1.88" -license = "MIT" -description = "Fail-closed rootless process isolation for macOS and Linux" -repository = "https://github.com/ORESoftware/ores-proc-isolation-cli" -readme = "README.md" -keywords = ["sandbox", "isolation", "security", "linux", "macos"] -categories = ["command-line-utilities", "os"] - -[lib] -name = "ores_proc_isolation" -path = "src/lib.rs" - -[[bin]] -name = "ores-proc-isolation" -path = "src/main.rs" - -[dependencies] -flags2env = { package = "flags2env", git = "https://github.com/flags-2-env/flags-2-env.git", rev = "3414281ed8d7339d5bfabf951048bff31aa3349e" } -ores-reactive-maps = { path = "vendor/ores-reactive-maps", package = "ores-reactive-maps" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -serde_yaml = "0.9" -thiserror = "2" -tempfile = "3" - -[dev-dependencies] -pretty_assertions = "1" - -[lints.rust] -unsafe_code = "forbid" -missing_docs = "warn" - -[lints.clippy] -all = "deny" -pedantic = "allow" -nursery = "allow" diff --git a/proc-isolation-proof/PROOF.md b/proc-isolation-proof/PROOF.md deleted file mode 100644 index 2578de0..0000000 --- a/proc-isolation-proof/PROOF.md +++ /dev/null @@ -1,7 +0,0 @@ -# Exact-head proof carrier - -Source: `ORESoftware/ores-proc-isolation-cli#12` - -Source head: `221449a9815808128d30fdb2e58d3e3f0e5ab392` - -`proof-source.json` records the Git blob identity for every vendored source/test file. CI verifies those hashes before executing the Linux/macOS proof. diff --git a/proc-isolation-proof/examples/beamscale-hosted-gleam.yaml b/proc-isolation-proof/examples/beamscale-hosted-gleam.yaml deleted file mode 100644 index ffb6365..0000000 --- a/proc-isolation-proof/examples/beamscale-hosted-gleam.yaml +++ /dev/null @@ -1,36 +0,0 @@ -version: 1 - -defaults: - group: beamscale-hosted-gleam - -groups: - beamscale-hosted-gleam: - members: - - beamscale-worker - filesystem: - # Exact-path mounts only. Do not replace these with /opt or /run. - # The tripwire directory contains inert wrappers; the socket is the only - # host runtime endpoint exposed to this bare-process profile. - read_only: - - /opt/beamscale/honeypot-bin - - /run/bmscl-honeypot/tripwire.sock - network: - mode: external - deny_loopback: true - deny_private_networks: true - limits: - max_open_files: 128 - cpu_seconds: 300 - environment: - # Hosted Gleam has a no-child-process contract. If compromised code still - # attempts PATH lookup, it can resolve only inert honeypot wrappers. - PATH: /opt/beamscale/honeypot-bin - -processes: - beamscale-worker: - group: beamscale-hosted-gleam - # Production replaces this with the canonical admitted BEAM executable. - # It remains absolute so policy validation is deterministic. - command: - - /opt/beamscale/runtime/beam.smp - environment: {} diff --git a/proc-isolation-proof/examples/loopback_fixture.rs b/proc-isolation-proof/examples/loopback_fixture.rs deleted file mode 100644 index e135dee..0000000 --- a/proc-isolation-proof/examples/loopback_fixture.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Minimal HTTP fixture used by sandbox network-boundary tests. - -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; - -fn handle(mut stream: TcpStream) -> std::io::Result<()> { - let mut request = [0_u8; 1024]; - let _ = stream.read(&mut request)?; - stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?; - stream.flush() -} - -fn main() -> std::io::Result<()> { - let address = std::env::args() - .nth(1) - .unwrap_or_else(|| "127.0.0.1:18080".to_owned()); - let listener = TcpListener::bind(&address)?; - eprintln!("loopback fixture listening on {address}"); - - for stream in listener.incoming() { - match stream { - Ok(stream) => { - if let Err(error) = handle(stream) { - eprintln!("loopback fixture connection error: {error}"); - } - } - Err(error) => eprintln!("loopback fixture accept error: {error}"), - } - } - - Ok(()) -} diff --git a/proc-isolation-proof/proof-source.json b/proc-isolation-proof/proof-source.json deleted file mode 100644 index d49d0d9..0000000 --- a/proc-isolation-proof/proof-source.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "schema": "ores.exact-head-proof/v1", - "sourceRepository": "ORESoftware/ores-proc-isolation-cli", - "sourcePullRequest": 12, - "sourceCommit": "221449a9815808128d30fdb2e58d3e3f0e5ab392", - "files": { - ".cli-flags.toml": "b5732649df2be3ad9f27307e07c4c7cdbd5c10d7", - ".ores-proc-isolation.yaml": "d7268c5f291d9521533be2d36da46bdd7c03a128", - "Cargo.toml": "75c1a02145851476974e41a16195e81cdea9d89c", - "examples/beamscale-hosted-gleam.yaml": "ffb6365ed5d32d4fd775307f80de5c8b8f6c66fb", - "examples/loopback_fixture.rs": "e135deee9c1af0f694c97bc6b6d73bb73883ad1c", - "scripts/linux/ores-proc-isolate.sh": "0765fa1e474f65b2ad0c3a7f41d53f814f66a7ce", - "scripts/macos/ores-proc-isolate.sh": "1be2252f87dea8880cdc89a5ad9b5ccdcbfaab47", - "src/config.rs": "478eefddd3fd675033dc02fa27242564070ff17a", - "src/error.rs": "1686b8f363e3ae3215918259f7c57b0d32e8e98d", - "src/flags.rs": "6a629de02a58091951e7493912739bb097a10cfe", - "src/lib.rs": "1d1ac2e26821df663acea714304cb5a9cd88d2ad", - "src/main.rs": "87738a4f0d494dcca175d3248d6c3283cc797f81", - "src/platform/linux.rs": "9e0bff1e2c90353e8321fde4df9abe48aefb6b51", - "src/platform/macos.rs": "7bce031f2816a8771436257ef377ccbc6ae5ee87", - "src/platform/mod.rs": "767ec7e24722729cbbba3bb8f21d2528090668bd", - "src/runner.rs": "4b7148cf72a4d590be7572fe7efd97a452ac0e3d", - "tests/beamscale_honeypot_profile.rs": "949a0378db47f114b6521cd0b4e33d6277dbddce", - "tests/linux_helper_tripwire_contract.rs": "93ee15574982e68fdf9c2f2ae17b3d92b1db5f26", - "tests/linux_network_helper_liveness_contract.rs": "a59b7d5777e92e7d60a81762a19a7540aad2b4d6", - "tests/nixos_trusted_helper_contract.rs": "e7c15f68322ca27ff68cde5e72d1a4d17e19a305", - "tests/policy.rs": "76e84045e4fc7b6450d3e84fd335e0fc46616ff0", - "vendor/ores-reactive-maps/Cargo.toml": "bb037c526961038d62e40221c82c35a3f71c944f", - "vendor/ores-reactive-maps/LICENSE": "01521bb00184c8d5307d0287bc22cb1af46fe1f2", - "vendor/ores-reactive-maps/UPSTREAM.md": "d6ea50e40d20811bdf80020543bf80d5bd027185", - "vendor/ores-reactive-maps/src/lib.rs": "ee8910a4f8d5db0f226b1b130672f234975ab586" - } -} diff --git a/proc-isolation-proof/scripts/linux/ores-proc-isolate.sh b/proc-isolation-proof/scripts/linux/ores-proc-isolate.sh deleted file mode 100644 index 0765fa1..0000000 --- a/proc-isolation-proof/scripts/linux/ores-proc-isolate.sh +++ /dev/null @@ -1,447 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2317 -# Trap handlers are invoked indirectly by bash and therefore look unreachable to ShellCheck. -set -euo pipefail -IFS=$'\n\t' -umask 077 - -fatal() { - printf 'ores-proc-isolation[linux]: %s\n' "$*" >&2 - exit 125 -} - -need() { - command -v "$1" >/dev/null 2>&1 || fatal "required helper not found in trusted PATH: $1" -} - -EXE='' -NETWORK='external' -DENY_LOOPBACK='1' -DENY_PRIVATE='1' -MAX_OPEN_FILES='128' -CPU_SECONDS='300' -BEAMSCALE_HONEYPOT='0' -BEAMSCALE_TRIPWIRE_DIR='/opt/beamscale/honeypot-bin' -BEAMSCALE_TRIPWIRE_SOCKET='/run/bmscl-honeypot/tripwire.sock' -RO_PATHS=() -ENV_KEYS=() -ENV_VALUES=() -TARGET_ARGS=() - -while (($#)); do - case "$1" in - --exe) - (($# >= 2)) || fatal '--exe requires a value' - EXE=$2 - shift 2 - ;; - --network) - (($# >= 2)) || fatal '--network requires a value' - NETWORK=$2 - shift 2 - ;; - --deny-loopback) - DENY_LOOPBACK='1' - shift - ;; - --allow-loopback) - DENY_LOOPBACK='0' - shift - ;; - --deny-private) - DENY_PRIVATE='1' - shift - ;; - --max-open-files) - (($# >= 2)) || fatal '--max-open-files requires a value' - MAX_OPEN_FILES=$2 - shift 2 - ;; - --cpu-seconds) - (($# >= 2)) || fatal '--cpu-seconds requires a value' - CPU_SECONDS=$2 - shift 2 - ;; - --beamscale-honeypot) - BEAMSCALE_HONEYPOT='1' - shift - ;; - --ro) - (($# >= 2)) || fatal '--ro requires a value' - RO_PATHS+=("$2") - shift 2 - ;; - --env) - (($# >= 3)) || fatal '--env requires KEY VALUE' - ENV_KEYS+=("$2") - ENV_VALUES+=("$3") - shift 3 - ;; - --) - shift - TARGET_ARGS=("$@") - break - ;; - *) - fatal "unknown helper option: $1" - ;; - esac -done - -[[ -n "$EXE" ]] || fatal 'missing --exe' -[[ "$EXE" == /* ]] || fatal 'executable must be absolute' -[[ -f "$EXE" ]] || fatal "executable does not exist: $EXE" -[[ -x "$EXE" ]] || fatal "executable is not executable: $EXE" -[[ "$NETWORK" == 'external' || "$NETWORK" == 'none' ]] || fatal "unsupported network mode: $NETWORK" -[[ "$MAX_OPEN_FILES" =~ ^[0-9]+$ ]] || fatal 'max-open-files must be numeric' -[[ "$CPU_SECONDS" =~ ^[0-9]+$ ]] || fatal 'cpu-seconds must be numeric' -if [[ "$NETWORK" == 'external' ]]; then - [[ "$DENY_LOOPBACK" == '1' ]] || fatal 'external networking requires loopback denial' - [[ "$DENY_PRIVATE" == '1' ]] || fatal 'external networking requires private/local destination denial' -fi -if [[ "$BEAMSCALE_HONEYPOT" == '1' ]]; then - [[ -d "$BEAMSCALE_TRIPWIRE_DIR" ]] || fatal "BeamScale tripwire directory is unavailable: $BEAMSCALE_TRIPWIRE_DIR" - [[ ! -L "$BEAMSCALE_TRIPWIRE_DIR" ]] || fatal 'BeamScale tripwire directory may not be a symlink' - [[ -S "$BEAMSCALE_TRIPWIRE_SOCKET" ]] || fatal "BeamScale tripwire socket is unavailable: $BEAMSCALE_TRIPWIRE_SOCKET" -fi - -need bwrap -BWRAP=$(command -v bwrap) -ulimit -n "$MAX_OPEN_FILES" || fatal 'unable to lower RLIMIT_NOFILE' -ulimit -t "$CPU_SECONDS" || fatal 'unable to lower RLIMIT_CPU' - -RESOLV_FILE='' -STATUS_FIFO='' -GATE_FIFO='' -READY_FIFO='' -INFO_FIFO='' -USERNS_GATE_FIFO='' -IPC_DIR='' -SLIRP_PID='' -BWRAP_PID='' - -cleanup() { - local rc=$? - if [[ -n "$SLIRP_PID" ]]; then - kill "$SLIRP_PID" >/dev/null 2>&1 || true - wait "$SLIRP_PID" >/dev/null 2>&1 || true - fi - if [[ -n "$BWRAP_PID" ]]; then - kill "$BWRAP_PID" >/dev/null 2>&1 || true - wait "$BWRAP_PID" >/dev/null 2>&1 || true - fi - [[ -n "$RESOLV_FILE" ]] && rm -f -- "$RESOLV_FILE" - [[ -n "$IPC_DIR" ]] && rm -rf -- "$IPC_DIR" - exit "$rc" -} -trap cleanup EXIT HUP INT TERM - -RESOLV_FILE=$(mktemp "${TMPDIR:-/tmp}/ores-pi-resolv.XXXXXX") -printf 'nameserver 10.0.2.3\noptions attempts:1 timeout:2\n' >"$RESOLV_FILE" -chmod 0600 "$RESOLV_FILE" - -TARGET_PATH='/nonexistent' -if [[ "$BEAMSCALE_HONEYPOT" == '1' ]]; then - TARGET_PATH=$BEAMSCALE_TRIPWIRE_DIR -fi - -# Bubblewrap creates exactly one unprivileged user namespace. For external -# networking the host supervisor pauses that namespace before initialization, -# installs a one-ID uid/gid map, disables any further user namespaces inside -# it, and then lets Bubblewrap finish the remaining namespace setup. This keeps -# the network namespace owned by the same user namespace that the trusted host -# supervisor can enter on Ubuntu 24.04, without relying on newer nsenter -# --user-parent support. The tenant still receives no capabilities. -BWRAP_ARGS=( - --die-with-parent - --new-session - --unshare-user - --uid 0 - --gid 0 - --unshare-ipc - --unshare-pid - --unshare-net - --unshare-uts - --unshare-cgroup - --cap-drop ALL - --hostname ores-sandbox - --clearenv - --setenv PATH "$TARGET_PATH" - --setenv HOME /nonexistent - --setenv TMPDIR /tmp - --setenv LANG C.UTF-8 - --proc /proc - --dev /dev - --tmpfs /tmp - --ro-bind "$EXE" "$EXE" - --ro-bind "$RESOLV_FILE" /etc/resolv.conf -) - -for runtime_path in /lib /lib64 /usr/lib /usr/lib64 /etc/ssl/certs /usr/share/ca-certificates; do - if [[ -e "$runtime_path" ]]; then - BWRAP_ARGS+=(--ro-bind "$runtime_path" "$runtime_path") - fi -done -if [[ -f /etc/nsswitch.conf ]]; then - BWRAP_ARGS+=(--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf) -fi - -if [[ "$BEAMSCALE_HONEYPOT" == '1' ]]; then - BWRAP_ARGS+=( - --dir /opt - --dir /opt/beamscale - --ro-bind "$BEAMSCALE_TRIPWIRE_DIR" "$BEAMSCALE_TRIPWIRE_DIR" - --dir /run - --dir /run/bmscl-honeypot - --ro-bind "$BEAMSCALE_TRIPWIRE_SOCKET" "$BEAMSCALE_TRIPWIRE_SOCKET" - ) -fi - -for ro_path in "${RO_PATHS[@]}"; do - [[ "$ro_path" == /* ]] || fatal "read-only path must be absolute: $ro_path" - [[ "$ro_path" != / ]] || fatal 'refusing to expose host root' - if [[ "$BEAMSCALE_HONEYPOT" == '1' ]]; then - case "$ro_path" in - /bin|/sbin|/usr/bin|/usr/sbin|/run|/opt) - fatal "BeamScale honeypot mode refuses broad utility/runtime mount: $ro_path" - ;; - esac - fi - [[ -e "$ro_path" ]] || fatal "read-only path does not exist: $ro_path" - BWRAP_ARGS+=(--ro-bind "$ro_path" "$ro_path") -done - -for ((i = 0; i < ${#ENV_KEYS[@]}; i++)); do - BWRAP_ARGS+=(--setenv "${ENV_KEYS[$i]}" "${ENV_VALUES[$i]}") -done - -# The strict BeamScale profile owns PATH. Re-assert it after generic reviewed -# environment injection so a policy PATH value cannot turn a canary-only PATH -# back into a host utility search path. -if [[ "$BEAMSCALE_HONEYPOT" == '1' ]]; then - BWRAP_ARGS+=(--setenv PATH "$BEAMSCALE_TRIPWIRE_DIR") -fi - -if [[ "$NETWORK" == 'none' ]]; then - # Bubblewrap's built-in disable-userns path deliberately creates a nested - # user namespace, which is fine when no host-side network supervisor must - # enter the owning namespace. - BWRAP_ARGS+=(--disable-userns) - trap - EXIT HUP INT TERM - exec "$BWRAP" "${BWRAP_ARGS[@]}" -- "$EXE" "${TARGET_ARGS[@]}" -fi - -need slirp4netns -need nsenter -need ip -need iptables -need ip6tables -need id -SLIRP=$(command -v slirp4netns) -NSENTER=$(command -v nsenter) -IP=$(command -v ip) -IPTABLES=$(command -v iptables) -IP6TABLES=$(command -v ip6tables) -HOST_UID=$(id -u) -HOST_GID=$(id -g) - -HOST_IPV4=() -while IFS=' ' read -r _ _ family cidr _; do - [[ "$family" == 'inet' ]] || continue - address=${cidr%%/*} - [[ -n "$address" ]] && HOST_IPV4+=("$address") -done < <("$IP" -o -4 addr show scope global) - -HOST_IPV6=() -while IFS=' ' read -r _ _ family cidr _; do - [[ "$family" == 'inet6' ]] || continue - address=${cidr%%/*} - [[ -n "$address" ]] && HOST_IPV6+=("$address") -done < <("$IP" -o -6 addr show scope global) - -IPC_DIR=$(mktemp -d "${TMPDIR:-/tmp}/ores-pi-ipc.XXXXXX") -chmod 0700 "$IPC_DIR" -STATUS_FIFO="$IPC_DIR/status" -GATE_FIFO="$IPC_DIR/gate" -READY_FIFO="$IPC_DIR/ready" -INFO_FIFO="$IPC_DIR/info" -USERNS_GATE_FIFO="$IPC_DIR/userns-gate" -mkfifo -m 0600 "$STATUS_FIFO" "$GATE_FIFO" "$READY_FIFO" "$INFO_FIFO" "$USERNS_GATE_FIFO" -exec 8<>"$STATUS_FIFO" -exec 9<>"$GATE_FIFO" -exec 10<>"$INFO_FIFO" -exec 11<>"$USERNS_GATE_FIFO" - -# Hold the first user namespace before Bubblewrap initializes it. The host -# process owns that child namespace, so it can install the exact one-ID map, -# enter it with ordinary Ubuntu 24.04 nsenter, and set the namespaced userns -# limit to zero. Bubblewrap then verifies the limit via --assert-userns-disabled. -"$BWRAP" \ - --info-fd 5 \ - --userns-block-fd 6 \ - --assert-userns-disabled \ - --json-status-fd 3 \ - --block-fd 4 \ - "${BWRAP_ARGS[@]}" \ - -- "$EXE" "${TARGET_ARGS[@]}" \ - 3>&8 4<&9 5>&10 6<&11 & -BWRAP_PID=$! - -setup_pid='' -info_line='' -while IFS= read -r info_line <&10; do - candidate=$(printf '%s\n' "$info_line" | sed -n 's/.*"child-pid"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p') - if [[ -n "$candidate" ]]; then - setup_pid=$candidate - break - fi -done -if [[ -z "$setup_pid" ]]; then - wait "$BWRAP_PID" || true - BWRAP_PID='' - fatal 'bubblewrap exited before reporting the user namespace setup PID' -fi -[[ -e "/proc/$setup_pid/ns/user" ]] || fatal 'bubblewrap user namespace disappeared before mapping' - -if [[ -e "/proc/$setup_pid/setgroups" ]]; then - printf 'deny\n' >"/proc/$setup_pid/setgroups" \ - || fatal 'unable to disable setgroups in sandbox user namespace' -fi -printf '0 %s 1\n' "$HOST_UID" >"/proc/$setup_pid/uid_map" \ - || fatal 'unable to install sandbox uid map' -printf '0 %s 1\n' "$HOST_GID" >"/proc/$setup_pid/gid_map" \ - || fatal 'unable to install sandbox gid map' - -"$NSENTER" -t "$setup_pid" -U --preserve-credentials -- "$BASH" -euc \ - 'printf "0\n" > /proc/sys/user/max_user_namespaces' \ - || fatal 'unable to disable nested user namespaces in sandbox user namespace' -printf '1' >&11 -exec 10>&- -exec 11>&- - -status_line='' -if ! IFS= read -r status_line <&8; then - wait "$BWRAP_PID" || true - BWRAP_PID='' - fatal 'bubblewrap exited before reporting the sandbox child PID' -fi -child_pid=$(printf '%s\n' "$status_line" | sed -n 's/.*"child-pid"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p') -[[ -n "$child_pid" ]] || fatal "unable to parse bubblewrap child PID from: $status_line" -NETNS_PATH="/proc/$child_pid/ns/net" -[[ -e "$NETNS_PATH" ]] || fatal 'sandbox network namespace disappeared before network setup' - -# The network namespace is owned by the same first-level user namespace as the -# tenant. Enter that descendant user namespace first, gaining capabilities only -# there (never in the host initial userns), then let slirp configure the netns. -"$NSENTER" -t "$child_pid" -U --preserve-credentials -- \ - "$SLIRP" \ - --configure \ - --mtu=65520 \ - --disable-host-loopback \ - --enable-seccomp \ - --netns-type=path \ - --ready-fd=6 \ - "$NETNS_PATH" tap0 \ - 6>"$READY_FIFO" & -SLIRP_PID=$! - -ready='' -if ! IFS= read -r -n 1 ready <"$READY_FIFO"; then - fatal 'slirp4netns exited before network setup completed' -fi -[[ "$ready" == '1' ]] || fatal 'slirp4netns did not acknowledge network readiness' - -# Enter the sandbox user namespace before the network namespace. Namespace-root -# is scoped to this descendant userns and is not host root. The tenant executes -# with the full capability set dropped and cannot create another user namespace. -ns_net() { - "$NSENTER" -t "$child_pid" -U --preserve-credentials -n -- "$@" -} - -ns_net "$IP" link set lo down \ - || fatal 'unable to disable namespace loopback' - -ns_iptables() { - ns_net "$IPTABLES" "$@" -} - -ns_ip6tables() { - ns_net "$IP6TABLES" "$@" -} - -ns_iptables -I OUTPUT 1 -d 10.0.2.3/32 -p udp --dport 53 -j ACCEPT -ns_iptables -I OUTPUT 2 -d 10.0.2.3/32 -p tcp --dport 53 -j ACCEPT -ns_iptables -A OUTPUT -d 0.0.0.0/8 -j REJECT -ns_iptables -A OUTPUT -d 127.0.0.0/8 -j REJECT -ns_iptables -A OUTPUT -d 10.0.2.2/32 -j REJECT -ns_iptables -A OUTPUT -d 169.254.0.0/16 -j REJECT -ns_iptables -A OUTPUT -d 10.0.0.0/8 -j REJECT -ns_iptables -A OUTPUT -d 172.16.0.0/12 -j REJECT -ns_iptables -A OUTPUT -d 192.168.0.0/16 -j REJECT -ns_iptables -A OUTPUT -d 100.64.0.0/10 -j REJECT -ns_iptables -A OUTPUT -d 224.0.0.0/4 -j REJECT -ns_iptables -A OUTPUT -d 240.0.0.0/4 -j REJECT -for host_ip in "${HOST_IPV4[@]}"; do - ns_iptables -A OUTPUT -d "$host_ip/32" -j REJECT -done - -ns_ip6tables -A OUTPUT -d ::1/128 -j REJECT -ns_ip6tables -A OUTPUT -d fe80::/10 -j REJECT -ns_ip6tables -A OUTPUT -d fc00::/7 -j REJECT -ns_ip6tables -A OUTPUT -d ff00::/8 -j REJECT -for host_ip in "${HOST_IPV6[@]}"; do - ns_ip6tables -A OUTPUT -d "$host_ip/128" -j REJECT -done - -printf '1' >&9 - -# The target and the user-mode network helper are one runtime unit. If Slirp -# dies while Bubblewrap still owns a live tenant, continuing would silently -# weaken a required dependency. Wait for whichever process exits first. If the -# helper exits first and the tenant is still live, terminate the tenant and -# return a supervisor failure. Normal tenant exit remains authoritative. -set +e -finished_pid='' -wait -n -p finished_pid "$BWRAP_PID" "$SLIRP_PID" -first_rc=$? -set -e - -if [[ "$finished_pid" == "$SLIRP_PID" ]]; then - SLIRP_PID='' - bwrap_state='' - if [[ -r "/proc/$BWRAP_PID/stat" ]]; then - bwrap_state=$(awk '{print $3}' "/proc/$BWRAP_PID/stat" 2>/dev/null || true) - fi - if [[ -n "$bwrap_state" && "$bwrap_state" != 'Z' ]]; then - printf 'ores-proc-isolation[linux]: network helper exited while tenant was still running\n' >&2 - kill "$BWRAP_PID" >/dev/null 2>&1 || true - set +e - wait "$BWRAP_PID" >/dev/null 2>&1 - set -e - BWRAP_PID='' - rc=125 - else - set +e - wait "$BWRAP_PID" - rc=$? - set -e - BWRAP_PID='' - fi -else - rc=$first_rc - BWRAP_PID='' -fi - -if [[ -n "$SLIRP_PID" ]]; then - kill "$SLIRP_PID" >/dev/null 2>&1 || true - wait "$SLIRP_PID" >/dev/null 2>&1 || true - SLIRP_PID='' -fi -trap - EXIT HUP INT TERM -exec 8>&- -exec 9>&- -rm -f -- "$RESOLV_FILE" -RESOLV_FILE='' -rm -rf -- "$IPC_DIR" -IPC_DIR='' -exit "$rc" diff --git a/proc-isolation-proof/scripts/macos/ores-proc-isolate.sh b/proc-isolation-proof/scripts/macos/ores-proc-isolate.sh deleted file mode 100644 index 1be2252..0000000 --- a/proc-isolation-proof/scripts/macos/ores-proc-isolate.sh +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2317 -# Trap handlers are invoked indirectly by bash and therefore look unreachable to ShellCheck. -set -euo pipefail -IFS=$'\n\t' -umask 077 - -EXE='' -NETWORK='external' -DENY_LOOPBACK='1' -DENY_PRIVATE='0' -MAX_OPEN_FILES='128' -CPU_SECONDS='300' -RO_PATHS=() -ENV_KEYS=() -ENV_VALUES=() -TARGET_ARGS=() - -fatal() { - printf 'ores-proc-isolation[macos]: %s\n' "$*" >&2 - exit 125 -} - -while (($#)); do - case "$1" in - --exe) - (($# >= 2)) || fatal '--exe requires a value' - EXE=$2 - shift 2 - ;; - --network) - (($# >= 2)) || fatal '--network requires a value' - NETWORK=$2 - shift 2 - ;; - --deny-loopback) - DENY_LOOPBACK='1' - shift - ;; - --allow-loopback) - DENY_LOOPBACK='0' - shift - ;; - --deny-private) - DENY_PRIVATE='1' - shift - ;; - --max-open-files) - (($# >= 2)) || fatal '--max-open-files requires a value' - MAX_OPEN_FILES=$2 - shift 2 - ;; - --cpu-seconds) - (($# >= 2)) || fatal '--cpu-seconds requires a value' - CPU_SECONDS=$2 - shift 2 - ;; - --ro) - (($# >= 2)) || fatal '--ro requires a value' - RO_PATHS+=("$2") - shift 2 - ;; - --env) - (($# >= 3)) || fatal '--env requires KEY VALUE' - ENV_KEYS+=("$2") - ENV_VALUES+=("$3") - shift 3 - ;; - --) - shift - TARGET_ARGS=("$@") - break - ;; - *) - fatal "unknown helper option: $1" - ;; - esac -done - -[[ -n "$EXE" ]] || fatal 'missing --exe' -[[ "$EXE" == /* ]] || fatal 'executable must be absolute' -[[ -f "$EXE" ]] || fatal "executable does not exist: $EXE" -[[ -x "$EXE" ]] || fatal "executable is not executable: $EXE" -[[ "$NETWORK" == 'external' || "$NETWORK" == 'none' ]] || fatal "unsupported network mode: $NETWORK" -[[ "$DENY_LOOPBACK" == '1' ]] || fatal 'macOS strict external mode refuses host loopback access' -[[ "$DENY_PRIVATE" == '0' ]] || fatal 'macOS Seatbelt cannot express RFC1918/CIDR egress denial; refusing to weaken requested policy' -[[ -x /usr/bin/sandbox-exec ]] || fatal '/usr/bin/sandbox-exec is unavailable' -[[ -x /usr/bin/env ]] || fatal '/usr/bin/env is unavailable' - -ulimit -n "$MAX_OPEN_FILES" || fatal 'unable to lower RLIMIT_NOFILE' -ulimit -t "$CPU_SECONDS" || fatal 'unable to lower RLIMIT_CPU' - -PROFILE=$(/usr/bin/mktemp "${TMPDIR:-/tmp}/ores-pi-seatbelt.XXXXXX") -cleanup() { - local rc=$? - /bin/rm -f -- "$PROFILE" - exit "$rc" -} -trap cleanup EXIT HUP INT TERM - -cat >"$PROFILE" <<'SBPL' -(version 1) - -; Closed by default. Children inherit this sandbox and cannot opt themselves out. -(deny default) - -(allow process-exec) -(allow process-fork) -(allow signal (target same-sandbox)) -(allow process-info* (target same-sandbox)) - -; Minimal device/stdio surface. -(allow file-read* file-test-existence - (literal "/dev/random") - (literal "/dev/urandom") - (literal "/dev/null") - (literal "/dev/zero")) -(allow file-write-data (literal "/dev/null") (literal "/dev/zero")) -(allow file-read-data file-test-existence file-write-data (subpath "/dev/fd")) -(allow file-read* (regex "^/dev/fd/(0|1|2)$")) -(allow file-write* (regex "^/dev/fd/(1|2)$")) -(allow file-read* file-write* (literal "/dev/tty")) -(allow pseudo-tty) -(allow file-read* file-write* file-ioctl (literal "/dev/ptmx")) -(allow file-read* file-write* file-ioctl (regex "^/dev/ttys[0-9]+$")) - -; The executable itself and only its ancestors for path traversal. -(allow file-read* file-map-executable file-test-existence - (literal (param "EXECUTABLE"))) -(allow file-read-metadata file-test-existence - (path-ancestors (param "EXECUTABLE"))) - -; Loader/framework runtime. These are system files, not user/project data. -(allow file-map-executable - (subpath "/Library/Apple/System/Library/Frameworks") - (subpath "/Library/Apple/System/Library/PrivateFrameworks") - (subpath "/Library/Apple/usr/lib") - (subpath "/System/Library/Extensions") - (subpath "/System/Library/Frameworks") - (subpath "/System/Library/PrivateFrameworks") - (subpath "/System/Library/SubFrameworks") - (subpath "/usr/lib")) -(allow file-read* file-test-existence - (subpath "/Library/Apple/System/Library/Frameworks") - (subpath "/Library/Apple/System/Library/PrivateFrameworks") - (subpath "/Library/Apple/usr/lib") - (subpath "/System/Library/Frameworks") - (subpath "/System/Library/PrivateFrameworks") - (subpath "/System/Library/SubFrameworks") - (subpath "/usr/lib") - (subpath "/usr/share")) -(allow file-read* file-test-existence (literal "/")) - -; CPU/runtime discovery required by common libc/runtimes. -(allow sysctl-read - (sysctl-name "hw.activecpu") - (sysctl-name "hw.byteorder") - (sysctl-name "hw.cachelinesize_compat") - (sysctl-name "hw.cpufamily") - (sysctl-name "hw.cputype") - (sysctl-name "hw.logicalcpu") - (sysctl-name "hw.logicalcpu_max") - (sysctl-name "hw.machine") - (sysctl-name "hw.memsize") - (sysctl-name "hw.model") - (sysctl-name "hw.ncpu") - (sysctl-name "hw.pagesize") - (sysctl-name "hw.physicalcpu") - (sysctl-name "hw.physicalcpu_max") - (sysctl-name-prefix "hw.optional.arm.") - (sysctl-name-prefix "hw.optional.armv8_") - (sysctl-name "kern.argmax") - (sysctl-name "kern.hostname") - (sysctl-name "kern.maxfilesperproc") - (sysctl-name "kern.maxproc") - (sysctl-name "kern.osproductversion") - (sysctl-name "kern.osrelease") - (sysctl-name "kern.ostype") - (sysctl-name "kern.osversion") - (sysctl-name "kern.version") - (sysctl-name "vm.loadavg")) - -(allow iokit-open (iokit-registry-entry-class "RootDomainUserClient")) -(allow system-mac-syscall (mac-policy-name "vnguard")) - -; Identity, DNS, routing, and TLS trust services. No arbitrary local unix socket -; access is granted. -(allow mach-lookup - (global-name "com.apple.system.opendirectoryd.libinfo") - (global-name "com.apple.system.opendirectoryd.membership") - (global-name "com.apple.bsd.dirhelper") - (global-name "com.apple.SecurityServer") - (global-name "com.apple.networkd") - (global-name "com.apple.ocspd") - (global-name "com.apple.trustd") - (global-name "com.apple.trustd.agent") - (global-name "com.apple.SystemConfiguration.DNSConfiguration") - (global-name "com.apple.SystemConfiguration.configd") - (global-name "com.apple.PowerManagement.control")) -(allow system-socket - (require-all - (socket-domain AF_SYSTEM) - (socket-protocol 2))) -SBPL - -SANDBOX_ARGS=(-D "EXECUTABLE=$EXE") - -for ((i = 0; i < ${#RO_PATHS[@]}; i++)); do - ro_path=${RO_PATHS[$i]} - [[ "$ro_path" == /* ]] || fatal "read-only path must be absolute: $ro_path" - [[ "$ro_path" != / ]] || fatal 'refusing to expose host root' - [[ -e "$ro_path" ]] || fatal "read-only path does not exist: $ro_path" - param="RO_PATH_$i" - SANDBOX_ARGS+=(-D "$param=$ro_path") - cat >>"$PROFILE" <>"$PROFILE" <<'SBPL' -; IP egress is allowed, but host/self loopback is explicitly denied after the -; broad IP rule. Inbound/bind and unix-domain sockets remain denied by default. -(allow network-outbound (remote ip "*:*")) -(deny network-outbound (remote ip "localhost:*")) -SBPL -fi - -ENV_ARGS=( - PATH=/nonexistent - HOME=/nonexistent - TMPDIR=/nonexistent - LANG=C -) -for ((i = 0; i < ${#ENV_KEYS[@]}; i++)); do - ENV_ARGS+=("${ENV_KEYS[$i]}=${ENV_VALUES[$i]}") -done - -TARGET_COMMAND=("$EXE") -if ((${#TARGET_ARGS[@]})); then - TARGET_COMMAND+=("${TARGET_ARGS[@]}") -fi - -cd / -set +e -/usr/bin/env -i "${ENV_ARGS[@]}" \ - /usr/bin/sandbox-exec "${SANDBOX_ARGS[@]}" -f "$PROFILE" -- \ - "${TARGET_COMMAND[@]}" -rc=$? -set -e -trap - EXIT HUP INT TERM -/bin/rm -f -- "$PROFILE" -exit "$rc" diff --git a/proc-isolation-proof/src/config.rs b/proc-isolation-proof/src/config.rs deleted file mode 100644 index 478eefd..0000000 --- a/proc-isolation-proof/src/config.rs +++ /dev/null @@ -1,527 +0,0 @@ -//! YAML policy schema and deterministic process/group resolution. - -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Path, PathBuf}; - -use ores_reactive_maps::ReactiveMap; -use serde::{Deserialize, Serialize}; - -use crate::error::{Error, Result}; - -const SUPPORTED_VERSION: u32 = 1; -const DEFAULT_LAYER: &str = "defaults"; -const GROUP_LAYER: &str = "group-members"; -const PROCESS_LAYER: &str = "process-explicit"; - -/// Root policy loaded from `.ores-proc-isolation.yaml`. -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Config { - /// Policy schema version. - pub version: u32, - /// Global defaults. - #[serde(default)] - pub defaults: Defaults, - /// Named policy groups. - #[serde(default)] - pub groups: BTreeMap, - /// Named process definitions. - #[serde(default)] - pub processes: BTreeMap, -} - -/// Global policy defaults. -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Defaults { - /// Group used when a process is not assigned elsewhere. - #[serde(default = "default_group")] - pub group: String, -} - -impl Default for Defaults { - fn default() -> Self { - Self { - group: default_group(), - } - } -} - -fn default_group() -> String { - "external-only".to_owned() -} - -/// Shared policy assigned to one or more processes. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct Group { - /// Process names that belong to the group. - #[serde(default)] - pub members: Vec, - /// Filesystem visibility for the target. - #[serde(default)] - pub filesystem: FilesystemPolicy, - /// Network visibility for the target. - #[serde(default)] - pub network: NetworkPolicy, - /// Resource bounds inherited by the target and its children. - #[serde(default)] - pub limits: ResourceLimits, - /// Explicit environment variables injected after host environment clearing. - #[serde(default)] - pub environment: BTreeMap, -} - -/// One configured process. -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Process { - /// Explicit group assignment. May duplicate `groups..members` if it agrees. - #[serde(default)] - pub group: Option, - /// Absolute executable followed by fixed arguments. - pub command: Vec, - /// Process-specific environment variables layered over group variables. - #[serde(default)] - pub environment: BTreeMap, -} - -/// Filesystem policy. Host paths are invisible unless explicitly named here. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct FilesystemPolicy { - /// Additional host paths exposed read-only inside the sandbox. - #[serde(default)] - pub read_only: Vec, -} - -/// Network mode for a process group. -#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum NetworkMode { - /// No network namespace connectivity at all. - None, - /// Outbound IP networking with host/local/private destinations blocked. - #[default] - External, -} - -/// Network restrictions for a process group. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct NetworkPolicy { - /// Whether the sandbox has no network or outbound external networking. - #[serde(default)] - pub mode: NetworkMode, - /// Deny host/self loopback reachability. - #[serde(default = "default_true")] - pub deny_loopback: bool, - /// Deny RFC1918, link-local, CGNAT, and other host-local destinations. - #[serde(default = "default_true")] - pub deny_private_networks: bool, -} - -impl Default for NetworkPolicy { - fn default() -> Self { - Self { - mode: NetworkMode::External, - deny_loopback: true, - deny_private_networks: true, - } - } -} - -const fn default_true() -> bool { - true -} - -/// Resource limits inherited by the sandbox target. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct ResourceLimits { - /// RLIMIT_NOFILE-style descriptor ceiling. - #[serde(default = "default_max_open_files")] - pub max_open_files: u64, - /// RLIMIT_CPU-style CPU time ceiling. - #[serde(default = "default_cpu_seconds")] - pub cpu_seconds: u64, -} - -impl Default for ResourceLimits { - fn default() -> Self { - Self { - max_open_files: default_max_open_files(), - cpu_seconds: default_cpu_seconds(), - } - } -} - -const fn default_max_open_files() -> u64 { - 128 -} - -const fn default_cpu_seconds() -> u64 { - 300 -} - -/// Effective process plus its resolved shared policy. -#[derive(Debug, Clone, Serialize)] -pub struct ResolvedProcess { - /// Process key in the YAML file. - pub name: String, - /// Effective group name. - pub group: String, - /// Absolute configured executable before canonicalization. - pub executable: PathBuf, - /// Fixed configured arguments (extra invocation arguments are appended later). - pub args: Vec, - /// Effective policy group. - pub policy: Group, - /// Sanitized environment that will be injected into the target. - pub environment: BTreeMap, -} - -impl Config { - /// Load and validate a YAML policy from disk. - pub fn load(path: impl AsRef) -> Result { - let path = path.as_ref(); - let text = fs::read_to_string(path).map_err(|source| Error::PolicyRead { - path: path.to_path_buf(), - source, - })?; - let config: Self = serde_yaml::from_str(&text).map_err(|error| Error::PolicyInvalid { - path: path.to_path_buf(), - message: error.to_string(), - })?; - config.validate().map_err(|message| Error::PolicyInvalid { - path: path.to_path_buf(), - message, - })?; - Ok(config) - } - - /// Validate policy invariants without resolving a specific process. - pub fn validate(&self) -> std::result::Result<(), String> { - if self.version != SUPPORTED_VERSION { - return Err(format!( - "unsupported version {}; expected {SUPPORTED_VERSION}", - self.version - )); - } - if self.groups.is_empty() { - return Err("at least one group is required".to_owned()); - } - if !self.groups.contains_key(&self.defaults.group) { - return Err(format!( - "defaults.group references unknown group {:?}", - self.defaults.group - )); - } - if self.processes.is_empty() { - return Err("at least one process is required".to_owned()); - } - - let mut declared_by_group = BTreeMap::::new(); - for (group_name, group) in &self.groups { - validate_name("group", group_name)?; - validate_environment(&group.environment)?; - validate_group_policy(group_name, group)?; - for member in &group.members { - if !self.processes.contains_key(member) { - return Err(format!( - "group {group_name:?} names unknown process {member:?}" - )); - } - if let Some(previous) = declared_by_group.insert(member.clone(), group_name.clone()) - { - if previous != *group_name { - return Err(format!( - "process {member:?} belongs to multiple groups: {previous:?} and {group_name:?}" - )); - } - } - } - } - - for (process_name, process) in &self.processes { - validate_name("process", process_name)?; - validate_environment(&process.environment)?; - if process.command.is_empty() { - return Err(format!("process {process_name:?} has an empty command")); - } - let executable = Path::new(&process.command[0]); - if !executable.is_absolute() { - return Err(format!( - "process {process_name:?} executable must be absolute: {:?}", - process.command[0] - )); - } - if process.command[0].contains('\0') { - return Err(format!("process {process_name:?} executable contains NUL")); - } - if let Some(explicit_group) = &process.group { - if !self.groups.contains_key(explicit_group) { - return Err(format!( - "process {process_name:?} references unknown group {explicit_group:?}" - )); - } - if let Some(member_group) = declared_by_group.get(process_name) { - if member_group != explicit_group { - return Err(format!( - "process {process_name:?} says group {explicit_group:?} but group membership says {member_group:?}" - )); - } - } - } - } - - Ok(()) - } - - /// Resolve one process to exactly one effective group using layered reactive maps. - pub fn resolve_process( - &self, - process_name: &str, - group_override: Option<&str>, - ) -> Result { - let process = self.processes.get(process_name).ok_or_else(|| { - Error::Resolution(format!("unknown configured process {process_name:?}")) - })?; - - let group_name = - if let Some(override_name) = group_override.filter(|value| !value.is_empty()) { - if !self.groups.contains_key(override_name) { - return Err(Error::Resolution(format!( - "group override references unknown group {override_name:?}" - ))); - } - override_name.to_owned() - } else { - self.membership_map().get_val(process_name).ok_or_else(|| { - Error::Resolution(format!("no group resolved for {process_name:?}")) - })? - }; - - let policy = self.groups.get(&group_name).cloned().ok_or_else(|| { - Error::Resolution(format!("resolved group {group_name:?} does not exist")) - })?; - - let mut environment = policy.environment.clone(); - environment.extend(process.environment.clone()); - validate_environment(&environment).map_err(Error::Resolution)?; - - Ok(ResolvedProcess { - name: process_name.to_owned(), - group: group_name, - executable: PathBuf::from(&process.command[0]), - args: process.command.iter().skip(1).cloned().collect(), - policy, - environment, - }) - } - - /// Return all processes that resolve to a particular group. - pub fn processes_for_group(&self, group_name: &str) -> Result> { - if !self.groups.contains_key(group_name) { - return Err(Error::Resolution(format!("unknown group {group_name:?}"))); - } - let memberships = self.membership_map(); - Ok(self - .processes - .keys() - .filter(|name| memberships.get_val(name).as_deref() == Some(group_name)) - .cloned() - .collect()) - } - - fn membership_map(&self) -> ReactiveMap { - let map = ReactiveMap::new(); - - let defaults = self - .processes - .keys() - .map(|name| (name.clone(), self.defaults.group.clone())) - .collect(); - map.replace_string_layer(DEFAULT_LAYER, "config.defaults.group", 10, defaults); - - let mut group_members = BTreeMap::new(); - for (group_name, group) in &self.groups { - for member in &group.members { - group_members.insert(member.clone(), group_name.clone()); - } - } - map.replace_string_layer(GROUP_LAYER, "config.groups.*.members", 20, group_members); - - let explicit = self - .processes - .iter() - .filter_map(|(name, process)| { - process - .group - .as_ref() - .map(|group| (name.clone(), group.clone())) - }) - .collect(); - map.replace_string_layer(PROCESS_LAYER, "config.processes.*.group", 30, explicit); - - map - } -} - -fn validate_name(kind: &str, value: &str) -> std::result::Result<(), String> { - if value.is_empty() { - return Err(format!("{kind} name must not be empty")); - } - if value.len() > 128 { - return Err(format!("{kind} name is too long: {value:?}")); - } - if !value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - { - return Err(format!( - "{kind} name contains unsupported characters: {value:?}" - )); - } - Ok(()) -} - -fn validate_group_policy(name: &str, group: &Group) -> std::result::Result<(), String> { - if group.limits.max_open_files < 3 { - return Err(format!("group {name:?} max_open_files must be >= 3")); - } - if group.limits.cpu_seconds == 0 { - return Err(format!("group {name:?} cpu_seconds must be > 0")); - } - if group.network.mode == NetworkMode::External && !group.network.deny_loopback { - return Err(format!( - "group {name:?} external networking must keep deny_loopback=true" - )); - } - if group.network.mode == NetworkMode::External && !group.network.deny_private_networks { - return Err(format!( - "group {name:?} external networking must keep deny_private_networks=true" - )); - } - - let mut seen = BTreeSet::new(); - for path in &group.filesystem.read_only { - if !path.is_absolute() { - return Err(format!( - "group {name:?} read_only path must be absolute: {}", - path.display() - )); - } - if path == Path::new("/") { - return Err(format!( - "group {name:?} may not expose host root as read_only" - )); - } - if !seen.insert(path) { - return Err(format!( - "group {name:?} has duplicate read_only path: {}", - path.display() - )); - } - } - Ok(()) -} - -fn validate_environment(environment: &BTreeMap) -> std::result::Result<(), String> { - for (key, value) in environment { - let valid_key = !key.is_empty() - && key.bytes().enumerate().all(|(index, byte)| match byte { - b'A'..=b'Z' | b'_' => true, - b'0'..=b'9' => index > 0, - _ => false, - }); - if !valid_key { - return Err(format!("invalid environment key {key:?}")); - } - if value.contains('\0') { - return Err(format!("environment value for {key:?} contains NUL")); - } - if key.starts_with("DYLD_") - || key.starts_with("LD_") - || matches!(key.as_str(), "BASH_ENV" | "ENV" | "SHELLOPTS") - { - return Err(format!( - "environment key {key:?} is forbidden at the sandbox boundary" - )); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fixture() -> Config { - serde_yaml::from_str( - r#" -version: 1 -defaults: - group: base -groups: - base: - members: [worker] - strict: - members: [] -processes: - worker: - group: base - command: [/usr/bin/true] - inherited: - command: [/usr/bin/false] -"#, - ) - .expect("fixture must parse") - } - - #[test] - fn explicit_and_group_membership_may_agree() { - let config = fixture(); - config.validate().expect("fixture must validate"); - assert_eq!( - config.resolve_process("worker", None).unwrap().group, - "base" - ); - } - - #[test] - fn default_membership_is_resolved_through_reactive_map() { - let config = fixture(); - assert_eq!( - config.resolve_process("inherited", None).unwrap().group, - "base" - ); - } - - #[test] - fn group_override_must_exist() { - let config = fixture(); - assert!(config.resolve_process("worker", Some("missing")).is_err()); - } - - #[test] - fn dangerous_loader_environment_is_rejected() { - let mut config = fixture(); - config - .processes - .get_mut("worker") - .unwrap() - .environment - .insert("LD_PRELOAD".into(), "/tmp/x.so".into()); - assert!(config.validate().is_err()); - } - - #[test] - fn external_network_defaults_are_strict() { - let policy = NetworkPolicy::default(); - assert_eq!(policy.mode, NetworkMode::External); - assert!(policy.deny_loopback); - assert!(policy.deny_private_networks); - } -} diff --git a/proc-isolation-proof/src/error.rs b/proc-isolation-proof/src/error.rs deleted file mode 100644 index 1686b8f..0000000 --- a/proc-isolation-proof/src/error.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Error types for configuration, CLI parsing, and sandbox launch failures. - -use std::path::PathBuf; - -use thiserror::Error; - -/// Result alias used throughout the crate. -pub type Result = std::result::Result; - -/// Fail-closed errors surfaced by the launcher. -#[derive(Debug, Error)] -pub enum Error { - /// The public CLI contract could not be audited or parsed. - #[error("CLI contract error: {0}")] - Cli(String), - - /// The policy file could not be read. - #[error("failed to read policy {path}: {source}")] - PolicyRead { - /// Policy path. - path: PathBuf, - /// I/O failure. - source: std::io::Error, - }, - - /// The policy YAML is malformed or violates the schema. - #[error("invalid policy {path}: {message}")] - PolicyInvalid { - /// Policy path. - path: PathBuf, - /// Validation detail. - message: String, - }, - - /// A configured process or group could not be resolved. - #[error("policy resolution error: {0}")] - Resolution(String), - - /// A configured executable is missing or unsafe to launch. - #[error("executable error: {0}")] - Executable(String), - - /// The host cannot provide the requested sandbox boundary. - #[error("sandbox unavailable: {0}")] - SandboxUnavailable(String), - - /// A trusted helper script could not be materialized. - #[error("failed to materialize sandbox helper: {0}")] - HelperIo(#[from] std::io::Error), - - /// The sandbox helper exited without launching or completing correctly. - #[error("sandbox launch failed: {0}")] - Launch(String), - - /// JSON serialization failed. - #[error("failed to serialize output: {0}")] - Json(#[from] serde_json::Error), -} diff --git a/proc-isolation-proof/src/flags.rs b/proc-isolation-proof/src/flags.rs deleted file mode 100644 index 6a629de..0000000 --- a/proc-isolation-proof/src/flags.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! `flags-2-env` adapter. `.cli-flags.toml` is the only public argv authority. - -use std::collections::HashMap; -use std::env; -use std::io::Write; -use std::path::PathBuf; - -use flags2env::BundledFlags2Env; -use serde::Deserialize; -use tempfile::NamedTempFile; - -use crate::error::{Error, Result}; - -const BUNDLED_CONTRACT: &str = include_str!("../.cli-flags.toml"); - -/// Typed command selected by the canonical flag contract. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CliCommand { - /// Validate configuration. - Check, - /// Validate configuration and host dependencies. - Doctor, - /// Explain one process. - Explain { - /// Configured process name. - process: String, - }, - /// Run one process with optional appended arguments. - Run { - /// Configured process name. - process: String, - /// Arguments appended after the configured fixed arguments. - extra_args: Vec, - }, -} - -/// Fully resolved CLI options. -#[derive(Debug, Clone)] -pub struct Cli { - /// Policy path. - pub config_path: PathBuf, - /// Do not launch; report the plan. - pub dry_run: bool, - /// Emit JSON output. - pub json: bool, - /// Enable the fixed BeamScale tripwire mount/PATH contract on Linux. - pub beamscale_honeypot: bool, - /// Selected command. - pub command: CliCommand, -} - -#[derive(Debug, Default, Deserialize)] -struct ResolvedFlags { - #[serde(rename = "ORES_PI_CONFIG", default = "default_policy_path")] - config: String, - #[serde(rename = "ORES_PI_DRY_RUN", default)] - dry_run: bool, - #[serde(rename = "ORES_PI_JSON", default)] - json: bool, - #[serde(rename = "ORES_PI_BEAMSCALE_HONEYPOT", default)] - beamscale_honeypot: bool, -} - -fn default_policy_path() -> String { - ".ores-proc-isolation.yaml".to_owned() -} - -/// Parse the process argv using the audited, bundled `flags-2-env` contract. -pub fn parse() -> Result { - parse_from(env::args().collect()) -} - -/// Parse a supplied argv vector. Exposed for tests and embedding. -pub fn parse_from(argv: Vec) -> Result { - let parser = BundledFlags2Env::new(); - let mut contract_file = NamedTempFile::new().map_err(Error::HelperIo)?; - contract_file - .write_all(BUNDLED_CONTRACT.as_bytes()) - .map_err(Error::HelperIo)?; - let contract = contract_file.path().to_string_lossy().into_owned(); - - parser - .audit_config(Some(&contract)) - .map_err(|error| Error::Cli(format!("flag contract audit failed: {error}")))?; - let structured = parser - .parse_structured(&argv, Some(&contract)) - .map_err(|error| Error::Cli(format!("flag parsing failed: {error}")))?; - - if !structured.unknown_options.is_empty() { - return Err(Error::Cli(format!( - "unknown options were rejected: {:?}", - structured.unknown_options - ))); - } - if !structured.errors.is_empty() { - return Err(Error::Cli(format!( - "invalid arguments were rejected: {:?}", - structured.errors - ))); - } - - let resolved_commands = parser - .resolve_commands(&argv, Some(&contract)) - .map_err(|error| Error::Cli(format!("command resolution failed: {error}")))?; - let values = coerce_values( - &parser, - &structured.dotenv, - &structured.dotenv_overrides, - &structured.provided_flags, - &contract, - )?; - let path = if resolved_commands.path.is_empty() { - let mut fallback = Vec::new(); - if !structured.command.trim().is_empty() { - fallback.push(structured.command.trim().to_owned()); - } - fallback.extend( - structured - .subcommands - .iter() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned), - ); - fallback - } else { - resolved_commands.path - }; - - let mut extras = structured.extras; - if extras.first().is_some_and(|value| value == "--") { - extras.remove(0); - } - - let command = match path.as_slice() { - [name] if name == "check" => { - reject_extras("check", &extras)?; - CliCommand::Check - } - [name] if name == "doctor" => { - reject_extras("doctor", &extras)?; - CliCommand::Doctor - } - [name] if name == "explain" => { - if extras.len() != 1 { - return Err(Error::Cli( - "usage: ores-proc-isolation explain ".to_owned(), - )); - } - CliCommand::Explain { - process: extras.remove(0), - } - } - [name] if name == "run" => { - if extras.is_empty() { - return Err(Error::Cli( - "usage: ores-proc-isolation run -- [extra args...]".to_owned(), - )); - } - let process = extras.remove(0); - if extras.first().is_some_and(|value| value == "--") { - extras.remove(0); - } - CliCommand::Run { - process, - extra_args: extras, - } - } - [] => { - return Err(Error::Cli( - "missing command: expected check, doctor, explain, or run".to_owned(), - )); - } - _ => { - return Err(Error::Cli(format!( - "unknown command path: {}", - path.join(" ") - ))); - } - }; - - Ok(Cli { - config_path: PathBuf::from(values.config), - dry_run: values.dry_run, - json: values.json, - beamscale_honeypot: values.beamscale_honeypot, - command, - }) -} - -fn reject_extras(command: &str, extras: &[String]) -> Result<()> { - if extras.is_empty() { - Ok(()) - } else { - Err(Error::Cli(format!( - "{command} does not accept positional arguments: {extras:?}" - ))) - } -} - -fn coerce_values( - parser: &BundledFlags2Env, - dotenv: &HashMap, - dotenv_overrides: &HashMap, - provided_flags: &HashMap, - contract: &str, -) -> Result { - let mut values = dotenv.clone(); - values.extend(env::vars()); - values.extend(dotenv_overrides.clone()); - values.extend(provided_flags.clone()); - parser - .coerce(&values, Some(contract)) - .map_err(|error| Error::Cli(format!("invalid typed flag/environment value: {error}"))) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn configured_group_cannot_be_overridden_from_cli() { - let result = parse_from(vec![ - "ores-proc-isolation".to_owned(), - "check".to_owned(), - "--group".to_owned(), - "weaker-policy".to_owned(), - ]); - assert!(result.is_err()); - } - - #[test] - fn bundled_contract_has_no_group_override_flag() { - assert!(!BUNDLED_CONTRACT.contains("[flags.group]")); - assert!(!BUNDLED_CONTRACT.contains("ORES_PI_GROUP")); - } - - #[test] - fn beamscale_honeypot_flag_is_explicit_and_typed() { - let cli = parse_from(vec![ - "ores-proc-isolation".to_owned(), - "--beamscale-honeypot".to_owned(), - "check".to_owned(), - ]) - .expect("honeypot flag should parse"); - assert!(cli.beamscale_honeypot); - } -} diff --git a/proc-isolation-proof/src/lib.rs b/proc-isolation-proof/src/lib.rs deleted file mode 100644 index 1d1ac2e..0000000 --- a/proc-isolation-proof/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Rootless, fail-closed process isolation for macOS and Linux. -//! -//! The crate intentionally splits policy resolution (Rust) from OS sandbox -//! mechanics (small reviewed shell helpers). Public CLI parsing is delegated to -//! `flags-2-env`; process/group resolution uses `ores-reactive-maps`. - -pub mod config; -pub mod error; -pub mod flags; -pub mod platform; -pub mod runner; - -pub use config::{Config, ResolvedProcess}; -pub use error::{Error, Result}; diff --git a/proc-isolation-proof/src/main.rs b/proc-isolation-proof/src/main.rs deleted file mode 100644 index 87738a4..0000000 --- a/proc-isolation-proof/src/main.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! CLI entrypoint. - -fn main() { - let exit_code = - match ores_proc_isolation::flags::parse().and_then(ores_proc_isolation::runner::run) { - Ok(code) => code, - Err(error) => { - eprintln!("ores-proc-isolation: {error}"); - match error { - ores_proc_isolation::Error::Cli(_) - | ores_proc_isolation::Error::PolicyRead { .. } - | ores_proc_isolation::Error::PolicyInvalid { .. } - | ores_proc_isolation::Error::Resolution(_) - | ores_proc_isolation::Error::Executable(_) => 2, - _ => 125, - } - } - }; - std::process::exit(exit_code); -} diff --git a/proc-isolation-proof/src/platform/linux.rs b/proc-isolation-proof/src/platform/linux.rs deleted file mode 100644 index 9e0bff1..0000000 --- a/proc-isolation-proof/src/platform/linux.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Linux bubblewrap + slirp4netns backend. - -use std::io::Write; -use std::os::unix::fs::{FileTypeExt, MetadataExt}; -use std::path::Path; -use std::process::Command; - -use tempfile::NamedTempFile; - -use super::{ - BEAMSCALE_TRIPWIRE_DIR, BEAMSCALE_TRIPWIRE_SOCKET, DependencyCheck, DoctorReport, SandboxPlan, - any_external_network, trusted_lookup, -}; -use crate::config::{Config, NetworkMode}; -use crate::error::{Error, Result}; - -const HELPER: &str = include_str!("../../scripts/linux/ores-proc-isolate.sh"); -const TRUSTED_PATH: &str = "/usr/sbin:/usr/bin:/sbin:/bin:/run/current-system/sw/bin"; - -fn validate_honeypot_boundary() -> Result<()> { - let dir = Path::new(BEAMSCALE_TRIPWIRE_DIR); - let dir_metadata = std::fs::symlink_metadata(dir).map_err(|error| { - Error::SandboxUnavailable(format!( - "BeamScale tripwire directory {BEAMSCALE_TRIPWIRE_DIR} is unavailable: {error}" - )) - })?; - if !dir_metadata.file_type().is_dir() - || dir_metadata.file_type().is_symlink() - || dir_metadata.uid() != 0 - || dir_metadata.mode() & 0o022 != 0 - { - return Err(Error::SandboxUnavailable(format!( - "BeamScale tripwire directory must be a root-owned, non-writable real directory: {BEAMSCALE_TRIPWIRE_DIR}" - ))); - } - - let socket = Path::new(BEAMSCALE_TRIPWIRE_SOCKET); - let socket_metadata = std::fs::symlink_metadata(socket).map_err(|error| { - Error::SandboxUnavailable(format!( - "BeamScale tripwire socket {BEAMSCALE_TRIPWIRE_SOCKET} is unavailable: {error}" - )) - })?; - if !socket_metadata.file_type().is_socket() - || socket_metadata.file_type().is_symlink() - || socket_metadata.uid() != 0 - { - return Err(Error::SandboxUnavailable(format!( - "BeamScale tripwire socket must be a root-owned Unix socket: {BEAMSCALE_TRIPWIRE_SOCKET}" - ))); - } - - Ok(()) -} - -pub(super) fn launch(plan: &SandboxPlan) -> Result { - if plan.beamscale_honeypot { - validate_honeypot_boundary()?; - } - - let mut helper = NamedTempFile::new().map_err(Error::HelperIo)?; - helper - .write_all(HELPER.as_bytes()) - .map_err(Error::HelperIo)?; - - let bash = trusted_lookup("bash").ok_or_else(|| { - Error::SandboxUnavailable("trusted bash is unavailable in system helper paths".to_owned()) - })?; - let mut command = Command::new(bash); - command - .env_clear() - .env("PATH", TRUSTED_PATH) - .env("LANG", "C") - .arg(helper.path()) - .arg("--exe") - .arg(&plan.executable) - .arg("--network") - .arg(match plan.network.mode { - NetworkMode::None => "none", - NetworkMode::External => "external", - }) - .arg("--max-open-files") - .arg(plan.limits.max_open_files.to_string()) - .arg("--cpu-seconds") - .arg(plan.limits.cpu_seconds.to_string()); - - if plan.network.deny_loopback { - command.arg("--deny-loopback"); - } else { - command.arg("--allow-loopback"); - } - if plan.network.deny_private_networks { - command.arg("--deny-private"); - } - if plan.beamscale_honeypot { - command.arg("--beamscale-honeypot"); - } - for path in &plan.read_only { - command.arg("--ro").arg(path); - } - for (key, value) in &plan.environment { - command.arg("--env").arg(key).arg(value); - } - command.arg("--").args(&plan.args); - - let status = command - .status() - .map_err(|error| Error::Launch(format!("failed to start Linux helper: {error}")))?; - Ok(status.code().unwrap_or(128)) -} - -pub(super) fn doctor(config: &Config, beamscale_honeypot: bool) -> DoctorReport { - let mut checks = Vec::new(); - push_binary(&mut checks, "bash"); - let bwrap_available = trusted_lookup("bwrap").is_some(); - push_binary(&mut checks, "bwrap"); - if any_external_network(config) { - for name in ["slirp4netns", "nsenter", "ip", "iptables", "ip6tables"] { - push_binary(&mut checks, name); - } - } - if beamscale_honeypot { - push_honeypot_checks(&mut checks); - } - - let mut notes = vec![ - "Target rootfs is an empty bubblewrap mount namespace; host /, home, cwd, /tmp, /proc, /sys, and /run are not shared.".to_owned(), - "External networking uses a new network namespace and slirp4netns with host loopback disabled; there is no host-network fallback.".to_owned(), - "IPv4/IPv6 loopback, private/link-local space, CGNAT, and the host's own interface addresses are denied before target exec.".to_owned(), - ]; - if beamscale_honeypot { - notes.push(format!( - "BeamScale honeypot mode exposes only {BEAMSCALE_TRIPWIRE_DIR} read-only and {BEAMSCALE_TRIPWIRE_SOCKET}; PATH contains only the inert tripwire directory." - )); - } - - checks.push(user_namespace_status()); - if bwrap_available { - checks.push(bubblewrap_namespace_status()); - } - if config - .groups - .values() - .any(|group| !group.network.deny_loopback) - { - notes.push( - "Configuration requests loopback access, which strict validation rejects before launch." - .to_owned(), - ); - } - - let ok = checks.iter().all(|check| check.available); - DoctorReport { - platform: "linux".to_owned(), - backend: "linux-bwrap-slirp4netns".to_owned(), - ok, - checks, - notes, - } -} - -fn push_honeypot_checks(checks: &mut Vec) { - let dir = Path::new(BEAMSCALE_TRIPWIRE_DIR); - let dir_check = match std::fs::symlink_metadata(dir) { - Ok(metadata) - if metadata.file_type().is_dir() - && !metadata.file_type().is_symlink() - && metadata.uid() == 0 - && metadata.mode() & 0o022 == 0 => - { - DependencyCheck { - name: "beamscale-tripwire-directory".to_owned(), - available: true, - detail: format!("{BEAMSCALE_TRIPWIRE_DIR} is a root-owned non-writable directory"), - } - } - Ok(metadata) => DependencyCheck { - name: "beamscale-tripwire-directory".to_owned(), - available: false, - detail: format!( - "{BEAMSCALE_TRIPWIRE_DIR} has unsafe type/ownership/mode uid={} mode={:o}", - metadata.uid(), - metadata.mode() & 0o7777 - ), - }, - Err(error) => DependencyCheck { - name: "beamscale-tripwire-directory".to_owned(), - available: false, - detail: format!("{BEAMSCALE_TRIPWIRE_DIR} is unavailable: {error}"), - }, - }; - checks.push(dir_check); - - let socket = Path::new(BEAMSCALE_TRIPWIRE_SOCKET); - let socket_check = match std::fs::symlink_metadata(socket) { - Ok(metadata) if metadata.file_type().is_socket() && metadata.uid() == 0 => { - DependencyCheck { - name: "beamscale-tripwire-socket".to_owned(), - available: true, - detail: format!("{BEAMSCALE_TRIPWIRE_SOCKET} is a root-owned Unix socket"), - } - } - Ok(metadata) => DependencyCheck { - name: "beamscale-tripwire-socket".to_owned(), - available: false, - detail: format!( - "{BEAMSCALE_TRIPWIRE_SOCKET} has unsafe type/ownership uid={} mode={:o}", - metadata.uid(), - metadata.mode() & 0o7777 - ), - }, - Err(error) => DependencyCheck { - name: "beamscale-tripwire-socket".to_owned(), - available: false, - detail: format!("{BEAMSCALE_TRIPWIRE_SOCKET} is unavailable: {error}"), - }, - }; - checks.push(socket_check); -} - -fn push_binary(checks: &mut Vec, name: &str) { - let path = trusted_lookup(name); - checks.push(DependencyCheck { - name: name.to_owned(), - available: path.is_some(), - detail: path - .map(|value| value.display().to_string()) - .unwrap_or_else(|| "not found in trusted system paths".to_owned()), - }); -} - -fn user_namespace_status() -> DependencyCheck { - let clone_gate = std::fs::read_to_string("/proc/sys/kernel/unprivileged_userns_clone") - .ok() - .map(|value| value.trim() != "0") - .unwrap_or(true); - let max_namespaces = std::fs::read_to_string("/proc/sys/user/max_user_namespaces") - .ok() - .and_then(|value| value.trim().parse::().ok()) - .unwrap_or(1); - let available = clone_gate && max_namespaces > 0; - DependencyCheck { - name: "unprivileged-user-namespaces".to_owned(), - available, - detail: format!( - "unprivileged_userns_clone={}, max_user_namespaces={max_namespaces}", - if clone_gate { "enabled" } else { "disabled" } - ), - } -} - -fn bubblewrap_namespace_status() -> DependencyCheck { - let Some(bwrap) = trusted_lookup("bwrap") else { - return DependencyCheck { - name: "bubblewrap-namespace-probe".to_owned(), - available: false, - detail: "bwrap is unavailable".to_owned(), - }; - }; - - let output = Command::new(bwrap) - .env_clear() - .env("PATH", TRUSTED_PATH) - .env("LANG", "C") - .args([ - "--die-with-parent", - "--new-session", - "--unshare-user", - "--unshare-ipc", - "--unshare-pid", - "--unshare-net", - "--unshare-uts", - "--unshare-cgroup", - "--disable-userns", - "--cap-drop", - "ALL", - "--ro-bind", - "/", - "/", - "--", - "/bin/true", - ]) - .output(); - - match output { - Ok(output) if output.status.success() => DependencyCheck { - name: "bubblewrap-namespace-probe".to_owned(), - available: true, - detail: "mandatory user/network/cgroup namespace setup succeeded".to_owned(), - }, - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - let apparmor_restricted = - std::fs::read_to_string("/proc/sys/kernel/apparmor_restrict_unprivileged_userns") - .ok() - .is_some_and(|value| value.trim() == "1"); - let apparmor_hint = if apparmor_restricted { - " Ubuntu AppArmor user-namespace mediation is enabled; install/load a narrowly scoped bwrap userns profile rather than disabling the restriction host-wide." - } else { - "" - }; - DependencyCheck { - name: "bubblewrap-namespace-probe".to_owned(), - available: false, - detail: format!("mandatory namespace setup failed: {stderr}.{apparmor_hint}"), - } - } - Err(error) => DependencyCheck { - name: "bubblewrap-namespace-probe".to_owned(), - available: false, - detail: format!("failed to execute bwrap probe: {error}"), - }, - } -} diff --git a/proc-isolation-proof/src/platform/macos.rs b/proc-isolation-proof/src/platform/macos.rs deleted file mode 100644 index 7bce031..0000000 --- a/proc-isolation-proof/src/platform/macos.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! macOS Seatbelt backend. - -use std::io::Write; -use std::path::Path; -use std::process::Command; - -use tempfile::NamedTempFile; - -use super::{DependencyCheck, DoctorReport, SandboxPlan, any_private_network_denial}; -use crate::config::{Config, NetworkMode}; -use crate::error::{Error, Result}; - -const HELPER: &str = include_str!("../../scripts/macos/ores-proc-isolate.sh"); -const TRUSTED_PATH: &str = "/usr/sbin:/usr/bin:/sbin:/bin"; - -pub(super) fn launch(plan: &SandboxPlan) -> Result { - if plan.network.mode == NetworkMode::External && !plan.network.deny_loopback { - return Err(Error::SandboxUnavailable( - "macOS strict backend refuses external networking with loopback enabled".to_owned(), - )); - } - if plan.network.mode == NetworkMode::External && plan.network.deny_private_networks { - return Err(Error::SandboxUnavailable( - "macOS Seatbelt cannot enforce strict Internet-only CIDR/host-address denial" - .to_owned(), - )); - } - - let mut helper = NamedTempFile::new().map_err(Error::HelperIo)?; - helper - .write_all(HELPER.as_bytes()) - .map_err(Error::HelperIo)?; - - let mut command = Command::new("/bin/bash"); - command - .env_clear() - .env("PATH", TRUSTED_PATH) - .env("LANG", "C") - .arg(helper.path()) - .arg("--exe") - .arg(&plan.executable) - .arg("--network") - .arg(match plan.network.mode { - NetworkMode::None => "none", - NetworkMode::External => "external", - }) - .arg("--max-open-files") - .arg(plan.limits.max_open_files.to_string()) - .arg("--cpu-seconds") - .arg(plan.limits.cpu_seconds.to_string()) - .arg("--deny-loopback"); - - for path in &plan.read_only { - command.arg("--ro").arg(path); - } - for (key, value) in &plan.environment { - command.arg("--env").arg(key).arg(value); - } - command.arg("--").args(&plan.args); - - let status = command - .status() - .map_err(|error| Error::Launch(format!("failed to start macOS helper: {error}")))?; - Ok(status.code().unwrap_or(128)) -} - -pub(super) fn doctor(config: &Config) -> DoctorReport { - let sandbox_exec = Path::new("/usr/bin/sandbox-exec"); - let env = Path::new("/usr/bin/env"); - let unsupported_external = any_private_network_denial(config); - let checks = vec![ - DependencyCheck { - name: "/usr/bin/sandbox-exec".to_owned(), - available: sandbox_exec.is_file(), - detail: sandbox_exec.display().to_string(), - }, - DependencyCheck { - name: "/usr/bin/env".to_owned(), - available: env.is_file(), - detail: env.display().to_string(), - }, - DependencyCheck { - name: "strict external-network compatibility".to_owned(), - available: !unsupported_external, - detail: if unsupported_external { - "an external group requires private/local destination denial, which macOS Seatbelt cannot express equivalently" - .to_owned() - } else { - "configuration uses only network modes enforceable by this backend".to_owned() - }, - }, - ]; - let ok = checks.iter().all(|check| check.available); - DoctorReport { - platform: "macos".to_owned(), - backend: "macos-seatbelt".to_owned(), - ok, - checks, - notes: vec![ - "Seatbelt starts from (deny default); the target gets system runtime reads, its executable, and explicitly configured read-only paths only.".to_owned(), - "network.mode=none is supported; strict Internet-only external mode fails closed because equivalent destination filtering is unavailable.".to_owned(), - "sandbox-exec is deprecated by Apple but remains the available kernel Seatbelt launcher on current macOS; this backend fails closed if it is absent.".to_owned(), - ], - } -} diff --git a/proc-isolation-proof/src/platform/mod.rs b/proc-isolation-proof/src/platform/mod.rs deleted file mode 100644 index 767ec7e..0000000 --- a/proc-isolation-proof/src/platform/mod.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Platform-specific sandbox preparation and launch. - -mod linux; -mod macos; - -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::os::unix::fs::MetadataExt; -use std::path::{Path, PathBuf}; - -use serde::Serialize; - -use crate::config::{Config, NetworkMode, NetworkPolicy, ResolvedProcess, ResourceLimits}; -use crate::error::{Error, Result}; - -pub(crate) const BEAMSCALE_TRIPWIRE_DIR: &str = "/opt/beamscale/honeypot-bin"; -pub(crate) const BEAMSCALE_TRIPWIRE_SOCKET: &str = "/run/bmscl-honeypot/tripwire.sock"; - -/// Platform selected for this build/host. -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum HostPlatform { - /// Linux namespace/bubblewrap backend. - Linux, - /// macOS Seatbelt backend. - Macos, -} - -/// One host dependency check. -#[derive(Debug, Clone, Serialize)] -pub struct DependencyCheck { - /// Executable or facility name. - pub name: String, - /// Whether it is available. - pub available: bool, - /// Resolved trusted path or diagnostic. - pub detail: String, -} - -/// Result of host capability inspection. -#[derive(Debug, Clone, Serialize)] -pub struct DoctorReport { - /// Host platform. - pub platform: String, - /// Chosen backend. - pub backend: String, - /// Whether all required capabilities are available. - pub ok: bool, - /// Individual dependency checks. - pub checks: Vec, - /// Important backend notes/limitations. - pub notes: Vec, -} - -/// Fully prepared sandbox launch plan. -#[derive(Debug, Clone, Serialize)] -pub struct SandboxPlan { - /// Platform backend. - pub platform: HostPlatform, - /// Backend implementation name. - pub backend: String, - /// Configured process key. - pub process: String, - /// Resolved policy group. - pub group: String, - /// Canonical host executable path exposed into the sandbox. - pub executable: PathBuf, - /// Complete target argv excluding argv[0]. - pub args: Vec, - /// Canonical explicit read-only host paths. - pub read_only: Vec, - /// Effective network policy. - pub network: NetworkPolicy, - /// Effective resource bounds. - pub limits: ResourceLimits, - /// Enable the fixed BeamScale tripwire directory/socket/PATH contract. - pub beamscale_honeypot: bool, - /// Environment keys injected into the target. Values are intentionally omitted from reports. - pub environment_keys: Vec, - /// Secret-capable environment values passed only to the helper at launch. - #[serde(skip_serializing)] - pub environment: BTreeMap, -} - -/// Detect the supported host platform. -pub fn host_platform() -> Result { - if cfg!(target_os = "linux") { - Ok(HostPlatform::Linux) - } else if cfg!(target_os = "macos") { - Ok(HostPlatform::Macos) - } else { - Err(Error::SandboxUnavailable( - "only Linux and macOS are supported".to_owned(), - )) - } -} - -/// Resolve and canonicalize the process launch plan before any sandbox helper runs. -pub fn prepare_plan( - resolved: ResolvedProcess, - extra_args: &[String], - beamscale_honeypot: bool, -) -> Result { - let platform = host_platform()?; - if beamscale_honeypot && platform != HostPlatform::Linux { - return Err(Error::SandboxUnavailable( - "BeamScale honeypot integration requires the Linux cgroup/namespace backend".to_owned(), - )); - } - if platform == HostPlatform::Macos - && resolved.policy.network.mode == NetworkMode::External - && resolved.policy.network.deny_private_networks - { - return Err(Error::SandboxUnavailable( - "macOS Seatbelt cannot express strict Internet-only destination filtering; use network.mode=none on macOS or run external mode on Linux" - .to_owned(), - )); - } - - let executable = fs::canonicalize(&resolved.executable).map_err(|error| { - Error::Executable(format!( - "cannot canonicalize {}: {error}", - resolved.executable.display() - )) - })?; - if !executable.is_absolute() || !executable.is_file() { - return Err(Error::Executable(format!( - "target must be an absolute regular file: {}", - executable.display() - ))); - } - - let mut read_only = Vec::new(); - let mut seen = BTreeSet::new(); - for path in &resolved.policy.filesystem.read_only { - let canonical = fs::canonicalize(path).map_err(|error| { - Error::Executable(format!( - "cannot canonicalize read-only path {}: {error}", - path.display() - )) - })?; - if canonical == Path::new("/") { - return Err(Error::Executable( - "refusing to expose host root as read-only".to_owned(), - )); - } - if seen.insert(canonical.clone()) { - read_only.push(canonical); - } - } - - let mut args = resolved.args.clone(); - args.extend(extra_args.iter().cloned()); - let environment_keys = resolved.environment.keys().cloned().collect(); - let backend = match platform { - HostPlatform::Linux => "linux-bwrap-slirp4netns", - HostPlatform::Macos => "macos-seatbelt", - } - .to_owned(); - - Ok(SandboxPlan { - platform, - backend, - process: resolved.name, - group: resolved.group, - executable, - args, - read_only, - network: resolved.policy.network, - limits: resolved.policy.limits, - beamscale_honeypot, - environment_keys, - environment: resolved.environment, - }) -} - -/// Launch a prepared plan and return the target exit code. -pub fn launch(plan: &SandboxPlan) -> Result { - match plan.platform { - HostPlatform::Linux => linux::launch(plan), - HostPlatform::Macos => macos::launch(plan), - } -} - -/// Inspect helper availability and policy/backend compatibility. -pub fn doctor(config: &Config, beamscale_honeypot: bool) -> DoctorReport { - match host_platform() { - Ok(HostPlatform::Linux) => linux::doctor(config, beamscale_honeypot), - Ok(HostPlatform::Macos) => { - let mut report = macos::doctor(config); - if beamscale_honeypot { - report.ok = false; - report.notes.push( - "BeamScale honeypot integration is Linux-only and is unavailable on macOS." - .to_owned(), - ); - } - report - } - Err(error) => DoctorReport { - platform: std::env::consts::OS.to_owned(), - backend: "unsupported".to_owned(), - ok: false, - checks: Vec::new(), - notes: vec![error.to_string()], - }, - } -} - -pub(crate) fn any_external_network(config: &Config) -> bool { - config - .groups - .values() - .any(|group| group.network.mode == NetworkMode::External) -} - -pub(crate) fn any_private_network_denial(config: &Config) -> bool { - config.groups.values().any(|group| { - group.network.mode == NetworkMode::External && group.network.deny_private_networks - }) -} - -pub(crate) fn trusted_lookup(name: &str) -> Option { - if name.is_empty() || name.contains('/') { - return None; - } - - // NixOS exposes system packages through /run/current-system/sw/bin while - // conventional distributions use /usr/{s,}bin and /{s,}bin. Never consult - // the caller's PATH: every accepted helper must resolve to a root-owned, - // executable, non-writable regular file. The NixOS profile gets an - // additional invariant that its canonical target lives in the immutable - // /nix/store. - const DIRS: &[(&str, bool)] = &[ - ("/usr/sbin", false), - ("/usr/bin", false), - ("/sbin", false), - ("/bin", false), - ("/run/current-system/sw/bin", true), - ]; - - DIRS.iter().find_map(|(dir, require_nix_store)| { - let candidate = Path::new(dir).join(name); - let canonical = fs::canonicalize(&candidate).ok()?; - let metadata = fs::metadata(&canonical).ok()?; - let safe_file = metadata.is_file() - && metadata.uid() == 0 - && metadata.mode() & 0o022 == 0 - && metadata.mode() & 0o111 != 0; - if !safe_file { - return None; - } - if *require_nix_store && !canonical.starts_with("/nix/store/") { - return None; - } - Some(canonical) - }) -} diff --git a/proc-isolation-proof/src/runner.rs b/proc-isolation-proof/src/runner.rs deleted file mode 100644 index 4b7148c..0000000 --- a/proc-isolation-proof/src/runner.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! High-level command execution. - -use serde::Serialize; - -use crate::config::Config; -use crate::error::Result; -use crate::flags::{Cli, CliCommand}; -use crate::platform::{self, SandboxPlan}; - -/// Execute one parsed CLI invocation and return the desired process exit code. -pub fn run(cli: Cli) -> Result { - let config = Config::load(&cli.config_path)?; - - match cli.command { - CliCommand::Check => { - let report = CheckReport { - ok: true, - config: cli.config_path.display().to_string(), - version: config.version, - groups: config.groups.len(), - processes: config.processes.len(), - beamscale_honeypot: cli.beamscale_honeypot, - }; - emit(&report, cli.json, || { - format!( - "policy ok: {} group(s), {} process(es), schema v{}, beamscale_honeypot={}", - report.groups, report.processes, report.version, report.beamscale_honeypot - ) - })?; - Ok(0) - } - CliCommand::Doctor => { - let report = platform::doctor(&config, cli.beamscale_honeypot); - let ok = report.ok; - emit(&report, cli.json, || { - let mut text = format!( - "backend={} platform={} status={}\n", - report.backend, - report.platform, - if report.ok { "ok" } else { "not-ready" } - ); - for check in &report.checks { - text.push_str(&format!( - " [{}] {}: {}\n", - if check.available { "ok" } else { "missing" }, - check.name, - check.detail - )); - } - for note in &report.notes { - text.push_str(&format!(" note: {note}\n")); - } - text.trim_end().to_owned() - })?; - Ok(if ok { 0 } else { 2 }) - } - CliCommand::Explain { process } => { - let resolved = config.resolve_process(&process, None)?; - let members = config.processes_for_group(&resolved.group)?; - let report = ExplainReport { - process: resolved.name, - group: resolved.group, - executable: resolved.executable.display().to_string(), - fixed_args: resolved.args, - read_only: resolved - .policy - .filesystem - .read_only - .iter() - .map(|path| path.display().to_string()) - .collect(), - network: resolved.policy.network, - limits: resolved.policy.limits, - environment_keys: resolved.environment.keys().cloned().collect(), - group_members: members, - beamscale_honeypot: cli.beamscale_honeypot, - }; - emit(&report, cli.json, || { - format!( - "process={} group={} executable={} members=[{}] beamscale_honeypot={}", - report.process, - report.group, - report.executable, - report.group_members.join(","), - report.beamscale_honeypot - ) - })?; - Ok(0) - } - CliCommand::Run { - process, - extra_args, - } => { - let resolved = config.resolve_process(&process, None)?; - let plan = platform::prepare_plan(resolved, &extra_args, cli.beamscale_honeypot)?; - if cli.dry_run { - emit_plan(&plan, cli.json)?; - return Ok(0); - } - platform::launch(&plan) - } - } -} - -fn emit_plan(plan: &SandboxPlan, json: bool) -> Result<()> { - emit(plan, json, || { - format!( - "dry-run: backend={} process={} group={} executable={} args={:?} read_only={} env_keys={:?} beamscale_honeypot={}", - plan.backend, - plan.process, - plan.group, - plan.executable.display(), - plan.args, - plan.read_only.len(), - plan.environment_keys, - plan.beamscale_honeypot - ) - }) -} - -fn emit(value: &T, json: bool, plain: F) -> Result<()> -where - T: Serialize, - F: FnOnce() -> String, -{ - if json { - println!("{}", serde_json::to_string(value)?); - } else { - println!("{}", plain()); - } - Ok(()) -} - -#[derive(Debug, Serialize)] -struct CheckReport { - ok: bool, - config: String, - version: u32, - groups: usize, - processes: usize, - beamscale_honeypot: bool, -} - -#[derive(Debug, Serialize)] -struct ExplainReport { - process: String, - group: String, - executable: String, - fixed_args: Vec, - read_only: Vec, - network: crate::config::NetworkPolicy, - limits: crate::config::ResourceLimits, - environment_keys: Vec, - group_members: Vec, - beamscale_honeypot: bool, -} diff --git a/proc-isolation-proof/tests/beamscale_honeypot_profile.rs b/proc-isolation-proof/tests/beamscale_honeypot_profile.rs deleted file mode 100644 index 949a037..0000000 --- a/proc-isolation-proof/tests/beamscale_honeypot_profile.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Contract tests for the fixed BeamScale honeypot worker profile. - -use std::path::{Path, PathBuf}; - -use ores_proc_isolation::Config; -use ores_proc_isolation::config::NetworkMode; - -fn profile_path() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("examples") - .join("beamscale-hosted-gleam.yaml") -} - -#[test] -fn beamscale_profile_exposes_only_exact_tripwire_paths() { - let config = Config::load(profile_path()).expect("BeamScale profile must validate"); - let resolved = config - .resolve_process("beamscale-worker", None) - .expect("BeamScale worker must resolve"); - - let mounts = &resolved.policy.filesystem.read_only; - assert_eq!( - mounts.as_slice(), - &[ - PathBuf::from("/opt/beamscale/honeypot-bin"), - PathBuf::from("/run/bmscl-honeypot/tripwire.sock"), - ] - ); - for forbidden in ["/", "/bin", "/usr/bin", "/run", "/opt"] { - assert!( - !mounts.iter().any(|path| path == Path::new(forbidden)), - "BeamScale profile must not expose broad host path {forbidden}" - ); - } -} - -#[test] -fn beamscale_profile_replaces_path_with_inert_tripwires() { - let config = Config::load(profile_path()).expect("BeamScale profile must validate"); - let resolved = config - .resolve_process("beamscale-worker", None) - .expect("BeamScale worker must resolve"); - - assert_eq!( - resolved.environment.get("PATH").map(String::as_str), - Some("/opt/beamscale/honeypot-bin") - ); - assert_eq!(resolved.policy.network.mode, NetworkMode::External); - assert!(resolved.policy.network.deny_loopback); - assert!(resolved.policy.network.deny_private_networks); -} - -#[test] -fn beamscale_profile_keeps_worker_executable_absolute() { - let config = Config::load(profile_path()).expect("BeamScale profile must validate"); - let resolved = config - .resolve_process("beamscale-worker", None) - .expect("BeamScale worker must resolve"); - - assert!(resolved.executable.is_absolute()); - assert_eq!( - resolved.executable, - PathBuf::from("/opt/beamscale/runtime/beam.smp") - ); -} diff --git a/proc-isolation-proof/tests/linux_helper_tripwire_contract.rs b/proc-isolation-proof/tests/linux_helper_tripwire_contract.rs deleted file mode 100644 index 93ee155..0000000 --- a/proc-isolation-proof/tests/linux_helper_tripwire_contract.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Contract tests for the Linux helper's fixed BeamScale tripwire boundary. - -const HELPER: &str = include_str!("../scripts/linux/ores-proc-isolate.sh"); - -#[test] -fn exact_read_only_paths_are_bound_without_parent_directory_expansion() { - assert!(HELPER.contains("BWRAP_ARGS+=(--ro-bind \"$ro_path\" \"$ro_path\")")); - assert!(!HELPER.contains("--ro-bind /run /run")); - assert!(!HELPER.contains("--ro-bind /opt /opt")); - assert!(!HELPER.contains("--ro-bind /bin /bin")); - assert!(!HELPER.contains("--ro-bind /usr/bin /usr/bin")); -} - -#[test] -fn tenant_path_is_fail_closed_unless_beamscale_tripwire_mode_is_explicit() { - assert!(HELPER.contains("TARGET_PATH='/nonexistent'")); - assert!(HELPER.contains("TARGET_PATH=$BEAMSCALE_TRIPWIRE_DIR")); - assert!(HELPER.contains("--setenv PATH \"$TARGET_PATH\"")); - assert!(HELPER.contains("--beamscale-honeypot")); -} - -#[test] -fn beamscale_mode_reasserts_tripwire_path_after_generic_environment() { - let environment_loop = HELPER - .find("BWRAP_ARGS+=(--setenv \"${ENV_KEYS[$i]}\" \"${ENV_VALUES[$i]}\")") - .expect("generic environment injection must remain explicit"); - let strict_path = HELPER - .rfind("BWRAP_ARGS+=(--setenv PATH \"$BEAMSCALE_TRIPWIRE_DIR\")") - .expect("BeamScale mode must re-assert its canary-only PATH"); - assert!(strict_path > environment_loop); -} - -#[test] -fn beamscale_mode_exposes_only_fixed_tripwire_paths() { - assert!(HELPER.contains("BEAMSCALE_TRIPWIRE_DIR='/opt/beamscale/honeypot-bin'")); - assert!(HELPER.contains("BEAMSCALE_TRIPWIRE_SOCKET='/run/bmscl-honeypot/tripwire.sock'")); - assert!(HELPER.contains("--ro-bind \"$BEAMSCALE_TRIPWIRE_DIR\" \"$BEAMSCALE_TRIPWIRE_DIR\"")); - assert!( - HELPER.contains("--ro-bind \"$BEAMSCALE_TRIPWIRE_SOCKET\" \"$BEAMSCALE_TRIPWIRE_SOCKET\"") - ); - assert!(!HELPER.contains("--ro-bind /run /run")); - assert!(!HELPER.contains("--ro-bind /opt /opt")); -} - -#[test] -fn beamscale_mode_rejects_broad_utility_mounts() { - assert!(HELPER.contains("/bin|/sbin|/usr/bin|/usr/sbin|/run|/opt")); - assert!(HELPER.contains("BeamScale honeypot mode refuses broad utility/runtime mount")); -} - -#[test] -fn one_bubblewrap_setup_owns_all_tenant_namespaces() { - assert!(!HELPER.contains("ORES_PI_SUPERVISOR_USERNS")); - assert!(!HELPER.contains("need unshare")); - assert!(!HELPER.contains("--map-root-user")); - - let tenant = HELPER - .find("BWRAP_ARGS=(") - .expect("tenant Bubblewrap boundary must exist"); - let tenant_slice = &HELPER[tenant..]; - assert!(tenant_slice.contains("--unshare-user")); - assert!(tenant_slice.contains("--uid 0")); - assert!(tenant_slice.contains("--gid 0")); - assert!(tenant_slice.contains("--unshare-ipc")); - assert!(tenant_slice.contains("--unshare-pid")); - assert!(tenant_slice.contains("--unshare-net")); - assert!(tenant_slice.contains("--unshare-uts")); - assert!(tenant_slice.contains("--unshare-cgroup")); - assert!(tenant_slice.contains("--cap-drop ALL")); -} - -#[test] -fn no_network_path_keeps_bubblewrap_nested_userns_lockdown() { - assert!(HELPER.contains("BWRAP_ARGS+=(--disable-userns)")); -} - -#[test] -fn external_network_preconfigures_one_userns_and_disables_nested_userns() { - assert!(HELPER.contains("--userns-block-fd 6")); - assert!(HELPER.contains("--assert-userns-disabled")); - assert!(HELPER.contains("/proc/$setup_pid/setgroups")); - assert!(HELPER.contains("/proc/$setup_pid/uid_map")); - assert!(HELPER.contains("/proc/$setup_pid/gid_map")); - assert!(HELPER.contains("/proc/sys/user/max_user_namespaces")); - assert!(HELPER.contains("printf '0 %s 1\\n' \"$HOST_UID\"")); - assert!(HELPER.contains("printf '0 %s 1\\n' \"$HOST_GID\"")); -} - -#[test] -fn bubblewrap_info_stream_is_consumed_until_child_pid_is_seen() { - assert!(HELPER.contains("while IFS= read -r info_line <&10; do")); - assert!(HELPER.contains("if [[ -n \"$candidate\" ]]; then")); - assert!(HELPER.contains("setup_pid=$candidate")); -} - -#[test] -fn host_network_supervisor_enters_same_owned_user_and_network_namespaces() { - assert!(HELPER.contains("NETNS_PATH=\"/proc/$child_pid/ns/net\"")); - assert!(HELPER.contains("--netns-type=path")); - assert!(HELPER.contains("\"$NETNS_PATH\" tap0")); - assert!(!HELPER.contains("--userns-path=")); - assert!(!HELPER.contains("-U --user-parent")); - assert!(HELPER.contains("--preserve-credentials")); - assert!(HELPER.contains("-U --preserve-credentials -- \"$BASH\"")); - assert!(HELPER.contains("-U --preserve-credentials -n --")); - assert!(!HELPER.contains("--keep-caps")); -} - -#[test] -fn host_root_and_runtime_directories_are_not_shared_wholesale() { - assert!(HELPER.contains("[[ \"$ro_path\" != / ]] || fatal 'refusing to expose host root'")); - assert!(HELPER.contains("--clearenv")); - assert!(HELPER.contains("--unshare-pid")); - assert!(HELPER.contains("--unshare-net")); - assert!(HELPER.contains("--unshare-cgroup")); - assert!(HELPER.contains("--cap-drop ALL")); -} - -#[test] -fn single_boundary_never_readds_caps_before_tenant_exec() { - assert!(!HELPER.contains("--cap-add ALL")); - assert!(HELPER.contains("--cap-drop ALL")); -} diff --git a/proc-isolation-proof/tests/linux_network_helper_liveness_contract.rs b/proc-isolation-proof/tests/linux_network_helper_liveness_contract.rs deleted file mode 100644 index a59b7d5..0000000 --- a/proc-isolation-proof/tests/linux_network_helper_liveness_contract.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Contract tests for Linux external-network helper liveness supervision. - -const HELPER: &str = include_str!("../scripts/linux/ores-proc-isolate.sh"); - -#[test] -fn external_network_helper_is_supervised_with_tenant() { - assert!(HELPER.contains("wait -n -p finished_pid \"$BWRAP_PID\" \"$SLIRP_PID\"")); - assert!(HELPER.contains("network helper exited while tenant was still running")); - assert!(HELPER.contains("kill \"$BWRAP_PID\"")); - assert!(HELPER.contains("rc=125")); -} - -#[test] -fn cleanup_reaps_both_network_helper_and_tenant() { - let cleanup = HELPER - .split("cleanup() {") - .nth(1) - .and_then(|tail| tail.split("trap cleanup").next()) - .expect("cleanup function"); - assert!(cleanup.contains("kill \"$SLIRP_PID\"")); - assert!(cleanup.contains("wait \"$SLIRP_PID\"")); - assert!(cleanup.contains("kill \"$BWRAP_PID\"")); - assert!(cleanup.contains("wait \"$BWRAP_PID\"")); -} diff --git a/proc-isolation-proof/tests/nixos_trusted_helper_contract.rs b/proc-isolation-proof/tests/nixos_trusted_helper_contract.rs deleted file mode 100644 index e7c15f6..0000000 --- a/proc-isolation-proof/tests/nixos_trusted_helper_contract.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Contract tests for trusted NixOS helper resolution without regressing the single Bubblewrap boundary. - -const PLATFORM: &str = include_str!("../src/platform/mod.rs"); -const LINUX: &str = include_str!("../src/platform/linux.rs"); -const HELPER: &str = include_str!("../scripts/linux/ores-proc-isolate.sh"); - -#[test] -fn nixos_profile_is_explicit_and_canonicalized_into_nix_store() { - assert!(PLATFORM.contains("(\"/run/current-system/sw/bin\", true)")); - assert!(PLATFORM.contains("fs::canonicalize(&candidate)")); - assert!(PLATFORM.contains("metadata.uid() == 0")); - assert!(PLATFORM.contains("metadata.mode() & 0o022 == 0")); - assert!(PLATFORM.contains("metadata.mode() & 0o111 != 0")); - assert!(PLATFORM.contains("canonical.starts_with(\"/nix/store/\")")); -} - -#[test] -fn helper_lookup_never_uses_inherited_path() { - assert!(PLATFORM.contains("if name.is_empty() || name.contains('/')")); - assert!(!PLATFORM.contains("std::env::var(\"PATH\")")); - assert!(LINUX.contains("env_clear()")); - assert!(LINUX.contains("/run/current-system/sw/bin")); -} - -#[test] -fn bash_is_resolved_through_the_same_trusted_lookup() { - assert!(LINUX.contains("trusted_lookup(\"bash\")")); - assert!(LINUX.contains("push_binary(&mut checks, \"bash\")")); - assert!(!LINUX.contains("Command::new(\"/bin/bash\")")); -} - -#[test] -fn single_bubblewrap_boundary_from_main_is_preserved() { - assert!(!HELPER.contains("ORES_PI_SUPERVISOR_USERNS")); - assert!(HELPER.contains("--unshare-user")); - assert!(HELPER.contains("--cap-drop ALL")); - assert!(HELPER.contains("--netns-type=path")); - assert!(!HELPER.contains("--userns-path=")); - assert!(!HELPER.contains("-U --user-parent")); - assert!(HELPER.contains("--preserve-credentials")); - assert!(HELPER.contains("--userns-block-fd 6")); - assert!(HELPER.contains("--assert-userns-disabled")); - assert!(HELPER.contains("/proc/sys/user/max_user_namespaces")); - assert!(HELPER.contains("-U --preserve-credentials -- \"$BASH\"")); - assert!(HELPER.contains("-U --preserve-credentials -n --")); -} diff --git a/proc-isolation-proof/tests/policy.rs b/proc-isolation-proof/tests/policy.rs deleted file mode 100644 index 76e8404..0000000 --- a/proc-isolation-proof/tests/policy.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! Regression tests for fail-closed process-isolation policy loading. - -use std::fs; - -use ores_proc_isolation::Config; -use tempfile::tempdir; - -#[test] -fn unknown_yaml_keys_fail_closed() { - let dir = tempdir().unwrap(); - let path = dir.path().join("policy.yaml"); - fs::write( - &path, - r#" -version: 1 -defaults: - group: strict -groups: - strict: - members: [job] - unexpected_escape_hatch: true -processes: - job: - command: [/usr/bin/true] -"#, - ) - .unwrap(); - assert!(Config::load(&path).is_err()); -} - -#[test] -fn contradictory_bidirectional_membership_fails_closed() { - let dir = tempdir().unwrap(); - let path = dir.path().join("policy.yaml"); - fs::write( - &path, - r#" -version: 1 -defaults: - group: a -groups: - a: - members: [job] - b: - members: [] -processes: - job: - group: b - command: [/usr/bin/true] -"#, - ) - .unwrap(); - assert!(Config::load(&path).is_err()); -} - -#[test] -fn host_root_cannot_be_allowlisted() { - let dir = tempdir().unwrap(); - let path = dir.path().join("policy.yaml"); - fs::write( - &path, - r#" -version: 1 -defaults: - group: strict -groups: - strict: - filesystem: - read_only: [/] -processes: - job: - command: [/usr/bin/true] -"#, - ) - .unwrap(); - assert!(Config::load(&path).is_err()); -} - -#[test] -fn external_network_cannot_disable_private_destination_denial() { - let dir = tempdir().unwrap(); - let path = dir.path().join("policy.yaml"); - fs::write( - &path, - r#" -version: 1 -defaults: - group: external -groups: - external: - network: - mode: external - deny_loopback: true - deny_private_networks: false -processes: - job: - command: [/usr/bin/true] -"#, - ) - .unwrap(); - assert!(Config::load(&path).is_err()); -} diff --git a/proc-isolation-proof/vendor/ores-reactive-maps/Cargo.toml b/proc-isolation-proof/vendor/ores-reactive-maps/Cargo.toml deleted file mode 100644 index bb037c5..0000000 --- a/proc-isolation-proof/vendor/ores-reactive-maps/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "ores-reactive-maps" -version = "0.1.0" -edition = "2021" -description = "Layered reactive key/value maps with deterministic precedence and source metadata" -license = "MIT" -repository = "https://github.com/ORESoftware/ores-reactive-maps" - -[lib] -name = "ores_reactive_maps" -path = "src/lib.rs" diff --git a/proc-isolation-proof/vendor/ores-reactive-maps/LICENSE b/proc-isolation-proof/vendor/ores-reactive-maps/LICENSE deleted file mode 100644 index 01521bb..0000000 --- a/proc-isolation-proof/vendor/ores-reactive-maps/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Alexander Mills / ORESoftware - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/proc-isolation-proof/vendor/ores-reactive-maps/UPSTREAM.md b/proc-isolation-proof/vendor/ores-reactive-maps/UPSTREAM.md deleted file mode 100644 index d6ea50e..0000000 --- a/proc-isolation-proof/vendor/ores-reactive-maps/UPSTREAM.md +++ /dev/null @@ -1,7 +0,0 @@ -# Upstream provenance - -This crate is a minimal vendored subset of the Rust API from `ORESoftware/ores-reactive-maps`, pinned from commit: - -`39fa222499e1a041117fc3e6b136189b16669e6b` - -Only the `ReactiveMap` functionality used by `ores-proc-isolation-cli` is retained here so CI and release builds do not require cross-repository credentials for the private upstream repository. Keep behavior and public names aligned with that pinned upstream source when refreshing this vendor snapshot. diff --git a/proc-isolation-proof/vendor/ores-reactive-maps/src/lib.rs b/proc-isolation-proof/vendor/ores-reactive-maps/src/lib.rs deleted file mode 100644 index ee8910a..0000000 --- a/proc-isolation-proof/vendor/ores-reactive-maps/src/lib.rs +++ /dev/null @@ -1,124 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; - -pub const DEFAULT_PUBLIC_PREFIX: &str = "ORES_PUBLIC_"; - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RawEntry { - value: String, -} - -#[derive(Debug, Clone)] -struct Layer { - priority: i64, - order: u64, - entries: BTreeMap, -} - -struct State { - layers: BTreeMap, - public_prefix: String, - next_layer_order: u64, -} - -impl State { - fn new(public_prefix: impl Into) -> Self { - Self { - layers: BTreeMap::new(), - public_prefix: public_prefix.into(), - next_layer_order: 0, - } - } - - fn resolve(&self, key: &str) -> Option { - let mut winner: Option<(&Layer, &RawEntry)> = None; - for layer in self.layers.values() { - let Some(candidate) = layer.entries.get(key) else { - continue; - }; - let replace = match winner { - None => true, - Some((current, _)) => { - layer.priority > current.priority - || (layer.priority == current.priority && layer.order > current.order) - } - }; - if replace { - winner = Some((layer, candidate)); - } - } - winner.map(|(_, raw)| raw.value.clone()) - } -} - -#[derive(Clone)] -pub struct ReactiveMap { - state: Arc>, -} - -impl Default for ReactiveMap { - fn default() -> Self { - Self::new() - } -} - -impl ReactiveMap { - #[must_use] - pub fn new() -> Self { - Self::with_public_prefix(DEFAULT_PUBLIC_PREFIX) - } - - #[must_use] - pub fn with_public_prefix(prefix: impl Into) -> Self { - Self { - state: Arc::new(Mutex::new(State::new(prefix))), - } - } - - #[must_use] - pub fn public_prefix(&self) -> String { - self.lock().public_prefix.clone() - } - - #[must_use] - pub fn get_val(&self, key: &str) -> Option { - self.lock().resolve(key) - } - - pub fn replace_string_layer( - &self, - name: impl Into, - _source: impl Into, - priority: i64, - values: BTreeMap, - ) { - let name = name.into(); - let mut state = self.lock(); - let order = match state.layers.get(&name) { - Some(layer) => layer.order, - None => { - let order = state.next_layer_order; - state.next_layer_order += 1; - order - } - }; - let entries = values - .into_iter() - .map(|(key, value)| (key, RawEntry { value })) - .collect(); - state.layers.insert( - name, - Layer { - priority, - order, - entries, - }, - ); - } - - fn lock(&self) -> std::sync::MutexGuard<'_, State> { - self.state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } -} diff --git a/scripts/cargo-workspace-e2e.sh b/scripts/cargo-workspace-e2e.sh deleted file mode 100755 index d61fe38..0000000 --- a/scripts/cargo-workspace-e2e.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${ZED_INTERFACES_REF:?ZED_INTERFACES_REF is required}" -: "${ZED_CLI_REF:?ZED_CLI_REF is required}" -: "${RUST_LIB_REF:?RUST_LIB_REF is required}" - -ROOT="${GITHUB_WORKSPACE:-$(pwd)}" -FIXTURE="$ROOT/fixtures/cargo-workspace" -TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}/zed-cargo-workspace}" -CONTEXT="$TMP/context" -REGISTRY="$TMP/registry" -ZED_HOME="$TMP/zed-home" -CARGO_HOME_DIR="$TMP/cargo-home" -LIB="$TMP/rust-lib" -IMAGE="zed-pkg-test/cargo-workspace:${GITHUB_SHA:-local}" -HOST_UID="$(id -u)" -HOST_GID="$(id -g)" - -rm -rf "$CONTEXT" "$REGISTRY" "$ZED_HOME" "$CARGO_HOME_DIR" "$LIB" -mkdir -p "$CONTEXT" "$REGISTRY" "$ZED_HOME" "$CARGO_HOME_DIR" - -clone_at() { - local url="$1" ref="$2" destination="$3" - git init -q "$destination" - git -C "$destination" remote add origin "$url" - git -C "$destination" fetch --depth 1 origin "$ref" - git -C "$destination" checkout -q --detach FETCH_HEAD - test "$(git -C "$destination" rev-parse HEAD)" = "$ref" -} - -clone_at https://github.com/zed-pkg/zed-interfaces.git "$ZED_INTERFACES_REF" "$CONTEXT/zed-interfaces" -clone_at https://github.com/zed-pkg/zed-cli.git "$ZED_CLI_REF" "$CONTEXT/zed-cli" -python3 - "$CONTEXT/zed-cli/Cargo.toml" "$ZED_INTERFACES_REF" <<'PY' -import sys -import tomllib -from pathlib import Path - -manifest_path = Path(sys.argv[1]) -expected = sys.argv[2] -with manifest_path.open("rb") as handle: - manifest = tomllib.load(handle) -dependency = manifest["dependencies"]["zed-interfaces"] -assert dependency["git"] == "https://github.com/zed-pkg/zed-interfaces.git" -assert dependency["rev"] == expected -PY -grep -Fq "$ZED_INTERFACES_REF" "$CONTEXT/zed-cli/Cargo.lock" -docker build -q -f "$ROOT/.github/docker/Dockerfile" -t "$IMAGE" "$CONTEXT" - -clone_at https://github.com/zed-pkg-test/rust-lib.git "$RUST_LIB_REF" "$LIB" -docker run --rm \ - --user "$HOST_UID:$HOST_GID" \ - -e HOME=/tmp/zed-user \ - -v "$LIB:/source:ro" \ - -v "$REGISTRY:/registry" \ - -v "$CARGO_HOME_DIR:/cargo-home" \ - -w /tmp "$IMAGE" sh -euc ' - mkdir -p "$HOME" - cp -a /source /tmp/package - chmod -R u+w /tmp/package - cd /tmp/package - CARGO_HOME=/cargo-home cargo test --quiet --all-features - CARGO_HOME=/cargo-home zed publish --registry file:///registry --skip-vcs-checks - ' -test -z "$(git -C "$LIB" status --porcelain)" -test -n "$(find "$REGISTRY" -type f -print -quit)" - -rm -rf "$FIXTURE/.vendor" "$FIXTURE/.zed" "$FIXTURE/.zpkg.lock" "$FIXTURE/target" -CARGO_HASH="$(git -C "$ROOT" hash-object fixtures/cargo-workspace/Cargo.lock)" - -# Development layout: product-generated Cargo wiring must match the fragment the -# consumer deliberately merged into .cargo/config.toml. -docker run --rm \ - --user "$HOST_UID:$HOST_GID" \ - -e HOME=/tmp/zed-user \ - -v "$FIXTURE:/work" \ - -v "$REGISTRY:/registry:ro" \ - -v "$ZED_HOME:/zed-home" \ - -v "$CARGO_HOME_DIR:/cargo-home" \ - -w /work "$IMAGE" sh -euc ' - mkdir -p "$HOME" - zed install --registry file:///registry --home /zed-home --install-mode symlink - test -L .vendor/.zed/zed-pkg-test/rust-lib - test -f .zpkg.lock - test -f .zed/cargo-paths.toml - test -f .zed/paths.json - grep -Fq "zed-pkg-test/rust-lib" .zed/paths.json - python3 - <<"PY" -import tomllib -from pathlib import Path -with Path(".zed/cargo-paths.toml").open("rb") as handle: - generated = tomllib.load(handle) -with Path(".cargo/config.toml").open("rb") as handle: - consumed = tomllib.load(handle) -assert generated["paths"] == consumed["paths"] -assert generated["patch"]["crates-io"] == consumed["patch"]["crates-io"] -PY - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo run --locked -q -p workspace-app - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo test --locked -q --workspace --all-targets - ' -test "$(git -C "$ROOT" hash-object fixtures/cargo-workspace/Cargo.lock)" = "$CARGO_HASH" -ZPKG_HASH="$(sha256sum "$FIXTURE/.zpkg.lock" | cut -d ' ' -f1)" - -# The symlink must not appear portable when its Zed store is absent. -if docker run --rm --network none \ - --user "$HOST_UID:$HOST_GID" \ - -e HOME=/tmp/cargo-user \ - -v "$FIXTURE:/work:ro" \ - -v "$CARGO_HOME_DIR:/cargo-home" \ - -w /work rust:1.97-bookworm sh -euc ' - mkdir -p "$HOME" - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo run --locked --offline -p workspace-app - ' -then - echo "expected missing Zed store to break symlink mode" >&2 - exit 1 -fi - -# Frozen copy mode must preserve both locks and run with no Zed store, registry, -# or network after Cargo's registry cache is warm. -docker run --rm \ - --user "$HOST_UID:$HOST_GID" \ - -e HOME=/tmp/zed-user \ - -v "$FIXTURE:/work" \ - -v "$REGISTRY:/registry:ro" \ - -v "$ZED_HOME:/zed-home" \ - -v "$CARGO_HOME_DIR:/cargo-home" \ - -w /work "$IMAGE" sh -euc ' - mkdir -p "$HOME" - zed install --frozen --registry file:///registry --home /zed-home --install-mode copy - test ! -L .vendor/.zed/zed-pkg-test/rust-lib - test -z "$(find .vendor/.zed -type l -print -quit)" - ' -test "$(git -C "$ROOT" hash-object fixtures/cargo-workspace/Cargo.lock)" = "$CARGO_HASH" -test "$(sha256sum "$FIXTURE/.zpkg.lock" | cut -d ' ' -f1)" = "$ZPKG_HASH" - -docker run --rm --network none \ - --user "$HOST_UID:$HOST_GID" \ - -e HOME=/tmp/cargo-user \ - -v "$FIXTURE:/work:ro" \ - -v "$CARGO_HOME_DIR:/cargo-home" \ - -w /work rust:1.97-bookworm sh -euc ' - mkdir -p "$HOME" - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo run --locked --offline -q -p workspace-app - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo test --locked --offline -q --workspace --all-targets - ' -test "$(git -C "$ROOT" hash-object fixtures/cargo-workspace/Cargo.lock)" = "$CARGO_HASH" -test "$(sha256sum "$FIXTURE/.zpkg.lock" | cut -d ' ' -f1)" = "$ZPKG_HASH" - -rm -rf "$FIXTURE/.vendor" "$FIXTURE/.zed" "$FIXTURE/.zpkg.lock" "$FIXTURE/target" -test -z "$(git -C "$ROOT" status --porcelain --untracked-files=all)" diff --git a/scripts/verify-rust-lib-main-ancestry.sh b/scripts/verify-rust-lib-main-ancestry.sh deleted file mode 100755 index a26e528..0000000 --- a/scripts/verify-rust-lib-main-ancestry.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${RUST_LIB_REF:?RUST_LIB_REF is required}" -: "${RUST_LIB_REVIEWED_MERGE_REF:?RUST_LIB_REVIEWED_MERGE_REF is required}" - -RUST_LIB_REPOSITORY="${RUST_LIB_REPOSITORY:-https://github.com/zed-pkg-test/rust-lib.git}" -EVIDENCE_PATH="${1:-}" - -for name in RUST_LIB_REF RUST_LIB_REVIEWED_MERGE_REF; do - value="${!name}" - if [[ ! "$value" =~ ^[0-9a-f]{40}$ ]]; then - echo "$name must be a lowercase 40-character Git commit" >&2 - exit 2 - fi -done - -TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/rust-lib-main-ancestry.XXXXXXXX")" -trap 'rm -rf "$TEMP_ROOT"' EXIT HUP INT TERM -CHECKOUT="$TEMP_ROOT/repository" - -git init -q "$CHECKOUT" -git -C "$CHECKOUT" remote add origin "$RUST_LIB_REPOSITORY" -git -C "$CHECKOUT" fetch --quiet --no-tags --prune \ - origin refs/heads/main:refs/remotes/origin/main - -# Fetch the certified ref explicitly as well. On hosts that permit fetching a -# reachable object by SHA this lets the ancestry check distinguish a fetchable -# side-branch commit from one that actually passed through reviewed main. -git -C "$CHECKOUT" fetch --quiet --no-tags origin "$RUST_LIB_REF" || true - -MAIN_TIP="$(git -C "$CHECKOUT" rev-parse refs/remotes/origin/main)" -for ref in "$RUST_LIB_REF" "$RUST_LIB_REVIEWED_MERGE_REF"; do - if ! git -C "$CHECKOUT" cat-file -e "$ref^{commit}" 2>/dev/null; then - echo "required rust-lib commit is not present in fetched history" >&2 - exit 1 - fi -done - -read -r -a merge_line <<<"$(git -C "$CHECKOUT" rev-list --parents -n 1 "$RUST_LIB_REVIEWED_MERGE_REF")" -if (( ${#merge_line[@]} < 3 )); then - echo "reviewed rust-lib integration ref is not a merge commit" >&2 - exit 1 -fi -if ! git -C "$CHECKOUT" merge-base --is-ancestor \ - "$RUST_LIB_REF" "$RUST_LIB_REVIEWED_MERGE_REF" -then - echo "certified rust-lib ref is not an ancestor of the reviewed merge" >&2 - exit 1 -fi -if ! git -C "$CHECKOUT" merge-base --is-ancestor \ - "$RUST_LIB_REVIEWED_MERGE_REF" "$MAIN_TIP" -then - echo "reviewed rust-lib merge is not an ancestor of rust-lib/main" >&2 - exit 1 -fi - -if [[ -n "$EVIDENCE_PATH" ]]; then - mkdir -p "$(dirname "$EVIDENCE_PATH")" - python3 - \ - "$EVIDENCE_PATH" \ - "$RUST_LIB_REF" \ - "$RUST_LIB_REVIEWED_MERGE_REF" \ - "$MAIN_TIP" \ - "$RUST_LIB_REPOSITORY" <<'PY' -import json -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -payload = { - "schema": "zed-pkg-test/rust-lib-main-ancestry/v2", - "repository": sys.argv[5], - "certified_ref": sys.argv[2], - "reviewed_merge_ref": sys.argv[3], - "observed_main_tip": sys.argv[4], - "certified_ref_is_reviewed_merge_ancestor": True, - "reviewed_merge_is_main_ancestor": True, -} -path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") -PY -fi - -printf 'certified rust-lib ref %s reached main through reviewed merge %s; observed tip %s\n' \ - "$RUST_LIB_REF" "$RUST_LIB_REVIEWED_MERGE_REF" "$MAIN_TIP" diff --git a/scripts/zed-e2e.sh b/scripts/zed-e2e.sh deleted file mode 100644 index cd46060..0000000 --- a/scripts/zed-e2e.sh +++ /dev/null @@ -1,266 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${ZED_INTERFACES_REF:?ZED_INTERFACES_REF is required}" -: "${ZED_CLI_REF:?ZED_CLI_REF is required}" -: "${RUST_LIB_REF:?RUST_LIB_REF is required}" - -WORKSPACE="${GITHUB_WORKSPACE:-$(pwd)}" -TEMP_ROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}/zed-pkg-test-rust}" -RUN_ID="${GITHUB_SHA:-local}" -CONTEXT="$TEMP_ROOT/zed-docker-context" -REGISTRY="$TEMP_ROOT/zed-registry" -HOME_DIR="$TEMP_ROOT/zed-home" -CARGO_HOME_DIR="$TEMP_ROOT/cargo-home" -EVIDENCE="$TEMP_ROOT/evidence" -RUST_LIB="$TEMP_ROOT/rust-lib" -DRIFT="$TEMP_ROOT/cargo-lock-drift" -IMAGE="zed-pkg-test/rust-cargo-e2e:$RUN_ID" - -rm -rf \ - "$CONTEXT" \ - "$REGISTRY" \ - "$HOME_DIR" \ - "$CARGO_HOME_DIR" \ - "$EVIDENCE" \ - "$RUST_LIB" \ - "$DRIFT" -mkdir -p "$CONTEXT" "$REGISTRY" "$HOME_DIR" "$CARGO_HOME_DIR" "$EVIDENCE" - -clone_at() { - local repo="$1" - local ref="$2" - local destination="$3" - git init -q "$destination" - git -C "$destination" remote add origin "$repo" - git -C "$destination" fetch --depth 1 origin "$ref" - git -C "$destination" checkout -q --detach FETCH_HEAD - test "$(git -C "$destination" rev-parse HEAD)" = "$ref" -} - -assert_lock_unchanged() { - local expected="$1" - test "$(git -C "$WORKSPACE" hash-object Cargo.lock)" = "$expected" -} - -cd "$WORKSPACE" - -test -f Cargo.lock -test ! -e .zpkg.lock - -# Cargo and Zed describe complementary ownership domains. -docker run --rm -i \ - --volume "$WORKSPACE:/work:ro" \ - --workdir /work \ - python:3.12-bookworm \ - python3 - <<'PY' -import tomllib -from pathlib import Path - -with Path("Cargo.toml").open("rb") as handle: - cargo = tomllib.load(handle) -with Path(".zpkg.toml").open("rb") as handle: - zed = tomllib.load(handle) - -package = cargo["package"] -zed_package = zed["package"] -assert package["name"] == zed_package["name"] == "rust-app" -assert package["version"] == zed_package["version"] == "0.2.0" -assert package["description"] == zed_package["description"] -assert package["license"] == zed_package["license"] -assert package["publish"] is False -assert package["resolver"] == "2" -assert package["rust-version"] == "1.88" -assert zed_package["org"] == "zed-pkg-test" - -normal = cargo["dependencies"]["rust-lib"] -build = cargo["build-dependencies"]["rust-lib"] -assert normal["path"] == ".vendor/.zed/zed-pkg-test/rust-lib" -assert normal["features"] == ["uppercase"] -assert build["path"] == normal["path"] -assert build["default-features"] is False -assert cargo["dependencies"]["unicode-ident"] == "=1.0.24" -assert zed["dependencies"] == {"zed-pkg-test/rust-lib": "^1.0.1"} -PY - -clone_at https://github.com/zed-pkg/zed-interfaces.git \ - "$ZED_INTERFACES_REF" "$CONTEXT/zed-interfaces" -clone_at https://github.com/zed-pkg/zed-cli.git \ - "$ZED_CLI_REF" "$CONTEXT/zed-cli" - -docker build \ - --file "$WORKSPACE/.github/docker/Dockerfile" \ - --tag "$IMAGE" \ - "$CONTEXT" - -clone_at https://github.com/zed-pkg-test/rust-lib.git \ - "$RUST_LIB_REF" "$RUST_LIB" -test -z "$(git -C "$RUST_LIB" status --porcelain)" - -# The Zed-distributed crate is independently valid under both Cargo feature sets. -docker run --rm \ - --volume "$RUST_LIB:/source:ro" \ - --volume "$CARGO_HOME_DIR:/cargo-home" \ - --workdir /tmp \ - rust:1.97-bookworm \ - sh -euc ' - cp -a /source /tmp/package - chmod -R u+w /tmp/package - cd /tmp/package - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo test --quiet - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo test --quiet --all-features - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo package --allow-dirty --quiet - test ! -e .zpkg.lock - ' -test -z "$(git -C "$RUST_LIB" status --porcelain)" -test "$(git -C "$RUST_LIB" rev-parse HEAD)" = "$RUST_LIB_REF" - -# Publish only to a disposable file registry. -docker run --rm \ - --volume "$RUST_LIB:/source:ro" \ - --volume "$REGISTRY:/registry" \ - --volume "$CARGO_HOME_DIR:/cargo-home" \ - --workdir /tmp \ - "$IMAGE" \ - sh -euc ' - cp -a /source /tmp/package - chmod -R u+w /tmp/package - cd /tmp/package - CARGO_HOME=/cargo-home zed publish \ - --registry file:///registry \ - --skip-vcs-checks - ' -test -z "$(git -C "$RUST_LIB" status --porcelain)" -test "$(git -C "$RUST_LIB" rev-parse HEAD)" = "$RUST_LIB_REF" -if ! find "$REGISTRY" -type f -print -quit | grep -q .; then - echo "zed publish produced no registry artifact" >&2 - exit 1 -fi - -rm -rf .vendor/.zed .zed .zpkg.lock target -CARGO_LOCK_HASH="$(git hash-object Cargo.lock)" - -# Symlink mode proves the store-backed development layout and warms Cargo's -# crates.io cache. Neither tool may rewrite the other tool's lockfile. -docker run --rm \ - --volume "$WORKSPACE:/work" \ - --volume "$REGISTRY:/registry:ro" \ - --volume "$HOME_DIR:/zed-home" \ - --volume "$CARGO_HOME_DIR:/cargo-home" \ - --volume "$EVIDENCE:/evidence" \ - --workdir /work \ - "$IMAGE" \ - sh -euc ' - zed install \ - --registry file:///registry \ - --home /zed-home \ - --install-mode symlink - test -L .vendor/.zed/zed-pkg-test/rust-lib - test -f .zpkg.lock - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo run --locked --quiet - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo test --locked --all-targets --quiet - CARGO_HOME=/cargo-home cargo metadata --locked --format-version 1 > /evidence/metadata.json - ' -assert_lock_unchanged "$CARGO_LOCK_HASH" -test -f .zpkg.lock -ZPKG_LOCK_HASH="$(sha256sum .zpkg.lock | cut -d ' ' -f1)" - -python3 - "$EVIDENCE/metadata.json" <<'PY' -import json -import sys -from pathlib import Path - -metadata = json.loads(Path(sys.argv[1]).read_text()) -packages = {package["name"]: package for package in metadata["packages"]} -assert packages["rust-app"]["version"] == "0.2.0" -assert packages["rust-lib"]["version"] == "1.0.1" -assert packages["rust-lib"]["source"] is None -assert packages["itoa"]["version"] == "1.0.18" -assert packages["unicode-ident"]["version"] == "1.0.24" -assert packages["itoa"]["source"].startswith("registry+") -assert packages["unicode-ident"]["source"].startswith("registry+") - -resolve = metadata["resolve"] -root = next(node for node in resolve["nodes"] if node["id"] == resolve["root"]) -# Cargo metadata uses crate aliases in resolve.nodes[].deps[].name, not package -# spellings. Hyphenated package names therefore appear with underscores here. -dep_names = {dependency["name"] for dependency in root["deps"]} -assert dep_names == {"rust_lib", "unicode_ident"} -PY -assert_lock_unchanged "$CARGO_LOCK_HASH" -test "$(sha256sum .zpkg.lock | cut -d ' ' -f1)" = "$ZPKG_LOCK_HASH" - -# A store-backed symlink is intentionally not portable without the Zed home. -if docker run --rm --network none \ - --volume "$WORKSPACE:/work:ro" \ - --volume "$CARGO_HOME_DIR:/cargo-home" \ - --workdir /work \ - rust:1.97-bookworm \ - sh -euc 'CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo run --locked --offline --quiet' -then - echo "expected the unmounted store-backed symlink to fail" >&2 - exit 1 -fi -assert_lock_unchanged "$CARGO_LOCK_HASH" -test "$(sha256sum .zpkg.lock | cut -d ' ' -f1)" = "$ZPKG_LOCK_HASH" - -# Frozen copy mode replaces the symlink with portable source while preserving -# both lockfiles byte-for-byte. -docker run --rm \ - --volume "$WORKSPACE:/work" \ - --volume "$REGISTRY:/registry:ro" \ - --volume "$HOME_DIR:/zed-home" \ - --volume "$CARGO_HOME_DIR:/cargo-home" \ - --workdir /work \ - "$IMAGE" \ - sh -euc ' - zed install \ - --frozen \ - --registry file:///registry \ - --home /zed-home \ - --install-mode copy - test ! -L .vendor/.zed/zed-pkg-test/rust-lib - test -z "$(find .vendor/.zed -type l -print -quit)" - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo run --locked --quiet - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo test --locked --all-targets --quiet - ' -assert_lock_unchanged "$CARGO_LOCK_HASH" -test "$(sha256sum .zpkg.lock | cut -d ' ' -f1)" = "$ZPKG_LOCK_HASH" - -# The copied package and warmed Cargo cache work in a fresh, network-disabled -# Rust container with no Zed store or registry mounted. -docker run --rm --network none \ - --volume "$WORKSPACE:/work:ro" \ - --volume "$CARGO_HOME_DIR:/cargo-home" \ - --workdir /work \ - rust:1.97-bookworm \ - sh -euc ' - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo run --locked --offline --quiet - CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo test --locked --offline --all-targets --quiet - ' -assert_lock_unchanged "$CARGO_LOCK_HASH" -test "$(sha256sum .zpkg.lock | cut -d ' ' -f1)" = "$ZPKG_LOCK_HASH" - -# Cargo requirement drift is rejected by --locked and cannot mutate the Zed -# lock. This uses an isolated copy so the reviewed source remains unchanged. -mkdir -p "$DRIFT" -git archive HEAD | tar -x -C "$DRIFT" -cp -a .vendor "$DRIFT/.vendor" -cp .zpkg.lock "$DRIFT/.zpkg.lock" -sed -i 's/unicode-ident = "=1.0.24"/unicode-ident = "=1.0.23"/' "$DRIFT/Cargo.toml" -DRIFT_ZPKG_HASH="$(sha256sum "$DRIFT/.zpkg.lock" | cut -d ' ' -f1)" -if docker run --rm --network none \ - --volume "$DRIFT:/work" \ - --volume "$CARGO_HOME_DIR:/cargo-home" \ - --workdir /work \ - rust:1.97-bookworm \ - sh -euc 'CARGO_HOME=/cargo-home CARGO_TARGET_DIR=/tmp/target cargo check --locked --offline' -then - echo "expected Cargo.lock drift to fail under --locked" >&2 - exit 1 -fi -test "$(sha256sum "$DRIFT/.zpkg.lock" | cut -d ' ' -f1)" = "$DRIFT_ZPKG_HASH" - -assert_lock_unchanged "$CARGO_LOCK_HASH" -test "$(sha256sum .zpkg.lock | cut -d ' ' -f1)" = "$ZPKG_LOCK_HASH" -test -z "$(git status --porcelain --untracked-files=all)" diff --git a/src/admission.rs b/src/admission.rs new file mode 100644 index 0000000..f44d570 --- /dev/null +++ b/src/admission.rs @@ -0,0 +1,888 @@ +use crate::error::{OrmError, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +pub const TJSV_CONTRACT_IR_SCHEMA: &str = "ores.typespec-json-schema-validator.contract-ir/v1"; +pub const TJSV_PARITY_REPORT_SCHEMA: &str = "ores.typespec-json-schema-validator.report/v1"; +pub const TJSV_PROJECTION_MANIFEST_SCHEMA: &str = + "ores.typespec-json-schema-validator.projection-manifest/v1"; +pub const TJSV_PROJECTION_VERIFICATION_RECEIPT_SCHEMA: &str = + "ores.typespec-json-schema-validator.projection-verification-receipt/v1"; +pub const TJSV_ADMISSION_BINDING_SCHEMA: &str = "ores.orm-core.tjsv-admission-binding/v2"; +pub const REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS: &[&str] = &[ + "dart-json", + "gleam-erlang", + "gleam-js", + "rust-serde", + "typescript-zod", +]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TjsvOutputBinding { + pub path: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TjsvAdmissionBinding { + schema: String, + projection_id: String, + contract_ir_id: String, + contract_ir_sha256: String, + projection_manifest_id: String, + projection_manifest_sha256: String, + projection_verification_id: String, + projection_verification_sha256: String, + parity_receipt_run_id: String, + evidence_digest: String, + declaration_ids: Vec, + runtime_validator_ids: Vec, + outputs: Vec, + #[serde(skip)] + verified: bool, +} + +impl TjsvAdmissionBinding { + #[must_use] + pub const fn is_verified(&self) -> bool { + return self.verified; + } + + #[must_use] + pub fn projection_id(&self) -> &str { + return &self.projection_id; + } + + #[must_use] + pub fn contract_ir_sha256(&self) -> &str { + return &self.contract_ir_sha256; + } + + #[must_use] + pub fn projection_manifest_sha256(&self) -> &str { + return &self.projection_manifest_sha256; + } + + #[must_use] + pub fn projection_verification_sha256(&self) -> &str { + return &self.projection_verification_sha256; + } + + #[must_use] + pub fn declaration_ids(&self) -> &[String] { + return &self.declaration_ids; + } + + #[must_use] + pub fn runtime_validator_ids(&self) -> &[String] { + return &self.runtime_validator_ids; + } + + #[must_use] + pub fn outputs(&self) -> &[TjsvOutputBinding] { + return &self.outputs; + } + + #[must_use] + pub fn projection_manifest_id(&self) -> &str { + return &self.projection_manifest_id; + } + + #[must_use] + pub fn projection_verification_id(&self) -> &str { + return &self.projection_verification_id; + } + + #[must_use] + pub fn contract_ir_id(&self) -> &str { + return &self.contract_ir_id; + } + + #[must_use] + pub fn parity_receipt_run_id(&self) -> &str { + return &self.parity_receipt_run_id; + } + + #[must_use] + pub fn evidence_digest(&self) -> &str { + return &self.evidence_digest; + } + + #[must_use] + pub fn schema(&self) -> &str { + return &self.schema; + } + + pub fn verify( + contract_ir_bytes: &[u8], + projection_manifest_bytes: &[u8], + projection_receipt_bytes: &[u8], + expected_projection_id: &str, + expected_declaration_ids: &[String], + ) -> Result { + if contract_ir_bytes.is_empty() + || projection_manifest_bytes.is_empty() + || projection_receipt_bytes.is_empty() + { + return Err(OrmError::Invalid( + "TJSV admission requires non-empty Contract IR, projection manifest, and projection verification receipt bytes" + .to_owned(), + )); + } + if !valid_identifier(expected_projection_id) { + return Err(OrmError::Invalid( + "expected TJSV projection id is not a valid projection identifier".to_owned(), + )); + } + + let expected_declarations = + normalize_expected_ids(expected_declaration_ids, "expected declaration ids")?; + let expected_runtime_validators = REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS + .iter() + .map(|value| (*value).to_owned()) + .collect::>(); + + let contract_ir = parse_json(contract_ir_bytes, "Contract IR")?; + let contract = contract_ir.as_object().ok_or_else(|| { + OrmError::Invalid("TJSV Contract IR must be a JSON object".to_owned()) + })?; + verify_contract_ir(contract, &contract_ir)?; + + let contract_ir_id = digest_field(contract, "irId", "Contract IR")?; + let contract_ir_digest = canonical_digest(&contract_ir)?; + let admission = object_field(contract, "admission", "Contract IR")?; + let parity = object_field(admission, "receipt", "Contract IR admission")?; + let parity_receipt_run_id = digest_field(parity, "runId", "Contract IR parity binding")?; + let parity_receipt_digest = digest_field(parity, "digest", "Contract IR parity binding")?; + let source_digests = contract_source_digests(contract)?; + + let projection_manifest = parse_json(projection_manifest_bytes, "projection manifest")?; + let manifest = projection_manifest.as_object().ok_or_else(|| { + OrmError::Invalid("TJSV projection manifest must be a JSON object".to_owned()) + })?; + require_string( + manifest, + "schema", + TJSV_PROJECTION_MANIFEST_SCHEMA, + "projection manifest", + )?; + require_string(manifest, "status", "passed", "projection manifest")?; + let projection_manifest_id = digest_field(manifest, "manifestId", "projection manifest")?; + if canonical_digest_without(&projection_manifest, "manifestId")? != projection_manifest_id { + return Err(OrmError::Invalid( + "TJSV projection manifest self-digest does not match manifestId".to_owned(), + )); + } + + let manifest_contract = object_field(manifest, "contract", "projection manifest")?; + require_digest_value( + manifest_contract, + "contractIrId", + &contract_ir_id, + "projection manifest contract", + )?; + require_digest_value( + manifest_contract, + "contractIrDigest", + &contract_ir_digest, + "projection manifest contract", + )?; + require_digest_value( + manifest_contract, + "receiptRunId", + &parity_receipt_run_id, + "projection manifest contract", + )?; + require_digest_value( + manifest_contract, + "receiptDigest", + &parity_receipt_digest, + "projection manifest contract", + )?; + verify_source_digests( + object_field( + manifest_contract, + "sourceDigests", + "projection manifest contract", + )?, + &source_digests, + "projection manifest contract sourceDigests", + )?; + + let manifest_declarations = normalize_ids( + string_array_field(manifest, "declarations", "projection manifest")?, + "projection manifest declarations", + true, + )?; + for declaration_id in &expected_declarations { + if !manifest_declarations.contains(declaration_id) { + return Err(OrmError::Invalid(format!( + "TJSV projection manifest does not contain expected declaration {declaration_id}" + ))); + } + } + + let target = projection_target(manifest, expected_projection_id)?; + let target_declarations = normalize_ids( + string_array_field(target, "declarationIds", "projection target")?, + "projection declaration ids", + false, + )?; + if target_declarations != expected_declarations { + return Err(OrmError::Invalid(format!( + "TJSV projection declaration scope does not match expected scope: expected {expected_declarations:?}, got {target_declarations:?}" + ))); + } + + let runtime_validator_ids = normalize_ids( + string_array_field(target, "runtimeValidatorIds", "projection target")?, + "projection runtime validator ids", + false, + )?; + if runtime_validator_ids != expected_runtime_validators { + return Err(OrmError::Invalid(format!( + "TJSV projection runtime-validator scope does not match expected scope: expected {expected_runtime_validators:?}, got {runtime_validator_ids:?}" + ))); + } + + verify_runtime_validators(manifest, expected_projection_id, &runtime_validator_ids)?; + let outputs = projection_outputs(manifest, target, expected_projection_id)?; + + let projection_receipt = + parse_json(projection_receipt_bytes, "projection verification receipt")?; + let receipt = projection_receipt.as_object().ok_or_else(|| { + OrmError::Invalid( + "TJSV projection verification receipt must be a JSON object".to_owned(), + ) + })?; + require_string( + receipt, + "schema", + TJSV_PROJECTION_VERIFICATION_RECEIPT_SCHEMA, + "projection verification receipt", + )?; + require_string( + receipt, + "status", + "passed", + "projection verification receipt", + )?; + require_bool( + receipt, + "admissible", + true, + "projection verification receipt", + )?; + if receipt.get("failureCode") != Some(&Value::Null) { + return Err(OrmError::Invalid( + "passed TJSV projection verification receipt must have null failureCode".to_owned(), + )); + } + let finding_rule_ids = + string_array_field(receipt, "findingRuleIds", "projection verification receipt")?; + if !finding_rule_ids.is_empty() { + return Err(OrmError::Invalid( + "passed TJSV projection verification receipt must not contain findings".to_owned(), + )); + } + + let projection_verification_id = + digest_field(receipt, "verificationId", "projection verification receipt")?; + if canonical_digest_without(&projection_receipt, "verificationId")? + != projection_verification_id + { + return Err(OrmError::Invalid( + "TJSV projection verification receipt self-digest does not match verificationId" + .to_owned(), + )); + } + require_digest_value( + receipt, + "manifestId", + &projection_manifest_id, + "projection verification receipt", + )?; + require_digest_value( + receipt, + "contractIrId", + &contract_ir_id, + "projection verification receipt", + )?; + require_digest_value( + receipt, + "receiptRunId", + &parity_receipt_run_id, + "projection verification receipt", + )?; + let evidence_digest = + digest_field(receipt, "evidenceDigest", "projection verification receipt")?; + verify_source_digests( + object_field(receipt, "sourceDigests", "projection verification receipt")?, + &source_digests, + "projection verification receipt sourceDigests", + )?; + verify_receipt_summary(receipt, manifest)?; + + return Ok(Self { + schema: TJSV_ADMISSION_BINDING_SCHEMA.to_owned(), + projection_id: expected_projection_id.to_owned(), + contract_ir_id, + contract_ir_sha256: digest(contract_ir_bytes), + projection_manifest_id, + projection_manifest_sha256: digest(projection_manifest_bytes), + projection_verification_id, + projection_verification_sha256: digest(projection_receipt_bytes), + parity_receipt_run_id, + evidence_digest, + declaration_ids: expected_declarations, + runtime_validator_ids, + outputs, + verified: true, + }); + } +} + +fn verify_contract_ir( + contract: &serde_json::Map, + contract_ir: &Value, +) -> Result<()> { + require_string(contract, "schema", TJSV_CONTRACT_IR_SCHEMA, "Contract IR")?; + require_string(contract, "status", "passed", "Contract IR")?; + require_bool(contract, "admissible", true, "Contract IR")?; + require_string( + contract, + "role", + "downstream-derived-parity-artifact", + "Contract IR", + )?; + require_bool(contract, "editableAuthority", false, "Contract IR")?; + + let authorities = object_field(contract, "authorities", "Contract IR")?; + require_string( + authorities, + "typespec", + "independently-authored", + "Contract IR authorities", + )?; + require_string( + authorities, + "jsonSchema", + "independently-authored", + "Contract IR authorities", + )?; + require_string( + authorities, + "generatedJsonSchema", + "comparison-evidence-only", + "Contract IR authorities", + )?; + require_string(authorities, "precedence", "none", "Contract IR authorities")?; + + let contract_ir_id = digest_field(contract, "irId", "Contract IR")?; + if canonical_digest_without(contract_ir, "irId")? != contract_ir_id { + return Err(OrmError::Invalid( + "TJSV Contract IR self-digest does not match irId".to_owned(), + )); + } + + let admission = object_field(contract, "admission", "Contract IR")?; + let parity = object_field(admission, "receipt", "Contract IR admission")?; + require_string( + parity, + "schema", + TJSV_PARITY_REPORT_SCHEMA, + "Contract IR parity binding", + )?; + require_string(parity, "status", "passed", "Contract IR parity binding")?; + require_bool( + parity, + "zeroUnexplainedFindings", + true, + "Contract IR parity binding", + )?; + digest_field(parity, "runId", "Contract IR parity binding")?; + digest_field(parity, "digest", "Contract IR parity binding")?; + + let requirements = object_field(admission, "requirements", "Contract IR admission")?; + for key in [ + "exactInputDigests", + "directDeclarationInventory", + "generatedSchemaComparison", + "differentialInstanceValidation", + "zeroUnexplainedFindings", + ] { + require_bool( + requirements, + key, + true, + "Contract IR admission requirements", + )?; + } + + declaration_ids(contract)?; + contract_source_digests(contract)?; + return Ok(()); +} + +fn projection_target<'a>( + manifest: &'a serde_json::Map, + projection_id: &str, +) -> Result<&'a serde_json::Map> { + let projections = manifest + .get("projections") + .and_then(Value::as_array) + .ok_or_else(|| { + OrmError::Invalid("TJSV projection manifest projections must be an array".to_owned()) + })?; + let mut matches = projections + .iter() + .filter_map(Value::as_object) + .filter(|projection| projection.get("id").and_then(Value::as_str) == Some(projection_id)); + let target = matches.next().ok_or_else(|| { + OrmError::Invalid(format!( + "TJSV projection manifest is missing projection {projection_id}" + )) + })?; + if matches.next().is_some() { + return Err(OrmError::Invalid(format!( + "TJSV projection manifest contains duplicate projection {projection_id}" + ))); + } + return Ok(target); +} + +fn projection_outputs( + manifest: &serde_json::Map, + target: &serde_json::Map, + projection_id: &str, +) -> Result> { + let expected_paths = normalize_ids( + string_array_field(target, "outputPaths", "projection target")?, + "projection output paths", + false, + )?; + if expected_paths.is_empty() { + return Err(OrmError::Invalid( + "admitted TJSV projection must contain at least one output".to_owned(), + )); + } + + let outputs = manifest + .get("outputs") + .and_then(Value::as_array) + .ok_or_else(|| { + OrmError::Invalid("TJSV projection manifest outputs must be an array".to_owned()) + })?; + let mut bindings = Vec::new(); + for output in outputs { + let output = output.as_object().ok_or_else(|| { + OrmError::Invalid("TJSV projection manifest output must be an object".to_owned()) + })?; + if output.get("projection").and_then(Value::as_str) != Some(projection_id) { + continue; + } + let path = nonempty_string_field(output, "path", "projection output")?; + let sha256 = digest_field(output, "sha256", "projection output")?; + bindings.push(TjsvOutputBinding { path, sha256 }); + } + bindings.sort_by(|left, right| left.path.cmp(&right.path)); + let actual_paths = bindings + .iter() + .map(|binding| binding.path.clone()) + .collect::>(); + if actual_paths != expected_paths { + return Err(OrmError::Invalid(format!( + "TJSV projection output closure does not match projection target: expected {expected_paths:?}, got {actual_paths:?}" + ))); + } + return Ok(bindings); +} + +fn verify_runtime_validators( + manifest: &serde_json::Map, + projection_id: &str, + expected_ids: &[String], +) -> Result<()> { + let validators = manifest + .get("runtimeValidators") + .and_then(Value::as_array) + .ok_or_else(|| { + OrmError::Invalid( + "TJSV projection manifest runtimeValidators must be an array".to_owned(), + ) + })?; + let mut actual_ids = Vec::new(); + for validator in validators { + let validator = validator.as_object().ok_or_else(|| { + OrmError::Invalid("TJSV runtime validator must be an object".to_owned()) + })?; + if validator.get("projection").and_then(Value::as_str) != Some(projection_id) { + continue; + } + let id = nonempty_string_field(validator, "id", "runtime validator")?; + if !valid_identifier(&id) { + return Err(OrmError::Invalid(format!( + "TJSV runtime validator id {id:?} is invalid" + ))); + } + digest_field(validator, "artifactDigest", "runtime validator")?; + digest_field(validator, "fixtureDigest", "runtime validator")?; + digest_field( + validator, + "ingressEgressCoverageDigest", + "runtime validator", + )?; + nonempty_string_field(validator, "artifactPath", "runtime validator")?; + actual_ids.push(id); + } + actual_ids = normalize_ids(actual_ids, "runtime validator manifest ids", false)?; + if actual_ids != expected_ids { + return Err(OrmError::Invalid(format!( + "TJSV runtime-validator manifest entries do not match projection target: expected {expected_ids:?}, got {actual_ids:?}" + ))); + } + return Ok(()); +} + +fn verify_receipt_summary( + receipt: &serde_json::Map, + manifest: &serde_json::Map, +) -> Result<()> { + let summary = object_field(receipt, "summary", "projection verification receipt")?; + for (key, manifest_key) in [ + ("declarations", "declarations"), + ("projections", "projections"), + ("outputs", "outputs"), + ("representationDeltas", "representationDeltas"), + ("runtimeValidators", "runtimeValidators"), + ] { + let expected = manifest + .get(manifest_key) + .and_then(Value::as_array) + .map(Vec::len) + .ok_or_else(|| { + OrmError::Invalid(format!( + "TJSV projection manifest {manifest_key} must be an array" + )) + })?; + let actual = summary + .get(key) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| { + OrmError::Invalid(format!( + "TJSV projection verification summary {key} must be a non-negative integer" + )) + })?; + if actual != expected { + return Err(OrmError::Invalid(format!( + "TJSV projection verification summary {key} does not match manifest: expected {expected}, got {actual}" + ))); + } + } + return Ok(()); +} + +fn contract_source_digests(contract: &serde_json::Map) -> Result<[String; 3]> { + let provenance = object_field(contract, "provenance", "Contract IR")?; + let typespec = object_field(provenance, "typespec", "Contract IR provenance")?; + let generated = object_field(provenance, "generatedJsonSchema", "Contract IR provenance")?; + let authored = object_field(provenance, "authoredJsonSchema", "Contract IR provenance")?; + return Ok([ + digest_field(typespec, "digest", "Contract IR TypeSpec provenance")?, + digest_field( + generated, + "digest", + "Contract IR generated JSON Schema provenance", + )?, + digest_field( + authored, + "digest", + "Contract IR authored JSON Schema provenance", + )?, + ]); +} + +fn verify_source_digests( + object: &serde_json::Map, + expected: &[String; 3], + label: &str, +) -> Result<()> { + for (index, key) in ["typespec", "generatedJsonSchema", "authoredJsonSchema"] + .iter() + .enumerate() + { + require_digest_value(object, key, &expected[index], label)?; + } + return Ok(()); +} + +fn parse_json(bytes: &[u8], label: &str) -> Result { + return serde_json::from_slice(bytes) + .map_err(|error| OrmError::Invalid(format!("could not parse TJSV {label}: {error}"))); +} + +fn require_string( + object: &serde_json::Map, + key: &str, + expected: &str, + label: &str, +) -> Result<()> { + let actual = object.get(key).and_then(Value::as_str); + if actual != Some(expected) { + return Err(OrmError::Invalid(format!( + "{label} {key} must be {expected:?}" + ))); + } + return Ok(()); +} + +fn require_bool( + object: &serde_json::Map, + key: &str, + expected: bool, + label: &str, +) -> Result<()> { + let actual = object.get(key).and_then(Value::as_bool); + if actual != Some(expected) { + return Err(OrmError::Invalid(format!( + "{label} {key} must be {expected}" + ))); + } + return Ok(()); +} + +fn require_digest_value( + object: &serde_json::Map, + key: &str, + expected: &str, + label: &str, +) -> Result<()> { + let actual = digest_field(object, key, label)?; + if actual != expected { + return Err(OrmError::Invalid(format!( + "{label} {key} does not match expected digest" + ))); + } + return Ok(()); +} + +fn object_field<'a>( + object: &'a serde_json::Map, + key: &str, + label: &str, +) -> Result<&'a serde_json::Map> { + return object + .get(key) + .and_then(Value::as_object) + .ok_or_else(|| OrmError::Invalid(format!("{label} {key} must be a JSON object"))); +} + +fn nonempty_string_field( + object: &serde_json::Map, + key: &str, + label: &str, +) -> Result { + return object + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| OrmError::Invalid(format!("{label} {key} must be a non-empty string"))); +} + +fn digest_field(object: &serde_json::Map, key: &str, label: &str) -> Result { + let value = object + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| OrmError::Invalid(format!("{label} {key} must be a SHA-256 digest")))?; + if !valid_digest(value) { + return Err(OrmError::Invalid(format!( + "{label} {key} must be a lowercase 64-character SHA-256 digest" + ))); + } + return Ok(value.to_owned()); +} + +fn string_array_field( + object: &serde_json::Map, + key: &str, + label: &str, +) -> Result> { + let values = object + .get(key) + .and_then(Value::as_array) + .ok_or_else(|| OrmError::Invalid(format!("{label} {key} must be an array")))?; + return values + .iter() + .map(|value| { + value + .as_str() + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + OrmError::Invalid(format!("{label} {key} entries must be non-empty strings")) + }) + }) + .collect(); +} + +fn declaration_ids(contract: &serde_json::Map) -> Result> { + let declarations = contract + .get("declarations") + .and_then(Value::as_array) + .ok_or_else(|| { + OrmError::Invalid("TJSV Contract IR declarations must be an array".to_owned()) + })?; + let mut ids = BTreeSet::new(); + for declaration in declarations { + let id = declaration + .as_object() + .and_then(|object| object.get("id")) + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + OrmError::Invalid( + "TJSV Contract IR declaration ids must be non-empty strings".to_owned(), + ) + })?; + if !ids.insert(id.to_owned()) { + return Err(OrmError::Invalid(format!( + "TJSV Contract IR contains duplicate declaration id {id}" + ))); + } + } + return Ok(ids); +} + +fn normalize_expected_ids(values: &[String], label: &str) -> Result> { + if values.is_empty() { + return Err(OrmError::Invalid(format!( + "TJSV admission requires at least one {label}" + ))); + } + return normalize_ids(values.to_vec(), label, false); +} + +fn normalize_ids(mut values: Vec, label: &str, allow_empty: bool) -> Result> { + if !allow_empty && values.is_empty() { + return Err(OrmError::Invalid(format!("{label} must not be empty"))); + } + if values.iter().any(|value| value.is_empty()) { + return Err(OrmError::Invalid(format!( + "{label} must not contain empty values" + ))); + } + values.sort(); + let original_len = values.len(); + values.dedup(); + if values.len() != original_len { + return Err(OrmError::Invalid(format!( + "{label} must not contain duplicates" + ))); + } + return Ok(values); +} + +fn canonical_digest_without(value: &Value, excluded_key: &str) -> Result { + let mut value = value.clone(); + let object = value.as_object_mut().ok_or_else(|| { + OrmError::Invalid("canonical TJSV evidence must be a JSON object".to_owned()) + })?; + if object.remove(excluded_key).is_none() { + return Err(OrmError::Invalid(format!( + "canonical TJSV evidence is missing {excluded_key}" + ))); + } + return canonical_digest(&value); +} + +fn canonical_digest(value: &Value) -> Result { + let canonical = canonical_json(value)?; + return Ok(digest(canonical.as_bytes())); +} + +fn canonical_json(value: &Value) -> Result { + let mut out = String::new(); + write_canonical(value, &mut out)?; + return Ok(out); +} + +fn write_canonical(value: &Value, out: &mut String) -> Result<()> { + match value { + Value::Null => out.push_str("null"), + Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }), + Value::Number(value) => out.push_str(&value.to_string()), + Value::String(value) => { + out.push_str(&serde_json::to_string(value).map_err(|error| { + OrmError::Invalid(format!("could not canonicalize JSON string: {error}")) + })?); + } + Value::Array(values) => { + out.push('['); + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push(','); + } + write_canonical(value, out)?; + } + out.push(']'); + } + Value::Object(object) => { + out.push('{'); + let mut keys = object.keys().collect::>(); + keys.sort(); + for (index, key) in keys.iter().enumerate() { + if index > 0 { + out.push(','); + } + out.push_str(&serde_json::to_string(key).map_err(|error| { + OrmError::Invalid(format!("could not canonicalize JSON object key: {error}")) + })?); + out.push(':'); + write_canonical( + object + .get(key.as_str()) + .expect("canonical object key must exist"), + out, + )?; + } + out.push('}'); + } + } + return Ok(()); +} + +fn valid_identifier(value: &str) -> bool { + if value.is_empty() || value.len() > 128 { + return false; + } + let bytes = value.as_bytes(); + if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() { + return false; + } + if bytes.len() > 1 + && !bytes[bytes.len() - 1].is_ascii_lowercase() + && !bytes[bytes.len() - 1].is_ascii_digit() + { + return false; + } + return bytes.iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(*byte, b'.' | b'_' | b'-') + }); +} + +fn valid_digest(value: &str) -> bool { + return value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()); +} + +fn digest(bytes: &[u8]) -> String { + return hex::encode(Sha256::digest(bytes)); +} diff --git a/src/descriptor.rs b/src/descriptor.rs new file mode 100644 index 0000000..141cc1d --- /dev/null +++ b/src/descriptor.rs @@ -0,0 +1,233 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType, Table}; +use crate::policy::{FieldVisibility, Policy}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const ORM_DESCRIPTOR_SCHEMA: &str = "ores.orm-descriptor/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub enum OrmWitness { + #[serde(rename = "diesel")] + Diesel, + #[serde(rename = "seaorm")] + SeaOrm, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DescriptorSource { + pub repository: String, + pub revision: String, + #[serde(rename = "crate")] + pub crate_name: String, + pub toolchain: String, + pub orm: Vec, +} + +impl DescriptorSource { + pub fn new( + repository: impl Into, + revision: impl Into, + crate_name: impl Into, + toolchain: impl Into, + orm: Vec, + ) -> Result { + let repository = repository.into(); + let revision = revision.into(); + let crate_name = crate_name.into(); + let toolchain = toolchain.into(); + + for (label, value) in [ + ("repository", repository.as_str()), + ("crate", crate_name.as_str()), + ("toolchain", toolchain.as_str()), + ] { + if value.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "ORM descriptor source {label} must not be empty" + ))); + } + } + + if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(OrmError::Invalid( + "ORM descriptor source revision must be a 40-character hexadecimal Git revision" + .to_owned(), + )); + } + if orm.is_empty() { + return Err(OrmError::Invalid( + "ORM descriptor must identify at least one ORM witness".to_owned(), + )); + } + let unique_witnesses = orm + .iter() + .copied() + .collect::>(); + if unique_witnesses.len() != orm.len() { + return Err(OrmError::Invalid( + "ORM descriptor source contains duplicate ORM witnesses".to_owned(), + )); + } + + return Ok(Self { + repository, + revision: revision.to_ascii_lowercase(), + crate_name, + toolchain, + orm, + }); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DescriptorType { + pub kind: DescriptorScalar, + pub array: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum DescriptorScalar { + Boolean, + Int16, + Int32, + Int64, + Float32, + Float64, + Decimal, + String, + Uuid, + PlainDate, + UtcDateTime, + Bytes, + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DescriptorField { + pub name: String, + pub wire_name: String, + pub database_name: String, + pub previous_wire_names: Vec, + pub previous_database_names: Vec, + pub r#type: DescriptorType, + pub nullable: bool, + pub generated: bool, + pub defaulted: bool, + pub selectable: bool, + pub insertable: bool, + pub updatable: bool, + pub visibility: FieldVisibility, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DescriptorModel { + pub namespace: String, + pub name: String, + pub table: String, + pub public_surface: bool, + pub fields: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct OrmDescriptor { + pub schema: String, + pub source: DescriptorSource, + pub model: DescriptorModel, +} + +pub fn describe_table( + table: &Table, + policy: &Policy, + namespace: impl Into, + source: DescriptorSource, +) -> Result { + policy.validate_table(table)?; + let table_policy = policy.table_policy(table); + let fields = table + .columns + .iter() + .map(|column| { + let wire_name = table_policy + .wire_names + .get(&column.db_name) + .cloned() + .unwrap_or_else(|| column.db_name.clone()); + + return descriptor_type(&column.ty).map(|r#type| DescriptorField { + name: column.rust_name.clone(), + wire_name, + database_name: column.db_name.clone(), + previous_wire_names: Vec::new(), + previous_database_names: Vec::new(), + r#type, + nullable: column.nullable, + generated: table_policy.generated.contains(&column.db_name), + defaulted: table_policy.defaulted.contains(&column.db_name), + selectable: true, + insertable: !table_policy.generated.contains(&column.db_name), + updatable: !table_policy.generated.contains(&column.db_name) + && !table_policy.immutable.contains(&column.db_name), + visibility: table_policy.visibility(&column.db_name), + }); + }) + .collect::>>()?; + + return Ok(OrmDescriptor { + schema: ORM_DESCRIPTOR_SCHEMA.to_owned(), + source, + model: DescriptorModel { + namespace: namespace.into(), + name: table.db_name.clone(), + table: table.qualified_name(), + public_surface: table_policy.public_surface, + fields, + }, + }); +} + +fn descriptor_type(ty: &OrmType) -> Result { + let (kind, array) = match ty { + OrmType::Scalar(scalar) => (descriptor_scalar(scalar), false), + OrmType::Array(inner) => { + if matches!(inner.as_ref(), OrmType::Array(_)) { + return Err(OrmError::Unsupported( + "nested ORM arrays are not representable in ores.orm-descriptor/v1".to_owned(), + )); + } + let inner = descriptor_type(inner)?; + (inner.kind, true) + } + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before descriptor emission" + ))); + } + }; + + return Ok(DescriptorType { kind, array }); +} + +fn descriptor_scalar(scalar: &ScalarType) -> DescriptorScalar { + return match scalar { + ScalarType::Boolean => DescriptorScalar::Boolean, + ScalarType::Int16 => DescriptorScalar::Int16, + ScalarType::Int32 => DescriptorScalar::Int32, + ScalarType::Int64 => DescriptorScalar::Int64, + ScalarType::Float32 => DescriptorScalar::Float32, + ScalarType::Float64 => DescriptorScalar::Float64, + ScalarType::Decimal => DescriptorScalar::Decimal, + ScalarType::String => DescriptorScalar::String, + ScalarType::Uuid => DescriptorScalar::Uuid, + ScalarType::Date => DescriptorScalar::PlainDate, + ScalarType::DateTime => DescriptorScalar::UtcDateTime, + ScalarType::Bytes => DescriptorScalar::Bytes, + ScalarType::Json => DescriptorScalar::Json, + }; +} diff --git a/src/diesel.rs b/src/diesel.rs new file mode 100644 index 0000000..d673fbf --- /dev/null +++ b/src/diesel.rs @@ -0,0 +1,259 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{Column, OrmIr, OrmType, ScalarType, SourceKind, Table}; +use quote::ToTokens; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Attribute, Ident, Item, Lit, Meta, Token, Type}; + +/// Parse one `diesel print-schema` style Rust source file. +pub fn parse_schema(source: &str) -> Result { + let file = syn::parse_file(source)?; + let mut tables = Vec::new(); + collect_items(&file.items, &mut tables)?; + if tables.is_empty() { + return Err(OrmError::Invalid( + "Diesel input contained no table! declarations".to_owned(), + )); + } + return OrmIr::try_new(SourceKind::Diesel, tables); +} + +fn collect_items(items: &[Item], tables: &mut Vec) -> Result<()> { + for item in items { + match item { + Item::Macro(item_macro) + if item_macro + .mac + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "table") => + { + let parsed = syn::parse2::(item_macro.mac.tokens.clone())?; + tables.push(parsed.into_table()?); + } + Item::Mod(module) => { + if let Some((_, nested)) = &module.content { + collect_items(nested, tables)?; + } + } + _ => {} + } + } + return Ok(()); +} + +#[derive(Debug)] +struct DieselColumn { + attrs: Vec, + name: Ident, + ty: Type, +} + +#[derive(Debug)] +struct DieselTable { + attrs: Vec, + schema_name: Option, + table_name: Ident, + primary_key: Vec, + columns: Vec, +} + +impl Parse for DieselTable { + fn parse(input: ParseStream<'_>) -> syn::Result { + while input.peek(Token![use]) { + let _: syn::ItemUse = input.parse()?; + } + + let attrs = input.call(Attribute::parse_outer)?; + let first: Ident = input.parse()?; + let (schema_name, table_name) = if input.peek(Token![.]) { + input.parse::()?; + (Some(first), input.parse()?) + } else { + (None, first) + }; + + let content; + syn::parenthesized!(content in input); + let primary_key = Punctuated::::parse_terminated(&content)? + .into_iter() + .collect(); + + let body; + syn::braced!(body in input); + let mut columns = Vec::new(); + while !body.is_empty() { + let attrs = body.call(Attribute::parse_outer)?; + let name: Ident = body.parse()?; + body.parse::]>()?; + let ty: Type = body.parse()?; + if body.peek(Token![,]) { + body.parse::()?; + } + columns.push(DieselColumn { attrs, name, ty }); + } + + if !input.is_empty() { + return Err(input.error("unexpected tokens after Diesel table declaration")); + } + + return Ok(Self { + attrs, + schema_name, + table_name, + primary_key, + columns, + }); + } +} + +impl DieselTable { + fn into_table(self) -> Result
{ + let table_db_name = sql_name(&self.attrs)?.unwrap_or_else(|| self.table_name.to_string()); + let primary_keys: Vec = self.primary_key.iter().map(ToString::to_string).collect(); + if primary_keys.is_empty() { + return Err(OrmError::Invalid(format!( + "Diesel table {table_db_name} declares an empty primary-key list" + ))); + } + for (index, key) in primary_keys.iter().enumerate() { + if primary_keys[..index].contains(key) { + return Err(OrmError::Invalid(format!( + "Diesel table {table_db_name} declares duplicate primary-key column {key}" + ))); + } + } + + let mut columns = Vec::with_capacity(self.columns.len()); + for (ordinal, column) in self.columns.into_iter().enumerate() { + let rust_name = column.name.to_string(); + let db_name = sql_name(&column.attrs)?.unwrap_or_else(|| rust_name.clone()); + let (ty, nullable) = map_diesel_type(&column.ty)?; + columns.push(Column { + rust_name: rust_name.clone(), + db_name, + ordinal, + ty, + nullable, + primary_key: primary_keys.iter().any(|key| key == &rust_name), + unique: false, + }); + } + + for key in &primary_keys { + if !columns.iter().any(|column| column.rust_name == *key) { + return Err(OrmError::Invalid(format!( + "Diesel table {table_db_name} primary-key column {key} is not declared in the table body" + ))); + } + } + + return Ok(Table { + schema_name: self.schema_name.map(|ident| ident.to_string()), + db_name: table_db_name, + columns, + }); + } +} + +fn sql_name(attrs: &[Attribute]) -> Result> { + for attr in attrs { + if !attr.path().is_ident("sql_name") { + continue; + } + match &attr.meta { + Meta::NameValue(name_value) => { + if let syn::Expr::Lit(expr_lit) = &name_value.value + && let Lit::Str(value) = &expr_lit.lit + { + return Ok(Some(value.value())); + } + return Err(OrmError::Unsupported( + "Diesel #[sql_name] must contain a string literal".to_owned(), + )); + } + _ => { + return Err(OrmError::Unsupported( + "unsupported Diesel #[sql_name] syntax".to_owned(), + )); + } + } + } + return Ok(None); +} + +fn map_diesel_type(ty: &Type) -> Result<(OrmType, bool)> { + let Type::Path(type_path) = ty else { + return Err(OrmError::Unsupported(format!( + "Diesel type is not a path: {}", + ty.to_token_stream() + ))); + }; + let segment = + type_path.path.segments.last().ok_or_else(|| { + OrmError::Unsupported("Diesel type path has no final segment".to_owned()) + })?; + let name = segment.ident.to_string(); + + if name == "Nullable" { + let inner = single_generic_type(segment)?; + let (inner, _) = map_diesel_type(inner)?; + return Ok((inner, true)); + } + if name == "Array" { + let inner = single_generic_type(segment)?; + let (inner, inner_nullable) = map_diesel_type(inner)?; + if inner_nullable { + return Err(OrmError::Unsupported( + "nullable array elements require explicit contract evidence".to_owned(), + )); + } + return Ok((OrmType::Array(Box::new(inner)), false)); + } + + let scalar = match name.as_str() { + "Bool" => Some(ScalarType::Boolean), + "SmallInt" | "Int2" => Some(ScalarType::Int16), + "Integer" | "Int4" => Some(ScalarType::Int32), + "BigInt" | "Int8" => Some(ScalarType::Int64), + "Float" | "Float4" => Some(ScalarType::Float32), + "Double" | "Float8" => Some(ScalarType::Float64), + "Numeric" => Some(ScalarType::Decimal), + "Text" | "Varchar" | "VarChar" | "Bpchar" | "Citext" => Some(ScalarType::String), + "Uuid" => Some(ScalarType::Uuid), + "Date" => Some(ScalarType::Date), + "Timestamp" | "Timestamptz" => Some(ScalarType::DateTime), + "Bytea" | "Binary" => Some(ScalarType::Bytes), + "Json" | "Jsonb" => Some(ScalarType::Json), + _ => None, + }; + + return Ok(( + scalar.map_or_else(|| OrmType::Named(name), OrmType::Scalar), + false, + )); +} + +fn single_generic_type(segment: &syn::PathSegment) -> Result<&Type> { + let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return Err(OrmError::Unsupported(format!( + "{} requires one type argument", + segment.ident + ))); + }; + let mut types = arguments.args.iter().filter_map(|argument| match argument { + syn::GenericArgument::Type(ty) => Some(ty), + _ => None, + }); + let first = types.next().ok_or_else(|| { + OrmError::Unsupported(format!("{} requires one type argument", segment.ident)) + })?; + if types.next().is_some() || arguments.args.len() != 1 { + return Err(OrmError::Unsupported(format!( + "{} accepts exactly one type argument in the ORM IR", + segment.ident + ))); + } + return Ok(first); +} diff --git a/src/emit/bundle.rs b/src/emit/bundle.rs new file mode 100644 index 0000000..1960e67 --- /dev/null +++ b/src/emit/bundle.rs @@ -0,0 +1,386 @@ +use super::{dart, gleam, json_schema, naming, rust, typescript}; +use crate::admission::TjsvAdmissionBinding; +use crate::error::{OrmError, Result}; +use crate::ir::OrmIr; +use crate::policy::Policy; +use crate::shapes::{Shape, ShapeKind, derive_shape}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const DERIVATIVE_MANIFEST_SCHEMA: &str = "ores.orm-core.derivative-manifest/v3"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContractEvidenceState { + NotApplicable, + Required, + /// Exact bytes have been digest-bound to the derivative. This does not + /// mean TJSV has admitted the derivative for publication. + EvidenceBound, + /// The exact bound Contract IR and projection-verification receipt were + /// independently verified before bundle promotion. + Admitted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PublicationState { + PrivateDerivative, + BlockedPendingContractAdmission, + PublicAdmitted, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InputDigest { + pub name: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContractEvidenceDigest { + pub contract_ir_sha256: String, + pub receipt_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenerationEvidence { + pub orm_ir_sha256: String, + pub source_digests: Vec, + pub generator_options_sha256: String, + pub contract: Option, +} + +impl GenerationEvidence { + pub fn from_bytes( + orm_ir: &OrmIr, + source_inputs: &[(&str, &[u8])], + generator_options: &[u8], + contract: Option<(&[u8], &[u8])>, + ) -> Result { + orm_ir.validate()?; + if source_inputs.is_empty() { + return Err(OrmError::Invalid( + "generation evidence requires at least one exact source input".to_owned(), + )); + } + + let mut source_digests = source_inputs + .iter() + .map(|(name, bytes)| { + if name.trim().is_empty() { + return Err(OrmError::Invalid( + "generation evidence source names must not be empty".to_owned(), + )); + } + return Ok(InputDigest { + name: (*name).to_owned(), + sha256: digest(bytes), + }); + }) + .collect::>>()?; + source_digests.sort_by(|left, right| left.name.cmp(&right.name)); + for pair in source_digests.windows(2) { + if pair[0].name == pair[1].name { + return Err(OrmError::Invalid(format!( + "generation evidence contains duplicate source name {}", + pair[0].name + ))); + } + } + + let ir_bytes = serde_json::to_vec(orm_ir) + .map_err(|error| OrmError::Invalid(format!("could not encode ORM IR: {error}")))?; + let contract = match contract { + None => None, + Some((contract_ir, receipt)) => { + if contract_ir.is_empty() || receipt.is_empty() { + return Err(OrmError::Invalid( + "contract evidence requires non-empty Contract IR and receipt bytes" + .to_owned(), + )); + } + Some(ContractEvidenceDigest { + contract_ir_sha256: digest(contract_ir), + receipt_sha256: digest(receipt), + }) + } + }; + + return Ok(Self { + orm_ir_sha256: digest(&ir_bytes), + source_digests, + generator_options_sha256: digest(generator_options), + contract, + }); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactDigest { + pub path: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DerivativeManifest { + pub schema: String, + pub generator_version: String, + pub table: String, + pub shape: ShapeKind, + pub projection_identity: String, + pub public_candidate: bool, + pub publication: PublicationState, + pub contract_evidence: ContractEvidenceState, + pub public_admission: Option, + pub shape_sha256: String, + /// SHA-256 of the deterministic JSON serialization of the exact validated + /// `.ores-orm.toml` policy object used to derive this shape. This is kept + /// separate from free-form generator options so provenance cannot claim one + /// policy while emission actually used another. + pub policy_sha256: String, + pub evidence: GenerationEvidence, + pub artifacts: Vec, +} + +impl DerivativeManifest { + #[must_use] + pub fn publishable(&self) -> bool { + return match self.publication { + PublicationState::PrivateDerivative => true, + PublicationState::BlockedPendingContractAdmission => false, + PublicationState::PublicAdmitted => self + .public_admission + .as_ref() + .is_some_and(TjsvAdmissionBinding::is_verified), + }; + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeneratedArtifact { + pub path: String, + pub sha256: String, + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeneratedBundle { + pub manifest: DerivativeManifest, + pub artifacts: Vec, +} + +impl GeneratedBundle { + pub fn manifest_json(&self) -> Result { + return serde_json::to_string_pretty(&self.manifest) + .map_err(|error| OrmError::Invalid(format!("could not encode manifest: {error}"))); + } +} + +/// Emit a derivative only after proving that the supplied shape is exactly the +/// shape derived from the same converged ORM IR and policy bound to this run. +pub fn emit( + orm_ir: &OrmIr, + policy: &Policy, + shape: &Shape, + evidence: &GenerationEvidence, +) -> Result { + return emit_with_admission(orm_ir, policy, shape, evidence, None); +} + +pub fn emit_admitted( + orm_ir: &OrmIr, + policy: &Policy, + shape: &Shape, + evidence: &GenerationEvidence, + admission: &TjsvAdmissionBinding, +) -> Result { + if !shape.kind.is_public() { + return Err(OrmError::Invalid( + "TJSV public admission can only be attached to public ORM shapes".to_owned(), + )); + } + if !admission.is_verified() { + return Err(OrmError::Invalid( + "TJSV public admission binding must be verified in this process before promotion" + .to_owned(), + )); + } + + let contract = evidence.contract.as_ref().ok_or_else(|| { + OrmError::Invalid( + "admitted public ORM bundle requires exact Contract IR and projection-verification receipt bytes in generation evidence" + .to_owned(), + ) + })?; + if contract.contract_ir_sha256.as_str() != admission.contract_ir_sha256() { + return Err(OrmError::Invalid( + "TJSV admission Contract IR digest does not match generation evidence".to_owned(), + )); + } + if contract.receipt_sha256.as_str() != admission.projection_verification_sha256() { + return Err(OrmError::Invalid( + "TJSV projection-verification receipt digest does not match generation evidence" + .to_owned(), + )); + } + + let expected_projection_id = naming::projection_id(shape); + if admission.projection_id() != expected_projection_id.as_str() { + return Err(OrmError::Invalid(format!( + "TJSV admission projection id {} does not match generated projection {expected_projection_id}", + admission.projection_id() + ))); + } + + return emit_with_admission(orm_ir, policy, shape, evidence, Some(admission.clone())); +} + +fn emit_with_admission( + orm_ir: &OrmIr, + policy: &Policy, + shape: &Shape, + evidence: &GenerationEvidence, + admission: Option, +) -> Result { + orm_ir.validate()?; + policy.validate_ir(orm_ir)?; + shape.validate()?; + + let ir_bytes = serde_json::to_vec(orm_ir) + .map_err(|error| OrmError::Invalid(format!("could not encode ORM IR: {error}")))?; + let actual_ir_sha256 = digest(&ir_bytes); + if evidence.orm_ir_sha256 != actual_ir_sha256 { + return Err(OrmError::Invalid(format!( + "generation evidence ORM IR digest {} does not match supplied ORM IR {}", + evidence.orm_ir_sha256, actual_ir_sha256 + ))); + } + + let policy_bytes = serde_json::to_vec(policy) + .map_err(|error| OrmError::Invalid(format!("could not encode ORM policy: {error}")))?; + let policy_sha256 = digest(&policy_bytes); + + let table = orm_ir.table(&shape.table).ok_or_else(|| { + OrmError::Invalid(format!( + "derived shape references table {} that is absent from the bound ORM IR", + shape.table + )) + })?; + let expected = derive_shape(table, policy, shape.kind)?; + if &expected != shape { + return Err(OrmError::Invalid(format!( + "supplied {:?} shape for {} does not match the exact shape derived from the bound ORM IR and policy", + shape.kind, shape.table + ))); + } + + naming::validate_identifiers(shape, &[], "generated")?; + + let stem = naming::safe_artifact_stem(shape); + let schema = serde_json::to_string_pretty(&json_schema::emit(shape)?) + .map_err(|error| OrmError::Invalid(format!("could not encode JSON Schema: {error}")))?; + + let sources = [ + (format!("rust/{stem}.rs"), rust::emit(shape)?), + (format!("typescript/{stem}.ts"), typescript::emit(shape)?), + (format!("dart/{stem}.dart"), dart::emit(shape)?), + (format!("gleam/{stem}.gleam"), gleam::emit(shape)?), + (format!("json-schema/{stem}.schema.json"), schema), + ]; + + let artifacts = sources + .into_iter() + .map(|(path, content)| GeneratedArtifact { + sha256: digest(content.as_bytes()), + path, + content, + }) + .collect::>(); + + if let Some(admission) = admission.as_ref() { + let mut actual = artifacts + .iter() + .map(|artifact| (artifact.path.clone(), artifact.sha256.clone())) + .collect::>(); + actual.sort(); + let mut expected = admission + .outputs() + .iter() + .map(|output| (output.path.clone(), output.sha256.clone())) + .collect::>(); + expected.sort(); + if actual != expected { + return Err(OrmError::Invalid(format!( + "TJSV verified output closure does not match generated bundle: expected {expected:?}, got {actual:?}" + ))); + } + } + + let artifact_digests = artifacts + .iter() + .map(|artifact| ArtifactDigest { + path: artifact.path.clone(), + sha256: artifact.sha256.clone(), + }) + .collect(); + let shape_bytes = serde_json::to_vec(shape) + .map_err(|error| OrmError::Invalid(format!("could not encode shape: {error}")))?; + let contract_evidence = match ( + shape.kind.is_public(), + evidence.contract.is_some(), + admission.is_some(), + ) { + (false, _, false) => ContractEvidenceState::NotApplicable, + (false, _, true) => { + return Err(OrmError::Invalid( + "private ORM bundle cannot carry public TJSV admission".to_owned(), + )); + } + (true, false, false) => ContractEvidenceState::Required, + (true, true, false) => ContractEvidenceState::EvidenceBound, + (true, true, true) => ContractEvidenceState::Admitted, + (true, false, true) => { + return Err(OrmError::Invalid( + "public TJSV admission requires bound contract evidence".to_owned(), + )); + } + }; + let publication = match (shape.kind.is_public(), admission.is_some()) { + (false, false) => PublicationState::PrivateDerivative, + (false, true) => { + return Err(OrmError::Invalid( + "private ORM bundle cannot be promoted through public TJSV admission".to_owned(), + )); + } + (true, false) => PublicationState::BlockedPendingContractAdmission, + (true, true) => PublicationState::PublicAdmitted, + }; + + return Ok(GeneratedBundle { + manifest: DerivativeManifest { + schema: DERIVATIVE_MANIFEST_SCHEMA.to_owned(), + generator_version: env!("CARGO_PKG_VERSION").to_owned(), + table: shape.table.clone(), + shape: shape.kind, + projection_identity: naming::projection_id(shape), + public_candidate: shape.kind.is_public(), + publication, + contract_evidence, + public_admission: admission, + shape_sha256: digest(&shape_bytes), + policy_sha256, + evidence: evidence.clone(), + artifacts: artifact_digests, + }, + artifacts, + }); +} + +fn digest(bytes: &[u8]) -> String { + return hex::encode(Sha256::digest(bytes)); +} diff --git a/src/emit/dart.rs b/src/emit/dart.rs new file mode 100644 index 0000000..a867135 --- /dev/null +++ b/src/emit/dart.rs @@ -0,0 +1,246 @@ +use super::naming; +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType}; +use crate::shapes::{Shape, ShapeField}; + +const DART_RESERVED: &[&str] = &[ + "abstract", "as", "assert", "async", "await", "break", "case", "catch", "class", "const", + "continue", "covariant", "default", "deferred", "do", "dynamic", "else", "enum", "export", + "extends", "extension", "external", "factory", "false", "final", "finally", "for", "Function", + "get", "hide", "if", "implements", "import", "in", "interface", "is", "late", "library", + "mixin", "new", "null", "on", "operator", "part", "required", "rethrow", "return", "sealed", + "set", "show", "static", "super", "switch", "sync", "this", "throw", "true", "try", "typedef", + "var", "void", "when", "while", "with", "yield", +]; + +pub fn emit(shape: &Shape) -> Result { + shape.validate()?; + naming::validate_identifiers(shape, DART_RESERVED, "Dart")?; + let type_name = naming::type_name(shape); + let mut out = String::from( + "// GENERATED by ores-orm-core — derivative evidence, do not edit.\n\n\ + final class OresOptional {\n\ + final bool isPresent;\n\ + final T? value;\n\ + const OresOptional.absent() : isPresent = false, value = null;\n\ + const OresOptional.present(T value) : isPresent = true, value = value;\n\ + }\n\n", + ); + + out.push_str(&format!("final class {type_name} {{\n")); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, DART_RESERVED); + out.push_str(&format!( + " final {} {identifier};\n", + dart_field_type(field)? + )); + } + + out.push_str(&format!("\n const {type_name}({{\n")); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, DART_RESERVED); + out.push_str(&format!(" required this.{identifier},\n")); + } + out.push_str(" });\n\n"); + + out.push_str(&format!( + " factory {type_name}.fromJson(Map json) {{\n" + )); + let allowed = shape + .fields + .iter() + .map(|field| dart_string(&field.wire_name)) + .collect::>() + .join(", "); + out.push_str(&format!(" const allowedKeys = {{{allowed}}};\n")); + out.push_str( + " final unknownKeys = json.keys.where((key) => !allowedKeys.contains(key)).toList();\n\ + if (unknownKeys.isNotEmpty) {\n\ + throw FormatException('unknown fields: $unknownKeys');\n\ + }\n", + ); + if shape.kind.requires_non_empty_object() { + out.push_str( + " if (json.isEmpty) {\n\ + throw const FormatException('patch must contain at least one field');\n\ + }\n", + ); + } + + for field in &shape.fields { + emit_dart_field_parse(&mut out, field)?; + } + + out.push_str(&format!("\n return {type_name}(\n")); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, DART_RESERVED); + out.push_str(&format!(" {identifier}: {identifier}Value,\n")); + } + out.push_str(" );\n }\n}\n"); + + return Ok(out); +} + +fn emit_dart_field_parse(out: &mut String, field: &ShapeField) -> Result<()> { + let identifier = naming::identifier(&field.db_name, DART_RESERVED); + let key = dart_string(&field.wire_name); + let raw = format!("{identifier}Raw"); + let check = dart_check(&field.ty, &raw)?; + let nullable_check = if field.nullable { + format!("{raw} == null || ({check})") + } else { + check + }; + let converted = dart_convert(&field.ty, &raw)?; + let converted = if field.nullable { + format!("{raw} == null ? null : {converted}") + } else { + converted + }; + + if field.required { + out.push_str(&format!( + " if (!json.containsKey({key})) {{\n\ + throw const FormatException('missing required field: {}');\n\ + }}\n\ + final {raw} = json[{key}];\n\ + if (!({nullable_check})) {{\n\ + throw const FormatException('invalid field: {}');\n\ + }}\n\ + final {identifier}Value = {converted};\n", + field.wire_name, field.wire_name + )); + return Ok(()); + } + + let field_type = dart_field_type(field)?; + out.push_str(&format!( + " final {field_type} {identifier}Value = !json.containsKey({key})\n\ + ? const OresOptional.absent()\n\ + : (() {{\n\ + final {raw} = json[{key}];\n\ + if (!({nullable_check})) {{\n\ + throw const FormatException('invalid field: {}');\n\ + }}\n\ + return OresOptional.present({converted});\n\ + }})();\n", + field.wire_name + )); + + return Ok(()); +} + +fn dart_field_type(field: &ShapeField) -> Result { + let base = dart_type(&field.ty)?; + let value = match (field.required, field.nullable) { + (true, false) => base, + (true, true) => format!("{base}?"), + (false, false) => format!("OresOptional<{base}>"), + (false, true) => format!("OresOptional<{base}?>"), + }; + + return Ok(value); +} + +fn unsupported_scalar(name: &str) -> OrmError { + OrmError::Unsupported(format!( + "{name} requires an admitted cross-runtime wire/domain mapping before Dart emission" + )) +} + +fn dart_type(ty: &OrmType) -> Result { + let value = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => "bool".to_owned(), + ScalarType::Int16 | ScalarType::Int32 => "int".to_owned(), + ScalarType::Int64 => return Err(unsupported_scalar("int64")), + ScalarType::Float32 => return Err(unsupported_scalar("float32")), + ScalarType::Float64 => "double".to_owned(), + ScalarType::Decimal => return Err(unsupported_scalar("decimal")), + ScalarType::String => "String".to_owned(), + ScalarType::Uuid => return Err(unsupported_scalar("uuid")), + ScalarType::Date => return Err(unsupported_scalar("date")), + ScalarType::DateTime => return Err(unsupported_scalar("date-time")), + ScalarType::Bytes => return Err(unsupported_scalar("bytes")), + ScalarType::Json => "Object".to_owned(), + }, + OrmType::Array(inner) => format!("List<{}>", dart_type(inner)?), + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Dart emission" + ))); + } + }; + + return Ok(value); +} + +fn dart_check(ty: &OrmType, value: &str) -> Result { + let check = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => format!("{value} is bool"), + ScalarType::Int16 => { + format!("{value} is int && {value} >= -32768 && {value} <= 32767") + } + ScalarType::Int32 => format!( + "{value} is int && {value} >= -2147483648 && {value} <= 2147483647" + ), + ScalarType::Int64 => return Err(unsupported_scalar("int64")), + ScalarType::Float32 => return Err(unsupported_scalar("float32")), + ScalarType::Float64 => format!("{value} is num && ({value} as num).isFinite"), + ScalarType::Decimal => return Err(unsupported_scalar("decimal")), + ScalarType::String => format!("{value} is String"), + ScalarType::Uuid => return Err(unsupported_scalar("uuid")), + ScalarType::Date => return Err(unsupported_scalar("date")), + ScalarType::DateTime => return Err(unsupported_scalar("date-time")), + ScalarType::Bytes => return Err(unsupported_scalar("bytes")), + ScalarType::Json => format!("{value} != null"), + }, + OrmType::Array(inner) => { + let inner_check = dart_check(inner, "item")?; + format!("{value} is List && {value}.every((item) => {inner_check})") + } + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Dart emission" + ))); + } + }; + + return Ok(check); +} + +fn dart_convert(ty: &OrmType, value: &str) -> Result { + let converted = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => format!("{value} as bool"), + ScalarType::Int16 | ScalarType::Int32 => format!("{value} as int"), + ScalarType::Int64 => return Err(unsupported_scalar("int64")), + ScalarType::Float32 => return Err(unsupported_scalar("float32")), + ScalarType::Float64 => format!("({value} as num).toDouble()"), + ScalarType::Decimal => return Err(unsupported_scalar("decimal")), + ScalarType::String => format!("{value} as String"), + ScalarType::Uuid => return Err(unsupported_scalar("uuid")), + ScalarType::Date => return Err(unsupported_scalar("date")), + ScalarType::DateTime => return Err(unsupported_scalar("date-time")), + ScalarType::Bytes => return Err(unsupported_scalar("bytes")), + ScalarType::Json => value.to_owned(), + }, + OrmType::Array(inner) => { + let inner_type = dart_type(inner)?; + let item = dart_convert(inner, "item")?; + format!("({value} as List).map((item) => {item}).cast<{inner_type}>().toList()") + } + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Dart emission" + ))); + } + }; + + return Ok(converted); +} + +fn dart_string(value: &str) -> String { + return format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")); +} diff --git a/src/emit/gleam.rs b/src/emit/gleam.rs new file mode 100644 index 0000000..79b7c83 --- /dev/null +++ b/src/emit/gleam.rs @@ -0,0 +1,208 @@ +use super::naming; +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType}; +use crate::shapes::{Shape, ShapeField}; + +const GLEAM_RESERVED: &[&str] = &[ + "as", "assert", "case", "const", "echo", "else", "fn", "if", "import", "let", "opaque", + "panic", "pub", "todo", "type", "use", +]; + +pub fn emit(shape: &Shape) -> Result { + shape.validate()?; + naming::validate_identifiers(shape, GLEAM_RESERVED, "Gleam")?; + let type_name = naming::type_name(shape); + let function_name = naming::identifier( + &format!( + "{}_{}", + shape.table.replace('.', "_"), + naming::shape_slug(shape.kind) + ), + GLEAM_RESERVED, + ); + + let mut out = String::from( + "// GENERATED by ores-orm-core — derivative evidence, do not edit.\n\ + import gleam/dict\n\ + import gleam/dynamic\n\ + import gleam/dynamic/decode\n\ + import gleam/list\n\ + import gleam/option.{type Option}\n\n\ + pub type Presence(a) {\n\ + Missing\n\ + Present(a)\n\ + }\n\n\ + fn int16_decoder() -> decode.Decoder(Int) {\n\ + use value <- decode.then(decode.int)\n\ + case value >= -32_768 && value <= 32_767 {\n\ + True -> decode.success(value)\n\ + False -> decode.failure(0, expected: \"Int16\")\n\ + }\n\ + }\n\n\ + fn int32_decoder() -> decode.Decoder(Int) {\n\ + use value <- decode.then(decode.int)\n\ + case value >= -2_147_483_648 && value <= 2_147_483_647 {\n\ + True -> decode.success(value)\n\ + False -> decode.failure(0, expected: \"Int32\")\n\ + }\n\ + }\n\n", + ); + + out.push_str(&format!("pub type {type_name} {{\n {type_name}(\n")); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, GLEAM_RESERVED); + out.push_str(&format!( + " {identifier}: {},\n", + gleam_field_type(field)? + )); + } + out.push_str(" )\n}\n\n"); + + out.push_str(&format!( + "fn {function_name}_decoder() -> decode.Decoder({type_name}) {{\n" + )); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, GLEAM_RESERVED); + let key = gleam_string(&field.wire_name); + let decoder = gleam_decoder(field)?; + if field.required { + out.push_str(&format!( + " use {identifier} <- decode.field({key}, {decoder})\n" + )); + } else { + out.push_str(&format!( + " use {identifier} <- decode.optional_field({key}, Missing, {decoder} |> decode.map(Present))\n" + )); + } + } + out.push_str(&format!(" decode.success({type_name}(\n")); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, GLEAM_RESERVED); + out.push_str(&format!(" {identifier}:,\n")); + } + out.push_str(" ))\n}\n\n"); + + let allowed = shape + .fields + .iter() + .map(|field| gleam_string(&field.wire_name)) + .collect::>() + .join(", "); + let valid_object = if shape.kind.requires_non_empty_object() { + format!( + "{{\n\ + case dict.size(fields) {{\n\ + 0 -> Error([decode.DecodeError(expected: \"non-empty patch object\", found: \"empty object\", path: [])])\n\ + _ -> decode.run(data, {function_name}_decoder())\n\ + }}\n\ + }}" + ) + } else { + format!("decode.run(data, {function_name}_decoder())") + }; + out.push_str(&format!( + "pub fn decode_{function_name}(data: dynamic.Dynamic) -> Result({type_name}, List(decode.DecodeError)) {{\n\ + case decode.run(data, decode.dict(decode.string, decode.dynamic)) {{\n\ + Error(errors) -> Error(errors)\n\ + Ok(fields) -> {{\n\ + let allowed = [{allowed}]\n\ + let unknown = fields\n\ + |> dict.keys\n\ + |> list.filter(fn(key) {{ !list.contains(allowed, key) }})\n\ + case unknown {{\n\ + [] -> {valid_object}\n\ + [first, ..] -> Error([decode.DecodeError(expected: \"closed object\", found: first, path: [first])])\n\ + }}\n\ + }}\n\ + }}\n\ + }}\n" + )); + + return Ok(out); +} + +fn gleam_field_type(field: &ShapeField) -> Result { + let base = gleam_type(&field.ty)?; + let value = match (field.required, field.nullable) { + (true, false) => base, + (true, true) => format!("Option({base})"), + (false, false) => format!("Presence({base})"), + (false, true) => format!("Presence(Option({base}))"), + }; + + return Ok(value); +} + +fn unsupported_scalar(name: &str) -> OrmError { + OrmError::Unsupported(format!( + "{name} requires an admitted cross-runtime wire/domain mapping before Gleam emission" + )) +} + +fn gleam_type(ty: &OrmType) -> Result { + let value = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => "Bool".to_owned(), + ScalarType::Int16 | ScalarType::Int32 => "Int".to_owned(), + ScalarType::Int64 => return Err(unsupported_scalar("int64")), + ScalarType::Float32 => return Err(unsupported_scalar("float32")), + ScalarType::Float64 => "Float".to_owned(), + ScalarType::Decimal => return Err(unsupported_scalar("decimal")), + ScalarType::String => "String".to_owned(), + ScalarType::Uuid => return Err(unsupported_scalar("uuid")), + ScalarType::Date => return Err(unsupported_scalar("date")), + ScalarType::DateTime => return Err(unsupported_scalar("date-time")), + ScalarType::Bytes => return Err(unsupported_scalar("bytes")), + ScalarType::Json => "dynamic.Dynamic".to_owned(), + }, + OrmType::Array(inner) => format!("List({})", gleam_type(inner)?), + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Gleam emission" + ))); + } + }; + + return Ok(value); +} + +fn gleam_decoder(field: &ShapeField) -> Result { + let base = gleam_decoder_type(&field.ty)?; + if field.nullable { + return Ok(format!("decode.optional({base})")); + } + + return Ok(base); +} + +fn gleam_decoder_type(ty: &OrmType) -> Result { + let value = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => "decode.bool".to_owned(), + ScalarType::Int16 => "int16_decoder()".to_owned(), + ScalarType::Int32 => "int32_decoder()".to_owned(), + ScalarType::Int64 => return Err(unsupported_scalar("int64")), + ScalarType::Float32 => return Err(unsupported_scalar("float32")), + ScalarType::Float64 => "decode.float".to_owned(), + ScalarType::Decimal => return Err(unsupported_scalar("decimal")), + ScalarType::String => "decode.string".to_owned(), + ScalarType::Uuid => return Err(unsupported_scalar("uuid")), + ScalarType::Date => return Err(unsupported_scalar("date")), + ScalarType::DateTime => return Err(unsupported_scalar("date-time")), + ScalarType::Bytes => return Err(unsupported_scalar("bytes")), + ScalarType::Json => "decode.dynamic".to_owned(), + }, + OrmType::Array(inner) => format!("decode.list({})", gleam_decoder_type(inner)?), + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Gleam emission" + ))); + } + }; + + return Ok(value); +} + +fn gleam_string(value: &str) -> String { + return format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")); +} diff --git a/src/emit/json_schema.rs b/src/emit/json_schema.rs new file mode 100644 index 0000000..8417c54 --- /dev/null +++ b/src/emit/json_schema.rs @@ -0,0 +1,122 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType}; +use crate::shapes::{Shape, ShapeField, ShapeKind}; +use serde_json::{Map, Value, json}; + +pub const DRAFT_2020_12: &str = "https://json-schema.org/draft/2020-12/schema"; + +pub fn emit(shape: &Shape) -> Result { + shape.validate()?; + let mut properties = Map::new(); + let mut required = Vec::new(); + for field in &shape.fields { + properties.insert(field.wire_name.clone(), field_schema(field)?); + if field.required { + required.push(Value::String(field.wire_name.clone())); + } + } + + let table_identity = hex::encode(shape.table.as_bytes()); + let mut schema = json!({ + "$schema": DRAFT_2020_12, + "$id": format!("urn:ores:orm:table:{table_identity}:{}", shape_slug(shape.kind)), + "type": "object", + "additionalProperties": false, + "properties": properties, + "required": required, + "x-ores-orm-derivative": true, + "x-ores-table": shape.table + }); + + if shape.kind.requires_non_empty_object() { + schema["minProperties"] = json!(1); + } + + return Ok(schema); +} + +const fn shape_slug(kind: ShapeKind) -> &'static str { + match kind { + ShapeKind::Row => "row", + ShapeKind::Create => "create", + ShapeKind::Update => "update", + ShapeKind::Patch => "patch", + ShapeKind::PublicRead => "public_read", + ShapeKind::PublicCreate => "public_create", + ShapeKind::PublicUpdate => "public_update", + ShapeKind::PublicPatch => "public_patch", + } +} + +fn field_schema(field: &ShapeField) -> Result { + let base = type_schema(&field.ty)?; + if !field.nullable { + return Ok(base); + } + Ok(json!({"anyOf": [base, {"type": "null"}]})) +} + +fn type_schema(ty: &OrmType) -> Result { + match ty { + OrmType::Scalar(scalar) => Ok(match scalar { + ScalarType::Boolean => json!({"type": "boolean"}), + ScalarType::Int16 => json!({ + "type": "integer", + "format": "int16", + "minimum": -32768, + "maximum": 32767 + }), + ScalarType::Int32 => json!({ + "type": "integer", + "format": "int32", + "minimum": -2147483648_i64, + "maximum": 2147483647_i64 + }), + ScalarType::Int64 => { + return Err(OrmError::Unsupported( + "int64 requires an explicit JSON wire mapping".to_owned(), + )); + } + ScalarType::Float32 => { + return Err(OrmError::Unsupported( + "float32 requires an admitted cross-runtime wire/domain mapping before JSON Schema emission".to_owned(), + )); + } + ScalarType::Float64 => json!({"type": "number"}), + ScalarType::Decimal => { + return Err(OrmError::Unsupported( + "decimal requires an explicit JSON wire mapping".to_owned(), + )); + } + ScalarType::String => json!({"type": "string"}), + ScalarType::Uuid => { + return Err(OrmError::Unsupported( + "uuid requires an admitted canonical string/validator mapping before JSON Schema emission; format annotations alone are not a portable assertion guarantee".to_owned(), + )); + } + ScalarType::Date => { + return Err(OrmError::Unsupported( + "date requires an admitted semantic calendar-date mapping before JSON Schema emission; format annotations alone are not a portable assertion guarantee".to_owned(), + )); + } + ScalarType::DateTime => { + return Err(OrmError::Unsupported( + "date-time requires an explicit JSON wire mapping".to_owned(), + )); + } + ScalarType::Bytes => { + return Err(OrmError::Unsupported( + "bytes require an explicit JSON wire mapping".to_owned(), + )); + } + ScalarType::Json => json!({}), + }), + OrmType::Array(inner) => Ok(json!({ + "type": "array", + "items": type_schema(inner)? + })), + OrmType::Named(name) => Err(OrmError::Unsupported(format!( + "named ORM type {name} must be resolved against contract evidence before JSON Schema emission" + ))), + } +} diff --git a/src/emit/mod.rs b/src/emit/mod.rs new file mode 100644 index 0000000..dd2a2ab --- /dev/null +++ b/src/emit/mod.rs @@ -0,0 +1,7 @@ +pub mod bundle; +pub mod dart; +pub mod gleam; +pub mod json_schema; +mod naming; +pub mod rust; +pub mod typescript; diff --git a/src/emit/naming.rs b/src/emit/naming.rs new file mode 100644 index 0000000..88ae213 --- /dev/null +++ b/src/emit/naming.rs @@ -0,0 +1,129 @@ +use crate::error::{OrmError, Result}; +use crate::shapes::{Shape, ShapeKind}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +const ESCAPED_IDENTIFIER_PREFIX: &str = "__ores_hex_"; + +pub(crate) fn type_name(shape: &Shape) -> String { + // Keep a readable prefix while including the complete table identity. The + // full UTF-8 hex suffix prevents `a-b`, `a_b`, `a.b`, and similarly + // normalized names from silently producing the same exported type. + return format!( + "{}T{}{}", + pascal(&shape.table.replace('.', "_")), + hex::encode(shape.table.as_bytes()), + shape_suffix(shape.kind) + ); +} + +pub(crate) fn projection_id(shape: &Shape) -> String { + let table_digest = hex::encode(Sha256::digest(shape.table.as_bytes())); + return format!("orm.{table_digest}.{}", shape_slug(shape.kind)); +} + +pub(crate) fn safe_artifact_stem(shape: &Shape) -> String { + // Database identifiers may legally contain path separators, `..`, spaces, + // or punctuation when quoted. Never interpolate them directly into output + // paths. Hex encoding is reversible, deterministic, and collision-free for + // the exact UTF-8 table identity. + return format!( + "table_{}_{}", + hex::encode(shape.table.as_bytes()), + shape_slug(shape.kind) + ); +} + +pub(crate) fn validate_identifiers(shape: &Shape, reserved: &[&str], language: &str) -> Result<()> { + let mut seen = BTreeMap::::new(); + for field in &shape.fields { + let generated = identifier(&field.db_name, reserved); + if let Some(previous) = seen.insert(generated.clone(), field.db_name.clone()) { + return Err(OrmError::Invalid(format!( + "{language} identifier {generated:?} collides for database fields {previous:?} and {:?}; add an explicit non-colliding mapping before generation", + field.db_name + ))); + } + } + return Ok(()); +} + +pub(crate) const fn shape_slug(kind: ShapeKind) -> &'static str { + return match kind { + ShapeKind::Row => "row", + ShapeKind::Create => "create", + ShapeKind::Update => "update", + ShapeKind::Patch => "patch", + ShapeKind::PublicRead => "public_read", + ShapeKind::PublicCreate => "public_create", + ShapeKind::PublicUpdate => "public_update", + ShapeKind::PublicPatch => "public_patch", + }; +} + +pub(crate) const fn shape_suffix(kind: ShapeKind) -> &'static str { + return match kind { + ShapeKind::Row => "Row", + ShapeKind::Create => "Create", + ShapeKind::Update => "Update", + ShapeKind::Patch => "Patch", + ShapeKind::PublicRead => "PublicRead", + ShapeKind::PublicCreate => "PublicCreate", + ShapeKind::PublicUpdate => "PublicUpdate", + ShapeKind::PublicPatch => "PublicPatch", + }; +} + +pub(crate) fn pascal(value: &str) -> String { + let mut out = String::new(); + let mut upper = true; + + for ch in value.chars() { + if !ch.is_ascii_alphanumeric() { + upper = true; + continue; + } + + if upper { + out.extend(ch.to_uppercase()); + upper = false; + } else { + out.push(ch); + } + } + + if out.is_empty() { + return "Generated".to_owned(); + } + if out.as_bytes()[0].is_ascii_digit() { + return format!("Generated{out}"); + } + + return out; +} + +pub(crate) fn identifier(value: &str, reserved: &[&str]) -> String { + let bytes = value.as_bytes(); + let starts_valid = bytes + .first() + .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_'); + let all_valid = bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_'); + let can_preserve = !value.is_empty() + && starts_valid + && all_valid + && !reserved.contains(&value) + && !value.starts_with(ESCAPED_IDENTIFIER_PREFIX); + + if can_preserve { + return value.to_owned(); + } + + // The escape prefix itself is reserved from pass-through above, so an + // authored identifier can never collide with this reversible encoding. + return format!( + "{ESCAPED_IDENTIFIER_PREFIX}{}", + hex::encode(value.as_bytes()) + ); +} diff --git a/src/emit/rust.rs b/src/emit/rust.rs new file mode 100644 index 0000000..50fbd56 --- /dev/null +++ b/src/emit/rust.rs @@ -0,0 +1,209 @@ +use super::naming; +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType}; +use crate::shapes::{Shape, ShapeField}; + +const RUST_RESERVED: &[&str] = &[ + "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", + "false", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", + "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "try", + "type", "unsafe", "use", "where", "while", +]; + +pub fn emit(shape: &Shape) -> Result { + shape.validate()?; + naming::validate_identifiers(shape, RUST_RESERVED, "Rust")?; + let type_name = naming::type_name(shape); + let needs_required_nullable = shape + .fields + .iter() + .any(|field| field.required && field.nullable); + let needs_optional_non_null = shape + .fields + .iter() + .any(|field| !field.required && !field.nullable); + let needs_optional_nullable = shape + .fields + .iter() + .any(|field| !field.required && field.nullable); + + // Do not derive schemars::JsonSchema here. Rust's Option cannot by + // itself represent all four required/optional × nullable/non-null states + // without custom schema plumbing, and an inferred schema would therefore + // be weaker than the canonical Draft 2020-12 artifact emitted alongside + // this DTO. Serde is the executable Rust admission surface; JSON Schema is + // emitted independently from the same Shape. + let mut out = String::from( + "// GENERATED by ores-orm-core — derivative evidence, do not edit.\n\ + // Canonical JSON Schema is emitted as a sibling artifact; do not infer it from this DTO.\n\ + #![allow(dead_code)]\n\n\ + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\n\ + #[serde(deny_unknown_fields)]\n", + ); + out.push_str(&format!("pub struct {type_name} {{\n")); + + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, RUST_RESERVED); + let base = rust_type(&field.ty)?; + let field_type = match (field.required, field.nullable) { + (true, false) => base, + (true, true) => format!("Option<{base}>"), + (false, false) => format!("Option<{base}>"), + (false, true) => format!("Option>"), + }; + + if identifier != field.wire_name { + out.push_str(&format!(" #[serde(rename = {:?})]\n", field.wire_name)); + } + match (field.required, field.nullable) { + (true, true) => { + out.push_str( + " #[serde(deserialize_with = \"deserialize_required_nullable\")]\n", + ); + } + (false, false) => { + out.push_str( + " #[serde(default, deserialize_with = \"deserialize_optional_non_null\", skip_serializing_if = \"Option::is_none\")]\n", + ); + } + (false, true) => { + out.push_str( + " #[serde(default, deserialize_with = \"deserialize_optional_nullable\", skip_serializing_if = \"Option::is_none\")]\n", + ); + } + (true, false) => {} + } + out.push_str(&format!(" pub {identifier}: {field_type},\n")); + } + out.push_str("}\n"); + + if shape.kind.requires_non_empty_object() { + let absent = shape + .fields + .iter() + .map(|field| { + format!( + "self.{}.is_none()", + naming::identifier(&field.db_name, RUST_RESERVED) + ) + }) + .collect::>() + .join(" && "); + + out.push_str(&format!("\nimpl {type_name} {{\n")); + out.push_str(" pub fn validate(&self) -> Result<(), &'static str> {\n"); + if absent.is_empty() { + out.push_str(" return Err(\"patch must contain at least one field\");\n"); + } else { + out.push_str(&format!(" if {absent} {{\n")); + out.push_str(" return Err(\"patch must contain at least one field\");\n"); + out.push_str(" }\n"); + out.push_str(" return Ok(());\n"); + } + out.push_str(" }\n}\n"); + } + + if needs_required_nullable { + out.push_str( + "\nfn deserialize_required_nullable<'de, D, T>(deserializer: D) -> Result, D::Error>\n\ + where\n\ + D: serde::Deserializer<'de>,\n\ + T: serde::Deserialize<'de>,\n\ + {\n\ + return as serde::Deserialize>::deserialize(deserializer);\n\ + }\n", + ); + } + + if needs_optional_non_null { + out.push_str( + "\nfn deserialize_optional_non_null<'de, D, T>(deserializer: D) -> Result, D::Error>\n\ + where\n\ + D: serde::Deserializer<'de>,\n\ + T: serde::de::DeserializeOwned,\n\ + {\n\ + let value = ::deserialize(deserializer)?;\n\ + if value.is_null() {\n\ + return Err(::custom(\"expected a non-null value\"));\n\ + }\n\ + let decoded = serde_json::from_value(value)\n\ + .map_err(|error| ::custom(error.to_string()))?;\n\ + return Ok(Some(decoded));\n\ + }\n", + ); + } + + if needs_optional_nullable { + out.push_str( + "\nfn deserialize_optional_nullable<'de, D, T>(deserializer: D) -> Result>, D::Error>\n\ + where\n\ + D: serde::Deserializer<'de>,\n\ + T: serde::Deserialize<'de>,\n\ + {\n\ + let value = as serde::Deserialize>::deserialize(deserializer)?;\n\ + return Ok(Some(value));\n\ + }\n", + ); + } + + return Ok(out); +} + +fn rust_type(ty: &OrmType) -> Result { + let value = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => "bool".to_owned(), + ScalarType::Int16 => "i16".to_owned(), + ScalarType::Int32 => "i32".to_owned(), + ScalarType::Int64 => { + return Err(OrmError::Unsupported( + "int64 requires an explicit Rust wire mapping".to_owned(), + )); + } + ScalarType::Float32 => { + return Err(OrmError::Unsupported( + "float32 requires an admitted cross-runtime wire/domain mapping before Rust emission".to_owned(), + )); + } + ScalarType::Float64 => "f64".to_owned(), + ScalarType::Decimal => { + return Err(OrmError::Unsupported( + "decimal requires an explicit Rust wire mapping".to_owned(), + )); + } + ScalarType::String => "String".to_owned(), + ScalarType::Uuid => { + return Err(OrmError::Unsupported( + "uuid requires an admitted canonical string/validator mapping before Rust emission".to_owned(), + )); + } + ScalarType::Date => { + return Err(OrmError::Unsupported( + "date requires an admitted semantic calendar-date mapping before Rust emission".to_owned(), + )); + } + ScalarType::DateTime => { + return Err(OrmError::Unsupported( + "date-time requires an explicit Rust wire mapping".to_owned(), + )); + } + ScalarType::Bytes => { + return Err(OrmError::Unsupported( + "bytes require an explicit Rust wire mapping".to_owned(), + )); + } + ScalarType::Json => "serde_json::Value".to_owned(), + }, + OrmType::Array(inner) => format!("Vec<{}>", rust_type(inner)?), + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Rust emission" + ))); + } + }; + + return Ok(value); +} + +#[allow(dead_code)] +fn _field_semantics(_: &ShapeField) {} diff --git a/src/emit/typescript.rs b/src/emit/typescript.rs new file mode 100644 index 0000000..e87b51a --- /dev/null +++ b/src/emit/typescript.rs @@ -0,0 +1,97 @@ +use super::naming; +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType}; +use crate::shapes::Shape; + +pub fn emit(shape: &Shape) -> Result { + shape.validate()?; + let type_name = naming::type_name(shape); + let mut out = String::from( + "// GENERATED by ores-orm-core — derivative evidence, do not edit.\n\ + import { z } from \"zod\";\n\n", + ); + out.push_str(&format!("export const {type_name}Schema = z.object({{\n")); + + for field in &shape.fields { + let key = serde_json::to_string(&field.wire_name).map_err(|error| { + OrmError::Invalid(format!("could not encode TypeScript field name: {error}")) + })?; + let mut validator = zod_type(&field.ty)?; + if field.nullable { + validator.push_str(".nullable()"); + } + if !field.required { + validator.push_str(".optional()"); + } + out.push_str(&format!(" {key}: {validator},\n")); + } + + if shape.kind.requires_non_empty_object() { + out.push_str( + "}).strict().refine((value) => Object.keys(value).length > 0, { message: \"patch must contain at least one field\" });\n\n", + ); + } else { + out.push_str("}).strict();\n\n"); + } + out.push_str(&format!( + "export type {type_name} = z.infer;\n" + )); + + return Ok(out); +} + +fn zod_type(ty: &OrmType) -> Result { + let value = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => "z.boolean()".to_owned(), + ScalarType::Int16 => "z.number().int().min(-32768).max(32767)".to_owned(), + ScalarType::Int32 => "z.number().int().min(-2147483648).max(2147483647)".to_owned(), + ScalarType::Int64 => { + return Err(OrmError::Unsupported( + "int64 requires an explicit TypeScript wire mapping; JavaScript numbers cannot represent the full domain safely".to_owned(), + )); + } + ScalarType::Float32 => { + return Err(OrmError::Unsupported( + "float32 requires an admitted cross-runtime wire/domain mapping before TypeScript emission".to_owned(), + )); + } + ScalarType::Float64 => "z.number().finite()".to_owned(), + ScalarType::Decimal => { + return Err(OrmError::Unsupported( + "decimal requires an explicit TypeScript wire mapping".to_owned(), + )); + } + ScalarType::String => "z.string()".to_owned(), + ScalarType::Uuid => { + return Err(OrmError::Unsupported( + "uuid requires an admitted canonical string/validator mapping before TypeScript emission".to_owned(), + )); + } + ScalarType::Date => { + return Err(OrmError::Unsupported( + "date requires an admitted semantic calendar-date mapping before TypeScript emission".to_owned(), + )); + } + ScalarType::DateTime => { + return Err(OrmError::Unsupported( + "date-time requires an explicit TypeScript wire mapping".to_owned(), + )); + } + ScalarType::Bytes => { + return Err(OrmError::Unsupported( + "bytes require an explicit TypeScript wire mapping".to_owned(), + )); + } + ScalarType::Json => "z.unknown()".to_owned(), + }, + OrmType::Array(inner) => format!("z.array({})", zod_type(inner)?), + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before TypeScript emission" + ))); + } + }; + + return Ok(value); +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..871af51 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum OrmError { + #[error("could not parse Rust source: {0}")] + RustParse(#[from] syn::Error), + #[error("could not parse ORM policy: {0}")] + PolicyParse(#[from] toml::de::Error), + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("unsupported ORM construct: {0}")] + Unsupported(String), + #[error("invalid ORM input: {0}")] + Invalid(String), + #[error("ORM parity failed: {0}")] + Parity(String), +} + +pub type Result = std::result::Result; diff --git a/src/ir.rs b/src/ir.rs new file mode 100644 index 0000000..e117bf2 --- /dev/null +++ b/src/ir.rs @@ -0,0 +1,215 @@ +use crate::error::{OrmError, Result}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +pub const ORM_IR_SCHEMA: &str = "ores.orm-core.ir/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + Diesel, + SeaOrm, + Converged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ScalarType { + Boolean, + Int16, + Int32, + Int64, + Float32, + Float64, + Decimal, + String, + Uuid, + Date, + DateTime, + Bytes, + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum OrmType { + Scalar(ScalarType), + Array(Box), + /// A DB/ORM named type whose semantic definition is not present in this + /// structural lane. It may participate in ORM parity, but public validation + /// emission must resolve it against admitted contract evidence first. + Named(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Column { + pub rust_name: String, + pub db_name: String, + pub ordinal: usize, + pub ty: OrmType, + pub nullable: bool, + pub primary_key: bool, + pub unique: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Table { + pub schema_name: Option, + pub db_name: String, + pub columns: Vec, +} + +impl Table { + #[must_use] + pub fn qualified_name(&self) -> String { + return self.schema_name.as_ref().map_or_else( + || self.db_name.clone(), + |schema| format!("{schema}.{}", self.db_name), + ); + } + + #[must_use] + pub fn column(&self, db_name: &str) -> Option<&Column> { + return self.columns.iter().find(|column| column.db_name == db_name); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct OrmIr { + pub schema: String, + pub source: SourceKind, + pub tables: Vec
, +} + +impl OrmIr { + #[must_use] + pub fn new(source: SourceKind, mut tables: Vec
) -> Self { + tables.sort_by_key(Table::qualified_name); + for table in &mut tables { + table.columns.sort_by_key(|column| column.ordinal); + } + return Self { + schema: ORM_IR_SCHEMA.to_owned(), + source, + tables, + }; + } + + pub fn try_new(source: SourceKind, tables: Vec
) -> Result { + let ir = Self::new(source, tables); + ir.validate()?; + return Ok(ir); + } + + pub fn validate(&self) -> Result<()> { + if self.schema != ORM_IR_SCHEMA { + return Err(OrmError::Invalid(format!( + "unsupported ORM IR schema {}; expected {ORM_IR_SCHEMA}", + self.schema + ))); + } + + let mut qualified_table_names = BTreeSet::new(); + for table in &self.tables { + if table.db_name.trim().is_empty() { + return Err(OrmError::Invalid( + "ORM IR table database name must not be empty".to_owned(), + )); + } + if table + .schema_name + .as_ref() + .is_some_and(|schema| schema.trim().is_empty()) + { + return Err(OrmError::Invalid(format!( + "ORM IR table {} has an empty schema name", + table.db_name + ))); + } + + let qualified_name = table.qualified_name(); + if !qualified_table_names.insert(qualified_name.clone()) { + return Err(OrmError::Invalid(format!( + "ORM IR contains duplicate table identity {qualified_name}" + ))); + } + + let mut database_names = BTreeSet::new(); + let mut rust_names = BTreeSet::new(); + let mut ordinals = BTreeSet::new(); + for column in &table.columns { + if column.db_name.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} contains an empty database column name" + ))); + } + if column.rust_name.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} contains an empty Rust column name" + ))); + } + if !database_names.insert(column.db_name.clone()) { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} contains duplicate database column {}", + column.db_name + ))); + } + if !rust_names.insert(column.rust_name.clone()) { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} contains duplicate Rust column {}", + column.rust_name + ))); + } + if !ordinals.insert(column.ordinal) { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} contains duplicate ordinal {}", + column.ordinal + ))); + } + + validate_type(&column.ty, &format!("{qualified_name}.{}", column.db_name))?; + } + + for (expected_ordinal, column) in table.columns.iter().enumerate() { + if column.ordinal != expected_ordinal { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} has non-contiguous ordinal {} for column {}; expected {expected_ordinal}", + column.ordinal, column.db_name + ))); + } + } + } + + return Ok(()); + } + + #[must_use] + pub fn table(&self, qualified_name: &str) -> Option<&Table> { + return self + .tables + .iter() + .find(|table| table.qualified_name() == qualified_name); + } +} + +fn validate_type(ty: &OrmType, path: &str) -> Result<()> { + match ty { + OrmType::Scalar(_) => {} + OrmType::Array(inner) => { + validate_type(inner, path)?; + } + OrmType::Named(name) => { + if name.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "ORM IR named type at {path} must not be empty" + ))); + } + } + } + + return Ok(()); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7d497bc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,32 @@ +//! ORM-first derivative schema and validator tooling. +//! +//! Diesel and SeaORM are parsed independently into a normalized structural IR. +//! The two lanes must converge before derivative shapes are generated. The IR +//! and everything emitted from it are downstream evidence, never authored +//! contract authority. + +pub mod admission; +pub mod descriptor; +pub mod diesel; +pub mod emit; +pub mod error; +pub mod ir; +pub mod parity; +pub mod policy; +pub mod seaorm; +pub mod shapes; + +pub use admission::{ + REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS, TJSV_ADMISSION_BINDING_SCHEMA, TJSV_CONTRACT_IR_SCHEMA, + TJSV_PARITY_REPORT_SCHEMA, TJSV_PROJECTION_MANIFEST_SCHEMA, + TJSV_PROJECTION_VERIFICATION_RECEIPT_SCHEMA, TjsvAdmissionBinding, TjsvOutputBinding, +}; +pub use descriptor::{ + DescriptorField, DescriptorModel, DescriptorScalar, DescriptorSource, DescriptorType, + ORM_DESCRIPTOR_SCHEMA, OrmDescriptor, OrmWitness, describe_table, +}; +pub use error::{OrmError, Result}; +pub use ir::{Column, OrmIr, OrmType, ScalarType, SourceKind, Table}; +pub use parity::{Finding, ParityReport, compare}; +pub use policy::{FieldVisibility, Policy, TablePolicy}; +pub use shapes::{Shape, ShapeField, ShapeKind, derive_shape}; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 396cf85..0000000 --- a/src/main.rs +++ /dev/null @@ -1,18 +0,0 @@ -fn main() { - let runtime_greeting = rust_lib::greet("rust-app"); - assert_eq!( - runtime_greeting, "HELLO RUST-APP FROM ZED-PKG-TEST/RUST-LIB", - "target dependency did not receive the requested Cargo feature" - ); - assert_eq!( - env!("ZED_BUILD_GREETING"), - "hello build-script from zed-pkg-test/rust-lib", - "build dependency feature set leaked from the target graph" - ); - assert_eq!(rust_lib::format_count(1_000_001), "1000001"); - assert!(unicode_ident::is_xid_start('Δ')); - - println!("{runtime_greeting}"); - println!("{}", env!("ZED_BUILD_GREETING")); - println!("OK: Zed package + Cargo registry dependencies resolved together"); -} diff --git a/src/parity.rs b/src/parity.rs new file mode 100644 index 0000000..9054390 --- /dev/null +++ b/src/parity.rs @@ -0,0 +1,265 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{Column, OrmIr, SourceKind, Table}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Finding { + pub code: String, + pub path: String, + pub left: Option, + pub right: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ParityReport { + pub passed: bool, + pub findings: Vec, +} + +impl ParityReport { + pub fn require_passed(&self) -> Result<()> { + if self.passed { + return Ok(()); + } + let summary = self + .findings + .iter() + .map(|finding| format!("{} at {}", finding.code, finding.path)) + .collect::>() + .join("; "); + return Err(OrmError::Parity(summary)); + } +} + +type TableIdentity = (Option, String); + +#[must_use] +pub fn compare(left: &OrmIr, right: &OrmIr) -> ParityReport { + let mut findings = Vec::new(); + if let Err(error) = left.validate() { + findings.push(Finding { + code: "invalid_left_ir".to_owned(), + path: "$".to_owned(), + left: Some(error.to_string()), + right: None, + }); + } + if let Err(error) = right.validate() { + findings.push(Finding { + code: "invalid_right_ir".to_owned(), + path: "$".to_owned(), + left: None, + right: Some(error.to_string()), + }); + } + if !findings.is_empty() { + return ParityReport { + passed: false, + findings, + }; + } + + let left_tables = index_tables(left); + let right_tables = index_tables(right); + let names: BTreeSet<_> = left_tables + .keys() + .chain(right_tables.keys()) + .cloned() + .collect(); + + for identity in names { + match (left_tables.get(&identity), right_tables.get(&identity)) { + (Some(left_table), Some(right_table)) => { + compare_table(left_table, right_table, &mut findings); + } + (Some(left_table), None) => findings.push(Finding { + code: "table_missing_right".to_owned(), + path: left_table.qualified_name(), + left: Some("present".to_owned()), + right: None, + }), + (None, Some(right_table)) => findings.push(Finding { + code: "table_missing_left".to_owned(), + path: right_table.qualified_name(), + left: None, + right: Some("present".to_owned()), + }), + (None, None) => unreachable!("identity came from at least one map"), + } + } + + return ParityReport { + passed: findings.is_empty(), + findings, + }; +} + +pub fn converge(left: &OrmIr, right: &OrmIr) -> Result { + let independent_lanes = matches!( + (left.source, right.source), + (SourceKind::Diesel, SourceKind::SeaOrm) | (SourceKind::SeaOrm, SourceKind::Diesel) + ); + if !independent_lanes { + return Err(OrmError::Invalid(format!( + "ORM convergence requires one Diesel witness and one SeaORM witness; got {:?} and {:?}", + left.source, right.source + ))); + } + + let report = compare(left, right); + report.require_passed()?; + + let right_tables = index_tables(right); + let mut tables = Vec::with_capacity(left.tables.len()); + for left_table in &left.tables { + let identity = table_identity(left_table); + let right_table = right_tables.get(&identity).copied().ok_or_else(|| { + OrmError::Parity(format!( + "missing converged table {}", + left_table.qualified_name() + )) + })?; + tables.push(canonical_table(left_table, right_table)?); + } + + return OrmIr::try_new(SourceKind::Converged, tables); +} + +fn table_identity(table: &Table) -> TableIdentity { + return (table.schema_name.clone(), table.db_name.clone()); +} + +fn index_tables(ir: &OrmIr) -> BTreeMap { + return ir + .tables + .iter() + .map(|table| (table_identity(table), table)) + .collect(); +} + +fn canonical_table(left: &Table, right: &Table) -> Result
{ + let right_columns: BTreeMap<_, _> = right + .columns + .iter() + .map(|column| (column.db_name.as_str(), column)) + .collect(); + let mut columns = Vec::with_capacity(left.columns.len()); + + for left_column in &left.columns { + let right_column = right_columns + .get(left_column.db_name.as_str()) + .copied() + .ok_or_else(|| { + OrmError::Parity(format!( + "missing converged column {}.{}", + left.qualified_name(), + left_column.db_name + )) + })?; + + let rust_name = if left_column.rust_name == right_column.rust_name { + left_column.rust_name.clone() + } else { + // Rust-side identifiers are source-specific syntax, not persistence + // semantics. Use the exact database identity when the witnesses use + // different aliases so convergence is independent of argument order. + left_column.db_name.clone() + }; + + columns.push(Column { + rust_name, + db_name: left_column.db_name.clone(), + ordinal: left_column.ordinal, + ty: left_column.ty.clone(), + nullable: left_column.nullable, + primary_key: left_column.primary_key, + // `unique` is not currently proven by both parser lanes (Diesel + // print-schema does not carry it). Retain it only if both witnesses + // independently prove it so source-specific metadata cannot leak + // through whichever lane happened to be passed first. + unique: left_column.unique && right_column.unique, + }); + } + + return Ok(Table { + schema_name: left.schema_name.clone(), + db_name: left.db_name.clone(), + columns, + }); +} + +fn compare_table(left: &Table, right: &Table, findings: &mut Vec) { + let path = left.qualified_name(); + let left_columns: BTreeMap<_, _> = left + .columns + .iter() + .map(|column| (column.db_name.clone(), column)) + .collect(); + let right_columns: BTreeMap<_, _> = right + .columns + .iter() + .map(|column| (column.db_name.clone(), column)) + .collect(); + let names: BTreeSet<_> = left_columns + .keys() + .chain(right_columns.keys()) + .cloned() + .collect(); + + for name in names { + let field_path = format!("{path}.{name}"); + match (left_columns.get(&name), right_columns.get(&name)) { + (Some(left_column), Some(right_column)) => { + if left_column.ordinal != right_column.ordinal { + findings.push(Finding { + code: "column_ordinal_mismatch".to_owned(), + path: field_path.clone(), + left: Some(left_column.ordinal.to_string()), + right: Some(right_column.ordinal.to_string()), + }); + } + if left_column.ty != right_column.ty { + findings.push(Finding { + code: "column_type_mismatch".to_owned(), + path: field_path.clone(), + left: Some(format!("{:?}", left_column.ty)), + right: Some(format!("{:?}", right_column.ty)), + }); + } + if left_column.nullable != right_column.nullable { + findings.push(Finding { + code: "column_nullability_mismatch".to_owned(), + path: field_path.clone(), + left: Some(left_column.nullable.to_string()), + right: Some(right_column.nullable.to_string()), + }); + } + if left_column.primary_key != right_column.primary_key { + findings.push(Finding { + code: "column_primary_key_mismatch".to_owned(), + path: field_path, + left: Some(left_column.primary_key.to_string()), + right: Some(right_column.primary_key.to_string()), + }); + } + } + (Some(_), None) => findings.push(Finding { + code: "column_missing_right".to_owned(), + path: field_path, + left: Some("present".to_owned()), + right: None, + }), + (None, Some(_)) => findings.push(Finding { + code: "column_missing_left".to_owned(), + path: field_path, + left: None, + right: Some("present".to_owned()), + }), + (None, None) => unreachable!("name came from at least one map"), + } + } +} diff --git a/src/policy.rs b/src/policy.rs new file mode 100644 index 0000000..f65670a --- /dev/null +++ b/src/policy.rs @@ -0,0 +1,273 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{OrmIr, Table}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FieldVisibility { + Public, + Private, + Secret, + Unclassified, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct TablePolicy { + pub public_surface: bool, + pub generated: BTreeSet, + pub defaulted: BTreeSet, + pub immutable: BTreeSet, + pub private: BTreeSet, + pub secret: BTreeSet, + pub public_read: BTreeSet, + pub public_create: BTreeSet, + pub public_update: BTreeSet, + pub wire_names: BTreeMap, +} + +impl TablePolicy { + #[must_use] + pub fn visibility(&self, column: &str) -> FieldVisibility { + let is_public = self.public_read.contains(column) + || self.public_create.contains(column) + || self.public_update.contains(column); + + if is_public { + return FieldVisibility::Public; + } + if self.secret.contains(column) { + return FieldVisibility::Secret; + } + if self.private.contains(column) { + return FieldVisibility::Private; + } + + return FieldVisibility::Unclassified; + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Policy { + pub version: u32, + #[serde(default = "default_true")] + pub require_diesel: bool, + #[serde(default = "default_true")] + pub require_seaorm: bool, + #[serde(default)] + pub table: BTreeMap, +} + +const fn default_true() -> bool { + return true; +} + +impl Policy { + pub fn from_toml(source: &str) -> Result { + let policy: Self = toml::from_str(source)?; + if policy.version != 1 { + return Err(OrmError::Invalid(format!( + "unsupported .ores-orm.toml version {}", + policy.version + ))); + } + + return Ok(policy); + } + + #[must_use] + pub fn table_policy(&self, table: &Table) -> TablePolicy { + return self + .table + .get(&table.qualified_name()) + .or_else(|| self.table.get(&table.db_name)) + .cloned() + .unwrap_or_default(); + } + + /// Validate the complete policy against one exact ORM IR. This is the + /// release-facing check: it rejects typo/orphan table keys and ambiguous + /// unqualified names instead of silently leaving those entries unused. + pub fn validate_ir(&self, ir: &OrmIr) -> Result<()> { + ir.validate()?; + + for table in &ir.tables { + self.validate_table(table)?; + } + + for configured_key in self.table.keys() { + let matches = ir + .tables + .iter() + .filter(|table| { + configured_key.as_str() == table.qualified_name() + || configured_key.as_str() == table.db_name + }) + .collect::>(); + + match matches.as_slice() { + [] => { + return Err(OrmError::Invalid(format!( + "policy table {configured_key:?} does not match any ORM IR table" + ))); + } + [_] => {} + [first, second, ..] => { + return Err(OrmError::Invalid(format!( + "policy table {configured_key:?} is ambiguous between {} and {}; use qualified table keys", + first.qualified_name(), + second.qualified_name() + ))); + } + } + } + + return Ok(()); + } + + pub fn validate_table(&self, table: &Table) -> Result<()> { + let qualified_name = table.qualified_name(); + if qualified_name != table.db_name + && self.table.contains_key(&qualified_name) + && self.table.contains_key(&table.db_name) + { + return Err(OrmError::Invalid(format!( + "policy configures table {} through both qualified key {qualified_name:?} and unqualified key {:?}; refusing precedence-based resolution", + table.qualified_name(), + table.db_name + ))); + } + + let policy = self.table_policy(table); + let actual: BTreeSet<_> = table + .columns + .iter() + .map(|column| column.db_name.as_str()) + .collect(); + + for (label, names) in [ + ("generated", &policy.generated), + ("defaulted", &policy.defaulted), + ("immutable", &policy.immutable), + ("private", &policy.private), + ("secret", &policy.secret), + ("public_read", &policy.public_read), + ("public_create", &policy.public_create), + ("public_update", &policy.public_update), + ] { + for name in names { + if !actual.contains(name.as_str()) { + return Err(OrmError::Invalid(format!( + "policy {label} references unknown column {}.{name}", + table.qualified_name() + ))); + } + } + } + + for name in policy.wire_names.keys() { + if !actual.contains(name.as_str()) { + return Err(OrmError::Invalid(format!( + "policy wire_names references unknown column {}.{name}", + table.qualified_name() + ))); + } + } + + let mut effective_wire_names = BTreeMap::new(); + for column in &table.columns { + let wire_name = policy + .wire_names + .get(&column.db_name) + .map_or(column.db_name.as_str(), String::as_str); + if wire_name.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "policy wire_names maps {}.{} to an empty wire name", + table.qualified_name(), + column.db_name + ))); + } + if let Some(previous_column) = + effective_wire_names.insert(wire_name.to_owned(), column.db_name.clone()) + { + return Err(OrmError::Invalid(format!( + "policy wire name {wire_name:?} collides for columns {}.{previous_column} and {}.{}", + table.qualified_name(), + table.qualified_name(), + column.db_name + ))); + } + } + + let has_public_fields = !policy.public_read.is_empty() + || !policy.public_create.is_empty() + || !policy.public_update.is_empty(); + if has_public_fields && !policy.public_surface { + return Err(OrmError::Invalid(format!( + "table {} declares public fields but public_surface is false", + table.qualified_name() + ))); + } + + let public_fields: BTreeSet<_> = policy + .public_read + .iter() + .chain(policy.public_create.iter()) + .chain(policy.public_update.iter()) + .cloned() + .collect(); + + for name in &public_fields { + if policy.private.contains(name) || policy.secret.contains(name) { + return Err(OrmError::Invalid(format!( + "column {}.{name} is both public and private/secret", + table.qualified_name() + ))); + } + } + + for name in &policy.private { + if policy.secret.contains(name) { + return Err(OrmError::Invalid(format!( + "column {}.{name} is both private and secret", + table.qualified_name() + ))); + } + } + + for name in &policy.public_create { + if policy.generated.contains(name) { + return Err(OrmError::Invalid(format!( + "column {}.{name} is both public_create and generated; refusing to silently drop it from create projections", + table.qualified_name() + ))); + } + } + + for name in &policy.public_update { + if policy.generated.contains(name) || policy.immutable.contains(name) { + return Err(OrmError::Invalid(format!( + "column {}.{name} is public_update but generated or immutable; refusing to silently drop it from update projections", + table.qualified_name() + ))); + } + } + + if policy.public_surface { + for column in &table.columns { + if policy.visibility(&column.db_name) == FieldVisibility::Unclassified { + return Err(OrmError::Invalid(format!( + "public table {} leaves column {} unclassified", + table.qualified_name(), + column.db_name + ))); + } + } + } + + return Ok(()); + } +} diff --git a/src/seaorm.rs b/src/seaorm.rs new file mode 100644 index 0000000..0ace0df --- /dev/null +++ b/src/seaorm.rs @@ -0,0 +1,336 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{Column, OrmIr, OrmType, ScalarType, SourceKind, Table}; +use quote::ToTokens; +use std::path::Path; +use syn::{Attribute, Fields, GenericArgument, Item, ItemStruct, LitStr, PathArguments, Type}; +use walkdir::WalkDir; + +/// Parse one Rust source file containing one or more SeaORM entities. +pub fn parse_source(source: &str) -> Result { + let file = syn::parse_file(source)?; + let mut tables = Vec::new(); + collect_items(&file.items, &mut tables)?; + if tables.is_empty() { + return Err(OrmError::Invalid( + "SeaORM input contained no DeriveEntityModel models".to_owned(), + )); + } + return OrmIr::try_new(SourceKind::SeaOrm, tables); +} + +/// Parse every `.rs` file under a generated/entity source directory. +pub fn parse_directory(root: impl AsRef) -> Result { + let mut tables = Vec::new(); + for entry in WalkDir::new(root) { + let entry = entry.map_err(|error| OrmError::Io(std::io::Error::other(error)))?; + if !entry.file_type().is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some("rs") + { + continue; + } + let source = std::fs::read_to_string(entry.path())?; + let file = syn::parse_file(&source)?; + collect_items(&file.items, &mut tables)?; + } + if tables.is_empty() { + return Err(OrmError::Invalid( + "SeaORM directory contained no DeriveEntityModel models".to_owned(), + )); + } + return OrmIr::try_new(SourceKind::SeaOrm, tables); +} + +fn collect_items(items: &[Item], tables: &mut Vec
) -> Result<()> { + for item in items { + match item { + Item::Struct(item_struct) if is_entity_model(item_struct) => { + tables.push(parse_model(item_struct)?); + } + Item::Mod(module) => { + if let Some((_, nested)) = &module.content { + collect_items(nested, tables)?; + } + } + _ => {} + } + } + return Ok(()); +} + +fn is_entity_model(item: &ItemStruct) -> bool { + return item.attrs.iter().any(|attr| { + if !attr.path().is_ident("derive") { + return false; + } + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "DeriveEntityModel") + { + found = true; + } + return Ok(()); + }); + return found; + }); +} + +fn parse_model(item: &ItemStruct) -> Result
{ + let options = sea_orm_options(&item.attrs)?; + ensure_supported_options(&options, &format!("SeaORM model {}", item.ident))?; + if options.ignore { + return Err(OrmError::Unsupported(format!( + "SeaORM model {} cannot use #[sea_orm(ignore)]", + item.ident + ))); + } + + let table_name = options.table_name.ok_or_else(|| { + OrmError::Invalid(format!( + "SeaORM model {} has no #[sea_orm(table_name = ...)]", + item.ident + )) + })?; + let Fields::Named(fields) = &item.fields else { + return Err(OrmError::Unsupported(format!( + "SeaORM model {} is not a named-field struct", + item.ident + ))); + }; + + let mut columns = Vec::new(); + for field in &fields.named { + let Some(ident) = &field.ident else { + continue; + }; + if is_relation_type(&field.ty) { + continue; + } + let field_options = sea_orm_options(&field.attrs)?; + if field_options.ignore { + continue; + } + ensure_supported_options( + &field_options, + &format!("SeaORM field {}.{}", item.ident, ident), + )?; + + let rust_name = ident.to_string(); + let db_name = field_options + .column_name + .clone() + .unwrap_or_else(|| rust_name.clone()); + let (inner_type, nullable) = unwrap_option(&field.ty)?; + if field_options.nullable && !nullable { + return Err(OrmError::Unsupported(format!( + "SeaORM field {}.{} uses #[sea_orm(nullable)] without Option; explicit nullability mapping is required", + item.ident, ident + ))); + } + let ty = map_rust_type(inner_type)?; + columns.push(Column { + rust_name, + db_name, + ordinal: columns.len(), + ty, + nullable, + primary_key: field_options.primary_key, + unique: field_options.unique, + }); + } + + return Ok(Table { + schema_name: options.schema_name, + db_name: table_name, + columns, + }); +} + +#[derive(Default)] +struct SeaOrmOptions { + table_name: Option, + schema_name: Option, + column_name: Option, + primary_key: bool, + unique: bool, + ignore: bool, + nullable: bool, + unsupported_semantic_overrides: Vec, +} + +fn sea_orm_options(attrs: &[Attribute]) -> Result { + let mut options = SeaOrmOptions::default(); + for attr in attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("primary_key") { + options.primary_key = true; + return Ok(()); + } + if meta.path.is_ident("unique") { + options.unique = true; + return Ok(()); + } + if meta.path.is_ident("ignore") { + options.ignore = true; + return Ok(()); + } + if meta.path.is_ident("nullable") { + options.nullable = true; + return Ok(()); + } + if meta.path.is_ident("indexed") { + return Ok(()); + } + if meta.path.is_ident("table_name") { + options.table_name = Some(meta.value()?.parse::()?.value()); + return Ok(()); + } + if meta.path.is_ident("schema_name") { + options.schema_name = Some(meta.value()?.parse::()?.value()); + return Ok(()); + } + if meta.path.is_ident("column_name") { + options.column_name = Some(meta.value()?.parse::()?.value()); + return Ok(()); + } + if meta.path.is_ident("column_type") + || meta.path.is_ident("select_as") + || meta.path.is_ident("save_as") + { + let name = meta.path.to_token_stream().to_string().replace(' ', ""); + let _ = meta.value()?.parse::()?; + options.unsupported_semantic_overrides.push(name); + return Ok(()); + } + if meta.path.is_ident("auto_increment") + || meta.path.is_ident("default_value") + || meta.path.is_ident("default_expr") + || meta.path.is_ident("comment") + { + if meta.input.peek(syn::Token![=]) { + let _ = meta.value()?.parse::()?; + } + return Ok(()); + } + + return Err(meta.error( + "unsupported SeaORM #[sea_orm(...)] option; add explicit ORM IR semantics before accepting it", + )); + })?; + } + return Ok(options); +} + +fn ensure_supported_options(options: &SeaOrmOptions, context: &str) -> Result<()> { + if options.unsupported_semantic_overrides.is_empty() { + return Ok(()); + } + + return Err(OrmError::Unsupported(format!( + "{context} uses SeaORM semantic override(s) {} that can change database type behavior; explicit mapping is required", + options.unsupported_semantic_overrides.join(", ") + ))); +} + +fn is_relation_type(ty: &Type) -> bool { + let Type::Path(path) = ty else { + return false; + }; + return path + .path + .segments + .last() + .is_some_and(|segment| matches!(segment.ident.to_string().as_str(), "HasOne" | "HasMany")); +} + +fn unwrap_option(ty: &Type) -> Result<(&Type, bool)> { + let Type::Path(path) = ty else { + return Ok((ty, false)); + }; + let Some(segment) = path.path.segments.last() else { + return Ok((ty, false)); + }; + if segment.ident != "Option" { + return Ok((ty, false)); + } + let inner = single_generic_type(segment)?; + return Ok((inner, true)); +} + +fn map_rust_type(ty: &Type) -> Result { + let Type::Path(path) = ty else { + return Err(OrmError::Unsupported(format!( + "SeaORM field type is not a path: {}", + ty.to_token_stream() + ))); + }; + let segment = path.path.segments.last().ok_or_else(|| { + OrmError::Unsupported("SeaORM field type path has no final segment".to_owned()) + })?; + let name = segment.ident.to_string(); + + if name == "Vec" { + let inner = single_generic_type(segment)?; + let (inner, element_nullable) = unwrap_option(inner)?; + if element_nullable { + return Err(OrmError::Unsupported( + "nullable SeaORM array elements require explicit contract evidence".to_owned(), + )); + } + if matches!(inner, Type::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "u8")) + { + return Ok(OrmType::Scalar(ScalarType::Bytes)); + } + return Ok(OrmType::Array(Box::new(map_rust_type(inner)?))); + } + + let scalar = match name.as_str() { + "bool" => Some(ScalarType::Boolean), + "i16" => Some(ScalarType::Int16), + "i32" => Some(ScalarType::Int32), + "i64" => Some(ScalarType::Int64), + "f32" => Some(ScalarType::Float32), + "f64" => Some(ScalarType::Float64), + "Decimal" | "BigDecimal" => Some(ScalarType::Decimal), + "String" => Some(ScalarType::String), + "Uuid" => Some(ScalarType::Uuid), + "Date" | "NaiveDate" => Some(ScalarType::Date), + "DateTime" | "DateTimeUtc" | "DateTimeWithTimeZone" | "NaiveDateTime" + | "OffsetDateTime" | "PrimitiveDateTime" => Some(ScalarType::DateTime), + "Json" | "JsonValue" | "Value" => Some(ScalarType::Json), + _ => None, + }; + + return Ok(scalar.map_or_else( + || OrmType::Named(path.path.to_token_stream().to_string().replace(' ', "")), + OrmType::Scalar, + )); +} + +fn single_generic_type(segment: &syn::PathSegment) -> Result<&Type> { + let PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return Err(OrmError::Unsupported(format!( + "{} requires exactly one type argument", + segment.ident + ))); + }; + if arguments.args.len() != 1 { + return Err(OrmError::Unsupported(format!( + "{} requires exactly one type argument", + segment.ident + ))); + } + let Some(GenericArgument::Type(inner)) = arguments.args.first() else { + return Err(OrmError::Unsupported(format!( + "{} requires exactly one type argument", + segment.ident + ))); + }; + return Ok(inner); +} diff --git a/src/shapes.rs b/src/shapes.rs new file mode 100644 index 0000000..d7f5b73 --- /dev/null +++ b/src/shapes.rs @@ -0,0 +1,177 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, Table}; +use crate::policy::Policy; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ShapeKind { + Row, + Create, + Update, + Patch, + PublicRead, + PublicCreate, + PublicUpdate, + PublicPatch, +} + +impl ShapeKind { + #[must_use] + pub const fn is_public(self) -> bool { + return matches!( + self, + Self::PublicRead | Self::PublicCreate | Self::PublicUpdate | Self::PublicPatch + ); + } + + #[must_use] + pub const fn requires_non_empty_object(self) -> bool { + return matches!(self, Self::Patch | Self::PublicPatch); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ShapeField { + pub db_name: String, + pub wire_name: String, + pub ty: OrmType, + pub nullable: bool, + pub required: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Shape { + pub table: String, + pub kind: ShapeKind, + pub fields: Vec, +} + +impl Shape { + pub fn validate(&self) -> Result<()> { + if self.table.trim().is_empty() { + return Err(OrmError::Invalid( + "derived shape table identity must not be empty".to_owned(), + )); + } + if self.fields.is_empty() { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} contains no fields", + self.kind, self.table + ))); + } + + let mut database_names = BTreeSet::new(); + let mut wire_names = BTreeSet::new(); + for field in &self.fields { + if field.db_name.trim().is_empty() || field.wire_name.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} contains an empty database or wire field name", + self.kind, self.table + ))); + } + if !database_names.insert(field.db_name.clone()) { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} contains duplicate database field {}", + self.kind, self.table, field.db_name + ))); + } + if !wire_names.insert(field.wire_name.clone()) { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} contains duplicate wire field {}", + self.kind, self.table, field.wire_name + ))); + } + + if matches!(self.kind, ShapeKind::Row | ShapeKind::PublicRead) && !field.required { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} cannot make row field {} optional", + self.kind, self.table, field.db_name + ))); + } + if matches!(self.kind, ShapeKind::Patch | ShapeKind::PublicPatch) && field.required { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} cannot require patch field {}", + self.kind, self.table, field.db_name + ))); + } + } + + return Ok(()); + } +} + +pub fn derive_shape(table: &Table, policy: &Policy, kind: ShapeKind) -> Result { + policy.validate_table(table)?; + let table_policy = policy.table_policy(table); + + if kind.is_public() && !table_policy.public_surface { + return Err(OrmError::Invalid(format!( + "cannot derive public shape for {} without public_surface = true", + table.qualified_name() + ))); + } + + let fields = table + .columns + .iter() + .filter_map(|column| { + let name = &column.db_name; + let include = match kind { + ShapeKind::Row => true, + ShapeKind::Create => !table_policy.generated.contains(name), + ShapeKind::Update | ShapeKind::Patch => { + !table_policy.generated.contains(name) && !table_policy.immutable.contains(name) + } + ShapeKind::PublicRead => table_policy.public_read.contains(name), + ShapeKind::PublicCreate => { + table_policy.public_create.contains(name) + && !table_policy.generated.contains(name) + } + ShapeKind::PublicUpdate | ShapeKind::PublicPatch => { + table_policy.public_update.contains(name) + && !table_policy.generated.contains(name) + && !table_policy.immutable.contains(name) + } + }; + + if !include { + return None; + } + + let required = match kind { + ShapeKind::Row | ShapeKind::PublicRead => true, + ShapeKind::Create | ShapeKind::PublicCreate => { + !column.nullable && !table_policy.defaulted.contains(name) + } + ShapeKind::Update | ShapeKind::PublicUpdate => true, + ShapeKind::Patch | ShapeKind::PublicPatch => false, + }; + let wire_name = table_policy + .wire_names + .get(name) + .cloned() + .unwrap_or_else(|| name.clone()); + + return Some(ShapeField { + db_name: name.clone(), + wire_name, + ty: column.ty.clone(), + nullable: column.nullable, + required, + }); + }) + .collect(); + + let shape = Shape { + table: table.qualified_name(), + kind, + fields, + }; + shape.validate()?; + return Ok(shape); +} diff --git a/tests/admission.rs b/tests/admission.rs new file mode 100644 index 0000000..bfce388 --- /dev/null +++ b/tests/admission.rs @@ -0,0 +1,465 @@ +use ores_orm_core::diesel; +use ores_orm_core::emit::bundle::{ + self, ContractEvidenceState, DerivativeManifest, GeneratedBundle, GenerationEvidence, + PublicationState, +}; +use ores_orm_core::parity; +use ores_orm_core::policy::Policy; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{ShapeKind, derive_shape}; +use ores_orm_core::{REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS, TjsvAdmissionBinding}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +const DECLARATION: &str = "Example.Accounts.Users.PublicCreate"; +fn digest(bytes: &[u8]) -> String { + return hex::encode(Sha256::digest(bytes)); +} + +fn canonical_json(value: &Value) -> String { + match value { + Value::Null => "null".to_owned(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => serde_json::to_string(value).expect("JSON string"), + Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(canonical_json) + .collect::>() + .join(",") + ), + Value::Object(object) => { + let mut keys = object.keys().collect::>(); + keys.sort(); + let entries = keys + .into_iter() + .map(|key| { + format!( + "{}:{}", + serde_json::to_string(key).expect("JSON key"), + canonical_json(object.get(key).expect("canonical key")) + ) + }) + .collect::>() + .join(","); + format!("{{{entries}}}") + } + } +} + +fn canonical_digest(value: &Value) -> String { + return digest(canonical_json(value).as_bytes()); +} + +fn converged() -> ores_orm_core::OrmIr { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let seaorm = seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"); + return parity::converge(&diesel, &seaorm).expect("fixtures should converge"); +} + +fn public_create_candidate() -> ( + ores_orm_core::OrmIr, + Policy, + ores_orm_core::Shape, + GeneratedBundle, +) { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy"); + let shape = derive_shape( + ir.table("users").expect("users"), + &policy, + ShapeKind::PublicCreate, + ) + .expect("public create"); + let evidence = GenerationEvidence::from_bytes( + &ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"admission-v2", + None, + ) + .expect("candidate evidence"); + let candidate = bundle::emit(&ir, &policy, &shape, &evidence).expect("candidate bundle"); + return (ir, policy, shape, candidate); +} + +fn media_type(path: &str) -> &'static str { + if path.starts_with("rust/") { + return "text/x-rust"; + } + if path.starts_with("typescript/") { + return "application/typescript"; + } + if path.starts_with("dart/") { + return "text/x-dart"; + } + if path.starts_with("gleam/") { + return "text/x-gleam"; + } + return "application/schema+json"; +} + +fn artifact<'a>( + candidate: &'a GeneratedBundle, + prefix: &str, +) -> &'a ores_orm_core::emit::bundle::GeneratedArtifact { + return candidate + .artifacts + .iter() + .find(|artifact| artifact.path.starts_with(prefix)) + .expect("language artifact"); +} + +fn tjsv_fixture(candidate: &GeneratedBundle) -> (Vec, Vec, Vec, TjsvAdmissionBinding) { + let projection_id = candidate.manifest.projection_identity.clone(); + let run_id = "a".repeat(64); + let parity_digest = "b".repeat(64); + let typespec_digest = "c".repeat(64); + let generated_schema_digest = "d".repeat(64); + let authored_schema_digest = "e".repeat(64); + + let mut contract = json!({ + "schema": "ores.typespec-json-schema-validator.contract-ir/v1", + "status": "passed", + "admissible": true, + "role": "downstream-derived-parity-artifact", + "editableAuthority": false, + "authorities": { + "typespec": "independently-authored", + "jsonSchema": "independently-authored", + "generatedJsonSchema": "comparison-evidence-only", + "precedence": "none" + }, + "admission": { + "receipt": { + "schema": "ores.typespec-json-schema-validator.report/v1", + "runId": run_id.clone(), + "digest": parity_digest.clone(), + "status": "passed", + "zeroUnexplainedFindings": true + }, + "requirements": { + "exactInputDigests": true, + "directDeclarationInventory": true, + "generatedSchemaComparison": true, + "differentialInstanceValidation": true, + "zeroUnexplainedFindings": true + } + }, + "provenance": { + "typespec": { + "role": "independently-authored-authority", + "digest": typespec_digest.clone(), + "files": [] + }, + "generatedJsonSchema": { + "role": "comparison-evidence-only", + "digest": generated_schema_digest.clone(), + "files": [] + }, + "authoredJsonSchema": { + "role": "independently-authored-authority", + "digest": authored_schema_digest.clone(), + "files": [] + } + }, + "declarations": [{ + "id": DECLARATION, + "kind": "model" + }], + "excludedDeclarations": [], + "outOfScopeDeclarations": [] + }); + let ir_id = canonical_digest(&contract); + contract + .as_object_mut() + .expect("contract object") + .insert("irId".to_owned(), Value::String(ir_id.clone())); + let contract_digest = canonical_digest(&contract); + + let outputs = candidate + .artifacts + .iter() + .map(|artifact| { + json!({ + "path": artifact.path.clone(), + "sha256": artifact.sha256.clone(), + "size": artifact.content.len(), + "mediaType": media_type(&artifact.path), + "projection": projection_id.clone() + }) + }) + .collect::>(); + let output_paths = candidate + .artifacts + .iter() + .map(|artifact| artifact.path.clone()) + .collect::>(); + + let rust = artifact(candidate, "rust/"); + let typescript = artifact(candidate, "typescript/"); + let dart = artifact(candidate, "dart/"); + let gleam = artifact(candidate, "gleam/"); + let runtime_validators = vec![ + json!({ + "id": "rust-serde", + "projection": projection_id.clone(), + "artifactPath": rust.path.clone(), + "artifactDigest": rust.sha256.clone(), + "fixtureDigest": "1".repeat(64), + "ingressEgressCoverageDigest": "6".repeat(64) + }), + json!({ + "id": "typescript-zod", + "projection": projection_id.clone(), + "artifactPath": typescript.path.clone(), + "artifactDigest": typescript.sha256.clone(), + "fixtureDigest": "2".repeat(64), + "ingressEgressCoverageDigest": "7".repeat(64) + }), + json!({ + "id": "dart-json", + "projection": projection_id.clone(), + "artifactPath": dart.path.clone(), + "artifactDigest": dart.sha256.clone(), + "fixtureDigest": "3".repeat(64), + "ingressEgressCoverageDigest": "8".repeat(64) + }), + json!({ + "id": "gleam-erlang", + "projection": projection_id.clone(), + "artifactPath": gleam.path.clone(), + "artifactDigest": gleam.sha256.clone(), + "fixtureDigest": "4".repeat(64), + "ingressEgressCoverageDigest": "9".repeat(64) + }), + json!({ + "id": "gleam-js", + "projection": projection_id.clone(), + "artifactPath": gleam.path.clone(), + "artifactDigest": gleam.sha256.clone(), + "fixtureDigest": "5".repeat(64), + "ingressEgressCoverageDigest": "0".repeat(64) + }), + ]; + + let mut manifest = json!({ + "schema": "ores.typespec-json-schema-validator.projection-manifest/v1", + "status": "passed", + "contract": { + "contractIrId": ir_id.clone(), + "contractIrDigest": contract_digest, + "receiptRunId": run_id.clone(), + "receiptDigest": parity_digest, + "sourceDigests": { + "typespec": typespec_digest.clone(), + "generatedJsonSchema": generated_schema_digest.clone(), + "authoredJsonSchema": authored_schema_digest.clone() + } + }, + "inputs": { + "operationInventory": { + "path": "projection/operations.json", + "sha256": "1".repeat(64), + "size": 1 + }, + "projectionMetadata": { + "path": "projection/metadata.json", + "sha256": "2".repeat(64), + "size": 1 + }, + "emitterConfiguration": { + "path": "projection/emitter.json", + "sha256": "3".repeat(64), + "size": 1 + } + }, + "toolchains": [{ + "id": "ores-orm-core", + "version": env!("CARGO_PKG_VERSION"), + "artifactDigest": "f".repeat(64) + }], + "declarations": [DECLARATION], + "projections": [{ + "id": projection_id, + "emitter": "ores-orm-core", + "declarationIds": [DECLARATION], + "outputPaths": output_paths, + "representationDeltaIds": [], + "runtimeValidatorIds": REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS + }], + "outputs": outputs, + "representationDeltas": [], + "runtimeValidators": runtime_validators + }); + let manifest_id = canonical_digest(&manifest); + manifest + .as_object_mut() + .expect("manifest object") + .insert("manifestId".to_owned(), Value::String(manifest_id.clone())); + + let mut receipt = json!({ + "schema": "ores.typespec-json-schema-validator.projection-verification-receipt/v1", + "status": "passed", + "admissible": true, + "manifestId": manifest_id, + "contractIrId": ir_id, + "receiptRunId": run_id, + "evidenceDigest": "f".repeat(64), + "sourceDigests": { + "typespec": typespec_digest, + "generatedJsonSchema": generated_schema_digest, + "authoredJsonSchema": authored_schema_digest + }, + "summary": { + "declarations": 1, + "projections": 1, + "outputs": candidate.artifacts.len(), + "representationDeltas": 0, + "runtimeValidators": REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS.len() + }, + "findingRuleIds": [], + "failureCode": null + }); + let verification_id = canonical_digest(&receipt); + receipt + .as_object_mut() + .expect("receipt object") + .insert("verificationId".to_owned(), Value::String(verification_id)); + + let contract_bytes = serde_json::to_vec(&contract).expect("contract bytes"); + let manifest_bytes = serde_json::to_vec(&manifest).expect("manifest bytes"); + let receipt_bytes = serde_json::to_vec(&receipt).expect("receipt bytes"); + let declarations = vec![DECLARATION.to_owned()]; + let binding = TjsvAdmissionBinding::verify( + &contract_bytes, + &manifest_bytes, + &receipt_bytes, + &candidate.manifest.projection_identity, + &declarations, + ) + .expect("fixture should verify"); + + return (contract_bytes, manifest_bytes, receipt_bytes, binding); +} + +#[test] +fn verified_projection_receipt_is_required_for_public_promotion() { + let (ir, policy, shape, candidate) = public_create_candidate(); + let (contract_ir, _manifest, projection_receipt, admission) = tjsv_fixture(&candidate); + let evidence = GenerationEvidence::from_bytes( + &ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"admission-v2", + Some((&contract_ir, &projection_receipt)), + ) + .expect("generation evidence"); + + let bound = bundle::emit(&ir, &policy, &shape, &evidence).expect("bound candidate"); + assert_eq!( + bound.manifest.contract_evidence, + ContractEvidenceState::EvidenceBound + ); + assert_eq!( + bound.manifest.publication, + PublicationState::BlockedPendingContractAdmission + ); + assert!(!bound.manifest.publishable()); + + let admitted = bundle::emit_admitted(&ir, &policy, &shape, &evidence, &admission) + .expect("admitted bundle"); + assert_eq!( + admitted.manifest.contract_evidence, + ContractEvidenceState::Admitted + ); + assert_eq!( + admitted.manifest.publication, + PublicationState::PublicAdmitted + ); + assert!(admitted.manifest.publishable()); + assert_eq!( + admitted.manifest.projection_identity, + admission.projection_id() + ); + assert_eq!(admission.declaration_ids(), &[DECLARATION.to_owned()]); + assert_eq!(admission.runtime_validator_ids().len(), 5); + assert_eq!(admission.outputs().len(), 5); +} + +#[test] +fn projection_admission_rejects_wrong_scope_and_tampered_receipt() { + let (_ir, _policy, _shape, candidate) = public_create_candidate(); + let (contract_ir, manifest, receipt, _) = tjsv_fixture(&candidate); + assert!( + TjsvAdmissionBinding::verify( + &contract_ir, + &manifest, + &receipt, + "orm.bad.public_create", + &[DECLARATION.to_owned()], + ) + .is_err() + ); + + let mut tampered: Value = serde_json::from_slice(&receipt).expect("receipt JSON"); + tampered["summary"]["runtimeValidators"] = json!(4); + let tampered = serde_json::to_vec(&tampered).expect("tampered receipt"); + assert!( + TjsvAdmissionBinding::verify( + &contract_ir, + &manifest, + &tampered, + &candidate.manifest.projection_identity, + &[DECLARATION.to_owned()], + ) + .is_err() + ); +} + +#[test] +fn admitted_bundle_requires_exact_verified_output_and_receipt_bytes() { + let (ir, policy, shape, candidate) = public_create_candidate(); + let (contract_ir, _manifest, receipt, admission) = tjsv_fixture(&candidate); + + let altered_receipt = [receipt.as_slice(), b"\n"].concat(); + let mismatched_evidence = GenerationEvidence::from_bytes( + &ir, + &[("fixtures/diesel/schema.rs", DIESEL.as_bytes())], + b"admission-v2", + Some((&contract_ir, &altered_receipt)), + ) + .expect("digest binding accepts exact supplied bytes"); + assert!(bundle::emit_admitted(&shape, &mismatched_evidence, &admission).is_err()); + + let evidence = GenerationEvidence::from_bytes( + &ir, + &[("fixtures/diesel/schema.rs", DIESEL.as_bytes())], + b"admission-v2", + Some((&contract_ir, &receipt)), + ) + .expect("evidence"); + let admitted = bundle::emit_admitted(&ir, &policy, &shape, &evidence, &admission) + .expect("admitted bundle"); + + let serialized = + serde_json::to_string(&admitted.manifest).expect("serialize admitted manifest"); + let replayed: DerivativeManifest = + serde_json::from_str(&serialized).expect("deserialize manifest"); + assert!( + !replayed.publishable(), + "deserialization must clear the in-process verified capability" + ); +} diff --git a/tests/audit_v2.rs b/tests/audit_v2.rs new file mode 100644 index 0000000..104a25b --- /dev/null +++ b/tests/audit_v2.rs @@ -0,0 +1,325 @@ +use ores_orm_core::diesel; +use ores_orm_core::emit::bundle::{ + self, ContractEvidenceState, GenerationEvidence, PublicationState, +}; +use ores_orm_core::emit::json_schema; +use ores_orm_core::ir::{Column, OrmIr, OrmType, ScalarType, SourceKind, Table}; +use ores_orm_core::parity; +use ores_orm_core::policy::{Policy, TablePolicy}; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{Shape, ShapeField, ShapeKind, derive_shape}; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +fn witnesses() -> (OrmIr, OrmIr) { + return ( + diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"), + seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"), + ); +} + +fn converged() -> OrmIr { + let (diesel, seaorm) = witnesses(); + return parity::converge(&diesel, &seaorm).expect("fixtures should converge"); +} + +fn evidence(ir: &OrmIr) -> GenerationEvidence { + return GenerationEvidence::from_bytes( + ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"audit-v2", + None, + ) + .expect("evidence"); +} + +fn empty_policy() -> Policy { + Policy::from_toml("version = 1\nrequire_diesel = true\nrequire_seaorm = true\n") + .expect("empty private policy") +} + +fn private_ir(table_name: &str, fields: &[(&str, &str)]) -> OrmIr { + let columns = fields + .iter() + .enumerate() + .map(|(ordinal, (db_name, rust_name))| Column { + rust_name: (*rust_name).to_owned(), + db_name: (*db_name).to_owned(), + ordinal, + ty: OrmType::Scalar(ScalarType::String), + nullable: false, + primary_key: false, + unique: false, + }) + .collect(); + OrmIr::try_new( + SourceKind::Converged, + vec![Table { + schema_name: None, + db_name: table_name.to_owned(), + columns, + }], + ) + .expect("synthetic private IR") +} + +fn synthetic_evidence(ir: &OrmIr) -> GenerationEvidence { + GenerationEvidence::from_bytes(ir, &[("synthetic", b"synthetic")], b"audit-v2", None) + .expect("synthetic evidence") +} + +#[test] +fn convergence_is_commutative_and_does_not_leak_source_only_unique_metadata() { + let (diesel, seaorm) = witnesses(); + assert!( + !diesel + .table("users") + .unwrap() + .column("email") + .unwrap() + .unique + ); + assert!( + seaorm + .table("users") + .unwrap() + .column("email") + .unwrap() + .unique + ); + + let diesel_first = parity::converge(&diesel, &seaorm).expect("converge"); + let seaorm_first = parity::converge(&seaorm, &diesel).expect("converge"); + assert_eq!(diesel_first, seaorm_first); + assert!( + !diesel_first + .table("users") + .unwrap() + .column("email") + .unwrap() + .unique + ); +} + +#[test] +fn ordinal_drift_is_a_blocking_parity_finding() { + let (diesel, mut seaorm) = witnesses(); + let table = seaorm.tables.first_mut().expect("users table"); + table.columns.swap(1, 2); + for (ordinal, column) in table.columns.iter_mut().enumerate() { + column.ordinal = ordinal; + } + seaorm + .validate() + .expect("drifted witness remains structurally valid"); + + let report = parity::compare(&diesel, &seaorm); + assert!(!report.passed); + assert!( + report + .findings + .iter() + .any(|finding| finding.code == "column_ordinal_mismatch") + ); +} + +#[test] +fn complete_policy_validation_rejects_orphan_table_entries() { + let ir = converged(); + let mut policy = Policy::from_toml(POLICY).expect("policy"); + policy + .table + .insert("typo_users".to_owned(), TablePolicy::default()); + + let error = policy + .validate_ir(&ir) + .expect_err("orphan policy entries must not be silently ignored"); + assert!( + error + .to_string() + .contains("does not match any ORM IR table") + ); +} + +#[test] +fn qualified_and_unqualified_policy_aliases_cannot_compete() { + let mut ir = converged(); + ir.tables[0].schema_name = Some("public".to_owned()); + ir.validate().expect("schema-qualified IR"); + + let mut policy = Policy::from_toml(POLICY).expect("policy"); + let users = policy.table.get("users").cloned().expect("users policy"); + policy.table.insert("public.users".to_owned(), users); + + let error = policy + .validate_table(&ir.tables[0]) + .expect_err("policy aliases must not rely on hidden precedence"); + assert!(error.to_string().contains("both qualified key")); +} + +#[test] +fn artifact_paths_are_safe_for_quoted_database_identifiers() { + let ir = private_ir("../odd/table\\name", &[("body", "body")]); + let policy = empty_policy(); + let shape = derive_shape(&ir.tables[0], &policy, ShapeKind::Row).expect("row shape"); + let bundle = bundle::emit(&ir, &policy, &shape, &synthetic_evidence(&ir)) + .expect("bundle should emit"); + + for artifact in &bundle.artifacts { + let mut parts = artifact.path.split('/'); + let directory = parts.next().expect("language directory"); + let filename = parts.next().expect("filename"); + assert!( + parts.next().is_none(), + "unexpected nested artifact path: {}", + artifact.path + ); + assert!(!directory.is_empty()); + assert!(!filename.contains("..")); + assert!(!filename.contains('\\')); + assert!(!filename.contains('/')); + } + + let schema = json_schema::emit(&shape).expect("schema"); + let id = schema["$id"].as_str().expect("schema id"); + assert!(!id.contains("../")); + assert_eq!(schema["x-ores-table"], "../odd/table\\name"); +} + +#[test] +fn source_identifiers_use_reversible_collision_free_escaping() { + let ir = private_ir("collision_test", &[("a-b", "left"), ("a_b", "right")]); + let policy = empty_policy(); + let shape = derive_shape(&ir.tables[0], &policy, ShapeKind::Row).expect("row shape"); + + let bundle = bundle::emit(&ir, &policy, &shape, &synthetic_evidence(&ir)) + .expect("collision-safe bundle"); + let rust = bundle + .artifacts + .iter() + .find(|artifact| artifact.path.starts_with("rust/")) + .expect("Rust artifact"); + assert!(rust.content.contains("__ores_hex_612d62")); + assert!(rust.content.contains("pub a_b:")); +} + +#[test] +fn bundle_rejects_shape_not_derived_from_bound_ir_and_policy() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy"); + let forged = Shape { + table: "users".to_owned(), + kind: ShapeKind::PublicCreate, + fields: vec![ShapeField { + db_name: "email".to_owned(), + wire_name: "email".to_owned(), + ty: OrmType::Scalar(ScalarType::String), + nullable: false, + required: true, + }], + }; + + let error = bundle::emit(&ir, &policy, &forged, &evidence(&ir)) + .expect_err("forged subsets must not inherit unrelated ORM provenance"); + assert!(error.to_string().contains("does not match the exact shape")); +} + +#[test] +fn bundle_rejects_evidence_from_different_ir() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy"); + let shape = derive_shape(ir.table("users").unwrap(), &policy, ShapeKind::PublicCreate) + .expect("shape"); + let other = private_ir("other", &[("body", "body")]); + + let error = bundle::emit(&ir, &policy, &shape, &synthetic_evidence(&other)) + .expect_err("evidence from another IR must be rejected"); + assert!(error.to_string().contains("does not match supplied ORM IR")); +} + +#[test] +fn attached_contract_bytes_do_not_make_public_candidates_publishable() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy"); + let shape = derive_shape( + ir.table("users").expect("users"), + &policy, + ShapeKind::PublicCreate, + ) + .expect("public create shape"); + let evidence = GenerationEvidence::from_bytes( + &ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ], + b"audit-v2", + Some(( + b"non-empty-unverified-contract-ir", + b"non-empty-unverified-receipt", + )), + ) + .expect("digest binding is allowed"); + let bundle = bundle::emit(&ir, &policy, &shape, &evidence).expect("candidate bundle"); + + assert_eq!( + bundle.manifest.contract_evidence, + ContractEvidenceState::EvidenceBound + ); + assert_eq!( + bundle.manifest.publication, + PublicationState::BlockedPendingContractAdmission + ); + assert!(!bundle.manifest.publishable()); +} + +#[test] +fn private_derivatives_remain_publishable_inside_the_backend_boundary() { + let ir = private_ir("private_note", &[("body", "body")]); + let policy = empty_policy(); + let shape = derive_shape(&ir.tables[0], &policy, ShapeKind::Row).expect("row shape"); + let bundle = bundle::emit(&ir, &policy, &shape, &synthetic_evidence(&ir)) + .expect("private bundle"); + + assert_eq!( + bundle.manifest.publication, + PublicationState::PrivateDerivative + ); + assert!(bundle.manifest.publishable()); +} + +#[test] +fn json_schema_enforces_int16_and_int32_domains_normatively() { + let shape = Shape { + table: "integer_bounds".to_owned(), + kind: ShapeKind::Row, + fields: vec![ + ShapeField { + db_name: "small".to_owned(), + wire_name: "small".to_owned(), + ty: OrmType::Scalar(ScalarType::Int16), + nullable: false, + required: true, + }, + ShapeField { + db_name: "normal".to_owned(), + wire_name: "normal".to_owned(), + ty: OrmType::Scalar(ScalarType::Int32), + nullable: false, + required: true, + }, + ], + }; + let schema = json_schema::emit(&shape).expect("schema"); + + assert_eq!(schema["properties"]["small"]["minimum"], -32768); + assert_eq!(schema["properties"]["small"]["maximum"], 32767); + assert_eq!(schema["properties"]["normal"]["minimum"], -2147483648_i64); + assert_eq!(schema["properties"]["normal"]["maximum"], 2147483647_i64); +} diff --git a/tests/cargo_zed_interop.rs b/tests/cargo_zed_interop.rs deleted file mode 100644 index e7f5f35..0000000 --- a/tests/cargo_zed_interop.rs +++ /dev/null @@ -1,17 +0,0 @@ -#[test] -fn target_and_build_dependency_feature_sets_remain_separate() { - assert_eq!( - rust_lib::greet("integration"), - "HELLO INTEGRATION FROM ZED-PKG-TEST/RUST-LIB" - ); - assert_eq!( - env!("ZED_BUILD_GREETING"), - "hello build-script from zed-pkg-test/rust-lib" - ); -} - -#[test] -fn zed_materialized_crate_keeps_its_cargo_dependency_graph() { - assert_eq!(rust_lib::format_count(u64::MAX), "18446744073709551615"); - assert!(unicode_ident::is_xid_continue('δ')); -} diff --git a/tests/diesel_hardening.rs b/tests/diesel_hardening.rs new file mode 100644 index 0000000..9760231 --- /dev/null +++ b/tests/diesel_hardening.rs @@ -0,0 +1,43 @@ +use ores_orm_core::diesel; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); + +#[test] +fn valid_diesel_primary_key_still_parses() { + let ir = diesel::parse_schema(DIESEL).expect("valid Diesel fixture should parse"); + let users = ir.table("users").expect("users table"); + let id = users.column("id").expect("id column"); + assert!(id.primary_key); +} + +#[test] +fn empty_diesel_primary_key_list_fails_closed() { + let source = DIESEL.replace("users (id)", "users ()"); + let error = diesel::parse_schema(&source) + .expect_err("an explicit empty primary-key list must be rejected"); + assert!(error.to_string().contains("empty primary-key list")); +} + +#[test] +fn duplicate_diesel_primary_key_column_fails_closed() { + let source = DIESEL.replace("users (id)", "users (id, id)"); + let error = diesel::parse_schema(&source) + .expect_err("duplicate primary-key identifiers must be rejected"); + assert!( + error + .to_string() + .contains("duplicate primary-key column id") + ); +} + +#[test] +fn undeclared_diesel_primary_key_column_fails_closed() { + let source = DIESEL.replace("users (id)", "users (missing_pk)"); + let error = diesel::parse_schema(&source) + .expect_err("primary-key identifiers must reference declared columns"); + assert!( + error + .to_string() + .contains("primary-key column missing_pk is not declared") + ); +} diff --git a/tests/emitters.rs b/tests/emitters.rs new file mode 100644 index 0000000..debda0a --- /dev/null +++ b/tests/emitters.rs @@ -0,0 +1,299 @@ +use ores_orm_core::descriptor::{DescriptorSource, OrmWitness, describe_table}; +use ores_orm_core::diesel; +use ores_orm_core::emit::bundle::{ContractEvidenceState, GenerationEvidence}; +use ores_orm_core::emit::{bundle, dart, gleam, rust, typescript}; +use ores_orm_core::ir::{OrmType, ScalarType}; +use ores_orm_core::parity; +use ores_orm_core::policy::{FieldVisibility, Policy}; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{Shape, ShapeField, ShapeKind, derive_shape}; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +fn converged() -> ores_orm_core::OrmIr { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let seaorm = seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"); + + return parity::converge(&diesel, &seaorm).expect("ORM fixtures should converge"); +} + +fn generation_evidence(ir: &ores_orm_core::OrmIr) -> GenerationEvidence { + return GenerationEvidence::from_bytes( + ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"shape-policy-v1", + None, + ) + .expect("generation evidence should be deterministic"); +} + +#[test] +fn descriptor_keeps_secret_storage_out_of_public_shapes() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = ir.table("users").expect("users table"); + let source = DescriptorSource::new( + "ORESoftware/example-orm-core", + "0123456789abcdef0123456789abcdef01234567", + "example-orm-core", + "rustc 1.88.0", + vec![OrmWitness::Diesel, OrmWitness::SeaOrm], + ) + .expect("source should validate"); + let descriptor = + describe_table(table, &policy, "Example.Accounts", source).expect("descriptor should emit"); + + let password = descriptor + .model + .fields + .iter() + .find(|field| field.database_name == "password_hash") + .expect("secret field"); + assert_eq!(password.visibility, FieldVisibility::Secret); + assert!(descriptor.model.public_surface); + + let public_read = + derive_shape(table, &policy, ShapeKind::PublicRead).expect("public read shape"); + assert!( + public_read + .fields + .iter() + .all(|field| field.db_name != "password_hash") + ); +} + +#[test] +fn public_surface_rejects_unclassified_columns() { + let ir = converged(); + let mut policy = Policy::from_toml(POLICY).expect("policy should parse"); + policy + .table + .get_mut("users") + .expect("users policy") + .secret + .clear(); + + let table = ir.table("users").expect("users table"); + let error = derive_shape(table, &policy, ShapeKind::PublicRead) + .expect_err("unclassified public table field must fail"); + + assert!(error.to_string().contains("password_hash")); + assert!(error.to_string().contains("unclassified")); +} + +#[test] +fn language_emitters_preserve_optional_nullable_and_closed_object_semantics() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = ir.table("users").expect("users table"); + let shape = derive_shape(table, &policy, ShapeKind::PublicCreate).expect("public create shape"); + + let rust = rust::emit(&shape).expect("Rust should emit"); + assert!(rust.contains("#[serde(deny_unknown_fields)]")); + assert!(rust.contains("pub display_name: Option>")); + assert!(rust.contains("deserialize_optional_nullable")); + assert!(rust.contains("Canonical JSON Schema is emitted as a sibling artifact")); + assert!(!rust.contains("schemars::JsonSchema")); + assert!(!rust.contains("password_hash")); + syn::parse_file(&rust).expect("generated Rust should parse as a Rust source file"); + + let typescript = typescript::emit(&shape).expect("TypeScript should emit"); + assert!(typescript.contains("z.object({")); + assert!(typescript.contains("}).strict()")); + assert!(typescript.contains("\"display_name\": z.string().nullable().optional()")); + assert!(!typescript.contains("password_hash")); + + let dart = dart::emit(&shape).expect("Dart should emit"); + assert!(dart.contains("OresOptional display_name")); + assert!(dart.contains("final OresOptional display_nameValue")); + assert!(dart.contains("unknownKeys")); + assert!(dart.contains("missing required field: email")); + assert!(!dart.contains("password_hash")); + + let gleam = gleam::emit(&shape).expect("Gleam should emit"); + assert!(gleam.contains("display_name: Presence(Option(String))")); + assert!(gleam.contains("import gleam/option.{type Option}")); + assert!(gleam.contains("decode.dict(decode.string, decode.dynamic)")); + assert!(gleam.contains("closed object")); + assert!(!gleam.contains("password_hash")); +} + +#[test] +fn bundle_is_deterministic_and_digest_bound() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = ir.table("users").expect("users table"); + let shape = derive_shape(table, &policy, ShapeKind::PublicCreate).expect("public create shape"); + + let evidence = generation_evidence(&ir); + let first = bundle::emit(&ir, &policy, &shape, &evidence).expect("bundle should emit"); + let second = bundle::emit(&ir, &policy, &shape, &evidence) + .expect("bundle should emit deterministically"); + + assert_eq!(first, second); + assert_eq!(first.artifacts.len(), 5); + assert!(first.manifest.public_candidate); + assert_eq!( + first.manifest.contract_evidence, + ContractEvidenceState::Required + ); + assert_eq!(first.manifest.generator_version, env!("CARGO_PKG_VERSION")); + assert_eq!(first.manifest.evidence.source_digests.len(), 3); + assert_eq!(first.manifest.evidence.orm_ir_sha256.len(), 64); + assert_eq!(first.manifest.evidence.generator_options_sha256.len(), 64); + assert_eq!(first.manifest.artifacts.len(), 5); + assert!( + first + .manifest + .artifacts + .iter() + .all(|artifact| artifact.sha256.len() == 64) + ); + assert_eq!( + first.manifest_json().expect("manifest JSON"), + second.manifest_json().expect("manifest JSON") + ); +} + +#[test] +fn unsafe_cross_target_numeric_domains_fail_closed() { + let shape = Shape { + table: "example".to_owned(), + kind: ShapeKind::PublicRead, + fields: vec![ShapeField { + db_name: "counter".to_owned(), + wire_name: "counter".to_owned(), + ty: OrmType::Scalar(ScalarType::Int64), + nullable: false, + required: true, + }], + }; + + assert!(typescript::emit(&shape).is_err()); + assert!(dart::emit(&shape).is_err()); + assert!(gleam::emit(&shape).is_err()); + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy"); + let evidence = generation_evidence(&ir); + assert!(bundle::emit(&ir, &policy, &shape, &evidence).is_err()); + assert!(rust::emit(&shape).is_err()); + assert!(ores_orm_core::emit::json_schema::emit(&shape).is_err()); +} + +#[test] +fn descriptor_source_fails_closed_on_invalid_provenance() { + assert!( + DescriptorSource::new( + "", + "0123456789abcdef0123456789abcdef01234567", + "example-orm-core", + "rustc 1.88.0", + vec![OrmWitness::Diesel], + ) + .is_err() + ); + assert!( + DescriptorSource::new( + "ORESoftware/example-orm-core", + "not-a-revision", + "example-orm-core", + "rustc 1.88.0", + vec![OrmWitness::Diesel], + ) + .is_err() + ); + assert!( + DescriptorSource::new( + "ORESoftware/example-orm-core", + "0123456789abcdef0123456789abcdef01234567", + "example-orm-core", + "rustc 1.88.0", + vec![OrmWitness::Diesel, OrmWitness::Diesel], + ) + .is_err() + ); +} + +#[test] +fn patch_emitters_reject_empty_objects() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = ir.table("users").expect("users table"); + let shape = derive_shape(table, &policy, ShapeKind::PublicPatch).expect("public patch shape"); + + let schema = ores_orm_core::emit::json_schema::emit(&shape).expect("JSON Schema should emit"); + assert_eq!(schema["minProperties"], 1); + + let rust = rust::emit(&shape).expect("Rust should emit"); + assert!(rust.contains("patch must contain at least one field")); + assert!(rust.contains("pub fn validate(&self)")); + + let typescript = typescript::emit(&shape).expect("TypeScript should emit"); + assert!(typescript.contains("Object.keys(value).length > 0")); + + let dart = dart::emit(&shape).expect("Dart should emit"); + assert!(dart.contains("if (json.isEmpty)")); + assert!(dart.contains("patch must contain at least one field")); + + let gleam = gleam::emit(&shape).expect("Gleam should emit"); + assert!(gleam.contains("dict.size(fields)")); + assert!(gleam.contains("non-empty patch object")); +} + +#[test] +fn dart_uuid_validation_accepts_all_hex_version_nibbles() { + let shape = Shape { + table: "example".to_owned(), + kind: ShapeKind::PublicRead, + fields: vec![ShapeField { + db_name: "id".to_owned(), + wire_name: "id".to_owned(), + ty: OrmType::Scalar(ScalarType::Uuid), + nullable: false, + required: true, + }], + }; + + let source = dart::emit(&shape).expect("Dart should emit"); + assert!(source.contains("[0-9a-fA-F]{4}-[0-9a-fA-F]{4}")); + assert!(!source.contains("[1-5][0-9a-fA-F]{3}")); +} + +#[test] +fn public_bundle_records_bound_contract_evidence_without_claiming_admission() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = ir.table("users").expect("users table"); + let shape = derive_shape(table, &policy, ShapeKind::PublicCreate).expect("public create shape"); + let evidence = GenerationEvidence::from_bytes( + &ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"shape-policy-v1", + Some((b"exact-tjsv-contract-ir", b"exact-tjsv-receipt")), + ) + .expect("bound evidence"); + + let bundle = bundle::emit(&ir, &policy, &shape, &evidence).expect("bundle should emit"); + assert_eq!( + bundle.manifest.contract_evidence, + ContractEvidenceState::EvidenceBound + ); + let contract = bundle + .manifest + .evidence + .contract + .as_ref() + .expect("contract evidence"); + assert_eq!(contract.contract_ir_sha256.len(), 64); + assert_eq!(contract.receipt_sha256.len(), 64); +} diff --git a/tests/hardening.rs b/tests/hardening.rs new file mode 100644 index 0000000..7db993c --- /dev/null +++ b/tests/hardening.rs @@ -0,0 +1,209 @@ +use ores_orm_core::diesel; +use ores_orm_core::emit::bundle::GenerationEvidence; +use ores_orm_core::emit::{dart, gleam, json_schema, typescript}; +use ores_orm_core::ir::{OrmType, ScalarType}; +use ores_orm_core::parity; +use ores_orm_core::policy::Policy; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{Shape, ShapeField, ShapeKind, derive_shape}; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +fn converged() -> ores_orm_core::OrmIr { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let seaorm = seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"); + return parity::converge(&diesel, &seaorm).expect("ORM fixtures should converge"); +} + +#[test] +fn duplicate_effective_wire_names_fail_closed() { + let ir = converged(); + let table = ir.table("users").expect("users table"); + let mut policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table_policy = policy.table.get_mut("users").expect("users policy"); + table_policy + .wire_names + .insert("email".to_owned(), "identity".to_owned()); + table_policy + .wire_names + .insert("display_name".to_owned(), "identity".to_owned()); + + let error = derive_shape(table, &policy, ShapeKind::PublicRead) + .expect_err("wire-name collisions must be rejected before emission"); + assert!(error.to_string().contains("wire name")); + assert!(error.to_string().contains("collides")); +} + +#[test] +fn direct_shapes_with_duplicate_wire_names_fail_before_emission() { + let shape = Shape { + table: "example".to_owned(), + kind: ShapeKind::Row, + fields: vec![ + ShapeField { + db_name: "first".to_owned(), + wire_name: "same".to_owned(), + ty: OrmType::Scalar(ScalarType::String), + nullable: false, + required: true, + }, + ShapeField { + db_name: "second".to_owned(), + wire_name: "same".to_owned(), + ty: OrmType::Scalar(ScalarType::String), + nullable: false, + required: true, + }, + ], + }; + + let error = json_schema::emit(&shape).expect_err("duplicate wire names must fail closed"); + assert!(error.to_string().contains("duplicate wire field")); +} + +#[test] +fn seaorm_time_offset_datetime_matches_diesel_timestamptz() { + let diesel = diesel::parse_schema( + "diesel::table! { events (id) { id -> Uuid, occurred_at -> Timestamptz, } }", + ) + .expect("Diesel event schema"); + let seaorm = seaorm::parse_source( + r#" + use sea_orm::entity::prelude::*; + use time::OffsetDateTime; + #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] + #[sea_orm(table_name = "events")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub occurred_at: OffsetDateTime, + } + "#, + ) + .expect("SeaORM time crate model"); + + parity::converge(&diesel, &seaorm) + .expect("time::OffsetDateTime must normalize to the same DateTime domain as Timestamptz"); +} + +#[test] +fn public_create_cannot_silently_drop_generated_fields() { + let ir = converged(); + let table = ir.table("users").expect("users table"); + let mut policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table_policy = policy.table.get_mut("users").expect("users policy"); + table_policy.public_create.insert("id".to_owned()); + + let error = derive_shape(table, &policy, ShapeKind::PublicCreate) + .expect_err("generated public-create fields must not disappear silently"); + assert!(error.to_string().contains("public_create")); + assert!(error.to_string().contains("generated")); +} + +#[test] +fn public_update_cannot_silently_drop_immutable_fields() { + let ir = converged(); + let table = ir.table("users").expect("users table"); + let mut policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table_policy = policy.table.get_mut("users").expect("users policy"); + table_policy.public_update.insert("created_at".to_owned()); + + let error = derive_shape(table, &policy, ShapeKind::PublicUpdate) + .expect_err("immutable public-update fields must not disappear silently"); + assert!(error.to_string().contains("public_update")); + assert!(error.to_string().contains("immutable")); +} + +#[test] +fn emitted_numeric_validators_do_not_widen_database_domains() { + let shape = Shape { + table: "numeric_bounds".to_owned(), + kind: ShapeKind::Row, + fields: vec![ + ShapeField { + db_name: "small".to_owned(), + wire_name: "small".to_owned(), + ty: OrmType::Scalar(ScalarType::Int16), + nullable: false, + required: true, + }, + ShapeField { + db_name: "normal".to_owned(), + wire_name: "normal".to_owned(), + ty: OrmType::Scalar(ScalarType::Int32), + nullable: false, + required: true, + }, + ShapeField { + db_name: "ratio".to_owned(), + wire_name: "ratio".to_owned(), + ty: OrmType::Scalar(ScalarType::Float64), + nullable: false, + required: true, + }, + ], + }; + + let ts = typescript::emit(&shape).expect("TypeScript"); + assert!(ts.contains("min(-32768).max(32767)")); + assert!(ts.contains("min(-2147483648).max(2147483647)")); + assert!(ts.contains("z.number().finite()")); + + let dart = dart::emit(&shape).expect("Dart"); + assert!(dart.contains(">= -32768")); + assert!(dart.contains("<= 2147483647")); + assert!(dart.contains(".isFinite")); + + let gleam = gleam::emit(&shape).expect("Gleam"); + assert!(gleam.contains("value >= -32_768 && value <= 32_767")); + assert!(gleam.contains("value >= -2_147_483_648 && value <= 2_147_483_647")); +} + +#[test] +fn provenance_rejects_duplicate_source_names() { + let ir = converged(); + let error = GenerationEvidence::from_bytes( + &ir, + &[ + ("schema.rs", DIESEL.as_bytes()), + ("schema.rs", SEAORM.as_bytes()), + ], + b"hardening-test", + None, + ) + .expect_err("duplicate provenance labels are ambiguous"); + assert!(error.to_string().contains("duplicate source name")); +} + +#[test] +fn empty_contract_evidence_cannot_claim_evidence_bound_state() { + let ir = converged(); + let error = GenerationEvidence::from_bytes( + &ir, + &[("schema.rs", DIESEL.as_bytes())], + b"hardening-test", + Some((b"", b"")), + ) + .expect_err("empty contract evidence must not be digest-bound"); + assert!( + error + .to_string() + .contains("non-empty Contract IR and receipt") + ); +} + +#[test] +fn provenance_rejects_invalid_ir() { + let mut ir = converged(); + ir.tables.push(ir.tables[0].clone()); + let error = GenerationEvidence::from_bytes( + &ir, + &[("schema.rs", DIESEL.as_bytes())], + b"hardening-test", + None, + ) + .expect_err("invalid ORM IR must not receive provenance digests"); + assert!(error.to_string().contains("duplicate table identity")); +} diff --git a/tests/parity.rs b/tests/parity.rs new file mode 100644 index 0000000..00a0cf5 --- /dev/null +++ b/tests/parity.rs @@ -0,0 +1,135 @@ +use ores_orm_core::diesel; +use ores_orm_core::emit::json_schema; +use ores_orm_core::parity; +use ores_orm_core::policy::Policy; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{ShapeKind, derive_shape}; +use pretty_assertions::assert_eq; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +#[test] +fn diesel_and_seaorm_converge_on_structural_shape() { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let seaorm = seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"); + let report = parity::compare(&diesel, &seaorm); + assert_eq!(report.findings, vec![]); + assert!(report.passed); + + let converged = parity::converge(&diesel, &seaorm).expect("ORM lanes should converge"); + assert_eq!(converged.tables.len(), 1); + assert_eq!(converged.tables[0].db_name, "users"); +} + +#[test] +fn public_create_keeps_presence_and_nullability_separate() { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = diesel.table("users").expect("users table"); + let shape = derive_shape(table, &policy, ShapeKind::PublicCreate).expect("derive shape"); + + assert_eq!(shape.fields.len(), 2); + let email = shape + .fields + .iter() + .find(|field| field.db_name == "email") + .expect("email"); + assert!(email.required); + assert!(!email.nullable); + + let display_name = shape + .fields + .iter() + .find(|field| field.db_name == "display_name") + .expect("display_name"); + assert!(!display_name.required); + assert!(display_name.nullable); + + let schema = json_schema::emit(&shape).expect("schema should emit"); + assert_eq!(schema["additionalProperties"], false); + assert_eq!(schema["required"], serde_json::json!(["email"])); + assert_eq!( + schema["properties"]["display_name"], + serde_json::json!({"anyOf": [{"type": "string"}, {"type": "null"}]}) + ); +} + +#[test] +fn drift_fails_closed() { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let drifted = SEAORM.replace("pub email: String", "pub email: i64"); + let seaorm = seaorm::parse_source(&drifted).expect("SeaORM drift fixture should parse"); + let report = parity::compare(&diesel, &seaorm); + assert!(!report.passed); + assert!(report.findings.iter().any(|finding| { + finding.code == "column_type_mismatch" && finding.path == "users.email" + })); +} + +#[test] +fn convergence_requires_independent_orm_witnesses() { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let error = parity::converge(&diesel, &diesel) + .expect_err("two Diesel witnesses must not be admitted as convergence"); + assert!( + error + .to_string() + .contains("one Diesel witness and one SeaORM witness") + ); +} + +#[test] +fn seaorm_ignore_fields_are_not_treated_as_database_columns() { + let ignored = SEAORM.replace( + " pub display_name: Option,", + " #[sea_orm(ignore)]\n pub display_name: Option,", + ); + let ir = seaorm::parse_source(&ignored).expect("ignored field should parse"); + let users = ir.table("users").expect("users table"); + assert!(users.column("display_name").is_none()); +} + +#[test] +fn seaorm_database_type_overrides_fail_closed() { + let overridden = SEAORM.replace( + " pub email: String,", + " #[sea_orm(column_type = \"Text\")]\n pub email: String,", + ); + let error = seaorm::parse_source(&overridden) + .expect_err("column_type changes database semantics and must not be ignored"); + assert!(error.to_string().contains("column_type")); + assert!(error.to_string().contains("explicit mapping")); +} + +#[test] +fn duplicate_database_column_names_fail_closed() { + let duplicate = SEAORM.replace( + " pub display_name: Option,", + " #[sea_orm(column_name = \"email\")]\n pub display_name: Option,", + ); + let error = seaorm::parse_source(&duplicate) + .expect_err("duplicate database identities must be rejected"); + assert!( + error + .to_string() + .contains("duplicate database column email") + ); +} + +#[test] +fn invalid_ir_cannot_hide_behind_parity_indexing() { + let mut diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + diesel.tables.push(diesel.tables[0].clone()); + let seaorm = seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"); + + let report = parity::compare(&diesel, &seaorm); + assert!(!report.passed); + assert!( + report + .findings + .iter() + .any(|finding| finding.code == "invalid_left_ir") + ); +} diff --git a/tests/policy_provenance.rs b/tests/policy_provenance.rs new file mode 100644 index 0000000..0611661 --- /dev/null +++ b/tests/policy_provenance.rs @@ -0,0 +1,72 @@ +use ores_orm_core::emit::bundle::{self, GenerationEvidence}; +use ores_orm_core::ir::{Column, OrmIr, OrmType, ScalarType, SourceKind, Table}; +use ores_orm_core::policy::Policy; +use ores_orm_core::shapes::{ShapeKind, derive_shape}; + +fn private_ir() -> OrmIr { + OrmIr::try_new( + SourceKind::Converged, + vec![Table { + schema_name: None, + db_name: "notes".to_owned(), + columns: vec![Column { + rust_name: "body".to_owned(), + db_name: "body".to_owned(), + ordinal: 0, + ty: OrmType::Scalar(ScalarType::String), + nullable: false, + primary_key: false, + unique: false, + }], + }], + ) + .expect("synthetic converged IR") +} + +fn evidence(ir: &OrmIr) -> GenerationEvidence { + GenerationEvidence::from_bytes( + ir, + &[("synthetic/diesel.rs", b"table! notes")], + b"same-generator-options", + None, + ) + .expect("generation evidence") +} + +#[test] +fn derivative_manifest_binds_the_exact_validated_policy_independently_of_options() { + let ir = private_ir(); + let policy_a = Policy::from_toml( + "version = 1\nrequire_diesel = true\nrequire_seaorm = true\n", + ) + .expect("policy a"); + let policy_b = Policy::from_toml( + "version = 1\nrequire_diesel = false\nrequire_seaorm = true\n", + ) + .expect("policy b"); + + // This private row shape is intentionally identical under both policies. + // Provenance still must distinguish which exact policy was supplied to the + // emitter, even when the caller reuses identical generator-options bytes. + let shape_a = derive_shape(&ir.tables[0], &policy_a, ShapeKind::Row).expect("shape a"); + let shape_b = derive_shape(&ir.tables[0], &policy_b, ShapeKind::Row).expect("shape b"); + assert_eq!(shape_a, shape_b); + + let evidence = evidence(&ir); + let bundle_a = bundle::emit(&ir, &policy_a, &shape_a, &evidence).expect("bundle a"); + let bundle_b = bundle::emit(&ir, &policy_b, &shape_b, &evidence).expect("bundle b"); + + assert_eq!( + bundle_a.manifest.evidence.generator_options_sha256, + bundle_b.manifest.evidence.generator_options_sha256 + ); + assert_ne!( + bundle_a.manifest.policy_sha256, + bundle_b.manifest.policy_sha256, + "policy provenance must not collapse merely because the derived shape is unchanged" + ); + assert_eq!( + bundle_a.manifest.schema, + "ores.orm-core.derivative-manifest/v3" + ); +} diff --git a/tests/rust-lib-main-ancestry.sh b/tests/rust-lib-main-ancestry.sh deleted file mode 100755 index b7f5393..0000000 --- a/tests/rust-lib-main-ancestry.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/rust-lib-main-ancestry-test.XXXXXXXX")" -trap 'rm -rf "$TEMP_ROOT"' EXIT HUP INT TERM -ORIGIN="$TEMP_ROOT/origin.git" -WORK="$TEMP_ROOT/work" -EVIDENCE="$TEMP_ROOT/evidence.json" - -git init -q --bare "$ORIGIN" -git init -q -b main "$WORK" -git -C "$WORK" config user.name "Cargo Zed Interop Test" -git -C "$WORK" config user.email "cargo-zed-interop@example.invalid" -printf 'base\n' > "$WORK/value.txt" -git -C "$WORK" add value.txt -git -C "$WORK" commit -q -m base -BASE_REF="$(git -C "$WORK" rev-parse HEAD)" - -git -C "$WORK" switch -q -c certified -printf 'certified\n' >> "$WORK/value.txt" -git -C "$WORK" commit -qam certified -CERTIFIED_REF="$(git -C "$WORK" rev-parse HEAD)" - -git -C "$WORK" switch -q main -printf 'main\n' > "$WORK/main.txt" -git -C "$WORK" add main.txt -git -C "$WORK" commit -q -m main -git -C "$WORK" merge -q --no-ff certified -m 'reviewed merge' -REVIEWED_MERGE_REF="$(git -C "$WORK" rev-parse HEAD)" - -git -C "$WORK" remote add origin "$ORIGIN" -git -C "$WORK" push -q -u origin main certified - -RUST_LIB_REPOSITORY="$ORIGIN" \ -RUST_LIB_REF="$CERTIFIED_REF" \ -RUST_LIB_REVIEWED_MERGE_REF="$REVIEWED_MERGE_REF" \ - "$ROOT/scripts/verify-rust-lib-main-ancestry.sh" "$EVIDENCE" - -python3 - "$EVIDENCE" "$CERTIFIED_REF" "$REVIEWED_MERGE_REF" <<'PY' -import json -import sys -from pathlib import Path - -payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) -assert payload["schema"] == "zed-pkg-test/rust-lib-main-ancestry/v2" -assert payload["certified_ref"] == sys.argv[2] -assert payload["reviewed_merge_ref"] == sys.argv[3] -assert payload["certified_ref_is_reviewed_merge_ancestor"] is True -assert payload["reviewed_merge_is_main_ancestor"] is True -assert len(payload["observed_main_tip"]) == 40 -PY - -git -C "$WORK" switch -q -c unmerged "$BASE_REF" -printf 'side\n' > "$WORK/side.txt" -git -C "$WORK" add side.txt -git -C "$WORK" commit -q -m side -UNMERGED_REF="$(git -C "$WORK" rev-parse HEAD)" -git -C "$WORK" push -q origin unmerged - -if RUST_LIB_REPOSITORY="$ORIGIN" \ - RUST_LIB_REF="$UNMERGED_REF" \ - RUST_LIB_REVIEWED_MERGE_REF="$REVIEWED_MERGE_REF" \ - "$ROOT/scripts/verify-rust-lib-main-ancestry.sh" >/dev/null 2>&1 -then - echo "expected an unmerged rust-lib ref to fail ancestry validation" >&2 - exit 1 -fi - -MAIN_PARENT_REF="$(git -C "$WORK" rev-parse "$REVIEWED_MERGE_REF^1")" -if RUST_LIB_REPOSITORY="$ORIGIN" \ - RUST_LIB_REF="$BASE_REF" \ - RUST_LIB_REVIEWED_MERGE_REF="$MAIN_PARENT_REF" \ - "$ROOT/scripts/verify-rust-lib-main-ancestry.sh" >/dev/null 2>&1 -then - echo "expected a non-merge review ref to fail validation" >&2 - exit 1 -fi - -if RUST_LIB_REPOSITORY="$ORIGIN" \ - RUST_LIB_REF="not-a-commit" \ - RUST_LIB_REVIEWED_MERGE_REF="$REVIEWED_MERGE_REF" \ - "$ROOT/scripts/verify-rust-lib-main-ancestry.sh" >/dev/null 2>&1 -then - echo "expected an invalid rust-lib ref to fail validation" >&2 - exit 1 -fi diff --git a/tests/wire_mapping_gates.rs b/tests/wire_mapping_gates.rs new file mode 100644 index 0000000..77b31a8 --- /dev/null +++ b/tests/wire_mapping_gates.rs @@ -0,0 +1,68 @@ +use ores_orm_core::emit::{dart, gleam, json_schema, rust, typescript}; +use ores_orm_core::ir::{OrmType, ScalarType}; +use ores_orm_core::shapes::{Shape, ShapeField, ShapeKind}; + +fn shape(scalar: ScalarType) -> Shape { + Shape { + table: "wire_mapping_gate".to_owned(), + kind: ShapeKind::PublicRead, + fields: vec![ShapeField { + db_name: "value".to_owned(), + wire_name: "value".to_owned(), + ty: OrmType::Scalar(scalar), + nullable: false, + required: true, + }], + } +} + +#[test] +fn ambiguous_cross_runtime_scalars_require_admitted_mappings() { + for scalar in [ + ScalarType::Int64, + ScalarType::Float32, + ScalarType::Decimal, + ScalarType::Uuid, + ScalarType::Date, + ScalarType::DateTime, + ScalarType::Bytes, + ] { + let candidate = shape(scalar.clone()); + assert!(rust::emit(&candidate).is_err(), "Rust unexpectedly admitted {scalar:?}"); + assert!( + typescript::emit(&candidate).is_err(), + "TypeScript unexpectedly admitted {scalar:?}" + ); + assert!(dart::emit(&candidate).is_err(), "Dart unexpectedly admitted {scalar:?}"); + assert!( + gleam::emit(&candidate).is_err(), + "Gleam unexpectedly admitted {scalar:?}" + ); + assert!( + json_schema::emit(&candidate).is_err(), + "JSON Schema unexpectedly admitted {scalar:?}" + ); + } +} + +#[test] +fn portable_scalar_subset_remains_generatable() { + for scalar in [ + ScalarType::Boolean, + ScalarType::Int16, + ScalarType::Int32, + ScalarType::Float64, + ScalarType::String, + ScalarType::Json, + ] { + let candidate = shape(scalar.clone()); + rust::emit(&candidate).unwrap_or_else(|error| panic!("Rust rejected {scalar:?}: {error}")); + typescript::emit(&candidate) + .unwrap_or_else(|error| panic!("TypeScript rejected {scalar:?}: {error}")); + dart::emit(&candidate).unwrap_or_else(|error| panic!("Dart rejected {scalar:?}: {error}")); + gleam::emit(&candidate) + .unwrap_or_else(|error| panic!("Gleam rejected {scalar:?}: {error}")); + json_schema::emit(&candidate) + .unwrap_or_else(|error| panic!("JSON Schema rejected {scalar:?}: {error}")); + } +}