diff --git a/.github/workflows/bench-eql.yml b/.github/workflows/bench-eql.yml index 8d46945ae..390745c0f 100644 --- a/.github/workflows/bench-eql.yml +++ b/.github/workflows/bench-eql.yml @@ -64,6 +64,7 @@ on: - "packages/eql/docker/**" - "packages/eql/README.md" - "packages/eql/SUPABASE.md" + - "conformance/stack-encrypt/**" - "packages/stack-encrypt/**" - "packages/stack-encrypt-derive/**" - "packages/stack-kms/**" diff --git a/.github/workflows/crap-crates.yml b/.github/workflows/crap-crates.yml index 8a325ab1a..79020aa40 100644 --- a/.github/workflows/crap-crates.yml +++ b/.github/workflows/crap-crates.yml @@ -56,10 +56,12 @@ env: CARGO_NET_GIT_FETCH_WITH_CLI: true NEXTEST_PROFILE: ci +# cargo-crap 0.6.1's prebuilt GNU/Linux binary needs GLIBC 2.39 (Ubuntu 24.04). +# Keep the runner and its mise cache on that image. The gate itself is unchanged. jobs: crap-stack-auth: name: CRAP (stack-auth) - runs-on: blacksmith-16vcpu-ubuntu-2204 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -74,16 +76,19 @@ jobs: working_directory: . cache: true # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs - cache_key_prefix: mise-v1-ubuntu-2204 + cache_key_prefix: mise-v1-ubuntu-2404 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: Check the CRAP executable before collecting coverage + run: cargo crap --version + - name: Run the CRAP gate run: mise run crap:stack-auth -- --format github crap-stack-encrypt: name: CRAP (stack-encrypt) - runs-on: blacksmith-16vcpu-ubuntu-2204 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -97,9 +102,12 @@ jobs: working_directory: . cache: true # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs - cache_key_prefix: mise-v1-ubuntu-2204 + cache_key_prefix: mise-v1-ubuntu-2404 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: Check the CRAP executable before collecting coverage + run: cargo crap --version + - name: Run the CRAP gate run: mise run crap:stack-encrypt -- --format github diff --git a/.github/workflows/test-eql.yml b/.github/workflows/test-eql.yml index 438cfb3bf..fc88db912 100644 --- a/.github/workflows/test-eql.yml +++ b/.github/workflows/test-eql.yml @@ -109,6 +109,7 @@ on: # cannot see them (a task body names a cargo package, not a path); the # manifest walk in eql-suite-ci.test.mjs does, and fails when one of them # is missing from this list. + - "conformance/stack-encrypt/**" - "packages/stack-encrypt/**" - "packages/stack-encrypt-derive/**" - "packages/stack-kms/**" @@ -208,6 +209,7 @@ jobs: - "packages/eql/docker/**" - "packages/eql/README.md" - "packages/eql/SUPABASE.md" + - "conformance/stack-encrypt/**" - "packages/stack-encrypt/**" - "packages/stack-encrypt-derive/**" - "packages/stack-kms/**" diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index 0fa4a6967..014884fd7 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -60,6 +60,9 @@ on: - scripts/go-binding-test.sh - scripts/stack-conformance.sh - scripts/stack-conformance-selection.mjs + - scripts/stack-eql-conformance.mjs + - scripts/stack-conformance-evidence.mjs + - scripts/stack-lifecycle-conformance.mjs - Cargo.toml - Cargo.lock - .cargo/** @@ -99,6 +102,9 @@ on: - scripts/go-binding-test.sh - scripts/stack-conformance.sh - scripts/stack-conformance-selection.mjs + - scripts/stack-eql-conformance.mjs + - scripts/stack-conformance-evidence.mjs + - scripts/stack-lifecycle-conformance.mjs - Cargo.toml - Cargo.lock - .cargo/** @@ -144,6 +150,18 @@ jobs: with: persist-credentials: false + # Native EQL conformance uses the EQL workspace's toolchain. Install it + # before root setup, so ordinary guest commands retain the root pin. + # Its local DATABASE_URL must not enable database tests in this job. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: packages/eql + env: false + cache: true + cache_key_prefix: mise-v1-ubuntu-2204 + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: version: 2026.4.0 @@ -258,6 +276,28 @@ jobs: echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB" mise run go:test + - name: Required lifecycle and key-I/O conformance + env: + STACK_CONFORMANCE_REPORT_DIR: ${{ runner.temp }}/lifecycle-conformance + run: mise run test:conformance:lifecycle + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: lifecycle-conformance + path: ${{ runner.temp }}/lifecycle-conformance/*.json + if-no-files-found: error + + - name: Required EQL and JSON conformance + env: + STACK_CONFORMANCE_REPORT_DIR: ${{ runner.temp }}/eql-conformance-fast + run: mise run test:conformance:eql + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: eql-conformance-fast + path: ${{ runner.temp }}/eql-conformance-fast/*.json + if-no-files-found: error + - name: Fresh Go scalar artifacts open in Rust env: STACK_CONFORMANCE_INPUT: ${{ runner.temp }}/stack-conformance-go.json @@ -392,6 +432,8 @@ jobs: # v4 bundle under packages/eql's mise config, which pins its own Rust; the # guests are the ones wasi-check built and checked. eql-postgres: + env: + MISE_ENV: test name: Go rows and queries in PostgreSQL with EQL v4 needs: wasi-check runs-on: blacksmith-16vcpu-ubuntu-2204 @@ -434,6 +476,16 @@ jobs: # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs cache_key_prefix: mise-v1-ubuntu-2204 + # mise caches Rust symlinks, not ~/.rustup. A cache hit can leave a + # toolchain directory without cargo and still report "all tools installed". + # Reconcile both workspaces' configured toolchains before cargo is used. + - name: Restore Rust toolchains behind the cached mise links + run: | + mise install --force rust + mise -C packages/eql install --force rust + mise x -- cargo --version + mise -C packages/eql x -- cargo --version + # `mise run build` compiles eql-codegen to write the bundle. - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -459,11 +511,18 @@ jobs: # Builds the bundle from the tree, installs it, and runs the tests; a # skip fails the task, so a missing guest or database cannot pass. - - name: Go rows and queries through EQL v4 + - name: Go rows, queries and required JSON conformance through EQL v4 env: EQL_TEST_DATABASE_URL: "host=localhost port=${{ job.services.postgres.ports['5432'] }} user=postgres password=postgres dbname=postgres sslmode=disable" + STACK_CONFORMANCE_REPORT_DIR: ${{ runner.temp }}/eql-conformance-postgres run: mise run go:test:postgres + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: eql-conformance-postgres + path: ${{ runner.temp }}/eql-conformance-postgres/*.json + if-no-files-found: error + # Round trips through real ZeroKMS. The Go live tests skip without their # STACK_ENCRYPT_TEST_* variables, so the secrets pre-flight runs first and # turns a missing credential into a failure rather than a silent skip. The diff --git a/Cargo.lock b/Cargo.lock index e14762064..1bcfb05d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,7 +565,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2311,12 +2311,12 @@ dependencies = [ "rand 0.10.1", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.8.1", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -3361,8 +3361,8 @@ dependencies = [ "tracing-subscriber", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -3384,7 +3384,7 @@ dependencies = [ "tempfile", "tokio", "url", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -3433,15 +3433,15 @@ dependencies = [ "tokio", "trybuild", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.8.1", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.8.1", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -3467,9 +3467,9 @@ dependencies = [ "proptest", "serde_json", "thiserror 1.0.69", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -3502,8 +3502,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -4265,16 +4265,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -4294,9 +4294,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "chrono", @@ -4304,10 +4304,10 @@ dependencies = [ "rust_decimal", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -4324,9 +4324,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -4335,12 +4335,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "3a65349bb75a9ecc9fd4f057e5a6aac964bea68e57d03d55118a22455002a424" dependencies = [ "serde", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -4356,12 +4357,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -4380,72 +4381,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "e4a244886bff4915e15000a69f5a10f14410cabb30c445e3d585057c06a9ce1a" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "d820d507ca7cce81772e38c1277b26acc422a4fd99402e6d9d49c412a5a135f0" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "6b80bf6789c1e2e1c192a255a9e2f6c3e7ffbce2fcb61d60edecee6ffc2f71ef" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "e86cfcbfb4089f5bf334723cbf106f7d0d4709abf0ad12aaf49d6cf89f3e78de" dependencies = [ "mutants", "thiserror 2.0.18", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -4467,9 +4468,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -4478,7 +4479,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -4495,9 +4496,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4522,16 +4523,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -4548,9 +4549,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -4575,17 +4576,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index d3234daee..e4a1f1ef8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,18 +139,21 @@ web-time = "1.1" zeroize = { version = "1.8.1", features = ["derive"] } # This is needed because lock_api 0.4.6 was breaking things and the branch forces the use of 0.4.12 temp-env = { git = "https://github.com/cipherstash/temp-env", branch = "main" } -vitaminc = { version = "0.8.0", features = ["random", "protected", "encrypt"] } -vitaminc-aead = "0.8.0" +vitaminc = { version = "0.8.1", features = ["random", "protected", "encrypt"] } +vitaminc-aead = "0.8.1" # The transport frame and ciphertext codec every language binding carries -# across its FFI boundary. Optional in stack-encrypt (the `dynamic` feature). -vitaminc-aead-frame = "0.8.0" -vitaminc-encrypt = "0.8.0" -vitaminc-hmac = "0.8.0" +# across its FFI boundary, and the storage profile (`storage`, feature +# `serde`) a whole value is encoded with before it is sealed as one leaf +# (`stack_encrypt::OpaqueBlob`). 0.8.1 is the first release with the storage +# profile; the Go SDK writes and reads the same profile through `vcffi`. +vitaminc-aead-frame = "0.8.1" +vitaminc-encrypt = "0.8.1" +vitaminc-hmac = "0.8.1" # Normalized equality terms (Unicode 16 NFC text, one integer encoding for # every width), which `vitaminc-prf`'s `normalize` feature derives over. -vitaminc-normalize = "0.8.0" +vitaminc-normalize = "0.8.1" # The text order key: the system ADR-0002's fold, pinned to Unicode 16, # which text ORE and OPE terms are derived over (ADR-0009). -vitaminc-ore = "0.8.0" -vitaminc-prf = { version = "0.8.0", features = ["normalize"] } -vitaminc-protected = "0.8.0" +vitaminc-ore = "0.8.1" +vitaminc-prf = { version = "0.8.1", features = ["normalize"] } +vitaminc-protected = "0.8.1" diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md index 4889deebc..6b202641c 100644 --- a/conformance/stack-encrypt/README.md +++ b/conformance/stack-encrypt/README.md @@ -21,7 +21,8 @@ native Rust against the ciphertexts Go has just produced. It never regenerates expectations. CI also runs the Go cases on its other supported host platforms. The same task runs the shared declaration acceptance/rejection corpus in both languages, with native observations of normalization. It also runs the shared -runtime, read and context cases, and enrolls the existing value-kind, context-field and query-operator +runtime, read, context, value-boundary, query-refusal and raw-frame cases, and +enrolls the existing value-kind, context-field and query-operator fixtures described below. Set no fixture-update variables when checking conformance. The Rust runner is `packages/stack-encrypt/tests/conformance.rs`; the Go runner @@ -29,6 +30,23 @@ is `languages/golang/encrypt/conformance_test.go`. Go calls the record interface used by generated code. Testing public SDK declarations is a separate layer. Python and TypeScript runners are pending; their absence is not a passing result. +## Lifecycle, cleanup and key I/O + +The [lifecycle contract](lifecycle-contract.md) maps native/Go ownership, +cancellation, partial-output cleanup, diagnostics, validation-before-I/O and +batching evidence. Run `mise run test:conformance:lifecycle` on a supported Unix +host with the normal and deterministic guests built. Reports retain known +cleanup gaps and unobserved behavior alongside the passing requirements. + +## EQL and JSON operations + +The [EQL/JSON contract](eql-json-contract.md) enrolls resolver capabilities, +document writes, queries, extraction/subtree reads, element-match and predicate +matching. It reuses the native, Go and independent PostgreSQL suites and shares +write/predicate case data. Run `mise run test:conformance:eql` and the separate +required `test:conformance:eql:postgres` task; see the contract for guest builds, +disposable database setup, intentional SQL differences and version reports. + ## Context composition and expectations The [context contract](context-contract.md) covers ordered extensions, canonical @@ -52,8 +70,30 @@ checks. The task runs the existing tests directly, and a missing guest fails when conformance is required. The ordinary CI suites already execute them. This is representative fixture coverage, not an exhaustive type/operator -matrix. Wider fresh artifact exchange, shared query refusals and EQL/JSON -operations remain separate work. +matrix. Wider fresh artifact exchange and shared scalar query refusals remain +separate work. The EQL/JSON profiles are described below. + +## Value boundaries, query refusals and raw frames + +Three profiles close the gaps the representative profiles left. The +[value contract](values-contract.md) defines `record-values-v1` in +`v1/values.json`: every kind at its edges, sealed and SQL nulls for the +fixed-width kinds, write refusals between kinds, frozen typed frames, and +the Go-only host conversions the host refuses before the engine. The +[query-refusal contract](query-refusals-contract.md) defines +`record-query-refusals-v1` in `v1/query-refusals.json`: one plan, every +scalar operator against every field capability that does not answer it, +the range shapes, other-kind values and nulls the contract lists, each refusal's +class and diagnostic, no key I/O, and the stage at which the typed Rust +chain refuses the same query. The [raw-frame contract](raw-frames-contract.md) +defines `record-raw-frames-v1` in `v1/raw-frames.json`: stored batches as +transport bytes, so repeated keys and malformed leaves reach both decoders, +and the table of error classes (transport, leaf, shape, authenticated, +invalid declaration, unsupported host conversion). The runners are +`value_conformance`, `query_refusal_conformance` and `raw_frame_conformance` +in the Rust crate and `TestValueConformance`, `TestQueryRefusalConformance` +and `TestRawFrameConformance` in the Go module; `test:conformance` runs all +six. ## `record-reads-v1` contract diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 47f204db3..349787374 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -6,13 +6,13 @@ Paths are relative to the repository root. New profile cases run from `v1/`. | Requirement | Existing evidence | Remaining work | |---|---|---| -| Scalar value widths and lossless transport | Initial scalar cases and [value-kind requirements](fixture-contract.md#value-kind-requirements), executed by the existing Rust and generated Go runners | Complete per-kind boundary matrices, bytes and broader floats; Python/TS mappings | -| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases | Blob profile; encoded targets; supported version matrix | +| Scalar value widths and lossless transport | Initial scalar cases, [value-kind requirements](fixture-contract.md#value-kind-requirements) and the [value boundary profile](values-contract.md) (`v1/values.json`: every kind's edges, float specials, bytes, decimal and timestamp precision, sealed/SQL nulls per kind, cross-kind write refusals, Go host conversions) | Fresh exchange of every kind; Python/TS mappings; dates before year 1 and the timestamp range ends | +| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases. Whole-value blobs: `packages/stack-encrypt/tests/opaque_blob.rs` (round trips, the bytes leaf shared with the structural writer, a pinned storage blob and a pinned sealed ciphertext, refusals by class after authentication, source wipe) and `languages/golang/encrypt/gensupport/gensupport_internal_test.go::TestOpaqueReadsARustWrittenBlob`; the storage profile itself is pinned upstream by Vitamin C's golden corpus | Encoded targets; supported version matrix; a fresh sealed exchange of a blob with Go (Go seals, Rust opens, and back) through the conformance task | | Record declaration and lowering | [Declaration grammar](declaration-grammar.md); `v1/declarations.json` shared by native Rust and Go; native normalization observations; existing record lowering suites | EQL-resolver profile; SDK-to-declaration mappings; runtime source/read shapes | | Context binding and composition | `packages/stack-encrypt/tests/target.rs`, `descriptor.rs`, `record_lowering.rs`; `languages/golang/encrypt/context_field_test.go`; initial wrong-context cases | Shared composition vectors; expected-context and required-input matrix | -| Query terms and diagnostics | [Query-operator requirements](fixture-contract.md#query-operator-requirements), existing Rust/Go runners and initial equality/query-error cases | Common rejection corpus for every operator, capability and input shape; wider kind/index combinations | +| Query terms and diagnostics | [Query-operator requirements](fixture-contract.md#query-operator-requirements), existing Rust/Go runners, initial equality/query-error cases and the [query-refusal profile](query-refusals-contract.md) (`v1/query-refusals.json`: every scalar operator against every field capability that does not answer it, the listed range shapes, other-kind values and nulls, by class, with the typed chain's refusal stage) | JSON operator and EQL target refusals; moving the typed chain's value refusals before the keyset | | Missing, SQL NULL and sealed null | `packages/stack-encrypt/src/dynamic/record.rs` null tests; `languages/golang/encrypt/null_test.go` | Explicit language mappings and common missing/null fixtures | -| Fresh cross-language storage | `.github/workflows/tests-golang.yml` exports fresh Go EQL rows for Rust; initial scalar artifact exchange | Every producer/consumer/backend pair and supported released versions | +| Fresh cross-language storage | `.github/workflows/tests-golang.yml` exports fresh Go EQL rows for Rust; initial scalar artifact exchange; fixed storage-blob vectors read by both Rust (`opaque_blob.rs`) and Go (`TestOpaqueReadsARustWrittenBlob`) | Every producer/consumer/backend pair and supported released versions; fresh sealed blob exchange | | JSON queries, extraction and tampering | `packages/stack-encrypt/tests/json_tree_vectors.rs`; `packages/eql/tests/encryption/tests/json_tree.rs`; `languages/golang/encrypt/json_tree_test.go`, `json_postgres_test.go` | Formal operation profile; JSON vectors consumed by all runners | | Element and predicate matching | Go `json_elements_test.go`, `json_match_test.go` and corresponding PostgreSQL tests; Rust/SQL predicate suites | Map exact supported grammar and documented semantic differences to case IDs | | Generated SDK declarations | Go stashgen and generated-code fixture tests | Python and TypeScript public-API runners; compiler/type-checker cases | @@ -54,8 +54,9 @@ data-key counters and Go's uninitialised guest verify preflight boundaries. | Native and guest context-source validation | `static-context-with-expectation` | Other source combinations and empty-batch behavior | These are overlap mappings, not grounds for deleting whole mixed-purpose tests. -Repeated-key trees cannot be emitted by Go's map-based tree encoder; a future -raw-byte profile must cover that boundary explicitly. +Repeated-key trees cannot be emitted by Go's map-based tree encoder; the +[raw-frame profile](raw-frames-contract.md) covers that boundary with +transport bytes both runners decode. ## Consolidation policy @@ -70,11 +71,13 @@ value kinds remain open. Shared semantic expectations should be maintained once and executed by each language runner. Conformance can replace duplicate case tables and fixture maintenance. It does not eliminate the need to execute the Rust and Go paths. -No existing test has been deleted by conformance enrollment. +Enrollment alone deleted no test; the [consolidations](#consolidations) below +record each duplicate table that was later removed, with the shared cases +and the fault evidence that replaced it. | Existing evidence | Relationship to conformance | Consolidation action | |---|---|---| -| Rust `dynamic/record.rs` tests `a_malformed_plan`, `a_null_is_refused_as_its_key`, `a_missing_or_null_index_payload_is_the_default` | Many inputs and diagnostic outcomes are now shared declaration cases | Move the remaining cases and retain any unique display/low-level assertions before removing duplicate rows | +| Rust `dynamic/record.rs` plan-refusal tests | Their inputs and diagnostic outcomes are shared declaration cases | Removed; the message naming its field stays in `a_plan_refusal_names_its_field_in_the_message` (see [consolidations](#consolidations)) | | Rust kind/index admission tests | The corpus has the complete 21-kind/five-index acceptance matrix | Share case data; retain direct tests that compare declaration admission with actual term execution | | Rust `value_kinds.rs`, Go `value_kinds_test.go` | Enrolled as `record-value-kinds-v1`, retaining the shared fixture and generated SDK calls | Extend the existing fixture/requirements for missing boundaries; retain typed and generated-code assertions | | Rust `query_operators.rs`, Go `query_test.go` | Enrolled as `record-query-operators-v1`, including verification and target limits | Add shared refusal cases separately; retain host/generated query mapping tests | @@ -106,3 +109,124 @@ Go proves canonical AAD indirectly through its actual guest plan frame and nativ ciphertext opening; provider-call instrumentation remains a separate gap. Nested composite extensions remain a native API capability rather than an unsupported feature silently advertised by the Go/dynamic record profile. + +## EQL/JSON checkpoint + +The [EQL/JSON contract](eql-json-contract.md) and its enrollment index cover +resolver/catalog capabilities, JSON writes and queries, request-bound reads, +element-match and predicate-match. Existing Rust, Go and PostgreSQL suites stay +in place. The common document and predicate-refusal tables now live in +`v1/eql-json.json`: Rust and Go execute them, retaining native key-call checks, +Go diagnostic mapping and all extra cases from the original suites. + +The database lane preserves independent plaintext oracles and generated +properties. Required test inventory/no-skip checks and engine/guest/SQL identity +reports make enrollment observable. This consolidates data and requirements; +it does not replace adapter tests or claim whole-database authentication. +Fresh JSON artifact exchange, released-version matrices and Python/TypeScript +runners remain open. + +## Value, refusal and raw-frame checkpoint + +Three shared corpora close the boundary gaps the representative profiles +left: `v1/values.json` (the [value contract](values-contract.md)), +`v1/query-refusals.json` (the [query-refusal contract](query-refusals-contract.md)) +and `v1/raw-frames.json` (the [raw-frame contract](raw-frames-contract.md)). +Each runs in native Rust and Go WASI under `test:conformance`. + +| Existing evidence | Shared case IDs | Retained assertions | +|---|---|---| +| `value_kinds.json` and its runners | `i8-*`, `u8-*`, `i16-*`, `u16-*`, `u32-*`, `i128-*`, `u128-*`, `f32-*`, `f64-*`, `bytes-*`, `decimal-*`, `date-*`, `timestamp-*`, `sealed-*`, `sql-*`, `*-given-*` | Typed Rust term comparisons and generated Go methods | +| Native `dynamic/query.rs` refusal tests, Go `TestVerifyAndTargetLimitRefuseWhatIsNotAMatchQuery` | Every `record-query-refusals-v1` case | Tree-notation inputs, the hand-built match options, generated query methods | +| Native typed refusal tests in `tests/op.rs` | The `typed` entries | Case-sensitivity mismatch, batch and `via` forms | +| Native `a_stored_record_that_does_not_fit`, the read profile | `shape-*`, `authenticated-*`, `success-*` | Mutation vocabulary over real ciphertext | +| Guest `test_frame` and `last_error` tests | `transport-*`, `leaf-*` | Plan-frame boundary cases, ABI status mapping | + +Unsupported combinations the corpora record rather than skip: the order +index on `bytes` stores a term no operator reads; the typed chain refuses +equality, order and block ORE value refusals only after its keyset loads, +where the binding refuses before any; a repeated key in a stored record is +a transport refusal in both decoders, and the engine's own `field_repeated` +and `repeated_key` guards are reached natively alone; the Go host refuses +the zero `Date`, a decimal scale above 28, a timestamp outside the +decoder's range and non-value Go types before the engine. `NaN` and +negative-zero decimals are excluded as host-dependent. + +## Lifecycle checkpoint + +The [lifecycle contract](lifecycle-contract.md) enrolls native pending/KMS tests, +heap observations, guest ABI cleanup, Go cancellation/memory tests and public SDK +diagnostics. New probes cover unpolled and in-flight drops, provider failure, +cleanup after a late authenticated type failure, and chunk concurrency/drop. +Existing runtime/read/context vectors retain their provider-call counters. + +This adds required execution and explicit observation limits; it removes no +backend-specific tests. The known Go post-decryption shadow-stack gap and native +heap-observer exclusions stay visible in reports. Logical batching is distinct +from downstream chunking and unobserved live HTTP behavior. Python and TypeScript +lifecycle evidence is pending. + +## Consolidations + +Each row is one old assertion table whose literal expectations are now held +by a shared corpus and executed by both language runners. A row was removed +only after a fault was introduced by hand into the engine and the named +replacement runner failed on it under `STACK_CONFORMANCE_REQUIRED=1`; the +fault was then reverted. "Fails on" names the fault and the case the runner +reported. Where an old table mixed shared rows with shapes the corpus cannot +spell, only the shared rows were removed and the test's doc comment says what +stays. Every shared case in these rows runs natively through the Rust binary +named and through the Go guest (`TestDeclarationConformance` on both guests, +`TestRuntimeConformance`, `TestReadConformance`, `TestQueryRefusalConformance`). + +Five cases were enrolled so the old rows had a one-to-one home rather than a +neighbour: `output-not-string`, `passthrough-with-index` and `target-number` +in `v1/declarations.json`; `ore-block-between-long-low` and +`ore-block-between-nul-high` in `v1/query-refusals.json`. + +| Old test (file) | Shared case IDs | Replacement runner | Fails on | +|---|---|---|---| +| `every_refusal_names_its_field_and_reason::a_malformed_plan` (`src/dynamic/record.rs`), 24 rows, removed; its check that the message names the field stays in `a_plan_refusal_names_its_field_in_the_message`, since the corpus compares payloads, not messages | `root-null`, `root-list`, `empty-plan`, `field-not-object`, `unknown-key`, `frame-duplicate-key` (a field given twice; the frame refuses a repeated key before the plan is read, and a key repeated inside a spec stays in the kept `a type given twice` and `a context_field key given twice` rows), `missing-context`, `missing-outputs`, `outputs-with-target`, `outputs-string`, `output-unknown`, `outputs-empty`, `output-duplicate`, `passthrough-with-ciphertext`, `target-number`, `type-unknown`, `type-uint32-match`, `type-bool-ore`, `context-bare-label`, `context-bool`, `mixed-prefix`, `shared-identity`, `context-empty` | `declarations::shared_declaration_grammar` | `missing_context` reported as `unknown_key`: `missing-context`; a label refusal reported as another reason: `context-bare-label`, `context-one-segment`, `context-segment-*`, `context-nested-extension`, `context-field-static-label` | +| `every_refusal_names_its_field_and_reason::a_null_is_refused_as_its_key` (`record.rs`), 6 rows, removed; the message check stays as above | `context-null`, `outputs-null`, `target-null`, `type-null`, `context-field-null`, `field-null` | `declarations::shared_declaration_grammar` | `present` reading a null as an absent key: `outputs-null`, `target-null`, `target-options-null`, `type-null`, `null-mode-null` | +| `every_refusal_names_its_field_and_reason::a_missing_or_null_index_payload_is_the_default` (`record.rs`), removed | `match-default-bare`, `match-default-null`, `match-default-empty`, `unit-output-null-eq` | `declarations::shared_declaration_grammar` (normalization) | a non-default option substituted for a missing match payload: `match-default-bare`, `match-default-null` | +| `given_a_plan_value::refuses_a_malformed_plan_before_any_field_is_built` (`record.rs`), 17 rows, removed | `root-null`, `empty-plan`, `field-not-object`, `unknown-key`, `missing-context`, `missing-outputs`, `outputs-empty`, `output-not-string`, `output-unknown`, `output-duplicate`, `passthrough-with-ciphertext`, `passthrough-with-index`, `frame-duplicate-key`, `context-bool`, `context-empty` | `declarations::shared_declaration_grammar` | as the first row; the old rows asserted the error variant alone, the corpus asserts code, reason and field | +| `given_a_plan_value::refuses_a_field_context_that_is_not_a_label` (`record.rs`), 6 of 9 rows removed; the integer, bytes and extended one-segment shapes stay | `context-bare-label`, `context-one-segment`, `context-segment-slash`, `context-segment-digit`, `context-nested-extension` | `declarations::shared_declaration_grammar` | a label refusal reported as another reason: every `context_not_label` case | +| `every_refusal_names_its_field_and_reason::a_source_that_does_not_fit` (`record.rs`), 3 of 9 rows removed; the passthrough, repeated-key and non-object shapes stay | `required-missing`, `late-missing`, `unknown-field`, `sql-missing-with-unknown-field`, `required-wrong-width`, `late-wrong-type` | `runtime_conformance::shared_record_runtime_profile` | a missing field tolerated by every null mode: `required-missing`; the declared type not checked on a required field: `required-null`; `unknown_field` reported as `field_missing`: `unknown-field` | +| `given_a_source_that_does_not_fit_the_plan::encrypt_and_check_source_refuse_it_alike_with_no_key_request` (`record.rs`), 3 of 11 rows and the two matching `encrypt` checks removed | as the row above; the runner also asserts no data key is generated on a refusal | `runtime_conformance::shared_record_runtime_profile` | as the row above | +| `every_refusal_names_its_field_and_reason::a_stored_record_that_does_not_fit` (`record.rs`), 5 of 7 rows removed; the repeated-field row stays (the engine guard is reached natively alone, see the raw-frame contract), and so does the record that is one leaf: the read profile stores every record as a batch, so `row-not-map` reaches the batch-row arm and not the whole-record one | `missing-field`, `field-not-map`, `missing-ciphertext`, `plaintext-under-c`, `context-tenant-no-payload` | `read_conformance::shared_record_read_profile` (`check_record` and `decrypt`, with the retrieval counter) | `outputs_not_map` and `no_ciphertext_node` reported as other reasons: `field-not-map`, `missing-ciphertext`; a missing field tolerated by every null mode: `missing-field`, `late-missing-field`, `context-drop-tenant`; `passthrough` and `no_passthrough_node` reported as other reasons: `plaintext-under-c`, `context-tenant-no-payload` | +| `given_a_field_that_stores_a_null::a_null_in_a_field_with_no_null_mode_is_refused` (`record.rs`), removed | `required-null` | `runtime_conformance::shared_record_runtime_profile` | the declared type not checked on a required field: `required-null` | +| `given_a_field_that_stores_a_null::a_stored_null_is_refused_through_a_plan_that_has_no_null` (`record.rs`), removed | `authenticated-null-as-required` (the refusal), `sealed-null` and `sealed-boundaries` (a value opens through the sealing plan) | `read_conformance::shared_record_read_profile`, `runtime_conformance::shared_record_runtime_profile` | a sealed null accepted by a plan with no null mode: `authenticated-null-as-required` | +| `given_a_field_that_stores_a_null::a_row_with_every_field_left_out_is_an_empty_record` (`record.rs`), removed | `sql-all-absent`, `sql-null` | `runtime_conformance::shared_record_runtime_profile` | an explicit SQL null not left out of the record: `sql-null` | +| `given_a_field_that_stores_a_null::a_stored_record_may_leave_out_only_a_sql_field` (`record.rs`), removed | `sql-absent-as-required`, `sql-absent-as-sealed` | `read_conformance::shared_record_read_profile` | a missing field tolerated by every null mode: `sql-absent-as-required`, `sql-absent-as-sealed` | +| `a_query_the_field_does_not_answer_is_refused_naming_the_field` (`src/dynamic/query.rs`), 10 rows, removed | `eq-on-match-only`, `matches-on-equality-only`, `eq-on-order-only`, `lt-text-for-integer`, `lt-fraction-for-integer`, `between-scalar`, `between-one-bound`, `between-mixed-bounds`, `no-such-field` | `query_refusal_conformance::shared_query_refusal_profile` (`check_query` and `query_term` alike) | `operator_not_answered` reported as `unknown_operator`: `eq-on-match-only`; `not_a_range` reported as `field_type`: `between-scalar`; `no_such_field` reported as `unknown_field`: `no-such-field` | +| `the_typed_paths_query_refusals_are_made_before_any_term` (`query.rs`), 6-row table removed; renamed `folded_equivalents_derive_one_term` for the one-term assertions that stay | `lt-unassigned`, `between-unassigned-high`, `between-unassigned-low`, `matches-too-short`, `matches-unassigned`, `eq-unassigned` | `query_refusal_conformance::shared_query_refusal_profile` | the upper bound of a range not folded before a term: `between-unassigned-high` | +| `block_ore_refuses_text_it_has_no_term_for_before_any_term` (`query.rs`), refusal rows removed; the accepted texts' index assertion stays as `accepted_block_ore_text_derives_a_block_ore_term`, since the corpus records that a case is accepted, not which index answers it | `ore-block-too-long`, `ore-block-trailing-nul`, `ore-block-between-long-high`, `ore-block-between-long-low`, `ore-block-between-nul-low`, `ore-block-between-nul-high` | `query_refusal_conformance::shared_query_refusal_profile` | the length refusal reported as the NUL refusal: `ore-block-too-long` | +| `TestAQueryTheFieldDoesNotAnswerIsRefused` (`languages/golang/encrypt/query_test.go`), 5-row table, the inverted range and the short match text removed; renamed `TestVerifyAndTargetLimitRefuseWhatIsNotAMatchQuery` for the `Verify`/`TargetLimit` refusals that stay | `matches-on-integer`, `lte-on-match-only`, `unknown-operator`, `between-scalar`, `lt-text-for-integer`, `inverted-range`, `matches-too-short` | `TestQueryRefusalConformance` | the host dropping the diagnostic's `reason`: every refusal case; the engine faults above, through the same engine the guest embeds | +| `TestAPlanRefusalNamesItsReason` (`languages/golang/encrypt/guest_test.go`), removed | `output-duplicate` as a frame, on both guests (its host plan in `v1/go-declarations.json` is refused by `Validate` before the engine, so the frame is what observes the engine's reason; `TestCheckerRefusesAnIndexedUntypedField` still reaches the engine through `engineCheck`) | `TestDeclarationConformance` | the host dropping the diagnostic's `reason`: `output-duplicate` and every other refusal case | + +Considered and kept, with the reason: + +- `given_a_stored_record_that_does_not_fit_the_plan::decrypt_and_check_record_refuse_it_before_any_key_is_retrieved`: + eight of its thirteen shapes are read cases, but the five that are not (a + passthrough inside the ciphertext subtree, a passthrough field carrying a + ciphertext, a field, an output and a key given twice) are the engine guards + the transport reaches natively alone, and the test is the module's + load-bearing forged-plaintext check with its retrieval counter. +- `refuses_fields_under_different_contexts_or_extensions` and + `refuses_two_fields_keyed_under_one_identity`: each holds one shared row + beside shapes the corpus has not got (one field extended and one not; two + passthrough fields sharing a label). +- `tests/op.rs` typed refusals (`a_match_query_with_no_tokens_is_refused_before_any_term_or_keyset`, + `a_match_query_is_cut_in_nfc_and_refuses_text_with_no_normal_form`, + `an_inverted_range_is_a_query_like_any_other`): the corpus's `typed` entries + record the typed chain's refusal stage, not its `via`, batch, case-sensitive + and bound-order assertions. +- Go `TestPlanCheckAnswersWithoutACipher`, `TestQueryTermRefusesWhatTheFieldDoesNotAnswerBeforeState`, + `TestGuestRefusesMalformedInputsBeforeState`, `TestCheckerRefusesAnIndexedUntypedField`: + their rows are shared declaration and refusal cases, but each is about the + uninitialised instance answering `ErrEncoding` before `ErrState`, or the + host rule refusing before the engine, which the corpus runners do not observe. +- Everything in the retained list above: compile-fail, ownership and + zeroization, batching, property, frozen-bytes, generated-code, host + conversion, error wrapping, cancellation, guest lifecycle, PostgreSQL and + live-provider tests. diff --git a/conformance/stack-encrypt/eql-json-contract.md b/conformance/stack-encrypt/eql-json-contract.md new file mode 100644 index 000000000..299b7943b --- /dev/null +++ b/conformance/stack-encrypt/eql-json-contract.md @@ -0,0 +1,134 @@ +# EQL and JSON operation profiles + +These experimental profiles enroll the existing EQL v4 JSON tree operations. +They do not describe EQL v3 SteVec, or certify an entire SDK. The +[enrollment index](v1/eql-json-profiles.json) names mandatory compiled tests; +[shared cases](v1/eql-json.json) supply document text and predicate refusals to +native Rust and Go. Python and TypeScript have no enrolled runner yet. + +## Requirements and evidence + +| Requirement | Contract | Existing evidence retained | +|---|---|---| +| `eql-resolver-catalog-v1` | The resolver names catalog domains, their producibility and operators. Unknown names, unavailable EQL builds and unsupported capabilities are refused. JSON options require a JSON target. | Native `targets` and `json_tree_data_plan`; Go catalog and plain-guest refusal tests | +| `json-write-v1` | JSON documents, including scalar roots, empty containers, JSON null and integer boundaries, round-trip as values. SQL null stores no document. Invalid text is refused without exposing it; native tests observe no key request. Typed and dynamic plans agree on search terms and can open each other's output. | Native typed/data-plan suites, Go raw and generated typed document APIs; existing seeded derivation fixture | +| `json-query-v1` | Containment, reverse containment, equality at a path, ordered bounds, selectors and child operations derive their documented operand shapes. Unsupported requests fail. | Native engine/EQL operand comparisons, Go generated queries, independent PostgreSQL `jsonb` containment oracles | +| `json-request-bound-reads-v1` | Extraction and subtree/wildcard reads open against the prepared path, column, position and bounds. Wrong requests and detectable tampering fail. | Native adversarial read suite, EQL request/open tests, Go selector reads, PostgreSQL extraction/subtree and hostile-row suites | +| `json-extraction-v1` | Single-node extraction opens the requested scalar. | Native scalar extraction, Go selectors and PostgreSQL plaintext extraction oracle | +| `json-subtree-v1` | Complete bounded subtrees and wildcard reads match plaintext queries. | Native/EQL prepared reads and Go/PostgreSQL read paths | +| `json-tamper-v1` | Detectable mixed-document, truncated-chain and substituted-column answers are refused. | Native and PostgreSQL adversarial suites; Go observes wrong-column rejection | +| `json-element-match-v1` | Every condition must hold on one element. Ordering paths and resolver capability are required; a probe beyond the declared bound raises rather than silently truncating. | Native operator/capability tests, Go generated member APIs, SQL domain and independent plaintext predicate tests | +| `json-predicate-match-v1` | Supported text compiles to existing operators; refusals preserve reason, construct and byte offset, with no caller text in diagnostics. Missing and null paths obey the documented truth rules. | Shared negative corpus, native compiler/property tests, Go generated `Match`, SQL `jsonb_path_match` and handwritten-operator oracles | + +The index is an evidence inventory, not a second copy of every existing test. +Native execution retains whole binaries, including their unindexed properties +and regressions. Go selects the exact enrolled tests and rejects missing passes, +failures and skips, including skipped subtests. Native inventory checks reject +missing names, filtered tests and a wrong `#[ignore]` state before execution. +Only the PostgreSQL lane executes the database tests marked `#[ignore]`. + +## Predicate grammar + +The current grammar in `packages/stack-encrypt/src/dynamic/json_query.rs` and +`json_predicate.rs` is the executable specification. This profile pins: + +```text +predicate := comparison | exists(path) | exists(path ? (filter)) + | (predicate) | !(predicate) + | predicate && predicate | predicate || predicate +comparison := path op value | value op path +op := == | != | <> | < | <= | > | >= +path := $ followed by .key, ."quoted key", or [*] steps +value := JSON scalar literal | $variable | $"quoted variable" +filter := comparison on @ or @.member, optionally joined with && +``` + +Negation binds before `&&`, which binds before `||`. Variables are a JSON +object; absence of `vars` means an empty object. Scalar literals are strings, +numbers, booleans and null; objects/arrays cannot be comparison values. +Orderings and multi-condition element filters require numbers or strings and +configured ordered paths. A filter requires a wildcard in its outer path; +its member cannot contain a wildcard. Predicate nesting is limited to 32. +A wildcard bound must be positive; its probe prevents an undersized bound from +silently producing a partial answer. + +Arithmetic, regex, `starts with`, `is unknown`, mode words, methods, `.*`, `.**`, +slices, `last`, path-to-path/value-to-value comparisons, array positions, +filters outside `exists`, and `||`, `!`, `!=` or nested `exists` inside filters +are unsupported. `!=` at a wildcard is refused. The corpus pins representative +syntax, unsupported-construct, variable, type, depth and path diagnostics. +Existing parser tests retain the wider grammar matrix and adapter-specific +invalid-input checks; the shared corpus is not an exhaustive grammar fuzzer. +Offsets are UTF-8 byte offsets, not character indices. Each refusal also names +`at_text`, the suffix beginning at that offset. Omitted `vars` exercises absent +variables rather than an explicit empty object. + +## Nulls, oracles and intentional differences + +SQL null (no encrypted document), JSON null (a document/node), and an absent +path are distinct. The SQL predicate suites compare both selected rows and +three-valued answers. An absent path does not satisfy a comparison. Ordering a +JSON null can produce SQL unknown where plaintext jsonpath returns false; +the compiled predicate adds the null test under negation where it affects +selected rows. This exception is checked explicitly, not normalized away. + +The independent oracles use PostgreSQL `jsonb @>`, `<@`, `jsonb_path_query` and +`jsonb_path_match`, alongside handcrafted plaintext conditions. They do not +calculate expected results from encrypted operands. Their supported-domain +assumptions and exceptions remain explicit: + +- Paths are literal. There is no lax implicit array unwrapping or scalar-to-array + wrapping. Use `[*]` and keep array shapes consistent. +- Keep one scalar kind at an ordered path. Terms do not preserve the plaintext + type distinctions used by jsonpath's cross-kind comparisons. +- Order terms fold text case/accents and compare numbers as float64. Equality in + a multi-condition element filter uses those terms; standalone `==` is exact. +- Reverse containment excludes the plaintext root-scalar-in-root-array special + case; the generated oracle logs how many such comparisons it excludes. +- Storage and projection list order is randomized. Compare projections as sets + and stored selector collections as multisets. The existing deterministic + `packages/stack-encrypt/tests/fixtures/json_tree_vectors.json` pins a seeded + permutation, tags and tokens, not a production ordering promise. Fresh + ciphertext bytes are not equality fixtures. +- Read authentication detects substituted paths, wrong columns, mixed-document + subtrees, forged tags and incomplete returned chains. It does not establish + database completeness or row identity: withholding a whole answer can look + like no node, and another row's node at the same path can open. Those limits + have named native evidence in the index. + +## Required execution and identity + +From the repository root: + +```sh +mise run wasm:guest:build +mise run wasm:guest:build:deterministic +mise run wasm:guest:build:eql +mise run wasm:guest:build:eql:deterministic +mise run test:conformance:eql + +# A disposable database: this command installs/replaces EQL v4 there. +EQL_TEST_DATABASE_URL='host=localhost port=5432 user=postgres password=postgres dbname=postgres' \ + mise run test:conformance:eql:postgres +``` + +No CipherStash credentials are required. Both lanes are required in +`tests-golang.yml` (the PostgreSQL lane is called by `go:test:postgres`); conformance changes trigger them. The database lane rebuilds +SQL from this tree, installs exactly the bundle whose SHA-256 it records, and +passes the same connection to both languages. It fails without a database or +required guests. Required mode deliberately applies to every Go test using +`deterministicEQLClient`, including tests outside the enrollment index. A fast-lane pass alone is not SQL conformance. + +Reports include the native engine and EQL binding package versions, root/EQL +lockfile hashes, corpus/enrollment hashes, deterministic guest hashes, executed +requirement test names, and (for PostgreSQL) the installed EQL version and schema name, the expected schema name, PostgreSQL version and actual SQL bundle hash. Development bundles can +report `DEV`; their hash distinguishes them. CI uploads reports and fails if +none were produced. Locally set `STACK_CONFORMANCE_REPORT_DIR` to retain them; +the same report is always printed after success. These describe this checkout +and its guest artifacts, not a claim of compatibility across released versions. + +Required checks reject all fixture-update environment variables. Extending a +language binding means running the applicable shared cases and providing its +own adapter evidence for each claimed requirement. Fresh cross-language JSON +artifact exchange, a release compatibility matrix, and Python/TypeScript +runners remain separate work. diff --git a/conformance/stack-encrypt/fixture-contract.md b/conformance/stack-encrypt/fixture-contract.md index 587c6762d..329d02387 100644 --- a/conformance/stack-encrypt/fixture-contract.md +++ b/conformance/stack-encrypt/fixture-contract.md @@ -69,7 +69,7 @@ which the record-interface test covers. Its existing SQL parameter conversion, empty-page, zero-query, inverted-range and floating-point ordering assertions remain in those selected tests. Wider operator refusal and secret-diagnostic tests remain in the full suites; this slice does not turn them into shared -negative cases. In Go, `TestAQueryTheFieldDoesNotAnswerIsRefused` and +negative cases. In Go, `TestVerifyAndTargetLimitRefuseWhatIsNotAMatchQuery` and `TestAMatchesQueryNeverPrintsItsText` share the fixture client, so they also fail on a missing guest in required mode. They remain outside the aggregate selection and the shared requirements; the missing-guest regression covers them. diff --git a/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md new file mode 100644 index 000000000..17a42a9db --- /dev/null +++ b/conformance/stack-encrypt/lifecycle-contract.md @@ -0,0 +1,117 @@ +# Lifecycle, cleanup and key-I/O conformance + +`lifecycle-v1` enrolls observable native Rust and Go WASI behavior. The +[index](v1/lifecycle-profiles.json) names existing compiled tests and focused +new lifetime probes. These are backend-specific observations, not another +semantic value corpus and not a promise to erase every plaintext copy in a +process. Python and TypeScript are explicitly not enrolled. + +## Required evidence + +| Requirement | Native evidence | Go evidence and limits | +|---|---|---| +| `lifecycle-ownership-v1` | Actual record operations release fulfilment captures exactly once on success, provider failure and unpolled drop; zipped fulfilments do not share key material. An owned `OpaqueBlob` source is wiped before its pending is returned, on success and on an encoding refusal. A decrypted `Zeroizing>` is wiped once on drop, and a plain `OpaqueBlob` is not. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | +| `lifecycle-cancellation-v1` | Dropping a future paused in generation drops the provider future and operation captures; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | +| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Existing context cases reject late authentication failures without returning partial output. An `OpaqueBlob` refused before its first field is built leaves no marked residue; a refusal after some fields are built is the `native-partial-decode-fields` limit below. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. It also checks an `OpaqueBlob`'s encoded buffer (which regrows while it holds plaintext), its planned leaf and its opened buffer, and for those blob paths that no marked block allocated during the operation is still live at its end (a leak the free-time scan cannot see), with its own negative control. The partial-decode limit is pinned separately and is not enrolled here (see below). Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-diagnostics-v1` | Guest errors discard untrusted foreign cause messages; failed/successful exports do not hand over stale diagnostics. | Structured errors remain available after failures; SDK errors and redacted display/logging expose types or fields rather than plaintext values. | +| `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. An `OpaqueBlob` whose `Serialize` refuses requests no key, and an oversized context is refused before the value is encoded; on decrypt the leaf authenticates (one retrieval) before any blob byte is read, and tampering is `Aead` however the bytes would decode. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | +| `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | +| `lifecycle-provider-chunks-v1` | The actual KMS chunking helper preserves order, handles a final short chunk, fails the whole call on a chunk error, bounds in-flight chunks and releases their futures on cancellation. | This is a native algorithm observation, not a measurement of live HTTP request counts. | + +The drop witnesses belong to fulfilment closures and provider futures in real +record operations. They establish ownership release. They do **not** establish +that arbitrary captured values implement zeroization. The separate heap and +buffer observations provide the narrower byte-cleanup evidence. + +A validation failure detectable only after authentication necessarily retrieves +keys. The profile distinguishes that from shape and context checks that can run +without keys. An error does not imply that no provider request ever occurred. + +## Logical calls and physical requests + +A `Pending` combines requests before awaiting them. For supported assemblies, +generation dispatches one logical `generate_keys` call; retrieval dispatches one +logical `retrieve_keys` call per keyset. Existing instrumented providers observe +those calls and their ordered payloads. + +`StackKms::send_chunked` subsequently splits a logical call according to +`max_keys_per_req` and bounds concurrent chunks by `max_concurrent_reqs`. +The chunk tests observe this helper with controlled callbacks: ten inputs with +chunk size three and concurrency two start only two three-item chunks while +both are pending; dropping the aggregate releases both. This is distinct from +β€œone HTTP request per batch.” Live service limits, retries and transport timing +are not inferred from the logical counters. + +## Limits carried in every report + +These entries in the index are limitations, not successful requirements: + +- **`go-post-decrypt-shadow-stack` β€” known gap.** + `TestPlaintextDoesNotRemainInGuestMemoryAfterDecrypt` in + `languages/golang/encrypt/roundtrip_test.go` is explicitly skipped because + decrypted plaintext can remain in the WASM shadow stack in underlying cipher + temporaries. It is not enrolled as passing evidence. The post-encryption scan + cannot establish the post-decryption guarantee. +- **`native-partial-decode-fields` β€” known gap.** + `opaque_blob::a_partial_decode_drops_the_fields_it_built_unwiped` in + `packages/stack-encrypt/tests/heap_hygiene.rs` is a `should_panic` negative + control: a derived `Deserialize` that has built a `String` field of an + `OpaqueBlob` and then refuses a later field drops that field as an + ordinary value, unwiped, because no `T` was constructed to wipe. The opened + buffer the fields were copied from is wiped. This is serde's construction + order, stated in the `blob` module docs; a `T` whose fields wipe on drop + closes it for those fields. It is enrolled as the observation of a limit, + not as passing cleanup evidence. +- **`native-heap-observer-exclusions` β€” limited observation.** + `packages/stack-encrypt/tests/heap_hygiene.rs` watches marked frees on the + executing thread. Its existing observer excludes a named upstream aws-lc + sealing frame by backtrace and reports excluded frees. It scrubs stack residue + before observing heap frees; it does not prove stack erasure. Its JSON query + paths use borrowed/static path text because owned path copies have separate + cleanup limitations. The lifecycle runner preserves the observer's output. +- **`caller-owned-host-values` β€” outside the guarantee.** Caller-owned strings, + bytes, objects, returned plaintext, aliases and copies remain the caller's + responsibility. Ownership transfer or protected borrowing must be stated by + each public API. Cancellation cannot retract work already accepted by a remote + service, and dropping a provider future does not prove remote cancellation. +- **`live-provider-http-chunking` β€” not observed.** Hermetic tests count logical + provider calls and chunk callbacks. They do not attest to actual ZeroKMS + request counts, retry behavior or remote cleanup. +- **`python-typescript-lifecycle` β€” not enrolled.** Their backends, cancellation + models, host mappings and extension-owned buffers need their own evidence. + +The current required lane supports Linux and macOS. Its native heap observer +requires Unix; unsupported hosts fail explicitly rather than silently skipping +that evidence. This is not Windows lifecycle certification. Memory locking under +OS resource limits, whole-process heap scans, other threads, compiler/register +residue and arbitrary caller-supplied sinks are outside the enrolled guarantees. +Existing broader platform, Miri, fuzz, live and locking tests remain in place. + +## Running and extending the profile + +From the repository root: + +```sh +mise run wasm:guest:build +mise run wasm:guest:build:deterministic +mise run test:conformance:lifecycle +``` + +No CipherStash credentials or database are required. Some Go cancellation tests +start local HTTP stubs. The runner shares inventory/execution checks with EQL +conformance: every enrolled native test must be compiled, selected and not +ignored, and every Go test must pass with no skipped subtests. Fresh Go tests +bypass the test cache. Required fixture-update variables are rejected. + +The required `wasi-check` CI job runs the task and uploads its report, failing +if the report is absent. Reports identify the engine, platform/architecture, +lockfile, guest hashes, enrollment, executed evidence and known limitations. +Set `STACK_CONFORMANCE_REPORT_DIR` locally to retain the same JSON report. + +Add evidence to the existing requirement where possible. Keep language-specific +lifetime probes and implementation regressions: a shared semantic round-trip +cannot replace them. Do not claim a stronger cleanup guarantee by removing a +limitation or accepting a skipped test; supply executable evidence on the +backend being claimed. Secure-buffer adoption and key-copy removal remain +implementation work, distinct from this inventory. diff --git a/conformance/stack-encrypt/query-refusals-contract.md b/conformance/stack-encrypt/query-refusals-contract.md new file mode 100644 index 000000000..421227a13 --- /dev/null +++ b/conformance/stack-encrypt/query-refusals-contract.md @@ -0,0 +1,117 @@ +# Record query-refusal profile + +`record-query-refusals-v1` describes the cases in `v1/query-refusals.json`: +what a query is refused for before any term exists, and the few inputs +that look refusable and are not. It is an experimental test contract for +native Rust and Go WASI under `NoTargets`. Python and TypeScript runners +remain pending. + +The scalar and runtime profiles cover a handful of equality refusals. This +profile is the shared rejection corpus: every scalar operator against +every field capability of the plan, and the operand shapes and values +listed below, with the class of each refusal, its structured diagnostic, +and the observation that no key is requested. + +The runners are `packages/stack-encrypt/tests/query_refusal_conformance.rs` +and `languages/golang/encrypt/query_refusal_conformance_test.go`. + +## Plan and inputs + +`fields` declares one plan every case queries, each field under context +`["conformance", ]`: equality-only text, match-only text, order-only +text (ORE and block ORE), an integer with equality and order, an order-only +float, a 128-bit integer with equality and OPE, a ciphertext-only bool and +text, a sealed-null and a SQL-null integer, and bytes with an order index. + +A case names a `field`, an `operator` and an `input`: a scalar `{type, text}` +in the value profile's form, or `{"type": "list", "items": [...]}` for a +range. `accepted` is mandatory; a refused case carries `expected_error`. + +## Refusal classes + +| `class` | Engine error | Go sentinel | Diagnostic | +|---|---|---|---| +| `source` | `Error::Source` | `ErrEncoding` | `stack_encrypt::dynamic_source` with `reason` and `field` | +| `plan` | `Error::Plan` | `ErrEncoding` | `stack_encrypt::dynamic_plan` with `reason` and `field` | +| `term` | the index's own refusal of the value | `ErrTerm` | the term error's code (`stack_encrypt::empty_term_text`, `term_normalize`, `ore_block_too_long`, `ore_block_trailing_nul`); no reason or field | + +Source and plan refusals cover every scalar operator (`eq`, `lt`, `lte`, +`gt`, `gte`, `between`, `matches`) on every field of the plan whose +indexes do not answer it, one case per pair: the equality-only text and +integer, the match-only text, the ORE and block ORE text, the order-only +float, the ciphertext-only bool and text, and the bytes field (equality +and an order index no operator reads). The 128-bit integer (equality and +OPE) and the 32-bit integer (equality and ORE) answer every operator but +`matches`. The SQL-null integer shares the equality-only +integer's indexes and is not repeated. A JSON document operator on an +integer is refused as well. The remaining source refusals are operand +shapes and values: a range that is not a list of two +(a scalar, one bound, three bounds, text), a value of another kind (text or +a fraction for an integer, a negative for an unsigned, an integer for text, +a bool for an integer, a null bound), a null on a field that does not seal +its nulls beside an equality term (ORE and OPE order indexes included), a field the plan does not name and an +operator name the engine does not know. + +Term refusals cover match text that cuts no token (too short, empty, only +separators), text no index can normalize (a code point Unicode 16 does not +assign) under match, equality and order, and the block ORE limits (a fold +over 1023 bytes, a trailing NUL) on either bound of a range. + +Accepted cases are the positive controls: an inverted range at 32-bit, +128-bit and float widths (neither path refuses it; as SQL's `BETWEEN`, it +matches nothing), the longest block ORE +text, an interior NUL, empty order text, a null equality query on a +sealed-null field, folded match text and a losslessly widened integer. + +## Required observations + +Both runners run every case through query-term derivation: `QueryTerm` in +Go, `dynamic::query_term` in Rust, which must refuse before returning a +pending operation. For `matches`, both also run verification of an empty +page (`Verify`, `dynamic::verify`) and require the same outcome. Rust also +runs the binding's key-free check, `dynamic::check_query`, and requires the +same diagnostic, and observes that no keyset lookup, data-key generation or +retrieval happens on any path. Go's deterministic guest makes no network +request; that is not a logical key-call count. + +The operator vocabulary is closed in Rust (`OpSpec`), so the unknown +operator case is observed at the binding boundary alone; the Rust runner +checks that the case expects exactly that and continues. + +## The typed chain + +`typed` records how the typed Rust query chain answers the same query, for +the queries it can spell (text on the text fields, a `uint32` range under +ORE and an `int128` range under OPE): `before_keyset`, `after_keyset` or `none`. The Rust runner +builds a one-field typed plan with the index that answers the operator, +runs the chain under a keyset named for the case, and counts the keyset +load. A refusal before the load is `before_keyset`; one after it is +`after_keyset`. The error code must equal the binding's. + +Today the two paths refuse the same inputs with the same codes, but not at +the same stage: + +| Refusal | Binding (`check_query`, `query_term`) | Typed chain | +|---|---|---| +| Match text with no token or no normal form | before any keyset | before any keyset | +| Equality text with no normal form | before any keyset | after the keyset is loaded | +| Order text with no normal form (ORE, OPE) | before any keyset | after the keyset is loaded | +| Block ORE text too long or ending in NUL | before any keyset | after the keyset is loaded | + +The binding checks every index's value refusals in `check_input`; the +typed chain's `Supports::check` runs before the keyset for the match index +alone, and the other indexes refuse the value when they derive its term. +This profile pins the current stage as a documented difference. Moving the +typed refusals earlier is the query-preparation consolidation tracked +separately; when it lands, the `typed` entries change to `before_keyset` +on purpose, and this table with them. + +## Scope and remaining work + +The corpus covers scalar operators under `NoTargets`. Match options the +index cannot use (an n-gram length of zero) are a declaration refusal, in +`v1/declarations.json` (`match-invalid-ngram-zero`). JSON document +operators against JSON fields, EQL target refusals, `target_limit` inputs and the match-case +mismatch only the typed chain can express remain with their existing +tests. The Go runner does not exercise generated query methods; those keep +their fixture tests. No existing test is removed. diff --git a/conformance/stack-encrypt/raw-frames-contract.md b/conformance/stack-encrypt/raw-frames-contract.md new file mode 100644 index 000000000..3998f8898 --- /dev/null +++ b/conformance/stack-encrypt/raw-frames-contract.md @@ -0,0 +1,107 @@ +# Record raw-frame profile and error classes + +`record-raw-frames-v1` describes the cases in `v1/raw-frames.json`: stored +record batches supplied as transport bytes (`frame_hex`), so both runners +hand the decoder the same bytes, including what no map-based tree encoder +can write. It is an experimental test contract for native Rust and Go WASI. +Python and TypeScript runners remain pending. + +The read profile builds stored records from real ciphertext and mutates the +tree. Its Go runner encodes that tree through the host's tree codec, which +refuses a repeated key, so repeated keys and malformed leaves could not be +shared there. This profile is the explicit raw-byte path the read profile +deferred. + +The runners are `packages/stack-encrypt/tests/raw_frame_conformance.rs` +and `languages/golang/encrypt/raw_frame_conformance_test.go`. Go reaches +the guest through a test-only hook that takes the encoded batch directly +(`OpenFrameForTest`), keeping cipher validation, context, production +decoding and buffer cleanup; the uninitialised checker observes the guest's +preflight alone. + +## Error classes + +A read can fail at five points. Each class is the stage, not the message, +and a case's `class` names where its frame stops: + +| `class` | Where it stops | Native observation | Go observation | +|---|---|---|---| +| `transport` | The bytes are not a ciphertext tree in the transport codec | `decode_ciphertext` fails; the codec reports no code | `stack_guest_abi::malformed_input`, the corpus-wide `boundary_error`, as `ErrEncoding`, from the checker and the cipher | +| `leaf` | The tree decodes and a sealed leaf is not in the leaf byte format | `SealedValue::from_bytes` fails with its code and payload | The same code and payload as `ErrEncoding` | +| `shape` | Leaves are well formed and the tree does not fit the plan | `check_record` and `decrypt` refuse with `stack_encrypt::dynamic_record` before any pending operation | The same diagnostic from the checker and the cipher | +| `authenticated` | The tree fits, the leaf is opened, and what it holds is not what the plan reads | The pending opener fails after one retrieval | The checker reaches `ErrState`; the cipher reports the diagnostic | +| `success` | The record opens | Typed rows, one retrieval | Typed rows | + +Two more classes live in other profiles and are listed here so the five +are read together. An **invalid declaration** stops before any record: the +declaration grammar's cases report `stack_encrypt::dynamic_plan`, and a +malformed plan frame reports the same `malformed_input` boundary. An +**unsupported host conversion** stops before the engine: the value profile's +`host_conversions` report the host's own `ErrEncoding` with no engine +diagnostic. + +Within `authenticated`, two outcomes are distinguished. A leaf sealed as an +`int32` and read as `int64` or `string` authenticates and then fails the +declared type: `stack_encrypt::plan_field_type` naming the field and the +expected kind, as the read profile's authenticated cases do. A leaf moved +into another role (the single leaf under an empty-sequence tag) fails +authentication itself: `stack_encrypt::aead`, which Go reports as +`ErrAuthentication` carrying that code. Both runners compare the code and +fields of either outcome, as they do for every refusal. Neither is a shape refusal: a container's membership +and order are not authenticated, so a tree that fits the plan passes +preflight and is judged leaf by leaf. + +## Repeated keys + +The transport codec refuses a map with a repeated key, in the frame a +binding writes and in the frame a guest reads, so a stored record with the +same field or output twice is a `transport` refusal in both runners. It +never reaches the engine's own `field_repeated` and `repeated_key` reasons. + +Those reasons still guard a tree built in native Rust without the codec. +`native_tree` records what the engine says of the same bytes when a reader +admits the repeated key and keeps every other codec rule: the runner reads +the frame leniently, checks the tree against the plan, and compares the +diagnostic. A separate test holds that reader to the codec on every frame +the codec accepts. Go parses `native_tree` and does not execute it: a +binding cannot reach the engine with such a tree. + +## Cases + +`frame_hex` is the complete batch: one row, one field `value` with `c` and +`eq`. The well-formed leaf is the scalar profile's frozen `i32-max` +ciphertext under seed zero and context `["conformance", "value"]`, with its +frozen equality term as the passthrough. `read` names the declaration the +batch is read under. `retrievals` is `1` for `authenticated` and `success` +and `0` otherwise; a read never generates a key. The counts are a native +observation: Go checks that `retrievals` agrees with the class, and its +checker's stop at the state boundary stands in for "nothing retrieved +before preflight". + +| Case | Class | What the bytes do | +|---|---|---| +| `success-frozen-leaf` | success | The frozen leaf read as `int32` | +| `authenticated-int64`, `authenticated-string` | authenticated | The frozen leaf read as another kind | +| `authenticated-c-empty-sequence` | authenticated | The frozen leaf under the empty-sequence tag | +| `shape-c-passthrough` | shape | A plaintext passthrough where the ciphertext must be | +| `leaf-unknown-version` | leaf | The leaf's version byte is 9 | +| `leaf-truncated` | leaf | Two leaf bytes | +| `transport-repeated-field` | transport | `value` twice in the row; natively `field_repeated` | +| `transport-repeated-output` | transport | `c` twice in the field; natively `repeated_key` | +| `transport-retired-none-tag` | transport | The retired absence marker as a node | +| `transport-trailing-byte` | transport | A byte after a complete tree | +| `transport-truncated-count` | transport | A sequence count of two with one row | +| `transport-not-a-tree` | transport | One byte that is no tag | + +Both runners reject a case whose expectations contradict its class, and a +transport case whose frame the codec accepts. Go additionally shows that +its own tree codec refuses every transport frame, so none of them is a +batch the host could have written. + +## Scope and remaining work + +The frames hold one row and one field. Depth and size bounds, context +fields, other output kinds and repeated keys inside passthrough payloads +stay with the codec's own tests and the read profile. Python and +TypeScript consumers should feed the same `frame_hex` to their decoders. +No existing test is removed. diff --git a/conformance/stack-encrypt/v1/declarations.json b/conformance/stack-encrypt/v1/declarations.json index c9bb1627a..cb2e4bea9 100644 --- a/conformance/stack-encrypt/v1/declarations.json +++ b/conformance/stack-encrypt/v1/declarations.json @@ -2342,6 +2342,19 @@ "fields": { "reason": "unknown_output", "field": "value" } } }, + { + "id": "output-not-string", + "input": { + "json": { + "value": { "context": ["users", "value"], "outputs": [1] } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, { "id": "output-options-on-equality", "input": { @@ -2405,6 +2418,22 @@ "fields": { "reason": "passthrough_with_outputs", "field": "value" } } }, + { + "id": "passthrough-with-index", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["eq", "passthrough"] + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "passthrough_with_outputs", "field": "value" } + } + }, { "id": "type-unknown", "input": { @@ -2760,6 +2789,17 @@ "fields": { "reason": "invalid_target", "field": "value" } } }, + { + "id": "target-number", + "input": { + "json": { "value": { "context": ["users", "value"], "target": 1 } } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "invalid_target", "field": "value" } + } + }, { "id": "target-options-null", "input": { diff --git a/conformance/stack-encrypt/v1/eql-json-profiles.json b/conformance/stack-encrypt/v1/eql-json-profiles.json new file mode 100644 index 000000000..e829ac1a0 --- /dev/null +++ b/conformance/stack-encrypt/v1/eql-json-profiles.json @@ -0,0 +1,266 @@ +{ + "version": 1, + "suites": { + "engine-queries": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "binary": "json_operators", + "features": ["--all-features"] + }, + "engine-reads": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "binary": "json_read_checks", + "features": ["--all-features"] + }, + "engine-vectors": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "binary": "json_tree_vectors", + "features": ["--all-features"] + }, + "eql-targets": { + "language": "rust", + "lane": "fast", + "workspace": "packages/eql", + "package": "eql-encryption-tests", + "binary": "targets", + "features": [] + }, + "eql-record": { + "language": "rust", + "lane": "fast", + "workspace": "packages/eql", + "package": "eql-encryption-tests", + "binary": "json_tree_data_plan", + "features": [] + }, + "eql-typed": { + "language": "rust", + "lane": "fast", + "workspace": "packages/eql", + "package": "eql-encryption-tests", + "binary": "json_tree", + "features": [] + }, + "postgres": { + "language": "rust", + "lane": "postgres", + "workspace": "packages/eql", + "package": "eql-encryption-tests", + "binary": "json_tree", + "features": [] + }, + "go": { + "language": "go", + "lane": "fast" + }, + "go-postgres": { + "language": "go", + "lane": "postgres" + } + }, + "requirements": [ + { + "id": "eql-resolver-catalog-v1", + "evidence": { + "eql-targets": [ + "the_table::names_every_stored_catalog_domain_in_order", + "the_table::every_row_is_a_compiled_domain_type_and_so_is_its_query", + "the_table::the_producible_types_are_the_ones_with_a_rust_plaintext", + "given_an_unknown_name::every_entry_point_says_no_such_type", + "given_a_name_the_engine_cannot_produce::every_entry_point_refuses_it_with_the_table_reason" + ], + "eql-record": [ + "the_engine_and_the_table_agree_on_which_targets_take_json_options" + ], + "go": [ + "TestTheCatalogListsEachTypesOperators", + "TestTextEqIsRefusedByTheBuildWithoutEQLTypes", + "TestTargetWithoutEQLIsRefusedBeforeTheGuest" + ] + } + }, + { + "id": "json-write-v1", + "evidence": { + "eql-record": [ + "a_batch_of_documents_round_trips_in_one_request", + "the_data_plan_and_the_typed_layout_agree", + "text_that_is_not_a_document_is_refused_before_any_key", + "a_sql_null_stores_no_document", + "a_document_does_not_open_under_another_column" + ], + "eql-typed": ["a_document_round_trips_through_the_typed_target"], + "engine-vectors": [ + "the_derivations_are_pinned_under_the_deterministic_source" + ], + "go": [ + "TestJSONDocumentsRoundTripThroughTheGuest", + "TestJSONDocumentsDoNotOpenUnderAnotherColumn", + "TestJSONTextThatIsNotADocumentIsErrEncoding", + "TestACustomersDocumentsRoundTrip", + "TestASubscribersTypedDocumentsRoundTrip" + ], + "go-postgres": ["TestJSONTypedDocumentsInPostgres"] + } + }, + { + "id": "json-query-v1", + "evidence": { + "eql-record": [ + "a_document_field_answers_the_json_operators", + "every_operand_is_the_typed_chains", + "a_json_operator_refuses_what_it_cannot_answer", + "a_document_has_no_one_plaintext_query" + ], + "engine-queries": [ + "every_operator_derives_its_tagged_operand", + "the_orderings_follow_the_resolved_options", + "malformed_inputs_are_refused_with_their_reason" + ], + "eql-typed": [ + "the_query_operands_are_the_engines_terms_in_the_sql_contract" + ], + "go": [ + "TestJSONOperatorsDeriveTaggedTerms", + "TestACustomersFieldsDeriveEachQuery", + "TestASubscribersTypedNeedlesDeriveTheRawQuery" + ], + "postgres": [ + "postgres_containment_over_the_encrypted_column_is_jsonb_containment", + "generated::given_generated_documents_in_postgres::contains_selects_the_rows_jsonb_contains_on_both_plans", + "generated::given_generated_documents_in_postgres::contained_by_selects_the_rows_jsonb_does_except_a_root_scalar_in_a_root_array" + ], + "go-postgres": ["TestJSONDocumentsQueryAndReadInPostgres"] + } + }, + { + "id": "json-request-bound-reads-v1", + "evidence": { + "eql-record": ["reads_open_against_the_request_that_prepared_them"], + "engine-queries": [ + "reads_open_against_the_request_that_prepared_them", + "rows_that_are_not_the_reads_are_refused", + "a_malformed_read_is_refused_before_any_key" + ], + "engine-reads": [ + "given_the_entry_arrow_returned::another_paths_entry_is_refused_before_any_key_is_retrieved", + "given_the_rows_of_a_read::the_honest_rows_open_to_the_nodes_value_in_any_order", + "given_the_rows_of_a_read::another_position_of_the_array_is_refused_by_its_label", + "given_the_rows_of_a_read::a_truncated_subtree_is_refused_by_the_cascade", + "given_the_rows_of_a_read::rows_of_two_documents_are_refused", + "given_answers_the_check_cannot_tell_apart::rows_withheld_whole_read_as_no_node", + "given_answers_the_check_cannot_tell_apart::another_rows_node_at_the_same_path_opens" + ], + "go": [ + "TestJSONSelectorReadsOpenAgainstTheirRequest", + "TestJSONReadTypesPrintAndLogRedacted" + ], + "postgres": [ + "postgres_every_subtree_read_answers_as_jsonb_does", + "postgres_wildcard_reads_answer_as_jsonb_path_query", + "postgres_a_position_reaches_array_elements_only", + "postgres_hostile_answers_are_refused_by_the_client", + "postgres_reads_never_answer_wrongly_under_tampering", + "generated::given_generated_documents_in_postgres::arrow_opens_a_leaf_to_the_value_jsonb_holds" + ] + } + }, + { + "id": "json-element-match-v1", + "evidence": { + "engine-queries": [ + "element_match::given_documents::one_element_must_satisfy_every_member", + "element_match::given_documents::answers_as_the_jsonpath_over_the_plaintext", + "element_match::given_the_fields_type::does_not_answer_it_when_the_type_names_no_domain", + "element_match::given_the_fields_type::does_not_list_it_without_an_ordered_path" + ], + "go": [ + "TestElementMatchIsTheReadsAndTheOrderingsOfEachMember", + "TestElementMatchRefusesBeforeTheEngine", + "TestElementMatchEngineRefusalsAreTypedErrors", + "TestAtPathMakesThePredicatesOfItsMember" + ], + "postgres": [ + "elements::postgres_an_element_predicate_holds_of_one_element", + "elements::postgres_one_element_must_satisfy_every_member", + "elements::postgres_a_probe_hit_raises", + "elements::postgres_the_elements_operand_domain_checks_each_member" + ], + "go-postgres": ["TestJSONElementMatchInPostgres"] + } + }, + { + "id": "json-predicate-match-v1", + "evidence": { + "engine-queries": [ + "compiled_match::given_an_input_it_cannot_answer::shared_predicate_refusals", + "compiled_match::given_documents::unknown_null_and_absent_answer_as_jsonpath_does", + "compiled_match::given_the_fields_type::does_not_answer_it_when_the_type_names_no_domain", + "compiled_match::given_a_predicate::each_leaf_is_its_operators_operand" + ], + "go": [ + "TestMatchCompilesToTheOperandsOfTheOtherOperators", + "TestMatchTakesItsVarsAsAnyJSONObject", + "TestMatchRefusalsNameTheConstructAndItsOffset", + "TestMatchOperandPrintsAndLogsRedacted" + ], + "postgres": [ + "predicate::postgres_a_compiled_predicate_answers_as_jsonb_path_match", + "predicate::postgres_a_compiled_predicate_answers_as_the_operators_written_out", + "predicate::postgres_a_null_leaf_is_unknown_under_and_or_and_not", + "predicate::postgres_the_predicate_domain_checks_each_node", + "predicate::postgres_the_operator_takes_a_typed_predicate_and_jsonpath_stays_blocked" + ], + "go-postgres": ["TestJSONPredicateMatchInPostgres"] + } + }, + { + "id": "json-extraction-v1", + "evidence": { + "engine-reads": [ + "given_the_entry_arrow_returned::the_entry_at_the_path_opens_to_its_scalar" + ], + "go": ["TestJSONSelectorReadsOpenAgainstTheirRequest"], + "postgres": [ + "generated::given_generated_documents_in_postgres::arrow_opens_a_leaf_to_the_value_jsonb_holds" + ] + } + }, + { + "id": "json-subtree-v1", + "evidence": { + "eql-record": ["reads_open_against_the_request_that_prepared_them"], + "engine-reads": [ + "given_the_rows_of_a_read::the_honest_rows_open_to_the_nodes_value_in_any_order" + ], + "postgres": [ + "postgres_every_subtree_read_answers_as_jsonb_does", + "postgres_wildcard_reads_answer_as_jsonb_path_query" + ], + "go-postgres": ["TestJSONDocumentsQueryAndReadInPostgres"] + } + }, + { + "id": "json-tamper-v1", + "evidence": { + "engine-reads": [ + "given_the_rows_of_a_read::a_truncated_subtree_is_refused_by_the_cascade", + "given_the_rows_of_a_read::rows_of_two_documents_are_refused" + ], + "postgres": [ + "postgres_hostile_answers_are_refused_by_the_client", + "postgres_reads_never_answer_wrongly_under_tampering" + ], + "go": ["TestJSONDocumentsDoNotOpenUnderAnotherColumn"] + } + } + ] +} diff --git a/conformance/stack-encrypt/v1/eql-json.json b/conformance/stack-encrypt/v1/eql-json.json new file mode 100644 index 000000000..4d27bb5ea --- /dev/null +++ b/conformance/stack-encrypt/v1/eql-json.json @@ -0,0 +1,213 @@ +{ + "version": 1, + "documents": [ + "{\"name\":\"Alice\",\"age\":34,\"visits\":[{\"at\":3},{\"at\":1}]}", + "{\"name\":\"Alice\",\"age\":34,\"visits\":[{\"at\":3,\"where\":\"x\"},{\"at\":1}],\"tags\":[\"a\",\"b\"]}", + "{\"name\":\"Bob\",\"age\":7.5,\"nested\":{\"deep\":[[1,2],{}]},\"none\":null}", + "[1,\"two\",{\"three\":3}]", + "\"a scalar root\"", + "18446744073709551615", + "-9223372036854775808", + "-926809.5051008083", + "null", + "{}", + "[]" + ], + "invalid_documents": [ + { + "text": "{\"secret\":1,\"secret\":2}", + "reason": "json_duplicate_key" + }, + { + "text": "99999999999999999999", + "reason": "json_integer_range" + }, + { + "text": "{\"secret\":", + "reason": "json_syntax" + }, + { + "text": "", + "reason": "json_syntax" + } + ], + "predicate_refusals": [ + { + "id": "text-the-grammar-does-not-read", + "predicate": "$.extra.country = $v", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_syntax", + "construct": "text the grammar does not read", + "at": 16, + "at_text": "= $v" + }, + { + "id": "like-regex", + "predicate": "$.extra.country like_regex \"secret\"", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unsupported", + "construct": "`like_regex`", + "at": 16, + "at_text": "like_regex \"secret\"" + }, + { + "id": "arithmetic", + "predicate": "$.weight + 1 == 2", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unsupported", + "construct": "arithmetic (`+`, `-`, `*`, `/`, `%`)", + "at": 9, + "at_text": "+ 1 == 2" + }, + { + "id": "a-method", + "predicate": "$.weight.size() == 1", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unsupported", + "construct": "a method (`.name()`)", + "at": 8, + "at_text": ".size() == 1" + }, + { + "id": "a-filter-outside-exists", + "predicate": "$.extra.preferred_languages[*] ? (@ == $v) == 1", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unsupported", + "construct": "a filter outside `exists`", + "at": 31, + "at_text": "? (@ == $v) == 1" + }, + { + "id": "in-a-filter", + "predicate": "exists($.extra.emergency_contacts[*] ? (@.relationship == $v || @.age > 1))", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unsupported", + "construct": "`||` in a filter", + "at": 61, + "at_text": "|| @.age > 1))" + }, + { + "id": "an-unknown-variable", + "predicate": "$.extra.country == $missing", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unknown_variable", + "construct": "a variable", + "at": 19, + "at_text": "$missing" + }, + { + "id": "a-value-an-ordering-cannot-take", + "predicate": "$.weight < $flag", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_value_type", + "construct": "a value", + "at": 11, + "at_text": "$flag" + }, + { + "id": "an-object", + "predicate": "$.extra.country == $obj", + "vars": { + "obj": { + "secret-nz": 1 + } + }, + "reason": "json_predicate_value_type", + "construct": "a value", + "at": 19, + "at_text": "$obj" + }, + { + "id": "at-a-wildcard", + "predicate": "$.extra.preferred_languages[*] != $v", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_not_equal_at_wildcard", + "construct": "`!=` at a path with a wildcard", + "at": 31, + "at_text": "!= $v" + }, + { + "id": "a-position", + "predicate": "$.extra.preferred_languages[2] == $v", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_path_position", + "construct": "a position (`[2]`)", + "at": 27, + "at_text": "[2] == $v" + }, + { + "id": "a-filter-with-no-wildcard", + "predicate": "exists($.address ? (@.postcode == $v))", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_path_no_wildcard", + "construct": "a filter on a path with no wildcard", + "at": 7, + "at_text": "$.address ? (@.postcode == $v))" + }, + { + "id": "a-wildcard-in-a-member", + "predicate": "exists($.extra.emergency_contacts[*] ? (@.calls[*] == $v))", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_path_wildcard", + "construct": "a wildcard in a filter's member", + "at": 40, + "at_text": "@.calls[*] == $v))" + }, + { + "id": "a-number-json-cannot-hold", + "predicate": "$.weight == 1e999", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_syntax", + "construct": "a value", + "at": 12, + "at_text": "1e999" + }, + { + "id": "too-deep", + "predicate": "((((((((((((((((((((((((((((((((($.a == 1)))))))))))))))))))))))))))))))))", + "reason": "json_predicate_too_deep", + "construct": "nesting", + "at": 33, + "at_text": "$.a == 1)))))))))))))))))))))))))))))))))" + } + ] +} diff --git a/conformance/stack-encrypt/v1/lifecycle-profiles.json b/conformance/stack-encrypt/v1/lifecycle-profiles.json new file mode 100644 index 000000000..41ffd2894 --- /dev/null +++ b/conformance/stack-encrypt/v1/lifecycle-profiles.json @@ -0,0 +1,280 @@ +{ + "version": 1, + "suites": { + "pending": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "lib": true + }, + "lifecycle": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "lifecycle_conformance" + }, + "heap": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "heap_hygiene", + "success_output": "immediate" + }, + "runtime": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "runtime_conformance" + }, + "reads": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "read_conformance" + }, + "contexts": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "context_conformance" + }, + "guest-abi": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-guest-abi", + "features": ["--all-features"], + "lib": true + }, + "kms": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-kms", + "features": ["--all-features"], + "lib": true + }, + "go": { + "language": "go", + "lane": "fast", + "package": "./encrypt" + }, + "go-memory": { + "language": "go", + "lane": "fast", + "package": "./internal/guest" + }, + "go-sdk": { + "language": "go", + "lane": "fast", + "package": "./encrypt/gensupport" + }, + "opaque": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "opaque_blob" + } + }, + "requirements": [ + { + "id": "lifecycle-ownership-v1", + "evidence": { + "lifecycle": [ + "dropping_unpolled_work_releases_captures_without_key_io", + "success_and_provider_failure_release_captures_once" + ], + "pending": [ + "target::pending::tests::zipped_fulfilments_never_share_key_material" + ], + "go": [ + "TestClientKeyIsConsumedAndNeverPrinted", + "TestCloseStopsTheCleanup", + "TestUnreachableClientIsReleased", + "TestClosedClientIsState" + ], + "opaque": [ + "the_owned_source_is_wiped_before_await_or_discard", + "an_encoding_refusal_wipes_the_source_and_requests_no_key", + "only_a_zeroizing_result_is_wiped_on_drop" + ] + } + }, + { + "id": "lifecycle-cancellation-v1", + "evidence": { + "lifecycle": ["cancelling_in_flight_work_drops_provider_and_captures"], + "guest-abi": [ + "step::tests::an_aborted_operation_is_dropped_and_its_handle_spent", + "step::tests::the_wrong_number_of_responses_is_refused_and_drops_the_operation" + ], + "go": [ + "TestACallerThatGivesUpBetweenStepsAbortsTheOperation", + "TestACallWhoseContextHasEndedNeverStarts", + "TestACallCancelledDuringATokenFetchFailsOnlyItself", + "TestAStepThatRunsOnAfterItsContextEndsClosesTheClient", + "TestACallThatSucceedsAfterItsContextEndsFailsWithIt", + "TestAnAbortedInitLeavesNoKeyInGuestMemory" + ] + } + }, + { + "id": "lifecycle-partial-output-v1", + "evidence": { + "heap": [ + "a_late_read_type_failure_leaves_no_plaintext_on_the_heap", + "opaque_blob::an_opaque_blob_of_another_shape_leaves_no_plaintext_on_the_heap" + ], + "contexts": ["shared_context_boundaries"], + "go": [ + "TestContextBoundaries", + "TestInterruptedResponseBodyIsTransport", + "TestACallThatSucceedsAfterItsContextEndsFailsWithIt" + ], + "go-sdk": ["TestDecryptErrorsHoldNoPlaintext"] + } + }, + { + "id": "lifecycle-buffer-cleanup-v1", + "evidence": { + "heap": [ + "a_record_encrypted_from_a_value_frame_leaves_no_plaintext_on_the_heap", + "a_record_decrypted_with_a_nested_field_leaves_no_plaintext_on_the_heap", + "a_json_document_read_from_a_frame_leaves_no_plaintext_on_the_heap", + "the_spy_sees_an_unwiped_free", + "the_spy_sees_a_leaked_block", + "opaque_blob::an_opaque_blob_encrypted_leaves_no_plaintext_on_the_heap", + "opaque_blob::an_opaque_blob_decrypted_leaves_no_plaintext_on_the_heap" + ], + "guest-abi": [ + "buffers::tests::wipe_all_leaves_no_live_buffer", + "last_error::tests::clearing_wipes_and_frees_the_registered_buffer", + "step::tests::pending_requests_are_handed_over_once_and_replaced_wiped", + "value::tests::text_is_wiped_on_drop" + ], + "go": [ + "TestRequestBodyWipesOnClose", + "TestRequestBodyOutlivesTheRoundTrip", + "TestARefusedRequestIsWiped", + "TestPlaintextDoesNotRemainInGuestMemory" + ], + "go-memory": [ + "TestHeapMemoryWipesWhatItAbandons", + "TestGuestMemoryIsFreedOnRuntimeClose", + "TestClientKeyIsOpaqueAndWipes", + "TestMemoryOutlivesACallClosedDuringAHostImport" + ] + } + }, + { + "id": "lifecycle-diagnostics-v1", + "evidence": { + "guest-abi": [ + "last_error::tests::a_foreign_cause_nobody_vouches_for_gives_no_message", + "call::tests::a_failure_never_reports_an_earlier_calls_error", + "call::tests::a_failure_then_a_success_leaves_nothing_to_hand_over" + ], + "go-sdk": [ + "TestDecryptErrorsHoldNoPlaintext", + "TestRedactedHidesSealedFieldsAndSortsShown", + "TestRedactedLogHidesSealedFields" + ], + "go-memory": [ + "TestAFailureCarriesItsDiagnostic", + "TestATrapFetchingTheDetailIsATrap" + ] + } + }, + { + "id": "lifecycle-validation-before-io-v1", + "evidence": { + "runtime": ["shared_record_runtime_profile"], + "reads": ["shared_record_read_profile"], + "pending": [ + "target::pending::tests::an_over_long_descriptor_is_refused_before_any_call", + "target::pending::tests::a_lazy_column_stops_building_after_a_local_failure" + ], + "go": [ + "TestRuntimeConformance", + "TestReadConformance", + "TestMismatchedPlanIsRefusedBeforeTheGuest" + ], + "opaque": [ + "an_encoding_refusal_wipes_the_source_and_requests_no_key", + "the_owned_source_is_wiped_when_context_validation_fails", + "the_context_is_refused_before_the_value_is_encoded", + "authentication_comes_before_decoding", + "malformed_and_mismatched_blobs_are_refused_by_class_after_authentication" + ] + } + }, + { + "id": "lifecycle-logical-batching-v1", + "evidence": { + "runtime": ["shared_record_runtime_profile"], + "pending": [ + "target::pending::tests::one_pending_asking_for_many_keys_is_one_call", + "target::pending::tests::all_merges_a_column_into_one_call_preserving_order", + "target::pending::tests::generate_and_retrieve_are_one_call_each", + "target::pending::tests::retrieves_group_by_keyset_and_return_in_request_order", + "target::pending::tests::all_of_nothing_resolves_empty_without_io", + "target::pending::tests::dispatch_forwards_each_requests_descriptor_in_order" + ] + } + }, + { + "id": "lifecycle-provider-chunks-v1", + "evidence": { + "kms": [ + "futures::tests::test_keeps_the_same_order", + "futures::tests::test_a_failing_chunk_fails_the_whole_call", + "futures::tests::test_works_when_chunks_dont_divide_nicely", + "futures::tests::concurrency_limit_bounds_chunks_and_drop_releases_them" + ] + } + } + ], + "limitations": [ + { + "id": "go-post-decrypt-shadow-stack", + "status": "known-gap", + "evidence": "languages/golang/encrypt/roundtrip_test.go::TestPlaintextDoesNotRemainInGuestMemoryAfterDecrypt" + }, + { + "id": "native-heap-observer-exclusions", + "status": "limited-observation", + "evidence": "packages/stack-encrypt/tests/heap_hygiene.rs" + }, + { + "id": "native-partial-decode-fields", + "status": "known-gap", + "evidence": "packages/stack-encrypt/tests/heap_hygiene.rs::opaque_blob::a_partial_decode_drops_the_fields_it_built_unwiped" + }, + { + "id": "caller-owned-host-values", + "status": "outside-guarantee" + }, + { + "id": "live-provider-http-chunking", + "status": "not-observed" + }, + { + "id": "python-typescript-lifecycle", + "status": "not-enrolled" + } + ] +} diff --git a/conformance/stack-encrypt/v1/query-refusals.json b/conformance/stack-encrypt/v1/query-refusals.json new file mode 100644 index 000000000..dee2ea770 --- /dev/null +++ b/conformance/stack-encrypt/v1/query-refusals.json @@ -0,0 +1,1648 @@ +{ + "version": 1, + "profile": "record-query-refusals-v1", + "required_capabilities": ["records", "query-errors", "term-errors"], + "fields": { + "email": { + "kind": "string", + "outputs": ["c", "eq"] + }, + "name": { + "kind": "string", + "outputs": ["c", "match"] + }, + "nickname": { + "kind": "string", + "outputs": ["c", "ore"] + }, + "title": { + "kind": "string", + "outputs": ["c", "ore_block"] + }, + "age": { + "kind": "uint32", + "outputs": ["c", "eq", "ore"] + }, + "score": { + "kind": "float64", + "outputs": ["c", "ore"] + }, + "big": { + "kind": "int128", + "outputs": ["c", "eq", "ope"] + }, + "flag": { + "kind": "bool", + "outputs": ["c"] + }, + "note": { + "kind": "string", + "outputs": ["c"] + }, + "sealed_age": { + "kind": "int64", + "outputs": ["c", "eq"], + "null": "sealed" + }, + "sql_age": { + "kind": "int64", + "outputs": ["c", "eq"], + "null": "sql" + }, + "data": { + "kind": "bytes", + "outputs": ["c", "eq", "ore"] + } + }, + "cases": [ + { + "id": "eq-on-match-only", + "field": "name", + "operator": "eq", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "name" + } + }, + { + "id": "matches-on-equality-only", + "field": "email", + "operator": "matches", + "input": { + "type": "string", + "text": "ali" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "email" + } + }, + { + "id": "eq-on-ciphertext-only", + "field": "note", + "operator": "eq", + "input": { + "type": "string", + "text": "x" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "note" + } + }, + { + "id": "eq-on-bool", + "field": "flag", + "operator": "eq", + "input": { + "type": "bool", + "text": "true" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "flag" + } + }, + { + "id": "lt-on-equality-only", + "field": "email", + "operator": "lt", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "email" + } + }, + { + "id": "matches-on-integer", + "field": "age", + "operator": "matches", + "input": { + "type": "string", + "text": "ali" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "age" + } + }, + { + "id": "eq-on-order-only", + "field": "score", + "operator": "eq", + "input": { + "type": "float64", + "text": "1.5" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "score" + } + }, + { + "id": "lte-on-match-only", + "field": "name", + "operator": "lte", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "name" + } + }, + { + "id": "gte-on-ciphertext-only", + "field": "note", + "operator": "gte", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "note" + } + }, + { + "id": "contains-on-integer", + "field": "age", + "operator": "contains", + "input": { + "type": "uint32", + "text": "1" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "age" + } + }, + { + "id": "lt-on-bytes-order-index", + "field": "data", + "operator": "lt", + "input": { + "type": "bytes", + "text": "00" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "data" + } + }, + { + "id": "lte-on-equality-only", + "field": "email", + "operator": "lte", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "email" + } + }, + { + "id": "gt-on-equality-only", + "field": "email", + "operator": "gt", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "email" + } + }, + { + "id": "gte-on-equality-only", + "field": "email", + "operator": "gte", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "email" + } + }, + { + "id": "between-on-equality-only", + "field": "email", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "string", + "text": "a" + }, + { + "type": "string", + "text": "b" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "email" + } + }, + { + "id": "lt-on-match-only", + "field": "name", + "operator": "lt", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "name" + } + }, + { + "id": "gt-on-match-only", + "field": "name", + "operator": "gt", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "name" + } + }, + { + "id": "gte-on-match-only", + "field": "name", + "operator": "gte", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "name" + } + }, + { + "id": "between-on-match-only", + "field": "name", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "string", + "text": "a" + }, + { + "type": "string", + "text": "b" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "name" + } + }, + { + "id": "eq-on-ore-only", + "field": "nickname", + "operator": "eq", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "nickname" + } + }, + { + "id": "matches-on-ore-only", + "field": "nickname", + "operator": "matches", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "nickname" + } + }, + { + "id": "eq-on-ore-block-only", + "field": "title", + "operator": "eq", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "title" + } + }, + { + "id": "matches-on-ore-block-only", + "field": "title", + "operator": "matches", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "title" + } + }, + { + "id": "matches-on-order-only", + "field": "score", + "operator": "matches", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "score" + } + }, + { + "id": "matches-on-ope", + "field": "big", + "operator": "matches", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "big" + } + }, + { + "id": "lt-on-bool", + "field": "flag", + "operator": "lt", + "input": { + "type": "bool", + "text": "true" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "flag" + } + }, + { + "id": "lte-on-bool", + "field": "flag", + "operator": "lte", + "input": { + "type": "bool", + "text": "true" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "flag" + } + }, + { + "id": "gt-on-bool", + "field": "flag", + "operator": "gt", + "input": { + "type": "bool", + "text": "true" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "flag" + } + }, + { + "id": "gte-on-bool", + "field": "flag", + "operator": "gte", + "input": { + "type": "bool", + "text": "true" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "flag" + } + }, + { + "id": "between-on-bool", + "field": "flag", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "bool", + "text": "false" + }, + { + "type": "bool", + "text": "true" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "flag" + } + }, + { + "id": "matches-on-bool", + "field": "flag", + "operator": "matches", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "flag" + } + }, + { + "id": "lt-on-ciphertext-only", + "field": "note", + "operator": "lt", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "note" + } + }, + { + "id": "lte-on-ciphertext-only", + "field": "note", + "operator": "lte", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "note" + } + }, + { + "id": "gt-on-ciphertext-only", + "field": "note", + "operator": "gt", + "input": { + "type": "string", + "text": "a" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "note" + } + }, + { + "id": "between-on-ciphertext-only", + "field": "note", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "string", + "text": "a" + }, + { + "type": "string", + "text": "b" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "note" + } + }, + { + "id": "matches-on-ciphertext-only", + "field": "note", + "operator": "matches", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "note" + } + }, + { + "id": "lt-on-equality-only-integer", + "field": "sealed_age", + "operator": "lt", + "input": { + "type": "int64", + "text": "1" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "sealed_age" + } + }, + { + "id": "lte-on-equality-only-integer", + "field": "sealed_age", + "operator": "lte", + "input": { + "type": "int64", + "text": "1" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "sealed_age" + } + }, + { + "id": "gt-on-equality-only-integer", + "field": "sealed_age", + "operator": "gt", + "input": { + "type": "int64", + "text": "1" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "sealed_age" + } + }, + { + "id": "gte-on-equality-only-integer", + "field": "sealed_age", + "operator": "gte", + "input": { + "type": "int64", + "text": "1" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "sealed_age" + } + }, + { + "id": "between-on-equality-only-integer", + "field": "sealed_age", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "int64", + "text": "1" + }, + { + "type": "int64", + "text": "2" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "sealed_age" + } + }, + { + "id": "matches-on-equality-only-integer", + "field": "sealed_age", + "operator": "matches", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "sealed_age" + } + }, + { + "id": "lte-on-bytes-order-index", + "field": "data", + "operator": "lte", + "input": { + "type": "bytes", + "text": "00" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "data" + } + }, + { + "id": "gt-on-bytes-order-index", + "field": "data", + "operator": "gt", + "input": { + "type": "bytes", + "text": "00" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "data" + } + }, + { + "id": "gte-on-bytes-order-index", + "field": "data", + "operator": "gte", + "input": { + "type": "bytes", + "text": "00" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "data" + } + }, + { + "id": "between-on-bytes-order-index", + "field": "data", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "bytes", + "text": "00" + }, + { + "type": "bytes", + "text": "01" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "data" + } + }, + { + "id": "matches-on-bytes-order-index", + "field": "data", + "operator": "matches", + "input": { + "type": "string", + "text": "alice" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "operator_not_answered", + "field": "data" + } + }, + { + "id": "between-scalar", + "field": "age", + "operator": "between", + "input": { + "type": "uint32", + "text": "40" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "not_a_range", + "field": "age" + } + }, + { + "id": "between-one-bound", + "field": "age", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "uint32", + "text": "1" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "not_a_range", + "field": "age" + } + }, + { + "id": "between-three-bounds", + "field": "age", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "uint32", + "text": "1" + }, + { + "type": "uint32", + "text": "2" + }, + { + "type": "uint32", + "text": "3" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "not_a_range", + "field": "age" + } + }, + { + "id": "between-text", + "field": "age", + "operator": "between", + "input": { + "type": "string", + "text": "1-2" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "not_a_range", + "field": "age" + } + }, + { + "id": "lt-text-for-integer", + "field": "age", + "operator": "lt", + "input": { + "type": "string", + "text": "40" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "age" + } + }, + { + "id": "lt-fraction-for-integer", + "field": "age", + "operator": "lt", + "input": { + "type": "float64", + "text": "40.5" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "age" + } + }, + { + "id": "between-mixed-bounds", + "field": "age", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "uint32", + "text": "1" + }, + { + "type": "string", + "text": "2" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "age" + } + }, + { + "id": "between-null-bound", + "field": "age", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "null", + "text": "" + }, + { + "type": "uint32", + "text": "1" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "age" + } + }, + { + "id": "eq-negative-for-unsigned", + "field": "age", + "operator": "eq", + "input": { + "type": "int32", + "text": "-1" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "age" + } + }, + { + "id": "eq-integer-for-text", + "field": "email", + "operator": "eq", + "input": { + "type": "int32", + "text": "1" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "email" + } + }, + { + "id": "matches-integer", + "field": "name", + "operator": "matches", + "input": { + "type": "int32", + "text": "1" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "name" + } + }, + { + "id": "eq-bool-for-integer", + "field": "big", + "operator": "eq", + "input": { + "type": "bool", + "text": "true" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "big" + } + }, + { + "id": "lt-fraction-for-int128", + "field": "big", + "operator": "lt", + "input": { + "type": "float64", + "text": "1.5" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "big" + } + }, + { + "id": "gte-text-for-int128", + "field": "big", + "operator": "gte", + "input": { + "type": "string", + "text": "1" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "big" + } + }, + { + "id": "eq-null-required", + "field": "email", + "operator": "eq", + "input": { + "type": "null", + "text": "" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "null_query", + "field": "email" + } + }, + { + "id": "eq-null-sql", + "field": "sql_age", + "operator": "eq", + "input": { + "type": "null", + "text": "" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "null_query", + "field": "sql_age" + } + }, + { + "id": "lt-null-ordered", + "field": "age", + "operator": "lt", + "input": { + "type": "null", + "text": "" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "null_query", + "field": "age" + } + }, + { + "id": "lte-null-ope", + "field": "big", + "operator": "lte", + "input": { + "type": "null", + "text": "" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "null_query", + "field": "big" + } + }, + { + "id": "no-such-field", + "field": "phone", + "operator": "eq", + "input": { + "type": "string", + "text": "1" + }, + "accepted": false, + "expected_error": { + "class": "plan", + "code": "stack_encrypt::dynamic_plan", + "reason": "no_such_field", + "field": "phone" + } + }, + { + "id": "unknown-operator", + "field": "age", + "operator": "like", + "input": { + "type": "uint32", + "text": "1" + }, + "accepted": false, + "expected_error": { + "class": "source", + "code": "stack_encrypt::dynamic_source", + "reason": "unknown_operator", + "field": "age" + } + }, + { + "id": "matches-too-short", + "field": "name", + "operator": "matches", + "input": { + "type": "string", + "text": "al" + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::empty_term_text" + }, + "typed": { + "refusal": "before_keyset" + } + }, + { + "id": "matches-empty", + "field": "name", + "operator": "matches", + "input": { + "type": "string", + "text": "" + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::empty_term_text" + }, + "typed": { + "refusal": "before_keyset" + } + }, + { + "id": "matches-separators", + "field": "name", + "operator": "matches", + "input": { + "type": "string", + "text": " " + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::empty_term_text" + }, + "typed": { + "refusal": "before_keyset" + } + }, + { + "id": "matches-unassigned", + "field": "name", + "operator": "matches", + "input": { + "type": "string", + "text": "ΝΈabc" + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::term_normalize" + }, + "typed": { + "refusal": "before_keyset" + } + }, + { + "id": "eq-unassigned", + "field": "email", + "operator": "eq", + "input": { + "type": "string", + "text": "aΝΈbc" + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::term_normalize" + }, + "typed": { + "refusal": "after_keyset" + } + }, + { + "id": "lt-unassigned", + "field": "nickname", + "operator": "lt", + "input": { + "type": "string", + "text": "aΝΈ" + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::term_normalize" + }, + "typed": { + "refusal": "after_keyset" + } + }, + { + "id": "between-unassigned-high", + "field": "nickname", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "string", + "text": "a" + }, + { + "type": "string", + "text": "bΝΈ" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::term_normalize" + }, + "typed": { + "refusal": "after_keyset" + } + }, + { + "id": "between-unassigned-low", + "field": "nickname", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "string", + "text": "aΝΈ" + }, + { + "type": "string", + "text": "b" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::term_normalize" + }, + "typed": { + "refusal": "after_keyset" + } + }, + { + "id": "ore-block-too-long", + "field": "title", + "operator": "lt", + "input": { + "type": "string", + "text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::ore_block_too_long" + }, + "typed": { + "refusal": "after_keyset" + } + }, + { + "id": "ore-block-trailing-nul", + "field": "title", + "operator": "gt", + "input": { + "type": "string", + "text": "a\u0000" + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::ore_block_trailing_nul" + }, + "typed": { + "refusal": "after_keyset" + } + }, + { + "id": "ore-block-between-nul-low", + "field": "title", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "string", + "text": "a\u0000" + }, + { + "type": "string", + "text": "b" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::ore_block_trailing_nul" + }, + "typed": { + "refusal": "after_keyset" + } + }, + { + "id": "ore-block-between-long-high", + "field": "title", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "string", + "text": "a" + }, + { + "type": "string", + "text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::ore_block_too_long" + }, + "typed": { + "refusal": "after_keyset" + } + }, + { + "id": "ore-block-between-long-low", + "field": "title", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "string", + "text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "type": "string", + "text": "b" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::ore_block_too_long" + }, + "typed": { + "refusal": "after_keyset" + } + }, + { + "id": "ore-block-between-nul-high", + "field": "title", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "string", + "text": "a" + }, + { + "type": "string", + "text": "b\u0000" + } + ] + }, + "accepted": false, + "expected_error": { + "class": "term", + "code": "stack_encrypt::ore_block_trailing_nul" + }, + "typed": { + "refusal": "after_keyset" + } + }, + { + "id": "inverted-range", + "field": "age", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "uint32", + "text": "65" + }, + { + "type": "uint32", + "text": "18" + } + ] + }, + "accepted": true, + "typed": { + "refusal": "none" + } + }, + { + "id": "inverted-range-int128", + "field": "big", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "int128", + "text": "170141183460469231731687303715884105727" + }, + { + "type": "int128", + "text": "-170141183460469231731687303715884105728" + } + ] + }, + "accepted": true, + "typed": { + "refusal": "none" + } + }, + { + "id": "inverted-range-float64", + "field": "score", + "operator": "between", + "input": { + "type": "list", + "items": [ + { + "type": "float64", + "text": "1.5" + }, + { + "type": "float64", + "text": "-1.5" + } + ] + }, + "accepted": true + }, + { + "id": "ore-block-longest", + "field": "title", + "operator": "lt", + "input": { + "type": "string", + "text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "accepted": true, + "typed": { + "refusal": "none" + } + }, + { + "id": "ore-block-interior-nul", + "field": "title", + "operator": "lt", + "input": { + "type": "string", + "text": "a\u0000b" + }, + "accepted": true, + "typed": { + "refusal": "none" + } + }, + { + "id": "ore-block-empty", + "field": "title", + "operator": "lt", + "input": { + "type": "string", + "text": "" + }, + "accepted": true, + "typed": { + "refusal": "none" + } + }, + { + "id": "eq-null-sealed", + "field": "sealed_age", + "operator": "eq", + "input": { + "type": "null", + "text": "" + }, + "accepted": true + }, + { + "id": "matches-folded", + "field": "name", + "operator": "matches", + "input": { + "type": "string", + "text": "ALICE" + }, + "accepted": true, + "typed": { + "refusal": "none" + } + }, + { + "id": "eq-widened-int", + "field": "big", + "operator": "eq", + "input": { + "type": "int64", + "text": "5" + }, + "accepted": true + } + ] +} diff --git a/conformance/stack-encrypt/v1/raw-frames.json b/conformance/stack-encrypt/v1/raw-frames.json new file mode 100644 index 000000000..e98211d45 --- /dev/null +++ b/conformance/stack-encrypt/v1/raw-frames.json @@ -0,0 +1,204 @@ +{ + "version": 1, + "profile": "record-raw-frames-v1", + "seed": "0000000000000000000000000000000000000000000000000000000000000000", + "required_capabilities": [ + "records", + "raw-frames", + "read-shapes", + "authenticated-types" + ], + "boundary_error": { + "code": "stack_guest_abi::malformed_input", + "fields": {} + }, + "cases": [ + { + "id": "success-frozen-leaf", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "030100000004010000000500000076616c75650402000000010000006301650000000100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "success", + "retrievals": 1, + "rows": [ + { + "value": { + "type": "int32", + "text": "2147483647" + } + } + ] + }, + { + "id": "authenticated-int64", + "read": { + "kind": "int64", + "null": "" + }, + "frame_hex": "030100000004010000000500000076616c75650402000000010000006301650000000100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "authenticated", + "retrievals": 1, + "expected": { + "code": "stack_encrypt::plan_field_type", + "fields": { + "expected": "int64", + "field": "value" + } + } + }, + { + "id": "authenticated-string", + "read": { + "kind": "string", + "null": "" + }, + "frame_hex": "030100000004010000000500000076616c75650402000000010000006301650000000100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "authenticated", + "retrievals": 1, + "expected": { + "code": "stack_encrypt::plan_field_type", + "fields": { + "expected": "string", + "field": "value" + } + } + }, + { + "id": "shape-c-passthrough", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "030100000004010000000500000076616c756504020000000100000063050422000000020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "shape", + "retrievals": 0, + "expected": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "passthrough" + } + } + }, + { + "id": "authenticated-c-empty-sequence", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "030100000004010000000500000076616c75650402000000010000006306650000000100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "authenticated", + "retrievals": 1, + "expected": { + "code": "stack_encrypt::aead", + "fields": {} + } + }, + { + "id": "leaf-unknown-version", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "030100000004010000000500000076616c75650402000000010000006301650000000900000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "leaf", + "retrievals": 0, + "expected": { + "code": "stack_encrypt::leaf_version", + "fields": { + "version": 9 + } + } + }, + { + "id": "leaf-truncated", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "030100000004010000000500000076616c75650402000000010000006301020000000100020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "leaf", + "retrievals": 0, + "expected": { + "code": "stack_encrypt::leaf_truncated", + "fields": {} + } + }, + { + "id": "transport-repeated-field", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "030100000004020000000500000076616c75650402000000010000006301650000000100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c0500000076616c75650402000000010000006301650000000100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "transport", + "retrievals": 0, + "native_tree": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "field_repeated" + } + } + }, + { + "id": "transport-repeated-output", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "030100000004010000000500000076616c75650403000000010000006301650000000100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923010000006301650000000100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "transport", + "retrievals": 0, + "native_tree": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "repeated_key" + } + } + }, + { + "id": "transport-retired-none-tag", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "030100000004010000000500000076616c7565040200000001000000630200000000020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "transport", + "retrievals": 0 + }, + { + "id": "transport-trailing-byte", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "030100000004010000000500000076616c75650402000000010000006301650000000100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c00", + "class": "transport", + "retrievals": 0 + }, + { + "id": "transport-truncated-count", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "030200000004010000000500000076616c75650402000000010000006301650000000100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923020000006571050b20000000d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c", + "class": "transport", + "retrievals": 0 + }, + { + "id": "transport-not-a-tree", + "read": { + "kind": "int32", + "null": "" + }, + "frame_hex": "ff", + "class": "transport", + "retrievals": 0 + } + ] +} diff --git a/conformance/stack-encrypt/v1/values.json b/conformance/stack-encrypt/v1/values.json new file mode 100644 index 000000000..a4d5455bb --- /dev/null +++ b/conformance/stack-encrypt/v1/values.json @@ -0,0 +1,973 @@ +{ + "version": 1, + "profile": "record-values-v1", + "required_capabilities": [ + "records", + "equality", + "value-kinds", + "null-policies" + ], + "cases": [ + { + "id": "bool-true", + "kind": "bool", + "null": "", + "outputs": ["c"], + "input": { + "type": "bool", + "text": "true" + }, + "frame": "03" + }, + { + "id": "bool-false", + "kind": "bool", + "null": "", + "outputs": ["c"], + "input": { + "type": "bool", + "text": "false" + }, + "frame": "02" + }, + { + "id": "i8-min", + "kind": "int8", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "int8", + "text": "-128" + }, + "frame": "0c80" + }, + { + "id": "i8-max", + "kind": "int8", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "int8", + "text": "127" + }, + "frame": "0c7f" + }, + { + "id": "u8-zero", + "kind": "uint8", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "uint8", + "text": "0" + }, + "frame": "0d00" + }, + { + "id": "u8-max", + "kind": "uint8", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "uint8", + "text": "255" + }, + "frame": "0dff" + }, + { + "id": "i16-min", + "kind": "int16", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "int16", + "text": "-32768" + }, + "frame": "0e0080" + }, + { + "id": "i16-max", + "kind": "int16", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "int16", + "text": "32767" + }, + "frame": "0eff7f" + }, + { + "id": "u16-zero", + "kind": "uint16", + "null": "", + "outputs": ["c", "eq", "ope"], + "input": { + "type": "uint16", + "text": "0" + }, + "frame": "0f0000" + }, + { + "id": "u16-max", + "kind": "uint16", + "null": "", + "outputs": ["c", "eq", "ope"], + "input": { + "type": "uint16", + "text": "65535" + }, + "frame": "0fffff" + }, + { + "id": "u32-zero", + "kind": "uint32", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "uint32", + "text": "0" + }, + "frame": "0600000000" + }, + { + "id": "u32-max", + "kind": "uint32", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "uint32", + "text": "4294967295" + }, + "frame": "06ffffffff" + }, + { + "id": "i128-max", + "kind": "int128", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "int128", + "text": "170141183460469231731687303715884105727" + }, + "frame": "10ffffffffffffffffffffffffffffff7f" + }, + { + "id": "i128-min", + "kind": "int128", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "int128", + "text": "-170141183460469231731687303715884105728" + }, + "frame": "1000000000000000000000000000000080" + }, + { + "id": "i128-below-i64", + "kind": "int128", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "int128", + "text": "-9223372036854775809" + }, + "frame": "10ffffffffffffff7fffffffffffffffff" + }, + { + "id": "i128-above-i64", + "kind": "int128", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "int128", + "text": "9223372036854775808" + }, + "frame": "1000000000000000800000000000000000" + }, + { + "id": "u128-zero", + "kind": "uint128", + "null": "", + "outputs": ["c", "eq", "ope"], + "input": { + "type": "uint128", + "text": "0" + }, + "frame": "1100000000000000000000000000000000" + }, + { + "id": "u128-above-u64", + "kind": "uint128", + "null": "", + "outputs": ["c", "eq", "ope"], + "input": { + "type": "uint128", + "text": "18446744073709551616" + }, + "frame": "1100000000000000000100000000000000" + }, + { + "id": "u128-max", + "kind": "uint128", + "null": "", + "outputs": ["c", "eq", "ope"], + "input": { + "type": "uint128", + "text": "340282366920938463463374607431768211455" + }, + "frame": "11ffffffffffffffffffffffffffffffff" + }, + { + "id": "f32-max", + "kind": "float32", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "float32", + "text": "3.4028235e38" + }, + "frame": "08ffff7f7f" + }, + { + "id": "f32-min-positive", + "kind": "float32", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "float32", + "text": "1e-45" + }, + "frame": "0801000000" + }, + { + "id": "f32-negative-zero", + "kind": "float32", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "float32", + "text": "-0" + }, + "frame": "0800000080" + }, + { + "id": "f32-inf", + "kind": "float32", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "float32", + "text": "inf" + }, + "frame": "080000807f" + }, + { + "id": "f32-neg-inf", + "kind": "float32", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "float32", + "text": "-inf" + }, + "frame": "08000080ff" + }, + { + "id": "f64-max", + "kind": "float64", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "float64", + "text": "1.7976931348623157e308" + }, + "frame": "09ffffffffffffef7f" + }, + { + "id": "f64-min-positive", + "kind": "float64", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "float64", + "text": "5e-324" + }, + "frame": "090100000000000000" + }, + { + "id": "f64-negative-zero", + "kind": "float64", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "float64", + "text": "-0" + }, + "frame": "090000000000000080" + }, + { + "id": "f64-inf", + "kind": "float64", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "float64", + "text": "inf" + }, + "frame": "09000000000000f07f" + }, + { + "id": "f64-neg-inf", + "kind": "float64", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "float64", + "text": "-inf" + }, + "frame": "09000000000000f0ff" + }, + { + "id": "bytes-empty", + "kind": "bytes", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "bytes", + "text": "" + }, + "frame": "0b00000000" + }, + { + "id": "bytes-binary", + "kind": "bytes", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "bytes", + "text": "00ff10" + }, + "frame": "0b0300000000ff10", + "unanswered": ["ore"] + }, + { + "id": "string-nul", + "kind": "string", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "string", + "text": "a\u0000b" + }, + "frame": "0a03000000610062" + }, + { + "id": "decimal-scale-0", + "kind": "decimal", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "decimal", + "text": "42" + }, + "frame": "14000000002a0000000000000000000000" + }, + { + "id": "decimal-scale-28", + "kind": "decimal", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "decimal", + "text": "0.0000000000000000000000000001" + }, + "frame": "1400001c00010000000000000000000000" + }, + { + "id": "decimal-trailing-zeros", + "kind": "decimal", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "decimal", + "text": "-1234.5600" + }, + "frame": "14000004800061bc000000000000000000", + "equal_query": { + "type": "decimal", + "text": "-1234.56" + } + }, + { + "id": "decimal-max-mantissa", + "kind": "decimal", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "decimal", + "text": "79228162514264337593543950335" + }, + "frame": "1400000000ffffffffffffffffffffffff" + }, + { + "id": "date-epoch", + "kind": "date", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "date", + "text": "1970-01-01" + }, + "frame": "123bf90a00" + }, + { + "id": "date-before-epoch", + "kind": "date", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "date", + "text": "1969-12-31" + }, + "frame": "123af90a00" + }, + { + "id": "date-year-one", + "kind": "date", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "date", + "text": "0001-01-01" + }, + "frame": "1201000000" + }, + { + "id": "date-year-9999", + "kind": "date", + "null": "", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "date", + "text": "9999-12-31" + }, + "frame": "12dbb93700" + }, + { + "id": "timestamp-epoch", + "kind": "timestamp", + "null": "", + "outputs": ["c", "eq", "ope"], + "input": { + "type": "timestamp", + "text": "1970-01-01T00:00:00Z" + }, + "frame": "13000000000000000000000000" + }, + { + "id": "timestamp-nanosecond", + "kind": "timestamp", + "null": "", + "outputs": ["c", "eq", "ope"], + "input": { + "type": "timestamp", + "text": "2026-10-17T01:02:03.000000001Z" + }, + "frame": "130bc9d26a0000000001000000" + }, + { + "id": "timestamp-before-epoch", + "kind": "timestamp", + "null": "", + "outputs": ["c", "eq", "ope"], + "input": { + "type": "timestamp", + "text": "1969-12-31T23:59:59.999999999Z" + }, + "frame": "13ffffffffffffffffffc99a3b" + }, + { + "id": "timestamp-microsecond-query", + "kind": "timestamp", + "null": "", + "outputs": ["c", "eq", "ope"], + "input": { + "type": "timestamp", + "text": "2026-10-17T01:02:03.123456789Z" + }, + "frame": "130bc9d26a0000000015cd5b07", + "equal_query": { + "type": "timestamp", + "text": "2026-10-17T01:02:03.123456Z" + } + }, + { + "id": "timestamp-year-9999", + "kind": "timestamp", + "null": "", + "outputs": ["c", "eq", "ope"], + "input": { + "type": "timestamp", + "text": "9999-12-31T23:59:59.999999999Z" + }, + "frame": "137f41f4ff3a000000ffc99a3b" + }, + { + "id": "sealed-bool", + "kind": "bool", + "null": "sealed", + "outputs": ["c"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "bool", + "text": "true" + } + }, + { + "id": "sealed-int8", + "kind": "int8", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "int8", + "text": "-1" + } + }, + { + "id": "sealed-int16", + "kind": "int16", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "int16", + "text": "-32768" + } + }, + { + "id": "sealed-uint8", + "kind": "uint8", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "uint8", + "text": "255" + } + }, + { + "id": "sealed-uint16", + "kind": "uint16", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "uint16", + "text": "65535" + } + }, + { + "id": "sealed-uint32", + "kind": "uint32", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "uint32", + "text": "4294967295" + } + }, + { + "id": "sealed-int128", + "kind": "int128", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "int128", + "text": "-170141183460469231731687303715884105728" + } + }, + { + "id": "sealed-uint128", + "kind": "uint128", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "uint128", + "text": "340282366920938463463374607431768211455" + } + }, + { + "id": "sealed-float32", + "kind": "float32", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "float32", + "text": "1.5" + } + }, + { + "id": "sealed-date", + "kind": "date", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "date", + "text": "2024-02-29" + } + }, + { + "id": "sealed-timestamp", + "kind": "timestamp", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "timestamp", + "text": "2026-10-17T01:02:03.123456789Z" + } + }, + { + "id": "sealed-decimal", + "kind": "decimal", + "null": "sealed", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "companion": { + "type": "decimal", + "text": "1.50" + } + }, + { + "id": "sql-bool", + "kind": "bool", + "null": "sql", + "outputs": ["c"], + "input": { + "type": "null", + "text": "" + } + }, + { + "id": "sql-bytes", + "kind": "bytes", + "null": "sql", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + } + }, + { + "id": "sql-string", + "kind": "string", + "null": "sql", + "outputs": ["c", "eq", "ore"], + "input": { + "type": "null", + "text": "" + } + }, + { + "id": "int8-given-int16", + "kind": "int8", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "int16", + "text": "1" + }, + "frame": "0e0100", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "uint8-given-int8", + "kind": "uint8", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "int8", + "text": "1" + }, + "frame": "0c01", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "int128-given-int64", + "kind": "int128", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "int64", + "text": "1" + }, + "frame": "050100000000000000", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "uint128-given-uint64", + "kind": "uint128", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "uint64", + "text": "1" + }, + "frame": "070100000000000000", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "float32-given-float64", + "kind": "float32", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "float64", + "text": "1.5" + }, + "frame": "09000000000000f83f", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "decimal-given-float64", + "kind": "decimal", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "float64", + "text": "1.5" + }, + "frame": "09000000000000f83f", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "date-given-int32", + "kind": "date", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "int32", + "text": "0" + }, + "frame": "0400000000", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "timestamp-given-int64", + "kind": "timestamp", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "int64", + "text": "0" + }, + "frame": "050000000000000000", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "bytes-given-string", + "kind": "bytes", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "string", + "text": "" + }, + "frame": "0a00000000", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "string-given-bytes", + "kind": "string", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "bytes", + "text": "00" + }, + "frame": "0b0100000000", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "bool-given-int32", + "kind": "bool", + "null": "", + "outputs": ["c"], + "input": { + "type": "int32", + "text": "1" + }, + "frame": "0401000000", + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "required-bool-null", + "kind": "bool", + "null": "", + "outputs": ["c"], + "input": { + "type": "null", + "text": "" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "required-bytes-null", + "kind": "bytes", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "required-decimal-null", + "kind": "decimal", + "null": "", + "outputs": ["c", "eq"], + "input": { + "type": "null", + "text": "" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + } + ], + "host_conversions": [ + { + "id": "go-zero-date", + "language": "go", + "kind": "date", + "value": "zero_date" + }, + { + "id": "go-decimal-scale-29", + "language": "go", + "kind": "decimal", + "value": "decimal_scale_29" + }, + { + "id": "go-timestamp-year-300000", + "language": "go", + "kind": "timestamp", + "value": "timestamp_year_300000" + }, + { + "id": "go-complex128", + "language": "go", + "kind": "float64", + "value": "complex128" + }, + { + "id": "go-map-int-key", + "language": "go", + "kind": "string", + "value": "map_int_key" + }, + { + "id": "go-channel", + "language": "go", + "kind": "string", + "value": "channel" + } + ] +} diff --git a/conformance/stack-encrypt/values-contract.md b/conformance/stack-encrypt/values-contract.md new file mode 100644 index 000000000..2889bbc2d --- /dev/null +++ b/conformance/stack-encrypt/values-contract.md @@ -0,0 +1,144 @@ +# Record value-kind profile + +`record-values-v1` describes the cases in `v1/values.json`: every kind the +engine names, at its boundaries, through the record path with no EQL +resolver. It is an experimental test contract for native Rust and Go WASI. +Python and TypeScript runners remain pending. + +The earlier scalar, runtime and fixture profiles are representative. This +profile is the boundary matrix they left open: the narrow and 128-bit +integer edges (the 32- and 64-bit edges are the scalar and runtime +profiles'), 32-bit floats, the float specials, bytes, decimal scale and +mantissa limits, date and timestamp precision, sealed nulls for every +fixed-width kind the runtime profile does not already seal (it has +`int32`, `int64`, `uint64` and `float64`), SQL nulls, and the write +refusals between kinds. + +The runners are `packages/stack-encrypt/tests/value_conformance.rs` and +`languages/golang/encrypt/value_conformance_test.go`. + +## Cases + +Each case declares one field, `value`, under context `["conformance", "value"]`, +with `kind`, a `null` policy (empty, `sql` or `sealed`) and an `outputs` list +that starts with `c`. Values use the scalar profile's `{type, text}` form, +extended to every kind: + +| `type` | `text` | +|---|---| +| `bool` | `true` or `false` | +| `int8` … `int128`, `uint8` … `uint128` | canonical base-10 integer at exactly that width | +| `float32`, `float64` | the canonical spelling of a representable value (below), or `inf`, `-inf`; `-0` is negative zero | +| `string` | the literal text, control characters included | +| `bytes` | the bytes in lowercase hex, possibly empty | +| `date` | `YYYY-MM-DD`, proleptic Gregorian | +| `timestamp` | RFC 3339 in UTC, `YYYY-MM-DDTHH:MM:SS[.fraction]Z`, at most nine fraction digits | +| `decimal` | fixed-point text keeping every trailing zero; the scale is the number of fraction digits | +| `null` | empty | + +A float's canonical spelling is built from the shortest decimal digits +that parse back to the value at its width. Spelled positionally (Rust's +`{}`, Go's `strconv.FormatFloat(f, 'f', -1, bits)`: `34`, `-0`, `0.5`, +`9007199254740992`) and scientifically (Rust's `{:e}`, Go's +`strconv.FormatFloat(f, 'e', -1, bits)` with the exponent's `+` and leading +zeros removed: `1e3`, `3.4028235e38`, `5e-324`), the shorter of the two is +canonical, and the positional one on a tie (`100`, not `1e2`). Both +runners refuse any other text that parses to the value as a corpus error: +`1.00`, `1.0`, `1e0`, `1000`, `1e+3`, `1E3`, `+1`, `.5`, `-0.0`, `0.001`, +`Inf`, a rounded decimal. Rust's +`the_float_reader_accepts_only_the_canonical_spelling` and Go's +`TestConformanceFloatSpelling` hold both readers to the same accepted and +refused lists. + +Runners construct real typed host values: Rust reads each integer at its +width and each logical type over its physical scalar; Go uses its own +integer types, `float32`, `[]byte`, and the SDK's `Int128`, `Uint128`, +`Date`, `Decimal` and `time.Time`. No value passes through a JSON number or +a floating-point conversion. A text the type cannot carry is a corpus +error in both runners, never a rounded value. `NaN` is excluded: hosts do +not agree on its equality, so a corpus row could not be compared. + +Every non-null input carries `frame`, its frozen typed transport frame in +hex. Both runners encode the host value and compare it byte for byte, +which pins the width, the logical type tag, the decimal scale and the +timestamp's nanoseconds. The Rust helper that parses the text forms is +held to the frames the value-kinds fixture froze for the same spellings. + +## Required behavior + +For a case with no `expected_error` whose input is not null: + +1. The typed frame equals the frozen `frame`. +2. One record of the input seals, storing ciphertext and exactly the + declared indexes, with one batched data-key generation. +3. Every stored term is the term a query of the same value derives: `eq` + for the equality index, `lt` for an order index. A query makes no key + call. With `equal_query`, that other spelling derives the stored + equality term too (a decimal without its trailing zeros; a timestamp + truncated to the microsecond). +4. The record opens to the input with its type, with one batched retrieval. + +The key-call counts (one generation, no call per query, one retrieval) and +the refusals' "no key call" are native observations: the Rust runner counts +them on its key source. The Go runner checks everything else; its +deterministic guest has no call counter to read. + +A null input carries no `frame`, so requirement 1 does not apply to it. +A sealed null (`"null": "sealed"`, input `null`) meets requirements 2 and +3: it stores ciphertext and the declared indexes with one batched +generation, and a null equality query derives the stored equality term +where one is declared. It opens as null with one batched retrieval, and its `companion` value row's ciphertext has the same +length. A SQL null (`"null": "sql"`, input `null`) meets none of +requirements 1 to 4: it stores no record, so no data key is generated, no +term exists to compare and no ciphertext is opened; its row reads back as +null. + +`unanswered` lists stored indexes no query operator answers for the kind. A +query of such an index is refused as `operator_not_answered` in both +runners. Today that is the order index on `bytes`: the declaration admits +it and the engine stores an order term, but no dynamic operator reads it. +Recording it here keeps the gap visible instead of silently skipping it. + +`expected_error` cases are refused before any pending operation, with the +structured diagnostic `code`, `reason` and `field`, and no key call. They +cover a value of another width or kind for every new kind (an `int16` for +an `int8` field, an `int64` for an `int128` field, a `float64` for a +`decimal` or `float32` field, an `int32` for a `date`, text for `bytes`, +bytes for `string`, an integer for `bool`) and a null for a required +`bool`, `bytes` or `decimal`. A losslessly convertible value of another +width is still refused on the write path, as the runtime profile says. + +## Unsupported host conversions + +`host_conversions` lists values a binding's host refuses before the engine +sees them. Each names its `language`; today every entry is `go`. The +`value` is one of a closed vocabulary both runners check: + +| `value` | The Go value | Why the host refuses it | +|---|---|---| +| `zero_date` | `encrypt.Date{}` | The zero `Date` is not a date | +| `decimal_scale_29` | `encrypt.Decimal{Scale: 29}` | A decimal's scale is at most 28 | +| `timestamp_year_300000` | `time.Date(300000, …)` | Outside the timestamp decoder's range | +| `complex128` | `complex(1, 2)` | No transport leaf carries a complex number | +| `map_int_key` | `map[int]string` | Object keys are text | +| `channel` | `chan int` | Not a value | + +Go refuses each through `Seal` and `QueryTerm` with `ErrEncoding` and no +engine diagnostic, which shows the guest was never asked. Native Rust has +no host conversion layer: the typed API cannot spell these values, and the +dynamic API takes frames. Its runner checks the entries' shape and +vocabulary and runs none of them. Python and TypeScript will add their own +rows (bool/int, bytes/text, Number/bigint) when their runners exist. + +## Scope and remaining work + +The profile covers the record path and equality/order terms under +`NoTargets`. Match terms, EQL targets, JSON documents, context fields and +fresh cross-language artifact exchange of these kinds remain the other +profiles' coverage. Dates before year 1 and the ends of the timestamp range +are not in the corpus: Go's date parsing does not read them, and the +engine's bound is the decoder's. Negative-zero decimals are not in the +corpus because Go's `Decimal` drops the sign of a zero before sealing. + +No existing fixture or test is changed. The value-kinds fixture keeps its +typed Rust comparisons and generated Go methods. diff --git a/docs/plans/2026-10-31-stack-encrypt-conformance.md b/docs/plans/2026-10-31-stack-encrypt-conformance.md index 4f55a7031..1474a8807 100644 --- a/docs/plans/2026-10-31-stack-encrypt-conformance.md +++ b/docs/plans/2026-10-31-stack-encrypt-conformance.md @@ -1,6 +1,6 @@ # Stack Encrypt cross-language conformance -Date: 2026-10-31. Status: scalar, declaration, initial runtime, read and context-composition slices implemented; existing value-kind, query-operator and context-field fixtures enrolled. The complete contract is not frozen. +Date: 2026-10-31. Status: scalar, declaration, initial runtime, read, context, EQL/JSON and lifecycle slices implemented; existing value-kind, query-operator and context-field fixtures enrolled. Wider boundaries, artifact exchange, Python/TypeScript enrollment and release gates remain. The complete contract is not frozen. This work is stacked on [PR #1404](https://github.com/cipherstash/stack/pull/1404), at `b684a217e2da629c762efa70fac958ec7e21646a`. It follows the accepted @@ -224,3 +224,77 @@ binding/search-scope split. Next: the EQL/JSON operation profile. Python and TypeScript runner delivery and the wider fresh artifact exchange remain separate planned work. + +## EQL/JSON operation checkpoint + +Implemented the EQL/JSON requirements in +`conformance/stack-encrypt/eql-json-contract.md`, enrolling the existing engine, +EQL binding, Go SDK and independent PostgreSQL suites. A shared corpus replaces +duplicate write/refusal tables; existing typed, generated, property and +adversarial assertions remain. Required fast and PostgreSQL commands check +compiled native inventory and exact Go pass events, refuse skipped evidence, +and report engine, lockfile, guest and installed SQL identities in CI artifacts. + +Next: backend lifecycle, cancellation, no-secret diagnostics, provider key-I/O +and batching requirements. Python/TypeScript enrollment and the broader fresh +artifact/version exchange remain separate delivery slices. The EQL/JSON profile +does not complete the refactoring track or introduce the future binding/search +scope split. + +## Lifecycle and key-I/O checkpoint + +Implemented `lifecycle-v1` with eight named requirements and required native/Go +CI evidence. Reuses the pending, KMS chunking, heap, ABI, cancellation, memory, +diagnostic and shared runtime/read suites. New observations cover unpolled and +in-flight operation drops, provider failure, cleanup of decoded prefixes on +late read errors, and bounded chunk concurrency/cancellation. The runner shares +inventory and no-skip checks with EQL conformance. + +Reports distinguish passing requirements from known residue, observer exclusions, +caller-owned values, unobserved live-provider behavior and unenrolled languages. +This does not complete secure-buffer/key-copy refactoring. Next conformance +slices remain value/refusal/raw-frame gaps, broader fresh artifact exchange, +Python/TypeScript enrollment and explicit release gates/version combinations. + +## Value, refusal and raw-frame checkpoint + +Implemented three shared corpora under `conformance/stack-encrypt/v1`, each +with a Rust and a Go runner required by `test:conformance`: +`record-values-v1` ([value contract](../../conformance/stack-encrypt/values-contract.md)), +every kind at its boundaries with frozen typed frames, sealed and SQL nulls +for every fixed-width kind, cross-kind write refusals and the Go host +conversions the host refuses before the engine; +`record-query-refusals-v1` ([query-refusal contract](../../conformance/stack-encrypt/query-refusals-contract.md)), +every scalar operator against every capability and operand shape, by refusal +class and diagnostic, with no key I/O, and the stage at which the typed Rust +chain refuses the same query; and `record-raw-frames-v1` +([raw-frame contract](../../conformance/stack-encrypt/raw-frames-contract.md)), +stored batches as transport bytes so repeated keys and malformed leaves reach +both decoders, with the table of error classes. + +The corpora pin three differences rather than fixing them: the order index +on `bytes` stores a term no operator reads; the typed chain refuses +equality, order and block ORE value refusals after its keyset loads where the +binding refuses before any (the query-preparation consolidation is tracked +separately and changes those entries on purpose); and a repeated key in a +stored record is a transport refusal in both codecs, so the engine's own +repeated-key guards are reached natively alone. No frozen fixture, artifact +or existing test changed. Fresh exchange of the wider kinds, Python and +TypeScript runners and release gates remain open. + +## Consolidation checkpoint + +The first duplicate case tables came out, now that the shared corpora hold +their expectations and both runners execute them. Fifteen Rust unit-test +tables in `src/dynamic/record.rs` and `src/dynamic/query.rs` (ten removed +whole, five trimmed to the rows the corpora cannot spell) and two Go tests +(one removed, one trimmed) were replaced by declaration, runtime, read and +query-refusal cases; five cases were enrolled so every old row had an exact +home. Each removal is recorded in the +[coverage map](../../conformance/stack-encrypt/coverage.md#consolidations) +with its case IDs, its runner and the hand-introduced engine fault the +runner failed on before the old rows went. Compile-fail, ownership and +zeroization, batching, property, frozen-bytes, generated-code, host +conversion, error wrapping, cancellation, guest lifecycle, PostgreSQL and +live-provider tests are untouched, and the mixed-purpose tests whose unique +shapes the corpora lack are kept and listed there with the reason. diff --git a/languages/golang/auth/guest/Cargo.lock b/languages/golang/auth/guest/Cargo.lock index 95eb9372e..75886f8a2 100644 --- a/languages/golang/auth/guest/Cargo.lock +++ b/languages/golang/auth/guest/Cargo.lock @@ -1807,8 +1807,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -1828,7 +1828,7 @@ dependencies = [ "tempfile", "tokio", "url", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -1839,9 +1839,9 @@ dependencies = [ "miette", "serde_json", "thiserror 1.0.69", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2260,16 +2260,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -2289,18 +2289,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.20", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2317,9 +2317,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -2328,11 +2328,11 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "3a65349bb75a9ecc9fd4f057e5a6aac964bea68e57d03d55118a22455002a424" dependencies = [ - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", ] [[package]] @@ -2347,12 +2347,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2371,15 +2371,15 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2402,9 +2402,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -2413,7 +2413,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -2430,9 +2430,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2457,16 +2457,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.3", "rand 0.10.3", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -2483,9 +2483,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -2510,17 +2510,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] diff --git a/languages/golang/auth/guest/Cargo.toml b/languages/golang/auth/guest/Cargo.toml index e3fd3cca4..4b654fd89 100644 --- a/languages/golang/auth/guest/Cargo.toml +++ b/languages/golang/auth/guest/Cargo.toml @@ -50,7 +50,7 @@ tempfile = "3" # Native stack-auth refreshes run in a Tokio task, including with a mock transport. tokio = { version = "1.47.1", default-features = false, features = ["macros", "rt"] } # `Controlled::risky_ref`, to read a codec value back in the tests. -vitaminc-protected = "0.8.0" +vitaminc-protected = "0.8.1" [profile.release] # Smaller .wasm; the guest is IO-bound on the FFI copy and the file reads, diff --git a/languages/golang/encrypt/conformance_test.go b/languages/golang/encrypt/conformance_test.go index 88039d9eb..fdbae356d 100644 --- a/languages/golang/encrypt/conformance_test.go +++ b/languages/golang/encrypt/conformance_test.go @@ -6,12 +6,16 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "io" + "math" "os" "path/filepath" "reflect" "strconv" + "strings" "testing" + "time" "github.com/cipherstash/stack/languages/golang/encrypt" "github.com/cipherstash/stack/languages/golang/internal/record" @@ -77,34 +81,90 @@ type conformanceInput struct { Text string `json:"text"` } +// conformanceValue is the typed Go value an input names: every integer at +// its own width, the SDK's Int128, Uint128, Date and Decimal, a time.Time +// for a timestamp, and bytes from hex. A text the type cannot carry fails +// the test; nothing is rounded. func conformanceValue(t *testing.T, input conformanceInput) any { t.Helper() - switch input.Type { - case "int32", "int64": - bits := 64 - if input.Type == "int32" { - bits = 32 - } + signed := func(bits int) int64 { n, err := strconv.ParseInt(input.Text, 10, bits) if err != nil { t.Fatal(err) } - if bits == 32 { - return int32(n) + return n + } + unsigned := func(bits int) uint64 { + n, err := strconv.ParseUint(input.Text, 10, bits) + if err != nil { + t.Fatal(err) + } + return n + } + float := func(bits int) float64 { + n, err := conformanceFloat(input.Text, bits) + if err != nil { + t.Fatal(err) + } + return n + } + switch input.Type { + case "int8": + return int8(signed(8)) + case "int16": + return int16(signed(16)) + case "int32": + return int32(signed(32)) + case "int64": + return signed(64) + case "int128": + n, err := encrypt.ParseInt128(input.Text) + if err != nil { + t.Fatal(err) } return n + case "uint8": + return uint8(unsigned(8)) + case "uint16": + return uint16(unsigned(16)) + case "uint32": + return uint32(unsigned(32)) case "uint64": - n, err := strconv.ParseUint(input.Text, 10, 64) + return unsigned(64) + case "uint128": + n, err := encrypt.ParseUint128(input.Text) if err != nil { t.Fatal(err) } return n + case "float32": + return float32(float(32)) case "float64": - n, err := strconv.ParseFloat(input.Text, 64) + return float(64) + case "bytes": + b, err := hex.DecodeString(input.Text) if err != nil { t.Fatal(err) } - return n + return b + case "date": + d, err := time.Parse(time.DateOnly, input.Text) + if err != nil { + t.Fatal(err) + } + return encrypt.DateOf(d) + case "timestamp": + at, err := time.Parse(time.RFC3339Nano, input.Text) + if err != nil { + t.Fatal(err) + } + return at + case "decimal": + d, err := encrypt.ParseDecimal(input.Text) + if err != nil { + t.Fatal(err) + } + return d case "bool": if input.Text != "true" && input.Text != "false" { t.Fatal("bool must be true or false") @@ -250,3 +310,85 @@ func assertConformanceDiagnostic(t *testing.T, err error, code, reason, field st t.Fatalf("unexpected diagnostic: %s reason=%s field=%s", diagnostic.Code, diagnostic.Reason(), diagnostic.Field()) } } + +// conformanceFloat reads a float from its canonical spelling +// (canonicalFloat). Any other text that parses, such as 1.00, 1e+3, 1E3, +// +1 or a rounded decimal, is a corpus error, so every runner reads one +// spelling alike. +func conformanceFloat(text string, bits int) (float64, error) { + n, err := strconv.ParseFloat(text, bits) + if err != nil { + return 0, err + } + if canonical, ok := canonicalFloat(n, bits); !ok || canonical != text { + return 0, fmt.Errorf("%s: not the shortest spelling of a representable value", text) + } + return n, nil +} + +// canonicalFloat is a float's canonical spelling: of its positional +// spelling (34, -0, 0.5) and its scientific spelling (1e3, 3.4028235e38, +// 5e-324; no plus sign or leading zero in the exponent), both of the +// shortest digits that round-trip, the shorter, and the positional one on a +// tie (100, not 1e2). The infinities are inf and -inf; NaN has none. It is +// the text Rust's canonical_float derives from its {} and {:e} formats. +func canonicalFloat(n float64, bits int) (string, bool) { + switch { + case math.IsNaN(n): + return "", false + case math.IsInf(n, 1): + return "inf", true + case math.IsInf(n, -1): + return "-inf", true + } + positional := strconv.FormatFloat(n, 'f', -1, bits) + mantissa, exponent, _ := strings.Cut(strconv.FormatFloat(n, 'e', -1, bits), "e") + sign, digits := "", strings.TrimPrefix(exponent, "+") + if strings.HasPrefix(digits, "-") { + sign, digits = "-", digits[1:] + } + if digits = strings.TrimLeft(digits, "0"); digits == "" { + digits = "0" + } + if scientific := mantissa + "e" + sign + digits; len(scientific) < len(positional) { + return scientific, true + } + return positional, true +} + +// TestConformanceFloatSpelling holds the float reader to the lists Rust's +// the_float_reader_accepts_only_the_canonical_spelling uses. +func TestConformanceFloatSpelling(t *testing.T) { + canonical := []struct { + bits int + text string + }{ + {64, "100"}, {64, "12000"}, {64, "1e3"}, {64, "34"}, {64, "-0"}, + {64, "1.5"}, {64, "0.5"}, {64, "1e-3"}, {64, "9007199254740992"}, + {64, "1.7976931348623157e308"}, {64, "5e-324"}, {64, "inf"}, {64, "-inf"}, + {32, "1e-45"}, {32, "3.4028235e38"}, + } + for _, c := range canonical { + if _, err := conformanceFloat(c.text, c.bits); err != nil { + t.Errorf("float%d %q: %v", c.bits, c.text, err) + } + } + nonCanonical := []struct { + bits int + text string + }{ + {64, "1.00"}, {64, "1.0"}, {64, "1e0"}, {64, "1.5e0"}, {64, "1000"}, + {64, "1e2"}, {64, "1e+3"}, {64, "1E3"}, {64, "10e2"}, {64, "01"}, + {64, "+1"}, {64, ".5"}, {64, "5."}, {64, "-0.0"}, {64, "0.001"}, + {64, "0.1000000000000000001"}, {64, "Inf"}, {64, "infinity"}, {64, "NaN"}, + {32, "1.4e-45"}, {32, "3.4028234664e38"}, + } + for _, c := range nonCanonical { + if _, err := strconv.ParseFloat(c.text, c.bits); err != nil { + t.Fatalf("float%d %q does not parse: %v", c.bits, c.text, err) + } + if n, err := conformanceFloat(c.text, c.bits); err == nil { + t.Errorf("float%d %q: read as %v, want a corpus error", c.bits, c.text, n) + } + } +} diff --git a/languages/golang/encrypt/eql_test.go b/languages/golang/encrypt/eql_test.go index dc11ea565..10af03190 100644 --- a/languages/golang/encrypt/eql_test.go +++ b/languages/golang/encrypt/eql_test.go @@ -25,9 +25,7 @@ import ( func deterministicEQLClient(t *testing.T) *encrypt.Client { t.Helper() c, err := encrypt.NewDeterministicEQLClient(context.Background(), testSeed) - if errors.Is(err, encrypt.ErrDeterministicEQLGuestNotBuilt) || errors.Is(err, encrypt.ErrGuestNotBuilt) { - t.Skip(err) - } + skipMissingConformanceGuest(t, err) if err != nil { t.Fatal(err) } diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index 56f8b7d13..970226924 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -221,6 +221,27 @@ func (k *Checker) OpenTreeForTest(ctx context.Context, tree []any, plan *record. return k.c.openTree(ctx, k.c.DefaultKeyset().keyset, expected, plan, tree) } +// OpenFrameForTest hands the guest a stored batch already in the transport +// codec, so a test can send what the tree encoder refuses to write (a +// repeated key, a malformed leaf). rows is the batch's record count. Cipher +// validation, extension, context and production decoding are kept. +func (cph *Cipher) OpenFrameForTest(ctx context.Context, encoded []byte, rows int, plan *record.Plan) ([]record.Source, error) { + if cph == nil { + return nil, fmt.Errorf("%w: Open on a nil *Cipher", ErrEncoding) + } + p, err := cph.plan(plan) + if err != nil { + return nil, err + } + return cph.client.openEncoded(ctx, cph.keyset, cph.context, p, encoded, rows) +} + +// OpenFrameForTest is the uninitialised guest's view of the same batch: its +// preflight on the raw bytes, before any cipher exists. +func (k *Checker) OpenFrameForTest(ctx context.Context, encoded []byte, rows int, plan *record.Plan, expected string) ([]record.Source, error) { + return k.c.openEncoded(ctx, k.c.DefaultKeyset().keyset, expected, plan, encoded, rows) +} + // ObserveEncryptPlansForTest copies the actual plan buffer at the WASM call // boundary. Install and restore only while this test's client is idle. func ObserveEncryptPlansForTest(c *Client, observe func([]byte)) func() { @@ -247,3 +268,8 @@ func (f *observedEncryptCall) Call(ctx context.Context, params ...uint64) ([]uin f.observe(append([]byte(nil), plan...)) return f.Function.Call(ctx, params...) } + +// UnmarshalCipherTextForTest decodes a stored batch with the host's tree +// codec, so a test can show that a frame the guest refuses is one the host +// could never have written either. +func UnmarshalCipherTextForTest(buf []byte) (any, error) { return unmarshalCipherText(buf) } diff --git a/languages/golang/encrypt/fixture_conformance_test.go b/languages/golang/encrypt/fixture_conformance_test.go index e5b7720e4..4c3e94bc4 100644 --- a/languages/golang/encrypt/fixture_conformance_test.go +++ b/languages/golang/encrypt/fixture_conformance_test.go @@ -18,7 +18,7 @@ import ( // remain failures in the caller, including errors wrapping neither sentinel. func skipMissingConformanceGuest(t *testing.T, err error) { t.Helper() - if (errors.Is(err, encrypt.ErrGuestNotBuilt) || errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt)) && os.Getenv("STACK_CONFORMANCE_REQUIRED") != "1" { + if (errors.Is(err, encrypt.ErrGuestNotBuilt) || errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt) || errors.Is(err, encrypt.ErrDeterministicEQLGuestNotBuilt)) && os.Getenv("STACK_CONFORMANCE_REQUIRED") != "1" { t.Skip(err) } } @@ -74,9 +74,11 @@ func TestFixtureConformanceRequiresGuest(t *testing.T) { } // These full-suite tests also share queryFixtureClient's required-mode policy; // they remain outside the shared fixture requirements and aggregate selection. + names["TestJSONDocumentsRoundTripThroughTheGuest"] = true + names["TestMatchRefusalsNameTheConstructAndItsOffset"] = true names["TestContextConformance"] = true names["TestContextBoundaries"] = true - names["TestAQueryTheFieldDoesNotAnswerIsRefused"] = true + names["TestVerifyAndTargetLimitRefuseWhatIsNotAMatchQuery"] = true names["TestAMatchesQueryNeverPrintsItsText"] = true tests := make([]string, 0, len(names)) for name := range names { diff --git a/languages/golang/encrypt/gensupport/gensupport_internal_test.go b/languages/golang/encrypt/gensupport/gensupport_internal_test.go index cb770bd87..f5dd78e3e 100644 --- a/languages/golang/encrypt/gensupport/gensupport_internal_test.go +++ b/languages/golang/encrypt/gensupport/gensupport_internal_test.go @@ -3,6 +3,7 @@ package gensupport import ( "bytes" "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -580,3 +581,44 @@ func TestEncryptNullOnlyForASealedNull(t *testing.T) { t.Fatalf("age: EncryptNull = %v", err) } } + +// TestOpaqueReadsARustWrittenBlob is the Go half of the storage exchange +// with Rust's stack-encrypt: the blob its OpaqueBlob writes +// (pinned as RUST_BLOB in packages/stack-encrypt/tests/opaque_blob.rs, +// where this struct's Go-written bytes are read in turn) opens into the +// Go shape, Rust's Some marker included. The two SDKs share the storage +// profile, so an opaque column is one format whichever wrote it. +func TestOpaqueReadsARustWrittenBlob(t *testing.T) { + type profile struct { + Name string `vc:"name"` + Age uint32 `vc:"age"` + Tags []string `vc:"tags"` + Nickname *string `vc:"nickname"` + } + rust, err := hex.DecodeString("56435301f104000000040000006e616d650a0c000000416461204c6f76656c6163650300000061676506240000000400000074616773f0020000000a050000006d617468730a07000000656e67696e6573080000006e69636b6e616d65f50a03000000616461") + if err != nil { + t.Fatal(err) + } + var got profile + if err := Opaque(Values{OpaqueField: rust}, &got); err != nil { + t.Fatalf("Opaque of a Rust-written blob: %v", err) + } + if got.Name != "Ada Lovelace" || got.Age != 36 || !reflect.DeepEqual(got.Tags, []string{"maths", "engines"}) || got.Nickname == nil || *got.Nickname != "ada" { + t.Fatalf("Opaque of a Rust-written blob = %+v", got) + } + // Rust writes every Some with the storage profile's marker; Go writes + // a non-nil pointer as the bare value. The bytes differ in that one + // byte, and both read as the same value here. + nick := "ada" + ours, err := vcffi.MarshalStorage(profile{Name: "Ada Lovelace", Age: 36, Tags: []string{"maths", "engines"}, Nickname: &nick}) + if err != nil { + t.Fatal(err) + } + if len(ours)+1 != len(rust) { + t.Fatalf("Go wrote %d bytes, Rust %d; expected to differ by the one Some marker", len(ours), len(rust)) + } + var back profile + if err := Opaque(Values{OpaqueField: ours}, &back); err != nil || !reflect.DeepEqual(back, got) { + t.Fatalf("Opaque of the Go-written blob: %v %+v", err, back) + } +} diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 81cbcb3c3..3fddac588 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -1470,12 +1470,12 @@ dependencies = [ "rand 0.10.2", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.8.1", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2110,8 +2110,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -2136,15 +2136,15 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.8.1", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.8.1", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2184,9 +2184,9 @@ dependencies = [ "miette", "serde_json", "thiserror 1.0.69", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2212,8 +2212,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -2688,16 +2688,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -2717,18 +2717,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.20", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2745,9 +2745,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -2756,12 +2756,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "3a65349bb75a9ecc9fd4f057e5a6aac964bea68e57d03d55118a22455002a424" dependencies = [ "serde", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2777,12 +2778,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2801,72 +2802,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "e4a244886bff4915e15000a69f5a10f14410cabb30c445e3d585057c06a9ce1a" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "d820d507ca7cce81772e38c1277b26acc422a4fd99402e6d9d49c412a5a135f0" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "6b80bf6789c1e2e1c192a255a9e2f6c3e7ffbce2fcb61d60edecee6ffc2f71ef" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "e86cfcbfb4089f5bf334723cbf106f7d0d4709abf0ad12aaf49d6cf89f3e78de" dependencies = [ "mutants", "thiserror 2.0.20", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2888,9 +2889,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -2899,7 +2900,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -2916,9 +2917,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2943,16 +2944,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.3", "rand 0.10.2", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -2969,9 +2970,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -2996,17 +2997,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] diff --git a/languages/golang/encrypt/guest_test.go b/languages/golang/encrypt/guest_test.go index e1cf3b7ec..d343f5eac 100644 --- a/languages/golang/encrypt/guest_test.go +++ b/languages/golang/encrypt/guest_test.go @@ -767,29 +767,6 @@ func TestCheckerRefusesAnIndexedUntypedField(t *testing.T) { } } -// The engine's reason reaches Go under the key Reason reads: a field whose -// outputs name the ciphertext twice is refused as a duplicate output. -func TestAPlanRefusalNamesItsReason(t *testing.T) { - guestOrSkip(t) - ctx := context.Background() - checker, err := NewChecker(ctx) - if err != nil { - t.Fatal(err) - } - defer checker.Close() - p := &record.Plan{Context: []string{"users"}, Fields: []record.Field{ - {Name: "age", Kind: record.Uint32, Outputs: []record.Output{record.Ciphertext, record.Ciphertext}}, - }} - err = checker.engineCheck(ctx, p) - if !errors.Is(err, ErrEncoding) { - t.Fatalf("err = %v, want ErrEncoding", err) - } - d := wantDiagnostic(t, err, "stack_encrypt::dynamic_plan") - if d.Field() != "age" || d.Reason() != "duplicate_output" { - t.Errorf("Field() = %q, Reason() = %q", d.Field(), d.Reason()) - } -} - // se_plan_check answers with no cipher: a plan the engine runs passes, a // plan it refuses is ErrEncoding, never ErrState. func TestPlanCheckAnswersWithoutACipher(t *testing.T) { diff --git a/languages/golang/encrypt/json_conformance_test.go b/languages/golang/encrypt/json_conformance_test.go new file mode 100644 index 000000000..7c456a1e1 --- /dev/null +++ b/languages/golang/encrypt/json_conformance_test.go @@ -0,0 +1,42 @@ +package encrypt_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +type jsonConformanceCorpus struct { + Version int `json:"version"` + Documents []string `json:"documents"` + InvalidDocuments []struct { + Text string `json:"text"` + Reason string `json:"reason"` + } `json:"invalid_documents"` + PredicateRefusals []struct { + ID string `json:"id"` + Predicate string `json:"predicate"` + Vars json.RawMessage `json:"vars"` + Reason string `json:"reason"` + Construct string `json:"construct"` + At int `json:"at"` + AtText string `json:"at_text"` + } `json:"predicate_refusals"` +} + +func jsonConformanceCases(t *testing.T) jsonConformanceCorpus { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1", "eql-json.json")) + if err != nil { + t.Fatal(err) + } + var cases jsonConformanceCorpus + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatal(err) + } + if cases.Version != 1 || len(cases.Documents) == 0 || len(cases.InvalidDocuments) == 0 || len(cases.PredicateRefusals) == 0 { + t.Fatal("empty or unsupported JSON conformance corpus") + } + return cases +} diff --git a/languages/golang/encrypt/json_match_test.go b/languages/golang/encrypt/json_match_test.go index 6edc85ba3..8ae6d3bb8 100644 --- a/languages/golang/encrypt/json_match_test.go +++ b/languages/golang/encrypt/json_match_test.go @@ -274,46 +274,28 @@ func TestMatchRefusalsNameTheConstructAndItsOffset(t *testing.T) { ctx := context.Background() cipher := c.DefaultKeyset() f := testusers.EntityFields.CustomFieldValues - vars := map[string]any{"v": "secret-nz", "flag": true} - deep := strings.Repeat("(", 33) + "$.a == 1" + strings.Repeat(")", 33) - for _, tc := range []struct { - name, predicate string - vars any - reason string - construct string - at string // the text the offset points at - }{ - {"text the grammar does not read", `$.extra.country = $v`, vars, "json_predicate_syntax", "text the grammar does not read", "= $v"}, - {"like_regex", `$.extra.country like_regex "secret"`, vars, "json_predicate_unsupported", "`like_regex`", "like_regex"}, - {"arithmetic", `$.weight + 1 == 2`, vars, "json_predicate_unsupported", "arithmetic (`+`, `-`, `*`, `/`, `%`)", "+ 1"}, - {"a method", `$.weight.size() == 1`, vars, "json_predicate_unsupported", "a method (`.name()`)", ".size()"}, - {"a filter outside exists", `$.extra.preferred_languages[*] ? (@ == $v) == 1`, vars, "json_predicate_unsupported", "a filter outside `exists`", "? (@"}, - {"|| in a filter", `exists($.extra.emergency_contacts[*] ? (@.relationship == $v || @.age > 1))`, vars, "json_predicate_unsupported", "`||` in a filter", "|| @.age"}, - {"an unknown variable", `$.extra.country == $missing`, vars, "json_predicate_unknown_variable", "a variable", "$missing"}, - {"a value an ordering cannot take", `$.weight < $flag`, vars, "json_predicate_value_type", "a value", "$flag"}, - {"an object", `$.extra.country == $obj`, map[string]any{"obj": map[string]any{"secret-nz": 1}}, "json_predicate_value_type", "a value", "$obj"}, - {"!= at a wildcard", `$.extra.preferred_languages[*] != $v`, vars, "json_predicate_not_equal_at_wildcard", "`!=` at a path with a wildcard", "!= $v"}, - {"too deep", deep, nil, "json_predicate_too_deep", "nesting", "$.a"}, - {"a position", `$.extra.preferred_languages[2] == $v`, vars, "json_path_position", "a position (`[2]`)", "[2]"}, - {"a filter with no wildcard", `exists($.address ? (@.postcode == $v))`, vars, "json_path_no_wildcard", "a filter on a path with no wildcard", "$.address ?"}, - {"a wildcard in a member", `exists($.extra.emergency_contacts[*] ? (@.calls[*] == $v))`, vars, "json_path_wildcard", "a wildcard in a filter's member", "@.calls"}, - {"a number JSON cannot hold", `$.weight == 1e999`, vars, "json_syntax", "a value", "1e999"}, - } { - _, err := f.Match(ctx, cipher, tc.predicate, tc.vars, encrypt.JSONReadOptions{}) + for _, tc := range jsonConformanceCases(t).PredicateRefusals { + var vars any + if tc.Vars != nil { + vars = tc.Vars + } + if strings.Index(tc.Predicate, tc.AtText) != tc.At { + t.Fatalf("%s: offset does not name at_text", tc.ID) + } + _, err := f.Match(ctx, cipher, tc.Predicate, vars, encrypt.JSONReadOptions{}) var d *encrypt.Diagnostic if !errors.Is(err, encrypt.ErrEncoding) || !errors.As(err, &d) { - t.Errorf("%s: %v, want ErrEncoding with a Diagnostic", tc.name, err) + t.Errorf("%s: %v, want ErrEncoding with a Diagnostic", tc.ID, err) continue } - want := strings.Index(tc.predicate, tc.at) - if at, ok := d.At(); d.Reason() != tc.reason || d.Construct() != tc.construct || !ok || at != want { - t.Errorf("%s: reason %q, construct %q, at %d (%v); want %q, %q, %d: %v", tc.name, d.Reason(), d.Construct(), at, ok, tc.reason, tc.construct, want, err) + if at, ok := d.At(); d.Reason() != tc.Reason || d.Construct() != tc.Construct || !ok || at != tc.At { + t.Errorf("%s: reason %q, construct %q, at %d (%v); want %q, %q, %d: %v", tc.ID, d.Reason(), d.Construct(), at, ok, tc.Reason, tc.Construct, tc.At, err) } if d.Field() != "custom_field_values" || d.Code != "stack_encrypt::dynamic_predicate" { - t.Errorf("%s: field %q, code %q", tc.name, d.Field(), d.Code) + t.Errorf("%s: field %q, code %q", tc.ID, d.Field(), d.Code) } - if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), tc.predicate) { - t.Errorf("%s: the error holds the caller's text: %v", tc.name, err) + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), tc.Predicate) { + t.Errorf("%s: the error holds the caller's text: %v", tc.ID, err) } } @@ -363,7 +345,7 @@ func TestMatchRefusalsNameTheConstructAndItsOffset(t *testing.T) { `$.score < 3`, `exists($.extra.emergency_contacts[*] ? (@.name == $v && @.age > 1))`, } { - _, err := f.Match(ctx, cipher, predicate, vars, encrypt.JSONReadOptions{}) + _, err := f.Match(ctx, cipher, predicate, map[string]any{"v": "secret-nz", "flag": true}, encrypt.JSONReadOptions{}) var d *encrypt.Diagnostic if !errors.Is(err, encrypt.ErrTerm) || !errors.As(err, &d) || d.Code != "stack_encrypt::json_path_not_ordered" { t.Errorf("%s: %v, want ErrTerm json_path_not_ordered", predicate, err) diff --git a/languages/golang/encrypt/json_tree_test.go b/languages/golang/encrypt/json_tree_test.go index 37023f35f..9fd49a503 100644 --- a/languages/golang/encrypt/json_tree_test.go +++ b/languages/golang/encrypt/json_tree_test.go @@ -26,18 +26,10 @@ func jsonPlan() *record.Plan { }}} } -var jsonDocuments = []string{ - `{"name":"Alice","age":34,"visits":[{"at":3},{"at":1}]}`, - `[1,"two",{"three":3}]`, - `"a scalar root"`, - `null`, - `18446744073709551615`, - `{}`, -} - func TestJSONDocumentsRoundTripThroughTheGuest(t *testing.T) { c := deterministicEQLClient(t) ctx := context.Background() + jsonDocuments := jsonConformanceCases(t).Documents rows := make([]record.Source, len(jsonDocuments)) for i, doc := range jsonDocuments { rows[i] = record.Source{"profile": []byte(doc)} @@ -93,7 +85,7 @@ func TestJSONDocumentsRoundTripThroughTheGuest(t *testing.T) { func TestJSONDocumentsDoNotOpenUnderAnotherColumn(t *testing.T) { c := deterministicEQLClient(t) ctx := context.Background() - sealed, err := c.DefaultKeyset().Seal(ctx, jsonPlan(), []record.Source{{"profile": []byte(jsonDocuments[0])}}) + sealed, err := c.DefaultKeyset().Seal(ctx, jsonPlan(), []record.Source{{"profile": []byte(jsonConformanceCases(t).Documents[0])}}) if err != nil { t.Fatal(err) } @@ -108,11 +100,8 @@ func TestJSONDocumentsDoNotOpenUnderAnotherColumn(t *testing.T) { // the engine's code and without the text. func TestJSONTextThatIsNotADocumentIsErrEncoding(t *testing.T) { c := deterministicEQLClient(t) - for text, code := range map[string]string{ - `{"secret":1,"secret":2}`: "json_duplicate_key", - `99999999999999999999`: "json_integer_range", - `{"secret":`: "json_syntax", - } { + for _, tc := range jsonConformanceCases(t).InvalidDocuments { + text, code := tc.Text, tc.Reason _, err := c.DefaultKeyset().Seal(context.Background(), jsonPlan(), []record.Source{{"profile": []byte(text)}}) if !errors.Is(err, encrypt.ErrEncoding) { t.Fatalf("%s: %v, want ErrEncoding", text, err) diff --git a/languages/golang/encrypt/query_refusal_conformance_test.go b/languages/golang/encrypt/query_refusal_conformance_test.go new file mode 100644 index 000000000..6271dea5c --- /dev/null +++ b/languages/golang/encrypt/query_refusal_conformance_test.go @@ -0,0 +1,190 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "path/filepath" + "reflect" + "sort" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/internal/record" +) + +type refusalFieldSpec struct { + Kind string `json:"kind"` + Outputs []string `json:"outputs"` + Null string `json:"null"` +} + +type refusalCase struct { + ID string `json:"id"` + Field string `json:"field"` + Operator string `json:"operator"` + Input json.RawMessage `json:"input"` + Accepted *bool `json:"accepted"` + ExpectedError *struct { + Class string `json:"class"` + Code string `json:"code"` + Reason *string `json:"reason"` + Field *string `json:"field"` + } `json:"expected_error"` + // How the typed Rust chain answers: a native observation this runner + // validates and does not execute. + Typed *struct { + Refusal string `json:"refusal"` + } `json:"typed"` +} + +// refusalInput is a scalar input, or for between a list of two: the +// shape the engine reads, as the SDK's QueryTerm takes it. +func refusalInput(t *testing.T, raw json.RawMessage) any { + t.Helper() + var scalar conformanceInput + if err := strictUnmarshal(raw, &scalar); err == nil { + return conformanceValue(t, scalar) + } + var list struct { + Type string `json:"type"` + Items []conformanceInput `json:"items"` + } + if err := strictUnmarshal(raw, &list); err != nil || list.Type != "list" { + t.Fatalf("input is neither a scalar nor a list: %s", raw) + } + items := make([]any, len(list.Items)) + for i, item := range list.Items { + items[i] = conformanceValue(t, item) + } + return items +} + +func strictUnmarshal(raw json.RawMessage, out any) error { + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + return d.Decode(out) +} + +func TestQueryRefusalConformance(t *testing.T) { + var corpus struct { + Version int `json:"version"` + Profile string `json:"profile"` + RequiredCapabilities []string `json:"required_capabilities"` + Fields map[string]refusalFieldSpec `json:"fields"` + Cases []refusalCase `json:"cases"` + } + readConformanceJSON(t, filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1", "query-refusals.json"), &corpus) + if corpus.Version != 1 || corpus.Profile != "record-query-refusals-v1" || len(corpus.Cases) == 0 || len(corpus.Fields) == 0 { + t.Fatal("unsupported or empty refusal corpus") + } + if !reflect.DeepEqual(corpus.RequiredCapabilities, []string{"records", "query-errors", "term-errors"}) { + t.Fatal("unsupported capabilities") + } + names := make([]string, 0, len(corpus.Fields)) + for name := range corpus.Fields { + names = append(names, name) + } + sort.Strings(names) + plan := &record.Plan{Context: []string{"conformance"}} + for _, name := range names { + spec := corpus.Fields[name] + outputs := make([]record.Output, len(spec.Outputs)) + for i, o := range spec.Outputs { + outputs[i] = record.Output(o) + } + plan.Fields = append(plan.Fields, record.Field{Name: name, Kind: record.Kind(spec.Kind), Null: record.Null(spec.Null), Outputs: outputs}) + } + if err := plan.Validate(); err != nil { + t.Fatal(err) + } + ctx := context.Background() + client, err := encrypt.NewDeterministicClient(ctx, [32]byte{}) + skipMissingConformanceGuest(t, err) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + cipher := client.DefaultKeyset() + seen := map[string]bool{} + for _, c := range corpus.Cases { + if c.ID == "" || seen[c.ID] { + t.Fatalf("empty or repeated ID %q", c.ID) + } + seen[c.ID] = true + t.Run(c.ID, func(t *testing.T) { + if c.Accepted == nil || *c.Accepted != (c.ExpectedError == nil) { + t.Fatal("contradictory outcome") + } + if c.Typed != nil { + switch c.Typed.Refusal { + case "none": + if c.ExpectedError != nil { + t.Fatal("the typed chain accepts only what the binding accepts") + } + case "before_keyset", "after_keyset": + if c.ExpectedError == nil || c.ExpectedError.Class != "term" { + t.Fatal("the typed chain's stage is recorded for term refusals alone") + } + default: + t.Fatalf("unknown typed stage %q", c.Typed.Refusal) + } + } + if want := c.ExpectedError; want != nil && (want.Class == "term") != (want.Reason == nil && want.Field == nil) { + t.Fatal("a term refusal alone has no reason or field") + } + input := refusalInput(t, c.Input) + want := c.ExpectedError + check := func(path string, err error) { + t.Helper() + if want == nil { + if err != nil { + t.Fatalf("%s: %v", path, err) + } + return + } + // The binding's sentinel is the class: a source or plan + // refusal is ErrEncoding, an index's refusal of the value + // is ErrTerm. The diagnostic code is the same the native + // engine reports. + sentinel := encrypt.ErrEncoding + if want.Class == "term" { + sentinel = encrypt.ErrTerm + } else if want.Class != "source" && want.Class != "plan" { + t.Fatalf("unknown refusal class %q", want.Class) + } + var diagnostic *encrypt.Diagnostic + if !errors.Is(err, sentinel) || !errors.As(err, &diagnostic) { + t.Fatalf("%s: expected %s diagnostic, got %v", path, want.Code, err) + } + if diagnostic.Code != want.Code { + t.Fatalf("%s: code %s, want %s", path, diagnostic.Code, want.Code) + } + // A term refusal carries neither a reason nor a field. + var reason, field string + if want.Reason != nil { + reason = *want.Reason + } + if want.Field != nil { + field = *want.Field + } + if diagnostic.Reason() != reason { + t.Fatalf("%s: reason %q, want %q", path, diagnostic.Reason(), reason) + } + if diagnostic.Field() != field { + t.Fatalf("%s: field %q, want %q", path, diagnostic.Field(), field) + } + } + _, err := cipher.QueryTerm(ctx, plan, c.Field, c.Operator, input) + check("QueryTerm", err) + if c.Operator == "matches" { + matched, err := cipher.Verify(ctx, plan, c.Field, c.Operator, input, []any{}) + check("Verify", err) + if err == nil && len(matched) != 0 { + t.Fatal("an empty page verifies to nothing") + } + } + }) + } +} diff --git a/languages/golang/encrypt/query_test.go b/languages/golang/encrypt/query_test.go index d4cbc7baf..022126ca9 100644 --- a/languages/golang/encrypt/query_test.go +++ b/languages/golang/encrypt/query_test.go @@ -316,42 +316,17 @@ func TestAnUnboundedLimitCutsThePageToTheLargestSQLLimit(t *testing.T) { } } -// A query the field does not answer is refused as a mistyped value is: an -// encoding error that names the field and the rule. The typed path's own -// refusals are term errors. -func TestAQueryTheFieldDoesNotAnswerIsRefused(t *testing.T) { +// Verify and TargetLimit answer match queries alone: an exact operator is +// refused by its reason, and half a position is not a query term. The +// QueryTerm refusals (an operator the field does not answer, an unknown +// operator, a scalar for between, text for an integer, an inverted range, +// text that cuts no token) are the shared query-refusal corpus, run by +// TestQueryRefusalConformance. +func TestVerifyAndTargetLimitRefuseWhatIsNotAMatchQuery(t *testing.T) { f := readQueryFixture(t) c := queryFixtureClient(t, f) ctx := context.Background() cipher := c.DefaultKeyset() - for _, tc := range []struct { - field, operator string - input any - reason string - }{ - {"age", "matches", "ali", "operator_not_answered"}, - {"name", "lt", "ali", "operator_not_answered"}, - {"age", "like", uint32(1), "unknown_operator"}, - {"age", "between", uint32(1), "not_a_range"}, - {"age", "eq", "34", "field_type"}, - } { - _, err := cipher.QueryTerm(ctx, queryPlan, tc.field, tc.operator, tc.input) - if !errors.Is(err, encrypt.ErrEncoding) { - t.Fatalf("%s on %s: %v, want ErrEncoding", tc.operator, tc.field, err) - } - var d *encrypt.Diagnostic - if !errors.As(err, &d) || d.Field() != tc.field || d.Reason() != tc.reason { - t.Fatalf("%s on %s: %v, want field %q and reason %q", tc.operator, tc.field, err, tc.field, tc.reason) - } - } - // An inverted range is not refused, as SQL's BETWEEN is not. - if _, err := cipher.QueryTerm(ctx, queryPlan, "age", "between", []any{uint32(65), uint32(18)}); err != nil { - t.Fatalf("an inverted range: %v", err) - } - if _, err := cipher.QueryTerm(ctx, queryPlan, "name", "matches", "al"); !errors.Is(err, encrypt.ErrTerm) { - t.Fatalf("text that cuts no token: %v, want ErrTerm", err) - } - _, err := cipher.Verify(ctx, queryPlan, "email", "eq", "bob@example.com", []any{"bob@example.com"}) var d *encrypt.Diagnostic if !errors.Is(err, encrypt.ErrEncoding) || !errors.As(err, &d) || d.Reason() != "exact_operator" { diff --git a/languages/golang/encrypt/raw_frame_conformance_test.go b/languages/golang/encrypt/raw_frame_conformance_test.go new file mode 100644 index 000000000..6015b1ee8 --- /dev/null +++ b/languages/golang/encrypt/raw_frame_conformance_test.go @@ -0,0 +1,125 @@ +package encrypt_test + +import ( + "context" + "encoding/hex" + "errors" + "path/filepath" + "reflect" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" +) + +type rawFrameCase struct { + ID string `json:"id"` + Read readDeclaration `json:"read"` + FrameHex string `json:"frame_hex"` + Class string `json:"class"` + Retrievals int `json:"retrievals"` + Expected *declarationError `json:"expected"` + Rows *[]map[string]conformanceInput `json:"rows"` + // What the native engine refuses when a frame the codec refuses is read + // leniently: a Rust observation. The binding never reaches the engine + // with such a frame, so it is parsed here and not executed. + NativeTree *declarationError `json:"native_tree"` +} + +func TestRawFrameConformance(t *testing.T) { + var corpus struct { + Version int `json:"version"` + Profile string `json:"profile"` + Seed string `json:"seed"` + RequiredCapabilities []string `json:"required_capabilities"` + BoundaryError *declarationError `json:"boundary_error"` + Cases []rawFrameCase `json:"cases"` + } + readConformanceJSON(t, filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1", "raw-frames.json"), &corpus) + if corpus.Version != 1 || corpus.Profile != "record-raw-frames-v1" || len(corpus.Cases) == 0 || corpus.BoundaryError == nil { + t.Fatal("unsupported or empty raw-frame corpus") + } + if !reflect.DeepEqual(corpus.RequiredCapabilities, []string{"records", "raw-frames", "read-shapes", "authenticated-types"}) { + t.Fatal("unsupported capabilities") + } + seed, err := hex.DecodeString(corpus.Seed) + if err != nil || len(seed) != 32 { + t.Fatal("expected a 32-byte deterministic seed") + } + ctx := context.Background() + client, err := encrypt.NewDeterministicClient(ctx, [32]byte(seed)) + skipMissingConformanceGuest(t, err) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + wasm, err := encrypt.DeterministicGuest() + if err != nil { + t.Fatal(err) + } + checker, err := encrypt.NewCheckerOver(ctx, wasm) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = checker.Close() }) + cipher := client.DefaultKeyset() + seen := map[string]bool{} + for _, c := range corpus.Cases { + if c.ID == "" || seen[c.ID] { + t.Fatalf("empty or repeated ID %q", c.ID) + } + seen[c.ID] = true + t.Run(c.ID, func(t *testing.T) { + frame, err := hex.DecodeString(c.FrameHex) + if err != nil { + t.Fatal(err) + } + refused := c.Class != "success" + switch c.Class { + case "transport", "leaf", "shape", "authenticated", "success": + default: + t.Fatalf("unknown class %q", c.Class) + } + if (c.Expected == nil) != (c.Class == "success" || c.Class == "transport") || (c.Rows == nil) == (c.Class == "success") { + t.Fatal("a case says exactly what its class needs") + } + if c.NativeTree != nil && c.Class != "transport" { + t.Fatal("a native tree belongs to a frame the codec refuses") + } + if c.Retrievals != 0 && c.Class != "authenticated" && c.Class != "success" { + t.Fatal("retrieval only once the shape has passed") + } + want := c.Expected + if c.Class == "transport" { + want = corpus.BoundaryError + // The host's own tree codec refuses what the guest's does. + if _, err := encrypt.UnmarshalCipherTextForTest(frame); err == nil { + t.Fatal("a transport refusal requires a frame the codec refuses") + } + } + plan := readPlan(c.Read, false, "") + // Without a cipher, the guest decodes and preflights the frame. + // A frame that passes preflight reaches the state boundary. + _, err = checker.OpenFrameForTest(ctx, frame, 1, plan, "") + if c.Class == "authenticated" || c.Class == "success" { + if !errors.Is(err, encrypt.ErrState) { + t.Fatalf("expected the state boundary after preflight, got %v", err) + } + } else { + readRefusal(t, err, want) + } + out, err := cipher.OpenFrameForTest(ctx, frame, 1, plan) + if refused { + // An authentication failure is held to its code and fields + // like any other refusal, under ErrAuthentication. + readRefusal(t, err, want) + return + } + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(out, runtimeRows(t, *c.Rows)) { + t.Fatalf("typed rows differ: %#v", out) + } + }) + } +} diff --git a/languages/golang/encrypt/read_conformance_test.go b/languages/golang/encrypt/read_conformance_test.go index bd63fbe1c..888d22f5d 100644 --- a/languages/golang/encrypt/read_conformance_test.go +++ b/languages/golang/encrypt/read_conformance_test.go @@ -120,8 +120,11 @@ func mutateRead(t *testing.T, batch []any, c readCase) { func readRefusal(t *testing.T, err error, want *declarationError) { t.Helper() sentinel := encrypt.ErrEncoding - if want.Code == "stack_encrypt::context_mismatch" { + switch want.Code { + case "stack_encrypt::context_mismatch": sentinel = encrypt.ErrContextMismatch + case "stack_encrypt::aead": + sentinel = encrypt.ErrAuthentication } var diagnostic *encrypt.Diagnostic if !errors.Is(err, sentinel) || !errors.As(err, &diagnostic) { diff --git a/languages/golang/encrypt/records.go b/languages/golang/encrypt/records.go index a48320ced..ba16bed54 100644 --- a/languages/golang/encrypt/records.go +++ b/languages/golang/encrypt/records.go @@ -414,6 +414,14 @@ func (c *Client) openTree(ctx context.Context, sel KeysetSelector, expected stri if err != nil { return nil, fmt.Errorf("%w: %v", ErrEncoding, err) } + return c.openEncoded(ctx, sel, expected, p, encodedTree, len(trees)) +} + +// openEncoded opens a batch already in the transport codec: rows is how +// many records the batch holds, so the answer can be held to it. The tree +// encoder refuses a repeated key, so a test that needs one feeds this +// directly, and the guest's decoder is what refuses it. +func (c *Client) openEncoded(ctx context.Context, sel KeysetSelector, expected string, p *record.Plan, encodedTree []byte, rows int) ([]record.Source, error) { encodedPlan, err := vcffi.Marshal(p.Wire()) if err != nil { return nil, fmt.Errorf("%w: %v", ErrEncoding, err) @@ -435,8 +443,8 @@ func (c *Client) openTree(ctx context.Context, sel KeysetSelector, expected stri return nil, fmt.Errorf("%w: %v", ErrInternal, err) } items, ok := decoded.([]any) - if !ok || len(items) != len(trees) { - return nil, fmt.Errorf("%w: %d records came back as %T", ErrInternal, len(trees), decoded) + if !ok || len(items) != rows { + return nil, fmt.Errorf("%w: %d records came back as %T", ErrInternal, rows, decoded) } order := make([]string, 0, len(p.Fields)+1) if p.ContextField != "" { diff --git a/languages/golang/encrypt/value_conformance_test.go b/languages/golang/encrypt/value_conformance_test.go new file mode 100644 index 000000000..64b497bfd --- /dev/null +++ b/languages/golang/encrypt/value_conformance_test.go @@ -0,0 +1,243 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "math" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/internal/record" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) + +type valueCase struct { + ID string `json:"id"` + Kind string `json:"kind"` + Null string `json:"null"` + Outputs []string `json:"outputs"` + Input conformanceInput `json:"input"` + Frame *string `json:"frame"` + Companion *conformanceInput `json:"companion"` + EqualQuery *conformanceInput `json:"equal_query"` + Unanswered []string `json:"unanswered"` + ExpectedError *struct { + Code string `json:"code"` + Reason string `json:"reason"` + Field string `json:"field"` + } `json:"expected_error"` +} + +type hostConversionCase struct { + ID string `json:"id"` + Language string `json:"language"` + Kind string `json:"kind"` + Value string `json:"value"` +} + +// hostConstruction is a Go value the host refuses to convert before the +// engine sees it. The names are the corpus's closed vocabulary; the Rust +// runner checks the same list. +func hostConstruction(t *testing.T, name string) any { + t.Helper() + switch name { + case "zero_date": + return encrypt.Date{} + case "decimal_scale_29": + return encrypt.Decimal{Scale: 29} + case "timestamp_year_300000": + return time.Date(300000, 1, 1, 0, 0, 0, 0, time.UTC) + case "complex128": + return complex(1, 2) + case "map_int_key": + return map[int]string{1: "a"} + case "channel": + return make(chan int) + } + t.Fatalf("unknown host construction %q", name) + return nil +} + +// sameValue compares an opened value with the one sealed: a time.Time by +// its instant (the opened one is in UTC, the input carries the location it +// was parsed with), bytes by content (an empty slice may come back nil), a +// float by its bits (so -0 is not 0), anything else structurally. +func sameValue(got, want any) bool { + switch w := want.(type) { + case time.Time: + g, ok := got.(time.Time) + return ok && g.Equal(w) && g.Location() == time.UTC + case []byte: + g, ok := got.([]byte) + return ok && bytes.Equal(g, w) + case float32: + g, ok := got.(float32) + return ok && math.Float32bits(g) == math.Float32bits(w) + case float64: + g, ok := got.(float64) + return ok && math.Float64bits(g) == math.Float64bits(w) + } + return reflect.DeepEqual(got, want) +} + +func TestValueConformance(t *testing.T) { + var corpus struct { + Version int `json:"version"` + Profile string `json:"profile"` + RequiredCapabilities []string `json:"required_capabilities"` + Cases []valueCase `json:"cases"` + HostConversions []hostConversionCase `json:"host_conversions"` + } + readConformanceJSON(t, filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1", "values.json"), &corpus) + if corpus.Version != 1 || corpus.Profile != "record-values-v1" || len(corpus.Cases) == 0 || len(corpus.HostConversions) == 0 { + t.Fatal("unsupported or empty value corpus") + } + if !reflect.DeepEqual(corpus.RequiredCapabilities, []string{"records", "equality", "value-kinds", "null-policies"}) { + t.Fatal("unsupported capabilities") + } + ctx := context.Background() + client, err := encrypt.NewDeterministicClient(ctx, [32]byte{}) + skipMissingConformanceGuest(t, err) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + cipher := client.DefaultKeyset() + seen := map[string]bool{} + for _, c := range corpus.Cases { + if c.ID == "" || seen[c.ID] { + t.Fatalf("empty or repeated ID %q", c.ID) + } + seen[c.ID] = true + t.Run(c.ID, func(t *testing.T) { + if c.Null != "" && c.Null != "sql" && c.Null != "sealed" { + t.Fatalf("unknown null policy %q", c.Null) + } + if !record.Kind(c.Kind).Known() || c.Kind == "" || len(c.Outputs) == 0 || c.Outputs[0] != "c" { + t.Fatal("unsupported declaration") + } + isNull := c.Input.Type == "null" + if (c.Frame == nil) != isNull { + t.Fatal("a frame is frozen for every value, and for no null") + } + outputs := make([]record.Output, len(c.Outputs)) + for i, o := range c.Outputs { + outputs[i] = record.Output(o) + } + plan := &record.Plan{Context: []string{"conformance"}, Fields: []record.Field{{Name: "value", Kind: record.Kind(c.Kind), Null: record.Null(c.Null), Outputs: outputs}}} + input := conformanceValue(t, c.Input) + if c.Frame != nil { + encoded, err := vcffi.Marshal(input) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(encoded) != *c.Frame { + t.Fatalf("typed frame %x differs from the frozen %s", encoded, *c.Frame) + } + } + rows := []record.Source{{"value": input}} + if c.Companion != nil { + rows = append(rows, record.Source{"value": conformanceValue(t, *c.Companion)}) + } + sealed, err := cipher.Seal(ctx, plan, rows) + if c.ExpectedError != nil { + if c.Companion != nil || c.EqualQuery != nil { + t.Fatal("a refused case has no companion or query") + } + assertConformanceDiagnostic(t, err, c.ExpectedError.Code, c.ExpectedError.Reason, c.ExpectedError.Field) + return + } + if err != nil { + t.Fatal(err) + } + if len(sealed) != len(rows) { + t.Fatal("row count differs") + } + stored, present := sealed[0]["value"] + if present == (isNull && c.Null == "sql") { + t.Fatalf("stored=%v for a %q null", present, c.Null) + } + // Every stored term is the term a query of the value derives, + // save an index no operator answers, whose query is refused. + check := func(t *testing.T, row record.Outputs, value any, equal *conformanceInput) { + t.Helper() + if len(row.Ciphertext) == 0 || len(row.Terms) != len(c.Outputs)-1 { + t.Fatalf("stored %d terms for %v", len(row.Terms), c.Outputs[1:]) + } + for _, o := range c.Outputs[1:] { + operator := "eq" + if o == "ore" || o == "ope" { + operator = "lt" + } + term, err := cipher.QueryTerm(ctx, plan, "value", operator, value) + unanswered := false + for _, u := range c.Unanswered { + unanswered = unanswered || u == o + } + if unanswered { + assertConformanceDiagnostic(t, err, "stack_encrypt::dynamic_source", "operator_not_answered", "value") + continue + } + if err != nil || len(term.Terms) != 1 || !bytes.Equal(term.Terms[0], row.Terms[record.Output(o)]) { + t.Fatalf("%s: stored/query term mismatch: %v", o, err) + } + if o == "eq" && equal != nil { + other, err := cipher.QueryTerm(ctx, plan, "value", "eq", conformanceValue(t, *equal)) + if err != nil || len(other.Terms) != 1 || !bytes.Equal(other.Terms[0], row.Terms[record.Equality]) { + t.Fatalf("the equal spelling derives another term: %v", err) + } + } + } + } + if present { + check(t, stored, input, c.EqualQuery) + } + if c.Companion != nil { + companion := sealed[1]["value"] + check(t, companion, rows[1]["value"], nil) + if present && len(companion.Ciphertext) != len(stored.Ciphertext) { + t.Fatal("a sealed null is as long as a value") + } + } + opened, err := cipher.Open(ctx, plan, sealed) + if err != nil { + t.Fatal(err) + } + if len(opened) != len(rows) { + t.Fatal("opened row count differs") + } + for i, row := range rows { + if len(opened[i]) != 1 || !sameValue(opened[i]["value"], row["value"]) { + t.Fatalf("row %d: opened %#v, sealed %#v", i, opened[i]["value"], row["value"]) + } + } + }) + } + for _, c := range corpus.HostConversions { + if c.ID == "" || seen[c.ID] { + t.Fatalf("empty or repeated ID %q", c.ID) + } + seen[c.ID] = true + t.Run(c.ID, func(t *testing.T) { + if c.Language != "go" { + t.Skipf("a %s host conversion", c.Language) + } + plan := &record.Plan{Context: []string{"conformance"}, Fields: []record.Field{{Name: "value", Kind: record.Kind(c.Kind), Outputs: []record.Output{record.Ciphertext}}}} + value := hostConstruction(t, c.Value) + // The host refuses the conversion itself: ErrEncoding with no + // engine diagnostic, because the guest was never asked. + var diagnostic *encrypt.Diagnostic + if _, err := cipher.Seal(ctx, plan, []record.Source{{"value": value}}); !errors.Is(err, encrypt.ErrEncoding) || errors.As(err, &diagnostic) { + t.Fatalf("Seal: expected a host refusal, got %v", err) + } + if _, err := cipher.QueryTerm(ctx, plan, "value", "eq", value); !errors.Is(err, encrypt.ErrEncoding) || errors.As(err, &diagnostic) { + t.Fatalf("QueryTerm: expected a host refusal, got %v", err) + } + }) + } +} diff --git a/mise.test.toml b/mise.test.toml index 57e4acb9a..3a25a7589 100644 --- a/mise.test.toml +++ b/mise.test.toml @@ -11,6 +11,10 @@ # (cargo@0.96 needs rustc 1.93; the Ubuntu runners ship 1.92). Bump a pin # deliberately, after checking it still builds with the runners' rustc. [tools] +# EQL setup can leave a cargo-binstall shim in the shared mise cache. Select +# its prebuilt installer here too: an unselected shim is still found on PATH +# by the cargo backend, then fails before it can install any test tools. +"cargo-binstall" = "1.26.2" "cargo:cargo-nextest" = "0.9.146" # Coverage β†’ CRAP (Change Risk Anti-Patterns) metric tooling. `cargo llvm-cov` # produces an LCOV file that `cargo crap` scores; see `mise run crap:stack-auth`. diff --git a/mise.toml b/mise.toml index 9e174844d..265975723 100644 --- a/mise.toml +++ b/mise.toml @@ -382,8 +382,8 @@ run = """ #!/usr/bin/env bash set -euo pipefail : "${EQL_TEST_DATABASE_URL:?set EQL_TEST_DATABASE_URL to a libpq connection string for a disposable PostgreSQL}" -mise -C packages/eql run build -psql "$EQL_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -q -f packages/eql/release/cipherstash-encrypt-v4.sql +# The conformance task owns SQL setup and the JSON PostgreSQL tests. +mise run test:conformance:eql:postgres cd languages/golang # The PostgreSQL tests, named once: the -run pattern and the check below # are both derived from this list. @@ -391,10 +391,6 @@ TESTS=( TestEQLTypesRoundTripAndQueryInPostgres TestEQLOrderMatchesPlaintextInPostgres TestQueriesRunAgainstPostgres - TestJSONDocumentsQueryAndReadInPostgres - TestJSONTypedDocumentsInPostgres - TestJSONElementMatchInPostgres - TestJSONPredicateMatchInPostgres ) pattern="^($(IFS='|'; echo "${TESTS[*]}"))$" # The tests skip without DATABASE_URL and without their guest builds; -v and @@ -433,3 +429,22 @@ cd languages/golang # if set, else the developer profile. See encrypt/example/README.md. CGO_ENABLED=0 go run ./encrypt/example """ + +[tasks."test:conformance:eql"] +description = "Required EQL/JSON profiles over native Rust and Go (build both deterministic guests first)" +run = "node scripts/stack-eql-conformance.mjs fast" + +[tasks."test:conformance:eql:postgres"] +description = "Build and install EQL v4 into disposable EQL_TEST_DATABASE_URL, then run required Rust/Go JSON PostgreSQL oracles" +run = """ +#!/usr/bin/env bash +set -euo pipefail +: "${EQL_TEST_DATABASE_URL:?set a disposable PostgreSQL database connection}" +mise -C packages/eql run clean +mise -C packages/eql run build +node scripts/stack-eql-conformance.mjs postgres +""" + +[tasks."test:conformance:lifecycle"] +description = "Required native/Go lifecycle, cleanup and key-I/O evidence (Unix; build the normal and deterministic guests first)" +run = "node scripts/stack-lifecycle-conformance.mjs" diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index a71c1d4e3..e988c4cbd 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -640,7 +640,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -1223,7 +1223,7 @@ dependencies = [ "stack-kms", "tokio", "uuid", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", ] [[package]] @@ -2667,12 +2667,12 @@ dependencies = [ "rand 0.10.1", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.8.1", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -4148,8 +4148,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -4176,15 +4176,15 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.8.1", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.8.1", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -4219,8 +4219,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -4975,16 +4975,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -5017,9 +5017,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "chrono", @@ -5027,10 +5027,10 @@ dependencies = [ "rust_decimal", "serde", "thiserror 2.0.20", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -5047,9 +5047,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -5058,12 +5058,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "3a65349bb75a9ecc9fd4f057e5a6aac964bea68e57d03d55118a22455002a424" dependencies = [ "serde", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -5079,12 +5080,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -5117,72 +5118,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "e4a244886bff4915e15000a69f5a10f14410cabb30c445e3d585057c06a9ce1a" dependencies = [ "hmac 0.13.0", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "d820d507ca7cce81772e38c1277b26acc422a4fd99402e6d9d49c412a5a135f0" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "6b80bf6789c1e2e1c192a255a9e2f6c3e7ffbce2fcb61d60edecee6ffc2f71ef" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "e86cfcbfb4089f5bf334723cbf106f7d0d4709abf0ad12aaf49d6cf89f3e78de" dependencies = [ "mutants", "thiserror 2.0.20", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -5219,9 +5220,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -5230,7 +5231,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -5258,9 +5259,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -5299,16 +5300,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -5336,9 +5337,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -5379,17 +5380,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] diff --git a/packages/eql/tests/encryption/Cargo.toml b/packages/eql/tests/encryption/Cargo.toml index 787f19abc..dd0dc9d42 100644 --- a/packages/eql/tests/encryption/Cargo.toml +++ b/packages/eql/tests/encryption/Cargo.toml @@ -31,7 +31,7 @@ stack-encrypt = { path = "../../../stack-encrypt", default-features = false, fea # `plaintext::Sink`, for a plaintext type with its own `Encrypt` impl; and # chrono's and rust_decimal's own sources, which seal the leaf a date, # timestamp or decimal from any other producer seals. -vitaminc-aead = { version = "0.8.0", features = ["chrono", "rust_decimal"] } +vitaminc-aead = { version = "0.8.1", features = ["chrono", "rust_decimal"] } chrono = { version = "0.4.44", default-features = false, features = ["std"] } rust_decimal = { version = "1", default-features = false, features = ["std"] } stack-kms = { path = "../../../stack-kms", default-features = false, features = ["test-support"] } diff --git a/packages/eql/tests/encryption/tests/json_tree_data_plan.rs b/packages/eql/tests/encryption/tests/json_tree_data_plan.rs index 77fcb1265..ba7d38262 100644 --- a/packages/eql/tests/encryption/tests/json_tree_data_plan.rs +++ b/packages/eql/tests/encryption/tests/json_tree_data_plan.rs @@ -131,18 +131,12 @@ fn frame(value: T) -> ValueFrame { /// The documents a batch carries, as values. fn documents() -> Vec { - vec![ - json!({"name": "Alice", "age": 34, "visits": [{"at": 3, "where": "x"}, {"at": 1}], "tags": ["a", "b"]}), - json!({"name": "Bob", "age": 7.5, "nested": {"deep": [[1, 2], {}]}, "none": null}), - json!([1, "two", {"three": 3}]), - json!("a scalar root"), - json!(18446744073709551615u64), - json!(-9223372036854775808i64), - json!(-926_809.505_100_808_3), - Value::Null, - json!({}), - json!([]), - ] + conformance_cases()["documents"] + .as_array() + .unwrap() + .iter() + .map(|text| serde_json::from_str(text.as_str().unwrap()).unwrap()) + .collect() } /// The EQL value a sealed row stores for `profile`, parsed. @@ -443,12 +437,9 @@ async fn text_that_is_not_a_document_is_refused_before_any_key() { let (cipher, calls) = common::cipher().await; let keyset = cipher.default_keyset(); let plan = plan(); - for (text, code) in [ - (r#"{"secret": 1, "secret": 2}"#, "json_duplicate_key"), - ("99999999999999999999", "json_integer_range"), - (r#"{"secret": "#, "json_syntax"), - ("", "json_syntax"), - ] { + for case in conformance_cases()["invalid_documents"].as_array().unwrap() { + let text = case["text"].as_str().unwrap(); + let code = case["reason"].as_str().unwrap(); let source = BTreeMap::from([("profile", text)]); let error = match record::encrypt_with(&keyset, frame(&source), &plan, &Eql) { Err(error) => error, @@ -1154,3 +1145,12 @@ async fn the_engine_and_the_table_agree_on_which_targets_take_json_options() { assert_eq!(engine, !refused, "{}", target.name); } } + +fn conformance_cases() -> Value { + let cases: Value = serde_json::from_str(include_str!( + "../../../../../conformance/stack-encrypt/v1/eql-json.json" + )) + .unwrap(); + assert_eq!(cases["version"], 1); + cases +} diff --git a/packages/stack-auth/fuzz/Cargo.lock b/packages/stack-auth/fuzz/Cargo.lock index b24b8daa9..45485b8a1 100644 --- a/packages/stack-auth/fuzz/Cargo.lock +++ b/packages/stack-auth/fuzz/Cargo.lock @@ -2626,8 +2626,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -3181,16 +3181,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -3210,18 +3210,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -3238,9 +3238,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -3259,12 +3259,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -3283,15 +3283,15 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -3314,9 +3314,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -3325,7 +3325,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -3342,9 +3342,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3369,16 +3369,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -3395,9 +3395,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -3422,17 +3422,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 74b5d3df6..483afbf1a 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- **`OpaqueDecode` no longer has vitaminc's `Decrypt` as a supertrait.** The + trait is sealed, so this changes nothing for a caller naming a type that + implements it; generic code bounded on `P: OpaqueDecode` that also used + `P::sink()` or another `Decrypt` method adds a `P: Decrypt` bound. The + capability moved into the sealed trait so that `OpaqueBlob`, which + opens through a bytes leaf and decodes after, can implement it. - Repeated plan-chain `.extend(..)` calls now append parts in order, matching Go's `Cipher.Extend`, instead of replacing earlier extensions. `.extend(())` adds nothing, and grouped `NonEmpty` parts retain their grouping. @@ -18,6 +24,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the final extension alone for both reads and queries of old data, or re-encrypt and re-index it under the appended context. This does not migrate stored data. +### Added + +- **`OpaqueBlob`: encrypt a whole value as one opaque `Ciphertext`** + (#1484). `keyset.encrypt(OpaqueBlob::new(value))` encodes any + `Serialize + Zeroize` value with Vitamin C's versioned storage profile + (`vitaminc_aead_frame::storage`, version 1: a `VCS` header and version + byte inside the plaintext, explicit `Some` markers, typed map keys, a + frozen specification and golden corpus) and seals the encoding as one + bytes leaf of the unchanged `SealedValue` v1 envelope, through the same + path as a scalar: one data key, one ciphertext, no new KMS request shape. + `keyset.decrypt::>(ct)` authenticates the leaf, then reads + it back into any `DeserializeOwned + Zeroize` type; `into_inner()` takes + the value. It is the Go SDK's opaque-struct format (`vcffi.MarshalStorage`), + so a Go-written opaque column opens in Rust and a Rust-written one in Go + (fixed vectors in both test suites). There is still no `Serialize` + fallback: a struct handed to `encrypt` without the wrapper does not + compile (`tests/ui/opaque_blob_requires_the_wrapper.rs`), and neither + does an `OpaqueBlob` of a type that is not `Zeroize`. `OpaqueBlob::encode` + and `decode` expose the codec itself, `blob::HEADER` and `blob::VERSION` + the discriminator. The `blob` module docs give the supported types, the + schema-evolution rules (serde's), what the ciphertext's length reveals (the + encoded length plus a constant: no padding, no constant-time claim) and + what is wiped; ADR-0011 records the decision. +- **`Error::Blob(BlobError)`**, transparent, one variant and code per class + of refusal: `blob_unwritable` (the value cannot be encoded; raised before + any key is requested), and after the leaf authenticated, `blob_not_a_blob`, + `blob_unsupported_version` (a later storage version: upgrade the reader), + `blob_malformed` and `blob_mismatch` (a well-formed blob of another shape or + type). Every decrypt-side `Blob` error means the ciphertext was authentic; + tampering and a wrong context are still `Aead`, and a blob opened as a + scalar or a scalar as a blob is `OpenedType`. No message or payload names a + value; a mismatch's payload names the declared field. +- **`Zeroizing` as the opt-in drop-guarded result** of the scalar API: + `OpaqueDecode` is implemented for `Zeroizing

` of any decodable `P`, so + `keyset.decrypt::>>(ct)` (or + `Zeroizing`) returns the opened value wrapped and wiped on drop. A + plain `P` is still an ordinary owned value that nothing wipes on drop; + `Zeroize` is the bound, not a guard. `OpaqueEncode` is implemented for + `Zeroizing` too. +- **`From for Ciphertext`, and back.** The bytes are the same: + a `CipherText::Single` leaf from `encrypt_tree` opens through the owned + API, and an owned `Ciphertext` hands its leaf to the structural API. +- **The owned source is wiped on an encoding refusal** as on success, before + the pending is returned and with no key requested; a context no request + can carry is refused before the value is encoded, as a scalar's is; the + owned blob buffer is `Protected` while it is planned and on decrypt while + it is read. The heap observer covers blob encrypt, decrypt and a refused + shape, checks those paths for marked blocks leaked rather than freed (an + opt-in `watched_for_leaks`, with its own negative control), and pins as a + negative control the one stated limit: a derived `Deserialize` that built + some fields and refuses a later one drops those fields unwiped, because no + `T` existed to wipe. + +### Changed + +- The vitaminc crates move from 0.8.0 to 0.8.1, the first release with the + storage profile. Nothing 0.8.0 wrote or read changes: the leaf format, the + transport frame and every existing ciphertext are unaffected. + `vitaminc-aead-frame` is now built unconditionally (with `serde`), where it + was behind the `dynamic` feature; that feature adds no dependency now. + ## [0.3.0] - 2026-10-27 ### Breaking diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 27e75f80b..134cfc8a3 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -53,9 +53,12 @@ rand = { version = "0.10.1", default-features = false } # `future::join` and `join_all`: a pending's dispatch loads the keysets its # JSON documents need beside the data-key retrieval, in one await. futures = { version = "0.3.25", default-features = false } -# `dynamic` only: serde over the transport frame, which reads a binding's +# Two things: the storage profile (`storage`, behind `serde`) that +# `OpaqueBlob` encodes a whole value with before sealing it as one leaf, +# the same versioned bytes the Go SDK writes through `vcffi.MarshalStorage`; +# and, for `dynamic`, serde over the transport frame, which reads a binding's # control data (statistics, options, a plan) into typed messages. -vitaminc-aead-frame = { workspace = true, optional = true, features = ["serde"] } +vitaminc-aead-frame = { workspace = true, features = ["serde"] } serde = { workspace = true } # The order terms' serde: lowercase hex in JSON, raw bytes in a binary # format, through serdect's constant-time hex. @@ -109,8 +112,10 @@ default = ["http"] http = ["dep:stack-auth", "stack-auth/http", "stack-kms/http"] # `stack_encrypt::dynamic`: encrypt and decrypt values whose types are known # only at runtime, as an FFI binding's are. Off by default β€” it is only of -# use to a binding author. Its transport frames are vitaminc-aead's own. -dynamic = ["dep:vitaminc-aead-frame"] +# use to a binding author. Its transport frames are vitaminc-aead's own, and +# the crate they come from is always built (the storage profile `OpaqueBlob` +# uses lives there), so the feature adds no dependency. +dynamic = [] # Conversions between chrono's `NaiveDate` / `DateTime` and # `value::Date` / `value::Timestamp`. chrono = ["dep:chrono", "vitaminc-aead/chrono"] @@ -180,3 +185,7 @@ all-features = true [[example]] name = "scalar" required-features = ["http"] + +[[example]] +name = "opaque_blob" +required-features = ["http"] diff --git a/packages/stack-encrypt/docs/adr/0011-a-whole-value-is-one-storage-blob-sealed-as-one-leaf.md b/packages/stack-encrypt/docs/adr/0011-a-whole-value-is-one-storage-blob-sealed-as-one-leaf.md new file mode 100644 index 000000000..57d11a2de --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0011-a-whole-value-is-one-storage-blob-sealed-as-one-leaf.md @@ -0,0 +1,129 @@ +--- +status: accepted +date: 2026-11-02 +extends: ADR-0003, ADR-0010 +--- + +# A whole value is one storage blob, sealed as one leaf, and only when named + +The owned scalar API (`KeysetCipher::encrypt`) seals one scalar as one +`Ciphertext` and refuses everything else: there is no `Serialize` fallback, +so a struct handed to `encrypt` does not compile. That leaves a real need +unmet. An application often has a value it wants stored as **one** +ciphertext, under one data key, with nothing inside it searchable: a card, +a profile, a settings object. The structural API seals a struct as one leaf +per field, which is a different thing (one key per field, a tree of +ciphertexts, a record shape to store), and a searchable JSON document is a +third. This ADR records how a whole value becomes one ciphertext. + +## Decision + +**`OpaqueBlob` is the one way to encrypt a composite value as one +ciphertext, and it is explicit.** `encrypt(OpaqueBlob::new(value))` and +`decrypt::>(ct)`. The wrapper implements the sealed +`OpaqueEncode` / `OpaqueDecode` capabilities; `T` does not, and no blanket +impl over `Serialize` is added. A reviewer can find every whole-value +ciphertext in a codebase by searching for the name. + +**The encoding is Vitamin C's storage profile, version 1, and nothing +else.** `vitaminc_aead_frame::storage` is the library-owned codec: a +self-describing binary encoding built from the sealed leaf's own scalar +tags and the transport frame's containers, with a header (`VCS` and a +version byte), a marker for a present `Option`, typed map keys and a frozen +specification pinned by a golden corpus. It is the format the Go SDK +already writes and reads for its opaque structs (`vcffi.MarshalStorage`, +`UnmarshalStorage`), so a Go-written opaque column and a Rust `OpaqueBlob` +are one stored format, and this crate adds no second wire format. The +transport serde codec in the same crate is not used: it is ephemeral by +design and carries no compatibility commitment. + +**The encoded blob is sealed as one bytes leaf of the ordinary envelope, +through the scalar path.** No new envelope, no new KMS request shape, no +new AAD: the ciphertext is exactly what sealing a `Vec` of the encoded +bytes produces, and `SealedValue` v1 is unchanged. The header is inside the +plaintext, so the discriminator and the version are encrypted and +authenticated with the value, and nothing about a blob is visible before +decryption. + +**Decryption authenticates before it decodes, and refusals are told +apart.** The leaf opens first; only then does the profile's reader see the +bytes, and it validates the whole blob before `T`'s `Deserialize` sees any +of it. A leaf that does not open is `Error::Aead`. A leaf that opens but is +not a bytes leaf is `Error::OpenedType`. Bytes that are not a blob this +type reads are `Error::Blob`, one `BlobError` variant and one code per +class: not a blob, an unsupported (later) version, malformed, or a +well-formed blob of another shape. Every `Error::Blob` is a refusal of an +authentic ciphertext, consistent with ADR-0010: the data was written as +something else, not tampered with. + +**The version is the profile's, not the application's.** `blob::VERSION` +names the byte grammar and the serde mapping together; version 1 is frozen +and a reader reports a later version apart from corruption. An +application's own schema evolves through `T` and serde's attributes +(`default`, `alias`, `Option`), and an application that must tell its own +versions apart carries that in the value. + +**Ownership and cleanup follow the scalar API's contract.** `T: Zeroize` is +required to encrypt and to decrypt. On encrypt the owned source is guarded, +encoded into a buffer the profile returns `Protected`, planned, and wiped +before the pending is returned; an encoding refusal wipes it too and +requests no key. On decrypt the opened bytes are held `Protected` until the +reader returns. The returned `OpaqueBlob` is an ordinary owned value +and is **not wiped on drop**; `Zeroizing>` is the opt-in +drop-guarded result, implemented for `Zeroizing` of any decodable type +rather than as a new adapter. + +## What is not claimed + +- **Constant time.** Neither serde's impls nor the profile's writer and + reader are constant time, and `OpaqueBlob` does not make them so. The + cryptography is unchanged; the encoding runs on plaintext the caller + already holds. +- **Length hiding.** AES-GCM keeps the plaintext's length and the profile + adds no padding, so the ciphertext's length is the encoded length plus a + constant, and the encoded length depends on string lengths, element + counts and whether each `Option` is present. The same disclosure a + `String` scalar makes; padding is the application's. +- **Complete erasure.** A derived `Deserialize` that built some fields and + refuses a later one drops those fields unwiped, because no `T` existed to + wipe; `tests/heap_hygiene.rs` holds this as a negative control rather + than hiding it. A `Copy` value moved out of leaves its bytes behind, and + compiler temporaries are not wiped. The promise covers the owned source, + the encoded buffer, the opened buffer and the library's copies. +- **Searchability or partial reads.** A blob is opaque. Nothing inside it + can be queried or read on its own; that is what the structural API and + the JSON index are for. + +## Alternatives considered + +- **A blanket `impl OpaqueEncode for T: Serialize`.** Rejected: it decides + the stored bytes of every composite silently, lets a `Vec` or a + struct become one ciphertext by accident, and cannot be told apart from + the structural API at a call site. +- **The transport serde codec.** Rejected: it is lockstep transport with + no compatibility commitment, and it collapses `Some(None)` and keeps no + map-key kinds. The storage profile exists because of exactly these gaps. +- **MessagePack or another general format.** Rejected upstream, where the + profile's specification records why: no header or version, options that + collapse, struct layout left to each library, repeated keys and integer + widths left to the reader, and plaintext copies left in buffers. +- **A `Decrypt` impl so a blob can be a field of a structural record.** + Deferred: the sink's error type cannot carry the blob's refusal classes, + so it would report every blob error as a shape mismatch. The owned API + is the contract this ADR settles; a field-level blob is separate work. + +## Consequences + +- `OpaqueEncode` and `OpaqueDecode` stay sealed and gain `OpaqueBlob` + and `Zeroizing<_>` implementors. `OpaqueDecode` no longer requires + vitaminc's `Decrypt`, since a blob opens through a bytes leaf and decodes + after; the sealed `open` is the capability instead. +- `Error` gains `Blob(BlobError)`, transparent, with five codes under + `stack_encrypt::blob_*`. +- The crate depends on `vitaminc-aead-frame` with its `serde` feature + unconditionally (0.8.1, the first release with the profile); the + `dynamic` feature no longer gates that dependency. +- The conformance coverage map enrolls the fixed blob vectors, the + Rust/Go storage exchange and the heap observations under the existing + requirements rather than a new profile; a fresh sealed exchange with Go + (Go seals, Rust opens, and back) is still open and listed as such. diff --git a/packages/stack-encrypt/examples/opaque_blob.rs b/packages/stack-encrypt/examples/opaque_blob.rs new file mode 100644 index 000000000..42ae07503 --- /dev/null +++ b/packages/stack-encrypt/examples/opaque_blob.rs @@ -0,0 +1,107 @@ +//! Encrypt a whole struct as one ciphertext with `OpaqueBlob`. +//! +//! Run with ZeroKMS credentials: `cargo run -p stack-encrypt --example opaque_blob`. +//! Authenticate with `npx stash auth login`, or configure the `CS_*` credentials +//! described in the crate's quick start. Initialization fails without credentials. +use serde::{Deserialize, Serialize}; +use stack_encrypt::{BlobError, Ciphertext, Error, OpaqueBlob, StackCipher}; +use zeroize::{Zeroize, Zeroizing}; + +/// An ordinary application struct. `Serialize` and `Deserialize` give it a +/// storage encoding; `Zeroize` lets the library wipe the owned source once +/// it has been encoded, and lets a caller wipe a decrypted copy. +#[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] +struct Profile { + name: String, + age: u32, + tags: Vec, + nickname: Option, +} + +/// The same data in a later shape: a field added with a default and one +/// renamed under an alias. An old blob still reads into it. +#[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] +struct ProfileV2 { + name: String, + age: u32, + #[serde(alias = "tags")] + labels: Vec, + nickname: Option, + #[serde(default)] + verified: bool, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let cipher = StackCipher::new().await?; + let keyset = cipher.default_keyset(); + + let profile = Profile { + name: "Ada Lovelace".into(), + age: 36, + tags: vec!["maths".into(), "engines".into()], + nickname: Some("ada".into()), + }; + + // One ciphertext for the whole value: one data key, one leaf. The owned + // struct is consumed and wiped before the request is awaited. + let encrypted = keyset + .encrypt_with_context(OpaqueBlob::new(profile), ("users", "profile")) + .await?; + + // Store and restore it like any other ciphertext. + let stored = encrypted.to_bytes(); + let restored = Ciphertext::from_bytes(&stored)?; + + // Decrypt to the wrapper and take the value out. The result is an + // ordinary owned value: nothing wipes it on drop. + let opened: OpaqueBlob = keyset + .decrypt_with_context(restored.clone(), ("users", "profile")) + .await?; + let profile = opened.into_inner(); + assert_eq!(profile.name, "Ada Lovelace"); + + // Or decrypt to `Zeroizing>`, the opt-in drop guard: the + // value is wiped when `guarded` goes out of scope. + let guarded: Zeroizing> = keyset + .decrypt_with_context(restored.clone(), ("users", "profile")) + .await?; + assert_eq!(guarded.get().age, 36); + drop(guarded); + + // Schema evolution is serde's: the blob written as a `Profile` reads + // into the later `ProfileV2`. + let later: OpaqueBlob = keyset + .decrypt_with_context(restored.clone(), ("users", "profile")) + .await?; + assert_eq!(later.get().labels, ["maths", "engines"]); + assert!(!later.get().verified); + + // The errors tell an authentic blob of another shape from tampering. + // A type the blob does not fit is a mismatch after authentication... + #[derive(Deserialize, Zeroize)] + struct Other { + #[allow(dead_code)] + id: u64, + } + match keyset + .decrypt_with_context::>(restored.clone(), ("users", "profile")) + .await + { + Err(Error::Blob(BlobError::Mismatch(_))) => {} + other => panic!("expected a blob mismatch, got {other:?}"), + } + // ...while the wrong context never reaches the blob at all. + match keyset + .decrypt_with_context::>(restored, ("users", "other")) + .await + { + // ZeroKMS refuses the key under the wrong context; a key source + // that ignores descriptors would fail the AEAD instead. + Err(Error::Kms(_) | Error::Aead) => {} + other => panic!("expected a refusal before decoding, got {other:?}"), + } + + println!("encrypted, stored, reopened and evolved one opaque blob"); + Ok(()) +} diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index a4dd39004..90e55cef4 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -375,7 +375,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -1487,12 +1487,12 @@ dependencies = [ "rand 0.10.1", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.8.1", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2066,8 +2066,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -2092,15 +2092,15 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.8.1", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.8.1", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2122,8 +2122,8 @@ dependencies = [ "serde_json", "stack-encrypt", "uuid", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2148,8 +2148,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -2594,16 +2594,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -2623,18 +2623,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2651,9 +2651,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -2662,12 +2662,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "3a65349bb75a9ecc9fd4f057e5a6aac964bea68e57d03d55118a22455002a424" dependencies = [ "serde", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2683,12 +2684,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2707,72 +2708,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "e4a244886bff4915e15000a69f5a10f14410cabb30c445e3d585057c06a9ce1a" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "d820d507ca7cce81772e38c1277b26acc422a4fd99402e6d9d49c412a5a135f0" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "6b80bf6789c1e2e1c192a255a9e2f6c3e7ffbce2fcb61d60edecee6ffc2f71ef" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "e86cfcbfb4089f5bf334723cbf106f7d0d4709abf0ad12aaf49d6cf89f3e78de" dependencies = [ "mutants", "thiserror 2.0.18", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2794,9 +2795,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -2805,7 +2806,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -2822,9 +2823,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2849,16 +2850,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -2875,9 +2876,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -2902,17 +2903,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] diff --git a/packages/stack-encrypt/fuzz/Cargo.toml b/packages/stack-encrypt/fuzz/Cargo.toml index 8b47f231a..e3f8722ee 100644 --- a/packages/stack-encrypt/fuzz/Cargo.toml +++ b/packages/stack-encrypt/fuzz/Cargo.toml @@ -17,9 +17,9 @@ cargo-fuzz = true [dependencies] # `Controlled`, to take a passthrough node's frame out of its wrapper: the # same line stack-encrypt builds on. -vitaminc-protected = "0.8.0" +vitaminc-protected = "0.8.1" # The sinks the record harness writes its plans and passthroughs through. -vitaminc-aead = "0.8.0" +vitaminc-aead = "0.8.1" libfuzzer-sys = "0.4" # Structure-aware inputs for `check_record` and `plan_build`: the mirror # types derive `Arbitrary`, so the fuzzer mutates trees and plans, not bytes. diff --git a/packages/stack-encrypt/src/blob.rs b/packages/stack-encrypt/src/blob.rs new file mode 100644 index 000000000..1c9e2389b --- /dev/null +++ b/packages/stack-encrypt/src/blob.rs @@ -0,0 +1,619 @@ +//! A whole value as one ciphertext: [`OpaqueBlob`]. +//! +//! The scalar API ([`KeysetCipher::encrypt`]) seals one scalar as one +//! [`Ciphertext`], and deliberately refuses everything else: a struct, a +//! `Vec`, a map. There is no `Serialize` fallback, because a fallback would +//! decide the stored bytes of every composite silently. `OpaqueBlob` is +//! the explicit way to say "encrypt this whole value as one ciphertext": +//! +//! ``` +//! use serde::{Deserialize, Serialize}; +//! use stack_encrypt::{OpaqueBlob, StackCipher}; +//! use zeroize::Zeroize; +//! +//! #[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] +//! struct Profile { +//! name: String, +//! age: u32, +//! tags: Vec, +//! nickname: Option, +//! } +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder() +//! .kms(stack_encrypt::kms::FakeDataKeySource::new()) +//! .init() +//! .await?; +//! let keyset = cipher.default_keyset(); +//! +//! let profile = Profile { +//! name: "Ada Lovelace".into(), +//! age: 36, +//! tags: vec!["maths".into(), "engines".into()], +//! nickname: Some("ada".into()), +//! }; +//! // One ciphertext, one data key, one leaf: `encrypt` consumes the value +//! // and wipes it before the pending request is returned. +//! let ciphertext = keyset +//! .encrypt_with_context(OpaqueBlob::new(profile), ("users", "profile")) +//! .await?; +//! +//! let opened: OpaqueBlob = keyset +//! .decrypt_with_context(ciphertext, ("users", "profile")) +//! .await?; +//! assert_eq!(opened.get().name, "Ada Lovelace"); +//! assert_eq!(opened.into_inner().tags.len(), 2); +//! # Ok::<(), stack_encrypt::Error>(()) +//! # }).unwrap(); +//! ``` +//! +//! What this is not: it is not the structural API, which seals a struct as +//! a tree of leaves, one data key and one ciphertext per field +//! ([`KeysetCipher::encrypt_tree`], the derives); it is not a batch, which +//! is many values and many ciphertexts in one request +//! ([`Pending::all`](crate::Pending::all)); and it is not a searchable +//! document ([`json`](crate::json)). A blob is one opaque value: nothing +//! inside it can be queried, and it is read back whole or not at all. +//! +//! # The codec +//! +//! The value is encoded with Vitamin C's **storage profile**, version 1 +//! (`vitaminc_aead_frame::storage`), and the encoded bytes are sealed as +//! one **bytes leaf** of the ordinary [`SealedValue`] v1 envelope. Nothing +//! else is new: no second envelope, no new key request, no new AAD. The +//! encoded blob is what the ciphertext would hold if the caller had sealed +//! a `Vec` holding those bytes. +//! +//! The profile is a self-describing binary encoding built from the sealed +//! leaf's own scalar tags and the transport frame's containers, with four +//! things added for bytes that are kept: a header naming the profile and +//! its version, a marker for a present `Option`, map keys that keep their +//! kind, and a compatibility commitment. Its specification is normative and +//! lives with the crate that implements it (`STORAGE.md` in +//! `vitaminc-aead-frame`), pinned by a golden corpus every implementation +//! must pass. The Go SDK writes and reads the same profile through `vcffi`: +//! a Go opaque struct and a Rust `OpaqueBlob` are one stored format. +//! +//! Every blob starts with [`HEADER`]: the ASCII bytes `VCS`, then the +//! version byte [`VERSION`]. The header is inside the plaintext, so it is +//! encrypted with the value and authenticated by the leaf's AEAD tag: it is +//! the authenticated discriminator that tells a blob from any other bytes +//! leaf, and its version from any later one. Nothing about a blob, its +//! version included, is visible before decryption. +//! +//! ## Supported types +//! +//! `T` is any `serde::Serialize` type to encrypt and any +//! `serde::de::DeserializeOwned` type to decrypt, in both cases also +//! [`Zeroize`] (see [Cleanup](#cleanup)). The mapping is serde's data +//! model, with these rules: +//! +//! | serde | blob node | +//! |------------------------------------|-----------------------------------------------------| +//! | `bool` | bool | +//! | `i8`…`i128`, `u8`…`u128` | an integer of that exact width (`u8` is admitted) | +//! | `f32`, `f64` | a float of that width, by its bits (NaN kept) | +//! | `char`, `&str`, `String` | text (UTF-8) | +//! | bytes (`serde_bytes`) | bytes; a `Vec` without it is an array of `u8` | +//! | `()`, unit struct, `None` | null | +//! | `Some(v)` | a presence marker, then `v` | +//! | sequence, tuple, tuple struct | array | +//! | struct | object, by field name | +//! | map | map; keys must be integers, text or bytes | +//! | enum | externally tagged: a unit variant is its name, any other an object of one entry | +//! +//! Refused when writing ([`BlobError::Unwritable`]): nesting deeper than +//! 128 levels, text or bytes longer than `u32::MAX`, a map key of another +//! kind, a repeated key, a container whose declared length is not the +//! number of children it wrote, or a value its own `Serialize` refused. +//! The profile carries plain scalars only: the logical types the sealed +//! leaf has (a date, a timestamp, a decimal, a UUID) are carried as what +//! their serde impls write (text, usually), not as those leaf kinds. +//! +//! Blobs are not canonical. Equal values of different types write +//! different bytes (`1u8` and `1u32`), a `HashMap` writes in its iteration +//! order, and a reader accepts forms a writer does not produce. Compare +//! values, not blobs. +//! +//! ## Versioning and schema evolution +//! +//! [`VERSION`] names the byte grammar and the serde mapping together. It is +//! the profile's version, not the application's, and version 1 is frozen: +//! no release changes what a version 1 blob reads as. A reader meeting a +//! version it does not read reports [`BlobError::UnsupportedVersion`], +//! distinct from a corrupt blob, so "upgrade the reader" and "the data is +//! damaged" are different errors. A new version will be introduced in two +//! steps, readers first, writers later, and this crate's changelog will say +//! which version a release writes. +//! +//! The application's own schema evolves through the type, and serde's +//! attributes govern what an old blob does against a new `T`: +//! +//! | Change to `T` | An old blob | +//! |---------------------------------------------|------------------------------------------------------| +//! | Add a field with `#[serde(default)]` | reads, with the default | +//! | Add a field without a default | is refused: [`BlobError::Mismatch`] | +//! | Remove a field | reads, unless `T` denies unknown fields | +//! | Rename a field | reads with `#[serde(alias = "…")]` | +//! | `F` to `Option` | reads as `Some` | +//! | `Option` to `F` | is refused if the blob held `Some` | +//! | Add an enum variant | reads; an older reader refuses the new variant | +//! | Widen an integer | reads; narrowing reads only values that fit | +//! +//! An application that needs to tell its own versions apart carries that in +//! the value (a version field, or an enum of versions). The blob header +//! never changes because `T` did. +//! +//! ## Decode errors, and when they can occur +//! +//! Decryption **authenticates before it decodes**. The leaf opens first, +//! under its key and context; only then does the profile's reader see the +//! bytes. So the errors tell three things apart: +//! +//! - [`Error::Aead`]: the leaf did not open. Wrong key, wrong context, or +//! altered bytes; the blob was never looked at. +//! - [`Error::OpenedType`]: the leaf opened but is not a bytes leaf: it was +//! sealed as a `String` or an integer, say, not as a blob. +//! - [`Error::Blob`]: the leaf opened as bytes and they are not a blob this +//! type reads: not a blob at all ([`BlobError::NotABlob`]), a later +//! version ([`BlobError::UnsupportedVersion`]), a blob that breaks a rule +//! of version 1 ([`BlobError::Malformed`]), or a well-formed blob that is +//! not a `T` ([`BlobError::Mismatch`]). +//! +//! All three are refusals of an authentic ciphertext, so an `Error::Blob` +//! never says the data was tampered with; it says the data was written as +//! something else. The whole blob is validated before `T`'s `Deserialize` +//! sees any of it: every length is checked against the end of the blob, so +//! a forged length allocates nothing, and a `Deserialize` impl never builds +//! part of a value from a malformed blob. No refusal names a value or a map +//! key; a mismatch names the declared field it is about. +//! +//! ## What the ciphertext reveals +//! +//! The leaf is AES-256-GCM, which keeps the plaintext's length, and the +//! profile adds no padding. A blob ciphertext is therefore the encoded +//! length plus a fixed overhead, and the encoded length is a function of +//! the value: the length of each string, the number of elements in each +//! sequence and map, and whether each `Option` is `Some` (the marker and +//! the value) or `None` (one null byte). Two profiles that differ only in +//! a nickname being present seal to ciphertexts of different lengths. This +//! is the same disclosure a `String` scalar makes of its length; a value +//! whose size must not be visible needs padding the application adds, as a +//! fixed-length field of its own. +//! +//! Neither serde's derived impls nor the profile's writer and reader are +//! constant time, and nothing here claims otherwise. Encoding takes time +//! that depends on the value, as serializing any value does. The +//! cryptography is Vitamin C's and is unchanged; what this module adds runs +//! before the seal and after the open, on plaintext the caller already +//! holds. +//! +//! ## Cleanup +//! +//! On encrypt, the owned `OpaqueBlob` is guarded in [`Protected`] by the +//! owned conversion before anything runs, encoded through a borrow into a +//! buffer the profile's writer returns `Protected` (it grows into fresh +//! allocations and wipes the old ones, writes a `Display` value straight +//! into the blob, and wipes what it wrote if writing fails), and that +//! buffer is planned into the leaf and dropped, wiped, before `encrypt` +//! returns its pending. The source is wiped through `T: Zeroize` at the +//! same point, on success and on an encoding refusal alike. Nothing of the +//! plaintext travels in the pending. +//! +//! On decrypt, the opened bytes are held `Protected` while the reader runs, +//! and wiped when it returns, whether it returned a value or a refusal. The +//! reader borrows text and bytes from that buffer rather than copying them. +//! The value it returns is an ordinary owned `OpaqueBlob`: the caller +//! owns it, and **nothing wipes it on drop**. `T: Zeroize` is the bound +//! that makes wiping possible, not a guard that performs it. To have the +//! result wiped when it is dropped, decrypt to `Zeroizing>` +//! ([`OpaqueDecode`] is implemented for `Zeroizing` +//! of any decodable type), or give `T` a `Drop` of its own. +//! +//! One limit is a property of serde, and is stated rather than solved: a +//! derived `Deserialize` that has read some fields and then refuses a later +//! one drops the fields it built as ordinary values, without `T::zeroize`, +//! because no `T` ever existed to wipe. The profile's up-front validation +//! means this happens only for a well-formed blob of another shape +//! ([`BlobError::Mismatch`]), never for a malformed one, and the bytes +//! those fields were copied from are wiped with the buffer. A type whose +//! fields are themselves wiped on drop (`Zeroizing`, a +//! `#[derive(ZeroizeOnDrop)]` type) closes it for those fields. +//! +//! A Rust move is not an erasure: a `Copy` field leaves its bytes where it +//! was moved from, and compiler temporaries are not wiped. The promise here +//! covers the owned source, the encoded buffer, the opened buffer and the +//! library's own copies, not every byte a program's stack once held. +//! +//! # Go +//! +//! The Go SDK's opaque struct (a `stashgen` declaration with one `value` +//! field, sealed as a storage blob under the context `/value`) is +//! this format: a Rust program opens such a column with +//! `decrypt_with_context::>(ct, ("", "value"))`, +//! with `T`'s serde field names matching the Go struct's `vc` tags, and +//! Go reads a Rust-written blob the same way. Two qualifications, both the +//! Go writer's: a nil slice or map is written as an empty one, and a +//! non-nil pointer is written as the bare value (Rust writes every `Some` +//! with its marker; both readers read either form). +//! +//! [`Error::Aead`]: crate::Error::Aead +//! [`Error::OpenedType`]: crate::Error::OpenedType +//! [`Error::Blob`]: crate::Error::Blob +//! [`KeysetCipher::encrypt`]: crate::KeysetCipher::encrypt +//! [`KeysetCipher::encrypt_tree`]: crate::KeysetCipher::encrypt_tree +//! [`Ciphertext`]: crate::Ciphertext +//! [`SealedValue`]: crate::SealedValue + +use std::fmt; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use vitaminc_aead_frame::serde::PathSegment; +use vitaminc_aead_frame::storage::{self, ErrorKind}; +use vitaminc_protected::{Controlled, Protected}; +use zeroize::Zeroize; + +use crate::scalar::sealed; +use crate::target::Pending; +use crate::{CipherText, Ciphertext, ContextPiece, KeysetCipher, OpaqueDecode, OpaqueEncode}; +use crate::{SealedValue, StackCipher}; + +/// The four bytes every blob starts with: `VCS`, then [`VERSION`]. Inside +/// the plaintext, so encrypted and authenticated with the value. +pub const HEADER: [u8; 4] = storage::HEADER; + +/// The storage profile version this release writes, and the only one it +/// reads. A blob of a later version is [`BlobError::UnsupportedVersion`]. +pub const VERSION: u8 = storage::VERSION; + +/// A whole value, encrypted as one opaque [`Ciphertext`]. +/// +/// Wrap the value to encrypt it (`keyset.encrypt(OpaqueBlob::new(v))`) +/// and name the wrapper to decrypt it +/// (`keyset.decrypt::>(ct)`), then [`into_inner`]. The +/// module docs give the format, its errors, what it reveals and what is +/// cleaned up. +/// +/// `Debug` prints no plaintext. The wrapper is an ordinary owned value: +/// it is not wiped on drop (decrypt to `Zeroizing>` for +/// that), and it has no serde impls of its own, so that a blob is never +/// written by accident where a `T` was meant. +/// +/// [`into_inner`]: OpaqueBlob::into_inner +#[derive(Clone, PartialEq, Eq, Default)] +pub struct OpaqueBlob(T); + +impl OpaqueBlob { + /// Wrap a value to encrypt it whole. + pub const fn new(value: T) -> Self { + Self(value) + } + + /// The value. + pub fn into_inner(self) -> T { + self.0 + } + + /// A borrow of the value. + pub const fn get(&self) -> &T { + &self.0 + } + + /// A mutable borrow of the value. + pub fn get_mut(&mut self) -> &mut T { + &mut self.0 + } +} + +impl From for OpaqueBlob { + fn from(value: T) -> Self { + Self(value) + } +} + +impl fmt::Debug for OpaqueBlob { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("OpaqueBlob(***)") + } +} + +impl Zeroize for OpaqueBlob { + fn zeroize(&mut self) { + self.0.zeroize(); + } +} + +impl OpaqueBlob { + /// The value as a storage blob: the plaintext the ciphertext seals, + /// starting with [`HEADER`]. Returned `Protected`, and wiped when it is + /// dropped. Encrypting does this itself; it is here for a caller that + /// needs the bytes, to check them against another writer's, say. + /// + /// # Errors + /// + /// [`BlobError::Unwritable`] for a value the profile cannot hold. + pub fn encode(&self) -> Result>, BlobError> { + storage::to_vec(&self.0).map_err(BlobError::Unwritable) + } +} + +impl OpaqueBlob { + /// Read a value from a storage blob: what decrypting does to the opened + /// bytes. The caller's `bytes` are borrowed and not wiped; the opened + /// buffer decrypting reads from is. + /// + /// # Errors + /// + /// One [`BlobError`] per class: not a blob, a later version, malformed, + /// or a well-formed blob that is not a `T`. + pub fn decode(bytes: &[u8]) -> Result { + storage::from_slice::(bytes) + .map(Self) + .map_err(|error| BlobError::reading(error, bytes)) + } +} + +impl sealed::Encode for OpaqueBlob { + fn seal<'a, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + context: ContextPiece<'static>, + ) -> Pending<'a, Ciphertext, K> { + // Refuse a context no request could carry before the value is + // encoded, as a scalar is: no plaintext copy is made for a request + // that cannot be sent, and the refusal is the same as a scalar's. + if let Err(error) = crate::Descriptor::from_piece(&context).check() { + return Pending::failed(cipher, error); + } + let blob = match self.encode() { + Ok(blob) => blob, + Err(error) => return Pending::failed(cipher, error.into()), + }; + // Planned as one bytes leaf, synchronously: the plaintext is copied + // into the pending's own guarded leaf here, and `blob` is wiped when + // this returns. The caller's source is wiped by the owned conversion + // that called us. + crate::target::core::encrypt_opaque(blob.risky_ref().as_slice(), cipher, context) + .try_map(Ciphertext::from_tree) + } +} +impl OpaqueEncode for OpaqueBlob {} + +impl sealed::Decode for OpaqueBlob { + fn open<'a, K: 'static>( + leaf: SealedValue, + cipher: &'a StackCipher, + context: ContextPiece<'static>, + ) -> Pending<'a, Self, K> { + // Authenticate first: the leaf opens as bytes, into a `Protected` + // buffer, and only then is it read as a blob. A leaf of another kind + // is `OpenedType`; a leaf that does not open is `Aead`. The buffer + // is wiped when `try_map`'s closure returns, value or refusal. + crate::target::core::open_opaque::>, K, _>( + CipherText::Single(leaf), + cipher, + context, + ) + .try_map(|bytes| Self::decode(bytes.risky_ref()).map_err(crate::Error::from)) + } +} +impl OpaqueDecode for OpaqueBlob {} + +/// Why an [`OpaqueBlob`] could not be written, or an authenticated +/// ciphertext did not read as one. One variant per class of refusal, each +/// with its own code, and none names a value: the profile's own errors +/// name kinds and declared field names only, and are kept as the source. +/// +/// On decrypt, every variant means the ciphertext **was authentic**: the +/// leaf opened under its key and context before the blob was looked at. +/// See the [module docs](self#decode-errors-and-when-they-can-occur). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] +pub enum BlobError { + /// The value cannot be written as a storage blob: nested deeper than + /// 128 levels, text or bytes too long for a `u32` length, a map key + /// that is not an integer, text or bytes, a repeated key, or a value its + /// own `Serialize` refused. Raised before any key is requested. + #[error("the value cannot be written as a storage blob")] + #[diagnostic( + code(stack_encrypt::blob_unwritable), + help("A blob holds serde's data model with integer, text or bytes map keys, distinct keys, and at most 128 levels of nesting. The source error names the kind refused and the declared field it is under.") + )] + Unwritable(#[source] storage::Error), + /// The ciphertext opened as bytes, but they do not start with + /// [`HEADER`]'s magic: the value was sealed as plain bytes, not as a + /// blob. + #[error("the ciphertext opened, but it does not hold a storage blob")] + #[diagnostic( + code(stack_encrypt::blob_not_a_blob), + help("The ciphertext is authentic and holds bytes that are not a storage blob. Open it as the type it was sealed as; a `Vec` sealed through the structural API is not an `OpaqueBlob`.") + )] + NotABlob, + /// The ciphertext opened and holds a blob of a storage version this + /// build does not read. The blob is not corrupt: a later release reads + /// it, so the reader needs upgrading. + #[error("the ciphertext opened, but it holds a storage blob of version {version}, which this build does not read")] + #[diagnostic( + code(stack_encrypt::blob_unsupported_version), + help("Upgrade stack-encrypt to a release that reads this storage version. The ciphertext is authentic and the blob is intact.") + )] + UnsupportedVersion { + /// The version byte the blob carries. + version: u8, + }, + /// The ciphertext opened and holds a blob of a version this build + /// reads, whose body breaks a rule of that version: truncated, trailing + /// bytes, an unknown tag, a repeated key, nested too deep. Found before + /// the type reads anything. + #[error("the ciphertext opened, but its storage blob is malformed")] + #[diagnostic( + code(stack_encrypt::blob_malformed), + help("The ciphertext is authentic, so the blob was written this way: by a writer that does not follow the storage profile, or by sealing bytes that happen to start with its header. It cannot be read by any version 1 reader.") + )] + Malformed(#[source] storage::Error), + /// The ciphertext opened and holds a well-formed blob, but not a value + /// of the type it was opened as: a missing field, text where a number + /// was expected, an unknown variant, an integer that does not fit. The + /// value was written as another type, or an older or newer shape of + /// this one. + #[error("the ciphertext opened and holds a storage blob, but not a value of the type it was opened as")] + #[diagnostic( + code(stack_encrypt::blob_mismatch), + help("The ciphertext is authentic. Open it as the type it was written as, or make the type read the old shape (`#[serde(default)]`, `#[serde(alias = \"…\")]`, an `Option`). The source error names the declared field the refusal is about, never the value.") + )] + Mismatch(#[source] storage::Error), +} + +impl BlobError { + /// The profile's refusal of `bytes`, in this crate's classes. + fn reading(error: storage::Error, bytes: &[u8]) -> Self { + match error.kind() { + ErrorKind::NotABlob => Self::NotABlob, + ErrorKind::UnsupportedVersion => Self::UnsupportedVersion { + // The reader found a header and refused its version, so the + // byte is there; the fallback is for a reader that changes + // its mind about what counts as a header. + version: bytes.get(HEADER.len() - 1).copied().unwrap_or(0), + }, + ErrorKind::Mismatch => Self::Mismatch(error), + // The reader never says `Unwritable`. A class a later profile + // release adds is one this build has no reading for, which is + // what a malformed blob is to it. + ErrorKind::Malformed | ErrorKind::Unwritable | _ => Self::Malformed(error), + } + } + + /// The declared field names and positions from the blob's root to the + /// node the refusal is about, as the source error records them. A map + /// key, which is data, is never among them: such an entry is recorded + /// by its position. + fn path(error: &storage::Error) -> serde_json::Value { + error + .path() + .iter() + .map(|segment| match segment { + PathSegment::Key(key) => serde_json::Value::from(key.as_str()), + PathSegment::Index(index) => serde_json::Value::from(*index), + PathSegment::Entry(index) => serde_json::json!({ "entry": index }), + }) + .collect() + } +} + +impl crate::ErrorPayload for BlobError { + fn payload(&self) -> serde_json::Map { + use crate::diagnostic::payload; + match self { + Self::Unwritable(error) | Self::Malformed(error) | Self::Mismatch(error) => { + payload([("path", Self::path(error))]) + } + Self::NotABlob => serde_json::Map::new(), + Self::UnsupportedVersion { version } => payload([ + ("version", (*version).into()), + ("supported", VERSION.into()), + ]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ErrorPayload; + use serde::Deserialize; + + #[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] + struct Pair { + left: String, + right: u32, + } + + #[test] + fn a_blob_starts_with_the_header_and_reads_back() { + let pair = OpaqueBlob::new(Pair { + left: "l".into(), + right: 7, + }); + let blob = pair.encode().unwrap(); + assert_eq!(&blob.risky_ref()[..4], &HEADER); + assert_eq!(HEADER, *b"VCS\x01"); + assert_eq!(OpaqueBlob::::decode(blob.risky_ref()).unwrap(), pair); + } + + #[test] + fn every_class_of_refusal_is_its_own_variant() { + let blob = OpaqueBlob::new(Pair { + left: "l".into(), + right: 7, + }) + .encode() + .unwrap(); + let read = |bytes: &[u8]| OpaqueBlob::::decode(bytes).unwrap_err(); + + assert!(matches!(read(b""), BlobError::NotABlob)); + assert!(matches!(read(b"VC"), BlobError::NotABlob)); + assert!(matches!(read(b"{\"left\":\"l\"}"), BlobError::NotABlob)); + let later = read(b"VCS\x02\x00"); + assert!(matches!( + later, + BlobError::UnsupportedVersion { version: 2 } + )); + assert_eq!(later.payload()["version"], 2); + assert_eq!(later.payload()["supported"], 1); + assert!(matches!( + read(&blob.risky_ref()[..blob.risky_ref().len() - 1]), + BlobError::Malformed(_) + )); + assert!(matches!(read(b"VCS\x01\xff"), BlobError::Malformed(_))); + + // A well-formed blob of another shape names the declared field. + #[derive(Serialize, Zeroize)] + struct Renamed { + left: String, + wrong: u32, + } + let renamed = OpaqueBlob::new(Renamed { + left: "l".into(), + wrong: 7, + }) + .encode() + .unwrap(); + let mismatch = read(renamed.risky_ref()); + assert!(matches!(mismatch, BlobError::Mismatch(_))); + let shown = format!("{mismatch} / {:?}", mismatch.payload()); + assert!(!shown.contains("\"l\""), "{shown}"); + } + + #[test] + fn debug_shows_no_plaintext() { + let blob = OpaqueBlob::new(Pair { + left: "secret".into(), + right: 7, + }); + assert_eq!(format!("{blob:?}"), "OpaqueBlob(***)"); + } + + #[test] + fn an_unwritable_value_is_refused_with_its_path() { + use std::collections::BTreeMap; + #[derive(Serialize, Zeroize)] + struct Keyed { + #[zeroize(skip)] + by_float: BTreeMap>, + #[zeroize(skip)] + deep: serde_json::Value, + } + let mut deep = serde_json::Value::Null; + for _ in 0..130 { + deep = serde_json::Value::Array(vec![deep]); + } + let value = OpaqueBlob::new(Keyed { + by_float: BTreeMap::new(), + deep, + }); + let error = value.encode().unwrap_err(); + assert!(matches!(error, BlobError::Unwritable(_))); + assert_eq!(error.payload()["path"][0], "deep"); + } +} diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 3a1b43b1b..0efa88ef0 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -399,6 +399,16 @@ pub enum Error { #[error(transparent)] #[diagnostic(transparent)] Plan(#[from] crate::plan::PlanError), + /// An [`OpaqueBlob`](crate::OpaqueBlob) could not be written as a + /// storage blob, or an authenticated ciphertext did not hold one its + /// type reads. Transparent: the message, code and help are the + /// [`BlobError`](crate::BlobError)'s, one per class of refusal. On + /// decrypt it is raised only after the leaf authenticated, so it never + /// stands in for [`Aead`](Self::Aead): the ciphertext is genuine and + /// holds a blob of another version, shape or type. + #[error(transparent)] + #[diagnostic(transparent)] + Blob(#[from] crate::BlobError), } /// How many separator-delimited parts a descriptor has: what an error about @@ -414,6 +424,7 @@ impl crate::ErrorPayload for Error { Self::Kms(error) => error.payload(), Self::Term(error) => error.payload(), Self::Plan(error) => error.payload(), + Self::Blob(error) => error.payload(), Self::KeyCountMismatch { expected, received } => payload([ ("expected", (*expected).into()), ("received", (*received).into()), diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 77c45aa03..f2af81fd8 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -11,7 +11,7 @@ mod tests { use crate::diagnostic::is_code_of; use crate::sem::{MatchOptions, TermBytesError, TermError}; use crate::target::IndexSpec; - use crate::{Descriptor, Error, LabelError, LeafBytesError, PlanError}; + use crate::{BlobError, Descriptor, Error, LabelError, LeafBytesError, PlanError}; /// One row per variant of an enum, written `pattern => value`. The /// patterns are the arms of a match with no wildcard, so a variant with @@ -39,6 +39,12 @@ mod tests { rows.into_iter().map(crate::diagnostic::named) } + /// A storage profile error, from reading `bytes` as a `u32`: the + /// profile's errors have no public constructor, so one is provoked. + fn storage_error(bytes: &[u8]) -> vitaminc_aead_frame::storage::Error { + vitaminc_aead_frame::storage::from_slice::(bytes).unwrap_err() + } + /// One of every variant of every error type here. fn every_variant() -> Vec { let cause = || Box::new(std::io::Error::other("cause")); @@ -79,6 +85,14 @@ mod tests { Error::NoKeyset => Error::NoKeyset, Error::NotOpened => Error::NotOpened, Error::Plan(_) => Error::Plan(PlanError::NoContext), + Error::Blob(_) => Error::Blob(BlobError::NotABlob), + ])); + errors.extend(boxed(variants![ + BlobError::Unwritable(_) => BlobError::Unwritable(storage_error(b"VCS\x01\x0a\x01\x00\x00\x00")), + BlobError::NotABlob => BlobError::NotABlob, + BlobError::UnsupportedVersion { .. } => BlobError::UnsupportedVersion { version: 2 }, + BlobError::Malformed(_) => BlobError::Malformed(storage_error(b"VCS\x01\xff")), + BlobError::Mismatch(_) => BlobError::Mismatch(storage_error(b"VCS\x01\x0a\x01\x00\x00\x00x")), ])); errors.extend(boxed(variants![ LeafBytesError::UnknownVersion(_) => LeafBytesError::UnknownVersion(9), diff --git a/packages/stack-encrypt/src/dynamic/query.rs b/packages/stack-encrypt/src/dynamic/query.rs index e678d0a63..1343b5b4d 100644 --- a/packages/stack-encrypt/src/dynamic/query.rs +++ b/packages/stack-encrypt/src/dynamic/query.rs @@ -1862,105 +1862,15 @@ mod tests { assert_eq!(term.fidelity(), Fidelity::Probabilistic); } + /// The typed path's term refusals (text with no normal form, text that + /// cuts no token, block ORE's length and NUL rules) are shared + /// query-refusal cases, run through the check and the derivation alike. + /// What stays here is what a refusal table cannot say: two spellings + /// that fold to one key derive one term. #[tokio::test] - async fn a_query_the_field_does_not_answer_is_refused_naming_the_field() { + async fn folded_equivalents_derive_one_term() { let cipher = cipher().await; let keyset = cipher.default_keyset(); - let cases = [ - ("name", OpSpec::Eq, text("x"), Reason::OperatorNotAnswered), - ( - "email", - OpSpec::Matches, - text("bob"), - Reason::OperatorNotAnswered, - ), - ( - "nickname", - OpSpec::Eq, - text("bob"), - Reason::OperatorNotAnswered, - ), - ( - "age", - OpSpec::Eq, - Tree::UInt32(1), - Reason::OperatorNotAnswered, - ), - ("age", OpSpec::Lt, text("40"), Reason::FieldType), - ("age", OpSpec::Lt, Tree::Float64(40.5), Reason::FieldType), - ("age", OpSpec::Between, Tree::UInt32(40), Reason::NotARange), - ( - "age", - OpSpec::Between, - Tree::Array(vec![Tree::UInt32(1)]), - Reason::NotARange, - ), - ( - "age", - OpSpec::Between, - Tree::Array(vec![Tree::UInt32(1), text("2")]), - Reason::FieldType, - ), - ]; - for (name, op, input, reason) in cases { - let what = format!("{op} on {name}"); - let error = query(&keyset, name, op, input.clone()).await.unwrap_err(); - assert!(matches!(error, Error::Source { .. }), "{what}: {error:?}"); - assert_eq!(reason_of(&error), (Some(name), Some(reason)), "{what}"); - let checked = check_query(&users(), name, op, input, &NoTargets).unwrap_err(); - assert_eq!( - reason_of(&checked), - (Some(name), Some(reason)), - "{what}: checked alike" - ); - } - let error = query(&keyset, "phone", OpSpec::Eq, text("1")) - .await - .unwrap_err(); - assert!(matches!(error, Error::Plan { .. })); - assert_eq!( - reason_of(&error), - (Some("phone"), Some(Reason::NoSuchField)) - ); - } - - #[tokio::test] - async fn the_typed_paths_query_refusals_are_made_before_any_term() { - let cipher = cipher().await; - let keyset = cipher.default_keyset(); - let one = |text: &str| ValueFrame::of(text).expect("a value"); - let two = |lo: &str, hi: &str| ValueFrame::of(vec![lo, hi]).expect("a value"); - let cases = [ - ("nickname", OpSpec::Lt, one("a\u{0378}"), "normalize"), - ( - "nickname", - OpSpec::Between, - two("a", "b\u{0378}"), - "normalize", - ), - ( - "nickname", - OpSpec::Between, - two("a\u{0378}", "b"), - "normalize", - ), - ("name", OpSpec::Matches, one("al"), "empty"), - ("name", OpSpec::Matches, one("\u{0378}abc"), "normalize"), - ("email", OpSpec::Eq, one("a\u{0378}bc"), "normalize"), - ]; - for (name, op, input, what) in cases { - for error in [ - query(&keyset, name, op, &input).await.unwrap_err(), - check_query(&users(), name, op, &input, &NoTargets).unwrap_err(), - ] { - let term = term_error(&error); - let expected = match what { - "empty" => matches!(term, Some(TermError::EmptyTermText)), - _ => matches!(term, Some(TermError::Normalize(_))), - }; - assert!(expected, "{name} {what}: {error:?}"); - } - } // The terms of folded equivalents are one term. let cafe = query(&keyset, "nickname", OpSpec::Lt, "cafe") .await @@ -1977,11 +1887,13 @@ mod tests { assert_eq!(negative_zero.operand, zero.operand); } - /// Block ORE takes order text whose fold is at most 1023 bytes and does - /// not end in NUL. A query is refused for any other, either bound of a - /// range included, before any term exists, by the key-free check too. + /// Block ORE's refusals and the text it accepts are shared query-refusal + /// cases; a refusal table records only that a case is accepted. What + /// stays here is which index answers: the longest text, text with an + /// interior NUL and empty text derive a block ORE term, either bound of + /// a range included. #[tokio::test] - async fn block_ore_refuses_text_it_has_no_term_for_before_any_term() { + async fn accepted_block_ore_text_derives_a_block_ore_term() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let plan = record::plan(&frame_of(&Tree::Object(vec![field( @@ -1993,40 +1905,19 @@ mod tests { let one = |text: &str| ValueFrame::of(text).expect("a value"); let two = |lo: &str, hi: &str| ValueFrame::of(vec![lo, hi]).expect("a value"); let longest = "a".repeat(1023); - let too_long = "a".repeat(1024); - let refused = [ - (OpSpec::Lt, one(&too_long), "long"), - (OpSpec::Gt, one("a\0"), "nul"), - (OpSpec::Between, two("a", &too_long), "long"), - (OpSpec::Between, two(&too_long, "b"), "long"), - (OpSpec::Between, two("a\0", "b"), "nul"), - (OpSpec::Between, two("a", "b\0"), "nul"), + let accepted = [ + (OpSpec::Lt, one(&longest)), + (OpSpec::Lt, one("a\0b")), + (OpSpec::Lt, one("")), + (OpSpec::Between, two("", &longest)), + (OpSpec::Between, two("a\0b", "b")), ]; - for (op, input, what) in refused { - let derived = match query_term(&keyset, &plan, "title", op, &input, &NoTargets) { - Ok(pending) => pending.await.map_err(Error::Cipher).map(|_| ()), - Err(error) => Err(error), - }; - for error in [ - derived.unwrap_err(), - check_query(&plan, "title", op, &input, &NoTargets).unwrap_err(), - ] { - let term = term_error(&error); - let expected = match what { - "long" => matches!(term, Some(TermError::OreBlockTooLong)), - _ => matches!(term, Some(TermError::OreBlockTrailingNul)), - }; - assert!(expected, "{op:?} {what}: {error:?}"); - } - } - for input in [one(&longest), one("a\0b"), one("")] { - check_query(&plan, "title", OpSpec::Lt, &input, &NoTargets) - .expect("block ORE takes it"); - let term = query_term(&keyset, &plan, "title", OpSpec::Lt, &input, &NoTargets) + for (op, input) in accepted { + let term = query_term(&keyset, &plan, "title", op, &input, &NoTargets) .expect("a query") .await .expect("a term"); - assert_eq!(term.index, IndexSpec::OreBlock); + assert_eq!(term.index, IndexSpec::OreBlock, "{op:?}"); } } diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 3baeebf32..41db2d34d 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -3189,158 +3189,24 @@ mod tests { ); } - #[test] - fn refuses_a_malformed_plan_before_any_field_is_built() { - let cases: Vec = vec![ - ("a plan that is not an object", s("x"), |e| { - matches!(e, Error::Plan { .. }) - }), - ("an empty plan", obj(vec![]), |e| { - matches!(e, Error::Plan { .. }) - }), - ( - "a field spec that is not an object", - obj(vec![("age", s("x"))]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "a field spec with an unknown key", - obj(vec![( - "age", - obj(vec![ - ("context", label("age")), - ("outputs", strings(&["c"])), - ("nullable", Tree::Bool(true)), - ]), - )]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "a field spec with no context", - obj(vec![("age", obj(vec![("outputs", strings(&["c"]))]))]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "a field spec with no outputs", - obj(vec![("age", obj(vec![("context", label("age"))]))]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "outputs that are not a list", - obj(vec![("age", spec(label("age"), &[]))]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "an output that is not a string", - obj(vec![( - "age", - obj(vec![ - ("context", label("age")), - ("outputs", Tree::Array(vec![Tree::UInt32(1)])), - ]), - )]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "an unknown output", - obj(vec![("age", spec(label("age"), &["c", "sum"]))]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "an output named twice", - obj(vec![("age", spec(label("age"), &["c", "eq", "c"]))]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "passthrough beside a ciphertext", - obj(vec![("age", spec(label("age"), &["passthrough", "c"]))]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "passthrough beside an index", - obj(vec![("age", spec(label("age"), &["eq", "passthrough"]))]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "a field named twice", - Tree::Object(vec![ - ("age".to_string(), spec(label("age"), &["c"])), - ("age".to_string(), spec(label("age"), &["eq"])), - ]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "a context given twice", - obj(vec![( - "age", - obj(vec![ - ("context", label("age")), - ("outputs", strings(&["c"])), - ("context", label("other")), - ]), - )]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "outputs given twice", - obj(vec![( - "age", - obj(vec![ - ("context", label("age")), - ("outputs", strings(&["c"])), - ("outputs", strings(&["eq"])), - ]), - )]), - |e| matches!(e, Error::Plan { .. }), - ), - ( - "a context that is not one", - obj(vec![("age", spec(Tree::Bool(true), &["c"]))]), - |e| matches!(e, Error::Context { .. }), - ), - ( - "a context that renders empty", - obj(vec![("age", spec(s(""), &["c"]))]), - |e| matches!(e, Error::Context { .. }), - ), - ]; - for (label, value, expected) in cases { - let err = plan(value).err(); - assert!( - err.as_ref().is_some_and(expected), - "{label} must be refused as the right error: {err:?}" - ); - } - } - /// A field's context is its label, optionally extended, and nothing /// else: what a fields plan can give a field. The shapes refused here /// are contexts (`dynamic::context` reads them) that no `.fields()` /// chain could spell. #[test] fn refuses_a_field_context_that_is_not_a_label() { + // The text, one-segment, malformed-segment and nested-extension + // shapes are shared declaration cases (`context-bare-label`, + // `context-one-segment`, `context-segment-slash`, + // `context-segment-digit`, `context-nested-extension`); these + // are the shapes the corpus's JSON projection cannot spell. let refused = [ - ("one text part, however it reads", s("users/age")), - ("a one-segment label", strings(&["users"])), - ("a one-element list", Tree::Array(vec![s("users")])), ("an integer", Tree::UInt64(7)), ("bytes", Tree::Bytes(b"users".to_vec())), ( "a one-segment label, extended", Tree::Array(vec![s("users"), Tree::UInt64(7)]), ), - ( - "a segment that is not plain", - strings(&["users", "age/years"]), - ), - ( - "a segment with a reserved prefix", - strings(&["users", "7age"]), - ), - ( - "a list part as an extension", - Tree::Array(vec![label("age"), strings(&["eu", "west"])]), - ), ]; for (what, context) in refused { let result = plan(obj(vec![("age", spec(context, &["c"]))])); @@ -3723,7 +3589,11 @@ mod tests { /// `check_source` is the conversion `encrypt` runs, so a binding's /// boundary rejection and the operation's are the same error β€” and - /// neither costs a key request. + /// neither costs a key request. A missing field, a field the plan + /// does not name and a value of another kind are shared runtime + /// cases (`required-missing`, `unknown-field`, `required-wrong-width` + /// and their late-row forms); the shapes here are the ones the + /// corpus's host projection cannot build. #[tokio::test] async fn encrypt_and_check_source_refuse_it_alike_with_no_key_request() { let cipher = cipher().await; @@ -3741,24 +3611,6 @@ mod tests { Tree::Array(vec![row(1), Tree::UInt32(2)]), |e| matches!(e, Error::Source { .. }), ), - ( - "a row missing a plan field", - obj(vec![ - ("age", Tree::UInt32(1)), - ("email", s("a@x")), - ("nick", s("al")), - ]), - |e| matches!(e, Error::Source { .. }), - ), - ( - "a row with a field the plan does not name", - { - let mut entries = object(row(1)); - entries.push(("extra".to_string(), s("x"))); - Tree::Object(entries) - }, - |e| matches!(e, Error::Source { .. }), - ), ( "a row with a field the plan does not name in place of one it does", { @@ -3810,15 +3662,6 @@ mod tests { }, |e| matches!(e, Error::Source { .. }), ), - ( - "a scalar of another kind under an indexed field", - { - let mut entries = object(row(1)); - entries[2].1 = Tree::UInt32(3); - Tree::Object(entries) - }, - |e| matches!(e, Error::Source { .. }), - ), ]; for (label_, source, expected) in cases { let err = check_source(source, &plan).err(); @@ -3827,16 +3670,6 @@ mod tests { "{label_}: check_source must refuse it as the right error: {err:?}" ); } - let missing = obj(vec![ - ("age", Tree::UInt32(1)), - ("email", s("a@x")), - ("nick", s("al")), - ]); - let err = encrypt(&keyset, missing, &plan).err(); - assert!( - matches!(err, Some(Error::Source { .. })), - "encrypt refuses a row missing a plan field: {err:?}" - ); let mut entries = object(row(1)); entries[1].1 = Tree::Passthrough(Box::new(s("a@x"))); let err = encrypt(&keyset, Tree::Object(entries), &plan).err(); @@ -3844,13 +3677,6 @@ mod tests { matches!(err, Some(Error::Source { .. })), "encrypt refuses a passthrough under a sealed field: {err:?}" ); - let mut entries = object(row(1)); - entries[2].1 = Tree::UInt32(3); - let err = encrypt(&keyset, Tree::Object(entries), &plan).err(); - assert!( - matches!(err, Some(Error::Source { .. })), - "encrypt refuses a value of another kind than the indexed field declares: {err:?}" - ); assert_eq!( generates(&cipher), 0, @@ -5672,51 +5498,6 @@ mod tests { ); } - /// A stored null opens only through a plan that says the field may - /// hold one: read through a plan with no `"null"`, the field's kind - /// check refuses it, so a nil never reads as a zero value. - #[tokio::test] - async fn a_stored_null_is_refused_through_a_plan_that_has_no_null() { - let cipher = cipher().await; - let keyset = cipher.default_keyset(); - let sealing = plan(obj(vec![( - "dob", - nullable(label("dob"), &["c"], Some("date"), "sealed"), - )])) - .expect("parses"); - let reading = - plan(obj(vec![("dob", typed(label("dob"), &["c"], "date"))])).expect("parses"); - let sealed = seal(&keyset, obj(vec![("dob", Tree::Null)]), &sealing).await; - let refused = decrypt(Scope::Client(&cipher), sealed, &reading, None) - .expect("the record fits the plan") - .await - .err(); - assert!( - matches!(&refused, Some(crate::Error::Plan(crate::PlanError::FieldType { field, .. })) if field == "dob"), - "{refused:?}" - ); - // And through the plan that says so, a value opens as a value. - let sealed = seal(&keyset, obj(vec![("dob", date(19_000))]), &sealing).await; - assert_eq!( - open(&cipher, sealed, &sealing).await, - obj(vec![("dob", date(19_000))]) - ); - } - - /// A null in a typed field with no `"null"` is still refused. - #[tokio::test] - async fn a_null_in_a_field_with_no_null_mode_is_refused() { - let cipher = cipher().await; - let keyset = cipher.default_keyset(); - let plan = - plan(obj(vec![("dob", typed(label("dob"), &["c"], "date"))])).expect("parses"); - let refused = encrypt(&keyset, obj(vec![("dob", Tree::Null)]), &plan).err(); - assert!( - matches!(&refused, Some(Error::Source { field: Some(f), reason: Reason::FieldType }) if f == "dob"), - "{refused:?}" - ); - } - /// Under `"sql"` a null, or a row with no entry for the field, is /// left out of the stored record: no ciphertext, no term, no key. /// The rest of the batch is one call, and every row opens with a @@ -5769,53 +5550,6 @@ mod tests { ]) ); } - - /// A row may leave out every field it seals; it is stored as an - /// empty record and opens with a null in each. - #[tokio::test] - async fn a_row_with_every_field_left_out_is_an_empty_record() { - let cipher = cipher().await; - let keyset = cipher.default_keyset(); - let plan = plan(obj(vec![( - "age", - nullable(label("age"), &["c", "eq"], Some("uint32"), "sql"), - )])) - .expect("parses"); - let sealed = seal(&keyset, obj(vec![("age", Tree::Null)]), &plan).await; - assert!(field_names(&sealed).is_empty(), "{sealed:?}"); - assert_eq!( - open(&cipher, sealed, &plan).await, - obj(vec![("age", Tree::Null)]) - ); - } - - /// A stored record with no entry for a field that has no `"null"` - /// is still refused: only a `"sql"` field may be left out. - #[tokio::test] - async fn a_stored_record_may_leave_out_only_a_sql_field() { - let cipher = cipher().await; - let keyset = cipher.default_keyset(); - let plan = plan(obj(vec![ - ("age", nullable(label("age"), &["c"], Some("uint32"), "sql")), - ("email", spec(label("email"), &["c"])), - ])) - .expect("parses"); - let sealed = seal( - &keyset, - obj(vec![("age", Tree::UInt32(3)), ("email", s("a@x"))]), - &plan, - ) - .await; - let without_email: Vec<_> = map(sealed) - .into_iter() - .filter(|(name, _)| name != "email") - .collect(); - let refused = check_record(CipherText::Map(without_email), &plan, None).err(); - assert!( - matches!(&refused, Some(Error::Record { field: Some(f), reason: Reason::FieldMissing }) if f == "email"), - "{refused:?}" - ); - } } mod given_a_typed_field { @@ -7808,107 +7542,6 @@ mod tests { } } - fn refused_plan(value: Tree) -> Error { - plan(value).expect_err("the plan is refused") - } - - fn age(spec: Vec<(&str, Tree)>) -> Tree { - obj(vec![("age", obj(spec))]) - } - - /// A key that is present must hold its kind of value: a null is - /// refused as that key's reason, never read as absent. - #[test] - fn a_null_is_refused_as_its_key() { - use Reason::*; - let cases: Vec<(&str, Tree, Option<&str>, Reason, &str)> = vec![ - ( - "a null context", - age(vec![("context", Tree::Null), ("outputs", strings(&["c"]))]), - Some("age"), - ContextKind, - "Context", - ), - ( - "null outputs", - age(vec![("context", label("age")), ("outputs", Tree::Null)]), - Some("age"), - OutputsNotList, - "Plan", - ), - ( - "a null target", - age(vec![("context", label("age")), ("target", Tree::Null)]), - Some("age"), - InvalidTarget, - "Plan", - ), - ( - "a null type", - age(vec![ - ("context", label("age")), - ("outputs", strings(&["c"])), - ("type", Tree::Null), - ]), - Some("age"), - UnknownType, - "Plan", - ), - ( - "a null context field", - obj(vec![ - ("context_field", Tree::Null), - ("age", spec(label("age"), &["c"])), - ]), - None, - ContextField, - "Plan", - ), - ( - "a spec that is null", - obj(vec![("age", Tree::Null)]), - Some("age"), - NotAnObject, - "Plan", - ), - ]; - for (what, value, field, reason, variant) in cases { - let error = refused_plan(value); - assert!( - !matches!(error, Error::UntypedIndex { .. }), - "{what}: {error}" - ); - expect(&error, variant, field, reason); - } - } - - /// An index's payload may be missing or null alike: a bare - /// `"match"`, `{"match": null}` and `{"match": {}}` are each the - /// match index under the default options, and `{"eq": null}` is the - /// bare `"eq"`. - #[test] - fn a_missing_or_null_index_payload_is_the_default() { - let outputs = |output: Tree| { - let plan = plan(age(vec![ - ("context", label("age")), - ("outputs", Tree::Array(vec![output])), - ("type", s("string")), - ])) - .expect("the plan parses"); - plan.fields()[0].outputs().to_vec() - }; - let default_match = vec![Output::Term(IndexSpec::Match( - crate::sem::MatchOptions::default(), - ))]; - assert_eq!(outputs(s("match")), default_match); - assert_eq!(outputs(obj(vec![("match", Tree::Null)])), default_match); - assert_eq!(outputs(obj(vec![("match", obj(vec![]))])), default_match); - assert_eq!( - outputs(obj(vec![("eq", Tree::Null)])), - vec![Output::Term(IndexSpec::Equality)] - ); - } - /// Bytes that are not one frame are not a plan. #[test] fn bytes_that_are_not_a_frame_are_not_a_plan() { @@ -7918,232 +7551,21 @@ mod tests { } } + /// A plan or context refusal names its field in the message as well + /// as the payload. Which reason each shape is refused as is the + /// shared declaration corpus (`missing-context`, `context-null`); + /// the corpus compares codes and payloads, not messages. #[test] - fn a_malformed_plan() { - use Reason::*; - let cases: Vec<(&str, Tree, Option<&str>, Reason, &str)> = vec![ - ("not an object", s("x"), None, NotAnObject, "Plan"), - ("no fields", obj(vec![]), None, NoFields, "Plan"), - ( - "a spec that is not an object", - obj(vec![("age", s("x"))]), - Some("age"), - NotAnObject, - "Plan", - ), - ( - "an unknown key", - age(vec![ - ("context", label("age")), - ("outputs", strings(&["c"])), - ("nullable", Tree::Bool(true)), - ]), - Some("age"), - UnknownKey, - "Plan", - ), - // A key given twice anywhere is refused by the frame before - // the plan is read, so it names no field. - ( - "a key given twice", - age(vec![ - ("context", label("age")), - ("context", label("age")), - ("outputs", strings(&["c"])), - ]), - None, - NotAnObject, - "Plan", - ), - ( - "no context", - age(vec![("outputs", strings(&["c"]))]), - Some("age"), - MissingContext, - "Plan", - ), - ( - "no outputs or target", - age(vec![("context", label("age"))]), - Some("age"), - MissingOutputs, - "Plan", - ), - ( - "outputs and a target", - age(vec![ - ("context", label("age")), - ("outputs", strings(&["c"])), - ("target", s("TextEq")), - ]), - Some("age"), - OutputsWithTarget, - "Plan", - ), - ( - "outputs that are not a list", - age(vec![("context", label("age")), ("outputs", s("c"))]), - Some("age"), - OutputsNotList, - "Plan", - ), - ( - "an output that is not one", - age(vec![ - ("context", label("age")), - ("outputs", strings(&["c", "zz"])), - ]), - Some("age"), - UnknownOutput, - "Plan", - ), - ( - "no outputs", - age(vec![("context", label("age")), ("outputs", strings(&[]))]), - Some("age"), - NoOutputs, - "Plan", - ), - ( - "an output named twice", - age(vec![ - ("context", label("age")), - ("outputs", strings(&["c", "c"])), - ]), - Some("age"), - DuplicateOutput, - "Plan", - ), - ( - "passthrough beside another output", - age(vec![ - ("context", label("age")), - ("outputs", strings(&["c", "passthrough"])), - ]), - Some("age"), - PassthroughWithOutputs, - "Plan", - ), - ( - "a target that is not a string", - age(vec![("context", label("age")), ("target", Tree::UInt32(1))]), - Some("age"), - InvalidTarget, - "Plan", - ), - ( - "a type that is not one", - age(vec![ - ("context", label("age")), - ("outputs", strings(&["c"])), - ("type", s("uuid")), - ]), - Some("age"), - UnknownType, - "Plan", - ), - ( - "an index the type has not", - age(vec![ - ("context", label("age")), - ("outputs", strings(&["c", "match"])), - ("type", s("uint32")), - ]), - Some("age"), - IndexNotAdmitted, - "Plan", - ), - ( - "an order index on a boolean", - age(vec![ - ("context", label("age")), - ("outputs", strings(&["c", "ore"])), - ("type", s("bool")), - ]), - Some("age"), - IndexNotAdmitted, - "Plan", - ), - ( - "a context that is not a label", - age(vec![("context", s("users")), ("outputs", strings(&["c"]))]), - Some("age"), - ContextNotLabel, - "Plan", - ), - ( - "a context of a kind no context has", - age(vec![ - ("context", Tree::Bool(true)), - ("outputs", strings(&["c"])), - ]), - Some("age"), - ContextKind, - "Context", - ), - ( - "a field named twice", - Tree::Object(vec![ - ("age".to_string(), spec(label("age"), &["c"])), - ("age".to_string(), spec(label("age"), &["c"])), - ]), - None, - NotAnObject, - "Plan", - ), - ( - "fields under different contexts", - obj(vec![ - ("age", spec(label("age"), &["c"])), - ("email", spec(strings(&["orders", "email"]), &["c"])), - ]), - Some("email"), - MixedContexts, - "Plan", - ), - ( - "two fields under one identity", - obj(vec![ - ("mail", typed(label("email"), &["c", "eq"], "string")), - ("mail2", typed(label("email"), &["c", "eq"], "string")), - ]), - Some("mail2"), - SharedIdentity, - "Plan", - ), - ( - "a context that renders empty", - age(vec![("context", s("")), ("outputs", strings(&["c"]))]), - Some("age"), - EmptyContext, - "Context", - ), - ( - "a match option given twice", - age(vec![ - ("context", label("age")), - ( - "outputs", - Tree::Array(vec![obj(vec![( - "match", - obj(vec![("k", Tree::UInt32(6)), ("k", Tree::UInt32(6))]), - )])]), - ), - ("type", s("string")), - ]), - None, - NotAnObject, - "Plan", - ), - ]; - for (what, value, field, reason, variant) in cases { - let error = refused_plan(value); - assert!( - !matches!(error, Error::UntypedIndex { .. }), - "{what}: {error}" - ); - expect(&error, variant, field, reason); - } + fn a_plan_refusal_names_its_field_in_the_message() { + let no_context = obj(vec![("age", obj(vec![("outputs", strings(&["c"]))]))]); + let error = plan(no_context).expect_err("no context"); + expect(&error, "Plan", Some("age"), Reason::MissingContext); + let null_context = obj(vec![( + "age", + obj(vec![("context", Tree::Null), ("outputs", strings(&["c"]))]), + )]); + let error = plan(null_context).expect_err("a null context"); + expect(&error, "Context", Some("age"), Reason::ContextKind); } /// A passthrough the frame cannot carry is refused when the source is @@ -8309,6 +7731,11 @@ mod tests { ); } + /// The source shapes the shared runtime corpus cannot build: its + /// host projection has no passthrough, no repeated key and no + /// non-object row. A missing field, an unknown field and a value + /// of another type are `required-missing`, `unknown-field` and + /// `required-wrong-width` there. #[test] fn a_source_that_does_not_fit() { use Reason::*; @@ -8320,10 +7747,6 @@ mod tests { }; let mut repeated = object(row(34)); repeated.push(("age".to_string(), Tree::UInt32(2))); - let mut extra = object(row(34)); - extra.push(("extra".to_string(), Tree::UInt32(2))); - let mut mistyped = without("age"); - mistyped.push(("age".to_string(), s("old"))); let mut forged = without("email"); forged.push(("email".to_string(), Tree::Passthrough(Box::new(s("a@x"))))); let mut deep_id = without("id"); @@ -8356,30 +7779,12 @@ mod tests { None, NotAnObject, ), - ( - "a field missing", - Tree::Object(without("email")), - Some("email"), - FieldMissing, - ), ( "a field given twice", Tree::Object(repeated), Some("age"), FieldRepeated, ), - ( - "a field the plan does not name", - Tree::Object(extra), - Some("extra"), - UnknownField, - ), - ( - "a value of another type", - Tree::Object(mistyped), - Some("age"), - FieldType, - ), ( "a passthrough under a sealed field", Tree::Object(forged), @@ -8393,89 +7798,33 @@ mod tests { } } + /// A stored field given twice is refused as repeated by the engine's + /// own guard, and a record that is one leaf rather than a map or a + /// batch is not an object. The other misfit shapes (a missing field, + /// a field that is not an output map, no ciphertext node, a + /// passthrough under `c`, no passthrough node, a batch row that is + /// not a map) are shared read cases. A repeated key cannot reach the + /// engine through the transport, where both decoders refuse it + /// first, and the shared read profile stores every record as a + /// batch, so these two are native-only (see the raw-frame contract). #[tokio::test] async fn a_stored_record_that_does_not_fit() { - use Reason::*; let cipher = cipher().await; let keyset = cipher.default_keyset(); let plan = the_plan(); - let sealed = || async { map(seal(&keyset, row(34), &plan).await) }; - - let mut cases: Vec<(&str, StackCipherText, Option<&str>, Reason)> = Vec::new(); - - let mut fields = sealed().await; - let _ = node(&mut fields, "email"); - cases.push(( - "a field missing", - CipherText::Map(fields), - Some("email"), - FieldMissing, - )); - - let mut fields = sealed().await; + let mut fields = map(seal(&keyset, row(34), &plan).await); let email = node(&mut fields, "email"); fields.push(("email".to_string(), forged(Tree::UInt32(1)))); fields.push(("email".to_string(), email)); - cases.push(( - "a field given twice", - CipherText::Map(fields), - Some("email"), - FieldRepeated, - )); - - let mut fields = sealed().await; - let _ = node(&mut fields, "email"); - fields.push(("email".to_string(), forged(Tree::UInt32(1)))); - cases.push(( - "a field that is not an output map", - CipherText::Map(fields), - Some("email"), - OutputsNotMap, - )); + let error = check_record(CipherText::Map(fields), &plan, None) + .expect_err("a field given twice"); + expect(&error, "Record", Some("email"), Reason::FieldRepeated); - let mut fields = sealed().await; - let mut email = map(node(&mut fields, "email")); - let _ = node(&mut email, "c"); - fields.push(("email".to_string(), CipherText::Map(email))); - cases.push(( - "no ciphertext node", - CipherText::Map(fields), - Some("email"), - NoCiphertextNode, - )); - - let mut fields = sealed().await; - let mut email = map(node(&mut fields, "email")); - let _ = node(&mut email, "c"); - email.push(("c".to_string(), forged(Tree::UInt32(1)))); - fields.push(("email".to_string(), CipherText::Map(email))); - cases.push(( - "a passthrough under c", - CipherText::Map(fields), - Some("email"), - Passthrough, - )); - - let mut fields = sealed().await; - let mut id = map(node(&mut fields, "id")); - let _ = node(&mut id, "passthrough"); - fields.push(("id".to_string(), CipherText::Map(id))); - cases.push(( - "no passthrough node", - CipherText::Map(fields), - Some("id"), - NoPassthroughNode, - )); - - let fields = sealed().await; - let mut first = fields; - let mut age = map(node(&mut first, "age")); - cases.push(("not a map", node(&mut age, "c"), None, NotAnObject)); - - for (what, record, field, reason) in cases { - let error = check_record(record, &plan, None).expect_err(what); - expect(&error, "Record", field, reason); - } + let mut fields = map(seal(&keyset, row(34), &plan).await); + let mut age = map(node(&mut fields, "age")); + let error = check_record(node(&mut age, "c"), &plan, None) + .expect_err("a record that is a leaf"); + expect(&error, "Record", None, Reason::NotAnObject); } /// A binding switches on `as_str`, so no two reasons share a name. diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index fa25af1c8..61f9d9ad9 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -220,7 +220,8 @@ endpoint β€” are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! The scalar API accepts owned [`OpaqueEncode`] values. It wipes the owned source //! before returning the pending request, including on planning failure. A //! successful decrypt returns an ordinary owned value: the caller is then -//! responsible for its cleanup. Copy types may still have copies held by the +//! responsible for its cleanup, and may ask for `Zeroizing

` in place of +//! `P` to have it wiped on drop. Copy types may still have copies held by the //! caller, and this is not a guarantee about every compiler-generated copy. //! //! For target-directed conversion, import [`EncryptInto`] and [`DecryptFrom`]: @@ -239,12 +240,42 @@ endpoint β€” are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); //! ``` //! +//! # Whole values as one ciphertext +//! +//! A struct, a `Vec` or a map is not a scalar, and `encrypt` refuses it: +//! there is no generic `Serialize` fallback and no implicit `Vec` blob. +//! To encrypt a whole value as **one** ciphertext, name it: wrap it in +//! [`OpaqueBlob`], which encodes it with Vitamin C's versioned storage +//! profile (the format the Go SDK's opaque structs use too) and seals the +//! encoding as one leaf through the same path as a scalar. The [`blob`] +//! module documents the format, its errors, what the ciphertext's length +//! reveals and what is wiped. +//! +//! ``` +//! # use serde::{Deserialize, Serialize}; +//! # use zeroize::Zeroize; +//! # #[derive(Serialize, Deserialize, Zeroize)] +//! # struct Card { number: String, expiry: (u8, u16) } +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::{OpaqueBlob, StackCipher}; +//! let cipher = StackCipher::builder() +//! .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! let card = Card { number: "4111 1111 1111 1111".into(), expiry: (12, 2031) }; +//! let ct = keyset.encrypt_with_context(OpaqueBlob::new(card), "users/42/card").await?; +//! let card: OpaqueBlob = keyset.decrypt_with_context(ct, "users/42/card").await?; +//! assert_eq!(card.get().expiry, (12, 2031)); +//! # Ok(()) +//! # } +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); +//! ``` +//! //! Existing structural plans and derives still use [`StackCipherText`], a tree //! of sealed leaves. Their low-level direct entry points are //! [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`]; their //! borrowed conversion helpers live in [`target::legacy`]. They are separate -//! from the owned scalar API. There is no generic `Serialize` fallback and -//! no implicit `Vec` blob. +//! from the owned scalar API: a struct sealed through them is one ciphertext +//! per field, and a struct in an [`OpaqueBlob`] is one ciphertext. //! //! # What is authenticated //! @@ -292,8 +323,10 @@ endpoint β€” are `StackKmsBuilder`'s, and the two keyset-cache knobs are /// makes its requests. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub mod blob; pub mod cipher; mod scalar; +pub use blob::{BlobError, OpaqueBlob}; pub use scalar::{Ciphertext, OpaqueDecode, OpaqueEncode}; #[cfg(test)] mod codes; diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs index b5aadf1f8..1e48be395 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -4,25 +4,54 @@ use crate::target::{ DecryptFrom, DecryptSource, Decryption, EncryptInto, EncryptTarget, Encryption, OptionalContext, Pending, ScalarContext, }; -use crate::{CipherText, Decrypt, Error, KeysetCipher, LeafBytesError, SealedValue}; +use crate::{CipherText, ContextPiece, Error, KeysetCipher, LeafBytesError, SealedValue}; use vitaminc_aead::plaintext::{Encrypt, Sink}; use vitaminc_protected::OpaqueDebug; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; -mod sealed { - pub trait Encode { +pub(crate) mod sealed { + use crate::target::Pending; + use crate::{Ciphertext, ContextPiece, KeysetCipher, SealedValue, StackCipher}; + + /// How a value becomes one sealed leaf. Planning, and for a blob the + /// encoding before it, runs synchronously inside this call, so the + /// pending it returns holds no plaintext the caller still owns. + pub trait Encode: Sized { + fn seal<'a, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + context: ContextPiece<'static>, + ) -> Pending<'a, Ciphertext, K>; + } + + /// A scalar's own Vitamin C encoding, by reference: what `ScalarRef` + /// forwards to. + pub(crate) trait Scalar { const DEPTH: Option; const FIXED: Option; fn visit(&self, sink: S) -> Result; } - pub trait Decode {} + + /// How one sealed leaf opens to a value. The leaf authenticates inside + /// the pending, before any decoding the type adds on top. + pub trait Decode: Sized { + fn open<'a, K: 'static>( + leaf: SealedValue, + cipher: &'a StackCipher, + context: ContextPiece<'static>, + ) -> Pending<'a, Self, K>; + } } /// A plaintext type that encrypts as one opaque [`Ciphertext`]. /// /// Implemented for `bool`, the fixed-width signed integers, the unsigned -/// integers from `u16` through `u128`, `f32`, `f64` and `String`. The trait is -/// sealed: collections and borrowed values deliberately do not implement it. +/// integers from `u16` through `u128`, `f32`, `f64` and `String`; for +/// [`OpaqueBlob`](crate::OpaqueBlob), which encodes a whole `Serialize` +/// value as one storage blob first; and for `Zeroizing` of any of those. The +/// trait is sealed: collections and borrowed values deliberately do not +/// implement it, and there is no blanket `Serialize` fallback. A composite +/// value is encrypted as one ciphertext only by naming `OpaqueBlob`. /// `u8` waits on separating Vitamin C's byte-buffer and element /// representations, and must not make `Vec` an implicit single /// ciphertext. @@ -33,33 +62,84 @@ pub trait OpaqueEncode: sealed::Encode + Zeroize + Send + 'static {} /// A plaintext type that one opaque [`Ciphertext`] decrypts to: the types /// that implement [`OpaqueEncode`]. Sealed, like it. -pub trait OpaqueDecode: sealed::Decode + Decrypt + Zeroize + Send + 'static {} +/// +/// A successful decrypt returns an ordinary owned value, and nothing wipes +/// it when it is dropped: `Zeroize` is the bound, not a drop guard. Ask for +/// `Zeroizing

` in place of `P` to have the result wiped on drop. +pub trait OpaqueDecode: sealed::Decode + Zeroize + Send + 'static {} macro_rules! opaque { ($($ty:ty),* $(,)?) => {$( - impl sealed::Encode for $ty { + impl sealed::Scalar for $ty { const DEPTH: Option = <&$ty as Encrypt>::DEPTH; const FIXED: Option = <&$ty as Encrypt>::FIXED; fn visit(&self, sink: S) -> Result { Encrypt::visit(self, sink) } } - impl sealed::Decode for $ty {} + impl sealed::Encode for $ty { + fn seal<'a, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + context: ContextPiece<'static>, + ) -> Pending<'a, Ciphertext, K> { + crate::target::core::encrypt_opaque(ScalarRef(self), cipher, context) + .try_map(Ciphertext::from_tree) + } + } + impl sealed::Decode for $ty { + fn open<'a, K: 'static>( + leaf: SealedValue, + cipher: &'a crate::StackCipher, + context: ContextPiece<'static>, + ) -> Pending<'a, Self, K> { + crate::target::core::open_opaque(CipherText::Single(leaf), cipher, context) + } + } impl OpaqueEncode for $ty {} impl OpaqueDecode for $ty {} )*}; } opaque!(bool, i8, i16, i32, i64, i128, u16, u32, u64, u128, f32, f64, String); +/// A `Zeroizing` encrypts as its `S` does: the way to hand the scalar +/// API a value that is wiped even if it is dropped before it is consumed. +impl sealed::Encode for Zeroizing { + fn seal<'a, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + context: ContextPiece<'static>, + ) -> Pending<'a, Ciphertext, K> { + S::seal(self, cipher, context) + } +} +impl OpaqueEncode for Zeroizing {} + +/// Decrypting to `Zeroizing

` is the opt-in drop-guarded result: the +/// value is opened as a `P` and wrapped before it is returned, so dropping +/// it wipes it. The wrap is a move, and a move does not erase what a `Copy` +/// value left on the stack; the guard covers the value's own storage (a +/// `String`'s buffer, a struct's fields) from then on. +impl sealed::Decode for Zeroizing

{ + fn open<'a, K: 'static>( + leaf: SealedValue, + cipher: &'a crate::StackCipher, + context: ContextPiece<'static>, + ) -> Pending<'a, Self, K> { + P::open(leaf, cipher, context).map(Zeroizing::new) + } +} +impl OpaqueDecode for Zeroizing

{} + pub(crate) struct ScalarRef<'a, S>(pub &'a S); // Vitamin C 0.8 implements Encrypt for &Protected, but not ProtectedRef. // This bridge preserves its encoding metadata while the owned conversion keeps // the source in Protected. Replace it when the encoder accepts protected borrows. -impl Encrypt for ScalarRef<'_, S> { - const DEPTH: Option = ::DEPTH; - const FIXED: Option = ::FIXED; +impl Encrypt for ScalarRef<'_, S> { + const DEPTH: Option = ::DEPTH; + const FIXED: Option = ::FIXED; fn visit(self, sink: V) -> Result { - sealed::Encode::visit(self.0, sink) + sealed::Scalar::visit(self.0, sink) } } @@ -98,6 +178,19 @@ impl Ciphertext { } } } +/// The one sealed leaf, as the structural API holds it. The bytes are the +/// same: this is how a caller holding a `CipherText::Single` from +/// [`KeysetCipher::encrypt_tree`] opens it through the owned API. +impl From for Ciphertext { + fn from(leaf: SealedValue) -> Self { + Self(leaf) + } +} +impl From for SealedValue { + fn from(ciphertext: Ciphertext) -> Self { + ciphertext.0 + } +} /// Human-readable formats store the canonical bytes as padded standard /// Base64; binary formats store them as bytes. impl serde::Serialize for Ciphertext { @@ -159,9 +252,7 @@ impl DecryptSource

for Ciphertext { type ContextInput = OptionalContext; fn decryption(self, context: Self::ContextInput) -> Decryption { let context = context.piece(); - Decryption::from_open(move |cipher| { - crate::target::core::open_opaque(CipherText::Single(self.0), cipher, context) - }) + Decryption::from_open(move |cipher| P::open(self.0, cipher, context)) } } impl KeysetCipher<'_, K> { diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index cc50c253e..cb878c1af 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -462,12 +462,15 @@ pub fn ciphertext<'s, S: crate::Encrypt + 's, K: 'static, M: ConsumeSource<'s, S ), } } -/// Encrypt a supported scalar as one opaque ciphertext, permitting empty context. +/// Encrypt a supported scalar, or an [`OpaqueBlob`](crate::OpaqueBlob), as +/// one opaque ciphertext, permitting empty context. /// /// This operation borrows its source. The owned [`EncryptInto`](super::EncryptInto) /// conversions supply a [`Protected`](vitaminc_protected::Protected) owner and /// wipe it after planning. Calling [`KeysetCipher::run`] directly leaves source /// cleanup with the caller; this operation alone does not wipe that borrow. +/// A blob is encoded here too, synchronously, so an encoding refusal is the +/// pending's result and no plaintext outlives this call in the pending. /// Mapping the encrypted output preserves the scalar's optional-context policy; /// it does not turn this into an EQL ciphertext or term operation. A wrapper may /// require caller context with [`RequiredContext`](super::RequiredContext), or @@ -477,12 +480,7 @@ pub fn scalar_ciphertext<'s, S: crate::OpaqueEncode, K: 'static>( Encryption { build: Box::new( move |source, cipher, context: super::OptionalContext| { - super::core::encrypt_opaque( - crate::scalar::ScalarRef(source), - cipher, - context.piece(), - ) - .try_map(crate::Ciphertext::from_tree) + crate::scalar::sealed::Encode::seal(source, cipher, context.piece()) }, ), } diff --git a/packages/stack-encrypt/tests/common/conformance.rs b/packages/stack-encrypt/tests/common/conformance.rs index 073483d4a..65be03cca 100644 --- a/packages/stack-encrypt/tests/common/conformance.rs +++ b/packages/stack-encrypt/tests/common/conformance.rs @@ -2,8 +2,9 @@ use super::frames::frame; use serde::Deserialize; use stack_encrypt::dynamic::ValueFrame; +use vitaminc_aead::plaintext::{Int, Logical, Physical, Raw, TimeUnit}; -#[derive(Deserialize)] +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Input { #[serde(rename = "type")] @@ -11,23 +12,161 @@ pub struct Input { pub text: String, } +/// The typed host value an `Input` names, as the engine holds it: every +/// integer at its own width, a logical type over its physical scalar, and +/// text parsed without rounding. A text the type cannot carry is a corpus +/// error, never a silently narrowed value. pub fn value(input: &Input) -> ValueFrame { + let int = |int: Int| frame(Raw(None, Physical::Int(int))); + let text = input.text.as_str(); match input.kind.as_str() { - "int32" => frame(input.text.parse::().expect("i32")), - "int64" => frame(input.text.parse::().expect("i64")), - "uint64" => frame(input.text.parse::().expect("u64")), - "float64" => frame(input.text.parse::().expect("f64")), - "bool" => frame(input.text.parse::().expect("bool")), + "bool" => frame(text.parse::().expect("bool")), + "int8" => int(Int::I8(text.parse().expect("i8"))), + "int16" => int(Int::I16(text.parse().expect("i16"))), + "int32" => frame(text.parse::().expect("i32")), + "int64" => frame(text.parse::().expect("i64")), + "int128" => int(Int::I128(text.parse().expect("i128"))), + "uint8" => int(Int::U8(text.parse().expect("u8"))), + "uint16" => int(Int::U16(text.parse().expect("u16"))), + "uint32" => int(Int::U32(text.parse().expect("u32"))), + "uint64" => frame(text.parse::().expect("u64")), + "uint128" => int(Int::U128(text.parse().expect("u128"))), + "float32" => frame(parse_float::(text)), + "float64" => frame(parse_float::(text)), "string" => frame(input.text.clone()), + "bytes" => frame(super::unhex(text)), + "date" => frame(Raw( + Some(Logical::Date), + Physical::Int(Int::I32(parse_date(text))), + )), + "timestamp" => frame(Raw( + Some(Logical::Timestamp(TimeUnit::Nanos)), + Physical::Int(Int::I128(parse_timestamp(text))), + )), + "decimal" => { + let (mantissa, scale) = parse_decimal(text); + frame(Raw( + Some(Logical::Decimal { scale }), + Physical::Int(Int::I128(mantissa)), + )) + } "null" => { assert!(input.text.is_empty()); - use vitaminc_aead::plaintext::{Physical, Raw}; frame(Raw(None, Physical::Null)) } other => panic!("unsupported input kind {other}"), } } +/// A float from its canonical spelling ([`canonical_float`]). Any other +/// text that parses, such as `1.00`, `1e+3`, `1E3`, `+1` or a rounded +/// decimal, is a corpus error, so every runner reads one spelling alike. +fn parse_float(text: &str) -> F +where + F: std::str::FromStr + std::fmt::Display + std::fmt::LowerExp + PartialEq + Copy, + F::Err: std::fmt::Debug, +{ + let parsed: F = text.parse().expect("float"); + assert_eq!( + canonical_float(parsed).as_deref(), + Some(text), + "{text}: not the shortest spelling of a representable value" + ); + parsed +} + +/// A float's canonical spelling: of its positional spelling (Rust's `{}`, +/// e.g. `34`, `-0`, `0.001`) and its scientific spelling (Rust's `{:e}`, +/// e.g. `1e3`, `3.4028235e38`, `5e-324`), both of the shortest digits that +/// round-trip, the shorter, and the positional one on a tie (`100`, not +/// `1e2`). The infinities are `inf` and `-inf`; `NaN` has none. Go's +/// `canonicalFloat` derives the same text from `strconv.FormatFloat`. +pub fn canonical_float(value: F) -> Option +where + F: std::fmt::Display + std::fmt::LowerExp + PartialEq + Copy, +{ + #[allow(clippy::eq_op)] + if value != value { + return None; + } + let positional = format!("{value}"); + let scientific = format!("{value:e}"); + Some(if scientific.len() < positional.len() { + scientific + } else { + positional + }) +} + +/// Days since 1970-01-01 of a proleptic Gregorian `YYYY-MM-DD`. +fn parse_date(text: &str) -> i32 { + let (year, rest) = text.split_at(text.len() - 6); + let year: i64 = year.parse().expect("year"); + let mut parts = rest[1..].split('-'); + let month: u32 = parts.next().expect("month").parse().expect("month"); + let day: u32 = parts.next().expect("day").parse().expect("day"); + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days_in_month = match month { + 2 if leap => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + }; + assert!( + parts.next().is_none() && (1..=12).contains(&month) && (1..=days_in_month).contains(&day), + "{text}: not a calendar date" + ); + i32::try_from(days_from_civil(year, month, day)).expect("date in range") +} + +/// Howard Hinnant's `days_from_civil`: the day count since the epoch of a +/// civil date, for any year. +fn days_from_civil(year: i64, month: u32, day: u32) -> i64 { + let y = if month <= 2 { year - 1 } else { year }; + let era = y.div_euclid(400); + let yoe = y.rem_euclid(400); + let mp = i64::from((month + 9) % 12); + let doy = (153 * mp + 2) / 5 + i64::from(day) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// Nanoseconds since the epoch of an RFC 3339 instant in UTC +/// (`YYYY-MM-DDTHH:MM:SS[.fraction]Z`), to the nanosecond, with no rounding. +fn parse_timestamp(text: &str) -> i128 { + let text = text.strip_suffix('Z').expect("a UTC instant ends in Z"); + let (date, time) = text.split_once('T').expect("a date and a time"); + let (clock, fraction) = time.split_once('.').unwrap_or((time, "")); + let mut parts = clock.split(':'); + let hour: i128 = parts.next().expect("hour").parse().expect("hour"); + let minute: i128 = parts.next().expect("minute").parse().expect("minute"); + let second: i128 = parts.next().expect("second").parse().expect("second"); + assert!(parts.next().is_none() && hour < 24 && minute < 60 && second < 60); + assert!(fraction.len() <= 9, "at most nanoseconds"); + let nanos: i128 = if fraction.is_empty() { + 0 + } else { + format!("{fraction:0<9}").parse().expect("fraction") + }; + let days = i128::from(parse_date(date)); + ((days * 86_400 + hour * 3_600 + minute * 60 + second) * 1_000_000_000) + nanos +} + +/// A decimal's mantissa and scale from fixed-point text, keeping every +/// trailing zero: `-1234.5600` is `(-12345600, 4)`. +fn parse_decimal(text: &str) -> (i128, u8) { + let (whole, fraction) = text.split_once('.').unwrap_or((text, "")); + let digits = format!("{whole}{fraction}"); + let mantissa: i128 = digits.parse().expect("decimal digits"); + assert!( + mantissa.unsigned_abs() < (1u128 << 96), + "mantissa fits 96 bits" + ); + let scale = u8::try_from(fraction.len()).expect("scale"); + assert!(scale <= 28, "scale at most 28"); + (mantissa, scale) +} + /// Inspect actual stored output, shared by scalar and runtime profiles. pub fn stored_outputs( tree: &stack_encrypt::StackCipherText, diff --git a/packages/stack-encrypt/tests/common/mod.rs b/packages/stack-encrypt/tests/common/mod.rs index 0bcd7faa3..38de5d740 100644 --- a/packages/stack-encrypt/tests/common/mod.rs +++ b/packages/stack-encrypt/tests/common/mod.rs @@ -85,8 +85,10 @@ pub async fn stack_cipher() -> StackCipher { .expect("build cipher") } -pub struct CountingSource { - inner: FakeDataKeySource, +/// Over the fake source by default; [`CountingSource::over`] counts the +/// calls another source answers, such as a seeded deterministic one. +pub struct CountingSource { + inner: S, generate_calls: Arc, retrieve_calls: Arc, load_calls: Arc, @@ -94,8 +96,14 @@ pub struct CountingSource { impl CountingSource { pub fn new() -> Self { + Self::over(FakeDataKeySource::new()) + } +} + +impl CountingSource { + pub fn over(inner: S) -> Self { Self { - inner: FakeDataKeySource::new(), + inner, generate_calls: Arc::new(AtomicUsize::new(0)), retrieve_calls: Arc::new(AtomicUsize::new(0)), load_calls: Arc::new(AtomicUsize::new(0)), @@ -113,7 +121,7 @@ impl CountingSource { } } -impl DataKeySource for CountingSource { +impl DataKeySource for CountingSource { async fn generate_keys( &self, payloads: Vec>, @@ -139,7 +147,7 @@ impl DataKeySource for CountingSource { } } -impl IndexKeySource for CountingSource { +impl IndexKeySource for CountingSource { async fn load_index_key( &self, keyset_id: Option, diff --git a/packages/stack-encrypt/tests/heap_hygiene.rs b/packages/stack-encrypt/tests/heap_hygiene.rs index 001b42c73..67423abc0 100644 --- a/packages/stack-encrypt/tests/heap_hygiene.rs +++ b/packages/stack-encrypt/tests/heap_hygiene.rs @@ -34,6 +34,13 @@ //! copy left in its dead frame would be picked up the same way by a future //! built later at that depth (see [`holds_key`]). //! +//! A free is what the spy sees, so a block that is never freed (a +//! `mem::forget`, a `Box::leak`) passes [`watched`]. [`watched_for_leaks`] +//! closes that for the tests that use it: it also records every block +//! allocated during the watch, and fails if one still live when the watch +//! ends holds the marker. It is opt-in because a watch that returns +//! plaintext to its caller keeps that plaintext live on purpose. +//! //! Unix only: the spy scans a freed block with libc's `memmem`, not as a //! Rust slice (see [`holds_mark`]). #![cfg(all(feature = "dynamic", unix))] @@ -43,6 +50,7 @@ mod common; use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use common::frames::{frame, object}; @@ -72,6 +80,30 @@ static UPSTREAM: Mutex = Mutex::new(0); /// allocation: a fixed array behind a lock. static KEY: Mutex> = Mutex::new(None); +/// How many blocks [`watched_for_leaks`] can track at once. A watch that +/// outgrows it fails rather than passing on a partial record. +const LIVE_SLOTS: usize = 16 * 1024; + +/// The blocks allocated on the watched thread during a +/// [`watched_for_leaks`] watch and not yet freed, as `(address, size)`. A +/// fixed array behind a lock: recording one must not allocate. +struct Live { + blocks: [(usize, usize); LIVE_SLOTS], + len: usize, + overflowed: bool, +} + +static LIVE: Mutex = Mutex::new(Live { + blocks: [(0, 0); LIVE_SLOTS], + len: 0, + overflowed: false, +}); + +/// Whether [`LIVE`] is recording. Read on every allocation and free, on +/// every thread, so a block the watched thread allocated is forgotten +/// wherever it is freed. +static TRACKING: AtomicBool = AtomicBool::new(false); + /// The vitaminc frame whose free is excluded (see the module docs). const UPSTREAM_SEAL: &str = "vitaminc_encrypt::backend::aws_lc::CipherKey::seal"; @@ -143,7 +175,23 @@ unsafe impl GlobalAlloc for Spy { // owner left it, and a block that is only partly written (a boxed // future's state, a `Vec`'s spare capacity) would otherwise show // what an earlier, excluded free left there and be blamed for it. - System.alloc_zeroed(layout) + let ptr = System.alloc_zeroed(layout); + if !ptr.is_null() + && TRACKING.load(Ordering::SeqCst) + && WATCHING.with(Cell::get) + && !BUSY.with(Cell::get) + { + if let Ok(mut live) = LIVE.lock() { + if live.len < LIVE_SLOTS { + let at = live.len; + live.blocks[at] = (ptr as usize, layout.size()); + live.len += 1; + } else { + live.overflowed = true; + } + } + } + ptr } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { @@ -168,6 +216,15 @@ unsafe impl GlobalAlloc for Spy { BUSY.with(|busy| busy.set(false)); } } + if TRACKING.load(Ordering::SeqCst) { + if let Ok(mut live) = LIVE.lock() { + let len = live.len; + if let Some(at) = live.blocks[..len].iter().position(|b| b.0 == ptr as usize) { + live.blocks[at] = live.blocks[len - 1]; + live.len -= 1; + } + } + } System.dealloc(ptr, layout) } } @@ -214,6 +271,46 @@ fn watched(what: &str, f: impl FnOnce() -> T) -> T { out } +/// [`watched`], and also fail if a block allocated during the watch is +/// still live when it ends and holds the marker: plaintext leaked rather +/// than freed, which the free-time scan cannot see. `f` returns nothing, +/// so nothing it built is meant to outlive it. +fn watched_for_leaks(what: &str, f: impl FnOnce()) { + { + let mut live = LIVE.lock().unwrap(); + live.len = 0; + live.overflowed = false; + } + TRACKING.store(true, Ordering::SeqCst); + // Stop recording whether `watched` returns or panics. + struct Stop; + impl Drop for Stop { + fn drop(&mut self) { + TRACKING.store(false, Ordering::SeqCst); + } + } + let stop = Stop; + watched(what, f); + drop(stop); + let live = LIVE.lock().unwrap(); + assert!( + !live.overflowed, + "{what}: more than {LIVE_SLOTS} blocks were live at once; raise LIVE_SLOTS" + ); + let leaked: Vec = live.blocks[..live.len] + .iter() + // SAFETY: every recorded block is still allocated: a free removes + // its record while `TRACKING` is on, and it was on until `stop`. + .filter(|(ptr, size)| unsafe { holds_mark(*ptr as *const u8, *size) }) + .map(|(_, size)| *size) + .collect(); + assert!( + leaked.is_empty(), + "{what}: {} heap blocks allocated during the watch are still live with plaintext in them (sizes {leaked:?})", + leaked.len() + ); +} + fn marked(tail: &str) -> String { format!("{}-{tail}", std::str::from_utf8(MARK).unwrap()) } @@ -338,6 +435,37 @@ fn a_record_decrypted_with_a_nested_field_leaves_no_plaintext_on_the_heap() { }); } +/// A valid first row is followed by an authenticated value of the wrong type. +/// The failed batch returns no output and releases its decoded prefix cleanly. +#[test] +fn a_late_read_type_failure_leaves_no_plaintext_on_the_heap() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let writer = record::plan(object(vec![spec("value", &["c"], None)]).risky_ref()).unwrap(); + let reader = + record::plan(object(vec![spec("value", &["c"], Some("string"))]).risky_ref()).unwrap(); + let source = frame(vec![ + object(vec![("value", frame(marked("first-row")))]), + object(vec![("value", frame(true))]), + ]); + let sealed = rt.block_on(async { record::encrypt(&keyset, source, &writer).unwrap().await }); + let sealed = sealed.unwrap(); + watched("late authenticated read type failure", || { + let result = rt.block_on(async { + record::decrypt(Scope::Client(&cipher), sealed, &reader, None) + .unwrap() + .await + }); + assert!(matches!( + result, + Err(stack_encrypt::Error::Plan( + stack_encrypt::plan::PlanError::FieldType { .. } + )) + )); + }); +} + #[test] fn a_query_term_leaves_no_plaintext_on_the_heap() { let rt = runtime(); @@ -564,6 +692,170 @@ fn the_spy_sees_an_unwiped_free() { watched("control", || drop(text)); } +/// The leak half's negative control: a marked block allocated during the +/// watch and never freed is seen by [`watched_for_leaks`], though the +/// free-time scan alone passes it. +#[test] +#[should_panic(expected = "still live with plaintext in them")] +fn the_spy_sees_a_leaked_block() { + let text = marked("control"); + watched("control (leak, free-time scan only)", || { + std::mem::forget(text.clone()) + }); + watched_for_leaks("control (leak)", || std::mem::forget(text.clone())); +} + +/// `OpaqueBlob`: the owned source, the storage blob it is encoded into +/// (which grows past its first allocation while it already holds the +/// marker), the planned leaf, and on decrypt the opened buffer the blob +/// is read from, all freed wiped, and none of them leaked rather than +/// freed. The opened value is the caller's and is wiped by hand before the +/// watch ends; a refusal of another shape, found before any field is +/// built, leaves nothing to wipe. +mod opaque_blob { + use super::*; + + use serde::{Deserialize, Serialize}; + use stack_encrypt::{Error, OpaqueBlob}; + use zeroize::Zeroize; + + #[derive(Serialize, Deserialize, Zeroize)] + struct Doc { + title: String, + notes: Vec, + /// Written after the marked fields, so the blob's buffer regrows + /// while it holds them. + body: String, + n: u32, + } + + fn doc() -> OpaqueBlob { + OpaqueBlob::new(Doc { + title: marked("title"), + notes: vec![marked("note-1"), marked("note-2")], + body: "x".repeat(8 * 1024), + n: 7, + }) + } + + #[test] + fn an_opaque_blob_encrypted_leaves_no_plaintext_on_the_heap() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let source = doc(); + watched_for_leaks("OpaqueBlob encrypt", || { + rt.block_on(async { + keyset + .encrypt_with_context(source, "docs/body") + .await + .unwrap() + }); + }); + } + + #[test] + fn an_opaque_blob_decrypted_leaves_no_plaintext_on_the_heap() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let sealed = rt.block_on(async { + keyset + .encrypt_with_context(doc(), "docs/body") + .await + .unwrap() + }); + watched_for_leaks("OpaqueBlob decrypt", || { + let mut opened = rt.block_on(async { + keyset + .decrypt_with_context::>(sealed, "docs/body") + .await + .unwrap() + }); + // The opened value is an ordinary owned value, holding the + // plaintext until the caller wipes it. + assert!(opened + .get() + .title + .as_bytes() + .windows(MARK.len()) + .any(|w| w == MARK)); + opened.zeroize(); + drop(opened); + }); + } + + /// Refused before `Deserialize` builds a field: the first field of the + /// target does not read the blob's text, so nothing of the marked value + /// is built, and the opened buffer is wiped with the refusal. + #[test] + fn an_opaque_blob_of_another_shape_leaves_no_plaintext_on_the_heap() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let sealed = rt.block_on(async { + keyset + .encrypt_with_context(doc(), "docs/body") + .await + .unwrap() + }); + #[derive(Deserialize, Zeroize)] + #[allow(dead_code)] + struct Other { + title: u32, + } + watched_for_leaks("OpaqueBlob decrypt (mismatch)", || { + let result = rt.block_on(async { + keyset + .decrypt_with_context::>(sealed, "docs/body") + .await + }); + assert!(matches!( + result, + Err(Error::Blob(stack_encrypt::BlobError::Mismatch(_))) + )); + }); + } + + /// The limit the blob docs state, held as a negative control: a derived + /// `Deserialize` that built a `String` field and then refuses a later + /// field drops that `String` as an ordinary value, unwiped, because no + /// `T` was ever constructed to wipe. The opened buffer itself is wiped; + /// what leaks is serde's partial value. A type whose fields wipe on drop + /// closes it; this test documents that the library does not. + #[test] + #[should_panic(expected = "freed with plaintext in them")] + fn a_partial_decode_drops_the_fields_it_built_unwiped() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let sealed = rt.block_on(async { + keyset + .encrypt_with_context(doc(), "docs/body") + .await + .unwrap() + }); + #[derive(Deserialize, Zeroize)] + #[allow(dead_code)] + struct Later { + title: String, + notes: Vec, + body: u32, + } + watched("OpaqueBlob decrypt (partial)", || { + let result = rt.block_on(async { + keyset + .decrypt_with_context::>(sealed, "docs/body") + .await + }); + assert!(matches!( + result, + Err(Error::Blob(stack_encrypt::BlobError::Mismatch(_))) + )); + }); + } +} + /// The JSON index: sealing a document under either root option, opening it /// whole (from the sealed root, and by walking its chain with the root /// open), opening a subtree read's rows and an extracted entry, and diff --git a/packages/stack-encrypt/tests/json_operators.rs b/packages/stack-encrypt/tests/json_operators.rs index 63e913121..5d4544dbb 100644 --- a/packages/stack-encrypt/tests/json_operators.rs +++ b/packages/stack-encrypt/tests/json_operators.rs @@ -2183,6 +2183,39 @@ mod compiled_match { ) } + /// Shared native/Go predicate refusals preserve structured diagnostics. + #[test] + fn shared_predicate_refusals() { + let cases: Value = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/eql-json.json" + )) + .unwrap(); + assert_eq!(cases["version"], 1); + for case in cases["predicate_refusals"].as_array().unwrap() { + let predicate = case["predicate"].as_str().unwrap(); + assert_eq!( + predicate.find(case["at_text"].as_str().unwrap()), + Some(case["at"].as_u64().unwrap() as usize) + ); + let request = match case.get("vars") { + Some(vars) => input(predicate, vars.clone()), + None => json!({"predicate": predicate}), + }; + let error = check(&request).unwrap_err(); + let payload = stack_encrypt::ErrorPayload::payload(&error); + assert_eq!( + error.reason().map(Reason::as_str), + case["reason"].as_str(), + "{}", + case["id"] + ); + assert_eq!(payload["at"], case["at"], "{}", case["id"]); + assert_eq!(payload["construct"], case["construct"], "{}", case["id"]); + assert_eq!(error.field(), Some("profile")); + assert!(!format!("{error} {payload:?}").contains("secret")); + } + } + /// A refusal names its reason, the construct and its offset in the /// caller's text, and the field; the input's own shape is refused /// as the other operators refuse theirs. diff --git a/packages/stack-encrypt/tests/json_tree_vectors.rs b/packages/stack-encrypt/tests/json_tree_vectors.rs index 2a3c5bfb4..467a973ec 100644 --- a/packages/stack-encrypt/tests/json_tree_vectors.rs +++ b/packages/stack-encrypt/tests/json_tree_vectors.rs @@ -171,6 +171,11 @@ async fn the_derivations_are_pinned_under_the_deterministic_source() { "sealed_root": vectors(&JsonOptions::default()).await, "open_root": vectors(&JsonOptions { sealed_root: false, ..JsonOptions::new() }).await, }); + assert!( + std::env::var("STACK_CONFORMANCE_REQUIRED").as_deref() != Ok("1") + || std::env::var_os("STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS").is_none(), + "required conformance must not regenerate JSON tree vectors" + ); if update_requested(std::env::var_os("STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS").as_deref()) { std::fs::write( fixture_path(), @@ -213,3 +218,22 @@ async fn the_deterministic_source_seals_the_same_tags_twice() { let two = vectors(&JsonOptions::default()).await; assert_eq!(one, two, "a pin needs a run that repeats"); } + +#[test] +fn required_checks_refuse_json_vector_updates() { + for value in ["", "0", "1"] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "the_derivations_are_pinned_under_the_deterministic_source", + "--nocapture", + ]) + .env("STACK_CONFORMANCE_REQUIRED", "1") + .env("STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS", value) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("required conformance must not regenerate JSON tree vectors")); + } +} diff --git a/packages/stack-encrypt/tests/lifecycle_conformance.rs b/packages/stack-encrypt/tests/lifecycle_conformance.rs new file mode 100644 index 000000000..5aef1a893 --- /dev/null +++ b/packages/stack-encrypt/tests/lifecycle_conformance.rs @@ -0,0 +1,181 @@ +//! Observe the lifetime of pending work independently of the provider's result. +//! Drop witnesses prove ownership release, not erasure of arbitrary host values. +#![cfg(feature = "dynamic")] + +mod common; + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::future::IntoFuture; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Waker}; + +use common::frames::frame; +use stack_encrypt::dynamic::{record, ValueFrame}; +use stack_encrypt::{Pending, StackCipher, StackCipherText}; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyError, + GenerateKeyPayload, IdentifiedBy, IndexKey, IndexKeySource, RetrieveKeyPayload, + UnverifiedContext, +}; +use uuid::Uuid; + +#[derive(Default)] +struct Observations { + block: AtomicBool, + fail: AtomicBool, + batches: Mutex>, + provider_drops: Arc, +} + +struct DropWitness(Arc); +impl Drop for DropWitness { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +struct Source { + inner: FakeDataKeySource, + observed: Arc, +} +impl DataKeySource for Source { + async fn generate_keys( + &self, + payloads: Vec>, + keyset: Option, + context: Option>, + ) -> Result, stack_kms::Error> { + self.observed.batches.lock().unwrap().push(payloads.len()); + let _owned_request = DropWitness(self.observed.provider_drops.clone()); + if self.observed.block.load(Ordering::SeqCst) { + std::future::pending::<()>().await; + } + if self.observed.fail.load(Ordering::SeqCst) { + return Err(GenerateKeyError::Unauthorized.into()); + } + self.inner.generate_keys(payloads, keyset, context).await + } + + async fn retrieve_keys( + &self, + payloads: Vec>, + keyset: Option, + context: Option<&UnverifiedContext>, + ) -> Result, stack_kms::Error> { + self.inner.retrieve_keys(payloads, keyset, context).await + } +} +impl IndexKeySource for Source { + async fn load_index_key( + &self, + keyset: Option, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset).await + } +} + +async fn cipher() -> (StackCipher, Arc) { + let observed = Arc::new(Observations::default()); + let cipher = StackCipher::builder() + .kms(Source { + inner: FakeDataKeySource::new(), + observed: observed.clone(), + }) + .init() + .await + .unwrap(); + (cipher, observed) +} + +fn plan() -> record::Plan { + record::plan( + &vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ + "value": {"context": ["lifecycle", "value"], "outputs": ["c"], "type": "string"} + })) + .unwrap(), + ) + .unwrap() +} +fn rows(count: usize) -> ValueFrame { + frame(vec![BTreeMap::from([("value", "lifecycle-input")]); count]) +} + +// The witness is owned by the real operation's fulfilment, not by the test's +// future. Dropping it therefore observes release of that fulfilment's captures. +fn observed<'a>( + pending: Pending<'a, StackCipherText, Source>, + drops: Arc, +) -> Pending<'a, StackCipherText, Source> { + let witness = DropWitness(drops); + pending.map(move |output| { + drop(witness); + output + }) +} + +#[tokio::test] +async fn dropping_unpolled_work_releases_captures_without_key_io() { + let (cipher, calls) = cipher().await; + let keyset = cipher.default_keyset(); + let drops = Arc::new(AtomicUsize::new(0)); + let pending = observed( + record::encrypt(&keyset, rows(2), &plan()).unwrap(), + drops.clone(), + ); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert!(calls.batches.lock().unwrap().is_empty()); + drop(pending); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!(calls.batches.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn cancelling_in_flight_work_drops_provider_and_captures() { + let (cipher, calls) = cipher().await; + calls.block.store(true, Ordering::SeqCst); + let keyset = cipher.default_keyset(); + let drops = Arc::new(AtomicUsize::new(0)); + let mut future = observed( + record::encrypt(&keyset, rows(3), &plan()).unwrap(), + drops.clone(), + ) + .into_future(); + assert!(future + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending()); + assert_eq!(*calls.batches.lock().unwrap(), vec![3]); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert_eq!(calls.provider_drops.load(Ordering::SeqCst), 0); + drop(future); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(calls.provider_drops.load(Ordering::SeqCst), 1); + // Cancellation does not poison the cipher or execute the abandoned work. + calls.block.store(false, Ordering::SeqCst); + record::encrypt(&keyset, rows(1), &plan()) + .unwrap() + .await + .unwrap(); + assert_eq!(*calls.batches.lock().unwrap(), vec![3, 1]); +} + +#[tokio::test] +async fn success_and_provider_failure_release_captures_once() { + for fail in [false, true] { + let (cipher, calls) = cipher().await; + calls.fail.store(fail, Ordering::SeqCst); + let keyset = cipher.default_keyset(); + let drops = Arc::new(AtomicUsize::new(0)); + let result = observed( + record::encrypt(&keyset, rows(4), &plan()).unwrap(), + drops.clone(), + ) + .await; + assert_eq!(result.is_err(), fail); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(calls.provider_drops.load(Ordering::SeqCst), 1); + assert_eq!(*calls.batches.lock().unwrap(), vec![4]); + } +} diff --git a/packages/stack-encrypt/tests/opaque_blob.rs b/packages/stack-encrypt/tests/opaque_blob.rs new file mode 100644 index 000000000..2542da49d --- /dev/null +++ b/packages/stack-encrypt/tests/opaque_blob.rs @@ -0,0 +1,771 @@ +//! `OpaqueBlob`: a whole value as one opaque ciphertext. Round trips, +//! the leaf it shares with the structural writer and the Go SDK, fixed +//! vectors, refusals by class after authentication, and the owned source's +//! cleanup. No service credentials are needed. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use stack_encrypt::blob::{HEADER, VERSION}; +use stack_encrypt::{ + BlobError, CipherText, Ciphertext, Controlled, DecryptFrom, EncryptInto, Error, OpaqueBlob, + SealedValue, +}; +use zeroize::{Zeroize, Zeroizing}; + +mod common; + +#[derive(Serialize, Deserialize, Zeroize, Clone, PartialEq, Debug)] +struct Profile { + name: String, + age: u32, + tags: Vec, + nickname: Option, +} + +fn ada() -> Profile { + Profile { + name: "Ada Lovelace".into(), + age: 36, + tags: vec!["maths".into(), "engines".into()], + nickname: Some("ada".into()), + } +} + +fn unhex(hex: &str) -> Vec { + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) + .collect() +} + +/// `ada()` as a version 1 storage blob, written by this crate. The same +/// hex is read by the Go SDK's `TestOpaqueReadsARustWrittenBlob` +/// (`languages/golang/encrypt/gensupport`), which is the other half of the +/// exchange: Rust writes, Go reads. +const RUST_BLOB: &str = "56435301f104000000040000006e616d650a0c000000416461204c6f76656c6163650300000061676506240000000400000074616773f0020000000a050000006d617468730a07000000656e67696e6573080000006e69636b6e616d65f50a03000000616461"; + +/// `ada()` as the Go SDK writes it, through `vcffi.MarshalStorage` over a +/// struct with `vc:"name"`, `vc:"age"`, `vc:"tags"` and `vc:"nickname"` +/// tags, the nickname a non-nil `*string`. Go writes a non-nil pointer as +/// the bare value, where Rust writes `Some` with its marker: the two blobs +/// differ in one byte, and both readers read both. +const GO_BLOB_SOME: &str = "56435301f104000000040000006e616d650a0c000000416461204c6f76656c6163650300000061676506240000000400000074616773f0020000000a050000006d617468730a07000000656e67696e6573080000006e69636b6e616d650a03000000616461"; + +/// The same, with the nickname a nil pointer: a null. +const GO_BLOB_NONE: &str = "56435301f104000000040000006e616d650a0c000000416461204c6f76656c6163650300000061676506240000000400000074616773f0020000000a050000006d617468730a07000000656e67696e6573080000006e69636b6e616d6500"; + +#[tokio::test] +async fn a_struct_round_trips_as_one_ciphertext() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + + let ct = keyset.encrypt(OpaqueBlob::new(ada())).await.unwrap(); + let opened: OpaqueBlob = keyset.decrypt(ct).await.unwrap(); + assert_eq!(opened.into_inner(), ada()); + + let ct = keyset + .encrypt_with_context(OpaqueBlob::new(ada()), ("users", "profile")) + .await + .unwrap(); + let opened = keyset + .decrypt_with_context::>(ct.clone(), ("users", "profile")) + .await + .unwrap(); + assert_eq!(opened.get(), &ada()); + + // The owned conversions are the same path. + let ct2: Ciphertext = OpaqueBlob::new(ada()) + .encrypt_into_with_context(&keyset, ("users", "profile")) + .await + .unwrap(); + let opened = + OpaqueBlob::::decrypt_from_with_context(ct2, &keyset, ("users", "profile")) + .await + .unwrap(); + assert_eq!(opened.into_inner(), ada()); + let ct3: Ciphertext = OpaqueBlob::new(ada()).encrypt_into(&keyset).await.unwrap(); + assert_eq!( + OpaqueBlob::::decrypt_from(ct3, &keyset) + .await + .unwrap() + .into_inner(), + ada() + ); +} + +/// One blob is one data key: the whole struct costs one generate call, +/// where the structural writer would mint one per leaf. +#[tokio::test] +async fn a_blob_is_one_data_key() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset.encrypt(OpaqueBlob::new(ada())).await.unwrap(); + assert_eq!(generates.load(Ordering::SeqCst), 1); + let _: OpaqueBlob = keyset.decrypt(ct).await.unwrap(); + assert_eq!(retrieves.load(Ordering::SeqCst), 1); +} + +/// The blob is a bytes leaf of the ordinary envelope: what the structural +/// writer seals for a `Vec` holding the encoded bytes, and what the Go +/// SDK's engine seals for an opaque struct. Each side opens the other's. +#[tokio::test] +async fn a_blob_is_one_bytes_leaf_of_the_structural_writer() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + + // Structural writer of the encoded bytes -> OpaqueBlob reader. + let encoded = OpaqueBlob::new(ada()).encode().unwrap(); + assert_eq!(&encoded.risky_ref()[..4], &HEADER); + let tree = keyset + .encrypt_tree(encoded.risky_ref().clone(), "users/profile") + .await + .unwrap(); + let CipherText::Single(leaf) = tree else { + panic!("a Vec is one leaf") + }; + let opened = keyset + .decrypt_with_context::>(Ciphertext::from(leaf), "users/profile") + .await + .unwrap(); + assert_eq!(opened.into_inner(), ada()); + + // OpaqueBlob writer -> structural reader of the bytes. + let ct = keyset + .encrypt_with_context(OpaqueBlob::new(ada()), "users/profile") + .await + .unwrap(); + let bytes: Vec = cipher + .decrypt(CipherText::Single(SealedValue::from(ct)), "users/profile") + .await + .unwrap(); + assert_eq!(&bytes[..4], &HEADER); + assert_eq!( + OpaqueBlob::::decode(&bytes).unwrap().into_inner(), + ada() + ); +} + +/// The bytes this crate writes for `ada()` are pinned: a change here is a +/// change to what every stored blob holds, and to what Go reads. +#[test] +fn the_blob_this_crate_writes_is_pinned() { + let encoded = OpaqueBlob::new(ada()).encode().unwrap(); + assert_eq!(encoded.risky_ref(), &unhex(RUST_BLOB)); + assert_eq!(VERSION, 1); + assert_eq!(encoded.risky_ref()[3], VERSION); +} + +/// Go writes, Rust reads: the Go SDK's bytes for the same struct, both +/// with and without the optional field, read as the Rust value through the +/// codec and through a sealed ciphertext opened as an `OpaqueBlob`. The +/// Go engine seals an opaque struct as a bytes leaf under +/// `/value`, which is what `encrypt_tree` of the bytes does here. +#[tokio::test] +async fn a_go_written_blob_opens_in_rust() { + let with_nickname = OpaqueBlob::::decode(&unhex(GO_BLOB_SOME)).unwrap(); + assert_eq!(with_nickname.into_inner(), ada()); + let without = OpaqueBlob::::decode(&unhex(GO_BLOB_NONE)).unwrap(); + assert_eq!( + without.into_inner(), + Profile { + nickname: None, + ..ada() + } + ); + // Go's `Some` is the bare value; Rust's carries the marker. One byte. + assert_eq!(unhex(GO_BLOB_SOME).len() + 1, unhex(RUST_BLOB).len()); + + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let tree = keyset + .encrypt_tree(unhex(GO_BLOB_SOME), ("users", "value")) + .await + .unwrap(); + let CipherText::Single(leaf) = tree else { + panic!("bytes are one leaf") + }; + let opened = keyset + .decrypt_with_context::>(Ciphertext::from(leaf), ("users", "value")) + .await + .unwrap(); + assert_eq!(opened.into_inner(), ada()); +} + +/// One fixed data key, IV and key tag for every request, so a ciphertext +/// sealed once can be pinned and opened by any later build. +struct FixedKeySource(stack_kms::FakeDataKeySource); +const FIXED_KEY: [u8; 32] = *b"fixed-data-key-for-pinned-blob!!"; +const FIXED_IV: [u8; 16] = *b"fixed-iv-pinned!"; +const FIXED_TAG: &[u8] = b"fixed-key-tag"; +impl stack_kms::DataKeySource for FixedKeySource { + async fn generate_keys( + &self, + payloads: Vec>, + _: Option, + _: Option>, + ) -> Result, stack_kms::Error> { + Ok(payloads + .into_iter() + .map(|payload| stack_kms::DataKeyWithTag { + key: stack_kms::DataKey { + iv: FIXED_IV, + key: FIXED_KEY, + }, + tag: FIXED_TAG.to_vec(), + decryption_policy: payload.decryption_policy, + }) + .collect()) + } + async fn retrieve_keys( + &self, + payloads: Vec>, + _: Option, + _: Option<&stack_kms::UnverifiedContext>, + ) -> Result, stack_kms::Error> { + Ok(payloads + .iter() + .map(|_| stack_kms::DataKey { + iv: FIXED_IV, + key: FIXED_KEY, + }) + .collect()) + } +} +impl stack_kms::IndexKeySource for FixedKeySource { + async fn load_index_key( + &self, + keyset: Option, + ) -> Result<(uuid::Uuid, stack_kms::IndexKey), stack_kms::Error> { + self.0.load_index_key(keyset).await + } +} + +async fn fixed_cipher() -> stack_encrypt::StackCipher { + stack_encrypt::StackCipher::builder() + .kms(FixedKeySource(stack_kms::FakeDataKeySource::new())) + .init() + .await + .unwrap() +} + +/// `OpaqueBlob::new(ada())` sealed under `"users/profile"` over +/// [`FixedKeySource`] by the build that introduced `OpaqueBlob`. Vitamin C +/// draws the AEAD nonce itself, so the ciphertext is not reproducible; it is +/// pinned so a later build is shown to open what this one wrote. +const PINNED_CIPHERTEXT: &str = "010000000000000000000000000000000066697865642d69762d70696e6e6564210d0066697865642d6b65792d7461670232e68c36bf5fa752e06eea1f8625c96dd4aa0ea1eceaf8b2414db5c09282bbc92a653bab98e7e715db1c56b063d461fa50297f1473be2faf29413c7bc9b292e6186a60661dccddd63576fc98e22bf32202f000251b49d1fbdb67bf1bcf1a8c666d4de9dabe7a1cbf5b67834c3ece6c458d7ceb7435ce5dcd68b216c326a4d933cb748c"; + +#[tokio::test] +async fn a_pinned_blob_ciphertext_opens() { + let cipher = fixed_cipher().await; + let keyset = cipher.default_keyset(); + let bytes = unhex(PINNED_CIPHERTEXT); + let ct = Ciphertext::from_bytes(&bytes).unwrap(); + assert_eq!(ct.key_tag(), FIXED_TAG); + assert_eq!(ct.to_bytes(), bytes); + let opened = keyset + .decrypt_with_context::>(ct.clone(), "users/profile") + .await + .unwrap(); + assert_eq!(opened.into_inner(), ada()); + // Under no context, or the wrong one, it never reaches the blob. + assert!(matches!( + keyset.decrypt::>(ct.clone()).await, + Err(Error::Aead) + )); + assert!(matches!( + keyset + .decrypt_with_context::>(ct, "users/other") + .await, + Err(Error::Aead) + )); +} + +/// Tampering is refused by the AEAD before any byte of the blob is read, +/// however the bytes would have decoded; and the leaf's kind is bound, so +/// a scalar leaf opened as a blob, or a blob as a scalar, is a shape +/// refusal of an authentic ciphertext, never a blob error. +#[tokio::test] +async fn authentication_comes_before_decoding() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt_with_context(OpaqueBlob::new(ada()), "users/profile") + .await + .unwrap(); + + let mut bytes = ct.to_bytes(); + *bytes.last_mut().unwrap() ^= 1; + let tampered = Ciphertext::from_bytes(&bytes).unwrap(); + assert!(matches!( + keyset + .decrypt_with_context::>(tampered, "users/profile") + .await, + Err(Error::Aead) + )); + assert!(matches!( + keyset + .decrypt_with_context::>(ct.clone(), "users/other") + .await, + Err(Error::Aead) + )); + + // A blob opened as a String: the leaf is bytes, not text. + assert!(matches!( + keyset + .decrypt_with_context::(ct, "users/profile") + .await, + Err(Error::OpenedType { .. }) + )); + // A String opened as a blob: the leaf is text, not bytes. + let text = keyset + .encrypt_with_context("VCS\u{1}".to_owned(), "users/profile") + .await + .unwrap(); + assert!(matches!( + keyset + .decrypt_with_context::>(text, "users/profile") + .await, + Err(Error::OpenedType { .. }) + )); +} + +#[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] +struct Pair { + left: String, + right: u32, +} + +/// Bytes sealed as a leaf that are not a blob this type reads are refused +/// by class, each after the leaf authenticated and each retrieving its key +/// once. The classes are the storage profile's, and the ciphertext is +/// genuine in every case: the data was written as something else. +#[tokio::test] +async fn malformed_and_mismatched_blobs_are_refused_by_class_after_authentication() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let open = |bytes: Vec| async { + let CipherText::Single(leaf) = keyset.encrypt_tree(bytes, "users/profile").await.unwrap() + else { + panic!("bytes are one leaf") + }; + keyset + .decrypt_with_context::>(Ciphertext::from(leaf), "users/profile") + .await + .unwrap_err() + }; + let blob = OpaqueBlob::new(ada()).encode().unwrap(); + let blob = blob.risky_ref(); + + assert!(matches!( + open(b"hello".to_vec()).await, + Error::Blob(BlobError::NotABlob) + )); + assert!(matches!( + open(b"{\"name\":\"Ada\"}".to_vec()).await, + Error::Blob(BlobError::NotABlob) + )); + let mut later = blob.clone(); + later[3] = VERSION + 1; + assert!(matches!( + open(later).await, + Error::Blob(BlobError::UnsupportedVersion { version }) if version == VERSION + 1 + )); + assert!(matches!( + open(blob[..blob.len() - 3].to_vec()).await, + Error::Blob(BlobError::Malformed(_)) + )); + let mut trailing = blob.clone(); + trailing.push(0); + assert!(matches!( + open(trailing).await, + Error::Blob(BlobError::Malformed(_)) + )); + let pair = OpaqueBlob::new(Pair { + left: "l".into(), + right: 1, + }) + .encode() + .unwrap(); + let mismatch = open(pair.risky_ref().clone()).await; + assert!(matches!(mismatch, Error::Blob(BlobError::Mismatch(_)))); + // Every refusal came after a retrieval: authentication first. + assert_eq!(retrieves.load(Ordering::SeqCst), 6); + // The refusal names the declared field, never the value. + let shown = format!("{mismatch} {mismatch:?}"); + assert!(!shown.contains("\"l\""), "{shown}"); +} + +/// The source's own `Serialize` can refuse. The refusal is the pending's +/// result, no key is requested, and the owned source is wiped all the same. +struct Refusing { + secret: String, + wiped: Arc, +} +impl Serialize for Refusing { + fn serialize(&self, _: S) -> Result { + Err(serde::ser::Error::custom( + "this value refuses to be written", + )) + } +} +impl Zeroize for Refusing { + fn zeroize(&mut self) { + self.secret.zeroize(); + self.wiped.store(true, Ordering::SeqCst); + } +} +impl Drop for Refusing { + fn drop(&mut self) { + assert!( + self.secret.is_empty(), + "owned source dropped without wiping" + ); + } +} + +#[tokio::test] +async fn an_encoding_refusal_wipes_the_source_and_requests_no_key() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let wiped = Arc::new(AtomicBool::new(false)); + let pending = keyset.encrypt(OpaqueBlob::new(Refusing { + secret: "secret".into(), + wiped: wiped.clone(), + })); + assert!(wiped.load(Ordering::SeqCst)); + assert!(matches!( + pending.await, + Err(Error::Blob(BlobError::Unwritable(_))) + )); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} + +/// A source that records being wiped; deliberately not `Clone`. +#[derive(Serialize)] +struct Tracked { + value: String, + #[serde(skip)] + wiped: Arc, +} +impl Zeroize for Tracked { + fn zeroize(&mut self) { + self.value.zeroize(); + self.wiped.store(true, Ordering::SeqCst); + } +} +impl Drop for Tracked { + fn drop(&mut self) { + assert!(self.value.is_empty(), "owned source dropped without wiping"); + } +} +fn tracked() -> (OpaqueBlob, Arc) { + let wiped = Arc::new(AtomicBool::new(false)); + ( + OpaqueBlob::new(Tracked { + value: "secret".into(), + wiped: wiped.clone(), + }), + wiped, + ) +} + +/// The owned source is encoded and wiped before `encrypt` returns, so a +/// pending that is dropped unpolled, or awaited, carries no plaintext of +/// the caller's and makes no key request until it is polled. +#[tokio::test] +async fn the_owned_source_is_wiped_before_await_or_discard() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + + let (source, wiped) = tracked(); + let pending = keyset.encrypt(source); + assert!(wiped.load(Ordering::SeqCst)); + assert_eq!(generates.load(Ordering::SeqCst), 0); + drop(pending); + assert_eq!(generates.load(Ordering::SeqCst), 0); + + let (source, wiped) = tracked(); + let pending = keyset.encrypt_with_context(source, "users/secret"); + assert!(wiped.load(Ordering::SeqCst)); + let ct = pending.await.unwrap(); + assert_eq!(generates.load(Ordering::SeqCst), 1); + #[derive(Deserialize, Zeroize)] + struct Read { + value: String, + } + assert_eq!( + keyset + .decrypt_with_context::>(ct, "users/secret") + .await + .unwrap() + .into_inner() + .value, + "secret" + ); +} + +#[tokio::test] +async fn the_owned_source_is_wiped_when_context_validation_fails() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let (source, wiped) = tracked(); + let pending = keyset.encrypt_with_context(source, "x".repeat(65536)); + assert!(wiped.load(Ordering::SeqCst)); + assert!(matches!( + pending.await, + Err(Error::DescriptorTooLong { .. }) + )); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} + +/// The context is checked before the value is encoded, as a scalar's is: a +/// value that would refuse to encode, under a context no request can +/// carry, is refused for the context, so no encoding ran. +#[tokio::test] +async fn the_context_is_refused_before_the_value_is_encoded() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let wiped = Arc::new(AtomicBool::new(false)); + let pending = keyset.encrypt_with_context( + OpaqueBlob::new(Refusing { + secret: "secret".into(), + wiped: wiped.clone(), + }), + "x".repeat(65536), + ); + assert!(wiped.load(Ordering::SeqCst)); + assert!(matches!( + pending.await, + Err(Error::DescriptorTooLong { .. }) + )); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} + +/// Counts its own wipes, so a test can see whether a decrypted value was +/// wiped when it was dropped. Used by one test only. +static WITNESS_WIPES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +#[derive(Serialize, Deserialize)] +struct Witness { + value: String, +} +impl Zeroize for Witness { + fn zeroize(&mut self) { + self.value.zeroize(); + WITNESS_WIPES.fetch_add(1, Ordering::SeqCst); + } +} + +/// The drop witness for both halves of the result contract: a decrypted +/// `OpaqueBlob` is an ordinary value that nothing wipes on drop, and a +/// decrypted `Zeroizing>` is wiped exactly once when it is +/// dropped, through `T::zeroize`. +#[tokio::test] +async fn only_a_zeroizing_result_is_wiped_on_drop() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(OpaqueBlob::new(Witness { + value: "secret".into(), + })) + .await + .unwrap(); + // Encrypting wiped the source; count from here. + let before = WITNESS_WIPES.load(Ordering::SeqCst); + + let plain: OpaqueBlob = keyset.decrypt(ct.clone()).await.unwrap(); + assert_eq!(plain.get().value, "secret"); + drop(plain); + assert_eq!(WITNESS_WIPES.load(Ordering::SeqCst), before); + + let guarded: Zeroizing> = keyset.decrypt(ct).await.unwrap(); + assert_eq!(guarded.get().value, "secret"); + assert_eq!(WITNESS_WIPES.load(Ordering::SeqCst), before); + drop(guarded); + assert_eq!(WITNESS_WIPES.load(Ordering::SeqCst), before + 1); +} + +/// `Zeroizing` is the opt-in drop guard on both sides: a `Zeroizing` +/// encrypts as its `S`, and asking for `Zeroizing

` returns the opened +/// value wrapped so it is wiped on drop. Blobs and scalars alike. +#[tokio::test] +async fn zeroizing_is_the_opt_in_drop_guard_on_both_sides() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + + let ct = keyset + .encrypt(Zeroizing::new(OpaqueBlob::new(ada()))) + .await + .unwrap(); + let guarded: Zeroizing> = keyset.decrypt(ct.clone()).await.unwrap(); + assert_eq!(guarded.get(), &ada()); + // The same ciphertext opens unguarded too: the guard is the caller's + // choice of result type, not part of the stored value. + let plain: OpaqueBlob = keyset.decrypt(ct).await.unwrap(); + assert_eq!(plain.into_inner(), ada()); + + let ct = keyset + .encrypt(Zeroizing::new("text".to_owned())) + .await + .unwrap(); + let text: Zeroizing = keyset.decrypt(ct).await.unwrap(); + assert_eq!(text.as_str(), "text"); +} + +/// Schema evolution is serde's: a default for a new field, an alias for a +/// renamed one, and an `Option` for one that may be absent all read an old +/// blob; a new required field does not. +#[tokio::test] +async fn schema_evolution_follows_serde() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset.encrypt(OpaqueBlob::new(ada())).await.unwrap(); + + #[derive(Deserialize, Zeroize, PartialEq, Debug)] + struct ProfileV2 { + name: String, + age: u64, + #[serde(alias = "tags")] + labels: Vec, + nickname: Option, + #[serde(default)] + verified: bool, + #[serde(default)] + title: Option, + } + let later = keyset + .decrypt::>(ct.clone()) + .await + .unwrap() + .into_inner(); + assert_eq!( + later, + ProfileV2 { + name: "Ada Lovelace".into(), + age: 36, + labels: vec!["maths".into(), "engines".into()], + nickname: Some("ada".into()), + verified: false, + title: None, + } + ); + + #[derive(Deserialize, Zeroize)] + #[allow(dead_code)] + struct Stricter { + name: String, + age: u8, + tags: Vec, + nickname: Option, + verified: bool, + } + assert!(matches!( + keyset.decrypt::>(ct.clone()).await, + Err(Error::Blob(BlobError::Mismatch(_))) + )); + #[derive(Deserialize, Zeroize)] + #[allow(dead_code)] + struct Narrower { + name: String, + age: u8, + } + // Narrowing reads values that fit; the blob holds 36. + assert_eq!( + keyset + .decrypt::>(ct) + .await + .unwrap() + .into_inner() + .age, + 36 + ); +} + +/// The ciphertext is the encoded length plus a fixed overhead: a present +/// optional field, a longer string, another element each show in it. The +/// documented disclosure, executable. +#[tokio::test] +async fn the_ciphertext_length_is_the_encoded_length_plus_a_constant() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let overhead = |profile: Profile| async { + let blob = OpaqueBlob::new(profile.clone()).encode().unwrap(); + let ct = keyset.encrypt(OpaqueBlob::new(profile)).await.unwrap(); + (blob.risky_ref().len(), ct.to_bytes().len()) + }; + let (with_len, with_ct) = overhead(ada()).await; + let (without_len, without_ct) = overhead(Profile { + nickname: None, + ..ada() + }) + .await; + // `Some("ada")`: marker, text tag, u32 length, three bytes; `None`: one + // null byte. + assert_eq!(with_len - without_len, 1 + 1 + 4 + 3 - 1); + assert_eq!(with_ct - without_ct, with_len - without_len); + assert_eq!(with_ct - with_len, without_ct - without_len); +} + +#[tokio::test] +async fn debug_and_refusals_show_no_plaintext() { + let blob = OpaqueBlob::new(ada()); + assert_eq!(format!("{blob:?}"), "OpaqueBlob(***)"); + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset.encrypt(blob).await.unwrap(); + assert!(format!("{ct:?}").ends_with("Ciphertext(\"***\")")); + let error = keyset.decrypt::>(ct).await.unwrap_err(); + let shown = format!( + "{error} / {error:?} / {:?}", + stack_encrypt::ErrorPayload::payload(&error) + ); + for word in ["Ada", "Lovelace", "maths", "engines", "ada"] { + assert!(!shown.contains(word), "{shown}"); + } +} + +/// A `Vec` is not a blob and a blob is not a `Vec`: neither +/// implements the other's capability, so the stored kind is never chosen +/// by accident. (The compile-fail half is `tests/ui/opaque_blob_*.rs`.) +#[tokio::test] +async fn a_blob_of_bytes_is_still_a_blob() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + #[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] + struct Raw(#[serde(with = "serde_bytes_shim")] Vec); + mod serde_bytes_shim { + pub fn serialize(v: &[u8], s: S) -> Result { + s.serialize_bytes(v) + } + pub fn deserialize<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + struct V; + impl serde::de::Visitor<'_> for V { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("bytes") + } + fn visit_bytes(self, v: &[u8]) -> Result, E> { + Ok(v.to_vec()) + } + } + d.deserialize_bytes(V) + } + } + let raw = Raw(b"hello".to_vec()); + let ct = keyset.encrypt(OpaqueBlob::new(raw)).await.unwrap(); + // Opened as bytes, the leaf holds a blob, not "hello". + let bytes: Vec = cipher + .decrypt(CipherText::Single(SealedValue::from(ct.clone())), ()) + .await + .unwrap(); + assert_eq!(&bytes[..4], &HEADER); + assert_eq!( + keyset + .decrypt::>(ct) + .await + .unwrap() + .into_inner(), + Raw(b"hello".to_vec()) + ); +} diff --git a/packages/stack-encrypt/tests/query_refusal_conformance.rs b/packages/stack-encrypt/tests/query_refusal_conformance.rs new file mode 100644 index 000000000..4055f6539 --- /dev/null +++ b/packages/stack-encrypt/tests/query_refusal_conformance.rs @@ -0,0 +1,430 @@ +//! The shared query-refusal profile: what a query is refused for before any +//! term exists, through the binding's check, the binding's derivation and, +//! where the typed chain can spell the same query, the typed chain. See +//! conformance/stack-encrypt/query-refusals-contract.md. +#![cfg(feature = "dynamic")] +mod common; + +use common::{ + conformance::{value, Input}, + frames::frame, + CountingSource, +}; +use miette::Diagnostic; +use serde::Deserialize; +use serde_json::json; +use stack_encrypt::dynamic::{self, record, Error, NoTargets, OpSpec, ValueFrame}; +use stack_encrypt::sem::DefaultMatch; +use stack_encrypt::{op, Equality, Match, Ope, Ore, OreBlock, Plan, StackCipher}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::atomic::{AtomicUsize, Ordering}, + sync::Arc, +}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Corpus { + version: u32, + profile: String, + required_capabilities: Vec, + fields: BTreeMap, + cases: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FieldSpec { + kind: String, + outputs: Vec, + #[serde(default)] + null: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + id: String, + field: String, + operator: String, + input: QueryInput, + accepted: bool, + expected_error: Option, + /// How the typed Rust chain answers the same query, for the queries it + /// can spell: refused before the named keyset is loaded, after it, or + /// not refused. + typed: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +enum QueryInput { + Scalar(Input), + List { + #[serde(rename = "type")] + _kind: ListTag, + items: Vec, + }, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +enum ListTag { + List, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedError { + class: Class, + code: String, + reason: Option, + field: Option, +} + +#[derive(Deserialize, Debug, PartialEq, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum Class { + /// `Error::Source`: the input is not one the field takes. + Source, + /// `Error::Plan`: the plan has no such field, or cannot be queried. + Plan, + /// `Error::Cipher(Error::Term(..))`: the index refuses the value. + Term, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Typed { + refusal: Refusal, +} + +#[derive(Deserialize, Debug, PartialEq, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum Refusal { + BeforeKeyset, + AfterKeyset, + None, +} + +fn input(query: &QueryInput) -> ValueFrame { + match query { + QueryInput::Scalar(scalar) => value(scalar), + QueryInput::List { items, .. } => frame(items.iter().map(value).collect::>()), + } +} + +fn class_of(error: &Error) -> Class { + match error { + Error::Source { .. } => Class::Source, + Error::Plan { .. } => Class::Plan, + Error::Cipher(stack_encrypt::Error::Term(_)) => Class::Term, + other => panic!("not a query refusal class: {other:?}"), + } +} + +fn check(id: &str, path: &str, result: Result<(), Error>, expected: Option<&ExpectedError>) { + match (result, expected) { + (Ok(()), None) => {} + (Err(error), Some(want)) => { + assert_eq!(class_of(&error), want.class, "{id} {path}: {error:?}"); + assert_eq!(error.code().unwrap().to_string(), want.code, "{id} {path}"); + assert_eq!( + error.reason().map(|reason| reason.as_str().to_owned()), + want.reason, + "{id} {path}" + ); + assert_eq!( + error.field().map(str::to_owned), + want.field, + "{id} {path}: field" + ); + } + (result, _) => panic!("{id} {path}: {result:?}, accepted={}", expected.is_none()), + } +} + +/// The typed chain's answer: a `stack_encrypt::Error` code, or none. +async fn typed( + cipher: &StackCipher, + keyset: &str, + field: &str, + kind: &str, + outputs: &[String], + operator: OpSpec, + query: &QueryInput, +) -> Option { + let context = format!("conformance/{field}"); + let text = |query: &QueryInput| match query { + QueryInput::Scalar(Input { kind, text }) if kind == "string" => text.clone(), + other => panic!("{field}: not text: {other:?}"), + }; + let pair = |query: &QueryInput| match query { + QueryInput::List { items, .. } if items.len() == 2 => (items[0].clone(), items[1].clone()), + other => panic!("{field}: not a pair: {other:?}"), + }; + let texts = |query: &QueryInput| { + let (lo, hi) = pair(query); + assert!(lo.kind == "string" && hi.kind == "string"); + (lo.text, hi.text) + }; + let u32s = |query: &QueryInput| { + let (lo, hi) = pair(query); + assert!(lo.kind == "uint32" && hi.kind == "uint32"); + ( + lo.text.parse::().unwrap(), + hi.text.parse::().unwrap(), + ) + }; + let i128s = |query: &QueryInput| { + let (lo, hi) = pair(query); + assert!(lo.kind == "int128" && hi.kind == "int128"); + ( + lo.text.parse::().unwrap(), + hi.text.parse::().unwrap(), + ) + }; + // The index that answers the operator, as the plan declares it: the + // equality, match or order index, whichever the operator reads. + let answers: &[&str] = match operator { + OpSpec::Eq => &["eq"], + OpSpec::Matches => &["match"], + _ => &["ore", "ore_block", "ope"], + }; + let index = outputs + .iter() + .map(String::as_str) + .find(|output| answers.contains(output)) + .expect("an index that answers the operator"); + macro_rules! run { + ($plan:expr, $op:expr, $value:expr) => { + run!($plan, $op, $value, Ore) + }; + ($plan:expr, $op:expr, $value:expr, $via:expr) => { + cipher + .query($op, $value) + .via($via) + .on(&$plan) + .keyset(keyset) + .await + .err() + .map(|error| error.code().expect("a code").to_string()) + }; + } + match (kind, index, operator) { + ("string", "eq", OpSpec::Eq) => { + let plan = Plan::context(context) + .with::(Equality) + .build() + .unwrap(); + run!(plan, op::Eq, &text(query), Equality) + } + ("string", "match", OpSpec::Matches) => { + let plan = Plan::context(context) + .with::(Match::::default()) + .build() + .unwrap(); + run!( + plan, + op::Matches, + &text(query), + Match::::default() + ) + } + ("string", "ore", OpSpec::Lt) => { + let plan = Plan::context(context) + .with::(Ore) + .build() + .unwrap(); + run!(plan, op::Lt, &text(query)) + } + ("string", "ore", OpSpec::Between) => { + let plan = Plan::context(context) + .with::(Ore) + .build() + .unwrap(); + run!(plan, op::Between, &texts(query)) + } + ("string", "ore_block", OpSpec::Lt) => { + let plan = Plan::context(context) + .with::(OreBlock) + .build() + .unwrap(); + run!(plan, op::Lt, &text(query), OreBlock) + } + ("string", "ore_block", OpSpec::Gt) => { + let plan = Plan::context(context) + .with::(OreBlock) + .build() + .unwrap(); + run!(plan, op::Gt, &text(query), OreBlock) + } + ("string", "ore_block", OpSpec::Between) => { + let plan = Plan::context(context) + .with::(OreBlock) + .build() + .unwrap(); + run!(plan, op::Between, &texts(query), OreBlock) + } + ("uint32", "ore", OpSpec::Between) => { + let plan = Plan::context(context).with::(Ore).build().unwrap(); + run!(plan, op::Between, &u32s(query)) + } + ("int128", "ope", OpSpec::Between) => { + let plan = Plan::context(context).with::(Ope).build().unwrap(); + run!(plan, op::Between, &i128s(query), Ope) + } + other => panic!("{field}: the typed chain is not spelled for {other:?}"), + } +} + +async fn cipher() -> (StackCipher, [Arc; 3]) { + let source = CountingSource::new(); + let loads = source.loads(); + let (generates, retrieves) = source.counters(); + let cipher = StackCipher::builder() + .kms(source) + .init() + .await + .expect("build cipher"); + loads.store(0, Ordering::SeqCst); + (cipher, [loads, generates, retrieves]) +} + +#[tokio::test] +async fn shared_query_refusal_profile() { + let corpus: Corpus = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/query-refusals.json" + )) + .unwrap(); + assert_eq!(corpus.version, 1); + assert_eq!(corpus.profile, "record-query-refusals-v1"); + assert_eq!( + corpus.required_capabilities, + ["records", "query-errors", "term-errors"] + ); + assert!(!corpus.cases.is_empty()); + let mut declaration = serde_json::Map::new(); + for (name, spec) in &corpus.fields { + let mut field = json!({ + "context": ["conformance", name], "outputs": spec.outputs, "type": spec.kind, + }); + if !spec.null.is_empty() { + field["null"] = json!(spec.null); + } + declaration.insert(name.clone(), field); + } + let plan = record::plan(&vitaminc_aead_frame::serde::to_vec(&declaration).unwrap()).unwrap(); + let (cipher, counters) = cipher().await; + let keyset = cipher.default_keyset(); + let mut ids = BTreeSet::new(); + for case in &corpus.cases { + assert!(!case.id.is_empty() && ids.insert(case.id.clone())); + eprintln!("query refusal conformance: {}", case.id); + assert_eq!(case.accepted, case.expected_error.is_none(), "{}", case.id); + let expected = case.expected_error.as_ref(); + if let Some(want) = expected { + assert_eq!(want.reason.is_none(), want.class == Class::Term); + assert_eq!(want.field.is_none(), want.class == Class::Term); + } + for counter in &counters { + counter.store(0, Ordering::SeqCst); + } + let Some(operator) = OpSpec::parse(&case.operator) else { + // The engine's operator vocabulary is closed: a name it does not + // parse never reaches a query here. The binding, which takes the + // name as text, is where the refusal is observed. + let want = expected.expect("an unknown operator is refused"); + assert_eq!(want.reason.as_deref(), Some("unknown_operator")); + assert_eq!(want.class, Class::Source); + continue; + }; + let value = input(&case.input); + let field = corpus.fields.get(&case.field); + check( + &case.id, + "check_query", + dynamic::check_query(&plan, &case.field, operator, &value, &NoTargets), + expected, + ); + let derived = + match dynamic::query_term(&keyset, &plan, &case.field, operator, &value, &NoTargets) { + Ok(pending) => { + assert!(case.accepted, "{}: refused only after pending", case.id); + pending.await.map(drop).map_err(Error::Cipher) + } + Err(error) => Err(error), + }; + check(&case.id, "query_term", derived, expected); + if operator == OpSpec::Matches { + let verified = dynamic::verify( + &plan, + &case.field, + operator, + &value, + Vec::::new(), + &NoTargets, + ) + .map(|matched| assert!(matched.is_empty())); + check(&case.id, "verify", verified, expected); + } + for (name, counter) in ["loads", "generates", "retrieves"].iter().zip(&counters) { + assert_eq!( + counter.load(Ordering::SeqCst), + 0, + "{}: the binding path made a {name} call", + case.id + ); + } + let Some(want) = &case.typed else { + continue; + }; + let spec = field.expect("a declared field"); + // A named keyset is loaded once and cached, so each typed query + // names its own: the load is the observation. + let code = typed( + &cipher, + &format!("keyset-{}", case.id), + &case.field, + &spec.kind, + &spec.outputs, + operator, + &case.input, + ) + .await; + let loads = counters[0].swap(0, Ordering::SeqCst); + match want.refusal { + Refusal::None => { + assert!( + case.accepted && code.is_none(), + "{}: typed {code:?}", + case.id + ); + assert_eq!(loads, 1, "{}: the typed chain loaded its keyset", case.id); + } + Refusal::BeforeKeyset | Refusal::AfterKeyset => { + let expected = expected.expect("a refusal"); + assert_eq!(expected.class, Class::Term, "{}: typed", case.id); + assert_eq!(code.as_deref(), Some(expected.code.as_str()), "{}", case.id); + assert_eq!( + loads, + usize::from(want.refusal == Refusal::AfterKeyset), + "{}: typed refusal stage", + case.id + ); + } + } + assert_eq!( + counters[1].load(Ordering::SeqCst), + 0, + "{}: typed generate", + case.id + ); + assert_eq!( + counters[2].load(Ordering::SeqCst), + 0, + "{}: typed retrieve", + case.id + ); + } +} diff --git a/packages/stack-encrypt/tests/raw_frame_conformance.rs b/packages/stack-encrypt/tests/raw_frame_conformance.rs new file mode 100644 index 000000000..8ff9cfb26 --- /dev/null +++ b/packages/stack-encrypt/tests/raw_frame_conformance.rs @@ -0,0 +1,390 @@ +//! The shared raw-frame profile: stored records supplied as transport +//! bytes, so both runners feed the decoder what no map-based tree encoder +//! can write, and each refusal is placed in its class. See +//! conformance/stack-encrypt/raw-frames-contract.md. +#![cfg(feature = "dynamic")] +mod common; + +use common::{ + conformance::{value, Input}, + frames::{frame, object}, + unhex, CountingSource, DeterministicSource, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use stack_encrypt::dynamic::{record, Scope}; +use stack_encrypt::{ + CipherText, ErrorPayload, Node, PassthroughFrame, SealedValue, StackCipher, StackCipherText, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::Ordering; +use vitaminc_aead_frame::{decode_ciphertext, Frame}; + +type Row = BTreeMap; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Corpus { + version: u32, + profile: String, + seed: String, + required_capabilities: Vec, + /// What a WASI boundary reports for bytes that are not a ciphertext + /// tree. The native codec reports no code of its own. + boundary_error: Refusal, + cases: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Declaration { + kind: String, + null: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + id: String, + read: Declaration, + frame_hex: String, + class: Class, + retrievals: usize, + expected: Option, + rows: Option>, + /// For a frame the codec refuses: what the engine refuses when the same + /// bytes are read by a decoder that admits what the codec does not. A + /// native observation; a binding never reaches the engine with it. + native_tree: Option, +} + +#[derive(Deserialize, Debug, PartialEq, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum Class { + /// The bytes are not a ciphertext tree in the transport codec. + Transport, + /// The tree decodes, and a sealed leaf is not in the leaf byte format. + Leaf, + /// Leaves are well formed, and the tree does not fit the plan. + Shape, + /// The tree fits, the leaf authenticates, and what it holds is not what + /// the plan reads. + Authenticated, + Success, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Refusal { + code: String, + fields: Map, +} + +fn refusal(error: &impl ErrorPayload, want: &Refusal) -> Result<(), String> { + let code = error + .code() + .map(|code| code.to_string()) + .unwrap_or_default(); + let fields = error.payload(); + if code != want.code || fields != want.fields { + return Err(format!( + "{code} {fields:?}, expected {} {:?}", + want.code, want.fields + )); + } + Ok(()) +} + +/// The transport codec, read as the guest reads a stored record: the tree, +/// then every leaf in the frozen leaf byte format. +fn decode(bytes: &[u8]) -> Result, ()> { + let tree: Node> = decode_ciphertext(bytes).map_err(drop)?; + Ok(leaves(tree)) +} + +fn leaves(tree: Node>) -> Result { + Ok(match tree { + CipherText::Single(leaf) => CipherText::Single(SealedValue::from_bytes(&leaf)?), + CipherText::EmptySequence(leaf) => { + CipherText::EmptySequence(SealedValue::from_bytes(&leaf)?) + } + CipherText::EmptyMap(leaf) => CipherText::EmptyMap(SealedValue::from_bytes(&leaf)?), + CipherText::Sequence(items) => { + CipherText::Sequence(items.into_iter().map(leaves).collect::>()?) + } + CipherText::Map(entries) => CipherText::Map( + entries + .into_iter() + .map(|(key, value)| Ok((key, leaves(value)?))) + .collect::>()?, + ), + CipherText::Passthrough(payload) => CipherText::Passthrough(payload), + }) +} + +/// The transport codec's grammar, read without its duplicate-key rule, so +/// the engine can be asked what it makes of a tree the codec refuses. Every +/// other rule (tags, bounds, one frame per passthrough, no trailing bytes) +/// is kept, so a frame this reads and the codec refuses differs in that +/// one respect. +fn lenient(bytes: &[u8]) -> Option>> { + const MAX_DEPTH: usize = 32; + fn count(bytes: &[u8], at: &mut usize) -> Option { + let len = u32::from_le_bytes(bytes.get(*at..*at + 4)?.try_into().ok()?) as usize; + *at += 4; + (len <= bytes.len() - *at).then_some(len) + } + fn take<'a>(bytes: &'a [u8], at: &mut usize) -> Option<&'a [u8]> { + let len = count(bytes, at)?; + let taken = &bytes[*at..*at + len]; + *at += len; + Some(taken) + } + fn node(bytes: &[u8], at: &mut usize, depth: usize) -> Option>> { + if depth > MAX_DEPTH { + return None; + } + let tag = *bytes.get(*at)?; + *at += 1; + Some(match tag { + 0x01 => CipherText::Single(take(bytes, at)?.to_vec()), + 0x06 => CipherText::EmptySequence(take(bytes, at)?.to_vec()), + 0x07 => CipherText::EmptyMap(take(bytes, at)?.to_vec()), + 0x03 => { + let n = count(bytes, at)?; + CipherText::Sequence( + (0..n) + .map(|_| node(bytes, at, depth + 1)) + .collect::>()?, + ) + } + 0x04 => { + let n = count(bytes, at)?; + let mut entries = Vec::new(); + for _ in 0..n { + let key = String::from_utf8(take(bytes, at)?.to_vec()).ok()?; + entries.push((key, node(bytes, at, depth + 1)?)); + } + CipherText::Map(entries) + } + 0x05 => { + let (frame, _) = Frame::parse_nested(&bytes[*at..], depth + 1).ok()?; + let len = frame.as_bytes().len(); + *at += len; + CipherText::Passthrough(PassthroughFrame(bytes[*at - len..*at].to_vec())) + } + _ => return None, + }) + } + let mut at = 0; + let tree = node(bytes, &mut at, 0)?; + (at == bytes.len()).then_some(tree) +} + +fn plan(read: &Declaration) -> record::Plan { + assert!(["", "sql", "sealed"].contains(&read.null.as_str())); + let mut spec = + json!({"context": ["conformance", "value"], "outputs": ["c", "eq"], "type": read.kind}); + if !read.null.is_empty() { + spec["null"] = json!(read.null); + } + record::plan(&vitaminc_aead_frame::serde::to_vec(&json!({"value": spec})).unwrap()).unwrap() +} + +#[tokio::test] +async fn shared_raw_frame_profile() { + let corpus: Corpus = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/raw-frames.json" + )) + .unwrap(); + assert_eq!(corpus.version, 1); + assert_eq!(corpus.profile, "record-raw-frames-v1"); + assert_eq!( + corpus.required_capabilities, + [ + "records", + "raw-frames", + "read-shapes", + "authenticated-types" + ] + ); + assert_eq!( + corpus.boundary_error.code, + "stack_guest_abi::malformed_input" + ); + assert!(corpus.boundary_error.fields.is_empty()); + assert!(!corpus.cases.is_empty()); + // The deterministic source with ZeroKMS call counters, so a frozen leaf + // opens and the stage of a refusal is observed. + let source = CountingSource::over(DeterministicSource::new( + unhex(&corpus.seed).try_into().expect("32-byte seed"), + )); + let (generates, retrieves) = source.counters(); + let cipher = StackCipher::builder() + .kms(source) + .init() + .await + .expect("build cipher"); + let keyset = cipher.default_keyset(); + let mut ids = BTreeSet::new(); + let mut failures = Vec::new(); + for case in &corpus.cases { + assert!(!case.id.is_empty() && ids.insert(case.id.clone())); + eprintln!("raw frame conformance: {}", case.id); + let mut fail = |message: String| failures.push(format!("{}: {message}", case.id)); + // A case says exactly what its class needs: a refusal for every + // class but success and transport (whose refusal is the corpus-wide + // boundary error), rows for success alone, retrieval only once the + // shape has passed, and a native tree only for a frame the codec + // refuses. + assert_eq!( + case.expected.is_none(), + matches!(case.class, Class::Success | Class::Transport) + ); + assert_eq!(case.rows.is_some(), case.class == Class::Success); + assert_eq!( + case.retrievals, + usize::from(matches!(case.class, Class::Authenticated | Class::Success)) + ); + assert!(case.native_tree.is_none() || case.class == Class::Transport); + let bytes = unhex(&case.frame_hex); + let plan = plan(&case.read); + generates.store(0, Ordering::SeqCst); + retrieves.store(0, Ordering::SeqCst); + let tree = match decode(&bytes) { + Err(()) => { + if case.class != Class::Transport { + fail("the codec refused the frame".into()); + } + if let Some(want) = &case.native_tree { + match lenient(&bytes).map(leaves) { + Some(Ok(tree)) => match record::check_record(tree, &plan, None) { + Ok(()) => fail("the engine accepted the lenient tree".into()), + Err(error) => { + if let Err(message) = refusal(&error, want) { + fail(format!("native tree: {message}")); + } + } + }, + _ => fail("the lenient reader refused the frame too".into()), + } + } + continue; + } + Ok(Err(error)) => { + if case.class != Class::Leaf { + fail(format!("a leaf is malformed: {error:?}")); + } else if let Err(message) = refusal(&error, case.expected.as_ref().unwrap()) { + fail(message); + } + continue; + } + Ok(Ok(tree)) => tree, + }; + if matches!(case.class, Class::Transport | Class::Leaf) { + fail("the frame decoded with well-formed leaves".into()); + continue; + } + let want = case.expected.as_ref(); + // The key-free preflight judges the shape alone: it refuses a shape + // case as decrypt does, and passes every tree that fits the plan. + let Ok(Ok(preflight)) = decode(&bytes) else { + unreachable!("decoded once already") + }; + match (case.class, record::check_record(preflight, &plan, None)) { + (Class::Shape, Err(error)) => { + if let Err(message) = refusal(&error, want.unwrap()) { + fail(format!("check_record: {message}")); + } + } + (Class::Shape, Ok(())) => fail("check_record accepted the shape".into()), + (_, Err(error)) => fail(format!("check_record refused a fitting tree: {error:?}")), + (_, Ok(())) => {} + } + let result = record::decrypt(Scope::Keyset(keyset.clone()), tree, &plan, None); + match (case.class, result) { + (Class::Shape, Err(error)) => { + if let Err(message) = refusal(&error, want.unwrap()) { + fail(message); + } + } + (Class::Shape, Ok(_)) => fail("the shape was accepted".into()), + (_, Err(error)) => fail(format!("refused before any pending operation: {error:?}")), + (Class::Authenticated, Ok(pending)) => match pending.await { + Ok(_) => fail("opened".into()), + Err(error) => { + if let Err(message) = refusal(&error, want.unwrap()) { + fail(message); + } + } + }, + (Class::Success, Ok(pending)) => match pending.await { + Err(error) => fail(format!("did not open: {error:?}")), + Ok(opened) => { + let rows: Vec<_> = case + .rows + .as_ref() + .unwrap() + .iter() + .map(|row| object(row.iter().map(|(k, v)| (k, value(v))).collect())) + .collect(); + if opened.risky_ref() != frame(rows).risky_ref() { + fail("typed rows differ".into()); + } + } + }, + (Class::Transport | Class::Leaf, Ok(_)) => unreachable!(), + } + if generates.load(Ordering::SeqCst) != 0 { + fail("a read generated a data key".into()); + } + let retrieved = retrieves.load(Ordering::SeqCst); + if retrieved != case.retrievals { + fail(format!( + "{retrieved} retrievals, expected {}", + case.retrievals + )); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// The lenient reader differs from the codec in the duplicate-key rule +/// alone: it reads every frame the codec reads, to the same tree. +#[test] +fn the_lenient_reader_agrees_with_the_codec_on_what_the_codec_accepts() { + let corpus: Value = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/raw-frames.json" + )) + .unwrap(); + let mut compared = 0; + for case in corpus["cases"].as_array().unwrap() { + let bytes = unhex(case["frame_hex"].as_str().unwrap()); + if let Ok(codec) = decode_ciphertext::>(&bytes) { + let ours = lenient(&bytes).expect("the lenient reader reads what the codec reads"); + assert_eq!( + vitaminc_aead_frame::serde::to_vec(&json!(encoded(&ours))).unwrap(), + vitaminc_aead_frame::serde::to_vec(&json!(encoded(&codec))).unwrap(), + "{}", + case["id"] + ); + compared += 1; + } + } + assert!(compared > 0); +} + +/// A tree as JSON, for comparison only. +fn encoded(tree: &Node>) -> Value { + match tree { + CipherText::Single(leaf) => json!({"single": common::hex(leaf)}), + CipherText::EmptySequence(leaf) => json!({"empty_sequence": common::hex(leaf)}), + CipherText::EmptyMap(leaf) => json!({"empty_map": common::hex(leaf)}), + CipherText::Sequence(items) => json!(items.iter().map(encoded).collect::>()), + CipherText::Map(entries) => json!(entries + .iter() + .map(|(k, v)| json!([k, encoded(v)])) + .collect::>()), + CipherText::Passthrough(payload) => json!({"passthrough": common::hex(&payload.0)}), + } +} diff --git a/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.rs b/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.rs new file mode 100644 index 000000000..dd10c96bf --- /dev/null +++ b/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.rs @@ -0,0 +1,23 @@ +//! A struct is not a scalar: `encrypt` refuses it however it serializes. +//! There is no `Serialize` fallback; a whole value is one ciphertext only +//! when the caller names `OpaqueBlob`. +use serde::Serialize; +use stack_encrypt::{KeysetCipher, OpaqueBlob}; +use stack_kms::FakeDataKeySource; +use zeroize::Zeroize; + +#[derive(Serialize, Zeroize)] +struct Profile { + name: String, + age: u32, +} + +async fn encrypt(keyset: &KeysetCipher<'_, FakeDataKeySource>, profile: Profile) { + let _refused = keyset.encrypt(profile).await.unwrap(); +} + +async fn encrypt_wrapped(keyset: &KeysetCipher<'_, FakeDataKeySource>, profile: Profile) { + let _sealed = keyset.encrypt(OpaqueBlob::new(profile)).await.unwrap(); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.stderr b/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.stderr new file mode 100644 index 000000000..ab6727458 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.stderr @@ -0,0 +1,28 @@ +error[E0277]: the trait bound `Profile: OpaqueEncode` is not satisfied + --> tests/ui/opaque_blob_requires_the_wrapper.rs:16:35 + | +16 | let _refused = keyset.encrypt(profile).await.unwrap(); + | ------- ^^^^^^^ unsatisfied trait bound + | | + | required by a bound introduced by this call + | +help: the trait `OpaqueEncode` is not implemented for `Profile` + --> tests/ui/opaque_blob_requires_the_wrapper.rs:10:1 + | +10 | struct Profile { + | ^^^^^^^^^^^^^^ + = help: the following other types implement trait `OpaqueEncode`: + OpaqueBlob + Zeroizing + bool + f32 + f64 + i128 + i16 + i32 + and $N others +note: required by a bound in `stack_encrypt::scalar::>::encrypt` + --> src/scalar.rs + | + | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` diff --git a/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.rs b/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.rs new file mode 100644 index 000000000..c016e6e95 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.rs @@ -0,0 +1,21 @@ +//! `OpaqueBlob` needs `T: Zeroize` to encrypt and to decrypt: the owned +//! source is wiped once it is encoded, and the bound is what makes a +//! decrypted value wipeable. `Serialize` alone is not enough. +use serde::{Deserialize, Serialize}; +use stack_encrypt::{Ciphertext, KeysetCipher, OpaqueBlob}; +use stack_kms::FakeDataKeySource; + +#[derive(Serialize, Deserialize)] +struct Unwiped { + name: String, +} + +async fn encrypt(keyset: &KeysetCipher<'_, FakeDataKeySource>, value: Unwiped) { + let _refused = keyset.encrypt(OpaqueBlob::new(value)).await.unwrap(); +} + +async fn decrypt(keyset: &KeysetCipher<'_, FakeDataKeySource>, ct: Ciphertext) { + let _refused: OpaqueBlob = keyset.decrypt(ct).await.unwrap(); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr b/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr new file mode 100644 index 000000000..65ad10ec0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr @@ -0,0 +1,59 @@ +error[E0277]: the trait bound `Unwiped: zeroize::DefaultIsZeroes` is not satisfied + --> tests/ui/opaque_blob_requires_zeroize.rs:14:35 + | +14 | let _refused = keyset.encrypt(OpaqueBlob::new(value)).await.unwrap(); + | ------- ^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound + | | + | required by a bound introduced by this call + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `Unwiped` + --> tests/ui/opaque_blob_requires_zeroize.rs:9:1 + | + 9 | struct Unwiped { + | ^^^^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `Unwiped` to implement `zeroize::Zeroize` + = note: required for `OpaqueBlob` to implement `OpaqueEncode` +note: required by a bound in `stack_encrypt::scalar::>::encrypt` + --> src/scalar.rs + | + | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` + +error[E0277]: the trait bound `Unwiped: zeroize::DefaultIsZeroes` is not satisfied + --> tests/ui/opaque_blob_requires_zeroize.rs:18:48 + | +18 | let _refused: OpaqueBlob = keyset.decrypt(ct).await.unwrap(); + | ^^^^^^^ unsatisfied trait bound + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `Unwiped` + --> tests/ui/opaque_blob_requires_zeroize.rs:9:1 + | + 9 | struct Unwiped { + | ^^^^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `Unwiped` to implement `zeroize::Zeroize` + = note: required for `OpaqueBlob` to implement `OpaqueDecode` +note: required by a bound in `stack_encrypt::scalar::>::decrypt` + --> src/scalar.rs + | + | pub fn decrypt(&self, ciphertext: Ciphertext) -> Pending<'_, P, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::decrypt` diff --git a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr index f8bde2374..b7ca5de46 100644 --- a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr @@ -7,6 +7,7 @@ error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied | required by a bound introduced by this call | = help: the following other types implement trait `OpaqueEncode`: + OpaqueBlob bool f32 f64 @@ -14,7 +15,6 @@ error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied i16 i32 i64 - i8 and $N others note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs @@ -31,6 +31,7 @@ error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied | required by a bound introduced by this call | = help: the following other types implement trait `OpaqueEncode`: + OpaqueBlob bool f32 f64 @@ -38,7 +39,6 @@ error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied i16 i32 i64 - i8 and $N others note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs diff --git a/packages/stack-encrypt/tests/ui/scalar_nullable.stderr b/packages/stack-encrypt/tests/ui/scalar_nullable.stderr index c4647de38..6430b6316 100644 --- a/packages/stack-encrypt/tests/ui/scalar_nullable.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_nullable.stderr @@ -7,6 +7,7 @@ error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied | required by a bound introduced by this call | = help: the following other types implement trait `OpaqueEncode`: + OpaqueBlob bool f32 f64 @@ -14,7 +15,6 @@ error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied i16 i32 i64 - i8 and $N others note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs @@ -31,6 +31,7 @@ error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied | required by a bound introduced by this call | = help: the following other types implement trait `OpaqueEncode`: + OpaqueBlob bool f32 f64 @@ -38,7 +39,6 @@ error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied i16 i32 i64 - i8 and $N others note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs diff --git a/packages/stack-encrypt/tests/value_conformance.rs b/packages/stack-encrypt/tests/value_conformance.rs new file mode 100644 index 000000000..85742ef15 --- /dev/null +++ b/packages/stack-encrypt/tests/value_conformance.rs @@ -0,0 +1,447 @@ +//! The shared value-kind boundary profile: every kind the engine names, at +//! its edges, through the record path, with native data-key observations. +//! See conformance/stack-encrypt/values-contract.md. +#![cfg(feature = "dynamic")] +mod common; + +use common::{ + conformance::{canonical_float, refusal, value, Input}, + frames::{frame, object}, + hex, +}; +use serde::Deserialize; +use serde_json::json; +use stack_encrypt::dynamic::{self, record, NoTargets, OpSpec, Operand, Scope, ValueFrame}; +use stack_encrypt::{CipherText, StackCipherText}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::atomic::Ordering, +}; + +const KINDS: [&str; 19] = [ + "bool", + "int8", + "int16", + "int32", + "int64", + "int128", + "uint8", + "uint16", + "uint32", + "uint64", + "uint128", + "float32", + "float64", + "decimal", + "date", + "timestamp", + "string", + "bytes", + "null", +]; + +/// The host constructions the Go runner knows how to make and the engine +/// never sees. A name outside this list is a corpus error in both runners. +const HOST_CONSTRUCTIONS: [&str; 6] = [ + "zero_date", + "decimal_scale_29", + "timestamp_year_300000", + "complex128", + "map_int_key", + "channel", +]; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Corpus { + version: u32, + profile: String, + required_capabilities: Vec, + cases: Vec, + host_conversions: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + id: String, + kind: String, + null: String, + outputs: Vec, + input: Input, + /// The input's frozen typed transport frame, hex. Required unless the + /// input is null. + frame: Option, + /// A value of the kind sealed in a second row: with a sealed null, the + /// row whose ciphertext length the null's must equal. + companion: Option, + /// Another spelling whose equality query must derive the stored term. + equal_query: Option, + /// Stored indexes no query operator answers for this kind: the term is + /// stored, and a query of it is refused as `operator_not_answered`. + #[serde(default)] + unanswered: Vec, + expected_error: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HostCase { + id: String, + language: String, + kind: String, + value: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedError { + code: String, + reason: String, + field: String, +} + +/// A stored row's outputs for `value`: the ciphertext leaf's length and the +/// term bytes under each index key, or `None` when nothing is stored. +fn stored(row: &StackCipherText) -> Option<(usize, BTreeMap>)> { + let CipherText::Map(fields) = row else { + panic!("record") + }; + assert!(fields.iter().all(|(name, _)| name == "value")); + let (_, node) = fields.first()?; + let CipherText::Map(outputs) = node else { + panic!("outputs") + }; + let mut length = None; + let mut terms = BTreeMap::new(); + for (key, output) in outputs { + match (key.as_str(), output) { + ("c", CipherText::Single(leaf)) => length = Some(leaf.to_bytes().len()), + ("c", _) => panic!("ciphertext is not one leaf"), + (index, CipherText::Passthrough(term)) => { + let bytes: &[u8] = vitaminc_aead_frame::serde::from_slice(&term.0).unwrap(); + terms.insert(index.to_owned(), bytes.to_vec()); + } + (key, _) => panic!("{key}: not a term"), + } + } + Some((length.expect("ciphertext stored"), terms)) +} + +fn row(input: &Input) -> ValueFrame { + object(vec![("value", value(input))]) +} + +#[tokio::test] +async fn shared_record_value_profile() { + let corpus: Corpus = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/values.json" + )) + .unwrap(); + assert_eq!(corpus.version, 1); + assert_eq!(corpus.profile, "record-values-v1"); + assert_eq!( + corpus.required_capabilities, + ["records", "equality", "value-kinds", "null-policies"] + ); + assert!(!corpus.cases.is_empty() && !corpus.host_conversions.is_empty()); + let mut ids = BTreeSet::new(); + // Host conversions are refused by a binding's host before the engine + // sees a value. Native Rust has no such layer: the corpus is checked + // for shape here and executed by the language each case names. + for case in &corpus.host_conversions { + assert!(!case.id.is_empty() && ids.insert(case.id.clone())); + assert_eq!(case.language, "go", "{}: unknown runner", case.id); + assert!(KINDS.contains(&case.kind.as_str()), "{}", case.id); + assert!( + HOST_CONSTRUCTIONS.contains(&case.value.as_str()), + "{}: unknown host construction {}", + case.id, + case.value + ); + } + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let mut failures = Vec::new(); + for case in &corpus.cases { + assert!(!case.id.is_empty() && ids.insert(case.id.clone())); + eprintln!("value conformance: {}", case.id); + assert!(["", "sql", "sealed"].contains(&case.null.as_str())); + assert!(KINDS[..18].contains(&case.kind.as_str()), "{}", case.id); + assert!(KINDS.contains(&case.input.kind.as_str()), "{}", case.id); + assert!(!case.outputs.is_empty() && case.outputs[0] == "c"); + let is_null = case.input.kind == "null"; + assert_eq!(case.frame.is_none(), is_null, "{}: frame", case.id); + let mut spec = json!({"context": ["conformance", "value"], "outputs": case.outputs, "type": case.kind}); + if !case.null.is_empty() { + spec["null"] = json!(case.null); + } + let plan = + record::plan(&vitaminc_aead_frame::serde::to_vec(&json!({"value": spec})).unwrap()) + .unwrap(); + generates.store(0, Ordering::SeqCst); + retrieves.store(0, Ordering::SeqCst); + let input = value(&case.input); + if let Some(frozen) = &case.frame { + let actual = hex(input.risky_ref()); + if &actual != frozen { + failures.push(format!("{}: frame {actual}, frozen {frozen}", case.id)); + } + } + if let Some(expected) = &case.expected_error { + assert!(case.companion.is_none() && case.equal_query.is_none()); + let error = record::encrypt(&keyset, frame(vec![row(&case.input)]), &plan) + .err() + .expect("refused before any pending operation"); + refusal(&error, &expected.code, &expected.reason, &expected.field); + assert_eq!(generates.load(Ordering::SeqCst), 0, "{}", case.id); + assert_eq!(retrieves.load(Ordering::SeqCst), 0, "{}", case.id); + continue; + } + let mut rows = vec![row(&case.input)]; + rows.extend(case.companion.iter().map(row)); + let sealed = record::encrypt(&keyset, frame(rows), &plan) + .unwrap() + .await + .unwrap(); + let CipherText::Sequence(batch) = &sealed else { + panic!("batch") + }; + let outputs: Vec<_> = batch.iter().map(stored).collect(); + let omitted = is_null && case.null == "sql"; + assert_eq!(outputs[0].is_none(), omitted, "{}: storage", case.id); + let stores = outputs.iter().any(Option::is_some); + assert_eq!( + generates.load(Ordering::SeqCst), + usize::from(stores), + "{}: one batched generation", + case.id + ); + for (index, (_, terms)) in outputs.iter().flatten().enumerate() { + let keys: BTreeSet<_> = terms.keys().map(String::as_str).collect(); + let declared: BTreeSet<_> = case.outputs[1..].iter().map(String::as_str).collect(); + assert_eq!( + keys, declared, + "{}: row {index} stores every index", + case.id + ); + } + if let Some((null_length, _)) = &outputs[0] { + if let Some(Some((companion_length, _))) = outputs.get(1) { + assert_eq!( + null_length, companion_length, + "{}: a sealed null is as long as a value", + case.id + ); + } + } + // Every stored term is the term a query of the value derives. + let mut queried = Vec::new(); + for (target, output) in [(&case.input, 0usize)] + .into_iter() + .chain(case.companion.iter().map(|c| (c, 1usize))) + { + let Some((_, terms)) = &outputs[output] else { + continue; + }; + for (index, term) in terms { + let operator = match index.as_str() { + "eq" => OpSpec::Eq, + "ore" | "ope" => OpSpec::Lt, + other => panic!("{other}"), + }; + if case.unanswered.contains(index) { + let error = dynamic::query_term( + &keyset, + &plan, + "value", + operator, + value(target), + &NoTargets, + ) + .err() + .expect("an unanswered index is refused before any term"); + refusal( + &error, + "stack_encrypt::dynamic_source", + "operator_not_answered", + "value", + ); + continue; + } + queried.push((target.clone(), operator, term.clone())); + } + if output == 0 { + if let Some(other) = &case.equal_query { + queried.push((other.clone(), OpSpec::Eq, terms["eq"].clone())); + } + } + } + for (target, operator, stored_term) in queried { + let term = dynamic::query_term( + &keyset, + &plan, + "value", + operator, + value(&target), + &NoTargets, + ) + .unwrap() + .await + .unwrap(); + if term.operand() != &Operand::One(stored_term) { + failures.push(format!( + "{}: {operator:?} of {target:?} is not the stored term", + case.id + )); + } + } + assert_eq!(generates.load(Ordering::SeqCst), usize::from(stores)); + assert_eq!( + retrieves.load(Ordering::SeqCst), + 0, + "queries retrieve nothing" + ); + let opened = record::decrypt(Scope::Keyset(keyset.clone()), sealed, &plan, None) + .unwrap() + .await + .unwrap(); + let mut expected = vec![row(&case.input)]; + expected.extend(case.companion.iter().map(row)); + if opened.risky_ref() != frame(expected).risky_ref() { + failures.push(format!("{}: typed round trip differs", case.id)); + } + assert_eq!( + retrieves.load(Ordering::SeqCst), + usize::from(stores), + "{}: one batched retrieval", + case.id + ); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// The corpus spells dates, instants and decimals as text, and the shared +/// helper parses them without a calendar library: hold it to the frames the +/// value-kinds fixture froze for the same spellings. +#[test] +fn the_shared_text_forms_frame_as_the_value_kinds_fixture() { + let framed = |kind: &str, text: &str| { + hex(value(&Input { + kind: kind.into(), + text: text.into(), + }) + .risky_ref()) + }; + assert_eq!(framed("date", "2024-02-29"), "1281460b00"); + assert_eq!( + framed("timestamp", "2026-10-17T01:02:03.123456789Z"), + "130bc9d26a0000000015cd5b07" + ); + assert_eq!( + framed("decimal", "-1234.5600"), + "14000004800061bc000000000000000000" + ); + assert_eq!(framed("int8", "-8"), "0cf8"); + assert_eq!( + framed("uint128", "340282366920938463463374607431768211455"), + "11ffffffffffffffffffffffffffffffff" + ); +} + +/// A float spelling with more digits than its value's shortest is rounded +/// by the parse: the helper refuses it instead of sealing another value. +#[test] +#[should_panic(expected = "not the shortest spelling")] +fn a_rounded_float_spelling_is_a_corpus_error() { + value(&Input { + kind: "float64".into(), + text: "0.1000000000000000001".into(), + }); +} + +/// A float spelling with more zeros than its shortest is refused too, though +/// it names the value exactly. +#[test] +#[should_panic(expected = "not the shortest spelling")] +fn a_float_with_trailing_zeros_is_a_corpus_error() { + value(&Input { + kind: "float64".into(), + text: "1.00".into(), + }); +} + +/// Spellings the float reader accepts, as `(bits, text)`; Go's +/// `TestConformanceFloatSpelling` holds its reader to the same lists. +const CANONICAL_FLOATS: &[(u32, &str)] = &[ + (64, "100"), + (64, "12000"), + (64, "1e3"), + (64, "34"), + (64, "-0"), + (64, "1.5"), + (64, "0.5"), + (64, "1e-3"), + (64, "9007199254740992"), + (64, "1.7976931348623157e308"), + (64, "5e-324"), + (64, "inf"), + (64, "-inf"), + (32, "1e-45"), + (32, "3.4028235e38"), +]; + +/// Spellings that parse to a float but are not its canonical spelling. +const NON_CANONICAL_FLOATS: &[(u32, &str)] = &[ + (64, "1.00"), + (64, "1.0"), + (64, "1e0"), + (64, "1.5e0"), + (64, "1000"), + (64, "1e2"), + (64, "1e+3"), + (64, "1E3"), + (64, "10e2"), + (64, "01"), + (64, "+1"), + (64, ".5"), + (64, "5."), + (64, "-0.0"), + (64, "0.001"), + (64, "0.1000000000000000001"), + (64, "Inf"), + (64, "infinity"), + (64, "NaN"), + (32, "1.4e-45"), + (32, "3.4028234664e38"), +]; + +/// The float reader accepts exactly the canonical spelling: the shorter of +/// the positional and scientific forms of the shortest round-trip digits. +#[test] +fn the_float_reader_accepts_only_the_canonical_spelling() { + fn canonical(bits: u32, text: &str) -> Option { + match bits { + 32 => text.parse::().ok().and_then(canonical_float), + 64 => text.parse::().ok().and_then(canonical_float), + _ => unreachable!(), + } + } + for &(bits, text) in CANONICAL_FLOATS { + assert_eq!(canonical(bits, text).as_deref(), Some(text), "float{bits}"); + } + for &(bits, text) in NON_CANONICAL_FLOATS { + assert!(text.parse::().is_ok(), "{text} parses"); + assert_ne!(canonical(bits, text).as_deref(), Some(text), "float{bits}"); + } +} + +/// A day the month does not have is a corpus error, as it is in Go. +#[test] +#[should_panic(expected = "not a calendar date")] +fn a_day_the_month_lacks_is_a_corpus_error() { + value(&Input { + kind: "date".into(), + text: "2023-02-29".into(), + }); +} diff --git a/packages/stack-kms/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock index 049141916..4532e4a4d 100644 --- a/packages/stack-kms/fuzz/Cargo.lock +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -1964,8 +1964,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -1992,8 +1992,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -2411,16 +2411,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -2440,18 +2440,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2468,9 +2468,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -2489,12 +2489,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2513,15 +2513,15 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2544,9 +2544,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -2555,7 +2555,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -2572,9 +2572,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2599,16 +2599,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -2625,9 +2625,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -2652,17 +2652,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] diff --git a/packages/stack-kms/src/futures.rs b/packages/stack-kms/src/futures.rs index 7ecc111ac..bb24224f8 100644 --- a/packages/stack-kms/src/futures.rs +++ b/packages/stack-kms/src/futures.rs @@ -90,4 +90,43 @@ mod tests { assert_eq!(input, output); } + #[test] + fn concurrency_limit_bounds_chunks_and_drop_releases_them() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Waker}; + + struct InFlight(Arc); + impl Drop for InFlight { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } + } + let active = Arc::new(AtomicUsize::new(0)); + let chunks = Arc::new(Mutex::new(Vec::new())); + let input: Vec<_> = (0..10).collect(); + let mut work = Box::pin(map_async_chunked( + &input, + |chunk| { + let active = active.clone(); + let chunks = chunks.clone(); + async move { + active.fetch_add(1, Ordering::SeqCst); + let _in_flight = InFlight(active); + chunks.lock().unwrap().push(chunk.to_vec()); + std::future::pending::, ()>>().await + } + }, + 3, + 2, + )); + assert!(work + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending()); + assert_eq!(active.load(Ordering::SeqCst), 2); + assert_eq!(*chunks.lock().unwrap(), vec![vec![0, 1, 2], vec![3, 4, 5]]); + drop(work); + assert_eq!(active.load(Ordering::SeqCst), 0); + } } diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs index 4d33fe72d..72c504612 100644 --- a/scripts/__tests__/crates-ci.test.mjs +++ b/scripts/__tests__/crates-ci.test.mjs @@ -34,6 +34,33 @@ const readRepo = (relPath) => readFileSync(join(REPO_ROOT, relPath), 'utf8') const ROOT_MISE = 'mise.toml' const IMPORTED_WORKFLOW_DIR = '.github/imported-workflows' +describe('the test toolchain installs from a shared mise cache', () => { + it('runs the prebuilt CRAP executable on a compatible image before coverage', () => { + const workflow = readWorkflow('.github/workflows/crap-crates.yml') + for (const name of ['crap-stack-auth', 'crap-stack-encrypt']) { + const job = workflow.jobs[name] + expect(job['runs-on']).toMatch(/ubuntu-2404$/) + const probe = job.steps.findIndex((s) => s.run === 'cargo crap --version') + const gate = job.steps.findIndex((s) => + s.run?.startsWith('mise run crap:'), + ) + expect(probe).toBeGreaterThan(-1) + expect(gate).toBeGreaterThan(probe) + } + }) + + it('selects the prebuilt cargo-binstall before installing cargo tools', () => { + // EQL installs cargo-binstall. A restored shim with no selected version + // makes mise's cargo backend fail instead of falling back to cargo install. + const tools = readRepo('mise.test.toml') + .split(/^\[tools\]\s*$/m)[1] + .split(/^\[/m)[0] + expect(tools).toMatch(/^"cargo:cargo-nextest"\s*=/m) + expect(tools).toMatch(/^"cargo-binstall"\s*=\s*"\d+\.\d+\.\d+"\s*$/m) + expect(tools).not.toMatch(/^"cargo:cargo-binstall"\s*=/m) + }) +}) + // --------------------------------------------------------------------------- // The task graph // --------------------------------------------------------------------------- diff --git a/scripts/__tests__/eql-suite-ci.test.mjs b/scripts/__tests__/eql-suite-ci.test.mjs index 1c9dc60fe..faace15f0 100644 --- a/scripts/__tests__/eql-suite-ci.test.mjs +++ b/scripts/__tests__/eql-suite-ci.test.mjs @@ -4,6 +4,7 @@ import { dirname, join, relative, sep } from 'node:path' import yaml from 'js-yaml' import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' +import { includes } from './lib/rust-compiled-files.mjs' import { readWorkflow, WORKFLOW_DIR, workflowFiles } from './lib/workflows.mjs' /** @@ -400,16 +401,29 @@ describe('the relevance filter still selects the imported tree', () => { }) it('prefixes every source path with the subtree root', () => { - // Three legitimate exceptions, all outside the subtree by nature: the + // Legitimate exceptions, all outside the subtree by nature: the // workflow's own path, a composite action it `uses:`, and a crate the // workspace compiles by path (plus the root manifest it inherits from). const external = externalPathDependencies.dirs.map((dir) => `${dir}/**`) + // The EQL data-plan suite compiles the shared corpus from outside its + // subtree. Keep this exception tied to that actual dependency. + const sharedCorpus = 'conformance/stack-encrypt/v1/eql-json.json' + const usesSharedCorpus = includes( + readFileSync( + join( + REPO_ROOT, + 'packages/eql/tests/encryption/tests/json_tree_data_plan.rs', + ), + 'utf8', + ), + ).some((entry) => entry.literal?.endsWith(`/${sharedCorpus}`)) const offenders = filterPaths.filter( (path) => path !== EQL_WORKFLOW && !path.startsWith(EQL_PREFIX) && !localActionPrefixes.some((prefix) => path.startsWith(prefix)) && !external.includes(path) && + !(usesSharedCorpus && path === 'conformance/stack-encrypt/**') && !(externalPathDependencies.inherits && path === 'Cargo.toml'), ) @@ -535,7 +549,11 @@ describe('the shared Rust cache is pointed and saved correctly', () => { // Held non-empty, so a change to `isEqlJob` cannot empty the check. expect(EQL_RUST_CACHE_STEPS.length).toBeGreaterThan(0) const wrong = EQL_RUST_CACHE_STEPS.filter( - ({ step }) => step?.with?.workspaces !== CARGO_WORKSPACE, + ({ step }) => + !String(step?.with?.workspaces ?? '') + .split('\n') + .map((line) => line.split('->')[0].trim()) + .includes(CARGO_WORKSPACE), ).map((entry) => stepLabel(entry, `workspaces: ${entry.step?.with?.workspaces}`), ) diff --git a/scripts/__tests__/stack-conformance-ci.test.mjs b/scripts/__tests__/stack-conformance-ci.test.mjs index 1bf80c4ec..450d63c1d 100644 --- a/scripts/__tests__/stack-conformance-ci.test.mjs +++ b/scripts/__tests__/stack-conformance-ci.test.mjs @@ -50,6 +50,13 @@ describe('shared Stack Encrypt conformance is required in CI', () => { expect(script).toMatch(/cargo nextest run[^\n]*--test context_conformance/) expect(script).toMatch(/cargo nextest run[^\n]*--test plan_grammar/) expect(script).toMatch(/cargo nextest run[^\n]*--test fixture_safety/) + expect(script).toMatch(/cargo nextest run[^\n]*--test value_conformance/) + expect(script).toMatch( + /cargo nextest run[^\n]*--test query_refusal_conformance/, + ) + expect(script).toMatch( + /cargo nextest run[^\n]*--test raw_frame_conformance/, + ) const selected = goSelection(script) for (const test of coreGoTests) { expect(selected.test(test), test).toBe(true) diff --git a/scripts/__tests__/stack-eql-conformance.test.mjs b/scripts/__tests__/stack-eql-conformance.test.mjs new file mode 100644 index 000000000..c2b5082ac --- /dev/null +++ b/scripts/__tests__/stack-eql-conformance.test.mjs @@ -0,0 +1,219 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + checkEnvironment, + checkGoResults, + checkRustInventory, + selections, +} from '../stack-eql-conformance.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' + +const read = (path) => readFileSync(join(REPO_ROOT, path), 'utf8') +const index = JSON.parse( + read('conformance/stack-encrypt/v1/eql-json-profiles.json'), +) + +describe('required EQL/JSON conformance', () => { + it('enrolls every operation family and both language/database lanes', () => { + expect(index.requirements.map((r) => r.id)).toEqual([ + 'eql-resolver-catalog-v1', + 'json-write-v1', + 'json-query-v1', + 'json-request-bound-reads-v1', + 'json-element-match-v1', + 'json-predicate-match-v1', + 'json-extraction-v1', + 'json-subtree-v1', + 'json-tamper-v1', + ]) + const contract = read('conformance/stack-encrypt/eql-json-contract.md') + for (const requirement of index.requirements) + expect(contract).toContain(requirement.id) + const reached = new Set() + for (const lane of ['fast', 'postgres']) { + for (const language of ['rust', 'go']) { + for (const [id, suite] of selections(index, lane, language)) { + reached.add(id) + expect(suite.tests.size).toBeGreaterThan(0) + } + } + } + expect([...reached].sort()).toEqual(Object.keys(index.suites).sort()) + const renamed = structuredClone(index) + renamed.requirements[0].evidence.missing = ['TestMissing'] + expect(() => selections(renamed, 'fast', 'all')).toThrow('Invalid suite') + expect(() => selections(index, 'unknown', 'all')).toThrow() + }) + + it('refuses renamed, filtered or wrongly ignored compiled native evidence', () => { + const suite = { + package: 'engine', + binary: 'json', + lane: 'fast', + tests: new Set(['required']), + } + const inventory = { + 'rust-suites': { + 'engine::json': { + status: 'listed', + testcases: { + required: { ignored: false, 'filter-match': { status: 'matches' } }, + }, + }, + }, + } + expect(() => checkRustInventory(inventory, suite)).not.toThrow() + for (const replacement of [ + undefined, + { ignored: true, 'filter-match': { status: 'matches' } }, + { ignored: false, 'filter-match': { status: 'mismatch' } }, + ]) { + const changed = structuredClone(inventory) + changed['rust-suites']['engine::json'].testcases.required = replacement + expect(() => checkRustInventory(changed, suite)).toThrow( + 'Missing or incorrectly ignored', + ) + } + expect(() => + checkRustInventory(inventory, { ...suite, lane: 'postgres' }), + ).toThrow() + inventory['rust-suites']['engine::json'].testcases.required.ignored = true + expect(() => + checkRustInventory(inventory, { ...suite, lane: 'postgres' }), + ).not.toThrow() + }) + + it('requires a Go pass for every name and rejects nested skips', () => { + const passed = JSON.stringify({ Action: 'pass', Test: 'TestRequired' }) + expect(() => checkGoResults(passed, ['TestRequired'])).not.toThrow() + expect(() => checkGoResults('', ['TestRequired'])).toThrow('did not pass') + for (const action of ['skip', 'fail']) { + const output = `${passed}\n${JSON.stringify({ Action: action, Test: 'TestRequired/case' })}` + expect(() => checkGoResults(output, ['TestRequired'])).toThrow(action) + } + expect(() => checkGoResults(passed, ['TestRenamed'])).toThrow( + 'did not pass', + ) + }) + + it('refuses every update-variable spelling, including an empty or zero value', () => { + for (const name of [ + 'STACK_CONFORMANCE_UPDATE', + 'STACK_ENCRYPT_UPDATE_FIXTURES', + 'STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS', + ]) { + for (const value of ['', '0', '1']) + expect(() => checkEnvironment({ [name]: value })).toThrow(name) + } + expect(() => checkEnvironment({})).not.toThrow() + }) + + it('shares the original write and predicate tables without dropping boundary cases', () => { + const corpus = JSON.parse( + read('conformance/stack-encrypt/v1/eql-json.json'), + ) + expect(corpus.version).toBe(1) + expect(new Set(corpus.documents).size).toBe(corpus.documents.length) + for (const text of [ + 'null', + '{}', + '[]', + '18446744073709551615', + '-9223372036854775808', + '-926809.5051008083', + ]) + expect(corpus.documents).toContain(text) + expect(corpus.invalid_documents.map((c) => c.reason)).toEqual([ + 'json_duplicate_key', + 'json_integer_range', + 'json_syntax', + 'json_syntax', + ]) + expect( + corpus.predicate_refusals.find((c) => c.id === 'too-deep'), + ).not.toHaveProperty('vars') + const ids = corpus.predicate_refusals.map((c) => c.id) + expect(new Set(ids).size).toBe(15) + for (const c of corpus.predicate_refusals) { + expect(c.at).toBeGreaterThanOrEqual(0) + expect(c.at).toBeLessThan(Buffer.byteLength(c.predicate)) + expect(c.reason).toMatch(/^json_/) + expect(c.construct).toBeTruthy() + expect(Buffer.from(c.predicate).subarray(c.at).toString()).toBe(c.at_text) + } + for (const path of [ + 'packages/stack-encrypt/tests/json_operators.rs', + 'packages/eql/tests/encryption/tests/json_tree_data_plan.rs', + 'languages/golang/encrypt/json_conformance_test.go', + ]) + expect(read(path)).toContain('eql-json.json') + }) + + it('runs both required lanes in CI and retains reports with SQL and engine identity', () => { + const workflow = readWorkflow('.github/workflows/tests-golang.yml') + const eqlSetup = workflow.jobs['wasi-check'].steps.find( + (step) => + step.uses?.startsWith('jdx/mise-action@') && + step.with?.working_directory === 'packages/eql', + ) + // Installing the EQL tools must not export its local DATABASE_URL into + // the hermetic Go job, which has no PostgreSQL service. + expect(eqlSetup).toBeDefined() + expect(eqlSetup.with.env).toBe(false) + for (const task of ['test:conformance:eql', 'go:test:postgres']) { + const jobs = Object.values(workflow.jobs).filter((job) => + job.steps?.some((step) => step.run === `mise run ${task}`), + ) + expect(jobs).toHaveLength(1) + const step = jobs[0].steps.find((step) => step.run === `mise run ${task}`) + expect(step['continue-on-error']).toBeUndefined() + expect(jobs[0].env.MISE_ENV).toBe('test') + expect(step.env.STACK_CONFORMANCE_REPORT_DIR).toBeTruthy() + if (task.endsWith(':postgres')) { + const steps = jobs[0].steps + const repair = steps.findIndex((s) => + s.run?.includes('mise install --force rust'), + ) + const cache = steps.findIndex((s) => + s.uses?.startsWith('Swatinem/rust-cache@'), + ) + expect(repair).toBeGreaterThan(-1) + expect(cache).toBeGreaterThan(repair) + expect(steps[repair].run).toContain( + 'mise -C packages/eql install --force rust', + ) + expect(steps[repair].run).toContain('mise x -- cargo --version') + expect(steps[repair].run).toContain( + 'mise -C packages/eql x -- cargo --version', + ) + expect(jobs[0].services.postgres).toBeDefined() + expect(step.env.EQL_TEST_DATABASE_URL).toContain( + 'job.services.postgres.ports', + ) + } + expect( + jobs[0].steps.some( + (s) => + s.uses?.startsWith('actions/upload-artifact@') && + s.with?.path?.includes('eql-conformance') && + s.with['if-no-files-found'] === 'error', + ), + ).toBe(true) + } + // Parse YAML paths rather than counting source spellings or indentation. + for (const event of ['push', 'pull_request']) { + expect(workflow.on[event].paths).toContain( + 'scripts/stack-eql-conformance.mjs', + ) + } + const tasks = read('mise.toml') + const postgresTask = tasks + .split('[tasks."go:test:postgres"]')[1] + .split('[tasks.')[0] + expect(postgresTask).toContain('mise run test:conformance:eql:postgres') + expect(postgresTask).not.toMatch(/^psql\s/m) + expect(postgresTask).not.toContain('mise -C packages/eql run build') + }) +}) diff --git a/scripts/__tests__/stack-lifecycle-conformance.test.mjs b/scripts/__tests__/stack-lifecycle-conformance.test.mjs new file mode 100644 index 000000000..42b05ba3a --- /dev/null +++ b/scripts/__tests__/stack-lifecycle-conformance.test.mjs @@ -0,0 +1,122 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + checkRustInventory, + selections, +} from '../stack-conformance-evidence.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' + +const read = (path) => readFileSync(join(REPO_ROOT, path), 'utf8') +const index = JSON.parse( + read('conformance/stack-encrypt/v1/lifecycle-profiles.json'), +) + +describe('required lifecycle evidence', () => { + it('enrolls each guarantee and keeps limitations outside passing evidence', () => { + expect(index.requirements.map((r) => r.id)).toEqual([ + 'lifecycle-ownership-v1', + 'lifecycle-cancellation-v1', + 'lifecycle-partial-output-v1', + 'lifecycle-buffer-cleanup-v1', + 'lifecycle-diagnostics-v1', + 'lifecycle-validation-before-io-v1', + 'lifecycle-logical-batching-v1', + 'lifecycle-provider-chunks-v1', + ]) + const contract = read('conformance/stack-encrypt/lifecycle-contract.md') + for (const r of index.requirements) expect(contract).toContain(r.id) + const selected = selections(index, 'fast', 'all') + expect([...selected.keys()].sort()).toEqual( + Object.keys(index.suites).sort(), + ) + expect( + selected + .get('go') + .tests.has('TestPlaintextDoesNotRemainInGuestMemoryAfterDecrypt'), + ).toBe(false) + expect(index.limitations).toContainEqual( + expect.objectContaining({ + id: 'go-post-decrypt-shadow-stack', + status: 'known-gap', + }), + ) + expect(index.limitations).toContainEqual( + expect.objectContaining({ + id: 'python-typescript-lifecycle', + status: 'not-enrolled', + }), + ) + for (const limitation of index.limitations) + expect(contract).toContain(limitation.id) + }) + + it('checks compiled library names as strictly as integration binaries', () => { + const suite = { + package: 'engine', + lib: true, + lane: 'fast', + tests: new Set(['nested::required']), + } + const inventory = { + 'rust-suites': { + engine: { + status: 'listed', + testcases: { + 'nested::required': { + ignored: false, + 'filter-match': { status: 'matches' }, + }, + }, + }, + }, + } + expect(() => checkRustInventory(inventory, suite)).not.toThrow() + delete inventory['rust-suites'].engine.testcases['nested::required'] + expect(() => checkRustInventory(inventory, suite)).toThrow( + 'Missing or incorrectly ignored', + ) + }) + + it('preserves package boundaries and fails on a missing requirement', () => { + const selected = selections(index, 'fast', 'go') + expect([...selected.values()].map((s) => s.package).sort()).toEqual([ + './encrypt', + './encrypt/gensupport', + './internal/guest', + ]) + const changed = structuredClone(index) + changed.requirements[0].evidence.go = [] + expect(() => selections(changed, 'fast', 'go')).toThrow('Invalid tests') + }) + + it('runs the lifecycle task in required CI and retains its report', () => { + const workflow = readWorkflow('.github/workflows/tests-golang.yml') + const job = workflow.jobs['wasi-check'] + expect(job.env.MISE_ENV).toBe('test') + const step = job.steps.find( + (s) => s.run === 'mise run test:conformance:lifecycle', + ) + expect(step).toBeDefined() + expect(step.if).toBeUndefined() + expect(step['continue-on-error']).toBeUndefined() + expect(step.env.STACK_CONFORMANCE_REPORT_DIR).toBeTruthy() + expect( + job.steps.some( + (s) => + s.uses?.startsWith('actions/upload-artifact@') && + s.with?.name === 'lifecycle-conformance' && + s.with['if-no-files-found'] === 'error', + ), + ).toBe(true) + for (const event of ['push', 'pull_request']) { + for (const path of [ + 'scripts/stack-conformance-evidence.mjs', + 'scripts/stack-lifecycle-conformance.mjs', + 'conformance/stack-encrypt/**', + ]) + expect(workflow.on[event].paths).toContain(path) + } + }) +}) diff --git a/scripts/stack-conformance-evidence.mjs b/scripts/stack-conformance-evidence.mjs new file mode 100644 index 000000000..4ca9bcd7f --- /dev/null +++ b/scripts/stack-conformance-evidence.mjs @@ -0,0 +1,192 @@ +// Execution and non-vacuity checks shared by conformance profiles. +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +export const read = (path) => readFileSync(join(root, path), 'utf8') +export const hash = (path) => + createHash('sha256') + .update(readFileSync(join(root, path))) + .digest('hex') + +export function selections(index, lane, language) { + if ( + index.version !== 1 || + !['fast', 'postgres'].includes(lane) || + !['rust', 'go', 'all'].includes(language) + ) { + throw new Error('Unsupported conformance version, lane or language') + } + const selected = new Map() + const ids = new Set() + for (const requirement of index.requirements) { + if (!/^[a-z][a-z0-9-]+-v1$/.test(requirement.id) || ids.has(requirement.id)) + throw new Error('Invalid or repeated requirement ID') + ids.add(requirement.id) + if (!Object.keys(requirement.evidence).length) + throw new Error(`Empty evidence: ${requirement.id}`) + for (const [id, tests] of Object.entries(requirement.evidence)) { + const suite = index.suites[id] + if ( + !suite || + !['rust', 'go'].includes(suite.language) || + !['fast', 'postgres'].includes(suite.lane) + ) + throw new Error(`Invalid suite: ${id}`) + if ( + !tests.length || + new Set(tests).size !== tests.length || + tests.some( + (name) => + !(suite.language === 'go' ? /^Test\w+$/ : /^[a-zA-Z0-9_:]+$/).test( + name, + ), + ) + ) + throw new Error(`Invalid tests: ${id}`) + if ( + suite.lane !== lane || + (language !== 'all' && language !== suite.language) + ) + continue + if (!selected.has(id)) selected.set(id, { ...suite, tests: new Set() }) + for (const name of tests) selected.get(id).tests.add(name) + } + } + if (!selected.size) throw new Error('No EQL conformance evidence selected') + return selected +} + +export function checkRustInventory(inventory, suite) { + const binary = + inventory['rust-suites'][ + suite.lib ? suite.package : `${suite.package}::${suite.binary}` + ] + if (binary?.status !== 'listed') + throw new Error(`Missing compiled binary: ${suite.binary}`) + for (const name of suite.tests) { + const test = binary.testcases[name] + if ( + !test || + test.ignored !== (suite.lane === 'postgres') || + test['filter-match'].status !== 'matches' + ) { + throw new Error( + `Missing or incorrectly ignored compiled test: ${suite.binary}::${name}`, + ) + } + } +} + +export function checkGoResults(output, tests) { + const passed = new Set() + for (const line of output.trim().split('\n')) { + if (!line) continue + const event = JSON.parse(line) + if (event.Action === 'skip' || event.Action === 'fail') + throw new Error( + `Go conformance ${event.Action}: ${event.Test ?? event.Package}`, + ) + if (event.Action === 'pass' && event.Test) passed.add(event.Test) + } + for (const name of tests) + if (!passed.has(name)) throw new Error(`Go test did not pass: ${name}`) +} + +export function checkEnvironment(env) { + for (const name of [ + 'STACK_CONFORMANCE_UPDATE', + 'STACK_ENCRYPT_UPDATE_FIXTURES', + 'STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS', + ]) { + if (Object.hasOwn(env, name)) + throw new Error(`Conformance cannot update fixtures: ${name}`) + } +} + +export function command(program, args, { capture = false, ...options } = {}) { + const result = spawnSync(program, args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', + ...options, + }) + if (result.error) throw result.error + if (result.status !== 0) { + if (capture && result.stdout) process.stderr.write(result.stdout) + throw new Error(`${program} failed (${result.status})`) + } + return result.stdout +} + +export function cargo(args, capture = false, workspace = '.') { + return command('mise', ['x', '--env', 'test', '--', 'cargo', ...args], { + capture, + cwd: join(root, workspace), + }) +} + +export function runSuite(suite, { wholeBinary = false } = {}) { + const lane = suite.lane + if (suite.language === 'rust') { + const common = [ + '--locked', + '--manifest-path', + 'Cargo.toml', + '-p', + suite.package, + ...(suite.lib ? ['--lib'] : ['--test', suite.binary]), + ...suite.features, + '--run-ignored', + lane === 'postgres' ? 'only' : 'default', + ] + if (!wholeBinary) + common.push( + '-E', + [...suite.tests].map((name) => `test(=${name})`).join(' | '), + ) + const inventory = JSON.parse( + cargo( + ['nextest', 'list', ...common, '--message-format', 'json'], + true, + suite.workspace, + ), + ) + checkRustInventory(inventory, suite) + // EQL retains whole binaries. Lifecycle selects enrolled tests from large + // unit suites. PostgreSQL tests are intentionally ignored in the fast lane. + cargo( + [ + 'nextest', + 'run', + ...common, + '--success-output', + suite.success_output ?? (lane === 'postgres' ? 'immediate' : 'never'), + ], + false, + suite.workspace, + ) + } else { + const pattern = `^(${[...suite.tests].sort().join('|')})$` + const output = command( + 'go', + [ + 'test', + '-C', + 'languages/golang', + '-count=1', + '-json', + '-run', + pattern, + suite.package ?? './encrypt', + ], + { capture: true, env: { ...process.env, CGO_ENABLED: '0' } }, + ) + checkGoResults(output, suite.tests) + console.log(`Passed ${suite.tests.size} required Go tests; no skips`) + } +} diff --git a/scripts/stack-conformance-selection.mjs b/scripts/stack-conformance-selection.mjs index 8725f360d..8422271d1 100644 --- a/scripts/stack-conformance-selection.mjs +++ b/scripts/stack-conformance-selection.mjs @@ -10,6 +10,9 @@ export const coreGoTests = [ 'TestReadConformance', 'TestContextConformance', 'TestContextBoundaries', + 'TestValueConformance', + 'TestQueryRefusalConformance', + 'TestRawFrameConformance', 'TestFixtureConformanceRequiresGuest', ] diff --git a/scripts/stack-conformance.sh b/scripts/stack-conformance.sh index 53cf2780a..446e126c2 100644 --- a/scripts/stack-conformance.sh +++ b/scripts/stack-conformance.sh @@ -19,7 +19,7 @@ go_tests=$(node scripts/stack-conformance-selection.mjs) artifacts=$(mktemp -d) trap 'rm -rf "$artifacts"' EXIT -mise x --env test -- cargo nextest run --locked -p stack-encrypt --all-features --test conformance --test declarations --test runtime_conformance --test read_conformance --test context_conformance --test record_lowering --test plan_grammar --test value_kinds --test query_operators --test fixture_safety +mise x --env test -- cargo nextest run --locked -p stack-encrypt --all-features --test conformance --test declarations --test runtime_conformance --test read_conformance --test context_conformance --test record_lowering --test plan_grammar --test value_kinds --test query_operators --test fixture_safety --test value_conformance --test query_refusal_conformance --test raw_frame_conformance ( cd languages/golang STACK_CONFORMANCE_OUTPUT="$artifacts/go.json" CGO_ENABLED=0 \ diff --git a/scripts/stack-eql-conformance.mjs b/scripts/stack-eql-conformance.mjs new file mode 100644 index 000000000..e19811b0b --- /dev/null +++ b/scripts/stack-eql-conformance.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// Named evidence over the existing suites. Missing, ignored and skipped tests +// are failures; a successful empty selection is not conformance. +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + cargo, + checkEnvironment, + command, + hash, + read, + runSuite, + selections, +} from './stack-conformance-evidence.mjs' + +export { + checkEnvironment, + checkGoResults, + checkRustInventory, + selections, +} from './stack-conformance-evidence.mjs' + +function metadata(workspace) { + return JSON.parse( + cargo( + [ + 'metadata', + '--locked', + '--no-deps', + '--format-version', + '1', + '--manifest-path', + 'Cargo.toml', + ], + true, + workspace, + ), + ) +} + +export function main(args = process.argv.slice(2)) { + // Positional arguments intentionally have no implicit database fallback. + const [lane, language = 'all'] = args + if (args.length > 2) + throw new Error( + 'Usage: stack-eql-conformance.mjs [rust|go|all]', + ) + checkEnvironment(process.env) + const index = JSON.parse( + read('conformance/stack-encrypt/v1/eql-json-profiles.json'), + ) + const selected = selections(index, lane, language) + process.env.STACK_CONFORMANCE_REQUIRED = '1' + delete process.env.STACK_CONFORMANCE_INPUT + delete process.env.STACK_CONFORMANCE_OUTPUT + const report = { + profile: 'eql-json-v1', + lane, + language, + engine: metadata('.').packages.find((p) => p.name === 'stack-encrypt') + .version, + eql_bindings: metadata('packages/eql').packages.find( + (p) => p.name === 'eql-bindings', + ).version, + corpus_sha256: hash('conformance/stack-encrypt/v1/eql-json.json'), + enrollment_sha256: hash( + 'conformance/stack-encrypt/v1/eql-json-profiles.json', + ), + root_lock_sha256: hash('Cargo.lock'), + eql_lock_sha256: hash('packages/eql/Cargo.lock'), + evidence: {}, + } + if ([...selected.values()].some((s) => s.language === 'go')) { + // Both builds are required: capability refusal is tested with the plain guest. + report.guests = Object.fromEntries( + [ + 'stack_encrypt_guest_deterministic.wasm', + 'stack_encrypt_guest_eql_deterministic.wasm', + ].map((name) => [ + name, + hash(`languages/golang/encrypt/testdata/${name}`), + ]), + ) + } + if (lane === 'postgres') { + const url = process.env.EQL_TEST_DATABASE_URL + if (!url) + throw new Error( + 'Set EQL_TEST_DATABASE_URL to a disposable PostgreSQL database', + ) + const bundle = 'packages/eql/release/cipherstash-encrypt-v4.sql' + report.expected_schema = 'eql_v4' + report.sql_bundle_sha256 = hash(bundle) + // Install exactly the bytes whose identity is recorded, into the same + // database both runners use. Never report a merely adjacent bundle's hash. + command('psql', ['-X', url, '-v', 'ON_ERROR_STOP=1', '-q', '-f', bundle]) + report.database = JSON.parse( + command( + 'psql', + [ + '-X', + url, + '-At', + '-v', + 'ON_ERROR_STOP=1', + '-c', + "select json_build_object('postgres', current_setting('server_version'), 'eql', eql_v4.version(), 'schema', (select nspname from pg_namespace where oid = to_regnamespace('eql_v4')))", + ], + { capture: true }, + ), + ) + process.env.DATABASE_URL = url + } + for (const [id, suite] of selected) { + console.log(`EQL conformance: ${id} (${suite.tests.size} required tests)`) + runSuite(suite, { wholeBinary: true }) + report.evidence[id] = [...suite.tests].sort() + } + console.log(JSON.stringify(report, null, 2)) + if (process.env.STACK_CONFORMANCE_REPORT_DIR) { + mkdirSync(process.env.STACK_CONFORMANCE_REPORT_DIR, { recursive: true }) + writeFileSync( + join( + process.env.STACK_CONFORMANCE_REPORT_DIR, + `eql-json-${lane}-${language}.json`, + ), + `${JSON.stringify(report, null, 2)}\n`, + ) + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main() diff --git a/scripts/stack-lifecycle-conformance.mjs b/scripts/stack-lifecycle-conformance.mjs new file mode 100644 index 000000000..a50113bc4 --- /dev/null +++ b/scripts/stack-lifecycle-conformance.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + cargo, + checkEnvironment, + hash, + read, + runSuite, + selections, +} from './stack-conformance-evidence.mjs' + +export function main(args = process.argv.slice(2)) { + const [language = 'all'] = args + if (args.length > 1) + throw new Error('Usage: stack-lifecycle-conformance.mjs [rust|go|all]') + checkEnvironment(process.env) + if (!['linux', 'darwin'].includes(process.platform)) + throw new Error( + 'The current lifecycle profile requires a Unix heap observer; this platform is not enrolled', + ) + const path = 'conformance/stack-encrypt/v1/lifecycle-profiles.json' + const index = JSON.parse(read(path)) + const selected = selections(index, 'fast', language) + process.env.STACK_CONFORMANCE_REQUIRED = '1' + delete process.env.STACK_CONFORMANCE_INPUT + delete process.env.STACK_CONFORMANCE_OUTPUT + const metadata = JSON.parse( + cargo(['metadata', '--locked', '--no-deps', '--format-version', '1'], true), + ) + const report = { + profile: 'lifecycle-v1', + language, + platform: process.platform, + architecture: process.arch, + engine: metadata.packages.find((p) => p.name === 'stack-encrypt').version, + enrollment_sha256: hash(path), + lock_sha256: hash('Cargo.lock'), + limitations: index.limitations, + evidence: {}, + } + if (language !== 'rust') { + report.guests = Object.fromEntries( + [ + 'encrypt/wasm/stack_encrypt_guest.wasm', + 'encrypt/testdata/stack_encrypt_guest_deterministic.wasm', + ].map((name) => [name, hash(`languages/golang/${name}`)]), + ) + } + for (const [id, suite] of selected) { + console.log( + `Lifecycle conformance: ${id} (${suite.tests.size} required tests)`, + ) + runSuite(suite) + report.evidence[id] = [...suite.tests].sort() + } + console.log(JSON.stringify(report, null, 2)) + if (process.env.STACK_CONFORMANCE_REPORT_DIR) { + mkdirSync(process.env.STACK_CONFORMANCE_REPORT_DIR, { recursive: true }) + writeFileSync( + join( + process.env.STACK_CONFORMANCE_REPORT_DIR, + `lifecycle-${language}.json`, + ), + `${JSON.stringify(report, null, 2)}\n`, + ) + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main()