diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23900ecc..5d8d69ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,8 @@ jobs: -p warp-geom \ -p warp-wasm \ -p echo-wasm-abi \ + -p echo-edict-canonical \ + -p echo-edict-provider-lowerer \ -p echo-runtime-schema \ -p echo-dry-tests \ -p echo-graph \ @@ -175,6 +177,55 @@ jobs: . - name: cargo test (workspace sans warp-core) run: cargo test --workspace --exclude warp-core + - name: package publishable Edict canonical leaf + run: cargo package -p echo-edict-canonical --locked + + build-edict-provider-lowerer: + name: Build and check Edict provider lowerer + runs-on: ubuntu-24.04 + container: + image: docker.io/library/rust@sha256:3914072ca0c3b8aad871db9169a651ccfce30cf58303e5d6f2db16d1d8a7e58f + options: --platform linux/amd64 + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: dtolnay/rust-toolchain@1.90.0 + with: + components: clippy + targets: wasm32-unknown-unknown + - name: build, audit, and check exact component bytes + env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: safe.directory + GIT_CONFIG_VALUE_0: ${{ github.workspace }} + run: | + cargo xtask provider-lowerer-component check \ + --target-dir target/provider-lowerer-component \ + --output schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm + - name: clippy (wasm32 Edict provider adapter) + run: cargo +1.90.0 clippy -p echo-edict-provider-lowerer --target wasm32-unknown-unknown --lib -- -D warnings -D missing_docs + + edict-provider-host-v1: + name: Edict provider host contract (Rust 1.94) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: dtolnay/rust-toolchain@1.90.0 + with: + targets: wasm32-unknown-unknown + - uses: dtolnay/rust-toolchain@1.94.0 + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + tests/edict-provider-host-v1 -> target/edict-provider-host-v1 + - name: invoke and replay through the exact Edict host + run: scripts/verify-edict-provider-host-v1.sh build-echo-cas-wasm: name: Build echo-cas (wasm32) diff --git a/.github/workflows/det-gates.yml b/.github/workflows/det-gates.yml index 0a76bd5e..3bf04c51 100644 --- a/.github/workflows/det-gates.yml +++ b/.github/workflows/det-gates.yml @@ -220,57 +220,120 @@ jobs: perf.log perf-report.json - build-repro: - name: G4 build reproducibility (wasm) + build-repro-candidate: + name: G4 build candidate ${{ matrix.candidate }} needs: classify-changes if: needs.classify-changes.outputs.run_g4 == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + container: + image: docker.io/library/rust@sha256:3914072ca0c3b8aad871db9169a651ccfce30cf58303e5d6f2db16d1d8a7e58f + options: --platform linux/amd64 timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + candidate: [1, 2] steps: - - name: Setup Rust (Global) - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - name: Checkout Build 1 + - name: Checkout exact candidate source uses: actions/checkout@v4 with: - path: build1 - - name: Build 1 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Install wasm target + run: rustup target add wasm32-unknown-unknown + - name: Build isolated candidate + env: + CANDIDATE: ${{ matrix.candidate }} + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: safe.directory + GIT_CONFIG_VALUE_0: ${{ github.workspace }} run: | - cd build1 - rustup target add wasm32-unknown-unknown repo_root="$(pwd -P)" export RUSTFLAGS="--remap-path-prefix=${repo_root}=/workspace" cargo build --release --target wasm32-unknown-unknown -p warp-wasm --features engine - sha256sum target/wasm32-unknown-unknown/release/warp_wasm.wasm > ../hash1.txt - cp target/wasm32-unknown-unknown/release/warp_wasm.wasm ../build1.wasm - - name: Checkout Build 2 + sha256sum target/wasm32-unknown-unknown/release/warp_wasm.wasm > "hash${CANDIDATE}.txt" + cp target/wasm32-unknown-unknown/release/warp_wasm.wasm "build${CANDIDATE}.wasm" + cargo xtask provider-lowerer-component designated-build \ + --target-dir target/provider-lowerer-repro \ + --output target/lowerer.echo-dpo.component.wasm + sha256sum target/lowerer.echo-dpo.component.wasm > "lowerer-hash${CANDIDATE}.txt" + cp target/lowerer.echo-dpo.component.wasm "build${CANDIDATE}.lowerer.component.wasm" + - name: Upload isolated candidate + uses: actions/upload-artifact@v4 + with: + name: build-repro-candidate-${{ matrix.candidate }} + overwrite: true + path: | + hash${{ matrix.candidate }}.txt + build${{ matrix.candidate }}.wasm + lowerer-hash${{ matrix.candidate }}.txt + build${{ matrix.candidate }}.lowerer.component.wasm + + build-repro: + name: G4 independent build comparison + needs: + - classify-changes + - build-repro-candidate + if: needs.classify-changes.outputs.run_g4 == 'true' && needs.build-repro-candidate.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact candidate source uses: actions/checkout@v4 with: - path: build2 - - name: Build 2 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Setup Rust for portable promotion + uses: dtolnay/rust-toolchain@1.90.0 + - name: Download candidate 1 + uses: actions/download-artifact@v4 + with: + name: build-repro-candidate-1 + path: candidate1 + - name: Download candidate 2 + uses: actions/download-artifact@v4 + with: + name: build-repro-candidate-2 + path: candidate2 + - name: Assemble exact evidence names run: | - cd build2 - rustup target add wasm32-unknown-unknown - repo_root="$(pwd -P)" - export RUSTFLAGS="--remap-path-prefix=${repo_root}=/workspace" - cargo build --release --target wasm32-unknown-unknown -p warp-wasm --features engine - sha256sum target/wasm32-unknown-unknown/release/warp_wasm.wasm > ../hash2.txt - cp target/wasm32-unknown-unknown/release/warp_wasm.wasm ../build2.wasm - - name: Compare hashes + cp candidate1/hash1.txt hash1.txt + cp candidate2/hash2.txt hash2.txt + cp candidate1/build1.wasm build1.wasm + cp candidate2/build2.wasm build2.wasm + cp candidate1/lowerer-hash1.txt lowerer-hash1.txt + cp candidate2/lowerer-hash2.txt lowerer-hash2.txt + cp candidate1/build1.lowerer.component.wasm build1.lowerer.component.wasm + cp candidate2/build2.lowerer.component.wasm build2.lowerer.component.wasm + - name: Compare, promote, and check exact bytes run: | diff hash1.txt hash2.txt || (echo "Reproducibility failure: Hashes differ!" && exit 1) + diff lowerer-hash1.txt lowerer-hash2.txt || (echo "Lowerer reproducibility failure: Hashes differ!" && exit 1) + cmp build1.wasm build2.wasm + cmp build1.lowerer.component.wasm build2.lowerer.component.wasm + cargo xtask provider-lowerer-component promote \ + --candidate-a build1.lowerer.component.wasm \ + --candidate-b build2.lowerer.component.wasm \ + --output target/lowerer.echo-dpo.promoted.component.wasm \ + --write + cmp build1.lowerer.component.wasm target/lowerer.echo-dpo.promoted.component.wasm + cmp build1.lowerer.component.wasm schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm + cmp build2.lowerer.component.wasm schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm echo "Hashes match: $(cat hash1.txt)" + echo "Lowerer hashes match: $(cat lowerer-hash1.txt)" - name: Upload build artifacts if: always() uses: actions/upload-artifact@v4 with: name: build-repro-artifacts + overwrite: true path: | hash1.txt hash2.txt build1.wasm build2.wasm + lowerer-hash1.txt + lowerer-hash2.txt + build1.lowerer.component.wasm + build2.lowerer.component.wasm validate-evidence: name: Evidence artifact presence @@ -349,6 +412,10 @@ jobs: gathered-artifacts/build-repro-artifacts/hash2.txt gathered-artifacts/build-repro-artifacts/build1.wasm gathered-artifacts/build-repro-artifacts/build2.wasm + gathered-artifacts/build-repro-artifacts/lowerer-hash1.txt + gathered-artifacts/build-repro-artifacts/lowerer-hash2.txt + gathered-artifacts/build-repro-artifacts/build1.lowerer.component.wasm + gathered-artifacts/build-repro-artifacts/build2.lowerer.component.wasm ) fi diff --git a/CHANGELOG.md b/CHANGELOG.md index e6d43b1e..02230015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,42 @@ ### Added +- Echo now provides the exact `edict:target-provider/lowerer@1.0.0` + Component Model implementation for the first checked provider closure. The + pure lowerer accepts only explicit digest-bound Core, target-profile, + authority, lawpack, lowerability, and output-role inputs; produces canonical + `echo.span-ir/v1` Target IR with byte-for-byte parity to Edict's built-in Echo + wrapper; and returns typed refusals for unsupported ABI, profiles, semantics, + reads, rebound operations, unresolved authored optics, changed type bindings, + Core type definitions, evaluation budgets, out-of-scope locals, intrinsics, + and output roles. Local admission distinguishes pre-effect, obstruction-arm, + and post-effect scope from the exact input, effect-result, and obstruction + declarations before cloning any expression into Target IR. The first closure + also requires an empty input-constraint set and the exact zero-argument + `domain.WriteRejected` obstruction constructor. Effect inputs and intent + results admit no call-expression callee, refusing unreviewed calls until their + own lowering laws exist. A + deterministic build boundary pins the frozen + WIT bytes, rejects ambient or callable imports, checks the exact decoded world + type graph and contract attestation, and reproduces the checked component + byte-for-byte across independently provisioned `linux/amd64` containers from + the immutable Rust image used by CI. The builder resolves and authenticates the + exact Rust and Cargo executables, binds Cargo to that compiler, owns the inner + Cargo home, removes ambient Cargo profile/build/target overrides, remaps its + dependency source paths to `/cargo`, and atomically promotes only distinct + candidates matching a reviewed repository digest. + Other-host builds are structural and semantic witnesses + rather than cross-host compiler-identity claims. The publication-enabled, + archive-self-contained `echo-edict-provider-lowerer` source crate carries + package-local copies of its four exact admitted resources, with a workspace + witness binding them to the checked generated corpus. Its full package gate + follows publication of `echo-edict-canonical 0.1.0`. These artifacts describe + and translate provider semantics; they confer no Echo runtime authority. +- `echo-edict-canonical` now owns the shared pure implementation of Edict's + canonical CBOR and domain-framed digest contracts as a publishable `0.1.0` + leaf. `echo-wesley-gen` retains its existing compatibility surface through a + re-export, while executable provider components use the same codec without + depending on generator or Wesley APIs. - `echo-wesley-gen` now checks in the first exact 22-file Edict provider artifact corpus: five canonical-CBOR primaries, fourteen canonical-CBOR resources, the self-contained CDDL, Wesley provenance JSON, and diff --git a/Cargo.lock b/Cargo.lock index a5ad3413..89e06c00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -648,6 +648,24 @@ dependencies = [ "warp-core", ] +[[package]] +name = "echo-edict-canonical" +version = "0.1.0" +dependencies = [ + "hex", + "sha2", +] + +[[package]] +name = "echo-edict-provider-lowerer" +version = "0.1.0" +dependencies = [ + "echo-edict-canonical", + "hex", + "sha2", + "wit-bindgen 0.58.0", +] + [[package]] name = "echo-file-aperture" version = "0.1.0" @@ -728,6 +746,7 @@ dependencies = [ "cddl-cat", "ciborium", "clap", + "echo-edict-canonical", "hex", "prettyplease", "proc-macro2", @@ -970,6 +989,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] [[package]] name = "hashlink" @@ -1041,6 +1065,12 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.14.0" @@ -1123,6 +1153,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.178" @@ -1661,6 +1697,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -2073,6 +2115,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2201,7 +2249,7 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", ] [[package]] @@ -2249,6 +2297,41 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f998ccc6e012f7b86865eb2a106c8a0422017a1a88977ce01a69f2244be2e57" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -2542,6 +2625,95 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a43552cfa071f246cfd99e5dbb23710dfe7336b3259e09339818483359470749" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4738d1c9a78e97bc7f664bfafd5d8e67d7bb26faa5c41e6d628e8bbdad3ec351" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130ce1f531bc9f9a75922244aa773bf5e2117fda1ef4a86b9f98d6b8135eb46" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.111", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07296369e4d598e7e79b64eef66f724d83324ea671bcf677d78fc5cf92604ae5" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.111", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a5e60173c413659c689f0581b0cf5d1a2404077568f9ffdce748a9eb2fc913" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e960732e824fab95099971a09e638979347c94ca48568d3c854c945729196947" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "xtask" version = "0.1.0" @@ -2553,12 +2725,17 @@ dependencies = [ "clap_mangen", "hex", "pulldown-cmark", + "same-file", "serde", "serde_json", "sha2", "time", "warp-cli", "warp-core", + "wasm-encoder", + "wasmparser", + "wit-component", + "wit-parser", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0be635ad..59381ddd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ members = [ "crates/echo-dind-tests", "crates/echo-wasm-abi", "crates/echo-registry-api", + "crates/echo-edict-canonical", + "crates/echo-edict-provider-lowerer", "crates/echo-wesley-gen", "crates/echo-dry-tests", "crates/echo-cas", @@ -39,6 +41,7 @@ echo-file-aperture = { version = "0.1.0", path = "crates/echo-file-aperture" } echo-graph = { version = "0.1.0", path = "crates/echo-graph" } echo-runtime-schema = { version = "0.1.0", path = "crates/echo-runtime-schema", default-features = false } echo-registry-api = { version = "0.1.0", path = "crates/echo-registry-api" } +echo-edict-canonical = { version = "0.1.0", path = "crates/echo-edict-canonical" } echo-scene-codec = { version = "0.1.0", path = "crates/echo-scene-codec" } echo-scene-port = { version = "0.1.0", path = "crates/echo-scene-port" } echo-wasm-abi = { version = "0.1.0", path = "crates/echo-wasm-abi" } diff --git a/crates/echo-edict-canonical/Cargo.toml b/crates/echo-edict-canonical/Cargo.toml new file mode 100644 index 00000000..922f345e --- /dev/null +++ b/crates/echo-edict-canonical/Cargo.toml @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +[package] +name = "echo-edict-canonical" +version = "0.1.0" +edition = "2021" +rust-version = "1.90.0" +description = "Pure Echo implementation of Edict canonical CBOR and artifact digest framing." +license = "Apache-2.0" +repository = "https://github.com/flyingrobots/echo" +readme = "README.md" +keywords = ["echo", "edict", "cbor", "canonical", "deterministic"] +categories = ["encoding", "development-tools"] + +[dependencies] +hex = "0.4" +sha2 = "0.10" + +[lints] +workspace = true diff --git a/crates/echo-edict-canonical/README.md b/crates/echo-edict-canonical/README.md new file mode 100644 index 00000000..5e663009 --- /dev/null +++ b/crates/echo-edict-canonical/README.md @@ -0,0 +1,29 @@ + + + +# Echo Edict Canonical Codec + +`echo-edict-canonical` is Echo's pure implementation of the frozen +`edict.canonical-cbor/v1` value and byte contract plus +`edict.digest/v1` domain-framed SHA-256 identities. + +The crate is intentionally smaller than either Echo's runtime codecs or the +provider generator. Both `echo-wesley-gen` and executable provider components +use this boundary so canonical bytes and digest propositions do not diverge. +It admits nulls, booleans, the CBOR major-zero and major-one integer range, +definite-length byte and UTF-8 text strings, arrays, and maps. Encoding sorts +maps by canonical key bytes and rejects duplicate canonical keys. Decoding +rejects indefinite-length, tagged, floating-point, simple, non-minimal, +trailing, malformed, duplicate-key, and noncanonical inputs. Both directions +enforce Edict's exact 128-container nesting boundary. + +Failures expose stable `CanonicalValueErrorKind` values rather than Rust debug +spellings. The implementation is deterministic and pure: it performs no +filesystem, registry, environment, or network discovery. Version `0.1.0` is a +publishable leaf consumed by `echo-wesley-gen`; the checked provider component +uses the same implementation without making its build crate part of the public +registry dependency graph. + +This codec authenticates neither an artifact's schema nor its authority. A +caller must separately validate the decoded value against the owning admitted +CDDL root and must not treat a domain-framed digest as runtime Echo admission. diff --git a/crates/echo-edict-canonical/src/lib.rs b/crates/echo-edict-canonical/src/lib.rs new file mode 100644 index 00000000..81cac630 --- /dev/null +++ b/crates/echo-edict-canonical/src/lib.rs @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Exact `edict.canonical-cbor/v1` values, bytes, and artifact digests. +//! +//! This crate is a pure compatibility boundary for the Edict-owned provider +//! contract. It deliberately does not reuse Echo's WASM ABI codec: that codec +//! admits a different value model. The implementation accepts only the +//! definite-length Edict v1 subset, enforces the published nesting bound, and +//! validates decoded bytes by exact canonical re-encoding. + +use std::collections::BTreeSet; +use std::fmt; +use std::str; + +use sha2::{Digest, Sha256}; + +/// Coordinate of the canonical encoding profile implemented by this module. +pub const EDICT_CANONICAL_CBOR_V1: &str = "edict.canonical-cbor/v1"; + +/// Domain marker at the head of every Edict v1 artifact digest frame. +pub const EDICT_DIGEST_FRAME_V1: &str = "edict.digest/v1"; + +/// Maximum child nesting depth accepted by the Edict v1 encoder and decoder. +/// +/// The root value is at depth zero. A scalar wrapped in exactly 128 containers +/// is accepted; one additional container is rejected. +pub const MAX_CANONICAL_NESTING_DEPTH_V1: usize = 128; + +/// Stable failure categories for Edict canonical values and byte streams. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CanonicalValueErrorKind { + /// A value or collection length is outside the supported value model. + UnsupportedValue, + /// An integer is outside the CBOR major-zero or major-one `u64` range. + InvalidInteger, + /// The byte stream ended before one complete value was decoded. + UnexpectedEof, + /// Bytes remained after one complete canonical value. + TrailingData, + /// The byte stream used an unsupported CBOR major or additional-info form. + UnsupportedCbor, + /// The decoded value did not re-encode to the exact supplied bytes. + NonCanonical, + /// The value exceeded the published 128-level nesting bound. + NestingLimitExceeded, + /// A map contained two keys with identical canonical encodings. + DuplicateMapKey, + /// A CBOR text string was not valid UTF-8. + InvalidUtf8, +} + +impl CanonicalValueErrorKind { + const fn label(self) -> &'static str { + match self { + Self::UnsupportedValue => "unsupported-value", + Self::InvalidInteger => "invalid-integer", + Self::UnexpectedEof => "unexpected-eof", + Self::TrailingData => "trailing-data", + Self::UnsupportedCbor => "unsupported-cbor", + Self::NonCanonical => "noncanonical", + Self::NestingLimitExceeded => "nesting-limit-exceeded", + Self::DuplicateMapKey => "duplicate-map-key", + Self::InvalidUtf8 => "invalid-utf8", + } + } +} + +/// Structured canonical-value failure with a stable kind and diagnostic detail. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CanonicalValueError { + kind: CanonicalValueErrorKind, + detail: String, +} + +impl CanonicalValueError { + /// Returns the stable machine-readable failure category. + #[must_use] + pub const fn kind(&self) -> CanonicalValueErrorKind { + self.kind + } + + /// Returns deterministic diagnostic detail for the failed boundary. + #[must_use] + pub fn detail(&self) -> &str { + &self.detail + } + + fn new(kind: CanonicalValueErrorKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + } + } +} + +impl fmt::Display for CanonicalValueError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.kind.label(), self.detail) + } +} + +impl std::error::Error for CanonicalValueError {} + +/// Value tree admitted by `edict.canonical-cbor/v1`. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum CanonicalValueV1 { + /// CBOR null. + Null, + /// CBOR false or true. + Bool(bool), + /// CBOR major-zero or major-one integer stored in a convenient host type. + Integer(i128), + /// Definite-length byte string. + Bytes(Vec), + /// Definite-length UTF-8 text string, without Unicode normalization. + Text(String), + /// Definite-length ordered array. + Array(Vec), + /// Definite-length map sorted during encoding by canonical key bytes. + Map(Vec<(CanonicalValueV1, CanonicalValueV1)>), +} + +/// Encodes one value using the exact `edict.canonical-cbor/v1` byte contract. +/// +/// # Errors +/// +/// Returns a stable failure when an integer or collection cannot be represented, +/// a map repeats a canonical key, or the value exceeds the nesting bound. +pub fn encode_canonical_cbor_v1(value: &CanonicalValueV1) -> Result, CanonicalValueError> { + let mut output = Vec::new(); + encode_value(value, &mut output, 0)?; + Ok(output) +} + +/// Decodes and authenticates one `edict.canonical-cbor/v1` value. +/// +/// Decoding is followed by exact canonical re-encoding. This rejects +/// non-minimal integers and lengths, unsorted maps, and any other supported +/// value whose supplied bytes differ from the unique Edict encoding. +/// Successful decoding authenticates the encoding only: callers must still +/// validate the value against its owning CDDL root, and Echo must separately +/// admit any runtime artifact or authority. +/// +/// # Errors +/// +/// Returns a stable failure for malformed, unsupported, trailing, +/// noncanonical, duplicate-key, invalid-UTF-8, or over-nested input. +pub fn decode_canonical_cbor_v1(bytes: &[u8]) -> Result { + let mut decoder = Decoder::new(bytes); + let value = decoder.value(0)?; + if decoder.remaining() != 0 { + return Err(CanonicalValueError::new( + CanonicalValueErrorKind::TrailingData, + "canonical CBOR stream contains bytes after the first value", + )); + } + if encode_canonical_cbor_v1(&value)? != bytes { + return Err(CanonicalValueError::new( + CanonicalValueErrorKind::NonCanonical, + "decoded value does not re-encode to the supplied bytes", + )); + } + Ok(value) +} + +/// Computes an Edict v1 domain-framed SHA-256 digest for a canonical value. +/// +/// The returned review rendering is `sha256:<64 lowercase hex>`. The preimage +/// is canonical CBOR for `["edict.digest/v1", domain, value]`. Each tuple member +/// is encoded at its own root so digest framing does not consume the artifact's +/// 128-level nesting budget. +/// A digest binds bytes and a caller-selected domain; it does not prove the +/// owning CDDL root or grant Echo runtime authority. +/// +/// # Errors +/// +/// Returns a stable failure for an empty domain or an unencodable value. +pub fn digest_canonical_value_v1( + domain: &str, + value: &CanonicalValueV1, +) -> Result { + if domain.is_empty() { + return Err(CanonicalValueError::new( + CanonicalValueErrorKind::UnsupportedValue, + "canonical artifact digest domain is empty", + )); + } + + let mut preimage = vec![0x83]; + preimage.extend(encode_canonical_cbor_v1(&CanonicalValueV1::Text( + EDICT_DIGEST_FRAME_V1.to_owned(), + ))?); + preimage.extend(encode_canonical_cbor_v1(&CanonicalValueV1::Text( + domain.to_owned(), + ))?); + preimage.extend(encode_canonical_cbor_v1(value)?); + + Ok(format!("sha256:{}", hex::encode(Sha256::digest(preimage)))) +} + +fn encode_value( + value: &CanonicalValueV1, + output: &mut Vec, + depth: usize, +) -> Result<(), CanonicalValueError> { + check_depth(depth)?; + match value { + CanonicalValueV1::Null => output.push(0xf6), + CanonicalValueV1::Bool(false) => output.push(0xf4), + CanonicalValueV1::Bool(true) => output.push(0xf5), + CanonicalValueV1::Integer(value) => encode_integer(*value, output)?, + CanonicalValueV1::Bytes(bytes) => { + encode_type_value(2, usize_to_u64(bytes.len())?, output); + output.extend_from_slice(bytes); + } + CanonicalValueV1::Text(text) => { + encode_type_value(3, usize_to_u64(text.len())?, output); + output.extend_from_slice(text.as_bytes()); + } + CanonicalValueV1::Array(values) => { + check_container_depth(depth)?; + encode_type_value(4, usize_to_u64(values.len())?, output); + for value in values { + encode_value(value, output, depth + 1)?; + } + } + CanonicalValueV1::Map(entries) => { + check_container_depth(depth)?; + let mut encoded_entries = Vec::with_capacity(entries.len()); + let mut encoded_keys = BTreeSet::new(); + for (key, value) in entries { + let mut key_bytes = Vec::new(); + encode_value(key, &mut key_bytes, depth + 1)?; + if !encoded_keys.insert(key_bytes.clone()) { + return Err(CanonicalValueError::new( + CanonicalValueErrorKind::DuplicateMapKey, + "canonical CBOR map contains duplicate keys", + )); + } + encoded_entries.push((key_bytes, value)); + } + encoded_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + encode_type_value(5, usize_to_u64(encoded_entries.len())?, output); + for (key_bytes, value) in encoded_entries { + output.extend_from_slice(&key_bytes); + encode_value(value, output, depth + 1)?; + } + } + } + Ok(()) +} + +fn check_depth(depth: usize) -> Result<(), CanonicalValueError> { + if depth > MAX_CANONICAL_NESTING_DEPTH_V1 { + return Err(CanonicalValueError::new( + CanonicalValueErrorKind::NestingLimitExceeded, + format!( + "canonical value nesting exceeds maximum depth {MAX_CANONICAL_NESTING_DEPTH_V1}" + ), + )); + } + Ok(()) +} + +fn check_container_depth(depth: usize) -> Result<(), CanonicalValueError> { + if depth >= MAX_CANONICAL_NESTING_DEPTH_V1 { + return Err(CanonicalValueError::new( + CanonicalValueErrorKind::NestingLimitExceeded, + format!( + "canonical container nesting exceeds maximum depth {MAX_CANONICAL_NESTING_DEPTH_V1}" + ), + )); + } + Ok(()) +} + +fn encode_integer(value: i128, output: &mut Vec) -> Result<(), CanonicalValueError> { + if value >= 0 { + let value = u64::try_from(value).map_err(|_| { + CanonicalValueError::new( + CanonicalValueErrorKind::InvalidInteger, + "positive integer exceeds the canonical CBOR uint range", + ) + })?; + encode_type_value(0, value, output); + return Ok(()); + } + + let magnitude = (-1i128).checked_sub(value).ok_or_else(|| { + CanonicalValueError::new( + CanonicalValueErrorKind::InvalidInteger, + "negative integer cannot be converted to the canonical CBOR range", + ) + })?; + let magnitude = u64::try_from(magnitude).map_err(|_| { + CanonicalValueError::new( + CanonicalValueErrorKind::InvalidInteger, + "negative integer exceeds the canonical CBOR negative range", + ) + })?; + encode_type_value(1, magnitude, output); + Ok(()) +} + +fn encode_type_value(major: u8, value: u64, output: &mut Vec) { + let prefix = major << 5; + let bytes = value.to_be_bytes(); + match value { + 0..=23 => output.push(prefix | bytes[7]), + 24..=0xff => { + output.push(prefix | 0x18); + output.push(bytes[7]); + } + 0x100..=0xffff => { + output.push(prefix | 0x19); + output.extend_from_slice(&bytes[6..]); + } + 0x1_0000..=0xffff_ffff => { + output.push(prefix | 0x1a); + output.extend_from_slice(&bytes[4..]); + } + _ => { + output.push(prefix | 0x1b); + output.extend_from_slice(&bytes); + } + } +} + +fn usize_to_u64(value: usize) -> Result { + u64::try_from(value).map_err(|_| { + CanonicalValueError::new( + CanonicalValueErrorKind::UnsupportedValue, + "canonical collection length does not fit the CBOR uint range", + ) + }) +} + +fn checked_collection_length( + declared: u64, + remaining: u64, +) -> Result +where + HostLength: TryFrom, +{ + if declared > remaining { + return Err(CanonicalValueError::new( + CanonicalValueErrorKind::UnexpectedEof, + "canonical CBOR declared length exceeds the remaining bytes", + )); + } + + HostLength::try_from(declared).map_err(|_| { + CanonicalValueError::new( + CanonicalValueErrorKind::UnsupportedCbor, + "canonical CBOR collection length does not fit usize", + ) + }) +} + +struct Decoder<'a> { + bytes: &'a [u8], + position: usize, +} + +impl<'a> Decoder<'a> { + const fn new(bytes: &'a [u8]) -> Self { + Self { bytes, position: 0 } + } + + const fn remaining(&self) -> usize { + self.bytes.len() - self.position + } + + fn value(&mut self, depth: usize) -> Result { + check_depth(depth)?; + let initial = self.byte()?; + let major = initial >> 5; + let additional = initial & 0x1f; + match major { + 0 => Ok(CanonicalValueV1::Integer(i128::from( + self.argument(additional)?, + ))), + 1 => Ok(CanonicalValueV1::Integer( + -1 - i128::from(self.argument(additional)?), + )), + 2 => { + let length = self.length(additional)?; + Ok(CanonicalValueV1::Bytes(self.take(length)?.to_vec())) + } + 3 => { + let length = self.length(additional)?; + let bytes = self.take(length)?; + let text = str::from_utf8(bytes).map_err(|_| { + CanonicalValueError::new( + CanonicalValueErrorKind::InvalidUtf8, + "canonical CBOR text string is not valid UTF-8", + ) + })?; + Ok(CanonicalValueV1::Text(text.to_owned())) + } + 4 => { + check_container_depth(depth)?; + let length = self.length(additional)?; + let mut values = Vec::with_capacity(length); + for _ in 0..length { + values.push(self.value(depth + 1)?); + } + Ok(CanonicalValueV1::Array(values)) + } + 5 => { + check_container_depth(depth)?; + let length = self.length(additional)?; + let mut entries = Vec::with_capacity(length); + let mut encoded_keys = BTreeSet::new(); + for _ in 0..length { + let key = self.value(depth + 1)?; + let mut key_bytes = Vec::new(); + encode_value(&key, &mut key_bytes, depth + 1)?; + if !encoded_keys.insert(key_bytes) { + return Err(CanonicalValueError::new( + CanonicalValueErrorKind::DuplicateMapKey, + "canonical CBOR map contains duplicate keys", + )); + } + let value = self.value(depth + 1)?; + entries.push((key, value)); + } + Ok(CanonicalValueV1::Map(entries)) + } + 7 => match additional { + 20 => Ok(CanonicalValueV1::Bool(false)), + 21 => Ok(CanonicalValueV1::Bool(true)), + 22 => Ok(CanonicalValueV1::Null), + _ => Err(CanonicalValueError::new( + CanonicalValueErrorKind::UnsupportedCbor, + "canonical CBOR simple value is unsupported", + )), + }, + _ => Err(CanonicalValueError::new( + CanonicalValueErrorKind::UnsupportedCbor, + "canonical CBOR major type is unsupported", + )), + } + } + + fn argument(&mut self, additional: u8) -> Result { + match additional { + 0..=23 => Ok(u64::from(additional)), + 24 => Ok(u64::from(self.byte()?)), + 25 => Ok(u64::from(u16::from_be_bytes(self.take_array::<2>()?))), + 26 => Ok(u64::from(u32::from_be_bytes(self.take_array::<4>()?))), + 27 => Ok(u64::from_be_bytes(self.take_array::<8>()?)), + _ => Err(CanonicalValueError::new( + CanonicalValueErrorKind::UnsupportedCbor, + "indefinite or reserved canonical CBOR length is unsupported", + )), + } + } + + fn length(&mut self, additional: u8) -> Result { + let declared = self.argument(additional)?; + let remaining = u64::try_from(self.remaining()).map_err(|_| { + CanonicalValueError::new( + CanonicalValueErrorKind::UnsupportedCbor, + "remaining canonical CBOR input does not fit the CBOR uint range", + ) + })?; + + checked_collection_length::(declared, remaining) + } + + fn byte(&mut self) -> Result { + let Some(value) = self.bytes.get(self.position).copied() else { + return Err(CanonicalValueError::new( + CanonicalValueErrorKind::UnexpectedEof, + "canonical CBOR expected another byte", + )); + }; + self.position += 1; + Ok(value) + } + + fn take(&mut self, length: usize) -> Result<&'a [u8], CanonicalValueError> { + let end = self.position.checked_add(length).ok_or_else(|| { + CanonicalValueError::new( + CanonicalValueErrorKind::UnexpectedEof, + "canonical CBOR length overflowed the input position", + ) + })?; + let Some(bytes) = self.bytes.get(self.position..end) else { + return Err(CanonicalValueError::new( + CanonicalValueErrorKind::UnexpectedEof, + "canonical CBOR value extends past the input", + )); + }; + self.position = end; + Ok(bytes) + } + + fn take_array(&mut self) -> Result<[u8; LENGTH], CanonicalValueError> { + let mut output = [0u8; LENGTH]; + output.copy_from_slice(self.take(LENGTH)?); + Ok(output) + } +} + +#[cfg(test)] +mod tests { + use super::{checked_collection_length, CanonicalValueError, CanonicalValueErrorKind}; + + #[test] + fn declared_length_bounds_precede_host_width_conversion() { + assert_eq!( + checked_collection_length::(u64::MAX, u64::from(u32::MAX)), + Err(CanonicalValueError::new( + CanonicalValueErrorKind::UnexpectedEof, + "canonical CBOR declared length exceeds the remaining bytes" + )) + ); + } +} diff --git a/crates/echo-edict-canonical/tests/canonical_contract.rs b/crates/echo-edict-canonical/tests/canonical_contract.rs new file mode 100644 index 00000000..1e095f0e --- /dev/null +++ b/crates/echo-edict-canonical/tests/canonical_contract.rs @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +#![allow(clippy::expect_used)] +//! Public contract witnesses for the independently publishable canonical leaf. + +use echo_edict_canonical::{ + decode_canonical_cbor_v1, digest_canonical_value_v1, encode_canonical_cbor_v1, + CanonicalValueErrorKind, CanonicalValueV1, MAX_CANONICAL_NESTING_DEPTH_V1, +}; + +fn text(value: &str) -> CanonicalValueV1 { + CanonicalValueV1::Text(value.to_owned()) +} + +fn map( + entries: impl IntoIterator, +) -> CanonicalValueV1 { + CanonicalValueV1::Map(entries.into_iter().collect()) +} + +fn string_map( + entries: impl IntoIterator, +) -> CanonicalValueV1 { + map(entries.into_iter().map(|(key, value)| (text(key), value))) +} + +fn nested_array(depth: usize) -> CanonicalValueV1 { + (0..depth).fold(CanonicalValueV1::Null, |value, _| { + CanonicalValueV1::Array(vec![value]) + }) +} + +fn nested_array_bytes(depth: usize) -> Vec { + let mut bytes = vec![0x81; depth]; + bytes.push(0xf6); + bytes +} + +fn nested_empty_array(container_count: usize) -> CanonicalValueV1 { + assert!(container_count > 0); + (1..container_count).fold(CanonicalValueV1::Array(Vec::new()), |value, _| { + CanonicalValueV1::Array(vec![value]) + }) +} + +fn nested_empty_array_bytes(container_count: usize) -> Vec { + assert!(container_count > 0); + let mut bytes = vec![0x81; container_count - 1]; + bytes.push(0x80); + bytes +} + +fn nested_empty_map(container_count: usize) -> CanonicalValueV1 { + assert!(container_count > 0); + (1..container_count).fold(CanonicalValueV1::Map(Vec::new()), |value, _| { + CanonicalValueV1::Map(vec![(text("k"), value)]) + }) +} + +fn nested_empty_map_bytes(container_count: usize) -> Vec { + assert!(container_count > 0); + let mut bytes = Vec::with_capacity((container_count - 1) * 3 + 1); + for _ in 1..container_count { + bytes.extend([0xa1, 0x61, b'k']); + } + bytes.push(0xa0); + bytes +} + +#[test] +fn canonical_maps_are_order_independent_and_fail_closed() { + let forward = string_map([("z", text("last")), ("a", text("first"))]); + let reversed = string_map([("a", text("first")), ("z", text("last"))]); + assert_eq!( + encode_canonical_cbor_v1(&forward).expect("forward map encodes"), + encode_canonical_cbor_v1(&reversed).expect("reversed map encodes") + ); + let heterogeneous = map([ + (text(""), CanonicalValueV1::Null), + (CanonicalValueV1::Integer(24), CanonicalValueV1::Null), + ]); + assert_eq!( + encode_canonical_cbor_v1(&heterogeneous).expect("heterogeneous map encodes"), + [0xa2, 0x18, 0x18, 0xf6, 0x60, 0xf6] + ); + + let duplicate = map([(text("same"), text("one")), (text("same"), text("two"))]); + assert_eq!( + encode_canonical_cbor_v1(&duplicate) + .expect_err("duplicate canonical keys reject") + .kind(), + CanonicalValueErrorKind::DuplicateMapKey + ); + assert_eq!( + decode_canonical_cbor_v1(&[0x18, 0x00]) + .expect_err("non-minimal integer rejects") + .kind(), + CanonicalValueErrorKind::NonCanonical + ); + assert_eq!( + decode_canonical_cbor_v1(&[0xf6, 0xf6]) + .expect_err("trailing canonical value rejects") + .kind(), + CanonicalValueErrorKind::TrailingData + ); + assert_eq!( + decode_canonical_cbor_v1(&[0xa2, 0x61, b'a', 0x01, 0x61, b'a', 0x02]) + .expect_err("duplicate decoded map keys reject") + .kind(), + CanonicalValueErrorKind::DuplicateMapKey + ); + assert_eq!( + decode_canonical_cbor_v1(&[0xa2, 0x61, b'b', 0x00, 0x61, b'a', 0x00]) + .expect_err("unsorted decoded map keys reject") + .kind(), + CanonicalValueErrorKind::NonCanonical + ); +} + +#[test] +fn canonical_integer_ranges_and_widths_are_exact() { + let cases: &[(i128, &[u8])] = &[ + (23, &[0x17]), + (24, &[0x18, 0x18]), + (255, &[0x18, 0xff]), + (256, &[0x19, 0x01, 0x00]), + (65_535, &[0x19, 0xff, 0xff]), + (65_536, &[0x1a, 0x00, 0x01, 0x00, 0x00]), + (4_294_967_295, &[0x1a, 0xff, 0xff, 0xff, 0xff]), + ( + 4_294_967_296, + &[0x1b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00], + ), + ( + i128::from(u64::MAX), + &[0x1b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], + ), + ( + -1 - i128::from(u64::MAX), + &[0x3b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], + ), + ]; + for (integer, expected) in cases { + let value = CanonicalValueV1::Integer(*integer); + let bytes = encode_canonical_cbor_v1(&value).expect("boundary integer encodes"); + assert_eq!(bytes, *expected); + assert_eq!( + decode_canonical_cbor_v1(expected).expect("boundary integer decodes"), + value + ); + } + + for integer in [i128::from(u64::MAX) + 1, -2 - i128::from(u64::MAX)] { + assert_eq!( + encode_canonical_cbor_v1(&CanonicalValueV1::Integer(integer)) + .expect_err("out-of-range integer rejects") + .kind(), + CanonicalValueErrorKind::InvalidInteger + ); + } +} + +#[test] +fn canonical_nesting_bound_includes_empty_containers_but_not_digest_framing() { + let at_limit = nested_array(MAX_CANONICAL_NESTING_DEPTH_V1); + let at_limit_bytes = nested_array_bytes(MAX_CANONICAL_NESTING_DEPTH_V1); + assert_eq!( + encode_canonical_cbor_v1(&at_limit).expect("maximum-depth value encodes"), + at_limit_bytes + ); + assert_eq!( + decode_canonical_cbor_v1(&at_limit_bytes).expect("maximum-depth bytes decode"), + at_limit + ); + digest_canonical_value_v1("test.maximum-depth/v1", &at_limit) + .expect("digest frame preserves the full artifact depth budget"); + + let over_limit = MAX_CANONICAL_NESTING_DEPTH_V1 + 1; + assert_eq!( + encode_canonical_cbor_v1(&nested_array(over_limit)) + .expect_err("over-depth value rejects") + .kind(), + CanonicalValueErrorKind::NestingLimitExceeded + ); + assert_eq!( + decode_canonical_cbor_v1(&nested_array_bytes(over_limit)) + .expect_err("over-depth bytes reject") + .kind(), + CanonicalValueErrorKind::NestingLimitExceeded + ); + + let empty_cases = [ + ( + nested_empty_array(MAX_CANONICAL_NESTING_DEPTH_V1), + nested_empty_array(over_limit), + nested_empty_array_bytes(MAX_CANONICAL_NESTING_DEPTH_V1), + nested_empty_array_bytes(over_limit), + ), + ( + nested_empty_map(MAX_CANONICAL_NESTING_DEPTH_V1), + nested_empty_map(over_limit), + nested_empty_map_bytes(MAX_CANONICAL_NESTING_DEPTH_V1), + nested_empty_map_bytes(over_limit), + ), + ]; + for (at_limit, over_limit_value, at_limit_bytes, over_limit_bytes) in empty_cases { + assert_eq!( + encode_canonical_cbor_v1(&at_limit).expect("128 empty containers encode"), + at_limit_bytes + ); + assert_eq!( + decode_canonical_cbor_v1(&at_limit_bytes).expect("128 empty containers decode"), + at_limit + ); + assert_eq!( + encode_canonical_cbor_v1(&over_limit_value) + .expect_err("the 129th empty container rejects on encode") + .kind(), + CanonicalValueErrorKind::NestingLimitExceeded + ); + assert_eq!( + decode_canonical_cbor_v1(&over_limit_bytes) + .expect_err("the 129th empty container rejects on decode") + .kind(), + CanonicalValueErrorKind::NestingLimitExceeded + ); + } +} + +#[test] +fn unsupported_cbor_and_digest_domains_fail_closed() { + for bytes in [ + &[0xc0, 0xf6][..], + &[0xf9, 0x00, 0x00], + &[0xf7], + &[0x9f, 0xff], + &[0x1c], + ] { + assert_eq!( + decode_canonical_cbor_v1(bytes) + .expect_err("unsupported CBOR form rejects") + .kind(), + CanonicalValueErrorKind::UnsupportedCbor + ); + } + assert_eq!( + decode_canonical_cbor_v1(&[0x61, 0xff]) + .expect_err("invalid UTF-8 rejects") + .kind(), + CanonicalValueErrorKind::InvalidUtf8 + ); + assert_eq!( + decode_canonical_cbor_v1(&[0x9b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) + .expect_err("oversized declared collection rejects before allocation") + .kind(), + CanonicalValueErrorKind::UnexpectedEof + ); + let value = text("domain-separated"); + assert_eq!( + digest_canonical_value_v1("", &value) + .expect_err("empty domain rejects") + .kind(), + CanonicalValueErrorKind::UnsupportedValue + ); + assert_ne!( + digest_canonical_value_v1("test.first/v1", &value).expect("first digest computes"), + digest_canonical_value_v1("test.second/v1", &value).expect("second digest computes") + ); +} diff --git a/crates/echo-edict-provider-lowerer/Cargo.toml b/crates/echo-edict-provider-lowerer/Cargo.toml new file mode 100644 index 00000000..6da1a4fc --- /dev/null +++ b/crates/echo-edict-provider-lowerer/Cargo.toml @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +[package] +name = "echo-edict-provider-lowerer" +version = "0.1.0" +edition = "2021" +rust-version = "1.90.0" +description = "Pure Echo provider lowerer for Edict's frozen Component Model boundary." +license = "Apache-2.0" +repository = "https://github.com/flyingrobots/echo" +readme = "README.md" +keywords = ["echo", "edict", "wasm", "component-model", "compiler"] +categories = ["compilers", "wasm", "development-tools"] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +echo-edict-canonical = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wit-bindgen = { version = "=0.58.0", default-features = false, features = ["macros", "realloc"] } + +[dev-dependencies] +hex = "0.4" +sha2 = "0.10" + +[lints] +workspace = true diff --git a/crates/echo-edict-provider-lowerer/README.md b/crates/echo-edict-provider-lowerer/README.md new file mode 100644 index 00000000..e759f4e6 --- /dev/null +++ b/crates/echo-edict-provider-lowerer/README.md @@ -0,0 +1,77 @@ + + + +# Echo Edict Provider Lowerer + +`echo-edict-provider-lowerer` implements Echo's exact +`edict:target-provider/lowerer@1.0.0` component for the first checked provider +closure. Its production boundary is pure: callers supply canonical Core, the +exact target profile, the complete semantic closure, requested output roles, +and response limits through the frozen Edict WIT request. + +The `0.1.0` Rust source crate is enabled and configured for publication, and its +source archive is self-contained. It packages the four exact admitted provider +resources needed by this first closure, while a repository-owned witness requires +those package-local bytes to remain identical to the checked generated corpus. +Registry publication exposes the pure native model and a reproducible component +source build; the frozen WIT component remains the provider ABI, and neither +distribution channel grants Echo runtime authority. Release +`echo-edict-canonical 0.1.0` first; after that dependency is visible in the +registry index, the lowerer's full package/publish gate becomes executable and +must run from the same clean revision. + +The first supported operation is the mutating `a.b@1.t` compatibility fixture. +The lowerer emits canonical `edict.target-ir.artifact/v1` bytes whose decoded +inner domain is `echo.span-ir/v1`. It accepts only the exact Core module +coordinate, local intent key, input/output type bindings, Echo DPO target +profile, and #652 lawpack, authority, and lowerability identities. Rebound +operations, authored optics that this crossing cannot yet discharge, changed +type bindings or definitions, evaluation budgets, unsupported Core ABI, target +profiles, semantics, and output roles produce typed provider refusals. The +one-effect closure requires exactly the compiler-owned input, effect-result, and +obstruction declarations, then validates pre-effect, obstruction-arm, and +post-effect scope by their complete identities before cloning expressions into +Target IR. It accepts only an empty input-constraint set and the reviewed +zero-argument `domain.WriteRejected` obstruction constructor. Effect inputs and +intent results admit no call-expression callee in this closure; later +constraint, constructor, or call semantics require explicit lowering laws. Reads remain +unsupported and fail closed; the lowerer never represents a read as a synthetic +mutation. + +The native Rust model is also the narrow unit-test boundary. A `wasm32` adapter +generated from [`wit/edict-target-provider.wit`](wit/edict-target-provider.wit) +exports the exact Component Model function and performs total conversions to +and from that model. The component imports only the frozen WIT's type closure; +it imports no filesystem, network, environment, clock, randomness, registry, +logging callback, or WASI capability. + +Build and audit local component bytes from the repository root with: + +```sh +cargo +1.90.0 xtask provider-lowerer-component build \ + --target-dir target/provider-lowerer-component +``` + +Local component construction resolves and authenticates absolute Rust 1.90.0 +Cargo and compiler executables, binds the inner Cargo build to that exact +compiler, disables configured wrappers, and validates the complete module, +import/export topology, and contract attestation without claiming cross-host byte +identity. The checked artifact is built and compared exactly on the designated +`linux/amd64` Rust image pinned by OCI digest. The inner build uses a controlled +Cargo home beneath its target directory, removes ambient Cargo +profile/build/target overrides, and remaps dependency source paths to `/cargo`; +physical cache locations therefore cannot alter the component bytes. +`audit` authenticates an existing component without rebuilding it. `promote` +requires two distinct underlying candidate files, byte equality, the +repository-approved SHA-256 identity, complete component admission, and +`--write`; G4 separately proves those candidates came from independently +provisioned designated builds. Successful refresh uses synchronized temporary +bytes and atomic replacement, while write failure preserves the prior artifact. +`designated-build` can emit candidates but refuses the checked repository path. +Edict-host invocation evidence lives in the isolated Rust 1.94 witness under +`tests/edict-provider-host-v1/` so Wasmtime and unpublished Edict host crates do +not enter Echo's Rust 1.90 workspace dependency graph. + +These bytes implement a provider translation. They do not install a package, +admit runtime authority, execute an operation, or attest an Echo consequence. +Those remain explicit Echo runtime crossings. diff --git a/crates/echo-edict-provider-lowerer/resources/authority-facts.echo-dpo.cbor b/crates/echo-edict-provider-lowerer/resources/authority-facts.echo-dpo.cbor new file mode 100644 index 00000000..807c2186 --- /dev/null +++ b/crates/echo-edict-provider-lowerer/resources/authority-facts.echo-dpo.cbor @@ -0,0 +1 @@ +fsourcedkindmtargetProfilefdigestfsha256X VbZe/T~U2-jcoordinatejecho.dpo@1gbudgetsjapiVersionxedict.authority-facts/v1qoperationProfileskp.effectfuldcorexcontinuum.profile.write/v1sallowedWriteClassesgreplacereffectWriteClassesntarget.replacegreplace \ No newline at end of file diff --git a/crates/echo-edict-provider-lowerer/resources/authority-facts.echo-lawpack.cbor b/crates/echo-edict-provider-lowerer/resources/authority-facts.echo-lawpack.cbor new file mode 100644 index 00000000..a3a7b681 Binary files /dev/null and b/crates/echo-edict-provider-lowerer/resources/authority-facts.echo-lawpack.cbor differ diff --git a/crates/echo-edict-provider-lowerer/resources/lawpack.echo-dpo.cbor b/crates/echo-edict-provider-lowerer/resources/lawpack.echo-dpo.cbor new file mode 100644 index 00000000..354071e7 Binary files /dev/null and b/crates/echo-edict-provider-lowerer/resources/lawpack.echo-dpo.cbor differ diff --git a/crates/echo-edict-provider-lowerer/resources/target-profile.echo-dpo.cbor b/crates/echo-edict-provider-lowerer/resources/target-profile.echo-dpo.cbor new file mode 100644 index 00000000..b3ca7861 Binary files /dev/null and b/crates/echo-edict-provider-lowerer/resources/target-profile.echo-dpo.cbor differ diff --git a/crates/echo-edict-provider-lowerer/src/component.rs b/crates/echo-edict-provider-lowerer/src/component.rs new file mode 100644 index 00000000..b85fddf7 --- /dev/null +++ b/crates/echo-edict-provider-lowerer/src/component.rs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! `wasm32` guest adapter for the frozen Edict target-provider lowerer world. + +// The generated canonical-ABI trampoline necessarily contains unsafe exports; +// all authored conversion and lowering code remains safe Rust. +#![allow(unsafe_code)] + +wit_bindgen::generate!({ + path: "wit", + world: "lowerer", +}); + +use edict::target_provider::protocol as wit; + +struct Component; + +impl Guest for Component { + fn lower(request: wit::LoweringRequestV1) -> wit::LoweringResultV1 { + from_model_result(super::lower(into_model_request(request))) + } +} + +fn into_model_request(request: wit::LoweringRequestV1) -> super::LoweringRequestV1 { + super::LoweringRequestV1 { + protocol_version: super::ProtocolVersionV1 { + major: request.protocol_version.major, + minor: request.protocol_version.minor, + patch: request.protocol_version.patch, + }, + core: into_model_bound_artifact(request.core), + target_profile: into_model_bound_artifact(request.target_profile), + semantic_inputs: request + .semantic_inputs + .into_iter() + .map(into_model_semantic_input) + .collect(), + requested_outputs: request + .requested_outputs + .into_iter() + .map(into_model_output_request) + .collect(), + limits: super::ResponseLimitsV1 { + max_output_count: request.limits.max_output_count, + max_diagnostic_count: request.limits.max_diagnostic_count, + max_total_response_bytes: request.limits.max_total_response_bytes, + }, + } +} + +fn into_model_bound_artifact(artifact: wit::BoundArtifact) -> super::BoundArtifact { + super::BoundArtifact { + reference: super::ResourceRef { + coordinate: artifact.reference.coordinate, + digest: super::Digest { + algorithm: match artifact.reference.digest.algorithm { + wit::DigestAlgorithm::Sha256 => super::DigestAlgorithm::Sha256, + }, + bytes: artifact.reference.digest.bytes, + }, + }, + artifact: super::Artifact { + domain: artifact.artifact.domain, + bytes: artifact.artifact.bytes, + }, + } +} + +fn into_model_semantic_input(input: wit::SemanticInput) -> super::SemanticInput { + super::SemanticInput { + role: input.role, + kind: match input.kind { + wit::SemanticInputKind::Lawpack => super::SemanticInputKind::Lawpack, + wit::SemanticInputKind::AuthorityFacts => super::SemanticInputKind::AuthorityFacts, + wit::SemanticInputKind::LowerabilityFacts => { + super::SemanticInputKind::LowerabilityFacts + } + wit::SemanticInputKind::Auxiliary(label) => super::SemanticInputKind::Auxiliary(label), + }, + artifact: into_model_bound_artifact(input.artifact), + } +} + +fn into_model_output_request(request: wit::LoweringOutputRequest) -> super::LoweringOutputRequest { + super::LoweringOutputRequest { + role: request.role, + kind: into_model_output_kind(request.kind), + domain: request.domain, + } +} + +const fn into_model_output_kind(kind: wit::LoweringOutputKind) -> super::LoweringOutputKind { + match kind { + wit::LoweringOutputKind::TargetIr => super::LoweringOutputKind::TargetIr, + wit::LoweringOutputKind::GeneratedArtifact => super::LoweringOutputKind::GeneratedArtifact, + wit::LoweringOutputKind::ReviewPayload => super::LoweringOutputKind::ReviewPayload, + } +} + +fn from_model_result(result: super::LoweringResultV1) -> wit::LoweringResultV1 { + result.map(from_model_success).map_err(from_model_refusal) +} + +fn from_model_success(success: super::LoweringSuccessV1) -> wit::LoweringSuccessV1 { + wit::LoweringSuccessV1 { + outputs: success.outputs.into_iter().map(from_model_output).collect(), + diagnostics: success + .diagnostics + .into_iter() + .map(from_model_diagnostic) + .collect(), + } +} + +fn from_model_output(output: super::LoweringOutputArtifact) -> wit::LoweringOutputArtifact { + wit::LoweringOutputArtifact { + role: output.role, + kind: from_model_output_kind(output.kind), + artifact: wit::Artifact { + domain: output.artifact.domain, + bytes: output.artifact.bytes, + }, + logical_path: output.logical_path, + } +} + +const fn from_model_output_kind(kind: super::LoweringOutputKind) -> wit::LoweringOutputKind { + match kind { + super::LoweringOutputKind::TargetIr => wit::LoweringOutputKind::TargetIr, + super::LoweringOutputKind::GeneratedArtifact => wit::LoweringOutputKind::GeneratedArtifact, + super::LoweringOutputKind::ReviewPayload => wit::LoweringOutputKind::ReviewPayload, + } +} + +fn from_model_refusal(refusal: super::ProviderRefusalV1) -> wit::ProviderRefusalV1 { + wit::ProviderRefusalV1 { + kind: match refusal.kind { + super::ProviderRefusalKind::UnsupportedCoreAbi => { + wit::ProviderRefusalKind::UnsupportedCoreAbi + } + super::ProviderRefusalKind::UnsupportedTargetProfile => { + wit::ProviderRefusalKind::UnsupportedTargetProfile + } + super::ProviderRefusalKind::UnsupportedSemantics => { + wit::ProviderRefusalKind::UnsupportedSemantics + } + super::ProviderRefusalKind::UnsupportedOutputRole => { + wit::ProviderRefusalKind::UnsupportedOutputRole + } + super::ProviderRefusalKind::InvalidSemanticArtifact => { + wit::ProviderRefusalKind::InvalidSemanticArtifact + } + }, + subject: refusal.subject, + diagnostics: refusal + .diagnostics + .into_iter() + .map(from_model_diagnostic) + .collect(), + } +} + +fn from_model_diagnostic(diagnostic: super::Diagnostic) -> wit::Diagnostic { + wit::Diagnostic { + code: diagnostic.code, + severity: match diagnostic.severity { + super::DiagnosticSeverity::Error => wit::DiagnosticSeverity::Error, + super::DiagnosticSeverity::Warning => wit::DiagnosticSeverity::Warning, + super::DiagnosticSeverity::Info => wit::DiagnosticSeverity::Info, + }, + message: diagnostic.message, + repair: diagnostic.repair, + } +} + +export!(Component); diff --git a/crates/echo-edict-provider-lowerer/src/lib.rs b/crates/echo-edict-provider-lowerer/src/lib.rs new file mode 100644 index 00000000..b8cf799d --- /dev/null +++ b/crates/echo-edict-provider-lowerer/src/lib.rs @@ -0,0 +1,1023 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Pure Echo lowering for Edict's frozen target-provider component boundary. +//! +//! This crate translates only explicit, digest-bound canonical artifacts. It +//! performs no discovery or I/O and grants no Echo runtime authority. + +#![deny(unsafe_code)] + +use echo_edict_canonical::{ + decode_canonical_cbor_v1, digest_canonical_value_v1, encode_canonical_cbor_v1, CanonicalValueV1, +}; + +#[cfg(target_arch = "wasm32")] +mod component; + +const PROVIDER_ABI: ProtocolVersionV1 = ProtocolVersionV1 { + major: 1, + minor: 0, + patch: 0, +}; +const CORE_DOMAIN: &str = "edict.core.module/v1"; +const CORE_ABI: &str = "edict.core/v1"; +const CORE_COORDINATE: &str = "a.b@1"; +const TARGET_PROFILE_DOMAIN: &str = "edict.target-profile/v1"; +const TARGET_PROFILE_COORDINATE: &str = "echo.dpo@1"; +const LAWPACK_DOMAIN: &str = "edict.lawpack/v1"; +const AUTHORITY_DOMAIN: &str = "edict.authority-facts/v1"; +const LOWERABILITY_DOMAIN: &str = "edict.lowering-requirements/v1"; +const LOWERABILITY_COORDINATE: &str = "echo.dpo-lowerability@1"; +const OUTPUT_DOMAIN: &str = "edict.target-ir.artifact/v1"; +const INNER_TARGET_IR_DOMAIN: &str = "echo.span-ir/v1"; +const TARGET_IR_ROLE: &str = "target-ir.echo-dpo"; +const OPERATION_COORDINATE: &str = "a.b@1.t"; +const OPERATION_INPUT_TYPE: &str = "a.b@1.Input"; +const OPERATION_OUTPUT_TYPE: &str = "a.b@1.Output"; +const OPERATION_RECEIPT_TYPE: &str = "a.b@1.Receipt"; +const OPERATION_PROFILE: &str = "continuum.profile.write/v1"; +const SEMANTIC_EFFECT: &str = "target.replace"; +const TARGET_INTRINSIC: &str = "echo.dpo@1.replace"; +const FAILURE_COORDINATE: &str = "rejected"; +const FAILURE_PAYLOAD_TYPE: &str = "target.replace.rejected"; +const DOMAIN_OBSTRUCTION: &str = "domain.WriteRejected"; + +const TARGET_PROFILE_BYTES: &[u8] = include_bytes!("../resources/target-profile.echo-dpo.cbor"); +const LAWPACK_BYTES: &[u8] = include_bytes!("../resources/lawpack.echo-dpo.cbor"); +const TARGET_AUTHORITY_BYTES: &[u8] = include_bytes!("../resources/authority-facts.echo-dpo.cbor"); +const LAWPACK_AUTHORITY_BYTES: &[u8] = + include_bytes!("../resources/authority-facts.echo-lawpack.cbor"); + +/// Semantic version carried by every invocation of the frozen provider ABI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProtocolVersionV1 { + /// Major protocol version. + pub major: u32, + /// Minor protocol version. + pub minor: u32, + /// Patch protocol version. + pub patch: u32, +} + +/// Digest algorithms admitted by the provider transport. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DigestAlgorithm { + /// SHA-256. + Sha256, +} + +/// Typed digest bytes carried by a resource reference. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Digest { + /// Digest algorithm. + pub algorithm: DigestAlgorithm, + /// Raw digest bytes. + pub bytes: Vec, +} + +/// Digest-bound semantic resource coordinate. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResourceRef { + /// Stable semantic coordinate. + pub coordinate: String, + /// Host-verified digest. + pub digest: Digest, +} + +/// Opaque canonical artifact transported with an explicit owning domain. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Artifact { + /// Owning artifact domain. + pub domain: String, + /// Exact canonical bytes. + pub bytes: Vec, +} + +/// Artifact bound to its semantic coordinate and digest. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BoundArtifact { + /// Digest-bound resource reference. + pub reference: ResourceRef, + /// Exact artifact domain and bytes. + pub artifact: Artifact, +} + +/// Structural role of one semantic-closure input. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SemanticInputKind { + /// Lawpack semantics. + Lawpack, + /// Source-partitioned authority facts. + AuthorityFacts, + /// Explicit lowerability requirements and facts. + LowerabilityFacts, + /// A separately constrained auxiliary semantic input. + Auxiliary(String), +} + +/// One role-constrained semantic input. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SemanticInput { + /// Unique invocation role. + pub role: String, + /// Structural semantic kind. + pub kind: SemanticInputKind, + /// Digest-bound artifact. + pub artifact: BoundArtifact, +} + +/// Output kinds that a lowerer can structurally claim. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LoweringOutputKind { + /// Target-owned intermediate representation. + TargetIr, + /// Generated application artifact. + GeneratedArtifact, + /// Non-authoritative review payload. + ReviewPayload, +} + +/// One requested output role. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LoweringOutputRequest { + /// Unique output role. + pub role: String, + /// Structurally permitted output kind. + pub kind: LoweringOutputKind, + /// Required owning domain. + pub domain: String, +} + +/// One provider-authored output without a provider-authored digest. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LoweringOutputArtifact { + /// Requested output role. + pub role: String, + /// Requested output kind. + pub kind: LoweringOutputKind, + /// Canonical output artifact. + pub artifact: Artifact, + /// Optional logical package-relative path. + pub logical_path: Option, +} + +/// Host-enforced response bounds carried through the WIT request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResponseLimitsV1 { + /// Maximum number of successful outputs. + pub max_output_count: u32, + /// Maximum number of diagnostics. + pub max_diagnostic_count: u32, + /// Maximum provider-authored response bytes. + pub max_total_response_bytes: u64, +} + +/// Diagnostic severity declared by the provider ABI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DiagnosticSeverity { + /// Error diagnostic. + Error, + /// Warning diagnostic. + Warning, + /// Informational diagnostic. + Info, +} + +/// Stable bounded diagnostic attached to success or refusal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Diagnostic { + /// Stable machine-readable code. + pub code: String, + /// Severity. + pub severity: DiagnosticSeverity, + /// Deterministic human-readable explanation. + pub message: String, + /// Optional deterministic repair guidance. + pub repair: Option, +} + +/// Target-owned refusal categories frozen by `edict:target-provider@1.0.0`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProviderRefusalKind { + /// The request or Core ABI is unsupported. + UnsupportedCoreAbi, + /// The selected target profile is unsupported. + UnsupportedTargetProfile, + /// The supplied semantics cannot be represented faithfully. + UnsupportedSemantics, + /// The requested output role, kind, or domain is unsupported. + UnsupportedOutputRole, + /// A semantic artifact is malformed, noncanonical, or incorrectly bound. + InvalidSemanticArtifact, +} + +/// Typed target-owned refusal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderRefusalV1 { + /// Stable refusal kind. + pub kind: ProviderRefusalKind, + /// Optional stable subject of the refusal. + pub subject: Option, + /// Deterministically ordered diagnostics. + pub diagnostics: Vec, +} + +/// Explicit pure lowering request mirroring the frozen WIT record. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LoweringRequestV1 { + /// Requested provider protocol version. + pub protocol_version: ProtocolVersionV1, + /// Exact canonical Edict Core artifact. + pub core: BoundArtifact, + /// Exact canonical target-profile artifact. + pub target_profile: BoundArtifact, + /// Complete explicit semantic closure. + pub semantic_inputs: Vec, + /// Exact requested output roles. + pub requested_outputs: Vec, + /// Host-owned response limits, which cannot alter canonical provider output. + pub limits: ResponseLimitsV1, +} + +/// Successful pure lowering response. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LoweringSuccessV1 { + /// Exactly the requested and supported outputs. + pub outputs: Vec, + /// Deterministically ordered diagnostics. + pub diagnostics: Vec, +} + +/// Result returned by the first provider lowerer. +pub type LoweringResultV1 = Result; + +/// Lowers the explicit canonical Echo provider closure without discovery or I/O. +/// +/// The function emits no authoritative digest or runtime authority. A future +/// Edict host remains responsible for validating the returned bytes against the +/// owning Target IR schema and computing their authoritative digest. +/// +/// # Errors +/// +/// Returns a typed provider refusal when the request crosses an unsupported ABI, +/// profile, semantic, output, or artifact boundary. +pub fn lower(request: LoweringRequestV1) -> LoweringResultV1 { + if request.protocol_version != PROVIDER_ABI { + return Err(refusal( + ProviderRefusalKind::UnsupportedCoreAbi, + format!( + "edict:target-provider@{}.{}.{}", + request.protocol_version.major, + request.protocol_version.minor, + request.protocol_version.patch + ), + "echo.provider.unsupported-protocol", + "the provider accepts only edict:target-provider@1.0.0", + )); + } + + validate_target_profile(&request.target_profile)?; + validate_semantic_closure(&request.semantic_inputs)?; + validate_requested_outputs(&request.requested_outputs)?; + let target_ir = lower_core(&request.core, &request.target_profile.reference.digest)?; + + let outputs = if request.requested_outputs.is_empty() { + Vec::new() + } else { + let bytes = encode_canonical_cbor_v1(&target_ir).map_err(|_| { + refusal( + ProviderRefusalKind::InvalidSemanticArtifact, + TARGET_IR_ROLE, + "echo.provider.target-ir-encoding", + "the lowered Target IR could not be canonically encoded", + ) + })?; + vec![LoweringOutputArtifact { + role: TARGET_IR_ROLE.to_owned(), + kind: LoweringOutputKind::TargetIr, + artifact: Artifact { + domain: OUTPUT_DOMAIN.to_owned(), + bytes, + }, + logical_path: None, + }] + }; + + // Response limits are deliberately host-owned. Canonical provider output is + // invariant under limit changes and the host decides whether it fits. + let _ = request.limits; + Ok(LoweringSuccessV1 { + outputs, + diagnostics: Vec::new(), + }) +} + +fn validate_target_profile(profile: &BoundArtifact) -> Result<(), ProviderRefusalV1> { + validate_binding(profile).map_err(|()| { + refusal( + ProviderRefusalKind::InvalidSemanticArtifact, + "target-profile.echo-dpo", + "echo.provider.invalid-target-profile-artifact", + "the target-profile artifact is not canonically digest-bound", + ) + })?; + if profile.reference.coordinate != TARGET_PROFILE_COORDINATE + || profile.artifact.domain != TARGET_PROFILE_DOMAIN + || profile.artifact.bytes != TARGET_PROFILE_BYTES + { + return Err(refusal( + ProviderRefusalKind::UnsupportedTargetProfile, + &profile.reference.coordinate, + "echo.provider.unsupported-target-profile", + "the lowerer accepts only the exact checked Echo DPO target profile", + )); + } + Ok(()) +} + +fn validate_semantic_closure(inputs: &[SemanticInput]) -> Result<(), ProviderRefusalV1> { + const EXPECTED: [(&str, SemanticInputKind, &str, &str, &[u8]); 3] = [ + ( + "authority-facts.echo-dpo", + SemanticInputKind::AuthorityFacts, + "echo.dpo-authority-facts@1", + AUTHORITY_DOMAIN, + TARGET_AUTHORITY_BYTES, + ), + ( + "authority-facts.echo-lawpack", + SemanticInputKind::AuthorityFacts, + "echo.dpo-lawpack-authority-facts@1", + AUTHORITY_DOMAIN, + LAWPACK_AUTHORITY_BYTES, + ), + ( + "lawpack.echo-dpo", + SemanticInputKind::Lawpack, + "echo.dpo-lawpack@1", + LAWPACK_DOMAIN, + LAWPACK_BYTES, + ), + ]; + if inputs.len() != 4 { + return Err(unsupported_semantics("semantic-inputs")); + } + + for ((role, kind, coordinate, domain, bytes), input) in EXPECTED.into_iter().zip(inputs.iter()) + { + if input.role != role || input.kind != kind { + return Err(unsupported_semantics(&input.role)); + } + validate_binding(&input.artifact) + .map_err(|()| invalid_artifact(&input.role, "artifact binding is invalid"))?; + if input.artifact.reference.coordinate != coordinate + || input.artifact.artifact.domain != domain + || input.artifact.artifact.bytes != bytes + { + return Err(invalid_artifact( + &input.role, + "artifact does not equal the checked provider closure", + )); + } + } + + let lowerability = &inputs[3]; + if lowerability.role != "lowerability.echo-dpo" + || lowerability.kind != SemanticInputKind::LowerabilityFacts + || lowerability.artifact.artifact.domain != LOWERABILITY_DOMAIN + { + return Err(unsupported_semantics(&lowerability.role)); + } + let value = validate_binding(&lowerability.artifact) + .map_err(|()| invalid_artifact(&lowerability.role, "artifact binding is invalid"))?; + if lowerability.artifact.reference.coordinate != LOWERABILITY_COORDINATE { + return Err(invalid_artifact( + &lowerability.role, + "artifact does not equal the checked provider closure", + )); + } + validate_lowerability(&value).map_err(|()| unsupported_semantics(&lowerability.role)) +} + +fn validate_lowerability(value: &CanonicalValueV1) -> Result<(), ()> { + if value != &expected_lowerability()? { + return Err(()); + } + Ok(()) +} + +fn expected_lowerability() -> Result { + let guard_kinds = || CanonicalValueV1::Array(vec![canonical_text("precommit-atomic")]); + let obstructions = || CanonicalValueV1::Array(vec![canonical_text(FAILURE_COORDINATE)]); + let footprint_obligations = + || CanonicalValueV1::Array(vec![canonical_text("target.replace.footprint")]); + let cost_obligations = || CanonicalValueV1::Array(vec![canonical_text("target.replace.cost")]); + canonical_sorted_map([ + ("apiVersion", canonical_text(LOWERABILITY_DOMAIN)), + ("operationProfile", canonical_text(OPERATION_PROFILE)), + ( + "semanticEffects", + CanonicalValueV1::Array(vec![canonical_sorted_map([ + ("coordinate", canonical_text(SEMANTIC_EFFECT)), + ("writeClass", canonical_text("replace")), + ("guardKinds", guard_kinds()), + ("obstructionCoordinates", obstructions()), + ("footprintObligations", footprint_obligations()), + ("costObligations", cost_obligations()), + ])?]), + ), + ( + "requiredWriteClasses", + CanonicalValueV1::Array(vec![canonical_text("replace")]), + ), + ("guardKinds", guard_kinds()), + ("atomicity", canonical_text("atomic")), + ("postconditionSupport", CanonicalValueV1::Bool(true)), + ("obstructionCoordinates", obstructions()), + ("footprintObligations", footprint_obligations()), + ("costObligations", cost_obligations()), + ("opticContract", canonical_text("replace-point")), + ]) +} + +fn expected_core_types() -> Result { + canonical_sorted_map([ + ("Input", expected_record_type("a.b@1.Input.id")?), + ("Output", expected_record_type("a.b@1.Output.id")?), + ("Receipt", expected_record_type("a.b@1.Receipt.id")?), + ("Input.id", expected_string_type()?), + ("Output.id", expected_string_type()?), + ("Receipt.id", expected_string_type()?), + ]) +} + +fn expected_record_type(field_type: &str) -> Result { + canonical_sorted_map([ + ("kind", canonical_text("Record")), + ( + "fields", + canonical_sorted_map([("id", canonical_text(field_type))])?, + ), + ]) +} + +fn expected_string_type() -> Result { + canonical_sorted_map([ + ("kind", canonical_text("String")), + ("max", CanonicalValueV1::Integer(16)), + ("canonical", canonical_text("raw-utf8")), + ]) +} + +fn expected_core_evaluation_budget() -> Result { + canonical_sorted_map([ + ("maxSteps", CanonicalValueV1::Integer(8)), + ("maxAllocatedBytes", CanonicalValueV1::Integer(1024)), + ("maxOutputBytes", CanonicalValueV1::Integer(256)), + ]) +} + +fn canonical_sorted_map<'a>( + entries: impl IntoIterator, +) -> Result { + let mut entries = entries + .into_iter() + .map(|(key, value)| { + let key = canonical_text(key); + let encoded_key = encode_canonical_cbor_v1(&key).map_err(|_| ())?; + Ok((encoded_key, key, value)) + }) + .collect::, ()>>()?; + entries.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(CanonicalValueV1::Map( + entries + .into_iter() + .map(|(_, key, value)| (key, value)) + .collect(), + )) +} + +fn validate_requested_outputs(requests: &[LoweringOutputRequest]) -> Result<(), ProviderRefusalV1> { + match requests { + [] => Ok(()), + [request] + if request.role == TARGET_IR_ROLE + && request.kind == LoweringOutputKind::TargetIr + && request.domain == OUTPUT_DOMAIN => + { + Ok(()) + } + [request, remaining @ ..] => { + let unsupported = if request.role == TARGET_IR_ROLE + && request.kind == LoweringOutputKind::TargetIr + && request.domain == OUTPUT_DOMAIN + { + remaining.first().unwrap_or(request) + } else { + request + }; + Err(refusal( + ProviderRefusalKind::UnsupportedOutputRole, + &unsupported.role, + "echo.provider.unsupported-output-role", + "the first lowerer serves only target-ir.echo-dpo", + )) + } + } +} + +fn lower_core( + core: &BoundArtifact, + target_profile_digest: &Digest, +) -> Result { + if core.artifact.domain != CORE_DOMAIN { + return Err(invalid_artifact( + "core.echo-provider", + "Core domain is invalid", + )); + } + let value = validate_binding(core) + .map_err(|()| invalid_artifact("core.echo-provider", "Core binding is invalid"))?; + let api_version = text_field(&value, "apiVersion") + .ok_or_else(|| invalid_artifact("core.echo-provider", "Core apiVersion is absent"))?; + if api_version != CORE_ABI { + return Err(refusal( + ProviderRefusalKind::UnsupportedCoreAbi, + api_version, + "echo.provider.unsupported-core-abi", + "the lowerer accepts only edict.core/v1", + )); + } + let coordinate = text_field(&value, "coordinate") + .filter(|coordinate| !coordinate.is_empty()) + .ok_or_else(|| invalid_artifact("core.echo-provider", "Core coordinate is invalid"))?; + if coordinate != core.reference.coordinate { + return Err(invalid_artifact( + "core.echo-provider", + "Core coordinate does not equal its bound reference", + )); + } + if coordinate != CORE_COORDINATE { + return Err(unsupported_semantics(coordinate)); + } + let expected_types = expected_core_types().map_err(|()| { + invalid_artifact( + "core.echo-provider", + "reviewed Core type definitions are invalid", + ) + })?; + if !matches!(array_field(&value, "imports"), Some(imports) if imports.is_empty()) + || map_field(&value, "types") != Some(&expected_types) + || !matches!(array_field(&value, "requiredCoreCapabilities"), Some(capabilities) if capabilities.is_empty()) + { + return Err(unsupported_semantics(coordinate)); + } + + let intents = map_field(&value, "intents") + .and_then(as_map) + .ok_or_else(|| invalid_artifact("core.echo-provider", "Core intents map is invalid"))?; + let [(intent_key, intent)] = intents.as_slice() else { + return Err(unsupported_semantics(coordinate)); + }; + let intent_name = as_text(intent_key).ok_or_else(|| unsupported_semantics(coordinate))?; + if intent_name != "t" { + return Err(unsupported_semantics(intent_name)); + } + let lowered_intent = lower_intent(intent_name, intent)?; + let digest_value = target_profile_digest_value(target_profile_digest) + .ok_or_else(|| invalid_artifact("target-profile.echo-dpo", "digest is invalid"))?; + + Ok(canonical_map([ + ("kind", canonical_text("targetIrArtifact")), + ("domain", canonical_text(INNER_TARGET_IR_DOMAIN)), + ( + "targetProfile", + canonical_map([ + ("id", canonical_text(TARGET_PROFILE_COORDINATE)), + ("digest", digest_value), + ]), + ), + ("sourceCoreCoordinate", canonical_text(coordinate)), + ("intents", canonical_map([(intent_name, lowered_intent)])), + ])) +} + +fn lower_intent( + intent_name: &str, + intent: &CanonicalValueV1, +) -> Result { + if text_field(intent, "input") != Some(OPERATION_INPUT_TYPE) + || text_field(intent, "output") != Some(OPERATION_OUTPUT_TYPE) + { + return Err(unsupported_semantics(OPERATION_COORDINATE)); + } + if map_field(intent, "optic").is_some() { + return Err(unsupported_semantics(OPERATION_COORDINATE)); + } + if text_field(intent, "requiredOperationProfile") != Some(OPERATION_PROFILE) { + return Err(unsupported_semantics(OPERATION_COORDINATE)); + } + let input_constraints = array_field(intent, "inputConstraints") + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "input constraints are invalid"))?; + if !input_constraints.is_empty() { + return Err(unsupported_semantics(OPERATION_COORDINATE)); + } + let budget = map_field(intent, "coreEvaluationBudget") + .filter(|budget| validate_budget(budget)) + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "Core budget is invalid"))?; + let expected_budget = expected_core_evaluation_budget().map_err(|()| { + invalid_artifact( + OPERATION_COORDINATE, + "reviewed Core evaluation budget is invalid", + ) + })?; + if budget != &expected_budget { + return Err(unsupported_semantics(OPERATION_COORDINATE)); + } + let body = map_field(intent, "body") + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "Core body is absent"))?; + let locals = array_field(body, "locals") + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "Core locals are invalid"))?; + if !validate_local_inventory(locals) { + return Err(invalid_artifact( + OPERATION_COORDINATE, + "Core local declarations are invalid", + )); + } + let nodes = array_field(body, "nodes") + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "Core nodes are invalid"))?; + let [node] = nodes.as_slice() else { + return Err(unsupported_semantics(OPERATION_COORDINATE)); + }; + let lowered_effect = lower_effect_node(intent_name, node, locals)?; + let result_scope = [lowered_effect.input_local, lowered_effect.binding]; + let result = map_field(body, "result") + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "Core result is invalid"))?; + validate_expr(result, &result_scope, &[]) + .map_err(|error| expression_refusal(error, "Core result is invalid"))?; + + Ok(canonical_map([ + ("operationProfile", canonical_text(OPERATION_PROFILE)), + ( + "inputConstraints", + CanonicalValueV1::Array(input_constraints.clone()), + ), + ("coreEvaluationBudget", budget.clone()), + ("requirements", CanonicalValueV1::Array(Vec::new())), + ("steps", CanonicalValueV1::Array(vec![lowered_effect.value])), + ("result", result.clone()), + ])) +} + +struct LoweredEffect<'a> { + value: CanonicalValueV1, + input_local: &'a CanonicalValueV1, + binding: &'a CanonicalValueV1, +} + +fn lower_effect_node<'a>( + intent_name: &str, + node: &'a CanonicalValueV1, + locals: &'a [CanonicalValueV1], +) -> Result, ProviderRefusalV1> { + if text_field(node, "kind") != Some("effect") + || text_field(node, "effect") != Some(SEMANTIC_EFFECT) + { + return Err(unsupported_semantics(OPERATION_COORDINATE)); + } + let binding = map_field(node, "binding") + .filter(|binding| validate_local(binding)) + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "effect binding is invalid"))?; + let obstruction_map = map_field(node, "obstructionMap") + .and_then(as_map) + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "obstruction map is invalid"))?; + let [(failure, arm)] = obstruction_map.as_slice() else { + return Err(unsupported_semantics(OPERATION_COORDINATE)); + }; + if as_text(failure) != Some(FAILURE_COORDINATE) { + return Err(unsupported_semantics(OPERATION_COORDINATE)); + } + let obstruction_binder = map_field(arm, "binder") + .filter(|binder| validate_local(binder)) + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "obstruction binder is invalid"))?; + let input_local = + reviewed_input_local(locals, binding, obstruction_binder).ok_or_else(|| { + invalid_artifact(OPERATION_COORDINATE, "Core local declarations are invalid") + })?; + + let pre_effect_scope = [input_local]; + let input = map_field(node, "input") + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "effect input is invalid"))?; + validate_expr(input, &pre_effect_scope, &[]) + .map_err(|error| expression_refusal(error, "effect input is invalid"))?; + + let obstruction_scope = [input_local, obstruction_binder]; + let obstruction_value = map_field(arm, "value") + .ok_or_else(|| invalid_artifact(OPERATION_COORDINATE, "obstruction value is invalid"))?; + if text_field(obstruction_value, "kind") != Some("call") + || text_field(obstruction_value, "callee") != Some(DOMAIN_OBSTRUCTION) + || !matches!(array_field(obstruction_value, "typeArgs"), Some(arguments) if arguments.is_empty()) + || !matches!(array_field(obstruction_value, "args"), Some(arguments) if arguments.is_empty()) + { + return Err(unsupported_semantics(OPERATION_COORDINATE)); + } + validate_expr(obstruction_value, &obstruction_scope, &[DOMAIN_OBSTRUCTION]) + .map_err(|error| expression_refusal(error, "obstruction value is invalid"))?; + + Ok(LoweredEffect { + value: canonical_map([ + ("id", canonical_text(&format!("{intent_name}.step.0"))), + ("binding", binding.clone()), + ("effect", canonical_text(SEMANTIC_EFFECT)), + ("targetIntrinsic", canonical_text(TARGET_INTRINSIC)), + ("input", input.clone()), + ( + "obstructionFailures", + CanonicalValueV1::Array(vec![canonical_text(FAILURE_COORDINATE)]), + ), + ( + "obstructionArms", + canonical_map([(FAILURE_COORDINATE, arm.clone())]), + ), + ]), + input_local, + binding, + }) +} + +fn validate_binding(bound: &BoundArtifact) -> Result { + if bound.reference.coordinate.is_empty() + || bound.reference.digest.algorithm != DigestAlgorithm::Sha256 + || bound.reference.digest.bytes.len() != 32 + || bound.artifact.domain.is_empty() + { + return Err(()); + } + let value = decode_canonical_cbor_v1(&bound.artifact.bytes).map_err(|_| ())?; + let computed = digest_canonical_value_v1(&bound.artifact.domain, &value).map_err(|_| ())?; + if computed != digest_review(&bound.reference.digest) { + return Err(()); + } + Ok(value) +} + +fn digest_review(digest: &Digest) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut review = String::with_capacity(7 + digest.bytes.len() * 2); + review.push_str("sha256:"); + for byte in &digest.bytes { + review.push(char::from(HEX[usize::from(byte >> 4)])); + review.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + review +} + +fn target_profile_digest_value(digest: &Digest) -> Option { + (digest.algorithm == DigestAlgorithm::Sha256 && digest.bytes.len() == 32).then(|| { + CanonicalValueV1::Array(vec![ + canonical_text("sha256"), + CanonicalValueV1::Bytes(digest.bytes.clone()), + ]) + }) +} + +fn validate_budget(value: &CanonicalValueV1) -> bool { + ["maxSteps", "maxAllocatedBytes", "maxOutputBytes"] + .into_iter() + .all(|field| matches!(map_field(value, field), Some(CanonicalValueV1::Integer(value)) if *value >= 0)) +} + +fn validate_local(value: &CanonicalValueV1) -> bool { + ["id", "alphaName", "type"] + .into_iter() + .all(|field| text_field(value, field).is_some_and(|value| !value.is_empty())) +} + +fn validate_local_inventory(locals: &[CanonicalValueV1]) -> bool { + locals.iter().enumerate().all(|(index, local)| { + validate_local(local) + && locals[index + 1..] + .iter() + .all(|other| !same_local_id(local, other)) + }) +} + +fn reviewed_input_local<'a>( + locals: &'a [CanonicalValueV1], + binding: &CanonicalValueV1, + obstruction_binder: &CanonicalValueV1, +) -> Option<&'a CanonicalValueV1> { + if locals.len() != 3 + || text_field(binding, "type") != Some(OPERATION_RECEIPT_TYPE) + || text_field(obstruction_binder, "type") != Some(FAILURE_PAYLOAD_TYPE) + || !locals.contains(binding) + || !locals.contains(obstruction_binder) + || same_local_id(binding, obstruction_binder) + { + return None; + } + + let mut inputs = locals.iter().filter(|local| { + !same_local_id(local, binding) && !same_local_id(local, obstruction_binder) + }); + let input = inputs.next()?; + if inputs.next().is_some() || text_field(input, "type") != Some(OPERATION_INPUT_TYPE) { + return None; + } + Some(input) +} + +fn same_local_id(left: &CanonicalValueV1, right: &CanonicalValueV1) -> bool { + text_field(left, "id").is_some_and(|id| text_field(right, "id") == Some(id)) +} + +#[derive(Clone, Copy)] +enum ExpressionValidationError { + Invalid, + LocalOutOfScope, + UnsupportedCall, +} + +fn validate_expr( + value: &CanonicalValueV1, + scope: &[&CanonicalValueV1], + allowed_callees: &[&str], +) -> Result<(), ExpressionValidationError> { + match text_field(value, "kind") { + Some("local") => { + let reference = map_field(value, "ref") + .filter(|reference| validate_local(reference)) + .ok_or(ExpressionValidationError::Invalid)?; + if scope.contains(&reference) { + Ok(()) + } else { + Err(ExpressionValidationError::LocalOutOfScope) + } + } + Some("const") => map_field(value, "value") + .filter(|value| validate_core_value(value)) + .map(|_| ()) + .ok_or(ExpressionValidationError::Invalid), + Some("record") => { + let fields = map_field(value, "fields") + .and_then(as_map) + .ok_or(ExpressionValidationError::Invalid)?; + for (key, value) in fields { + if as_text(key).is_none_or(str::is_empty) { + return Err(ExpressionValidationError::Invalid); + } + validate_expr(value, scope, allowed_callees)?; + } + Ok(()) + } + Some("field") => { + if text_field(value, "field").is_none_or(str::is_empty) { + return Err(ExpressionValidationError::Invalid); + } + validate_expr( + map_field(value, "base").ok_or(ExpressionValidationError::Invalid)?, + scope, + allowed_callees, + ) + } + Some("call") => { + let callee = text_field(value, "callee") + .filter(|callee| !callee.is_empty()) + .ok_or(ExpressionValidationError::Invalid)?; + if !allowed_callees.contains(&callee) { + return Err(ExpressionValidationError::UnsupportedCall); + } + if !array_field(value, "typeArgs") + .is_some_and(|values| values.iter().all(|value| as_text(value).is_some())) + { + return Err(ExpressionValidationError::Invalid); + } + for argument in array_field(value, "args").ok_or(ExpressionValidationError::Invalid)? { + validate_expr(argument, scope, allowed_callees)?; + } + Ok(()) + } + _ => Err(ExpressionValidationError::Invalid), + } +} + +fn expression_refusal( + error: ExpressionValidationError, + invalid_message: &str, +) -> ProviderRefusalV1 { + match error { + ExpressionValidationError::Invalid => { + invalid_artifact(OPERATION_COORDINATE, invalid_message) + } + ExpressionValidationError::LocalOutOfScope => local_scope_refusal(), + ExpressionValidationError::UnsupportedCall => unsupported_semantics(OPERATION_COORDINATE), + } +} + +fn validate_core_value(value: &CanonicalValueV1) -> bool { + match text_field(value, "kind") { + Some("null") => true, + Some("bool") => matches!(map_field(value, "value"), Some(CanonicalValueV1::Bool(_))), + Some("int") => { + text_field(value, "width").is_some() + && matches!( + map_field(value, "value"), + Some(CanonicalValueV1::Integer(_)) + ) + } + Some("string") => text_field(value, "value").is_some(), + Some("bytes") => matches!(map_field(value, "value"), Some(CanonicalValueV1::Bytes(_))), + _ => false, + } +} + +fn canonical_map<'a>( + entries: impl IntoIterator, +) -> CanonicalValueV1 { + CanonicalValueV1::Map( + entries + .into_iter() + .map(|(key, value)| (canonical_text(key), value)) + .collect(), + ) +} + +fn canonical_text(value: &str) -> CanonicalValueV1 { + CanonicalValueV1::Text(value.to_owned()) +} + +fn map_field<'a>(value: &'a CanonicalValueV1, field: &str) -> Option<&'a CanonicalValueV1> { + as_map(value)?.iter().find_map(|(key, value)| { + (matches!(key, CanonicalValueV1::Text(key) if key == field)).then_some(value) + }) +} + +fn text_field<'a>(value: &'a CanonicalValueV1, field: &str) -> Option<&'a str> { + map_field(value, field).and_then(as_text) +} + +fn array_field<'a>(value: &'a CanonicalValueV1, field: &str) -> Option<&'a Vec> { + match map_field(value, field)? { + CanonicalValueV1::Array(values) => Some(values), + _ => None, + } +} + +fn as_map(value: &CanonicalValueV1) -> Option<&Vec<(CanonicalValueV1, CanonicalValueV1)>> { + match value { + CanonicalValueV1::Map(entries) => Some(entries), + _ => None, + } +} + +fn as_text(value: &CanonicalValueV1) -> Option<&str> { + match value { + CanonicalValueV1::Text(value) => Some(value), + _ => None, + } +} + +fn refusal( + kind: ProviderRefusalKind, + subject: impl Into, + code: &str, + message: &str, +) -> ProviderRefusalV1 { + ProviderRefusalV1 { + kind, + subject: Some(subject.into()), + diagnostics: vec![Diagnostic { + code: code.to_owned(), + severity: DiagnosticSeverity::Error, + message: message.to_owned(), + repair: None, + }], + } +} + +fn invalid_artifact(subject: &str, message: &str) -> ProviderRefusalV1 { + refusal( + ProviderRefusalKind::InvalidSemanticArtifact, + subject, + "echo.provider.invalid-semantic-artifact", + message, + ) +} + +fn local_scope_refusal() -> ProviderRefusalV1 { + refusal( + ProviderRefusalKind::InvalidSemanticArtifact, + OPERATION_COORDINATE, + "echo.provider.local-reference-out-of-scope", + "Core local reference is not in scope", + ) +} + +fn unsupported_semantics(subject: &str) -> ProviderRefusalV1 { + refusal( + ProviderRefusalKind::UnsupportedSemantics, + subject, + "echo.provider.unsupported-semantics", + "the supplied semantics are outside the exact first Echo lowering closure", + ) +} diff --git a/crates/echo-edict-provider-lowerer/tests/lowerer_contract.rs b/crates/echo-edict-provider-lowerer/tests/lowerer_contract.rs new file mode 100644 index 00000000..5c39826d --- /dev/null +++ b/crates/echo-edict-provider-lowerer/tests/lowerer_contract.rs @@ -0,0 +1,1074 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +#![allow(clippy::expect_used, clippy::panic)] +//! Executable contract for the first pure Echo Edict provider lowerer. + +use echo_edict_canonical::{ + decode_canonical_cbor_v1, digest_canonical_value_v1, encode_canonical_cbor_v1, CanonicalValueV1, +}; +use echo_edict_provider_lowerer::{ + lower, Artifact, BoundArtifact, Digest, DigestAlgorithm, LoweringOutputKind, + LoweringOutputRequest, LoweringRequestV1, ProtocolVersionV1, ProviderRefusalKind, ResourceRef, + ResponseLimitsV1, SemanticInput, SemanticInputKind, +}; +use sha2::{Digest as ShaDigest, Sha256}; + +const TARGET_PROFILE: &[u8] = include_bytes!("../resources/target-profile.echo-dpo.cbor"); +const LAWPACK: &[u8] = include_bytes!("../resources/lawpack.echo-dpo.cbor"); +const TARGET_AUTHORITY: &[u8] = include_bytes!("../resources/authority-facts.echo-dpo.cbor"); +const LAWPACK_AUTHORITY: &[u8] = include_bytes!("../resources/authority-facts.echo-lawpack.cbor"); + +const CORE_DOMAIN: &str = "edict.core.module/v1"; +const TARGET_PROFILE_DOMAIN: &str = "edict.target-profile/v1"; +const LAWPACK_DOMAIN: &str = "edict.lawpack/v1"; +const AUTHORITY_DOMAIN: &str = "edict.authority-facts/v1"; +const LOWERABILITY_DOMAIN: &str = "edict.lowering-requirements/v1"; +const OUTER_TARGET_IR_DOMAIN: &str = "edict.target-ir.artifact/v1"; +const INNER_TARGET_IR_DOMAIN: &str = "echo.span-ir/v1"; +const TARGET_IR_ROLE: &str = "target-ir.echo-dpo"; + +const EDICT_ORACLE_CORE_HEX: &str = concat!( + "a6657479706573a665496e707574a2646b696e64665265636f7264666669656c6473a16269646e612e6240312e496e70", + "75742e6964664f7574707574a2646b696e64665265636f7264666669656c6473a16269646f612e6240312e4f75747075", + "742e69646752656365697074a2646b696e64665265636f7264666669656c6473a162696470612e6240312e5265636569", + "70742e696468496e7075742e6964a3636d617810646b696e6466537472696e676963616e6f6e6963616c687261772d75", + "746638694f75747075742e6964a3636d617810646b696e6466537472696e676963616e6f6e6963616c687261772d7574", + "66386a526563656970742e6964a3636d617810646b696e6466537472696e676963616e6f6e6963616c687261772d7574", + "663867696d706f7274738067696e74656e7473a16174a664626f6479a3656e6f64657381a5646b696e64666566666563", + "7465696e707574a36462617365a263726566a3626964656172672e3064747970656b612e6240312e496e70757469616c", + "7068614e616d65652461726730646b696e64656c6f63616c646b696e64656669656c64656669656c6462696466656666", + "6563746e7461726765742e7265706c6163656762696e64696e67a3626964676c6f63616c2e3064747970656d612e6240", + "312e5265636569707469616c7068614e616d6567246c6f63616c306e6f62737472756374696f6e4d6170a16872656a65", + "63746564a26576616c7565a4646172677380646b696e646463616c6c6663616c6c656574646f6d61696e2e5772697465", + "52656a6563746564687479706541726773806662696e646572a36269646d6f62737472756374696f6e2e306474797065", + "777461726765742e7265706c6163652e72656a656374656469616c7068614e616d656d246f62737472756374696f6e30", + "666c6f63616c7383a3626964656172672e3064747970656b612e6240312e496e70757469616c7068614e616d65652461", + "726730a3626964676c6f63616c2e3064747970656d612e6240312e5265636569707469616c7068614e616d6567246c6f", + "63616c30a36269646d6f62737472756374696f6e2e306474797065777461726765742e7265706c6163652e72656a6563", + "74656469616c7068614e616d656d246f62737472756374696f6e3066726573756c74a2646b696e64667265636f726466", + "6669656c6473a1626964a36462617365a263726566a3626964656172672e3064747970656b612e6240312e496e707574", + "69616c7068614e616d65652461726730646b696e64656c6f63616c646b696e64656669656c64656669656c6462696465", + "696e7075746b612e6240312e496e707574666f75747075746c612e6240312e4f757470757470696e707574436f6e7374", + "7261696e74738074636f72654576616c756174696f6e427564676574a3686d61785374657073086e6d61784f75747075", + "744279746573190100716d6178416c6c6f63617465644279746573190400781872657175697265644f7065726174696f", + "6e50726f66696c65781a636f6e74696e75756d2e70726f66696c652e77726974652f76316a61706956657273696f6e6d", + "65646963742e636f72652f76316a636f6f7264696e61746565612e62403178187265717569726564436f726543617061", + "62696c697469657380", +); + +const EDICT_ORACLE_TARGET_IR_HEX: &str = concat!( + "a5646b696e64707461726765744972417274696661637466646f6d61696e6f6563686f2e7370616e2d69722f76316769", + "6e74656e7473a16174a665737465707381a762696468742e737465702e3065696e707574a36462617365a263726566a3", + "626964656172672e3064747970656b612e6240312e496e70757469616c7068614e616d65652461726730646b696e6465", + "6c6f63616c646b696e64656669656c64656669656c64626964666566666563746e7461726765742e7265706c61636567", + "62696e64696e67a3626964676c6f63616c2e3064747970656d612e6240312e5265636569707469616c7068614e616d65", + "67246c6f63616c306f6f62737472756374696f6e41726d73a16872656a6563746564a26576616c7565a4646172677380", + "646b696e646463616c6c6663616c6c656574646f6d61696e2e577269746552656a656374656468747970654172677380", + "6662696e646572a36269646d6f62737472756374696f6e2e306474797065777461726765742e7265706c6163652e7265", + "6a656374656469616c7068614e616d656d246f62737472756374696f6e306f746172676574496e7472696e7369637265", + "63686f2e64706f40312e7265706c616365736f62737472756374696f6e4661696c75726573816872656a656374656466", + "726573756c74a2646b696e64667265636f7264666669656c6473a1626964a36462617365a263726566a3626964656172", + "672e3064747970656b612e6240312e496e70757469616c7068614e616d65652461726730646b696e64656c6f63616c64", + "6b696e64656669656c64656669656c646269646c726571756972656d656e74738070696e707574436f6e73747261696e", + "747380706f7065726174696f6e50726f66696c65781a636f6e74696e75756d2e70726f66696c652e77726974652f7631", + "74636f72654576616c756174696f6e427564676574a3686d61785374657073086e6d61784f7574707574427974657319", + "0100716d6178416c6c6f636174656442797465731904006d74617267657450726f66696c65a26269646a6563686f2e64", + "706f40316664696765737482667368613235365820f41df38156625a05c1ee8bce652ffddf04e71b54fe027eeab9d255", + "d0d8322db074736f75726365436f7265436f6f7264696e61746565612e624031", +); + +fn text(value: &str) -> CanonicalValueV1 { + CanonicalValueV1::Text(value.to_owned()) +} + +fn integer(value: u64) -> CanonicalValueV1 { + CanonicalValueV1::Integer(i128::from(value)) +} + +fn map(entries: impl IntoIterator) -> CanonicalValueV1 { + let mut entries = entries + .into_iter() + .map(|(key, value)| (text(key), value)) + .collect::>(); + entries.sort_by_cached_key(|(key, _)| canonical_bytes(key)); + CanonicalValueV1::Map(entries) +} + +fn string_map( + entries: impl IntoIterator, +) -> CanonicalValueV1 { + map(entries) +} + +fn local(id: &str, alpha_name: &str, ty: &str) -> CanonicalValueV1 { + map([ + ("id", text(id)), + ("alphaName", text(alpha_name)), + ("type", text(ty)), + ]) +} + +fn local_expr(reference: CanonicalValueV1) -> CanonicalValueV1 { + map([("kind", text("local")), ("ref", reference)]) +} + +fn field_expr(base: CanonicalValueV1, field: &str) -> CanonicalValueV1 { + map([ + ("kind", text("field")), + ("base", base), + ("field", text(field)), + ]) +} + +fn record_expr(id: CanonicalValueV1) -> CanonicalValueV1 { + map([ + ("kind", text("record")), + ("fields", string_map([("id", id)])), + ]) +} + +fn call_expr(callee: &str) -> CanonicalValueV1 { + map([ + ("kind", text("call")), + ("callee", text(callee)), + ("typeArgs", CanonicalValueV1::Array(Vec::new())), + ("args", CanonicalValueV1::Array(Vec::new())), + ]) +} + +fn canonical_bytes(value: &CanonicalValueV1) -> Vec { + encode_canonical_cbor_v1(value).expect("test value has a canonical encoding") +} + +fn bound(coordinate: &str, domain: &str, bytes: impl Into>) -> BoundArtifact { + let bytes = bytes.into(); + let value = decode_canonical_cbor_v1(&bytes).expect("fixture is canonical CBOR"); + let review_digest = + digest_canonical_value_v1(domain, &value).expect("fixture has a domain-framed digest"); + let digest_bytes = hex::decode( + review_digest + .strip_prefix("sha256:") + .expect("review digest uses sha256"), + ) + .expect("review digest is hexadecimal"); + BoundArtifact { + reference: ResourceRef { + coordinate: coordinate.to_owned(), + digest: Digest { + algorithm: DigestAlgorithm::Sha256, + bytes: digest_bytes, + }, + }, + artifact: Artifact { + domain: domain.to_owned(), + bytes, + }, + } +} + +fn core_types() -> CanonicalValueV1 { + string_map([ + ( + "Input", + map([ + ("kind", text("Record")), + ("fields", string_map([("id", text("a.b@1.Input.id"))])), + ]), + ), + ( + "Output", + map([ + ("kind", text("Record")), + ("fields", string_map([("id", text("a.b@1.Output.id"))])), + ]), + ), + ( + "Receipt", + map([ + ("kind", text("Record")), + ("fields", string_map([("id", text("a.b@1.Receipt.id"))])), + ]), + ), + ( + "Input.id", + map([ + ("kind", text("String")), + ("max", integer(16)), + ("canonical", text("raw-utf8")), + ]), + ), + ( + "Output.id", + map([ + ("kind", text("String")), + ("max", integer(16)), + ("canonical", text("raw-utf8")), + ]), + ), + ( + "Receipt.id", + map([ + ("kind", text("String")), + ("max", integer(16)), + ("canonical", text("raw-utf8")), + ]), + ), + ]) +} + +fn core_value(result: CanonicalValueV1, effect: Option<&str>) -> CanonicalValueV1 { + let input = local("local:0", "input", "a.b@1.Input"); + let receipt = local("local:1", "receipt", "a.b@1.Receipt"); + let reason = local("local:2", "reason", "target.replace.rejected"); + let nodes = effect.map_or_else(Vec::new, |effect| { + vec![map([ + ("kind", text("effect")), + ("binding", receipt.clone()), + ("effect", text(effect)), + ("input", field_expr(local_expr(input.clone()), "id")), + ( + "obstructionMap", + string_map([( + "rejected", + map([ + ("binder", reason.clone()), + ("value", call_expr("domain.WriteRejected")), + ]), + )]), + ), + ])] + }); + let intent = map([ + ("input", text("a.b@1.Input")), + ("output", text("a.b@1.Output")), + ( + "requiredOperationProfile", + text("continuum.profile.write/v1"), + ), + ("inputConstraints", CanonicalValueV1::Array(Vec::new())), + ( + "coreEvaluationBudget", + map([ + ("maxSteps", integer(8)), + ("maxAllocatedBytes", integer(1024)), + ("maxOutputBytes", integer(256)), + ]), + ), + ( + "body", + map([ + ( + "locals", + CanonicalValueV1::Array(vec![input, receipt, reason]), + ), + ("nodes", CanonicalValueV1::Array(nodes)), + ("result", result), + ]), + ), + ]); + map([ + ("apiVersion", text("edict.core/v1")), + ("coordinate", text("a.b@1")), + ("imports", CanonicalValueV1::Array(Vec::new())), + ("types", core_types()), + ("intents", string_map([("t", intent)])), + ( + "requiredCoreCapabilities", + CanonicalValueV1::Array(Vec::new()), + ), + ]) +} + +fn ordinary_result() -> CanonicalValueV1 { + record_expr(field_expr( + local_expr(local("local:0", "input", "a.b@1.Input")), + "id", + )) +} + +fn lowerability_value() -> CanonicalValueV1 { + map([ + ("apiVersion", text(LOWERABILITY_DOMAIN)), + ("operationProfile", text("continuum.profile.write/v1")), + ( + "semanticEffects", + CanonicalValueV1::Array(vec![map([ + ("coordinate", text("target.replace")), + ("writeClass", text("replace")), + ( + "guardKinds", + CanonicalValueV1::Array(vec![text("precommit-atomic")]), + ), + ( + "obstructionCoordinates", + CanonicalValueV1::Array(vec![text("rejected")]), + ), + ( + "footprintObligations", + CanonicalValueV1::Array(vec![text("target.replace.footprint")]), + ), + ( + "costObligations", + CanonicalValueV1::Array(vec![text("target.replace.cost")]), + ), + ])]), + ), + ( + "requiredWriteClasses", + CanonicalValueV1::Array(vec![text("replace")]), + ), + ( + "guardKinds", + CanonicalValueV1::Array(vec![text("precommit-atomic")]), + ), + ("atomicity", text("atomic")), + ("postconditionSupport", CanonicalValueV1::Bool(true)), + ( + "obstructionCoordinates", + CanonicalValueV1::Array(vec![text("rejected")]), + ), + ( + "footprintObligations", + CanonicalValueV1::Array(vec![text("target.replace.footprint")]), + ), + ( + "costObligations", + CanonicalValueV1::Array(vec![text("target.replace.cost")]), + ), + ("opticContract", text("replace-point")), + ]) +} + +fn request_with_core(core: CanonicalValueV1) -> LoweringRequestV1 { + LoweringRequestV1 { + protocol_version: ProtocolVersionV1 { + major: 1, + minor: 0, + patch: 0, + }, + core: bound("a.b@1", CORE_DOMAIN, canonical_bytes(&core)), + target_profile: bound("echo.dpo@1", TARGET_PROFILE_DOMAIN, TARGET_PROFILE), + semantic_inputs: vec![ + SemanticInput { + role: "authority-facts.echo-dpo".to_owned(), + kind: SemanticInputKind::AuthorityFacts, + artifact: bound( + "echo.dpo-authority-facts@1", + AUTHORITY_DOMAIN, + TARGET_AUTHORITY, + ), + }, + SemanticInput { + role: "authority-facts.echo-lawpack".to_owned(), + kind: SemanticInputKind::AuthorityFacts, + artifact: bound( + "echo.dpo-lawpack-authority-facts@1", + AUTHORITY_DOMAIN, + LAWPACK_AUTHORITY, + ), + }, + SemanticInput { + role: "lawpack.echo-dpo".to_owned(), + kind: SemanticInputKind::Lawpack, + artifact: bound("echo.dpo-lawpack@1", LAWPACK_DOMAIN, LAWPACK), + }, + SemanticInput { + role: "lowerability.echo-dpo".to_owned(), + kind: SemanticInputKind::LowerabilityFacts, + artifact: bound( + "echo.dpo-lowerability@1", + LOWERABILITY_DOMAIN, + canonical_bytes(&lowerability_value()), + ), + }, + ], + requested_outputs: vec![LoweringOutputRequest { + role: TARGET_IR_ROLE.to_owned(), + kind: LoweringOutputKind::TargetIr, + domain: OUTER_TARGET_IR_DOMAIN.to_owned(), + }], + limits: ResponseLimitsV1 { + max_output_count: 8, + max_diagnostic_count: 8, + max_total_response_bytes: 64 * 1024, + }, + } +} + +fn request() -> LoweringRequestV1 { + request_with_core(core_value(ordinary_result(), Some("target.replace"))) +} + +fn map_field<'a>(value: &'a CanonicalValueV1, field: &str) -> &'a CanonicalValueV1 { + let CanonicalValueV1::Map(entries) = value else { + panic!("value is not a map"); + }; + entries + .iter() + .find_map(|(key, value)| (key == &text(field)).then_some(value)) + .unwrap_or_else(|| panic!("map field `{field}` is absent")) +} + +fn text_value(value: &CanonicalValueV1) -> &str { + let CanonicalValueV1::Text(value) = value else { + panic!("value is not text"); + }; + value +} + +#[test] +fn minimal_echo_mutation_lowers_from_explicit_semantics() { + let request = request(); + let target_profile_digest = request.target_profile.reference.digest.clone(); + let success = lower(request).expect("supported explicit closure lowers"); + + assert!(success.diagnostics.is_empty()); + assert_eq!(success.outputs.len(), 1); + let output = &success.outputs[0]; + assert_eq!(output.role, TARGET_IR_ROLE); + assert_eq!(output.kind, LoweringOutputKind::TargetIr); + assert_eq!(output.artifact.domain, OUTER_TARGET_IR_DOMAIN); + assert_eq!(output.logical_path, None); + + let artifact = decode_canonical_cbor_v1(&output.artifact.bytes) + .expect("provider output is canonical CBOR"); + assert_eq!(text_value(map_field(&artifact, "kind")), "targetIrArtifact"); + assert_eq!( + text_value(map_field(&artifact, "domain")), + INNER_TARGET_IR_DOMAIN + ); + assert_eq!( + text_value(map_field(&artifact, "sourceCoreCoordinate")), + "a.b@1" + ); + let target_profile = map_field(&artifact, "targetProfile"); + assert_eq!(text_value(map_field(target_profile, "id")), "echo.dpo@1"); + assert_eq!( + map_field(target_profile, "digest"), + &CanonicalValueV1::Array(vec![ + text("sha256"), + CanonicalValueV1::Bytes(target_profile_digest.bytes), + ]) + ); + + let intent = map_field(map_field(&artifact, "intents"), "t"); + assert_eq!( + text_value(map_field(intent, "operationProfile")), + "continuum.profile.write/v1" + ); + assert_eq!( + map_field(intent, "coreEvaluationBudget"), + map_field( + map_field( + map_field( + &core_value(ordinary_result(), Some("target.replace")), + "intents" + ), + "t" + ), + "coreEvaluationBudget" + ) + ); + assert_eq!(map_field(intent, "result"), &ordinary_result()); + + let CanonicalValueV1::Array(steps) = map_field(intent, "steps") else { + panic!("steps is not an array"); + }; + assert_eq!(steps.len(), 1); + assert_eq!(text_value(map_field(&steps[0], "id")), "t.step.0"); + assert_eq!(text_value(map_field(&steps[0], "effect")), "target.replace"); + assert_eq!( + text_value(map_field(&steps[0], "targetIntrinsic")), + "echo.dpo@1.replace" + ); + assert_eq!( + map_field(&steps[0], "binding"), + &local("local:1", "receipt", "a.b@1.Receipt") + ); + assert_eq!( + map_field(&steps[0], "input"), + &field_expr(local_expr(local("local:0", "input", "a.b@1.Input")), "id") + ); + assert_eq!( + map_field(map_field(&steps[0], "obstructionArms"), "rejected"), + &map([ + ( + "binder", + local("local:2", "reason", "target.replace.rejected") + ), + ("value", call_expr("domain.WriteRejected")), + ]) + ); +} + +#[test] +fn reviewed_edict_fixture_has_exact_builtin_wrapper_parity() { + let core_bytes = hex::decode(EDICT_ORACLE_CORE_HEX).expect("oracle Core hex is valid"); + assert_eq!(core_bytes.len(), 1209); + let mut request = request(); + request.core = bound("a.b@1", CORE_DOMAIN, core_bytes); + assert_eq!( + hex::encode(&request.core.reference.digest.bytes), + "c3dbe413c78a82f6120e64c9a04bc94e2d79505f9e4b8a65c2bc26b408d775de" + ); + + let success = lower(request).expect("reviewed Edict fixture lowers"); + assert!(success.diagnostics.is_empty()); + assert_eq!(success.outputs.len(), 1); + let output = &success.outputs[0]; + let expected = hex::decode(EDICT_ORACLE_TARGET_IR_HEX).expect("oracle Target IR hex is valid"); + assert_eq!(expected.len(), 848); + assert_eq!(output.artifact.bytes, expected); + + let output_value = decode_canonical_cbor_v1(&output.artifact.bytes) + .expect("oracle-parity output is canonical CBOR"); + assert_eq!( + digest_canonical_value_v1(OUTER_TARGET_IR_DOMAIN, &output_value) + .expect("oracle-parity output has a domain-framed digest"), + "sha256:b0d9e218f00a102d1e951c73e5063a9bbe6077e6c7468d171ec08b420e7b47da" + ); +} + +#[test] +fn vendored_wit_is_the_frozen_edict_lowerer_contract() { + let bytes = include_bytes!("../wit/edict-target-provider.wit"); + assert_eq!(bytes.len(), 7392); + assert_eq!( + hex::encode(Sha256::digest(bytes)), + "2971fe44def7e51d5271dfc0f04f3088aa58754cffdc847681a587605aac749e" + ); +} + +#[test] +fn output_overclaim_identifies_the_first_unsupported_role() { + let mut request = request(); + request.requested_outputs.push(LoweringOutputRequest { + role: "review.echo-dpo".to_owned(), + kind: LoweringOutputKind::ReviewPayload, + domain: "edict.review-payload/v1".to_owned(), + }); + + let refusal = lower(request).expect_err("a second output role is outside this lowerer"); + assert_eq!(refusal.kind, ProviderRefusalKind::UnsupportedOutputRole); + assert_eq!(refusal.subject.as_deref(), Some("review.echo-dpo")); +} + +#[test] +fn unrecognized_lowerability_obligation_refuses_instead_of_being_ignored() { + let mut lowerability = lowerability_value(); + *map_field_mut(&mut lowerability, "footprintObligations") = + CanonicalValueV1::Array(vec![text("unexpected.footprint")]); + let mut request = request(); + request.semantic_inputs[3].artifact = bound( + "echo.dpo-lowerability@1", + LOWERABILITY_DOMAIN, + canonical_bytes(&lowerability), + ); + + let refusal = lower(request).expect_err("unrecognized obligations cannot be discharged"); + assert_eq!(refusal.kind, ProviderRefusalKind::UnsupportedSemantics); + assert_eq!(refusal.subject.as_deref(), Some("lowerability.echo-dpo")); +} + +#[test] +fn no_requested_outputs_is_an_empty_success() { + let mut request = request(); + request.requested_outputs.clear(); + let success = lower(request).expect("zero requested roles is valid"); + assert!(success.outputs.is_empty()); + assert!(success.diagnostics.is_empty()); +} + +#[test] +fn provider_output_is_independent_of_host_response_limits() { + let first = lower(request()).expect("baseline lowers"); + let mut changed = request(); + changed.limits = ResponseLimitsV1 { + max_output_count: 0, + max_diagnostic_count: 0, + max_total_response_bytes: 0, + }; + let second = lower(changed).expect("provider does not reinterpret host limits"); + assert_eq!(first, second); +} + +#[test] +fn protocol_target_and_output_mismatches_refuse_with_stable_kinds() { + let mut protocol = request(); + protocol.protocol_version.patch = 1; + assert_eq!( + lower(protocol).expect_err("protocol mismatch refuses").kind, + ProviderRefusalKind::UnsupportedCoreAbi + ); + + let mut profile = request(); + profile.target_profile.reference.coordinate = "echo.other@1".to_owned(); + assert_eq!( + lower(profile).expect_err("profile mismatch refuses").kind, + ProviderRefusalKind::UnsupportedTargetProfile + ); + + let mut profile_domain = request(); + let coordinate = profile_domain.target_profile.reference.coordinate.clone(); + let bytes = profile_domain.target_profile.artifact.bytes.clone(); + profile_domain.target_profile = bound(&coordinate, "wrong.target-profile/v1", bytes); + assert_eq!( + lower(profile_domain) + .expect_err("profile domain mismatch refuses") + .kind, + ProviderRefusalKind::UnsupportedTargetProfile + ); + + let mut output = request(); + output.requested_outputs[0].role = "generated.echo-dpo".to_owned(); + assert_eq!( + lower(output).expect_err("unserved output refuses").kind, + ProviderRefusalKind::UnsupportedOutputRole + ); +} + +#[test] +fn malformed_or_incomplete_semantic_closure_refuses() { + let mut malformed = request(); + malformed.semantic_inputs[0].artifact.artifact.bytes.push(0); + assert_eq!( + lower(malformed) + .expect_err("malformed authority facts refuse") + .kind, + ProviderRefusalKind::InvalidSemanticArtifact + ); + + let mut missing = request(); + missing + .semantic_inputs + .retain(|input| input.role != "authority-facts.echo-lawpack"); + assert_eq!( + lower(missing).expect_err("incomplete closure refuses").kind, + ProviderRefusalKind::UnsupportedSemantics + ); +} + +#[test] +fn unsupported_core_abi_and_semantics_refuse_without_artifacts() { + let mut wrong_abi = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(&mut wrong_abi, "apiVersion") = text("edict.core/v2"); + assert_eq!( + lower(request_with_core(wrong_abi)) + .expect_err("unknown Core ABI refuses") + .kind, + ProviderRefusalKind::UnsupportedCoreAbi + ); + + let read = core_value(ordinary_result(), None); + assert_eq!( + lower(request_with_core(read)) + .expect_err("effect-free reads are not synthetic mutations") + .kind, + ProviderRefusalKind::UnsupportedSemantics + ); +} + +#[test] +fn fully_qualified_core_intent_key_refuses_instead_of_broadening_the_boundary() { + let mut core = core_value(ordinary_result(), Some("target.replace")); + let CanonicalValueV1::Map(intents) = map_field_mut(&mut core, "intents") else { + panic!("Core intents is not a map"); + }; + intents[0].0 = text("a.b@1.t"); + + assert_eq!( + lower(request_with_core(core)) + .expect_err("the canonical Core intent key is package-local") + .kind, + ProviderRefusalKind::UnsupportedSemantics + ); +} + +#[test] +fn rebound_core_coordinate_refuses_as_unsupported_semantics() { + let mut core = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(&mut core, "coordinate") = text("x.y@1"); + let mut request = request_with_core(core); + request.core.reference.coordinate = "x.y@1".to_owned(); + + let refusal = lower(request).expect_err("a rebound Core module is not the reviewed operation"); + assert_eq!(refusal.kind, ProviderRefusalKind::UnsupportedSemantics); + assert_eq!(refusal.subject.as_deref(), Some("x.y@1")); +} + +#[test] +fn authored_core_optic_refuses_instead_of_being_silently_discarded() { + let mut core = core_value(ordinary_result(), Some("target.replace")); + let CanonicalValueV1::Map(intents) = map_field_mut(&mut core, "intents") else { + panic!("Core intents is not a map"); + }; + let CanonicalValueV1::Map(intent) = &mut intents[0].1 else { + panic!("Core intent is not a map"); + }; + intent.push(( + text("optic"), + map([ + ("opticKind", text("affectReintegration")), + ("boundaryKind", text("affect")), + ( + "apertureRequirement", + map([ + ("kind", text("footprintCeiling")), + ("ref", text("echo.dpo@1.replace-footprint")), + ]), + ), + ("supportPolicy", text("echo.dpo@1.replace-support")), + ("lossDisposition", text("refuse")), + ]), + )); + intent.sort_by_cached_key(|(key, _)| canonical_bytes(key)); + + let refusal = lower(request_with_core(core)) + .expect_err("an authored optic cannot disappear across lowering"); + assert_eq!(refusal.kind, ProviderRefusalKind::UnsupportedSemantics); + assert_eq!(refusal.subject.as_deref(), Some("a.b@1.t")); +} + +#[test] +fn unsupported_intent_type_bindings_refuse_instead_of_being_ignored() { + for field in ["input", "output"] { + let mut core = core_value(ordinary_result(), Some("target.replace")); + let CanonicalValueV1::Map(intents) = map_field_mut(&mut core, "intents") else { + panic!("Core intents is not a map"); + }; + *map_field_mut(&mut intents[0].1, field) = text("x.y@1.Other"); + + let refusal = lower(request_with_core(core)) + .expect_err("unsupported operation type bindings cannot disappear across lowering"); + assert_eq!(refusal.kind, ProviderRefusalKind::UnsupportedSemantics); + assert_eq!(refusal.subject.as_deref(), Some("a.b@1.t")); + } +} + +#[test] +fn altered_core_type_definitions_refuse_instead_of_disappearing() { + let mut core = core_value(ordinary_result(), Some("target.replace")); + let input_id = map_field_mut(map_field_mut(&mut core, "types"), "Input.id"); + *map_field_mut(input_id, "max") = integer(17); + + let refusal = lower(request_with_core(core)) + .expect_err("changed Core type semantics cannot disappear across lowering"); + assert_eq!(refusal.kind, ProviderRefusalKind::UnsupportedSemantics); + assert_eq!(refusal.subject.as_deref(), Some("a.b@1")); + assert_eq!(refusal.diagnostics.len(), 1); + assert_eq!( + refusal.diagnostics[0].code, + "echo.provider.unsupported-semantics" + ); +} + +#[test] +fn changed_evaluation_budget_refuses_instead_of_broadening_the_closure() { + for (field, value) in [ + ("maxSteps", 0), + ("maxAllocatedBytes", 2048), + ("maxOutputBytes", 512), + ] { + let mut core = core_value(ordinary_result(), Some("target.replace")); + let intent = map_field_mut(map_field_mut(&mut core, "intents"), "t"); + *map_field_mut(map_field_mut(intent, "coreEvaluationBudget"), field) = integer(value); + + let refusal = lower(request_with_core(core)) + .expect_err("a different evaluation budget is outside the reviewed closure"); + assert_eq!(refusal.kind, ProviderRefusalKind::UnsupportedSemantics); + assert_eq!(refusal.subject.as_deref(), Some("a.b@1.t")); + assert_eq!(refusal.diagnostics.len(), 1); + assert_eq!( + refusal.diagnostics[0].code, + "echo.provider.unsupported-semantics" + ); + } +} + +#[test] +fn nonempty_input_constraints_refuse_instead_of_crossing_unchecked() { + let mut core = core_value(ordinary_result(), Some("target.replace")); + let CanonicalValueV1::Array(constraints) = + map_field_mut(operation_intent_mut(&mut core), "inputConstraints") + else { + panic!("Core input constraints is not an array"); + }; + constraints.push(map([ + ("coordinate", text("a.b@1.t.where.0")), + ("source", text("where")), + ( + "predicate", + map([ + ("kind", text("call")), + ("predicate", text("domain.Unreviewed")), + ( + "args", + CanonicalValueV1::Array(vec![local_expr(local( + "local:99", + "ghost", + "a.b@1.Input", + ))]), + ), + ]), + ), + ])); + + assert_unsupported_semantics( + core, + "input constraints need explicit lowering and scope validation", + ); +} + +#[test] +fn unreviewed_effect_input_call_refuses_as_unsupported_semantics() { + let mut core = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(operation_node_mut(&mut core), "input") = call_expr("domain.Unreviewed"); + + assert_unsupported_semantics( + core, + "an unreviewed effect-input call cannot cross lowering", + ); +} + +#[test] +fn unreviewed_intent_result_call_refuses_as_unsupported_semantics() { + let mut core = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(operation_body_mut(&mut core), "result") = + record_expr(call_expr("domain.Unreviewed")); + + assert_unsupported_semantics( + core, + "an unreviewed intent-result call cannot cross lowering", + ); +} + +#[test] +fn nested_undeclared_local_reference_refuses_with_stable_details() { + let mut core = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(operation_body_mut(&mut core), "result") = record_expr(field_expr( + local_expr(local("local:99", "ghost", "a.b@1.Input")), + "id", + )); + assert_out_of_scope(core, "a nested undeclared result local cannot lower"); +} + +#[test] +fn effect_result_binding_is_not_visible_in_its_own_input() { + let mut core = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(operation_node_mut(&mut core), "input") = + local_expr(local("local:1", "receipt", "a.b@1.Receipt")); + assert_out_of_scope(core, "an effect cannot consume its own result binding"); +} + +#[test] +fn obstruction_binder_does_not_escape_its_arm() { + let mut core = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(operation_body_mut(&mut core), "result") = + local_expr(local("local:2", "reason", "target.replace.rejected")); + assert_out_of_scope(core, "an obstruction binder cannot escape its arm"); +} + +#[test] +fn local_reference_must_match_the_declared_identity_triple() { + for changed_reference in [ + local("local:0", "other", "a.b@1.Input"), + local("local:0", "input", "a.b@1.Output"), + ] { + let mut core = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(operation_body_mut(&mut core), "result") = + record_expr(field_expr(local_expr(changed_reference), "id")); + assert_out_of_scope(core, "a local reference cannot alter its declaration"); + } +} + +#[test] +fn duplicate_or_conflicting_local_ids_refuse() { + for duplicate in [ + local("local:0", "input", "a.b@1.Input"), + local("local:0", "other", "a.b@1.Output"), + ] { + let mut core = core_value(ordinary_result(), Some("target.replace")); + let CanonicalValueV1::Array(locals) = + map_field_mut(operation_body_mut(&mut core), "locals") + else { + panic!("Core locals is not an array"); + }; + locals.push(duplicate); + assert_invalid_local_declarations(core); + } +} + +#[test] +fn local_inventory_is_exactly_the_reviewed_binding_closure() { + for missing_id in ["local:1", "local:2"] { + let mut core = core_value(ordinary_result(), Some("target.replace")); + operation_locals_mut(&mut core) + .retain(|local| text_value(map_field(local, "id")) != missing_id); + assert_invalid_local_declarations(core); + } + + let mut core = core_value(ordinary_result(), Some("target.replace")); + operation_locals_mut(&mut core).push(local("local:99", "ghost", "a.b@1.Input")); + assert_invalid_local_declarations(core); +} + +#[test] +fn local_binding_roles_authenticate_their_reviewed_types() { + let mut input = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(local_by_id_mut(&mut input, "local:0"), "type") = text("a.b@1.Output"); + assert_invalid_local_declarations(input); + + let mut receipt = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(local_by_id_mut(&mut receipt, "local:1"), "type") = text("a.b@1.Output"); + *map_field_mut( + map_field_mut(operation_node_mut(&mut receipt), "binding"), + "type", + ) = text("a.b@1.Output"); + assert_invalid_local_declarations(receipt); + + let mut reason = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut(local_by_id_mut(&mut reason, "local:2"), "type") = text("a.b@1.Input"); + *map_field_mut( + map_field_mut(obstruction_arm_mut(&mut reason), "binder"), + "type", + ) = text("a.b@1.Input"); + assert_invalid_local_declarations(reason); +} + +#[test] +fn obstruction_constructor_is_exactly_the_reviewed_mapping() { + for (field, changed) in [ + ("callee", text("domain.Unreviewed")), + ("typeArgs", CanonicalValueV1::Array(vec![text("T")])), + ( + "args", + CanonicalValueV1::Array(vec![local_expr(local("local:0", "input", "a.b@1.Input"))]), + ), + ] { + let mut core = core_value(ordinary_result(), Some("target.replace")); + *map_field_mut( + map_field_mut(obstruction_arm_mut(&mut core), "value"), + field, + ) = changed; + assert_unsupported_semantics( + core, + "an unreviewed obstruction constructor cannot cross lowering", + ); + } +} + +#[test] +fn renamed_lowerability_artifact_refuses_as_an_invalid_closure_member() { + let mut request = request(); + request.semantic_inputs[3].artifact.reference.coordinate = + "echo.dpo-renamed-lowerability@1".to_owned(); + + let refusal = lower(request).expect_err("lowerability identity includes its coordinate"); + assert_eq!(refusal.kind, ProviderRefusalKind::InvalidSemanticArtifact); + assert_eq!(refusal.subject.as_deref(), Some("lowerability.echo-dpo")); +} + +#[test] +fn lowering_uses_core_values_instead_of_replaying_static_bytes() { + let baseline = lower(request()).expect("baseline lowers").outputs.remove(0); + let changed_result = record_expr(map([ + ("kind", text("const")), + ( + "value", + map([("kind", text("string")), ("value", text("changed"))]), + ), + ])); + let changed = lower(request_with_core(core_value( + changed_result.clone(), + Some("target.replace"), + ))) + .expect("supported semantic variant lowers") + .outputs + .remove(0); + assert_ne!(baseline.artifact.bytes, changed.artifact.bytes); + let value = decode_canonical_cbor_v1(&changed.artifact.bytes).expect("output is canonical"); + let intent = map_field(map_field(&value, "intents"), "t"); + assert_eq!(map_field(intent, "result"), &changed_result); +} + +fn map_field_mut<'a>(value: &'a mut CanonicalValueV1, field: &str) -> &'a mut CanonicalValueV1 { + let CanonicalValueV1::Map(entries) = value else { + panic!("value is not a map"); + }; + entries + .iter_mut() + .find_map(|(key, value)| (key == &text(field)).then_some(value)) + .unwrap_or_else(|| panic!("map field `{field}` is absent")) +} + +fn operation_body_mut(core: &mut CanonicalValueV1) -> &mut CanonicalValueV1 { + map_field_mut(operation_intent_mut(core), "body") +} + +fn operation_intent_mut(core: &mut CanonicalValueV1) -> &mut CanonicalValueV1 { + map_field_mut(map_field_mut(core, "intents"), "t") +} + +fn operation_locals_mut(core: &mut CanonicalValueV1) -> &mut Vec { + let CanonicalValueV1::Array(locals) = map_field_mut(operation_body_mut(core), "locals") else { + panic!("Core locals is not an array"); + }; + locals +} + +fn local_by_id_mut<'a>( + core: &'a mut CanonicalValueV1, + expected_id: &str, +) -> &'a mut CanonicalValueV1 { + operation_locals_mut(core) + .iter_mut() + .find(|local| text_value(map_field(local, "id")) == expected_id) + .unwrap_or_else(|| panic!("Core local `{expected_id}` is absent")) +} + +fn operation_node_mut(core: &mut CanonicalValueV1) -> &mut CanonicalValueV1 { + let CanonicalValueV1::Array(nodes) = map_field_mut(operation_body_mut(core), "nodes") else { + panic!("Core nodes is not an array"); + }; + nodes.first_mut().expect("reviewed closure has one node") +} + +fn obstruction_arm_mut(core: &mut CanonicalValueV1) -> &mut CanonicalValueV1 { + map_field_mut( + map_field_mut(operation_node_mut(core), "obstructionMap"), + "rejected", + ) +} + +fn assert_out_of_scope(core: CanonicalValueV1, message: &str) { + let refusal = lower(request_with_core(core)).expect_err(message); + assert_eq!(refusal.kind, ProviderRefusalKind::InvalidSemanticArtifact); + assert_eq!(refusal.subject.as_deref(), Some("a.b@1.t")); + assert_eq!(refusal.diagnostics.len(), 1); + assert_eq!( + refusal.diagnostics[0].code, + "echo.provider.local-reference-out-of-scope" + ); +} + +fn assert_invalid_local_declarations(core: CanonicalValueV1) { + let refusal = lower(request_with_core(core)).expect_err("ambiguous Core locals cannot lower"); + assert_eq!(refusal.kind, ProviderRefusalKind::InvalidSemanticArtifact); + assert_eq!(refusal.subject.as_deref(), Some("a.b@1.t")); + assert_eq!(refusal.diagnostics.len(), 1); + assert_eq!( + refusal.diagnostics[0].code, + "echo.provider.invalid-semantic-artifact" + ); +} + +fn assert_unsupported_semantics(core: CanonicalValueV1, message: &str) { + let refusal = lower(request_with_core(core)).expect_err(message); + assert_eq!(refusal.kind, ProviderRefusalKind::UnsupportedSemantics); + assert_eq!(refusal.subject.as_deref(), Some("a.b@1.t")); + assert_eq!(refusal.diagnostics.len(), 1); + assert_eq!( + refusal.diagnostics[0].code, + "echo.provider.unsupported-semantics" + ); +} diff --git a/crates/echo-edict-provider-lowerer/wit/edict-target-provider.wit b/crates/echo-edict-provider-lowerer/wit/edict-target-provider.wit new file mode 100644 index 00000000..0efa5903 --- /dev/null +++ b/crates/echo-edict-provider-lowerer/wit/edict-target-provider.wit @@ -0,0 +1,221 @@ +// edict-target-provider.wit +// +// Runtime-neutral component boundary for provider-owned lowerer and verifier +// components. The transport carries opaque artifact bytes. The artifact domain +// selects the separately versioned canonical schema; this WIT package does not +// redefine Core, lawpack, target-profile, authority, lowerability, Target IR, +// or verifier-report semantics. +// +// Every input artifact is bound to a host-verified resource reference. Output +// artifacts deliberately carry no digest: a future Edict host must validate +// their declared role and domain, check canonicality under the owning schema, +// and compute their authoritative digest after component invocation. + +package edict:target-provider@1.0.0; + +interface protocol { + // This value is carried in each request so pure envelope validation can + // reject a protocol mismatch before component execution. It must agree with + // this package's semantic version. + record protocol-version-v1 { + major: u32, + minor: u32, + patch: u32, + } + + enum digest-algorithm { + sha256, + } + + record digest { + algorithm: digest-algorithm, + bytes: list, + } + + record resource-ref { + coordinate: string, + digest: digest, + } + + record artifact { + domain: string, + bytes: list, + } + + record bound-artifact { + reference: resource-ref, + artifact: artifact, + } + + // The target profile and Core are explicit request fields. This list carries + // the deterministic closure of every other semantic input needed by the + // selected component. Auxiliary kinds remain role- and domain-constrained by + // the host; they are not ambient discovery handles. + variant semantic-input-kind { + lawpack, + authority-facts, + lowerability-facts, + auxiliary(string), + } + + record semantic-input { + role: string, + kind: semantic-input-kind, + artifact: bound-artifact, + } + + // Every role in this package is nonempty. Every semantic-inputs list is + // strictly ascending by the UTF-8 bytes of its unique role. The future host + // rejects empty, duplicate, or out-of-order roles. + + // The separate output vocabularies make lowerer and verifier authority + // structural. A lowerer cannot claim verifier evidence, and a verifier + // cannot claim Target IR or generated artifacts. + enum lowering-output-kind { + target-ir, + generated-artifact, + review-payload, + } + + record lowering-output-request { + role: string, + kind: lowering-output-kind, + domain: string, + } + + // Every requested-outputs list and every successful outputs list is strictly + // ascending by the UTF-8 bytes of its unique role. Success contains exactly + // one output for every request and no others. Each output's role, kind, and + // artifact.domain must equal its request's role, kind, and domain. + + // No digest field is permitted here. A provider cannot notarize its own + // bytes; the future host validates an optional logical package-relative path + // and recomputes output identity after validation. A logical path is + // nonempty UTF-8, uses `/` separators, has no leading slash, backslash, colon, + // empty segment, `.` segment, or `..` segment, and is never resolved through + // a symlink by the validator. Present logical paths within one response are + // unique by exact, case-sensitive UTF-8 bytes with no Unicode normalization. + record lowering-output-artifact { + role: string, + kind: lowering-output-kind, + artifact: artifact, + logical-path: option, + } + + enum verification-output-kind { + verifier-report, + } + + record verification-output-request { + role: string, + kind: verification-output-kind, + domain: string, + } + + record verification-output-artifact { + role: string, + kind: verification-output-kind, + artifact: artifact, + logical-path: option, + } + + // These bounds apply to both success and refusal. Max-output-count counts the + // success outputs list (zero for refusal), and max-diagnostic-count counts the + // diagnostics list on either result arm. Max-total-response-bytes is the + // checked-u64 sum of every provider-authored list length and every + // provider-authored string's UTF-8 byte length in the selected result arm: + // output role/domain/artifact bytes/logical path, diagnostic + // code/message/repair, and refusal subject. Enum values, numeric values, and + // canonical-ABI framing add zero. Sum overflow or a value above any bound is + // a host-owned response-limit failure. A zero bound permits only zero counted + // items or bytes. A provider's canonical result is independent of these + // limits. For requests otherwise identical, if that result fits both the old + // and new bounds, the selected result arm and every provider-authored field + // must be byte-identical. A provider must not substitute, truncate, reorder, + // or change result arms to fit a smaller bound. Component memory, fuel, and + // interruption remain host-only. + record response-limits-v1 { + max-output-count: u32, + max-diagnostic-count: u32, + max-total-response-bytes: u64, + } + + enum diagnostic-severity { + error, + warning, + info, + } + + record diagnostic { + code: string, + severity: diagnostic-severity, + message: string, + repair: option, + } + + // Every diagnostics list is strictly ascending by the tuple (code UTF-8 + // bytes, severity declaration index, message UTF-8 bytes, repair), with none + // before some and present repair ordered by UTF-8 bytes. Exact duplicates are + // therefore invalid. + + enum provider-refusal-kind { + unsupported-core-abi, + unsupported-target-profile, + unsupported-semantics, + unsupported-output-role, + invalid-semantic-artifact, + } + + // Provider refusal is target-owned evidence. Component loading, denied + // imports, traps, exhaustion, malformed envelopes, binding mismatches, and + // replay mismatches are distinct host-owned failure categories. + record provider-refusal-v1 { + kind: provider-refusal-kind, + subject: option, + diagnostics: list, + } + + record lowering-request-v1 { + protocol-version: protocol-version-v1, + core: bound-artifact, + target-profile: bound-artifact, + semantic-inputs: list, + requested-outputs: list, + limits: response-limits-v1, + } + + record verification-request-v1 { + protocol-version: protocol-version-v1, + core: bound-artifact, + target-profile: bound-artifact, + target-ir: bound-artifact, + semantic-inputs: list, + requested-outputs: list, + limits: response-limits-v1, + } + + record lowering-success-v1 { + outputs: list, + diagnostics: list, + } + + record verification-success-v1 { + outputs: list, + diagnostics: list, + } + + type lowering-result-v1 = result; + type verification-result-v1 = result; +} + +world lowerer { + use protocol.{lowering-request-v1, lowering-result-v1}; + + export lower: func(request: lowering-request-v1) -> lowering-result-v1; +} + +world verifier { + use protocol.{verification-request-v1, verification-result-v1}; + + export verify: func(request: verification-request-v1) -> verification-result-v1; +} diff --git a/crates/echo-wesley-gen/Cargo.toml b/crates/echo-wesley-gen/Cargo.toml index a75ce68e..a72cd6dd 100644 --- a/crates/echo-wesley-gen/Cargo.toml +++ b/crates/echo-wesley-gen/Cargo.toml @@ -21,6 +21,7 @@ cap-std = "=4.0.2" cddl-cat = { version = "0.7.1", default-features = false, features = ["ciborium"] } ciborium = "0.2.2" clap = { version = "4.4", features = ["derive"] } +echo-edict-canonical = { workspace = true } hex = "0.4" proc-macro2 = "1.0" quote = "1.0" diff --git a/crates/echo-wesley-gen/src/provider_canonical.rs b/crates/echo-wesley-gen/src/provider_canonical.rs index f91f1ba0..0b263e54 100644 --- a/crates/echo-wesley-gen/src/provider_canonical.rs +++ b/crates/echo-wesley-gen/src/provider_canonical.rs @@ -1,521 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 // © James Ross Ω FLYING•ROBOTS -//! Exact `edict.canonical-cbor/v1` values, bytes, and artifact digests. -//! -//! This is a pure compatibility boundary for the Edict-owned provider -//! contract. It deliberately does not reuse Echo's WASM ABI codec: that codec -//! admits a different value model. The implementation accepts only the -//! definite-length Edict v1 subset, enforces the published nesting bound, and -//! validates decoded bytes by exact canonical re-encoding. +//! Compatibility re-export of Echo's pure Edict canonical codec. -use std::collections::BTreeSet; -use std::fmt; -use std::str; - -use sha2::{Digest, Sha256}; - -/// Coordinate of the canonical encoding profile implemented by this module. -pub const EDICT_CANONICAL_CBOR_V1: &str = "edict.canonical-cbor/v1"; - -/// Domain marker at the head of every Edict v1 artifact digest frame. -pub const EDICT_DIGEST_FRAME_V1: &str = "edict.digest/v1"; - -/// Maximum child nesting depth accepted by the Edict v1 encoder and decoder. -/// -/// The root value is at depth zero. A scalar wrapped in exactly 128 containers -/// is accepted; one additional container is rejected. -pub const MAX_CANONICAL_NESTING_DEPTH_V1: usize = 128; - -/// Stable failure categories for Edict canonical values and byte streams. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CanonicalValueErrorKind { - /// A value or collection length is outside the supported value model. - UnsupportedValue, - /// An integer is outside the CBOR major-zero or major-one `u64` range. - InvalidInteger, - /// The byte stream ended before one complete value was decoded. - UnexpectedEof, - /// Bytes remained after one complete canonical value. - TrailingData, - /// The byte stream used an unsupported CBOR major or additional-info form. - UnsupportedCbor, - /// The decoded value did not re-encode to the exact supplied bytes. - NonCanonical, - /// The value exceeded the published 128-level nesting bound. - NestingLimitExceeded, - /// A map contained two keys with identical canonical encodings. - DuplicateMapKey, - /// A CBOR text string was not valid UTF-8. - InvalidUtf8, -} - -impl CanonicalValueErrorKind { - const fn label(self) -> &'static str { - match self { - Self::UnsupportedValue => "unsupported-value", - Self::InvalidInteger => "invalid-integer", - Self::UnexpectedEof => "unexpected-eof", - Self::TrailingData => "trailing-data", - Self::UnsupportedCbor => "unsupported-cbor", - Self::NonCanonical => "noncanonical", - Self::NestingLimitExceeded => "nesting-limit-exceeded", - Self::DuplicateMapKey => "duplicate-map-key", - Self::InvalidUtf8 => "invalid-utf8", - } - } -} - -/// Structured canonical-value failure with a stable kind and diagnostic detail. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CanonicalValueError { - kind: CanonicalValueErrorKind, - detail: String, -} - -impl CanonicalValueError { - /// Returns the stable machine-readable failure category. - #[must_use] - pub const fn kind(&self) -> CanonicalValueErrorKind { - self.kind - } - - /// Returns deterministic diagnostic detail for the failed boundary. - #[must_use] - pub fn detail(&self) -> &str { - &self.detail - } - - fn new(kind: CanonicalValueErrorKind, detail: impl Into) -> Self { - Self { - kind, - detail: detail.into(), - } - } -} - -impl fmt::Display for CanonicalValueError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}: {}", self.kind.label(), self.detail) - } -} - -impl std::error::Error for CanonicalValueError {} - -/// Value tree admitted by `edict.canonical-cbor/v1`. -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub enum CanonicalValueV1 { - /// CBOR null. - Null, - /// CBOR false or true. - Bool(bool), - /// CBOR major-zero or major-one integer stored in a convenient host type. - Integer(i128), - /// Definite-length byte string. - Bytes(Vec), - /// Definite-length UTF-8 text string, without Unicode normalization. - Text(String), - /// Definite-length ordered array. - Array(Vec), - /// Definite-length map sorted during encoding by canonical key bytes. - Map(Vec<(CanonicalValueV1, CanonicalValueV1)>), -} - -/// Encodes one value using the exact `edict.canonical-cbor/v1` byte contract. -/// -/// # Errors -/// -/// Returns a stable failure when an integer or collection cannot be represented, -/// a map repeats a canonical key, or the value exceeds the nesting bound. -pub fn encode_canonical_cbor_v1(value: &CanonicalValueV1) -> Result, CanonicalValueError> { - let mut output = Vec::new(); - encode_value(value, &mut output, 0)?; - Ok(output) -} - -/// Decodes and authenticates one `edict.canonical-cbor/v1` value. -/// -/// Decoding is followed by exact canonical re-encoding. This rejects -/// non-minimal integers and lengths, unsorted maps, and any other supported -/// value whose supplied bytes differ from the unique Edict encoding. -/// Successful decoding authenticates the encoding only: callers must still -/// validate the value against its owning CDDL root, and Echo must separately -/// admit any runtime artifact or authority. -/// -/// # Errors -/// -/// Returns a stable failure for malformed, unsupported, trailing, -/// noncanonical, duplicate-key, invalid-UTF-8, or over-nested input. -pub fn decode_canonical_cbor_v1(bytes: &[u8]) -> Result { - let mut decoder = Decoder::new(bytes); - let value = decoder.value(0)?; - if decoder.remaining() != 0 { - return Err(CanonicalValueError::new( - CanonicalValueErrorKind::TrailingData, - "canonical CBOR stream contains bytes after the first value", - )); - } - if encode_canonical_cbor_v1(&value)? != bytes { - return Err(CanonicalValueError::new( - CanonicalValueErrorKind::NonCanonical, - "decoded value does not re-encode to the supplied bytes", - )); - } - Ok(value) -} - -/// Computes an Edict v1 domain-framed SHA-256 digest for a canonical value. -/// -/// The returned review rendering is `sha256:<64 lowercase hex>`. The preimage -/// is canonical CBOR for `["edict.digest/v1", domain, value]`. Each tuple member -/// is encoded at its own root so digest framing does not consume the artifact's -/// 128-level nesting budget. -/// A digest binds bytes and a caller-selected domain; it does not prove the -/// owning CDDL root or grant Echo runtime authority. -/// -/// # Errors -/// -/// Returns a stable failure for an empty domain or an unencodable value. -pub fn digest_canonical_value_v1( - domain: &str, - value: &CanonicalValueV1, -) -> Result { - if domain.is_empty() { - return Err(CanonicalValueError::new( - CanonicalValueErrorKind::UnsupportedValue, - "canonical artifact digest domain is empty", - )); - } - - let mut preimage = vec![0x83]; - preimage.extend(encode_canonical_cbor_v1(&CanonicalValueV1::Text( - EDICT_DIGEST_FRAME_V1.to_owned(), - ))?); - preimage.extend(encode_canonical_cbor_v1(&CanonicalValueV1::Text( - domain.to_owned(), - ))?); - preimage.extend(encode_canonical_cbor_v1(value)?); - - Ok(format!("sha256:{}", hex::encode(Sha256::digest(preimage)))) -} - -fn encode_value( - value: &CanonicalValueV1, - output: &mut Vec, - depth: usize, -) -> Result<(), CanonicalValueError> { - check_depth(depth)?; - match value { - CanonicalValueV1::Null => output.push(0xf6), - CanonicalValueV1::Bool(false) => output.push(0xf4), - CanonicalValueV1::Bool(true) => output.push(0xf5), - CanonicalValueV1::Integer(value) => encode_integer(*value, output)?, - CanonicalValueV1::Bytes(bytes) => { - encode_type_value(2, usize_to_u64(bytes.len())?, output); - output.extend_from_slice(bytes); - } - CanonicalValueV1::Text(text) => { - encode_type_value(3, usize_to_u64(text.len())?, output); - output.extend_from_slice(text.as_bytes()); - } - CanonicalValueV1::Array(values) => { - check_container_depth(depth)?; - encode_type_value(4, usize_to_u64(values.len())?, output); - for value in values { - encode_value(value, output, depth + 1)?; - } - } - CanonicalValueV1::Map(entries) => { - check_container_depth(depth)?; - let mut encoded_entries = Vec::with_capacity(entries.len()); - let mut encoded_keys = BTreeSet::new(); - for (key, value) in entries { - let mut key_bytes = Vec::new(); - encode_value(key, &mut key_bytes, depth + 1)?; - if !encoded_keys.insert(key_bytes.clone()) { - return Err(CanonicalValueError::new( - CanonicalValueErrorKind::DuplicateMapKey, - "canonical CBOR map contains duplicate keys", - )); - } - encoded_entries.push((key_bytes, value)); - } - encoded_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - encode_type_value(5, usize_to_u64(encoded_entries.len())?, output); - for (key_bytes, value) in encoded_entries { - output.extend_from_slice(&key_bytes); - encode_value(value, output, depth + 1)?; - } - } - } - Ok(()) -} - -fn check_depth(depth: usize) -> Result<(), CanonicalValueError> { - if depth > MAX_CANONICAL_NESTING_DEPTH_V1 { - return Err(CanonicalValueError::new( - CanonicalValueErrorKind::NestingLimitExceeded, - format!( - "canonical value nesting exceeds maximum depth {MAX_CANONICAL_NESTING_DEPTH_V1}" - ), - )); - } - Ok(()) -} - -fn check_container_depth(depth: usize) -> Result<(), CanonicalValueError> { - if depth >= MAX_CANONICAL_NESTING_DEPTH_V1 { - return Err(CanonicalValueError::new( - CanonicalValueErrorKind::NestingLimitExceeded, - format!( - "canonical container nesting exceeds maximum depth {MAX_CANONICAL_NESTING_DEPTH_V1}" - ), - )); - } - Ok(()) -} - -fn encode_integer(value: i128, output: &mut Vec) -> Result<(), CanonicalValueError> { - if value >= 0 { - let value = u64::try_from(value).map_err(|_| { - CanonicalValueError::new( - CanonicalValueErrorKind::InvalidInteger, - "positive integer exceeds the canonical CBOR uint range", - ) - })?; - encode_type_value(0, value, output); - return Ok(()); - } - - let magnitude = (-1i128).checked_sub(value).ok_or_else(|| { - CanonicalValueError::new( - CanonicalValueErrorKind::InvalidInteger, - "negative integer cannot be converted to the canonical CBOR range", - ) - })?; - let magnitude = u64::try_from(magnitude).map_err(|_| { - CanonicalValueError::new( - CanonicalValueErrorKind::InvalidInteger, - "negative integer exceeds the canonical CBOR negative range", - ) - })?; - encode_type_value(1, magnitude, output); - Ok(()) -} - -fn encode_type_value(major: u8, value: u64, output: &mut Vec) { - let prefix = major << 5; - let bytes = value.to_be_bytes(); - match value { - 0..=23 => output.push(prefix | bytes[7]), - 24..=0xff => { - output.push(prefix | 0x18); - output.push(bytes[7]); - } - 0x100..=0xffff => { - output.push(prefix | 0x19); - output.extend_from_slice(&bytes[6..]); - } - 0x1_0000..=0xffff_ffff => { - output.push(prefix | 0x1a); - output.extend_from_slice(&bytes[4..]); - } - _ => { - output.push(prefix | 0x1b); - output.extend_from_slice(&bytes); - } - } -} - -fn usize_to_u64(value: usize) -> Result { - u64::try_from(value).map_err(|_| { - CanonicalValueError::new( - CanonicalValueErrorKind::UnsupportedValue, - "canonical collection length does not fit the CBOR uint range", - ) - }) -} - -fn checked_collection_length( - declared: u64, - remaining: u64, -) -> Result -where - HostLength: TryFrom, -{ - if declared > remaining { - return Err(CanonicalValueError::new( - CanonicalValueErrorKind::UnexpectedEof, - "canonical CBOR declared length exceeds the remaining bytes", - )); - } - - HostLength::try_from(declared).map_err(|_| { - CanonicalValueError::new( - CanonicalValueErrorKind::UnsupportedCbor, - "canonical CBOR collection length does not fit usize", - ) - }) -} - -struct Decoder<'a> { - bytes: &'a [u8], - position: usize, -} - -impl<'a> Decoder<'a> { - const fn new(bytes: &'a [u8]) -> Self { - Self { bytes, position: 0 } - } - - const fn remaining(&self) -> usize { - self.bytes.len() - self.position - } - - fn value(&mut self, depth: usize) -> Result { - check_depth(depth)?; - let initial = self.byte()?; - let major = initial >> 5; - let additional = initial & 0x1f; - match major { - 0 => Ok(CanonicalValueV1::Integer(i128::from( - self.argument(additional)?, - ))), - 1 => Ok(CanonicalValueV1::Integer( - -1 - i128::from(self.argument(additional)?), - )), - 2 => { - let length = self.length(additional)?; - Ok(CanonicalValueV1::Bytes(self.take(length)?.to_vec())) - } - 3 => { - let length = self.length(additional)?; - let bytes = self.take(length)?; - let text = str::from_utf8(bytes).map_err(|_| { - CanonicalValueError::new( - CanonicalValueErrorKind::InvalidUtf8, - "canonical CBOR text string is not valid UTF-8", - ) - })?; - Ok(CanonicalValueV1::Text(text.to_owned())) - } - 4 => { - check_container_depth(depth)?; - let length = self.length(additional)?; - let mut values = Vec::with_capacity(length); - for _ in 0..length { - values.push(self.value(depth + 1)?); - } - Ok(CanonicalValueV1::Array(values)) - } - 5 => { - check_container_depth(depth)?; - let length = self.length(additional)?; - let mut entries = Vec::with_capacity(length); - let mut encoded_keys = BTreeSet::new(); - for _ in 0..length { - let key = self.value(depth + 1)?; - let mut key_bytes = Vec::new(); - encode_value(&key, &mut key_bytes, depth + 1)?; - if !encoded_keys.insert(key_bytes) { - return Err(CanonicalValueError::new( - CanonicalValueErrorKind::DuplicateMapKey, - "canonical CBOR map contains duplicate keys", - )); - } - let value = self.value(depth + 1)?; - entries.push((key, value)); - } - Ok(CanonicalValueV1::Map(entries)) - } - 7 => match additional { - 20 => Ok(CanonicalValueV1::Bool(false)), - 21 => Ok(CanonicalValueV1::Bool(true)), - 22 => Ok(CanonicalValueV1::Null), - _ => Err(CanonicalValueError::new( - CanonicalValueErrorKind::UnsupportedCbor, - "canonical CBOR simple value is unsupported", - )), - }, - _ => Err(CanonicalValueError::new( - CanonicalValueErrorKind::UnsupportedCbor, - "canonical CBOR major type is unsupported", - )), - } - } - - fn argument(&mut self, additional: u8) -> Result { - match additional { - 0..=23 => Ok(u64::from(additional)), - 24 => Ok(u64::from(self.byte()?)), - 25 => Ok(u64::from(u16::from_be_bytes(self.take_array::<2>()?))), - 26 => Ok(u64::from(u32::from_be_bytes(self.take_array::<4>()?))), - 27 => Ok(u64::from_be_bytes(self.take_array::<8>()?)), - _ => Err(CanonicalValueError::new( - CanonicalValueErrorKind::UnsupportedCbor, - "indefinite or reserved canonical CBOR length is unsupported", - )), - } - } - - fn length(&mut self, additional: u8) -> Result { - let declared = self.argument(additional)?; - let remaining = u64::try_from(self.remaining()).map_err(|_| { - CanonicalValueError::new( - CanonicalValueErrorKind::UnsupportedCbor, - "remaining canonical CBOR input does not fit the CBOR uint range", - ) - })?; - - checked_collection_length::(declared, remaining) - } - - fn byte(&mut self) -> Result { - let Some(value) = self.bytes.get(self.position).copied() else { - return Err(CanonicalValueError::new( - CanonicalValueErrorKind::UnexpectedEof, - "canonical CBOR expected another byte", - )); - }; - self.position += 1; - Ok(value) - } - - fn take(&mut self, length: usize) -> Result<&'a [u8], CanonicalValueError> { - let end = self.position.checked_add(length).ok_or_else(|| { - CanonicalValueError::new( - CanonicalValueErrorKind::UnexpectedEof, - "canonical CBOR length overflowed the input position", - ) - })?; - let Some(bytes) = self.bytes.get(self.position..end) else { - return Err(CanonicalValueError::new( - CanonicalValueErrorKind::UnexpectedEof, - "canonical CBOR value extends past the input", - )); - }; - self.position = end; - Ok(bytes) - } - - fn take_array(&mut self) -> Result<[u8; LENGTH], CanonicalValueError> { - let mut output = [0u8; LENGTH]; - output.copy_from_slice(self.take(LENGTH)?); - Ok(output) - } -} - -#[cfg(test)] -mod tests { - use super::{checked_collection_length, CanonicalValueError, CanonicalValueErrorKind}; - - #[test] - fn declared_length_bounds_precede_host_width_conversion() { - assert_eq!( - checked_collection_length::(u64::MAX, u64::from(u32::MAX)), - Err(CanonicalValueError::new( - CanonicalValueErrorKind::UnexpectedEof, - "canonical CBOR declared length exceeds the remaining bytes" - )) - ); - } -} +pub use echo_edict_canonical::*; diff --git a/crates/echo-wesley-gen/src/provider_corpus.rs b/crates/echo-wesley-gen/src/provider_corpus.rs index f2d31339..86e3c215 100644 --- a/crates/echo-wesley-gen/src/provider_corpus.rs +++ b/crates/echo-wesley-gen/src/provider_corpus.rs @@ -349,9 +349,17 @@ pub fn build_provider_generator_source_bundle_v1( /// contract. No filesystem or environment discovery is performed. pub fn checked_provider_generator_source_bundle_v1( ) -> Result { - let sources: [(&str, &[u8]); 14] = [ + let sources: [(&str, &[u8]); 16] = [ ("Cargo.lock", include_bytes!("../../../Cargo.lock")), ("Cargo.toml", include_bytes!("../../../Cargo.toml")), + ( + "crates/echo-edict-canonical/Cargo.toml", + include_bytes!("../../echo-edict-canonical/Cargo.toml"), + ), + ( + "crates/echo-edict-canonical/src/lib.rs", + include_bytes!("../../echo-edict-canonical/src/lib.rs"), + ), ( "crates/echo-wesley-gen/Cargo.toml", include_bytes!("../Cargo.toml"), diff --git a/crates/echo-wesley-gen/tests/provider_artifact_corpus.rs b/crates/echo-wesley-gen/tests/provider_artifact_corpus.rs index 5d0d63f0..c18ce001 100644 --- a/crates/echo-wesley-gen/tests/provider_artifact_corpus.rs +++ b/crates/echo-wesley-gen/tests/provider_artifact_corpus.rs @@ -38,9 +38,11 @@ const CONTRACT_CDDL: &[u8] = const CONTRACT_MANIFEST: &[u8] = include_bytes!("../../../schemas/edict-provider/contracts/v1/manifest.json"); -const GENERATOR_SOURCE_PATHS: [&str; 14] = [ +const GENERATOR_SOURCE_PATHS: [&str; 16] = [ "Cargo.lock", "Cargo.toml", + "crates/echo-edict-canonical/Cargo.toml", + "crates/echo-edict-canonical/src/lib.rs", "crates/echo-wesley-gen/Cargo.toml", "crates/echo-wesley-gen/src/bin/echo-edict-provider-artifacts.rs", "crates/echo-wesley-gen/src/lib.rs", diff --git a/det-policy.yaml b/det-policy.yaml index b1e7527f..bb92a2d3 100644 --- a/det-policy.yaml +++ b/det-policy.yaml @@ -39,6 +39,14 @@ crates: class: DET_CRITICAL owner_role: "Architect" paths: ["crates/echo-wasm-abi/**"] + echo-edict-canonical: + class: DET_CRITICAL + owner_role: "Tooling Engineer" + paths: ["crates/echo-edict-canonical/**"] + echo-edict-provider-lowerer: + class: DET_CRITICAL + owner_role: "Tooling Engineer" + paths: ["crates/echo-edict-provider-lowerer/**", "schemas/edict-provider/components/v1/**", "tests/edict-provider-host-v1/**"] echo-runtime-schema: class: DET_CRITICAL owner_role: "Architect" diff --git a/docs/architecture/application-contract-hosting.md b/docs/architecture/application-contract-hosting.md index ee3a0f95..e914c2b5 100644 --- a/docs/architecture/application-contract-hosting.md +++ b/docs/architecture/application-contract-hosting.md @@ -192,6 +192,32 @@ manifest separately binds the exact provider-owned components and their frozen WIT world attestations, preserving independent lowerers and component upgrades without target-profile semantic churn. +The first executable lowerer implements the frozen +`edict:target-provider/lowerer@1.0.0` world as a pure component over explicit +request bytes. It recognizes only the checked mutation closure for local Core +intent `t` (global semantic coordinate `a.b@1.t`) and emits the exact canonical +Target IR bytes produced by Edict's built-in Echo compatibility wrapper. An +effect-free operation is refused as unsupported semantics rather than encoded +as a synthetic mutation. The same fail-closed rule applies to a rebound Core +module, changed operation type binding, or authored Core optic that this first +crossing cannot faithfully discharge. The lowerer authenticates the complete +reviewed Core type-definition map and exact evaluation budget because neither +may disappear or broaden across Target IR projection. It also derives bounded +pre-effect, obstruction-arm, and post-effect local scopes from exactly one input, +one effect-result, and one obstruction declaration. Every local expression must +resolve to one complete compiler-owned identity before the expression is copied. +This first closure admits no input constraints and only the reviewed +zero-argument `domain.WriteRejected` obstruction constructor; non-empty +constraints or a different construction fail closed until their own semantic +crossings are implemented. The complete semantic closure includes the exact +lowerability coordinate as well as its canonical bytes and digest. +The component's protocol instance and request/result aliases are type-only +imports; filesystem, network, environment, clock, randomness, registry, +logging callback, WASI, and every other callable import are forbidden. This +crossing proves translation only. Package admission, installation, invocation, +commitment, observation, and receipt authority remain separate Echo runtime +crossings. + The package-root projection pins `echo.edict-provider@1` to exact component world `edict:target-provider@1.0.0`. Generated provenance is a generic Edict `generationProvenance` package member whose document contract remains owned by diff --git a/schemas/edict-provider/README.md b/schemas/edict-provider/README.md index ba085b35..5f7c31d4 100644 --- a/schemas/edict-provider/README.md +++ b/schemas/edict-provider/README.md @@ -147,6 +147,14 @@ contract documents. They are not executable component bytes. Issue #655 binds the exact lowerer and verifier components, including their frozen WIT world attestations, when it assembles the provider manifest. +The first checked executable lowerer now lives under +[`components/v1/`](components/v1/README.md). It consumes only the frozen Edict +WIT request, accepts the exact generated mutation closure, and has exact Target +IR byte parity with Edict's built-in Echo wrapper. Unsupported reads and other +semantics produce typed refusals instead of invented artifacts. The checked +component remains uninstalled package material; neither it nor the generated +authority-facts documents are runtime Echo authority. + External Edict contract inputs come from the checked [`contracts/v1/`](contracts/v1/README.md) publication merged in [Edict PR #162](https://github.com/flyingrobots/edict/pull/162). Echo passes the diff --git a/schemas/edict-provider/components/v1/README.md b/schemas/edict-provider/components/v1/README.md new file mode 100644 index 00000000..622e1980 --- /dev/null +++ b/schemas/edict-provider/components/v1/README.md @@ -0,0 +1,72 @@ + + + +# Echo Edict Provider Components v1 + +This directory checks the first executable Echo provider component for the +frozen Edict target-provider world. Issue #655 will bind this exact component +into the digest-locked provider package; its presence here does not claim that +Echo has installed, admitted, authorized, or executed it. + +`lowerer.echo-dpo.component.wasm` implements +`edict:target-provider/lowerer@1.0.0`. It was built from +`echo-edict-provider-lowerer` in the `linux/amd64` image +`docker.io/library/rust@sha256:3914072ca0c3b8aad871db9169a651ccfce30cf58303e5d6f2db16d1d8a7e58f`. +Inside that immutable builder, the absolute rustup-resolved Rust 1.90.0 compiler +is commit `1159e78c4747b02ef996e55082b704c09b970588` and Cargo is commit +`840b83a10fb0e039a83f4d70ad032892c287570a`. The build binds Cargo to that +exact compiler, explicitly disables compiler wrappers, creates a controlled +Cargo home beneath the build target, removes ambient Cargo profile/build/target +overrides, and remaps that dependency source root to `/cargo`. The core module +is componentized with `wit-component` 0.251.0. +The source WIT is the exact 7,392-byte Edict contract with SHA-256 +`2971fe44def7e51d5271dfc0f04f3088aa58754cffdc847681a587605aac749e`. + +The checked component is 130,679 bytes with SHA-256 +`03edee44c6bc70eb998c0c17662a214809746af3bba0740f3407c18a4016309e`. +Its sole contract attestation is the top-level custom section +`edict:target-provider-contract` containing +`edict:target-provider/lowerer@1.0.0`. Its only imports are the frozen WIT's +non-callable protocol instance and equality-bounded request/result type aliases; +its only callable world export is `lower`. It has no core, WASI, or ambient +capability imports. + +In the designated immutable `linux/amd64` Rust 1.90.0 builder above, rebuild and +check the artifact without rewriting it: + +```sh +cargo +1.90.0 xtask provider-lowerer-component check \ + --target-dir target/provider-lowerer-component \ + --output schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm +``` + +Two independently provisioned containers with fresh target and controlled Cargo +home directories must produce byte-identical component bytes. Builds on other +hosts are local structural and semantic witnesses; Rust/LLVM cross-host code +generation is not claimed to be byte-identical. The standalone Edict-host +witness audits and invokes the checked +portable component and separately proves invocation parity, typed refusal, host +failure separation, replay equality, and cross-process determinism. Exact +component identity is build evidence for this translation crossing only; it is +not runtime Echo authority. + +After two designated-builder candidates compare exactly, promote either explicit +candidate through the same structural admission boundary: + +```sh +cargo +1.90.0 xtask provider-lowerer-component promote \ + --candidate-a /explicit/build-a/lowerer.echo-dpo.component.wasm \ + --candidate-b /explicit/build-b/lowerer.echo-dpo.component.wasm \ + --output schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm \ + --write +``` + +Promotion performs no discovery. It requires two distinct underlying files, +exact byte equality, the repository-approved SHA-256 identity above, and complete +component/world admission. It then synchronizes a same-directory temporary file +and atomically replaces a regular output; symlink and non-regular outputs fail +closed. The one-build `designated-build` command refuses this checked repository +path, so a refresh must cross the promotion boundary. The command does not infer +how the two files were produced. G4's two separately provisioned designated +container jobs and exact source checkouts—or equivalent explicit operator +evidence—establish independent build provenance. diff --git a/schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm b/schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm new file mode 100644 index 00000000..a85ae30a Binary files /dev/null and b/schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm differ diff --git a/schemas/edict-provider/generated/v1/evidence/provenance.provider-generation.json b/schemas/edict-provider/generated/v1/evidence/provenance.provider-generation.json index 42f527a3..490f121c 100644 --- a/schemas/edict-provider/generated/v1/evidence/provenance.provider-generation.json +++ b/schemas/edict-provider/generated/v1/evidence/provenance.provider-generation.json @@ -1 +1 @@ -{"apiVersion":"wesley.generation-provenance-manifest/v1","contractVersions":{"generatorAbi":"wesley.extension-generator/v1","inputSchema":"wesley.extension-generation-input/v1","provenanceSchema":"wesley.generation-provenance-manifest/v1"},"emittedArtifacts":[{"coordinate":"echo.dpo-authority-facts@1","digest":"sha256:d17b03810ecc53f288aa1de457a5ba295c537c4f64046f8e3777b8f98ff3fc86"},{"coordinate":"echo.dpo-lawpack-authority-facts@1","digest":"sha256:3911de5075d3709a3ba40419e4b67f1226961f3520ba9dfbbad78278c9bb0e96"},{"coordinate":"echo.dpo-lawpack@1","digest":"sha256:df62a4ff2b56f9553c80cf400728cab3717f5f442c4c2fc415d2c89c21c41dad"},{"coordinate":"echo.dpo.registration/v1","digest":"sha256:8cc385a3f287ad6ea522766d7b4e92bc5164226586eef6b3f2ac6c5253370dd3"},{"coordinate":"echo.dpo@1","digest":"sha256:95626e5be6e6b2c1c8aa1858277f1c67487ab6724b08408eb3c0054adce6b1eb"},{"coordinate":"echo.provider-artifacts.cddl@1","digest":"sha256:19901cc33bea0699334af3cb4f9889f752652e35b81a2a9c91e7e31a52b803af"}],"generationInputDigest":"sha256:326024c55f16dfedfc887b11c24b6f6b596b08ae6b1abcc3a7269dc7728d9730","generator":{"coordinate":"echo-wesley-gen.provider-artifact-generator@1","digest":"sha256:13cc7656796d3f3b24a35fc3b565893d2e2f6ec018ead60d5bc7a4d5d7f894dc","version":"0.1.0"},"settingsDigest":"sha256:0f708e76898de6fdb8186352e81d0f5c445adf94bb6c7de9204952d9fe913d4a","sourceArtifacts":[{"coordinate":"echo.semantic-schema@1","digest":"sha256:656a68b126669a629643302d679712626a90dbd7a38b521932630a141fe426ac"},{"coordinate":"edict.provider-contract-pack.cddl@1","digest":"sha256:92697bc9a5262c68258be9ee451ee8c144aeb363b92142915b8224430b85cf74"},{"coordinate":"edict.provider-contract-pack.manifest@1","digest":"sha256:6902467149fec3e0338bb90e8cd7963ee21b8ce24f368f9b12e748343cbe0e4f"}]} \ No newline at end of file +{"apiVersion":"wesley.generation-provenance-manifest/v1","contractVersions":{"generatorAbi":"wesley.extension-generator/v1","inputSchema":"wesley.extension-generation-input/v1","provenanceSchema":"wesley.generation-provenance-manifest/v1"},"emittedArtifacts":[{"coordinate":"echo.dpo-authority-facts@1","digest":"sha256:d17b03810ecc53f288aa1de457a5ba295c537c4f64046f8e3777b8f98ff3fc86"},{"coordinate":"echo.dpo-lawpack-authority-facts@1","digest":"sha256:3911de5075d3709a3ba40419e4b67f1226961f3520ba9dfbbad78278c9bb0e96"},{"coordinate":"echo.dpo-lawpack@1","digest":"sha256:df62a4ff2b56f9553c80cf400728cab3717f5f442c4c2fc415d2c89c21c41dad"},{"coordinate":"echo.dpo.registration/v1","digest":"sha256:8cc385a3f287ad6ea522766d7b4e92bc5164226586eef6b3f2ac6c5253370dd3"},{"coordinate":"echo.dpo@1","digest":"sha256:95626e5be6e6b2c1c8aa1858277f1c67487ab6724b08408eb3c0054adce6b1eb"},{"coordinate":"echo.provider-artifacts.cddl@1","digest":"sha256:19901cc33bea0699334af3cb4f9889f752652e35b81a2a9c91e7e31a52b803af"}],"generationInputDigest":"sha256:326024c55f16dfedfc887b11c24b6f6b596b08ae6b1abcc3a7269dc7728d9730","generator":{"coordinate":"echo-wesley-gen.provider-artifact-generator@1","digest":"sha256:9bac584554dfedf119f835b056bb56e7051b7e7dfe6275f2e863a66bd49638be","version":"0.1.0"},"settingsDigest":"sha256:0f708e76898de6fdb8186352e81d0f5c445adf94bb6c7de9204952d9fe913d4a","sourceArtifacts":[{"coordinate":"echo.semantic-schema@1","digest":"sha256:656a68b126669a629643302d679712626a90dbd7a38b521932630a141fe426ac"},{"coordinate":"edict.provider-contract-pack.cddl@1","digest":"sha256:92697bc9a5262c68258be9ee451ee8c144aeb363b92142915b8224430b85cf74"},{"coordinate":"edict.provider-contract-pack.manifest@1","digest":"sha256:6902467149fec3e0338bb90e8cd7963ee21b8ce24f368f9b12e748343cbe0e4f"}]} \ No newline at end of file diff --git a/schemas/edict-provider/generated/v1/evidence/review.provider-generation.json b/schemas/edict-provider/generated/v1/evidence/review.provider-generation.json index 2f1ee704..d0564d71 100644 --- a/schemas/edict-provider/generated/v1/evidence/review.provider-generation.json +++ b/schemas/edict-provider/generated/v1/evidence/review.provider-generation.json @@ -1 +1 @@ -{"apiVersion":"wesley.generation-review/v1","authoritative":false,"emittedArtifacts":[{"coordinate":"echo.dpo-authority-facts@1","digest":"sha256:d17b03810ecc53f288aa1de457a5ba295c537c4f64046f8e3777b8f98ff3fc86"},{"coordinate":"echo.dpo-lawpack-authority-facts@1","digest":"sha256:3911de5075d3709a3ba40419e4b67f1226961f3520ba9dfbbad78278c9bb0e96"},{"coordinate":"echo.dpo-lawpack@1","digest":"sha256:df62a4ff2b56f9553c80cf400728cab3717f5f442c4c2fc415d2c89c21c41dad"},{"coordinate":"echo.dpo.registration/v1","digest":"sha256:8cc385a3f287ad6ea522766d7b4e92bc5164226586eef6b3f2ac6c5253370dd3"},{"coordinate":"echo.dpo@1","digest":"sha256:95626e5be6e6b2c1c8aa1858277f1c67487ab6724b08408eb3c0054adce6b1eb"},{"coordinate":"echo.provider-artifacts.cddl@1","digest":"sha256:19901cc33bea0699334af3cb4f9889f752652e35b81a2a9c91e7e31a52b803af"}],"generationInputDigest":"sha256:326024c55f16dfedfc887b11c24b6f6b596b08ae6b1abcc3a7269dc7728d9730","generator":{"coordinate":"echo-wesley-gen.provider-artifact-generator@1","digest":"sha256:13cc7656796d3f3b24a35fc3b565893d2e2f6ec018ead60d5bc7a4d5d7f894dc","version":"0.1.0"},"projectionRoles":["authority-facts.echo-dpo","authority-facts.echo-lawpack","generated-artifact-profile.echo-dpo-registration","lawpack.echo-dpo","schema.echo-provider-artifacts","target-profile.echo-dpo"],"provenanceManifestDigest":"sha256:8ab2000629f7bd7778fb696845309f64837930d0a83689bc18fea2d3ac2c69f9","sourceArtifacts":[{"coordinate":"echo.semantic-schema@1","digest":"sha256:656a68b126669a629643302d679712626a90dbd7a38b521932630a141fe426ac"},{"coordinate":"edict.provider-contract-pack.cddl@1","digest":"sha256:92697bc9a5262c68258be9ee451ee8c144aeb363b92142915b8224430b85cf74"},{"coordinate":"edict.provider-contract-pack.manifest@1","digest":"sha256:6902467149fec3e0338bb90e8cd7963ee21b8ce24f368f9b12e748343cbe0e4f"}]} \ No newline at end of file +{"apiVersion":"wesley.generation-review/v1","authoritative":false,"emittedArtifacts":[{"coordinate":"echo.dpo-authority-facts@1","digest":"sha256:d17b03810ecc53f288aa1de457a5ba295c537c4f64046f8e3777b8f98ff3fc86"},{"coordinate":"echo.dpo-lawpack-authority-facts@1","digest":"sha256:3911de5075d3709a3ba40419e4b67f1226961f3520ba9dfbbad78278c9bb0e96"},{"coordinate":"echo.dpo-lawpack@1","digest":"sha256:df62a4ff2b56f9553c80cf400728cab3717f5f442c4c2fc415d2c89c21c41dad"},{"coordinate":"echo.dpo.registration/v1","digest":"sha256:8cc385a3f287ad6ea522766d7b4e92bc5164226586eef6b3f2ac6c5253370dd3"},{"coordinate":"echo.dpo@1","digest":"sha256:95626e5be6e6b2c1c8aa1858277f1c67487ab6724b08408eb3c0054adce6b1eb"},{"coordinate":"echo.provider-artifacts.cddl@1","digest":"sha256:19901cc33bea0699334af3cb4f9889f752652e35b81a2a9c91e7e31a52b803af"}],"generationInputDigest":"sha256:326024c55f16dfedfc887b11c24b6f6b596b08ae6b1abcc3a7269dc7728d9730","generator":{"coordinate":"echo-wesley-gen.provider-artifact-generator@1","digest":"sha256:9bac584554dfedf119f835b056bb56e7051b7e7dfe6275f2e863a66bd49638be","version":"0.1.0"},"projectionRoles":["authority-facts.echo-dpo","authority-facts.echo-lawpack","generated-artifact-profile.echo-dpo-registration","lawpack.echo-dpo","schema.echo-provider-artifacts","target-profile.echo-dpo"],"provenanceManifestDigest":"sha256:83f38ea22697ef30dc61a13e705e40e897d67a15e207a685af62952840f37ae9","sourceArtifacts":[{"coordinate":"echo.semantic-schema@1","digest":"sha256:656a68b126669a629643302d679712626a90dbd7a38b521932630a141fe426ac"},{"coordinate":"edict.provider-contract-pack.cddl@1","digest":"sha256:92697bc9a5262c68258be9ee451ee8c144aeb363b92142915b8224430b85cf74"},{"coordinate":"edict.provider-contract-pack.manifest@1","digest":"sha256:6902467149fec3e0338bb90e8cd7963ee21b8ce24f368f9b12e748343cbe0e4f"}]} \ No newline at end of file diff --git a/scripts/ban-globals.sh b/scripts/ban-globals.sh index f5b8f423..46ba4bfe 100755 --- a/scripts/ban-globals.sh +++ b/scripts/ban-globals.sh @@ -18,13 +18,13 @@ set -euo pipefail # ./scripts/ban-globals.sh # # Optional env: -# BAN_GLOBALS_PATHS="crates/warp-core crates/warp-math crates/warp-wasm crates/echo-wasm-abi" +# BAN_GLOBALS_PATHS="crates/warp-core crates/warp-math crates/warp-wasm crates/echo-wasm-abi crates/echo-edict-canonical crates/echo-edict-provider-lowerer" # BAN_GLOBALS_ALLOWLIST=".ban-globals-allowlist" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -PATHS_DEFAULT="crates/warp-core crates/warp-math crates/warp-wasm crates/echo-wasm-abi" +PATHS_DEFAULT="crates/warp-core crates/warp-math crates/warp-wasm crates/echo-wasm-abi crates/echo-edict-canonical crates/echo-edict-provider-lowerer" PATHS="${BAN_GLOBALS_PATHS:-$PATHS_DEFAULT}" ALLOWLIST="${BAN_GLOBALS_ALLOWLIST:-.ban-globals-allowlist}" diff --git a/scripts/ban-nondeterminism.sh b/scripts/ban-nondeterminism.sh index e49c2bbe..fc7bed56 100755 --- a/scripts/ban-nondeterminism.sh +++ b/scripts/ban-nondeterminism.sh @@ -9,7 +9,7 @@ set -euo pipefail # ./scripts/ban-nondeterminism.sh # # Optional env: -# DETERMINISM_PATHS="crates/warp-core crates/warp-math crates/warp-wasm crates/echo-wasm-abi" +# DETERMINISM_PATHS="crates/warp-core crates/warp-math crates/warp-wasm crates/echo-wasm-abi crates/echo-edict-canonical crates/echo-edict-provider-lowerer" # DETERMINISM_ALLOWLIST=".ban-nondeterminism-allowlist" # # Every waiver is rule-scoped to an exact path and must explain why the @@ -19,7 +19,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" source scripts/lib/determinism-scan.sh -PATHS_DEFAULT="crates/warp-core crates/warp-math crates/warp-wasm crates/echo-wasm-abi" +PATHS_DEFAULT="crates/warp-core crates/warp-math crates/warp-wasm crates/echo-wasm-abi crates/echo-edict-canonical crates/echo-edict-provider-lowerer" PATHS="${DETERMINISM_PATHS:-$PATHS_DEFAULT}" ALLOWLIST="${DETERMINISM_ALLOWLIST:-.ban-nondeterminism-allowlist}" diff --git a/scripts/verify-edict-provider-host-v1.sh b/scripts/verify-edict-provider-host-v1.sh new file mode 100755 index 00000000..5f0f7af1 --- /dev/null +++ b/scripts/verify-edict-provider-host-v1.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS + +set -euo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +readonly ROOT +readonly MANIFEST="$ROOT/tests/edict-provider-host-v1/Cargo.toml" +readonly HOST_TARGET_DIR="$ROOT/target/edict-provider-host-v1" +readonly COMPONENT_TARGET_DIR="$ROOT/target/provider-lowerer-local" + +component="${ECHO_PROVIDER_LOWERER_COMPONENT:-schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm}" +if [[ "$component" != /* ]]; then + component="$ROOT/$component" +fi +readonly component + +cd "$ROOT" + +cargo +1.90.0 xtask provider-lowerer-component build \ + --target-dir "$COMPONENT_TARGET_DIR" +cargo +1.90.0 xtask provider-lowerer-component audit \ + --input "$component" + +cargo +1.94.0 fmt --manifest-path "$MANIFEST" --all -- --check + +ECHO_PROVIDER_LOWERER_COMPONENT="$component" \ + CARGO_TARGET_DIR="$HOST_TARGET_DIR" \ + cargo +1.94.0 test \ + --manifest-path "$MANIFEST" \ + --locked \ + --test host_contract + +CARGO_TARGET_DIR="$HOST_TARGET_DIR" \ + cargo +1.94.0 clippy \ + --manifest-path "$MANIFEST" \ + --locked \ + --all-targets \ + -- \ + -D warnings diff --git a/scripts/verify-local.sh b/scripts/verify-local.sh index ea0b389a..e8e542f7 100755 --- a/scripts/verify-local.sh +++ b/scripts/verify-local.sh @@ -257,6 +257,10 @@ readonly FULL_CRITICAL_PREFIXES=( "crates/warp-geom/" "crates/warp-wasm/" "crates/echo-wasm-abi/" + "crates/echo-edict-canonical/" + "crates/echo-edict-provider-lowerer/" + "schemas/edict-provider/components/v1/" + "tests/edict-provider-host-v1/" "crates/echo-scene-port/" "crates/echo-scene-codec/" "crates/echo-graph/" @@ -309,6 +313,8 @@ readonly FULL_LOCAL_PACKAGES=( "warp-geom" "warp-wasm" "echo-wasm-abi" + "echo-edict-canonical" + "echo-edict-provider-lowerer" "echo-scene-port" "echo-scene-codec" "echo-graph" @@ -320,6 +326,8 @@ readonly FULL_LOCAL_PACKAGES=( readonly FULL_LOCAL_TEST_PACKAGES=( "warp-geom" "warp-math" + "echo-edict-canonical" + "echo-edict-provider-lowerer" "echo-graph" "echo-scene-port" "echo-scene-codec" @@ -336,6 +344,8 @@ readonly FULL_LOCAL_CLIPPY_CORE_PACKAGES=( ) readonly FULL_LOCAL_CLIPPY_SUPPORT_PACKAGES=( + "echo-edict-canonical" + "echo-edict-provider-lowerer" "echo-scene-port" "echo-scene-codec" "echo-graph" @@ -352,6 +362,8 @@ readonly FULL_LOCAL_RUSTDOC_PACKAGES=( "warp-math" "warp-geom" "warp-wasm" + "echo-edict-canonical" + "echo-edict-provider-lowerer" ) readonly FAST_CLIPPY_LIB_ONLY_PACKAGES=( @@ -370,6 +382,7 @@ FULL_SCOPE_CLIPPY_SUPPORT_PACKAGES=() FULL_SCOPE_CLIPPY_BIN_ONLY_PACKAGES=() FULL_SCOPE_TEST_SUPPORT_PACKAGES=() FULL_SCOPE_RUSTDOC_PACKAGES=() +FULL_SCOPE_RUN_EDICT_PROVIDER_HOST=0 FULL_SCOPE_RUN_WARP_CORE_SMOKE=0 FULL_SCOPE_WARP_WASM_TEST_MODE="none" FULL_SCOPE_ECHO_WASM_ABI_RUN_LIB=0 @@ -636,6 +649,9 @@ list_changed_critical_crates() { while IFS= read -r file; do [[ -z "$file" ]] && continue case "$file" in + crates/echo-edict-provider-lowerer/wit/*|schemas/edict-provider/components/v1/*|tests/edict-provider-host-v1/*|scripts/verify-edict-provider-host-v1.sh) + printf '%s\n' "echo-edict-provider-lowerer" + ;; crates/*/Cargo.toml|crates/*/build.rs|crates/*/src/*|crates/*/tests/*) crate="$(printf '%s\n' "$file" | sed -n 's#^crates/\([^/]*\)/.*#\1#p')" [[ -z "$crate" ]] && continue @@ -1516,6 +1532,11 @@ prepare_full_scope() { FULL_SCOPE_ECHO_WASM_ABI_EXTRA_TESTS=() FULL_SCOPE_WARP_CORE_EXTRA_TESTS=() FULL_SCOPE_WARP_MATH_RUN_PRNG=0 + FULL_SCOPE_RUN_EDICT_PROVIDER_HOST=0 + + if array_contains "echo-edict-provider-lowerer" ${FULL_SCOPE_SELECTED_CRATES[@]+"${FULL_SCOPE_SELECTED_CRATES[@]}"}; then + FULL_SCOPE_RUN_EDICT_PROVIDER_HOST=1 + fi if array_contains "warp-core" ${FULL_SCOPE_SELECTED_CRATES[@]+"${FULL_SCOPE_SELECTED_CRATES[@]}"}; then FULL_SCOPE_RUN_WARP_CORE_SMOKE=1 @@ -1799,6 +1820,20 @@ run_full_lane_guards() { run_docs_lint } +run_full_lane_edict_provider_host() { + echo "[verify-local][edict-provider-host-v1] isolated Edict host contract" + scripts/verify-edict-provider-host-v1.sh +} + +run_full_lane_edict_provider_wasm_clippy() { + echo "[verify-local][clippy-edict-provider-wasm] strict wasm32 lowerer adapter" + rustup target add wasm32-unknown-unknown --toolchain "$PINNED" + lane_cargo "full-clippy-edict-provider-wasm" clippy \ + -p echo-edict-provider-lowerer \ + --target wasm32-unknown-unknown \ + --lib -- -D warnings -D missing_docs +} + run_full_checks_sequential() { echo "[verify-local] critical local gate (${FULL_SCOPE_MODE})" run_timed_step "fmt" run_full_lane_fmt @@ -1818,6 +1853,10 @@ run_full_checks_sequential() { if local_rustdoc_enabled; then run_timed_step "rustdoc" run_full_lane_rustdoc fi + if [[ "$FULL_SCOPE_RUN_EDICT_PROVIDER_HOST" == "1" ]]; then + run_timed_step "clippy-edict-provider-wasm" run_full_lane_edict_provider_wasm_clippy + run_timed_step "edict-provider-host-v1" run_full_lane_edict_provider_host + fi run_timed_step "guards" run_full_lane_guards } @@ -1854,6 +1893,10 @@ run_full_checks_parallel() { if local_rustdoc_enabled && [[ ${#FULL_SCOPE_RUSTDOC_PACKAGES[@]} -gt 0 ]]; then lanes+=("rustdoc" run_full_lane_rustdoc) fi + if [[ "$FULL_SCOPE_RUN_EDICT_PROVIDER_HOST" == "1" ]]; then + lanes+=("clippy-edict-provider-wasm" run_full_lane_edict_provider_wasm_clippy) + lanes+=("edict-provider-host-v1" run_full_lane_edict_provider_host) + fi run_parallel_lanes "${lanes[@]}" } diff --git a/tests/edict-provider-host-v1/Cargo.lock b/tests/edict-provider-host-v1/Cargo.lock new file mode 100644 index 00000000..d0f223da --- /dev/null +++ b/tests/edict-provider-host-v1/Cargo.lock @@ -0,0 +1,1372 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cddl-cat" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0def7310489015a41757b6ae8a0126ad1d91c4a3f77089f862eea7c000638825" +dependencies = [ + "base64", + "ciborium", + "escape8259", + "float-ord", + "hex", + "nom", + "regex", + "serde", + "strum_macros", + "thiserror 1.0.69", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e06aeba2c965fc446d13c56a6ccb2631b78445d7544543dd9a25289977630914" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee2d2dde4ec1352715595b5cfa6fe2e5b8ebb9da3457b3ee8db0aa2808c069aa" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b4982ef9fa54ec9eee841e891e7ddc5434be1250e88de31572e000c888f30b" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529143118c4eeb58c39ecb02319557d512be6c61348486422974ab8e3906b8a8" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7780677247ad3577e3a6a3ebf43f39b325a11d6393db72b2c9968a910d4d13d" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9645250416cbf92454fe61160e17e026e0ce405906a54500b114f923ddffc9" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck 0.5.0", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ee8d222ff0fd3681791979afbf88586ac9f49010d3db96b3cbe4c96759aee3" + +[[package]] +name = "cranelift-control" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591abe6f5312bd2c4220f1b3bead56c2ad00257c52668015ba013b85dcf2a17a" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5300c49cf940526fe771517b3b3eabd5d0ff164ee61698579cf403fe8d3af3c" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da4adbf760207fdbbe130f1191cce01cdef66831a9f648b1f39ff2800d126d45" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.1", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8315b21ff018226a42a60a4702c2dd75f6447cac26e9bca622e14c22088c2ff5" + +[[package]] +name = "cranelift-native" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d506ef23a60715bde451b06620b14402166ded3b648454fccbf04f3e46a4aa70" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.133.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48ed47e602652e3410f9387fc0db70fefadcee4d78a78881421aabcab4e26b89" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "echo-edict-provider-host-v1-witness" +version = "0.0.0" +dependencies = [ + "edict-provider-host-wasmtime", + "edict-provider-schema", + "edict-syntax", + "sha2", +] + +[[package]] +name = "edict-provider-host-wasmtime" +version = "0.11.0-alpha.1" +source = "git+https://github.com/flyingrobots/edict?rev=c75c3f550d049485ba00eae0dc272c6dd6aca11f#c75c3f550d049485ba00eae0dc272c6dd6aca11f" +dependencies = [ + "edict-provider-schema", + "edict-syntax", + "sha2", + "wasmparser", + "wasmtime", +] + +[[package]] +name = "edict-provider-schema" +version = "0.11.0-alpha.1" +source = "git+https://github.com/flyingrobots/edict?rev=c75c3f550d049485ba00eae0dc272c6dd6aca11f#c75c3f550d049485ba00eae0dc272c6dd6aca11f" +dependencies = [ + "cddl-cat", + "ciborium", + "edict-syntax", + "sha2", +] + +[[package]] +name = "edict-syntax" +version = "0.11.0-alpha.1" +source = "git+https://github.com/flyingrobots/edict?rev=c75c3f550d049485ba00eae0dc272c6dd6aca11f#c75c3f550d049485ba00eae0dc272c6dd6aca11f" +dependencies = [ + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "escape8259" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap", + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulley-interpreter" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b92604caae1a1899b6a5b54967289dd538177c626004c91accf9d0ec7e4a12" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a7ac85c0bb3fb351f10d531230aaa5e366b46d7c4e5328e5f02801d6dac1165" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regalloc2" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "serde", + "smallvec", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum_macros" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bb0dc7ee9c15cea6199cde9a127fa16a4c5819af85395457ad72d68edc85a38" +dependencies = [ + "heck 0.3.3", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmprinter" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8798c1a699bd25648b6708eefe94d97c6f9891febb94b42cca1f7a4b086ea64e" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser", +] + +[[package]] +name = "wasmtime" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4213d2f019a5e44aa8a61d8826dd33a505bff79f749b14a8bafd67321cb9351" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "futures", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rustix", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "windows-sys", +] + +[[package]] +name = "wasmtime-environ" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45863de41977ec6453e859cf843d456fa3fcb45a659b66d16e794f90ec4f5b7" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasm-encoder", + "wasmparser", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1e48f8d4966d62a10b6d70722bc432c1e163890be2801d3b5784589ad36ffc3" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ad5abd5822a22dbf4014475cdfd1fe790707761cd732d74aaa3ba4d5ba489" + +[[package]] +name = "wasmtime-internal-core" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fc28372e36eaf8cf70faa83b5779137f7e99c8d18569a125d1580e735cc9e4d" +dependencies = [ + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a433efc6e35112a5457e1dc8bc4d8d39820ac7722267e89bc04e5df641f32124" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a1d3a39d0d210f6b8574ee96a4315e0a14c67f3a1fc3cd5372cb10d2fb4422" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f667288cb4dfa68a4639ffac4d5628535dda64ebdc2b990526efb12b30ba803" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba651d44ab0faad4c58106b3adb45068189fb65ef50f0c404b6d9e3bf81a357" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecc52563b0558af2a7487eb710de07cc4532564b55528876129238e83118cb1" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e747f4a074699ba1b4e4d841fb263f9b7df5bd1555181c4752bf5990d21ba676" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80009f46991622814196d96fac6fc0a938f46b5cba737a8f4e21e24e5a03856f" +dependencies = [ + "anyhow", + "bitflags", + "heck 0.5.0", + "indexmap", + "wit-parser", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-parser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e960732e824fab95099971a09e638979347c94ca48568d3c854c945729196947" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/edict-provider-host-v1/Cargo.toml b/tests/edict-provider-host-v1/Cargo.toml new file mode 100644 index 00000000..567d8f16 --- /dev/null +++ b/tests/edict-provider-host-v1/Cargo.toml @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS + +[package] +name = "echo-edict-provider-host-v1-witness" +version = "0.0.0" +edition = "2021" +rust-version = "1.94" +license = "Apache-2.0" +publish = false + +[workspace] +resolver = "2" + +[dependencies] +edict-provider-host-wasmtime = { git = "https://github.com/flyingrobots/edict", rev = "c75c3f550d049485ba00eae0dc272c6dd6aca11f" } +edict-provider-schema = { git = "https://github.com/flyingrobots/edict", rev = "c75c3f550d049485ba00eae0dc272c6dd6aca11f" } +edict-syntax = { git = "https://github.com/flyingrobots/edict", rev = "c75c3f550d049485ba00eae0dc272c6dd6aca11f" } +sha2 = "=0.10.9" + +[lints.rust] +missing_docs = "deny" +unsafe_code = "deny" + +[lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +dbg_macro = "deny" +print_stdout = "deny" +print_stderr = "deny" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +too_many_lines = "allow" +multiple_crate_versions = "allow" diff --git a/tests/edict-provider-host-v1/fixtures/edict-7cd8858c/ORIGIN.toml b/tests/edict-provider-host-v1/fixtures/edict-7cd8858c/ORIGIN.toml new file mode 100644 index 00000000..c710cedd --- /dev/null +++ b/tests/edict-provider-host-v1/fixtures/edict-7cd8858c/ORIGIN.toml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS + +repository = "https://github.com/flyingrobots/edict" +commit = "7cd8858c577fcfb6a05f0f617dfa821bb183c7df" +license = "Apache-2.0" + +[[artifact]] +file = "lowerer.component.wasm" +source = "fixtures/providers/components/lowerer.component.wasm" +bytes = 28007 +sha256 = "bec69c4fa02aa2dfb4f492d4a1c6849ae4ebe81c1725477efb6b0f2e885676aa" + +[[artifact]] +file = "malformed-lowerer.component.wasm" +source = "fixtures/providers/components/malformed-lowerer.component.wasm" +bytes = 2165 +sha256 = "dfcd171918373d18b9dff16778e98b7618eeb4ac85976dd7134b9e201562f41b" diff --git a/tests/edict-provider-host-v1/fixtures/edict-7cd8858c/lowerer.component.wasm b/tests/edict-provider-host-v1/fixtures/edict-7cd8858c/lowerer.component.wasm new file mode 100644 index 00000000..d9af894b Binary files /dev/null and b/tests/edict-provider-host-v1/fixtures/edict-7cd8858c/lowerer.component.wasm differ diff --git a/tests/edict-provider-host-v1/fixtures/edict-7cd8858c/malformed-lowerer.component.wasm b/tests/edict-provider-host-v1/fixtures/edict-7cd8858c/malformed-lowerer.component.wasm new file mode 100644 index 00000000..be873af8 Binary files /dev/null and b/tests/edict-provider-host-v1/fixtures/edict-7cd8858c/malformed-lowerer.component.wasm differ diff --git a/tests/edict-provider-host-v1/rust-toolchain.toml b/tests/edict-provider-host-v1/rust-toolchain.toml new file mode 100644 index 00000000..2a13a48b --- /dev/null +++ b/tests/edict-provider-host-v1/rust-toolchain.toml @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS + +[toolchain] +channel = "1.94.0" +profile = "minimal" +components = ["clippy", "rustfmt"] diff --git a/tests/edict-provider-host-v1/src/lib.rs b/tests/edict-provider-host-v1/src/lib.rs new file mode 100644 index 00000000..c411c4df --- /dev/null +++ b/tests/edict-provider-host-v1/src/lib.rs @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Rust-1.94-isolated witness for Echo's Edict provider component. diff --git a/tests/edict-provider-host-v1/tests/host_contract.rs b/tests/edict-provider-host-v1/tests/host_contract.rs new file mode 100644 index 00000000..732b90cb --- /dev/null +++ b/tests/edict-provider-host-v1/tests/host_contract.rs @@ -0,0 +1,1084 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +#![allow( + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::unwrap_used +)] +//! Standalone Rust 1.94 witness for the frozen Edict provider-host contract. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; + +use edict_provider_host_wasmtime::{ + PreparedProviderComponent, ProviderComponentHost, ProviderHostFailureKind, ProviderHostLimits, + ProviderHostPhase, ProviderReplayObservation, ResolvedProviderComponent, +}; +use edict_provider_schema::{ProviderArtifactSchemaRegistry, ResolvedProviderSchemaArtifact}; +use edict_syntax::{ + bind_target_provider_manifest, compile_to_core, decode_canonical_cbor, + digest_target_ir_artifact, encode_canonical_cbor, encode_core_module, + encode_target_ir_artifact, lower_with_builtin_lowerer, parse_module, select_provider_component, + validate_provider_lowering_request, BuiltinLowererRequest, BuiltinTargetLowerer, + CanonicalValue, CompilerContext, CoreBudget, CoreModule, ProviderArtifact, + ProviderArtifactBinding, ProviderArtifactKind, ProviderArtifactRef, ProviderArtifactSource, + ProviderBoundArtifact, ProviderDigest, ProviderDigestAlgorithm, ProviderInvocationKind, + ProviderInvocationValidationFailureKind, ProviderLoweringInvocationContract, + ProviderLoweringOutputKind, ProviderLoweringOutputRequest, ProviderLoweringRequest, + ProviderRefusalKind, ProviderResourceRef, ProviderResponseLimits, ProviderSchemaBinding, + ProviderSchemaFormat, ProviderSemanticInput, ProviderSemanticInputBinding, + ProviderSemanticInputKind, ResourceRef, TargetEffectLowering, TargetIrLoweringFacts, + TargetProviderManifest, ValidatedProviderLoweringRequest, WriteClass, + AUTHORITY_FACTS_API_VERSION, CORE_DIGEST_FRAME, CORE_MODULE_DIGEST_DOMAIN, + ECHO_DPO_TARGET_PROFILE, ECHO_SPAN_IR_DOMAIN, MAX_CANONICAL_NESTING_DEPTH, + PROVIDER_LAWPACK_ARTIFACT_DOMAIN, TARGET_IR_ARTIFACT_DIGEST_DOMAIN, TARGET_PROFILE_API_VERSION, + TARGET_PROVIDER_ABI, TARGET_PROVIDER_MANIFEST_API_VERSION, TARGET_PROVIDER_PROTOCOL_VERSION, +}; +use sha2::{Digest as _, Sha256}; + +const ECHO_SOURCE: &str = "package a.b@1;\n\ + type Input = { id: String, };\n\ + type Receipt = { id: String, };\n\ + type Output = { id: String, };\n\ + intent t(input: Input) returns Output\n\ + profile p.effectful\n\ + basis none\n\ + budget <= p.tiny {\n\ + let receipt: Receipt = target.replace(input.id)\n\ + else { rejected(reason) => domain.WriteRejected };\n\ + return { id: input.id };\n\ + }"; + +const LOWERABILITY_DOMAIN: &str = "edict.lowering-requirements/v1"; +const TARGET_IR_ROLE: &str = "target-ir.echo-dpo"; +const LOWERER_ROLE: &str = "lowerer.echo-dpo"; +const SCHEMA_ROLE: &str = "schema.echo-provider-artifacts"; +const RAW_TARGET_IR_SHA256: &str = + "41ae7a1d95e5068cb09ec581f16a90cc6e26a80f83ec073e86d5108c3a61ea41"; +const DOMAIN_TARGET_IR_SHA256: &str = + "b0d9e218f00a102d1e951c73e5063a9bbe6077e6c7468d171ec08b420e7b47da"; +const TARGET_PROFILE_SHA256: &str = + "f41df38156625a05c1ee8bce652ffddf04e71b54fe027eeab9d255d0d8322db0"; +const OBSERVATION_MARKER: &str = "ECHO_EDICT_HOST_OBSERVATION="; + +const SCHEMA_BYTES: &[u8] = include_bytes!( + "../../../schemas/edict-provider/generated/v1/primary/schema.echo-provider-artifacts.cddl" +); +const TARGET_PROFILE_BYTES: &[u8] = include_bytes!( + "../../../schemas/edict-provider/generated/v1/primary/target-profile.echo-dpo.cbor" +); +const LAWPACK_BYTES: &[u8] = + include_bytes!("../../../schemas/edict-provider/generated/v1/primary/lawpack.echo-dpo.cbor"); +const TARGET_AUTHORITY_BYTES: &[u8] = include_bytes!( + "../../../schemas/edict-provider/generated/v1/primary/authority-facts.echo-dpo.cbor" +); +const LAWPACK_AUTHORITY_BYTES: &[u8] = include_bytes!( + "../../../schemas/edict-provider/generated/v1/primary/authority-facts.echo-lawpack.cbor" +); +const FIXTURE_LOWERER_BYTES: &[u8] = + include_bytes!("../fixtures/edict-7cd8858c/lowerer.component.wasm"); +const FIXTURE_MALFORMED_BYTES: &[u8] = + include_bytes!("../fixtures/edict-7cd8858c/malformed-lowerer.component.wasm"); + +const FIXTURE_SCHEMA: &[u8] = br#" +artifact = null / { + kind: "targetIrArtifact", + domain: tstr, + intents: { * tstr => any }, + targetProfile: { * tstr => any }, + sourceCoreCoordinate: tstr, +} +"#; +const NULL_BYTES: &[u8] = &[0xf6]; +const FIXTURE_OUTPUT_DOMAIN: &str = "runtime.output/v1"; + +struct LowerHarness { + host: ProviderComponentHost, + prepared: PreparedProviderComponent<'static>, + request: ValidatedProviderLoweringRequest<'static>, + schema: &'static ProviderArtifactSchemaRegistry, +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("repository root resolves") +} + +fn echo_component_path() -> PathBuf { + let configured = std::env::var_os("ECHO_PROVIDER_LOWERER_COMPONENT").map(PathBuf::from); + let path = configured.unwrap_or_else(|| { + PathBuf::from("schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm") + }); + if path.is_absolute() { + path + } else { + repo_root().join(path) + } +} + +fn echo_component_bytes() -> &'static [u8] { + let path = echo_component_path(); + let bytes = std::fs::read(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + Box::leak(bytes.into_boxed_slice()) +} + +fn hex(bytes: &[u8]) -> String { + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + write!(&mut output, "{byte:02x}").expect("writing hexadecimal to String cannot fail"); + } + output +} + +fn raw_sha256(bytes: &[u8]) -> String { + hex(&Sha256::digest(bytes)) +} + +fn raw_resource(coordinate: &str, bytes: &[u8]) -> ResourceRef { + ResourceRef { + coordinate: coordinate.to_owned(), + digest: Some(format!("sha256:{}", raw_sha256(bytes))), + } +} + +fn locked_test_resource(coordinate: &str, digit: char) -> ResourceRef { + ResourceRef { + coordinate: coordinate.to_owned(), + digest: Some(format!("sha256:{}", digit.to_string().repeat(64))), + } +} + +fn provider_digest(domain: &str, canonical_bytes: &[u8]) -> ProviderDigest { + decode_canonical_cbor(canonical_bytes).expect("artifact is canonical CBOR"); + let mut framed = vec![0x83]; + framed + .extend(encode_canonical_cbor(&text(CORE_DIGEST_FRAME)).expect("digest frame tag encodes")); + framed.extend(encode_canonical_cbor(&text(domain)).expect("digest domain encodes")); + framed.extend_from_slice(canonical_bytes); + ProviderDigest { + algorithm: ProviderDigestAlgorithm::Sha256, + bytes: Sha256::digest(framed).to_vec(), + } +} + +fn bound_artifact(coordinate: &str, domain: &str, bytes: &[u8]) -> ProviderBoundArtifact { + ProviderBoundArtifact { + reference: ProviderResourceRef { + coordinate: coordinate.to_owned(), + digest: provider_digest(domain, bytes), + }, + artifact: ProviderArtifact { + domain: domain.to_owned(), + bytes: bytes.to_vec(), + }, + } +} + +fn artifact_binding(bound: &ProviderBoundArtifact) -> ProviderArtifactBinding { + ProviderArtifactBinding { + reference: bound.reference.clone(), + domain: bound.artifact.domain.clone(), + } +} + +fn text(value: &str) -> CanonicalValue { + CanonicalValue::Text(value.to_owned()) +} + +fn map(entries: impl IntoIterator) -> CanonicalValue { + CanonicalValue::Map( + entries + .into_iter() + .map(|(key, value)| (text(key), value)) + .collect(), + ) +} + +fn lowerability_bytes() -> Vec { + let value = map([ + ("apiVersion", text(LOWERABILITY_DOMAIN)), + ("operationProfile", text("continuum.profile.write/v1")), + ( + "semanticEffects", + CanonicalValue::Array(vec![map([ + ("coordinate", text("target.replace")), + ("writeClass", text("replace")), + ( + "guardKinds", + CanonicalValue::Array(vec![text("precommit-atomic")]), + ), + ( + "obstructionCoordinates", + CanonicalValue::Array(vec![text("rejected")]), + ), + ( + "footprintObligations", + CanonicalValue::Array(vec![text("target.replace.footprint")]), + ), + ( + "costObligations", + CanonicalValue::Array(vec![text("target.replace.cost")]), + ), + ])]), + ), + ( + "requiredWriteClasses", + CanonicalValue::Array(vec![text("replace")]), + ), + ( + "guardKinds", + CanonicalValue::Array(vec![text("precommit-atomic")]), + ), + ("atomicity", text("atomic")), + ("postconditionSupport", CanonicalValue::Bool(true)), + ( + "obstructionCoordinates", + CanonicalValue::Array(vec![text("rejected")]), + ), + ( + "footprintObligations", + CanonicalValue::Array(vec![text("target.replace.footprint")]), + ), + ( + "costObligations", + CanonicalValue::Array(vec![text("target.replace.cost")]), + ), + ("opticContract", text("replace-point")), + ]); + encode_canonical_cbor(&value).expect("lowerability facts encode canonically") +} + +fn echo_core() -> CoreModule { + let context = CompilerContext::new() + .with_operation_profile("p.effectful", "continuum.profile.write/v1") + .with_operation_profile_write_classes("p.effectful", [WriteClass::Replace]) + .with_effect_write_class("target.replace", WriteClass::Replace) + .with_budget( + "p.tiny", + CoreBudget { + max_steps: 8, + max_allocated_bytes: 1024, + max_output_bytes: 256, + }, + ); + let module = parse_module(ECHO_SOURCE).expect("Echo source parses"); + compile_to_core(&module, &context).expect("Echo source compiles to Core") +} + +fn semantic_input( + role: &str, + kind: ProviderSemanticInputKind, + coordinate: &str, + domain: &str, + bytes: &[u8], +) -> ProviderSemanticInput { + ProviderSemanticInput { + role: role.to_owned(), + kind, + artifact: bound_artifact(coordinate, domain, bytes), + } +} + +fn echo_manifest(component_bytes: &'static [u8]) -> &'static TargetProviderManifest { + let component = raw_resource("echo.dpo.lowerer/component@1", component_bytes); + let schema = raw_resource("echo.provider-artifacts.cddl@1", SCHEMA_BYTES); + Box::leak(Box::new(TargetProviderManifest { + api_version: TARGET_PROVIDER_MANIFEST_API_VERSION.to_owned(), + provider_abi: TARGET_PROVIDER_ABI.to_owned(), + provider: locked_test_resource("echo.edict-provider-host-witness@1", '1'), + artifacts: vec![ + ProviderArtifactRef { + role: LOWERER_ROLE.to_owned(), + artifact_kind: ProviderArtifactKind::Lowerer, + resource: component.clone(), + source: ProviderArtifactSource::Component { component }, + }, + ProviderArtifactRef { + role: SCHEMA_ROLE.to_owned(), + artifact_kind: ProviderArtifactKind::ArtifactSchema, + resource: schema, + source: ProviderArtifactSource::Generated { + semantic_source: locked_test_resource( + "echo.edict-provider-host-witness.schema-source@1", + '2', + ), + generator: locked_test_resource( + "echo.edict-provider-host-witness.schema-generator@1", + '3', + ), + }, + }, + ], + schema_bindings: [ + (AUTHORITY_FACTS_API_VERSION, "authority-facts"), + (CORE_MODULE_DIGEST_DOMAIN, "core-module"), + (PROVIDER_LAWPACK_ARTIFACT_DOMAIN, "lawpack-manifest"), + (LOWERABILITY_DOMAIN, "lowering-requirements"), + (TARGET_IR_ARTIFACT_DIGEST_DOMAIN, "target-ir-artifact"), + (TARGET_PROFILE_API_VERSION, "target-profile-manifest"), + ] + .into_iter() + .map(|(domain, root_rule)| ProviderSchemaBinding { + domain: domain.to_owned(), + schema_role: SCHEMA_ROLE.to_owned(), + format: ProviderSchemaFormat::SelfContainedCddlV1, + root_rule: root_rule.to_owned(), + }) + .collect(), + })) +} + +fn echo_registry( + manifest: &'static TargetProviderManifest, +) -> &'static ProviderArtifactSchemaRegistry { + let proof = bind_target_provider_manifest(manifest).expect("Echo provider manifest validates"); + Box::leak(Box::new( + ProviderArtifactSchemaRegistry::from_manifest( + &proof, + [ResolvedProviderSchemaArtifact { + role: SCHEMA_ROLE.to_owned(), + bytes: Arc::from(SCHEMA_BYTES), + }], + [ + AUTHORITY_FACTS_API_VERSION, + CORE_MODULE_DIGEST_DOMAIN, + PROVIDER_LAWPACK_ARTIFACT_DOMAIN, + LOWERABILITY_DOMAIN, + TARGET_IR_ARTIFACT_DIGEST_DOMAIN, + TARGET_PROFILE_API_VERSION, + ], + ) + .expect("Echo provider schema registry constructs"), + )) +} + +fn echo_request_from_core_bytes( + core_bytes: &[u8], + output_role: &str, +) -> (ProviderLoweringInvocationContract, ProviderLoweringRequest) { + let core_artifact = bound_artifact("a.b@1", CORE_MODULE_DIGEST_DOMAIN, core_bytes); + let target_profile_artifact = bound_artifact( + ECHO_DPO_TARGET_PROFILE, + TARGET_PROFILE_API_VERSION, + TARGET_PROFILE_BYTES, + ); + let lowerability = lowerability_bytes(); + let semantic_inputs = vec![ + semantic_input( + "authority-facts.echo-dpo", + ProviderSemanticInputKind::AuthorityFacts, + "echo.dpo-authority-facts@1", + AUTHORITY_FACTS_API_VERSION, + TARGET_AUTHORITY_BYTES, + ), + semantic_input( + "authority-facts.echo-lawpack", + ProviderSemanticInputKind::AuthorityFacts, + "echo.dpo-lawpack-authority-facts@1", + AUTHORITY_FACTS_API_VERSION, + LAWPACK_AUTHORITY_BYTES, + ), + semantic_input( + "lawpack.echo-dpo", + ProviderSemanticInputKind::Lawpack, + "echo.dpo-lawpack@1", + PROVIDER_LAWPACK_ARTIFACT_DOMAIN, + LAWPACK_BYTES, + ), + semantic_input( + "lowerability.echo-dpo", + ProviderSemanticInputKind::LowerabilityFacts, + "echo.dpo-lowerability@1", + LOWERABILITY_DOMAIN, + &lowerability, + ), + ]; + let contract = ProviderLoweringInvocationContract { + core: artifact_binding(&core_artifact), + target_profile: artifact_binding(&target_profile_artifact), + semantic_inputs: semantic_inputs + .iter() + .map(|input| ProviderSemanticInputBinding { + role: input.role.clone(), + kind: input.kind.clone(), + artifact: artifact_binding(&input.artifact), + }) + .collect(), + }; + let request = ProviderLoweringRequest { + protocol_version: TARGET_PROVIDER_PROTOCOL_VERSION, + core: core_artifact, + target_profile: target_profile_artifact, + semantic_inputs, + requested_outputs: vec![ProviderLoweringOutputRequest { + role: output_role.to_owned(), + kind: ProviderLoweringOutputKind::TargetIr, + domain: TARGET_IR_ARTIFACT_DIGEST_DOMAIN.to_owned(), + }], + limits: ProviderResponseLimits { + max_output_count: 8, + max_diagnostic_count: 8, + max_total_response_bytes: 64 * 1024, + }, + }; + (contract, request) +} + +fn echo_request( + core: &CoreModule, + output_role: &str, +) -> (ProviderLoweringInvocationContract, ProviderLoweringRequest) { + let core_bytes = encode_core_module(core).expect("Core module encodes canonically"); + echo_request_from_core_bytes(&core_bytes, output_role) +} + +fn canonical_map_field_mut<'a>( + value: &'a mut CanonicalValue, + field: &str, +) -> &'a mut CanonicalValue { + let CanonicalValue::Map(entries) = value else { + panic!("canonical value is not a map"); + }; + entries + .iter_mut() + .find_map(|(key, value)| (key == &text(field)).then_some(value)) + .unwrap_or_else(|| panic!("canonical map field `{field}` is absent")) +} + +fn canonical_core_intent_mut(core: &mut CanonicalValue) -> &mut CanonicalValue { + canonical_map_field_mut(canonical_map_field_mut(core, "intents"), "t") +} + +fn echo_harness_with_request( + contract: ProviderLoweringInvocationContract, + request: ProviderLoweringRequest, +) -> LowerHarness { + let component = echo_component_bytes(); + let manifest = echo_manifest(component); + let manifest_proof = Box::leak(Box::new( + bind_target_provider_manifest(manifest).expect("Echo provider manifest validates"), + )); + let selected = select_provider_component( + manifest_proof, + LOWERER_ROLE, + ProviderInvocationKind::Lowering, + ) + .expect("Echo lowerer selects"); + let resolved = ResolvedProviderComponent::new(selected, Arc::from(component)); + let host = ProviderComponentHost::new().expect("host configures"); + let prepared = host.prepare(&resolved).expect("Echo lowerer prepares"); + let schema = echo_registry(manifest); + let contract = Box::leak(Box::new(contract)); + let request = Box::leak(Box::new(request)); + let request = validate_provider_lowering_request(schema, contract, request) + .expect("Echo lowering request validates"); + LowerHarness { + host, + prepared, + request, + schema, + } +} + +fn echo_harness(core: &CoreModule, output_role: &str) -> LowerHarness { + let (contract, request) = echo_request(core, output_role); + echo_harness_with_request(contract, request) +} + +const fn host_limits() -> ProviderHostLimits { + ProviderHostLimits { + max_input_bytes: 1024 * 1024, + max_output_bytes: 3 * 1024 * 1024, + max_diagnostic_bytes: 3 * 1024 * 1024, + max_wasm_memory_bytes: 16 * 1024 * 1024, + max_table_elements: 10_000, + max_instances: 100, + max_memories: 8, + max_tables: 8, + max_wasm_fuel: 50_000_000, + max_hostcall_bytes: 4 * 1024 * 1024, + max_host_diagnostic_bytes: 512, + } +} + +fn assert_echo_refusal( + harness: &LowerHarness, + expected_kind: ProviderRefusalKind, + expected_subject: Option<&str>, +) { + let outcome = harness + .host + .invoke_lowerer( + &harness.prepared, + &harness.request, + harness.schema, + host_limits(), + ) + .expect("typed Echo refusal crosses the WIT transport"); + assert!(outcome.response().is_none()); + assert!(outcome.manifest().is_none()); + let refusal = outcome + .refusal() + .expect("Echo component returned a typed refusal"); + assert_eq!(refusal.kind, expected_kind); + assert_eq!(refusal.subject.as_deref(), expected_subject); + + let replay = harness + .host + .replay_lowerer( + &harness.prepared, + &harness.request, + harness.schema, + host_limits(), + ) + .expect("typed Echo refusal replays identically in fresh stores"); + let ProviderReplayObservation::Completed(replayed) = replay.observation() else { + panic!("typed Echo refusal must be a completed replay observation"); + }; + assert_eq!(replayed.refusal(), Some(refusal)); + assert!(replayed.response().is_none()); + assert!(replayed.manifest().is_none()); +} + +fn fixture_manifest(component_bytes: &'static [u8]) -> &'static TargetProviderManifest { + let component = raw_resource("runtime.lowerer/component@1", component_bytes); + let schema = raw_resource("runtime.artifacts.cddl@1", FIXTURE_SCHEMA); + Box::leak(Box::new(TargetProviderManifest { + api_version: TARGET_PROVIDER_MANIFEST_API_VERSION.to_owned(), + provider_abi: TARGET_PROVIDER_ABI.to_owned(), + provider: locked_test_resource("runtime.provider@1", '4'), + artifacts: vec![ + ProviderArtifactRef { + role: "lowerer.runtime".to_owned(), + artifact_kind: ProviderArtifactKind::Lowerer, + resource: component.clone(), + source: ProviderArtifactSource::Component { component }, + }, + ProviderArtifactRef { + role: "schema.runtime".to_owned(), + artifact_kind: ProviderArtifactKind::ArtifactSchema, + resource: schema, + source: ProviderArtifactSource::Generated { + semantic_source: locked_test_resource("runtime.semantic-source@1", '5'), + generator: locked_test_resource("runtime.provider-generator@1", '6'), + }, + }, + ], + schema_bindings: [ + CORE_MODULE_DIGEST_DOMAIN, + TARGET_PROFILE_API_VERSION, + FIXTURE_OUTPUT_DOMAIN, + ] + .into_iter() + .map(|domain| ProviderSchemaBinding { + domain: domain.to_owned(), + schema_role: "schema.runtime".to_owned(), + format: ProviderSchemaFormat::SelfContainedCddlV1, + root_rule: "artifact".to_owned(), + }) + .collect(), + })) +} + +fn fixture_registry( + manifest: &'static TargetProviderManifest, +) -> &'static ProviderArtifactSchemaRegistry { + let proof = bind_target_provider_manifest(manifest).expect("fixture manifest validates"); + Box::leak(Box::new( + ProviderArtifactSchemaRegistry::from_manifest( + &proof, + [ResolvedProviderSchemaArtifact { + role: "schema.runtime".to_owned(), + bytes: Arc::from(FIXTURE_SCHEMA), + }], + [ + CORE_MODULE_DIGEST_DOMAIN, + TARGET_PROFILE_API_VERSION, + FIXTURE_OUTPUT_DOMAIN, + ], + ) + .expect("fixture schema registry constructs"), + )) +} + +fn fixture_request(role: &str) -> (ProviderLoweringInvocationContract, ProviderLoweringRequest) { + let core = bound_artifact("core@1", CORE_MODULE_DIGEST_DOMAIN, NULL_BYTES); + let target_profile = bound_artifact("profile@1", TARGET_PROFILE_API_VERSION, NULL_BYTES); + let contract = ProviderLoweringInvocationContract { + core: artifact_binding(&core), + target_profile: artifact_binding(&target_profile), + semantic_inputs: Vec::new(), + }; + let request = ProviderLoweringRequest { + protocol_version: TARGET_PROVIDER_PROTOCOL_VERSION, + core, + target_profile, + semantic_inputs: Vec::new(), + requested_outputs: vec![ProviderLoweringOutputRequest { + role: role.to_owned(), + kind: ProviderLoweringOutputKind::GeneratedArtifact, + domain: FIXTURE_OUTPUT_DOMAIN.to_owned(), + }], + limits: ProviderResponseLimits { + max_output_count: 4, + max_diagnostic_count: 4, + max_total_response_bytes: 1024 * 1024, + }, + }; + (contract, request) +} + +fn fixture_harness_with_component(role: &str, component: &'static [u8]) -> LowerHarness { + let manifest = fixture_manifest(component); + let manifest_proof = Box::leak(Box::new( + bind_target_provider_manifest(manifest).expect("fixture manifest validates"), + )); + let selected = select_provider_component( + manifest_proof, + "lowerer.runtime", + ProviderInvocationKind::Lowering, + ) + .expect("fixture lowerer selects"); + let resolved = ResolvedProviderComponent::new(selected, Arc::from(component)); + let host = ProviderComponentHost::new().expect("host configures"); + let prepared = host.prepare(&resolved).expect("fixture lowerer prepares"); + let schema = fixture_registry(manifest); + let (contract, request) = fixture_request(role); + let contract = Box::leak(Box::new(contract)); + let request = Box::leak(Box::new(request)); + let request = validate_provider_lowering_request(schema, contract, request) + .expect("fixture request validates"); + LowerHarness { + host, + prepared, + request, + schema, + } +} + +fn fixture_harness(role: &str) -> LowerHarness { + fixture_harness_with_component(role, FIXTURE_LOWERER_BYTES) +} + +fn echo_observation() -> String { + let core = echo_core(); + let harness = echo_harness(&core, TARGET_IR_ROLE); + let replay = harness + .host + .replay_lowerer( + &harness.prepared, + &harness.request, + harness.schema, + host_limits(), + ) + .expect("fresh-store Echo component observations agree"); + let ProviderReplayObservation::Completed(outcome) = replay.observation() else { + panic!("valid Echo component must replay as a completed observation"); + }; + let response = outcome.response().expect("Echo component returns success"); + let manifest = outcome + .manifest() + .expect("host authors the Echo component output manifest"); + format!( + "{}:{}:{}", + raw_sha256(echo_component_bytes()), + raw_sha256(&response.outputs[0].artifact.bytes), + hex(&manifest.outputs()[0].digest.bytes) + ) +} + +fn oracle_target_ir(core: &CoreModule) -> (Vec, ProviderDigest) { + let facts = TargetIrLoweringFacts { + target_profile: ResourceRef { + coordinate: ECHO_DPO_TARGET_PROFILE.to_owned(), + digest: Some(format!("sha256:{TARGET_PROFILE_SHA256}")), + }, + target_ir_domain: ECHO_SPAN_IR_DOMAIN.to_owned(), + operation_profiles: vec!["continuum.profile.write/v1".to_owned()], + obstruction_coordinates: vec!["rejected".to_owned()], + effect_lowerings: vec![TargetEffectLowering { + effect: "target.replace".to_owned(), + target_intrinsic: "echo.dpo@1.replace".to_owned(), + }], + }; + let report = lower_with_builtin_lowerer( + BuiltinTargetLowerer::EchoDpo, + BuiltinLowererRequest { + core, + facts: &facts, + }, + ) + .expect("Edict built-in Echo lowerer accepts the exact fixture"); + let artifact = report.artifact.expect("Echo Core lowers to Target IR"); + let bytes = encode_target_ir_artifact(&artifact).expect("oracle Target IR encodes"); + let digest = digest_target_ir_artifact(&artifact).expect("oracle Target IR digests"); + assert_eq!(digest.algorithm(), "sha256"); + ( + bytes, + ProviderDigest { + algorithm: ProviderDigestAlgorithm::Sha256, + bytes: digest.bytes().to_vec(), + }, + ) +} + +#[test] +fn echo_component_matches_independent_edict_target_ir_bytes_and_digest() { + let core = echo_core(); + let core_bytes = encode_core_module(&core).expect("Core module encodes"); + assert_eq!(core_bytes.len(), 1209); + assert_eq!( + hex(&provider_digest(CORE_MODULE_DIGEST_DOMAIN, &core_bytes).bytes), + "c3dbe413c78a82f6120e64c9a04bc94e2d79505f9e4b8a65c2bc26b408d775de" + ); + assert_eq!( + hex(&provider_digest(TARGET_PROFILE_API_VERSION, TARGET_PROFILE_BYTES).bytes), + TARGET_PROFILE_SHA256 + ); + + let (oracle_bytes, oracle_digest) = oracle_target_ir(&core); + let harness = echo_harness(&core, TARGET_IR_ROLE); + let outcome = harness + .host + .invoke_lowerer( + &harness.prepared, + &harness.request, + harness.schema, + host_limits(), + ) + .expect("component result crosses complete Edict host admission"); + assert!(outcome.refusal().is_none()); + let response = outcome.response().expect("component returns success"); + assert!(response.diagnostics.is_empty()); + assert_eq!(response.outputs.len(), 1); + let output = &response.outputs[0]; + assert_eq!(output.role, TARGET_IR_ROLE); + assert_eq!(output.kind, ProviderLoweringOutputKind::TargetIr); + assert_eq!(output.artifact.domain, TARGET_IR_ARTIFACT_DIGEST_DOMAIN); + assert_eq!(output.logical_path, None); + assert_eq!(output.artifact.bytes, oracle_bytes); + assert_eq!(output.artifact.bytes.len(), 848); + assert_eq!(raw_sha256(&output.artifact.bytes), RAW_TARGET_IR_SHA256); + + let manifest = outcome.manifest().expect("host authors an output manifest"); + assert_eq!(manifest.outputs().len(), 1); + let entry = &manifest.outputs()[0]; + assert_eq!(entry.role, TARGET_IR_ROLE); + assert_eq!(entry.kind, ProviderLoweringOutputKind::TargetIr); + assert_eq!(entry.domain, TARGET_IR_ARTIFACT_DIGEST_DOMAIN); + assert_eq!(entry.digest, oracle_digest); + assert_eq!(hex(&entry.digest.bytes), DOMAIN_TARGET_IR_SHA256); + + let replay = harness + .host + .replay_lowerer( + &harness.prepared, + &harness.request, + harness.schema, + host_limits(), + ) + .expect("Echo component success replays identically in fresh stores"); + let ProviderReplayObservation::Completed(replayed) = replay.observation() else { + panic!("Echo component success must be a completed replay observation"); + }; + assert_eq!(replayed.response(), outcome.response()); + assert_eq!(replayed.manifest(), outcome.manifest()); + assert!(replayed.refusal().is_none()); +} + +#[test] +fn exact_merged_edict_host_fixtures_are_unchanged() { + assert_eq!(FIXTURE_LOWERER_BYTES.len(), 28_007); + assert_eq!( + raw_sha256(FIXTURE_LOWERER_BYTES), + "bec69c4fa02aa2dfb4f492d4a1c6849ae4ebe81c1725477efb6b0f2e885676aa" + ); + assert_eq!(FIXTURE_MALFORMED_BYTES.len(), 2_165); + assert_eq!( + raw_sha256(FIXTURE_MALFORMED_BYTES), + "dfcd171918373d18b9dff16778e98b7618eeb4ac85976dd7134b9e201562f41b" + ); +} + +#[test] +fn provider_digest_accepts_the_maximum_canonical_value_depth() { + let mut value = text("leaf"); + for _ in 0..MAX_CANONICAL_NESTING_DEPTH { + value = CanonicalValue::Array(vec![value]); + } + let bytes = encode_canonical_cbor(&value).expect("maximum-depth value encodes"); + + let digest = provider_digest("test.maximum-depth/v1", &bytes); + + assert_eq!(digest.algorithm, ProviderDigestAlgorithm::Sha256); + assert_eq!(digest.bytes.len(), 32); +} + +#[test] +fn echo_component_refuses_an_unsupported_profile_through_the_actual_host() { + let core = echo_core(); + let (mut contract, mut request) = echo_request(&core, TARGET_IR_ROLE); + request.target_profile.reference.coordinate = "echo.other@1".to_owned(); + contract.target_profile = artifact_binding(&request.target_profile); + let harness = echo_harness_with_request(contract, request); + + assert_echo_refusal( + &harness, + ProviderRefusalKind::UnsupportedTargetProfile, + Some("echo.other@1"), + ); +} + +#[test] +fn echo_component_refuses_unsupported_core_semantics_through_the_actual_host() { + let mut core = echo_core(); + core.coordinate = "x.y@1".to_owned(); + let (mut contract, mut request) = echo_request(&core, TARGET_IR_ROLE); + request.core.reference.coordinate = core.coordinate; + contract.core = artifact_binding(&request.core); + let harness = echo_harness_with_request(contract, request); + + assert_echo_refusal( + &harness, + ProviderRefusalKind::UnsupportedSemantics, + Some("x.y@1"), + ); +} + +#[test] +fn echo_component_refuses_nonempty_input_constraints_through_the_actual_host() { + let core_bytes = encode_core_module(&echo_core()).expect("Core module encodes canonically"); + let mut core = decode_canonical_cbor(&core_bytes).expect("Core module decodes canonically"); + *canonical_map_field_mut(canonical_core_intent_mut(&mut core), "inputConstraints") = + CanonicalValue::Array(vec![map([ + ("coordinate", text("a.b@1.t.where.0")), + ("source", text("where")), + ( + "predicate", + map([ + ("kind", text("call")), + ("predicate", text("domain.Unreviewed")), + ("args", CanonicalValue::Array(Vec::new())), + ]), + ), + ])]); + let core_bytes = encode_canonical_cbor(&core).expect("mutated Core encodes canonically"); + let (contract, request) = echo_request_from_core_bytes(&core_bytes, TARGET_IR_ROLE); + let harness = echo_harness_with_request(contract, request); + + assert_echo_refusal( + &harness, + ProviderRefusalKind::UnsupportedSemantics, + Some("a.b@1.t"), + ); +} + +#[test] +fn echo_component_refuses_incomplete_local_inventory_through_the_actual_host() { + let core_bytes = encode_core_module(&echo_core()).expect("Core module encodes canonically"); + let mut core = decode_canonical_cbor(&core_bytes).expect("Core module decodes canonically"); + let body = canonical_map_field_mut(canonical_core_intent_mut(&mut core), "body"); + let CanonicalValue::Array(locals) = canonical_map_field_mut(body, "locals") else { + panic!("Core locals is not an array"); + }; + locals.retain(|local| { + let CanonicalValue::Map(entries) = local else { + return true; + }; + !entries + .iter() + .any(|(key, value)| key == &text("type") && value == &text("target.replace.rejected")) + }); + let core_bytes = encode_canonical_cbor(&core).expect("mutated Core encodes canonically"); + let (contract, request) = echo_request_from_core_bytes(&core_bytes, TARGET_IR_ROLE); + let harness = echo_harness_with_request(contract, request); + + assert_echo_refusal( + &harness, + ProviderRefusalKind::InvalidSemanticArtifact, + Some("a.b@1.t"), + ); +} + +#[test] +fn echo_component_refuses_output_overclaim_through_the_actual_host() { + let core = echo_core(); + let harness = echo_harness(&core, "generated.echo-dpo"); + + assert_echo_refusal( + &harness, + ProviderRefusalKind::UnsupportedOutputRole, + Some("generated.echo-dpo"), + ); +} + +#[test] +fn wrong_target_profile_domain_rejects_before_component_invocation() { + let core = echo_core(); + let (mut contract, mut request) = echo_request(&core, TARGET_IR_ROLE); + request.target_profile = bound_artifact( + ECHO_DPO_TARGET_PROFILE, + "wrong.target-profile/v1", + TARGET_PROFILE_BYTES, + ); + contract.target_profile = artifact_binding(&request.target_profile); + let manifest = echo_manifest(echo_component_bytes()); + let schema = echo_registry(manifest); + + let report = validate_provider_lowering_request(schema, &contract, &request) + .expect_err("wrong fixed target-profile domain rejects before invocation"); + assert!(report.failures.iter().any(|failure| { + failure.kind == ProviderInvocationValidationFailureKind::ArtifactDomainMismatch + })); +} + +#[test] +fn typed_provider_refusal_is_completed_without_an_output_manifest() { + let harness = fixture_harness("fixture.refusal"); + let outcome = harness + .host + .invoke_lowerer( + &harness.prepared, + &harness.request, + harness.schema, + host_limits(), + ) + .expect("typed refusal crosses the WIT transport"); + assert!(outcome.response().is_none()); + assert!(outcome.manifest().is_none()); + let refusal = outcome + .refusal() + .expect("provider returned a typed refusal"); + assert_eq!(refusal.kind, ProviderRefusalKind::UnsupportedSemantics); + assert_eq!(refusal.subject, None); + + let replay = harness + .host + .replay_lowerer( + &harness.prepared, + &harness.request, + harness.schema, + host_limits(), + ) + .expect("typed refusal replays identically in fresh stores"); + let ProviderReplayObservation::Completed(replayed) = replay.observation() else { + panic!("typed refusal must be a completed replay observation"); + }; + assert_eq!(replayed.refusal(), Some(refusal)); + assert!(replayed.manifest().is_none()); +} + +#[test] +fn host_rejections_preserve_trap_lifting_and_envelope_identity() { + let trapped = fixture_harness("fixture.trap"); + let failure = trapped + .host + .invoke_lowerer( + &trapped.prepared, + &trapped.request, + trapped.schema, + host_limits(), + ) + .expect_err("explicit guest trap rejects"); + assert_eq!(failure.kind(), ProviderHostFailureKind::GuestTrap); + assert_eq!(failure.phase(), ProviderHostPhase::Lower); + assert!(failure.validation_report().is_none()); + + let malformed = fixture_harness_with_component("output.runtime", FIXTURE_MALFORMED_BYTES); + let failure = malformed + .host + .invoke_lowerer( + &malformed.prepared, + &malformed.request, + malformed.schema, + host_limits(), + ) + .expect_err("invalid canonical ABI discriminant rejects during lifting"); + assert_eq!(failure.kind(), ProviderHostFailureKind::MalformedResponse); + assert!(failure.validation_report().is_none()); + + let undeclared = fixture_harness("fixture.undeclared-output"); + let failure = undeclared + .host + .invoke_lowerer( + &undeclared.prepared, + &undeclared.request, + undeclared.schema, + host_limits(), + ) + .expect_err("undeclared output cannot cross host admission"); + assert_eq!( + failure.kind(), + ProviderHostFailureKind::ResponseEnvelopeInvalid + ); + assert_eq!(failure.phase(), ProviderHostPhase::ValidateResponse); + let report = failure + .validation_report() + .expect("envelope rejection retains structured validation evidence"); + assert!(report + .failures + .iter() + .any(|item| { item.kind == ProviderInvocationValidationFailureKind::UndeclaredOutput })); +} + +#[test] +fn rejected_host_invocation_replays_with_the_same_typed_failure() { + let harness = fixture_harness("fixture.trap"); + let replay = harness + .host + .replay_lowerer( + &harness.prepared, + &harness.request, + harness.schema, + host_limits(), + ) + .expect("guest trap replays as equal host rejections"); + let ProviderReplayObservation::Rejected(failure) = replay.observation() else { + panic!("guest trap must remain a rejected replay observation"); + }; + assert_eq!(failure.kind(), ProviderHostFailureKind::GuestTrap); + assert_eq!(failure.phase(), ProviderHostPhase::Lower); +} + +#[test] +#[ignore = "child entrypoint exercised by the independent-process witness"] +fn emit_echo_component_host_observation() { + println!("{OBSERVATION_MARKER}{}", echo_observation()); +} + +#[test] +fn independent_processes_reproduce_the_same_echo_component_observation() { + let executable = std::env::current_exe().expect("current test executable is discoverable"); + let run_child = || { + let output = Command::new(&executable) + .arg("emit_echo_component_host_observation") + .args(["--exact", "--ignored", "--nocapture", "--test-threads=1"]) + .output() + .expect("child host process launches"); + assert!( + output.status.success(), + "child host witness failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("child output is UTF-8"); + stdout + .lines() + .find_map(|line| { + line.split_once(OBSERVATION_MARKER) + .map(|(_, observation)| observation) + }) + .unwrap_or_else(|| panic!("child omitted stable observation:\n{stdout}")) + .to_owned() + }; + + let first = run_child(); + let second = run_child(); + assert_eq!(first, second); +} diff --git a/tests/hooks/test_verify_local.sh b/tests/hooks/test_verify_local.sh index c91a62cc..21f68582 100755 --- a/tests/hooks/test_verify_local.sh +++ b/tests/hooks/test_verify_local.sh @@ -28,6 +28,11 @@ if grep -q -- 'cargo clippy -p warp-math --all-targets -- -D warnings -D missing else fail "CI clippy should cover all warp-math targets" fi +if grep -q -- 'cargo +1.90.0 clippy -p echo-edict-provider-lowerer --target wasm32-unknown-unknown --lib -- -D warnings -D missing_docs' .github/workflows/ci.yml; then + pass "CI clippy covers the wasm-only Edict provider adapter" +else + fail "CI clippy should cover the wasm-only Edict provider adapter" +fi if awk ' /^permissions:$/ { in_permissions = 1; next } in_permissions && /^[^[:space:]]/ { in_permissions = 0 } @@ -243,6 +248,12 @@ run_fake_verify() { mkdir -p "$tmp/crates/warp-math/src/bin" cp scripts/verify-local.sh "$tmp/scripts/verify-local.sh" chmod +x "$tmp/scripts/verify-local.sh" + cat >"$tmp/scripts/verify-edict-provider-host-v1.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "edict-provider-host-v1" >>"$VERIFY_FAKE_HOOK_LOG" +EOF + chmod +x "$tmp/scripts/verify-edict-provider-host-v1.sh" cat >"$tmp/rust-toolchain.toml" <<'EOF' [toolchain] @@ -1822,6 +1833,68 @@ else pass "canonical ABI changes avoid the generic lib smoke lane" fi +fake_edict_component_output="$(run_fake_verify full schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm)" +if printf '%s\n' "$fake_edict_component_output" | grep -q 'critical local gate (targeted-rust)'; then + pass "checked Edict component changes select the lowerer Rust scope" +else + fail "checked Edict component changes should select the lowerer Rust scope" + printf '%s\n' "$fake_edict_component_output" +fi +if printf '%s\n' "$fake_edict_component_output" | grep -q -- 'clippy -p echo-edict-provider-lowerer --target wasm32-unknown-unknown --lib -- -D warnings -D missing_docs'; then + pass "checked Edict component changes run wasm-target strict Clippy" +else + fail "checked Edict component changes should run wasm-target strict Clippy" + printf '%s\n' "$fake_edict_component_output" +fi +if printf '%s\n' "$fake_edict_component_output" | grep -q '^edict-provider-host-v1$'; then + pass "checked Edict component changes run the isolated host witness" +else + fail "checked Edict component changes should run the isolated host witness" + printf '%s\n' "$fake_edict_component_output" +fi + +fake_edict_wit_output="$(run_fake_verify full crates/echo-edict-provider-lowerer/wit/edict-target-provider.wit)" +if printf '%s\n' "$fake_edict_wit_output" | grep -q 'critical local gate (targeted-rust)'; then + pass "Edict lowerer WIT changes select the lowerer Rust scope" +else + fail "Edict lowerer WIT changes should select the lowerer Rust scope" + printf '%s\n' "$fake_edict_wit_output" +fi +if printf '%s\n' "$fake_edict_wit_output" | grep -q -- 'clippy -p echo-edict-provider-lowerer --target wasm32-unknown-unknown --lib -- -D warnings -D missing_docs'; then + pass "Edict lowerer WIT changes run wasm-target strict Clippy" +else + fail "Edict lowerer WIT changes should run wasm-target strict Clippy" + printf '%s\n' "$fake_edict_wit_output" +fi +if printf '%s\n' "$fake_edict_wit_output" | grep -q '^edict-provider-host-v1$'; then + pass "Edict lowerer WIT changes run the isolated host witness" +else + fail "Edict lowerer WIT changes should run the isolated host witness" + printf '%s\n' "$fake_edict_wit_output" +fi + +fake_edict_host_output="$(run_fake_verify full tests/edict-provider-host-v1/tests/provider_host.rs)" +if printf '%s\n' "$fake_edict_host_output" | grep -q -- 'clippy -p echo-edict-provider-lowerer --target wasm32-unknown-unknown --lib -- -D warnings -D missing_docs'; then + pass "isolated Edict host changes run wasm-target strict Clippy" +else + fail "isolated Edict host changes should run wasm-target strict Clippy" + printf '%s\n' "$fake_edict_host_output" +fi +if printf '%s\n' "$fake_edict_host_output" | grep -q '^edict-provider-host-v1$'; then + pass "isolated Edict host changes rerun the host witness" +else + fail "isolated Edict host changes should rerun the host witness" + printf '%s\n' "$fake_edict_host_output" +fi + +fake_edict_host_script_output="$(run_fake_verify full scripts/verify-edict-provider-host-v1.sh)" +if printf '%s\n' "$fake_edict_host_script_output" | grep -q '^edict-provider-host-v1$'; then + pass "Edict host verifier script changes rerun the host witness" +else + fail "Edict host verifier script changes should rerun the host witness" + printf '%s\n' "$fake_edict_host_script_output" +fi + fake_warp_wasm_readme_output="$(run_fake_verify full crates/warp-wasm/README.md)" if printf '%s\n' "$fake_warp_wasm_readme_output" | grep -q 'critical local gate (tooling-only)'; then pass "non-rust critical crate docs stay off the Rust smoke lanes" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index d0487d85..5612850a 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -19,10 +19,15 @@ hex = "0.4" pulldown-cmark = { version = "0.13", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" +same-file = "1" sha2 = "0.10" time = { version = "0.3.47", features = ["formatting", "parsing"] } +wasm-encoder = "=0.251.0" +wasmparser = { version = "=0.251.0", features = ["component-model", "validate"] } warp-cli = { path = "../crates/warp-cli", version = "0.1.0" } warp-core = { workspace = true, features = ["native_rule_bootstrap"] } +wit-component = "=0.251.0" +wit-parser = "=0.251.0" [lints] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index fed6bec4..0f2b5fd2 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -23,6 +23,7 @@ use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; mod hello_echo; +mod provider_lowerer_component; #[derive(Parser)] #[command( @@ -47,6 +48,8 @@ enum Commands { PrStatus(PrStatusArgs), /// Record a durable PR review-state snapshot under local ignored artifacts. PrSnapshot(PrSnapshotArgs), + /// Build or check the exact Echo Edict provider lowerer component. + ProviderLowererComponent(ProviderLowererComponentArgs), /// List, reply to, or resolve PR review threads via `gh`. PrThreads(PrThreadsArgs), /// Run the high-signal local gate before opening a PR. @@ -75,6 +78,67 @@ struct TestSliceArgs { dry_run: bool, } +#[derive(Args)] +struct ProviderLowererComponentArgs { + /// Exact component operation to perform. + #[command(subcommand)] + command: ProviderLowererComponentCommand, +} + +#[derive(Subcommand)] +enum ProviderLowererComponentCommand { + /// Build and audit local bytes without claiming checked cross-host identity. + Build(ProviderLowererComponentLocalBuildArgs), + /// Build and write candidate bytes on the exact designated host. + DesignatedBuild(ProviderLowererComponentBuildArgs), + /// Build on the designated host and compare exact checked bytes. + Check(ProviderLowererComponentBuildArgs), + /// Audit an explicit existing component without rebuilding it. + Audit(ProviderLowererComponentAuditArgs), + /// Audit and intentionally promote an explicit candidate to an output. + Promote(ProviderLowererComponentPromoteArgs), +} + +#[derive(Args)] +struct ProviderLowererComponentLocalBuildArgs { + /// Explicit Cargo target directory, relative to the repository root unless absolute. + #[arg(long)] + target_dir: PathBuf, +} + +#[derive(Args)] +struct ProviderLowererComponentBuildArgs { + /// Explicit output path, relative to the repository root unless absolute. + #[arg(long)] + output: PathBuf, + /// Explicit Cargo target directory, relative to the repository root unless absolute. + #[arg(long)] + target_dir: PathBuf, +} + +#[derive(Args)] +struct ProviderLowererComponentAuditArgs { + /// Explicit component path, relative to the repository root unless absolute. + #[arg(long)] + input: PathBuf, +} + +#[derive(Args)] +struct ProviderLowererComponentPromoteArgs { + /// First explicit candidate path, relative to the repository root unless absolute. + #[arg(long)] + candidate_a: PathBuf, + /// Second distinct candidate path, relative to the repository root unless absolute. + #[arg(long)] + candidate_b: PathBuf, + /// Explicit checked output path, relative to the repository root unless absolute. + #[arg(long)] + output: PathBuf, + /// Confirm the intentional checked-artifact write. + #[arg(long, required = true)] + write: bool, +} + #[derive(Args)] struct HelloEchoArgs { /// Output the evidence capsule as JSON. @@ -370,6 +434,7 @@ fn main() -> Result<()> { Commands::HelloEcho(args) => run_hello_echo(args), Commands::PrStatus(args) => run_pr_status(args), Commands::PrSnapshot(args) => run_pr_snapshot(args), + Commands::ProviderLowererComponent(args) => run_provider_lowerer_component(args), Commands::PrThreads(args) => run_pr_threads(args), Commands::PrPreflight(args) => run_pr_preflight(args), Commands::Dind(args) => run_dind(args), @@ -381,6 +446,120 @@ fn main() -> Result<()> { } } +fn run_provider_lowerer_component(args: ProviderLowererComponentArgs) -> Result<()> { + let repository_root = find_repo_root()?; + match args.command { + ProviderLowererComponentCommand::Build(args) => { + let toolchain = provider_lowerer_component::pinned_rust_toolchain()?; + let component = provider_lowerer_component::build_component( + &repository_root, + &args.target_dir, + &toolchain, + )?; + print_provider_component("local-build", None, &component, None); + } + ProviderLowererComponentCommand::DesignatedBuild(args) => { + let output_path = repository_path(&repository_root, args.output); + let checked_path = + repository_root.join(provider_lowerer_component::CHECKED_COMPONENT_REPOSITORY_PATH); + provider_lowerer_component::ensure_designated_candidate_output( + &output_path, + &checked_path, + )?; + let builder = provider_lowerer_component::require_checked_builder()?; + let component = provider_lowerer_component::build_component( + &repository_root, + &args.target_dir, + &builder, + )?; + let status = provider_lowerer_component::sync_output( + &output_path, + component.bytes(), + provider_lowerer_component::ComponentOutputMode::Write, + )?; + print_provider_component( + &format!("designated-build:{}", builder.host()), + Some(&output_path), + &component, + Some(status), + ); + } + ProviderLowererComponentCommand::Check(args) => { + let builder = provider_lowerer_component::require_checked_builder()?; + let output_path = repository_path(&repository_root, args.output); + let component = provider_lowerer_component::build_component( + &repository_root, + &args.target_dir, + &builder, + )?; + let status = provider_lowerer_component::sync_output( + &output_path, + component.bytes(), + provider_lowerer_component::ComponentOutputMode::Check, + )?; + print_provider_component( + &format!("checked-build:{}", builder.host()), + Some(&output_path), + &component, + Some(status), + ); + } + ProviderLowererComponentCommand::Audit(args) => { + let input_path = repository_path(&repository_root, args.input); + let component = provider_lowerer_component::read_component(&input_path)?; + print_provider_component("audited", Some(&input_path), &component, None); + } + ProviderLowererComponentCommand::Promote(args) => { + if !args.write { + bail!("provider lowerer promotion requires --write"); + } + let candidate_a = repository_path(&repository_root, args.candidate_a); + let candidate_b = repository_path(&repository_root, args.candidate_b); + let output_path = repository_path(&repository_root, args.output); + let (component, status) = provider_lowerer_component::promote_reproducible_candidates( + &candidate_a, + &candidate_b, + &output_path, + )?; + print_provider_component("promoted", Some(&output_path), &component, Some(status)); + } + } + Ok(()) +} + +fn repository_path(repository_root: &Path, path: PathBuf) -> PathBuf { + if path.is_absolute() { + path + } else { + repository_root.join(path) + } +} + +fn print_provider_component( + action: &str, + path: Option<&Path>, + component: &provider_lowerer_component::ProviderLowererComponent, + status: Option, +) { + let status = match status { + Some(provider_lowerer_component::ComponentOutputStatus::Current) => "; current", + Some(provider_lowerer_component::ComponentOutputStatus::Written) => "; written", + None => "", + }; + if let Some(path) = path { + println!( + "provider lowerer component: {action}{status}; sha256:{}; {}", + component.sha256_hex(), + path.display() + ); + } else { + println!( + "provider lowerer component: {action}{status}; sha256:{}", + component.sha256_hex() + ); + } +} + fn run_hello_echo(args: HelloEchoArgs) -> Result<()> { let report = hello_echo::run(hello_echo::HelloEchoConfig { out_dir: args.out_dir, diff --git a/xtask/src/provider_lowerer_component.rs b/xtask/src/provider_lowerer_component.rs new file mode 100644 index 00000000..8c901f83 --- /dev/null +++ b/xtask/src/provider_lowerer_component.rs @@ -0,0 +1,2172 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Deterministic build and structural admission for the Echo Edict lowerer component. + +use sha2::{Digest, Sha256}; +use std::borrow::Cow; +use std::ffi::OsString; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use wasm_encoder::{ComponentSection, CustomSection}; +use wasmparser::{ + ComponentExternalKind, ComponentType, ComponentTypeRef, Encoding, InstanceTypeDeclaration, + Parser, Payload, TypeBounds, Validator, WasmFeatures, +}; +use wit_component::{ComponentEncoder, DecodedWasm, StringEncoding}; +use wit_parser::{LiveTypes, PackageName, Resolve, WorldId, WorldItem}; + +/// Name of the sole provider-contract attestation custom section. +pub(crate) const CONTRACT_SECTION_NAME: &str = "edict:target-provider-contract"; + +/// Exact Edict lowerer contract coordinate carried by the attestation. +pub(crate) const LOWERER_CONTRACT_COORDINATE: &str = "edict:target-provider/lowerer@1.0.0"; + +/// Exact type-only protocol instance that the lowerer component may import. +pub(crate) const PROTOCOL_INSTANCE_COORDINATE: &str = "edict:target-provider/protocol@1.0.0"; + +const PROTOCOL_TYPE_IMPORTS: [&str; 2] = ["lowering-request-v1", "lowering-result-v1"]; +const LOWERER_WORLD_NAME: &str = "lowerer"; +const LOWERER_WIT_SOURCE: &str = + include_str!("../../crates/echo-edict-provider-lowerer/wit/edict-target-provider.wit"); +const LOWERER_PACKAGE: &str = "echo-edict-provider-lowerer"; +const LOWERER_CORE_WASM: &str = "echo_edict_provider_lowerer.wasm"; +const LOWER_EXPORT: &str = "lower"; + +/// Exact host triple designated to produce the checked component bytes. +pub(crate) const CHECKED_COMPONENT_BUILDER_HOST: &str = "x86_64-unknown-linux-gnu"; +const PINNED_RUST_TOOLCHAIN: &str = "1.90.0"; +const PINNED_RUSTC_COMMIT: &str = "1159e78c4747b02ef996e55082b704c09b970588"; +const PINNED_CARGO_COMMIT: &str = "840b83a10fb0e039a83f4d70ad032892c287570a"; + +/// Reviewed identity that the portable promotion command is permitted to install. +pub(crate) const APPROVED_CHECKED_COMPONENT_SHA256: &str = + "03edee44c6bc70eb998c0c17662a214809746af3bba0740f3407c18a4016309e"; +pub(crate) const CHECKED_COMPONENT_REPOSITORY_PATH: &str = + "schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm"; + +/// Stable classification for a component build or audit failure. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProviderLowererComponentErrorKind { + /// A path required by the deterministic build is not valid UTF-8. + InvalidPath, + /// Cargo could not be started. + BuildInvocationFailed, + /// Cargo completed without producing a successful lowerer build. + BuildFailed, + /// The expected core WebAssembly artifact could not be read. + CoreWasmReadFailed, + /// The pinned Rust compiler could not report its host identity. + BuilderHostInvocationFailed, + /// The pinned Rust compiler reported no unique host identity. + BuilderHostInvalid, + /// A pinned Rust tool reported a release other than the reviewed release. + BuilderReleaseMismatch, + /// A pinned Rust tool reported a commit other than the reviewed commit. + BuilderCommitMismatch, + /// Exact checked-byte comparison was attempted on a non-designated host. + BuilderHostMismatch, + /// An explicit component candidate could not be read. + ComponentReadFailed, + /// Two explicit reproducibility candidates differ byte-for-byte. + CandidateMismatch, + /// Two candidate paths resolve to the same underlying file. + CandidateAliased, + /// An expected candidate identity is not exact lowercase SHA-256. + ExpectedDigestInvalid, + /// Reproducible candidates do not have the explicitly expected digest. + CandidateDigestMismatch, + /// A one-build candidate command targeted the checked repository artifact. + CheckedOutputRequiresPromotion, + /// The core module could not be componentized. + ComponentEncodingFailed, + /// The component byte stream is malformed or fails WebAssembly validation. + ComponentInvalid, + /// The component is not a top-level WebAssembly component. + ComponentEncodingInvalid, + /// The required contract attestation is absent. + ContractAttestationMissing, + /// More than one contract attestation is present. + ContractAttestationDuplicate, + /// A contract attestation is nested rather than top-level. + ContractAttestationNested, + /// The contract attestation carries bytes other than the frozen coordinate. + ContractAttestationMismatch, + /// A core WebAssembly import is present. + CoreImportForbidden, + /// A component import other than the frozen protocol instance is present. + ComponentImportForbidden, + /// More than one frozen protocol instance import is present. + ProtocolImportDuplicate, + /// The protocol import is callable, unknown, or not backed by a type-only instance. + ProtocolImportInvalid, + /// The protocol instance and its exact type aliases do not form one closure. + ProtocolImportClosureInvalid, + /// The candidate component's decoded WIT world could not be authenticated. + WorldContractInvalid, + /// The candidate's complete decoded WIT world differs from the frozen lowerer world. + WorldContractMismatch, + /// The required `lower` export is absent. + LowerExportMissing, + /// More than one `lower` export is present. + LowerExportDuplicate, + /// A top-level export is not the exact callable `lower` export. + LowerExportInvalid, + /// An explicit checked output does not exist. + OutputMissing, + /// An explicit checked output could not be read. + OutputReadFailed, + /// An explicit checked output differs from the newly built bytes. + OutputDrift, + /// An explicit output path is a symlink or is not a regular file. + OutputTypeInvalid, + /// An explicit output could not be written. + OutputWriteFailed, +} + +impl ProviderLowererComponentErrorKind { + fn code(self) -> &'static str { + match self { + Self::InvalidPath => "invalid-path", + Self::BuildInvocationFailed => "build-invocation-failed", + Self::BuildFailed => "build-failed", + Self::CoreWasmReadFailed => "core-wasm-read-failed", + Self::BuilderHostInvocationFailed => "builder-host-invocation-failed", + Self::BuilderHostInvalid => "builder-host-invalid", + Self::BuilderReleaseMismatch => "builder-release-mismatch", + Self::BuilderCommitMismatch => "builder-commit-mismatch", + Self::BuilderHostMismatch => "builder-host-mismatch", + Self::ComponentReadFailed => "component-read-failed", + Self::CandidateMismatch => "candidate-mismatch", + Self::CandidateAliased => "candidate-aliased", + Self::ExpectedDigestInvalid => "expected-digest-invalid", + Self::CandidateDigestMismatch => "candidate-digest-mismatch", + Self::CheckedOutputRequiresPromotion => "checked-output-requires-promotion", + Self::ComponentEncodingFailed => "component-encoding-failed", + Self::ComponentInvalid => "component-invalid", + Self::ComponentEncodingInvalid => "component-encoding-invalid", + Self::ContractAttestationMissing => "contract-attestation-missing", + Self::ContractAttestationDuplicate => "contract-attestation-duplicate", + Self::ContractAttestationNested => "contract-attestation-nested", + Self::ContractAttestationMismatch => "contract-attestation-mismatch", + Self::CoreImportForbidden => "core-import-forbidden", + Self::ComponentImportForbidden => "component-import-forbidden", + Self::ProtocolImportDuplicate => "protocol-import-duplicate", + Self::ProtocolImportInvalid => "protocol-import-invalid", + Self::ProtocolImportClosureInvalid => "protocol-import-closure-invalid", + Self::WorldContractInvalid => "world-contract-invalid", + Self::WorldContractMismatch => "world-contract-mismatch", + Self::LowerExportMissing => "lower-export-missing", + Self::LowerExportDuplicate => "lower-export-duplicate", + Self::LowerExportInvalid => "lower-export-invalid", + Self::OutputMissing => "output-missing", + Self::OutputReadFailed => "output-read-failed", + Self::OutputDrift => "output-drift", + Self::OutputTypeInvalid => "output-type-invalid", + Self::OutputWriteFailed => "output-write-failed", + } + } +} + +/// Stable, typed error returned by component build and audit operations. +#[derive(Debug)] +pub(crate) struct ProviderLowererComponentError { + kind: ProviderLowererComponentErrorKind, + subject: String, + reference: Option, + detail: Option, +} + +impl ProviderLowererComponentError { + fn new( + kind: ProviderLowererComponentErrorKind, + subject: impl Into, + reference: Option, + ) -> Self { + Self { + kind, + subject: subject.into(), + reference, + detail: None, + } + } + + fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Returns the stable failure classification. + #[cfg(test)] + pub(crate) fn kind(&self) -> ProviderLowererComponentErrorKind { + self.kind + } + + /// Returns the stable subject of the failed operation. + #[cfg(test)] + pub(crate) fn subject(&self) -> &str { + &self.subject + } + + /// Returns the optional stable reference associated with the failure. + #[cfg(test)] + pub(crate) fn reference(&self) -> Option<&str> { + self.reference.as_deref() + } +} + +impl fmt::Display for ProviderLowererComponentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "provider-lowerer-component:{}: {}", + self.kind.code(), + self.subject + )?; + if let Some(reference) = &self.reference { + write!(formatter, " ({reference})")?; + } + Ok(()) + } +} + +impl std::error::Error for ProviderLowererComponentError {} + +/// Result alias for deterministic component operations. +pub(crate) type Result = std::result::Result; + +/// Exact lowerer component bytes and their SHA-256 identity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProviderLowererComponent { + bytes: Vec, + sha256: [u8; 32], +} + +#[derive(Debug)] +pub(crate) struct PinnedRustToolchain { + cargo: PathBuf, + rustc: PathBuf, + host: String, +} + +impl PinnedRustToolchain { + pub(crate) fn host(&self) -> &str { + &self.host + } +} + +impl ProviderLowererComponent { + /// Returns the exact final component bytes. + pub(crate) fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// Returns the lowercase hexadecimal SHA-256 digest. + pub(crate) fn sha256_hex(&self) -> String { + digest_hex(&self.sha256) + } +} + +/// Resolves and authenticates the exact Rust toolchain used by component builds. +pub(crate) fn pinned_rust_toolchain() -> Result { + let rustc = resolve_rustup_tool("rustc")?; + let cargo = resolve_rustup_tool("cargo")?; + let rustc_identity = invoke_tool_identity(&rustc, "-vV", "rustc")?; + let cargo_identity = invoke_tool_identity(&cargo, "-Vv", "cargo")?; + let rustc_host = authenticate_tool_identity("rustc", &rustc_identity, PINNED_RUSTC_COMMIT)?; + let cargo_host = authenticate_tool_identity("cargo", &cargo_identity, PINNED_CARGO_COMMIT)?; + if rustc_host != cargo_host { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostMismatch, + cargo_host, + Some(rustc_host.to_owned()), + )); + } + Ok(PinnedRustToolchain { + cargo, + rustc, + host: rustc_host.to_owned(), + }) +} + +/// Authenticates the pinned compiler host before an exact checked-byte build. +pub(crate) fn require_checked_builder() -> Result { + let toolchain = pinned_rust_toolchain()?; + ensure_checked_builder_host(toolchain.host())?; + Ok(toolchain) +} + +fn resolve_rustup_tool(tool: &str) -> Result { + let output = Command::new("rustup") + .args(["which", "--toolchain", PINNED_RUST_TOOLCHAIN, tool]) + .output() + .map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvocationFailed, + format!("rustup which {tool}"), + Some(PINNED_RUST_TOOLCHAIN.to_owned()), + ) + .with_detail(error.to_string()) + })?; + if !output.status.success() { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvocationFailed, + format!("rustup which {tool}"), + Some(PINNED_RUST_TOOLCHAIN.to_owned()), + ) + .with_detail(String::from_utf8_lossy(&output.stderr))); + } + + let resolved = String::from_utf8(output.stdout).map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvalid, + format!("rustup which {tool}"), + Some("utf-8-absolute-path".to_owned()), + ) + .with_detail(error.to_string()) + })?; + let path = parse_resolved_tool_path(&resolved).ok_or_else(|| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvalid, + format!("rustup which {tool}"), + Some("one-absolute-path".to_owned()), + ) + })?; + fs::canonicalize(&path).map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvalid, + path.display().to_string(), + Some(tool.to_owned()), + ) + .with_detail(error.to_string()) + }) +} + +fn parse_resolved_tool_path(output: &str) -> Option { + let mut lines = output.lines(); + let path = lines.next()?; + if path.is_empty() || lines.next().is_some() { + return None; + } + let path = PathBuf::from(path); + path.is_absolute().then_some(path) +} + +fn invoke_tool_identity(path: &Path, version_arg: &str, tool: &str) -> Result { + let output = Command::new(path) + .arg(version_arg) + .output() + .map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvocationFailed, + path.display().to_string(), + Some(tool.to_owned()), + ) + .with_detail(error.to_string()) + })?; + if !output.status.success() { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvocationFailed, + path.display().to_string(), + Some(tool.to_owned()), + ) + .with_detail(String::from_utf8_lossy(&output.stderr))); + } + String::from_utf8(output.stdout).map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvalid, + path.display().to_string(), + Some(tool.to_owned()), + ) + .with_detail(error.to_string()) + }) +} + +fn authenticate_tool_identity<'a>( + tool: &str, + identity: &'a str, + expected_commit: &str, +) -> Result<&'a str> { + let release = parse_identity_field(identity, "release").ok_or_else(|| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvalid, + tool, + Some("release".to_owned()), + ) + })?; + if release != PINNED_RUST_TOOLCHAIN { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderReleaseMismatch, + release, + Some(PINNED_RUST_TOOLCHAIN.to_owned()), + )); + } + let commit = parse_identity_field(identity, "commit-hash").ok_or_else(|| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvalid, + tool, + Some("commit-hash".to_owned()), + ) + })?; + if commit != expected_commit { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderCommitMismatch, + commit, + Some(expected_commit.to_owned()), + )); + } + parse_identity_field(identity, "host").ok_or_else(|| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostInvalid, + tool, + Some("host".to_owned()), + ) + }) +} + +#[cfg(test)] +fn parse_rustc_host(version: &str) -> Option<&str> { + parse_identity_field(version, "host") +} + +fn parse_identity_field<'a>(identity: &'a str, field: &str) -> Option<&'a str> { + let prefix = format!("{field}: "); + let mut values = identity + .lines() + .filter_map(|line| line.strip_prefix(&prefix)); + let value = values.next()?; + if value.is_empty() || values.next().is_some() { + return None; + } + Some(value) +} + +fn ensure_checked_builder_host(host: &str) -> Result<()> { + if host == CHECKED_COMPONENT_BUILDER_HOST { + return Ok(()); + } + Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuilderHostMismatch, + host, + Some(CHECKED_COMPONENT_BUILDER_HOST.to_owned()), + )) +} + +/// Reads and fully audits an explicit component candidate. +pub(crate) fn read_component(input_path: &Path) -> Result { + authenticated_component(read_component_bytes(input_path)?) +} + +fn read_component_bytes(input_path: &Path) -> Result> { + fs::read(input_path).map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ComponentReadFailed, + input_path.display().to_string(), + None, + ) + .with_detail(error.to_string()) + }) +} + +pub(crate) fn ensure_designated_candidate_output( + output_path: &Path, + checked_path: &Path, +) -> Result<()> { + let aliases_checked = match same_file::is_same_file(output_path, checked_path) { + Ok(aliases) => aliases, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + normalized_destination(output_path)? == normalized_destination(checked_path)? + } + Err(error) => { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputReadFailed, + output_path.display().to_string(), + Some(checked_path.display().to_string()), + ) + .with_detail(error.to_string())); + } + }; + if aliases_checked { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::CheckedOutputRequiresPromotion, + output_path.display().to_string(), + Some(checked_path.display().to_string()), + )); + } + Ok(()) +} + +fn normalized_destination(path: &Path) -> Result { + match fs::canonicalize(path) { + Ok(path) => Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let file_name = path.file_name().ok_or_else(|| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::InvalidPath, + path.display().to_string(), + Some("file-name".to_owned()), + ) + })?; + fs::canonicalize(parent) + .map(|parent| parent.join(file_name)) + .map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputReadFailed, + path.display().to_string(), + Some("canonical-parent".to_owned()), + ) + .with_detail(error.to_string()) + }) + } + Err(error) => Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputReadFailed, + path.display().to_string(), + Some("canonical-path".to_owned()), + ) + .with_detail(error.to_string())), + } +} + +/// Audits two explicit candidates and admits only one exact expected identity. +pub(crate) fn read_reproducible_candidates( + first_path: &Path, + second_path: &Path, +) -> Result { + let first = read_component_bytes(first_path)?; + let second = read_component_bytes(second_path)?; + let aliased = same_file::is_same_file(first_path, second_path).map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ComponentReadFailed, + first_path.display().to_string(), + Some(second_path.display().to_string()), + ) + .with_detail(error.to_string()) + })?; + if aliased { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::CandidateAliased, + first_path.display().to_string(), + Some(second_path.display().to_string()), + )); + } + ensure_reproducible_candidates(&first, &second, APPROVED_CHECKED_COMPONENT_SHA256)?; + authenticated_component(first) +} + +/// Audits, authenticates, and intentionally writes two reproducible candidates. +pub(crate) fn promote_reproducible_candidates( + first_path: &Path, + second_path: &Path, + output_path: &Path, +) -> Result<(ProviderLowererComponent, ComponentOutputStatus)> { + let component = read_reproducible_candidates(first_path, second_path)?; + let status = sync_output(output_path, component.bytes(), ComponentOutputMode::Write)?; + Ok((component, status)) +} + +fn ensure_reproducible_candidates( + first: &[u8], + second: &[u8], + expected_sha256: &str, +) -> Result<()> { + if first != second { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::CandidateMismatch, + LOWERER_CONTRACT_COORDINATE, + Some(format!( + "first:{};second:{}", + digest_hex(&sha256(first)), + digest_hex(&sha256(second)) + )), + )); + } + if expected_sha256.len() != 64 + || !expected_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ExpectedDigestInvalid, + expected_sha256, + Some("lowercase-sha256".to_owned()), + )); + } + if expected_sha256 != APPROVED_CHECKED_COMPONENT_SHA256 { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::CandidateDigestMismatch, + APPROVED_CHECKED_COMPONENT_SHA256, + Some(expected_sha256.to_owned()), + )); + } + let observed = digest_hex(&sha256(first)); + if observed != expected_sha256 { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::CandidateDigestMismatch, + expected_sha256, + Some(observed), + )); + } + Ok(()) +} + +/// Structural facts established by [`audit_component`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ComponentAudit { + /// Number of exact top-level contract attestations. + pub(crate) contract_attestations: u32, + /// Number of exact type-only protocol imports (zero or one). + pub(crate) protocol_imports: u32, + /// Number of exact equality-bounded protocol type aliases (zero or two). + pub(crate) protocol_type_imports: u32, + /// Number of exact callable `lower` exports. + pub(crate) lower_exports: u32, +} + +/// Whether an explicit output should be checked or updated. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ComponentOutputMode { + /// Report missing or stale bytes without changing the output. + Check, + /// Write exact bytes when the output is missing or stale. + Write, +} + +/// Outcome of checking or writing an explicit component output. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ComponentOutputStatus { + /// The explicit output already contained the exact bytes. + Current, + /// The explicit output was written with the exact bytes. + Written, +} + +/// Builds, componentizes, attests, audits, and hashes the provider lowerer. +/// +/// Both paths are explicit inputs. This boundary performs no repository or +/// output discovery. Cargo is pinned to Rust 1.90.0, uses the lockfile, and +/// receives a caller-owned target directory. +pub(crate) fn build_component( + repository_root: &Path, + target_directory: &Path, + toolchain: &PinnedRustToolchain, +) -> Result { + let target_directory = absolute_target_directory(repository_root, target_directory); + let cargo_home = target_directory.join("cargo-home"); + let encoded_rustflags = + encoded_build_rustflags(repository_root, &target_directory, &cargo_home)?; + + let mut command = Command::new(&toolchain.cargo); + remove_ambient_cargo_build_overrides(&mut command, std::env::vars_os().map(|(name, _)| name)); + bind_pinned_toolchain(&mut command, toolchain); + let output = command + .args([ + "build", + "-p", + LOWERER_PACKAGE, + "--target", + "wasm32-unknown-unknown", + "--release", + "--locked", + ]) + .current_dir(repository_root) + .env("CARGO_HOME", &cargo_home) + .env("CARGO_TARGET_DIR", &target_directory) + .env("CARGO_INCREMENTAL", "0") + .env("CARGO_ENCODED_RUSTFLAGS", encoded_rustflags) + .env("SOURCE_DATE_EPOCH", "1") + .env_remove("RUSTFLAGS") + .env_remove("RUSTC_BOOTSTRAP") + .output() + .map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuildInvocationFailed, + LOWERER_PACKAGE, + Some(toolchain.cargo.display().to_string()), + ) + .with_detail(error.to_string()) + })?; + + if !output.status.success() { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::BuildFailed, + LOWERER_PACKAGE, + Some("wasm32-unknown-unknown/release".to_owned()), + ) + .with_detail(String::from_utf8_lossy(&output.stderr))); + } + + let core_path = target_directory + .join("wasm32-unknown-unknown") + .join("release") + .join(LOWERER_CORE_WASM); + let core_bytes = fs::read(&core_path).map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::CoreWasmReadFailed, + core_path.display().to_string(), + Some(LOWERER_CORE_WASM.to_owned()), + ) + .with_detail(error.to_string()) + })?; + + componentize(&core_bytes) +} + +fn encoded_build_rustflags( + repository_root: &Path, + target_directory: &Path, + cargo_home: &Path, +) -> Result { + let repository_root_text = path_text(repository_root)?; + let target_directory_text = path_text(target_directory)?; + let cargo_home_text = path_text(cargo_home)?; + + // rustc uses the last matching remap, so broad roots precede nested roots. + Ok([ + format!("--remap-path-prefix={repository_root_text}=/echo"), + format!("--remap-path-prefix={target_directory_text}=/target"), + format!("--remap-path-prefix={cargo_home_text}=/cargo"), + ] + .join("\u{1f}")) +} + +fn remove_ambient_cargo_build_overrides( + command: &mut Command, + names: impl IntoIterator, +) { + for name in names { + let Some(name_text) = name.to_str() else { + continue; + }; + if ["CARGO_PROFILE_", "CARGO_BUILD_", "CARGO_TARGET_"] + .iter() + .any(|prefix| name_text.starts_with(prefix)) + { + command.env_remove(name); + } + } +} + +fn bind_pinned_toolchain(command: &mut Command, toolchain: &PinnedRustToolchain) { + command + .env("RUSTC", &toolchain.rustc) + .env("CARGO_BUILD_RUSTC", &toolchain.rustc) + .env("RUSTC_WRAPPER", "") + .env("RUSTC_WORKSPACE_WRAPPER", "") + .env_remove("CARGO_BUILD_RUSTC_WRAPPER") + .env_remove("CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER"); +} + +/// Componentizes explicit core Wasm bytes and appends the exact attestation. +pub(crate) fn componentize(core_bytes: &[u8]) -> Result { + let encoder = ComponentEncoder::default() + .validate(true) + .merge_imports_based_on_semver(false) + .module(core_bytes) + .map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ComponentEncodingFailed, + LOWERER_PACKAGE, + Some("core-module".to_owned()), + ) + .with_detail(error.to_string()) + })?; + let mut encoder = encoder; + let mut bytes = encoder.encode().map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ComponentEncodingFailed, + LOWERER_PACKAGE, + Some("component".to_owned()), + ) + .with_detail(error.to_string()) + })?; + + append_contract_attestation(&mut bytes); + authenticated_component(bytes) +} + +fn authenticated_component(bytes: Vec) -> Result { + audit_component(&bytes)?; + let sha256 = sha256(&bytes); + Ok(ProviderLowererComponent { bytes, sha256 }) +} + +/// Audits exact attestation, import, export, and WebAssembly validity claims. +pub(crate) fn audit_component(bytes: &[u8]) -> Result { + let mut depth = 0_u32; + let mut outer_is_component = false; + let mut contract_attestations = 0_u32; + let mut protocol_imports = 0_u32; + let mut protocol_type_imports = [false; PROTOCOL_TYPE_IMPORTS.len()]; + let mut lower_exports = 0_u32; + let mut top_level_types = Vec::new(); + + for payload in Parser::new(0).parse_all(bytes) { + let payload = payload.map_err(|error| component_invalid(error.to_string()))?; + match payload { + Payload::Version { encoding, .. } => { + depth = depth.checked_add(1).ok_or_else(|| { + component_invalid("component nesting depth overflow".to_owned()) + })?; + if depth == 1 { + outer_is_component = encoding == Encoding::Component; + } + } + Payload::End(_) => { + depth = depth.checked_sub(1).ok_or_else(|| { + component_invalid("component nesting depth underflow".to_owned()) + })?; + } + Payload::CustomSection(section) if section.name() == CONTRACT_SECTION_NAME => { + if depth != 1 { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ContractAttestationNested, + CONTRACT_SECTION_NAME, + Some(format!("depth:{depth}")), + )); + } + contract_attestations += 1; + if contract_attestations > 1 { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ContractAttestationDuplicate, + CONTRACT_SECTION_NAME, + Some(LOWERER_CONTRACT_COORDINATE.to_owned()), + )); + } + if section.data() != LOWERER_CONTRACT_COORDINATE.as_bytes() { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ContractAttestationMismatch, + CONTRACT_SECTION_NAME, + Some(LOWERER_CONTRACT_COORDINATE.to_owned()), + )); + } + } + Payload::ImportSection(section) => { + let import = section + .into_imports() + .next() + .transpose() + .map_err(|error| component_invalid(error.to_string()))?; + if let Some(import) = import { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::CoreImportForbidden, + "core-import", + Some(format!("{}::{}", import.module, import.name)), + )); + } + } + Payload::ComponentTypeSection(section) if depth == 1 => { + for component_type in section { + let component_type = + component_type.map_err(|error| component_invalid(error.to_string()))?; + top_level_types.push(type_only_instance(&component_type)); + } + } + Payload::ComponentImportSection(section) => { + for import in section { + let import = import.map_err(|error| component_invalid(error.to_string()))?; + if depth != 1 || import.name.implements.is_some() { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ComponentImportForbidden, + import.name.name, + Some(format!("{};depth:{depth}", import.ty.kind().desc())), + )); + } + + if import.name.name == PROTOCOL_INSTANCE_COORDINATE { + protocol_imports += 1; + if protocol_imports > 1 { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ProtocolImportDuplicate, + PROTOCOL_INSTANCE_COORDINATE, + None, + )); + } + + let ComponentTypeRef::Instance(type_index) = import.ty else { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ProtocolImportInvalid, + PROTOCOL_INSTANCE_COORDINATE, + Some(import.ty.kind().desc().to_owned()), + )); + }; + if top_level_types.get(type_index as usize) != Some(&true) { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ProtocolImportInvalid, + PROTOCOL_INSTANCE_COORDINATE, + Some(format!("type-index:{type_index}")), + )); + } + continue; + } + + let Some(alias_index) = PROTOCOL_TYPE_IMPORTS + .iter() + .position(|name| *name == import.name.name) + else { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ComponentImportForbidden, + import.name.name, + Some(import.ty.kind().desc().to_owned()), + )); + }; + if protocol_type_imports[alias_index] { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ProtocolImportDuplicate, + import.name.name, + Some("type-alias".to_owned()), + )); + } + if !matches!(import.ty, ComponentTypeRef::Type(TypeBounds::Eq(_))) { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ProtocolImportInvalid, + import.name.name, + Some(import.ty.kind().desc().to_owned()), + )); + } + protocol_type_imports[alias_index] = true; + } + } + Payload::ComponentExportSection(section) if depth == 1 => { + for export in section { + let export = export.map_err(|error| component_invalid(error.to_string()))?; + if export.name.name != LOWER_EXPORT + || export.name.implements.is_some() + || export.kind != ComponentExternalKind::Func + { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::LowerExportInvalid, + export.name.name, + Some(export.kind.desc().to_owned()), + )); + } + lower_exports += 1; + if lower_exports > 1 { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::LowerExportDuplicate, + LOWER_EXPORT, + None, + )); + } + } + } + _ => {} + } + } + + if !outer_is_component { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ComponentEncodingInvalid, + LOWERER_PACKAGE, + Some("component-model".to_owned()), + )); + } + if contract_attestations != 1 { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ContractAttestationMissing, + CONTRACT_SECTION_NAME, + Some(LOWERER_CONTRACT_COORDINATE.to_owned()), + )); + } + let protocol_type_import_count = protocol_type_imports + .iter() + .fold(0_u32, |count, present| count + u32::from(*present)); + if !matches!( + (protocol_imports, protocol_type_import_count), + (0, 0) | (1, 2) + ) { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ProtocolImportClosureInvalid, + PROTOCOL_INSTANCE_COORDINATE, + Some(format!( + "instances:{protocol_imports};type-aliases:{protocol_type_import_count}" + )), + )); + } + if lower_exports != 1 { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::LowerExportMissing, + LOWER_EXPORT, + None, + )); + } + + Validator::new_with_features(WasmFeatures::all()) + .validate_all(bytes) + .map_err(|error| component_invalid(error.to_string()))?; + authenticate_component_world(bytes)?; + + Ok(ComponentAudit { + contract_attestations, + protocol_imports, + protocol_type_imports: protocol_type_import_count, + lower_exports, + }) +} + +/// Checks or writes exact component bytes at a caller-selected output path. +pub(crate) fn sync_output( + output_path: &Path, + bytes: &[u8], + mode: ComponentOutputMode, +) -> Result { + validate_output_type(output_path)?; + match fs::read(output_path) { + Ok(existing) if existing == bytes => Ok(ComponentOutputStatus::Current), + Ok(existing) if mode == ComponentOutputMode::Check => { + Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputDrift, + output_path.display().to_string(), + Some(format!( + "expected:{};observed:{}", + digest_hex(&sha256(bytes)), + digest_hex(&sha256(&existing)) + )), + )) + } + Ok(_) => write_output(output_path, bytes), + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && mode == ComponentOutputMode::Check => + { + Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputMissing, + output_path.display().to_string(), + None, + )) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + write_output(output_path, bytes) + } + Err(error) => Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputReadFailed, + output_path.display().to_string(), + None, + ) + .with_detail(error.to_string())), + } +} + +fn write_output(output_path: &Path, bytes: &[u8]) -> Result { + write_output_with(output_path, |temporary| temporary.write_all(bytes)) +} + +fn write_output_with( + output_path: &Path, + writer: impl FnOnce(&mut File) -> std::io::Result<()>, +) -> Result { + if let Some(parent) = output_path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|error| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputWriteFailed, + output_path.display().to_string(), + Some("create-parent".to_owned()), + ) + .with_detail(error.to_string()) + })?; + } + } + + let (temporary_path, mut temporary) = create_temporary_sibling(output_path)?; + if let Err(error) = writer(&mut temporary).and_then(|()| temporary.sync_all()) { + drop(temporary); + let _ = fs::remove_file(&temporary_path); + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputWriteFailed, + output_path.display().to_string(), + Some("write-temporary".to_owned()), + ) + .with_detail(error.to_string())); + } + drop(temporary); + + fs::rename(&temporary_path, output_path).map_err(|error| { + let _ = fs::remove_file(&temporary_path); + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputWriteFailed, + output_path.display().to_string(), + Some("atomic-replace".to_owned()), + ) + .with_detail(error.to_string()) + })?; + Ok(ComponentOutputStatus::Written) +} + +fn validate_output_type(output_path: &Path) -> Result<()> { + match fs::symlink_metadata(output_path) { + Ok(metadata) if metadata.file_type().is_file() => Ok(()), + Ok(metadata) => { + let kind = if metadata.file_type().is_symlink() { + "symlink" + } else if metadata.file_type().is_dir() { + "directory" + } else { + "non-regular" + }; + Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputTypeInvalid, + output_path.display().to_string(), + Some(kind.to_owned()), + )) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputReadFailed, + output_path.display().to_string(), + Some("metadata".to_owned()), + ) + .with_detail(error.to_string())), + } +} + +fn create_temporary_sibling(output_path: &Path) -> Result<(PathBuf, File)> { + let parent = output_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let file_name = output_path.file_name().ok_or_else(|| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::InvalidPath, + output_path.display().to_string(), + Some("file-name".to_owned()), + ) + })?; + + for nonce in 0_u16..128 { + let mut temporary_name = OsString::from("."); + temporary_name.push(file_name); + temporary_name.push(format!(".tmp-{}-{nonce}", std::process::id())); + let temporary_path = parent.join(temporary_name); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path) + { + Ok(file) => return Ok((temporary_path, file)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputWriteFailed, + output_path.display().to_string(), + Some("create-temporary".to_owned()), + ) + .with_detail(error.to_string())); + } + } + } + + Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::OutputWriteFailed, + output_path.display().to_string(), + Some("temporary-name-exhausted".to_owned()), + )) +} + +fn append_contract_attestation(bytes: &mut Vec) { + CustomSection { + name: Cow::Borrowed(CONTRACT_SECTION_NAME), + data: Cow::Borrowed(LOWERER_CONTRACT_COORDINATE.as_bytes()), + } + .append_to_component(bytes); +} + +fn type_only_instance(component_type: &ComponentType<'_>) -> bool { + let ComponentType::Instance(declarations) = component_type else { + return false; + }; + let mut export_count = 0_u32; + for declaration in declarations { + if let InstanceTypeDeclaration::Export { ty, .. } = declaration { + export_count += 1; + if !matches!(ty, ComponentTypeRef::Type(_)) { + return false; + } + } + } + export_count > 0 +} + +fn authenticate_component_world(bytes: &[u8]) -> Result<()> { + let (mut expected_resolve, expected_world) = + parse_lowerer_world(LOWERER_WIT_SOURCE, "frozen-wit")?; + let expected_package = expected_resolve.worlds[expected_world] + .package + .map(|package| expected_resolve.packages[package].name.clone()) + .ok_or_else(|| world_contract_invalid("frozen-wit", "lowerer world has no package"))?; + canonicalize_world_type_closure(&mut expected_resolve, expected_world); + let expected = encode_world_contract(&expected_resolve, expected_world, "frozen-wit")?; + + let decoded = wit_component::decode(bytes).map_err(|error| { + world_contract_invalid("candidate-component", "component WIT decode failed") + .with_detail(error.to_string()) + })?; + let DecodedWasm::Component(mut observed_resolve, observed_world) = decoded else { + return Err(world_contract_invalid( + "candidate-component", + "decoded bytes are a WIT package rather than a component", + )); + }; + normalize_decoded_world_identity(&mut observed_resolve, observed_world, expected_package)?; + canonicalize_world_type_closure(&mut observed_resolve, observed_world); + let observed = encode_world_contract(&observed_resolve, observed_world, "candidate-component")?; + compare_world_contracts(&expected, &observed) +} + +#[cfg(test)] +fn authenticate_wit_source_for_test(source: &str) -> Result<()> { + let (mut expected_resolve, expected_world) = + parse_lowerer_world(LOWERER_WIT_SOURCE, "frozen-wit")?; + canonicalize_world_type_closure(&mut expected_resolve, expected_world); + let expected = encode_world_contract(&expected_resolve, expected_world, "frozen-wit")?; + let (mut observed_resolve, observed_world) = parse_lowerer_world(source, "candidate-wit")?; + canonicalize_world_type_closure(&mut observed_resolve, observed_world); + let observed = encode_world_contract(&observed_resolve, observed_world, "candidate-wit")?; + compare_world_contracts(&expected, &observed) +} + +fn parse_lowerer_world(source: &str, reference: &str) -> Result<(Resolve, WorldId)> { + let mut resolve = Resolve::default(); + let package = resolve + .push_str("edict-target-provider.wit", source) + .map_err(|error| { + world_contract_invalid(reference, "WIT parse failed").with_detail(error.to_string()) + })?; + let world = resolve + .select_world(&[package], Some(LOWERER_WORLD_NAME)) + .map_err(|error| { + world_contract_invalid(reference, "lowerer world selection failed") + .with_detail(error.to_string()) + })?; + Ok((resolve, world)) +} + +fn normalize_decoded_world_identity( + resolve: &mut Resolve, + world: WorldId, + package_name: PackageName, +) -> Result<()> { + let package = resolve.worlds[world].package.ok_or_else(|| { + world_contract_invalid("candidate-component", "decoded world has no package") + })?; + let previous_world_name = std::mem::replace( + &mut resolve.worlds[world].name, + LOWERER_WORLD_NAME.to_owned(), + ); + let package_worlds = &mut resolve.packages[package].worlds; + if package_worlds.get(&previous_world_name) == Some(&world) { + package_worlds.shift_remove(&previous_world_name); + package_worlds.insert(LOWERER_WORLD_NAME.to_owned(), world); + } + resolve.packages[package].name = package_name; + Ok(()) +} + +fn canonicalize_world_type_closure(resolve: &mut Resolve, world: WorldId) { + let live_types = { + let mut live = LiveTypes::default(); + for item in resolve.worlds[world] + .imports + .values() + .chain(resolve.worlds[world].exports.values()) + { + match item { + WorldItem::Function(function) => live.add_func(resolve, function), + WorldItem::Type { id, .. } => live.add_type_id(resolve, *id), + WorldItem::Interface { id, .. } => { + for function in resolve.interfaces[*id].functions.values() { + live.add_func(resolve, function); + } + } + } + } + live.iter().collect::>() + }; + + for (_, interface) in &mut resolve.interfaces { + let declared_types = std::mem::take(&mut interface.types); + for live_type in &live_types { + if let Some((name, id)) = declared_types + .iter() + .find(|(_, declared_type)| *declared_type == live_type) + { + interface.types.insert(name.clone(), *id); + } + } + } +} + +fn encode_world_contract(resolve: &Resolve, world: WorldId, reference: &str) -> Result> { + wit_component::metadata::encode(resolve, world, StringEncoding::UTF8, None).map_err(|error| { + world_contract_invalid(reference, "canonical world encoding failed") + .with_detail(error.to_string()) + }) +} + +fn compare_world_contracts(expected: &[u8], observed: &[u8]) -> Result<()> { + if expected == observed { + return Ok(()); + } + Err(ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::WorldContractMismatch, + LOWERER_CONTRACT_COORDINATE, + Some(format!( + "expected:{};observed:{}", + digest_hex(&sha256(expected)), + digest_hex(&sha256(observed)) + )), + )) +} + +fn world_contract_invalid( + reference: impl Into, + detail: impl Into, +) -> ProviderLowererComponentError { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::WorldContractInvalid, + LOWERER_CONTRACT_COORDINATE, + Some(reference.into()), + ) + .with_detail(detail) +} + +fn absolute_target_directory(repository_root: &Path, target_directory: &Path) -> PathBuf { + if target_directory.is_absolute() { + target_directory.to_owned() + } else { + repository_root.join(target_directory) + } +} + +fn path_text(path: &Path) -> Result<&str> { + path.to_str().ok_or_else(|| { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::InvalidPath, + path.display().to_string(), + Some("utf-8".to_owned()), + ) + }) +} + +fn component_invalid(detail: String) -> ProviderLowererComponentError { + ProviderLowererComponentError::new( + ProviderLowererComponentErrorKind::ComponentInvalid, + LOWERER_PACKAGE, + Some("wasmparser".to_owned()), + ) + .with_detail(detail) +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +fn digest_hex(digest: &[u8; 32]) -> String { + hex::encode(digest) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use wasm_encoder::Component; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + #[test] + fn appends_one_exact_top_level_attestation( + ) -> std::result::Result<(), Box> { + let mut bytes = Component::new().finish(); + append_contract_attestation(&mut bytes); + + let mut sections = Vec::new(); + for payload in Parser::new(0).parse_all(&bytes) { + match payload? { + Payload::CustomSection(section) if section.name() == CONTRACT_SECTION_NAME => { + sections.push(section.data().to_vec()); + } + _ => {} + } + } + + assert_eq!(sections, [LOWERER_CONTRACT_COORDINATE.as_bytes()]); + Ok(()) + } + + #[test] + fn rejects_duplicate_contract_attestations( + ) -> std::result::Result<(), Box> { + let mut bytes = Component::new().finish(); + append_contract_attestation(&mut bytes); + append_contract_attestation(&mut bytes); + + let error = match audit_component(&bytes) { + Err(error) => error, + Ok(_) => { + return Err(std::io::Error::other("duplicate attestation must fail").into()); + } + }; + + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::ContractAttestationDuplicate + ); + assert_eq!(error.subject(), CONTRACT_SECTION_NAME); + assert_eq!(error.reference(), Some(LOWERER_CONTRACT_COORDINATE)); + Ok(()) + } + + #[test] + fn rejects_mismatched_contract_attestation_bytes( + ) -> std::result::Result<(), Box> { + let mut bytes = Component::new().finish(); + CustomSection { + name: Cow::Borrowed(CONTRACT_SECTION_NAME), + data: Cow::Borrowed(b"edict:target-provider/lowerer@9.9.9"), + } + .append_to_component(&mut bytes); + + let error = match audit_component(&bytes) { + Err(error) => error, + Ok(_) => return Err(std::io::Error::other("wrong attestation must fail").into()), + }; + + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::ContractAttestationMismatch + ); + Ok(()) + } + + #[test] + fn zero_import_topology_requires_only_the_lower_export( + ) -> std::result::Result<(), Box> { + let mut bytes = Component::new().finish(); + append_contract_attestation(&mut bytes); + + let error = match audit_component(&bytes) { + Err(error) => error, + Ok(_) => { + return Err( + std::io::Error::other("component without lower export must fail").into(), + ); + } + }; + + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::LowerExportMissing + ); + Ok(()) + } + + #[test] + fn wrong_lower_parameter_and_result_cannot_receive_the_frozen_attestation( + ) -> std::result::Result<(), Box> { + let source = + include_str!("../../crates/echo-edict-provider-lowerer/wit/edict-target-provider.wit") + .replacen( + "export lower: func(request: lowering-request-v1) -> lowering-result-v1;", + "export lower: func(request: lowering-result-v1) -> lowering-request-v1;", + 1, + ); + + let error = match authenticate_wit_source_for_test(&source) { + Err(error) => error, + Ok(()) => { + return Err(std::io::Error::other( + "wrong lower signature received the frozen attestation", + ) + .into()); + } + }; + + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::WorldContractMismatch + ); + assert_eq!(error.subject(), LOWERER_CONTRACT_COORDINATE); + Ok(()) + } + + #[test] + fn reachable_alias_graph_change_cannot_receive_the_frozen_attestation( + ) -> std::result::Result<(), Box> { + let source = + include_str!("../../crates/echo-edict-provider-lowerer/wit/edict-target-provider.wit") + .replacen( + " core: bound-artifact,\n target-profile: bound-artifact,", + " core: string,\n target-profile: bound-artifact,", + 1, + ); + + let error = match authenticate_wit_source_for_test(&source) { + Err(error) => error, + Ok(()) => { + return Err(std::io::Error::other( + "unrelated alias graph received the frozen attestation", + ) + .into()); + } + }; + + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::WorldContractMismatch + ); + assert_eq!(error.subject(), LOWERER_CONTRACT_COORDINATE); + Ok(()) + } + + #[test] + fn check_reports_drift_without_rewriting() -> std::result::Result<(), Box> + { + let output = temporary_output("check-drift"); + fs::write(&output, b"old")?; + + let error = match sync_output(&output, b"new", ComponentOutputMode::Check) { + Err(error) => error, + Ok(_) => return Err(std::io::Error::other("check must report drift").into()), + }; + + assert_eq!(error.kind(), ProviderLowererComponentErrorKind::OutputDrift); + assert_eq!(fs::read(&output)?, b"old"); + fs::remove_file(output)?; + Ok(()) + } + + #[test] + fn write_is_exact_and_idempotent() -> std::result::Result<(), Box> { + let output = temporary_output("write"); + + assert_eq!( + sync_output(&output, b"component", ComponentOutputMode::Write)?, + ComponentOutputStatus::Written + ); + assert_eq!( + sync_output(&output, b"component", ComponentOutputMode::Write)?, + ComponentOutputStatus::Current + ); + assert_eq!(fs::read(&output)?, b"component"); + fs::remove_file(output)?; + Ok(()) + } + + #[test] + fn failed_atomic_replacement_preserves_the_previous_output( + ) -> std::result::Result<(), Box> { + let output = temporary_output("atomic-write-failure"); + fs::write(&output, b"preserve-me")?; + + let error = match write_output_with(&output, |temporary| { + temporary.write_all(b"partial")?; + Err(std::io::Error::other("injected write failure")) + }) { + Err(error) => error, + Ok(_) => return Err(std::io::Error::other("partial output was installed").into()), + }; + + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::OutputWriteFailed + ); + assert_eq!(fs::read(&output)?, b"preserve-me"); + fs::remove_file(output)?; + Ok(()) + } + + #[test] + fn non_regular_output_paths_fail_closed() -> std::result::Result<(), Box> + { + let output = temporary_output("non-regular"); + fs::create_dir(&output)?; + + let error = match sync_output(&output, b"component", ComponentOutputMode::Write) { + Err(error) => error, + Ok(_) => return Err(std::io::Error::other("directory output was accepted").into()), + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::OutputTypeInvalid + ); + fs::remove_dir(output)?; + Ok(()) + } + + #[cfg(unix)] + #[test] + fn symlink_outputs_fail_without_mutating_the_link_target( + ) -> std::result::Result<(), Box> { + use std::os::unix::fs::symlink; + + let target = temporary_output("symlink-target"); + let output = temporary_output("symlink-output"); + fs::write(&target, b"preserve-target")?; + symlink(&target, &output)?; + + let error = match sync_output(&output, b"component", ComponentOutputMode::Write) { + Err(error) => error, + Ok(_) => return Err(std::io::Error::other("symlink output was accepted").into()), + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::OutputTypeInvalid + ); + assert_eq!(error.reference(), Some("symlink")); + assert_eq!(fs::read(&target)?, b"preserve-target"); + assert!(fs::symlink_metadata(&output)?.file_type().is_symlink()); + fs::remove_file(output)?; + fs::remove_file(target)?; + Ok(()) + } + + #[test] + fn provider_component_build_flags_remap_cargo_home_to_one_stable_prefix( + ) -> std::result::Result<(), Box> { + assert_eq!( + encoded_build_rustflags( + Path::new("/workspace/echo"), + Path::new("/workspace/echo/target/provider-a"), + Path::new("/workspace/echo/target/provider-a/cargo-home"), + )?, + [ + "--remap-path-prefix=/workspace/echo=/echo", + "--remap-path-prefix=/workspace/echo/target/provider-a=/target", + "--remap-path-prefix=/workspace/echo/target/provider-a/cargo-home=/cargo", + ] + .join("\u{1f}") + ); + assert_eq!( + encoded_build_rustflags( + Path::new("/checkout-b/echo"), + Path::new("/var/tmp/provider-b"), + Path::new("/var/tmp/provider-b/cargo-home"), + )?, + [ + "--remap-path-prefix=/checkout-b/echo=/echo", + "--remap-path-prefix=/var/tmp/provider-b=/target", + "--remap-path-prefix=/var/tmp/provider-b/cargo-home=/cargo", + ] + .join("\u{1f}") + ); + Ok(()) + } + + #[test] + fn checked_builder_policy_is_explicit_and_fails_closed( + ) -> std::result::Result<(), Box> { + assert_eq!( + parse_rustc_host( + "rustc 1.90.0 (1159e78c4 2025-09-14)\nbinary: rustc\nhost: x86_64-unknown-linux-gnu\n" + ), + Some(CHECKED_COMPONENT_BUILDER_HOST) + ); + assert_eq!( + parse_rustc_host("host: x86_64-unknown-linux-gnu\nhost: aarch64-apple-darwin\n"), + None + ); + + assert!(ensure_checked_builder_host(CHECKED_COMPONENT_BUILDER_HOST).is_ok()); + let error = match ensure_checked_builder_host("aarch64-apple-darwin") { + Err(error) => error, + Ok(()) => { + return Err( + std::io::Error::other("a non-designated builder must fail closed").into(), + ); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::BuilderHostMismatch + ); + assert_eq!(error.subject(), "aarch64-apple-darwin"); + assert_eq!(error.reference(), Some(CHECKED_COMPONENT_BUILDER_HOST)); + Ok(()) + } + + #[test] + fn pinned_tool_paths_and_identities_fail_closed( + ) -> std::result::Result<(), Box> { + assert_eq!( + parse_resolved_tool_path("/toolchains/1.90.0/bin/rustc\n"), + Some(PathBuf::from("/toolchains/1.90.0/bin/rustc")) + ); + for malformed in ["", "relative/rustc\n", "/first/rustc\n/second/rustc\n"] { + assert_eq!(parse_resolved_tool_path(malformed), None); + } + + let valid = format!( + "release: {PINNED_RUST_TOOLCHAIN}\ncommit-hash: {PINNED_RUSTC_COMMIT}\nhost: {CHECKED_COMPONENT_BUILDER_HOST}\n" + ); + assert_eq!( + authenticate_tool_identity("rustc", &valid, PINNED_RUSTC_COMMIT)?, + CHECKED_COMPONENT_BUILDER_HOST + ); + + let duplicate_release = format!( + "release: {PINNED_RUST_TOOLCHAIN}\nrelease: {PINNED_RUST_TOOLCHAIN}\ncommit-hash: {PINNED_RUSTC_COMMIT}\nhost: {CHECKED_COMPONENT_BUILDER_HOST}\n" + ); + let error = + match authenticate_tool_identity("rustc", &duplicate_release, PINNED_RUSTC_COMMIT) { + Err(error) => error, + Ok(_) => return Err(std::io::Error::other("duplicate release was accepted").into()), + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::BuilderHostInvalid + ); + + let wrong_release = format!( + "release: 1.91.0\ncommit-hash: {PINNED_RUSTC_COMMIT}\nhost: {CHECKED_COMPONENT_BUILDER_HOST}\n" + ); + let error = match authenticate_tool_identity("rustc", &wrong_release, PINNED_RUSTC_COMMIT) { + Err(error) => error, + Ok(_) => return Err(std::io::Error::other("wrong release was accepted").into()), + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::BuilderReleaseMismatch + ); + + let wrong_commit = format!( + "release: {PINNED_RUST_TOOLCHAIN}\ncommit-hash: deadbeef\nhost: {CHECKED_COMPONENT_BUILDER_HOST}\n" + ); + let error = match authenticate_tool_identity("rustc", &wrong_commit, PINNED_RUSTC_COMMIT) { + Err(error) => error, + Ok(_) => return Err(std::io::Error::other("wrong commit was accepted").into()), + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::BuilderCommitMismatch + ); + Ok(()) + } + + #[test] + fn installed_pinned_toolchain_has_one_authenticated_identity( + ) -> std::result::Result<(), Box> { + let toolchain = pinned_rust_toolchain()?; + assert!(toolchain.cargo.is_absolute()); + assert!(toolchain.rustc.is_absolute()); + assert!(!toolchain.host().is_empty()); + Ok(()) + } + + #[test] + fn ambient_cargo_build_overrides_are_removed_by_prefix_family() { + let mut command = Command::new("cargo"); + let overridden = [ + "CARGO_PROFILE_RELEASE_OPT_LEVEL", + "CARGO_PROFILE_RELEASE_BUILD_OVERRIDE_DEBUG", + "CARGO_BUILD_TARGET", + "CARGO_BUILD_JOBS", + "CARGO_TARGET_DIR", + "CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS", + ]; + for name in overridden { + command.env(name, "ambient"); + } + command.env("CARGO_NET_OFFLINE", "true"); + + remove_ambient_cargo_build_overrides( + &mut command, + overridden + .into_iter() + .chain(["CARGO_NET_OFFLINE"]) + .map(OsString::from), + ); + + for name in overridden { + assert!(command_environment_is_removed(&command, name)); + } + assert_eq!( + command_environment_value(&command, "CARGO_NET_OFFLINE"), + Some(std::ffi::OsStr::new("true")) + ); + } + + #[test] + fn cargo_build_is_bound_to_the_authenticated_toolchain() { + let toolchain = PinnedRustToolchain { + cargo: PathBuf::from("/approved/cargo"), + rustc: PathBuf::from("/approved/rustc"), + host: CHECKED_COMPONENT_BUILDER_HOST.to_owned(), + }; + let mut command = Command::new(&toolchain.cargo); + for name in [ + "RUSTC", + "CARGO_BUILD_RUSTC", + "RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + ] { + command.env(name, "/ambient/override"); + } + + bind_pinned_toolchain(&mut command, &toolchain); + + assert_eq!(command.get_program(), toolchain.cargo.as_os_str()); + assert_eq!( + command_environment_value(&command, "RUSTC"), + Some(toolchain.rustc.as_os_str()) + ); + assert_eq!( + command_environment_value(&command, "CARGO_BUILD_RUSTC"), + Some(toolchain.rustc.as_os_str()) + ); + for name in ["RUSTC_WRAPPER", "RUSTC_WORKSPACE_WRAPPER"] { + assert_eq!( + command_environment_value(&command, name), + Some(std::ffi::OsStr::new("")) + ); + } + for name in [ + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + ] { + assert!(command_environment_is_removed(&command, name)); + } + } + + fn command_environment_value<'a>( + command: &'a Command, + name: &str, + ) -> Option<&'a std::ffi::OsStr> { + command + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new(name)) + .and_then(|(_, value)| value) + } + + fn command_environment_is_removed(command: &Command, name: &str) -> bool { + command + .get_envs() + .any(|(key, value)| key == std::ffi::OsStr::new(name) && value.is_none()) + } + + #[test] + fn explicit_component_candidates_are_audited_before_use( + ) -> std::result::Result<(), Box> { + let candidate = temporary_output("candidate-invalid"); + fs::write(&candidate, b"not-a-component")?; + + let error = match read_component(&candidate) { + Err(error) => error, + Ok(_) => { + return Err( + std::io::Error::other("an unaudited candidate became a component").into(), + ); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::ComponentInvalid + ); + fs::remove_file(candidate)?; + Ok(()) + } + + #[test] + fn failed_promotion_never_touches_the_output( + ) -> std::result::Result<(), Box> { + let first = temporary_output("promotion-invalid-a"); + let second = temporary_output("promotion-invalid-b"); + let output = temporary_output("promotion-preserved"); + fs::write(&first, b"not-a-component")?; + fs::write(&second, b"not-a-component")?; + fs::write(&output, b"preserve-me")?; + let error = match promote_reproducible_candidates(&first, &second, &output) { + Err(error) => error, + Ok(_) => { + return Err(std::io::Error::other("an invalid candidate was promoted").into()); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::CandidateDigestMismatch + ); + assert_eq!(fs::read(&output)?, b"preserve-me"); + fs::remove_file(first)?; + fs::remove_file(second)?; + fs::remove_file(output)?; + Ok(()) + } + + #[test] + fn promotion_rejects_one_candidate_supplied_twice( + ) -> std::result::Result<(), Box> { + let candidate = temporary_output("promotion-aliased"); + fs::write(&candidate, b"one-candidate")?; + let error = match read_reproducible_candidates(&candidate, &candidate) { + Err(error) => error, + Ok(_) => { + return Err(std::io::Error::other( + "one candidate path satisfied the two-candidate boundary", + ) + .into()); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::CandidateAliased + ); + fs::remove_file(candidate)?; + Ok(()) + } + + #[test] + fn promotion_rejects_two_paths_to_one_underlying_file( + ) -> std::result::Result<(), Box> { + let first = temporary_output("promotion-hardlink-a"); + let second = temporary_output("promotion-hardlink-b"); + fs::write(&first, b"one-underlying-file")?; + fs::hard_link(&first, &second)?; + + let error = match read_reproducible_candidates(&first, &second) { + Err(error) => error, + Ok(_) => { + return Err(std::io::Error::other( + "hard-linked paths satisfied the two-candidate boundary", + ) + .into()); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::CandidateAliased + ); + fs::remove_file(first)?; + fs::remove_file(second)?; + Ok(()) + } + + #[test] + fn designated_candidate_build_cannot_target_the_checked_output( + ) -> std::result::Result<(), Box> { + let checked = temporary_output("checked-route"); + fs::write(&checked, b"checked")?; + + let error = match ensure_designated_candidate_output(&checked, &checked) { + Err(error) => error, + Ok(()) => { + return Err(std::io::Error::other( + "one designated build could write the checked artifact", + ) + .into()); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::CheckedOutputRequiresPromotion + ); + let alias = temporary_output("checked-route-hardlink"); + fs::hard_link(&checked, &alias)?; + let error = match ensure_designated_candidate_output(&alias, &checked) { + Err(error) => error, + Ok(()) => { + return Err(std::io::Error::other( + "hard-linked candidate output bypassed promotion", + ) + .into()); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::CheckedOutputRequiresPromotion + ); + fs::remove_file(alias)?; + fs::remove_file(checked)?; + Ok(()) + } + + #[test] + fn promotion_requires_two_equal_candidates_and_the_expected_digest( + ) -> std::result::Result<(), Box> { + let first = b"first"; + let second = b"second"; + let first_digest = digest_hex(&sha256(first)); + let second_digest = digest_hex(&sha256(second)); + + let error = match ensure_reproducible_candidates(first, second, &first_digest) { + Err(error) => error, + Ok(()) => { + return Err(std::io::Error::other("different candidates were promotable").into()); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::CandidateMismatch + ); + + let error = match ensure_reproducible_candidates(first, first, "not-a-sha256") { + Err(error) => error, + Ok(()) => { + return Err( + std::io::Error::other("a malformed expected digest was promotable").into(), + ); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::ExpectedDigestInvalid + ); + + let error = match ensure_reproducible_candidates(first, first, &second_digest) { + Err(error) => error, + Ok(()) => { + return Err(std::io::Error::other( + "an unexpected candidate identity was promotable", + ) + .into()); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::CandidateDigestMismatch + ); + let error = match ensure_reproducible_candidates(first, first, &first_digest) { + Err(error) => error, + Ok(()) => { + return Err(std::io::Error::other( + "a caller-selected digest became release authority", + ) + .into()); + } + }; + assert_eq!( + error.kind(), + ProviderLowererComponentErrorKind::CandidateDigestMismatch + ); + Ok(()) + } + + #[test] + fn packaged_lowerer_resources_match_the_checked_provider_corpus() { + let pairs: [(&[u8], &[u8]); 4] = [ + ( + include_bytes!("../../crates/echo-edict-provider-lowerer/resources/target-profile.echo-dpo.cbor"), + include_bytes!("../../schemas/edict-provider/generated/v1/primary/target-profile.echo-dpo.cbor"), + ), + ( + include_bytes!("../../crates/echo-edict-provider-lowerer/resources/lawpack.echo-dpo.cbor"), + include_bytes!("../../schemas/edict-provider/generated/v1/primary/lawpack.echo-dpo.cbor"), + ), + ( + include_bytes!("../../crates/echo-edict-provider-lowerer/resources/authority-facts.echo-dpo.cbor"), + include_bytes!("../../schemas/edict-provider/generated/v1/primary/authority-facts.echo-dpo.cbor"), + ), + ( + include_bytes!("../../crates/echo-edict-provider-lowerer/resources/authority-facts.echo-lawpack.cbor"), + include_bytes!("../../schemas/edict-provider/generated/v1/primary/authority-facts.echo-lawpack.cbor"), + ), + ]; + + for (packaged, checked) in pairs { + assert_eq!(packaged, checked); + } + } + + #[test] + fn checked_component_promotion_is_exact_and_idempotent( + ) -> std::result::Result<(), Box> { + let bytes = include_bytes!( + "../../schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm" + ); + assert_eq!( + digest_hex(&sha256(bytes)), + APPROVED_CHECKED_COMPONENT_SHA256 + ); + let first = temporary_output("promotion-valid-a"); + let second = temporary_output("promotion-valid-b"); + let output = temporary_output("promotion-output"); + fs::write(&first, bytes)?; + fs::write(&second, bytes)?; + + let (component, status) = promote_reproducible_candidates(&first, &second, &output)?; + assert_eq!(status, ComponentOutputStatus::Written); + assert_eq!(component.bytes(), bytes); + let (_, status) = promote_reproducible_candidates(&first, &second, &output)?; + assert_eq!(status, ComponentOutputStatus::Current); + assert_eq!(fs::read(&output)?, bytes); + fs::remove_file(first)?; + fs::remove_file(second)?; + fs::remove_file(output)?; + Ok(()) + } + + #[test] + fn checked_component_contains_no_physical_builder_paths() { + let bytes = include_bytes!( + "../../schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm" + ); + for forbidden in [ + b"/usr/local/cargo".as_slice(), + b"/home/runner/.cargo".as_slice(), + b"/target/cargo-home".as_slice(), + ] { + assert!(!bytes.windows(forbidden.len()).any(|part| part == forbidden)); + } + let canonical = b"/cargo/registry/src/"; + assert!(bytes.windows(canonical.len()).any(|part| part == canonical)); + } + + #[test] + fn repository_routes_preserve_separate_build_propositions() { + let designated_image = "docker.io/library/rust@sha256:3914072ca0c3b8aad871db9169a651ccfce30cf58303e5d6f2db16d1d8a7e58f"; + let ci = include_str!("../../.github/workflows/ci.yml"); + assert!(ci.contains("cargo xtask provider-lowerer-component check")); + assert!(ci.contains("runs-on: ubuntu-24.04")); + assert!(ci.contains(designated_image)); + assert!(ci.contains("options: --platform linux/amd64")); + assert!(ci.contains(concat!( + "- name: build, audit, and check exact component bytes\n", + " env:\n", + " GIT_CONFIG_COUNT: 1\n", + " GIT_CONFIG_KEY_0: safe.directory\n", + " GIT_CONFIG_VALUE_0: ${{ github.workspace }}", + ))); + + let determinism = include_str!("../../.github/workflows/det-gates.yml"); + assert!(determinism.contains(designated_image)); + assert!(determinism.contains("options: --platform linux/amd64")); + assert!(determinism.contains(concat!( + "- name: Build isolated candidate\n", + " env:\n", + " CANDIDATE: ${{ matrix.candidate }}\n", + " GIT_CONFIG_COUNT: 1\n", + " GIT_CONFIG_KEY_0: safe.directory\n", + " GIT_CONFIG_VALUE_0: ${{ github.workspace }}", + ))); + assert!(determinism.contains("candidate: [1, 2]")); + assert!(determinism.contains("build-repro-candidate-${{ matrix.candidate }}")); + assert!(determinism.matches("overwrite: true").count() >= 2); + assert!(determinism.contains("cargo xtask provider-lowerer-component designated-build")); + assert!(determinism.contains("cargo xtask provider-lowerer-component promote")); + assert!(determinism.contains("--write")); + assert!(determinism.contains( + "cmp build1.lowerer.component.wasm schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm" + )); + assert!(determinism.contains( + "cmp build2.lowerer.component.wasm schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm" + )); + + let host = include_str!("../../scripts/verify-edict-provider-host-v1.sh"); + assert!(host.contains("provider-lowerer-component build")); + assert!(host.contains("provider-lowerer-component audit")); + assert!(!host.contains("provider-lowerer-component promote")); + } + + fn temporary_output(label: &str) -> PathBuf { + let nonce = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "echo-provider-lowerer-component-{}-{label}-{nonce}.wasm", + std::process::id() + )) + } +}