diff --git a/.github/workflows/zed-e2e.yml b/.github/workflows/zed-e2e.yml index 04a5b87..1bf736a 100644 --- a/.github/workflows/zed-e2e.yml +++ b/.github/workflows/zed-e2e.yml @@ -1,171 +1,168 @@ -name: Zed and Cargo interoperability +name: ORES ORM exact-tree funded certification on: pull_request: - push: - branches: [main] - workflow_dispatch: permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ores-orm-core-pr12-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true env: - ZED_INTERFACES_REF: a23cdd2ac509a39ca6fd6d21a3774fdd3a0f7660 - ZED_CLI_REF: bf1dc05e05900748a44ee02e7c507fd65bab4c7a - RUST_LIB_REF: f3c4e33cd0e5726b2c668c0306936f01674261fa + SOURCE_HEAD: a09fd6fa07c91a3ba65a44ab7da13a63d015450f + SOURCE_TREE: 59df9ec5ac372c041e1454124ef530d78a07a189 + MIRROR_DIR: cert/ores-orm-core-pr12 jobs: - workflow-contract: + rust: runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 20 steps: - - name: Check out exact revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout carrier merge candidate + uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a with: persist-credentials: false - - - name: Validate workflow - uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 - with: - args: .github/workflows/zed-e2e.yml - - - name: Validate executable test script - run: bash -n scripts/zed-e2e.sh - - - name: Verify immutable fixture references + - name: Verify exact source tree shell: bash run: | set -euo pipefail - for value in \ - "$ZED_INTERFACES_REF" \ - "$ZED_CLI_REF" \ - "$RUST_LIB_REF" - do - [[ "$value" =~ ^[0-9a-f]{40}$ ]] - done - test -f Cargo.lock - test ! -e .zpkg.lock - - cargo-matrix: - needs: workflow-contract - strategy: - fail-fast: false - matrix: - os: - - ubuntu-24.04 - - macos-15 - - windows-2025 - runs-on: ${{ matrix.os }} + test "$(git rev-parse "HEAD:$MIRROR_DIR")" = "$SOURCE_TREE" + printf 'source-head=%s\nsource-tree=%s\n' "$SOURCE_HEAD" "$SOURCE_TREE" + - name: Rust toolchain + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de + with: + toolchain: 1.88.0 + components: rustfmt,clippy + - name: Formatting + working-directory: cert/ores-orm-core-pr12 + run: cargo fmt --all -- --check + - name: Check + working-directory: cert/ores-orm-core-pr12 + run: cargo check --all-targets + - name: Clippy + working-directory: cert/ores-orm-core-pr12 + run: cargo clippy --all-targets --all-features -- -D warnings + - name: Tests + working-directory: cert/ores-orm-core-pr12 + run: cargo test --all-targets + + generated-runtime-surfaces: + needs: rust + runs-on: ubuntu-24.04 timeout-minutes: 25 steps: - - name: Check out exact revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout carrier merge candidate + uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a with: persist-credentials: false - - - name: Install pinned Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: "3.12" - - - name: Install pinned Rust toolchain - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: "1.97.0" - components: rustfmt, clippy - - - name: Materialize exact Zed package fixture + - name: Verify exact source tree shell: bash run: | set -euo pipefail - target=.vendor/.zed/zed-pkg-test/rust-lib - mkdir -p "$(dirname "$target")" - git init -q "$target" - git -C "$target" remote add origin https://github.com/zed-pkg-test/rust-lib.git - git -C "$target" fetch --depth 1 origin "$RUST_LIB_REF" - git -C "$target" checkout -q --detach FETCH_HEAD - test "$(git -C "$target" rev-parse HEAD)" = "$RUST_LIB_REF" - - - name: Run locked Cargo matrix - shell: bash + test "$(git rev-parse "HEAD:$MIRROR_DIR")" = "$SOURCE_TREE" + - name: Rust toolchain + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de + with: + toolchain: 1.88.0 + - name: Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 22.16.0 + - name: Dart + uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d + with: + sdk: 3.13.3 + - name: Gleam and Erlang + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 + with: + otp-version: '29.1' + gleam-version: '1.18.1' + rebar3-version: '3' + - name: Emit exact runtime fixture + working-directory: cert/ores-orm-core-pr12 + env: + ORES_ORM_RUNTIME_FIXTURE_DIR: ${{ runner.temp }}/ores-orm-generated + run: cargo run --quiet --example emit_runtime_fixture + - name: Compile generated Rust DTO + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-rust run: | - set -euo pipefail - cargo_lock_hash="$(git hash-object Cargo.lock)" - cargo fmt --all -- --check - cargo clippy --locked --all-targets -- -D warnings - cargo test --locked --all-targets - cargo run --locked --quiet - cargo metadata --locked --format-version 1 > cargo-metadata.json - cargo tree --locked -e features > cargo-features.txt - cargo run --locked --offline --quiet - cargo test --locked --offline --all-targets - test "$(git hash-object Cargo.lock)" = "$cargo_lock_hash" - test ! -e .zpkg.lock - - - name: Validate Cargo provenance - shell: bash + mkdir -p "$WORK/src" + cp "$GEN"/rust/*.rs "$WORK/src/lib.rs" + cat > "$WORK/Cargo.toml" <<'TOML' + [package] + name = "ores-orm-generated-runtime-witness" + version = "0.0.0" + edition = "2024" + publish = false + + [dependencies] + serde = { version = "1.0.228", features = ["derive"] } + serde_json = "1.0.145" + TOML + cargo check --quiet --manifest-path "$WORK/Cargo.toml" + - name: Type-check generated TypeScript and Zod + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-typescript run: | - set -euo pipefail - python3 - <<'PY' - import json - from pathlib import Path - - metadata = json.loads(Path("cargo-metadata.json").read_text()) - packages = {package["name"]: package for package in metadata["packages"]} - assert packages["rust-app"]["version"] == "0.2.0" - assert packages["rust-lib"]["version"] == "1.0.1" - assert packages["rust-lib"]["source"] is None - assert packages["itoa"]["version"] == "1.0.18" - assert packages["unicode-ident"]["version"] == "1.0.24" - assert packages["itoa"]["source"].startswith("registry+") - assert packages["unicode-ident"]["source"].startswith("registry+") - PY - - - name: Upload Cargo provenance - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: cargo-provenance-${{ matrix.os }} - path: | - cargo-metadata.json - cargo-features.txt - if-no-files-found: warn - retention-days: 7 - - - name: Verify source and lock cleanliness - if: ${{ always() }} - shell: bash + mkdir -p "$WORK" + cp "$GEN"/typescript/*.ts "$WORK/index.ts" + cat > "$WORK/package.json" <<'JSON' + {"private":true,"type":"module","dependencies":{"zod":"4.6.5"},"devDependencies":{"typescript":"7.0.2"}} + JSON + cd "$WORK" + npm install --ignore-scripts --no-audit --no-fund + ./node_modules/.bin/tsc --noEmit --strict --skipLibCheck false --target ES2022 --module NodeNext --moduleResolution NodeNext index.ts + - name: Analyze generated Dart validator + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-dart run: | - set -euo pipefail - rm -rf .vendor target cargo-metadata.json cargo-features.txt .zed .zpkg.lock - git diff --exit-code -- Cargo.toml Cargo.lock .zpkg.toml build.rs src tests - test -z "$(git status --porcelain --untracked-files=all)" - - zed-registry-e2e: - needs: workflow-contract - runs-on: ubuntu-24.04 - timeout-minutes: 45 - steps: - - name: Check out exact revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Run pinned cross-container interoperability suite - shell: bash + mkdir -p "$WORK/lib" + cp "$GEN"/dart/*.dart "$WORK/lib/orm_derived.dart" + cat > "$WORK/pubspec.yaml" <<'YAML' + name: ores_orm_generated_runtime_witness + version: 0.0.0 + environment: + sdk: '>=3.13.0 <4.0.0' + YAML + cd "$WORK" + dart pub get + dart analyze --fatal-infos --fatal-warnings + - name: Compile generated Gleam decoder + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-gleam run: | - set -o pipefail - bash scripts/zed-e2e.sh 2>&1 | tee "$RUNNER_TEMP/zed-cargo-e2e.log" - - - name: Upload interoperability diagnostics - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: zed-cargo-e2e-log - path: ${{ runner.temp }}/zed-cargo-e2e.log - if-no-files-found: warn - retention-days: 7 + mkdir -p "$WORK/src" + cp "$GEN"/gleam/*.gleam "$WORK/src/orm_derived.gleam" + cat > "$WORK/gleam.toml" <<'TOML' + name = "ores_orm_generated_runtime_witness" + version = "0.0.0" + target = "erlang" + + [dependencies] + gleam_stdlib = "1.0.5" + TOML + cd "$WORK" + gleam deps download + gleam check + - name: Parse generated Draft 2020-12 JSON Schema and manifest + env: + GEN: ${{ runner.temp }}/ores-orm-generated + run: | + node --input-type=module <<'NODE' + import { readFileSync, readdirSync } from 'node:fs'; + import { join } from 'node:path'; + const root = process.env.GEN; + const schemaName = readdirSync(join(root, 'json-schema')).find((name) => name.endsWith('.json')); + if (!schemaName) throw new Error('missing JSON Schema artifact'); + const schema = JSON.parse(readFileSync(join(root, 'json-schema', schemaName), 'utf8')); + const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); + if (schema.$schema !== 'https://json-schema.org/draft/2020-12/schema') throw new Error('wrong JSON Schema dialect'); + if (manifest.publication !== 'blocked_pending_contract_admission') throw new Error('public derivative must remain blocked pending TJSV admission'); + NODE diff --git a/cert/ores-orm-core-pr12/.github/workflows/ci.yml b/cert/ores-orm-core-pr12/.github/workflows/ci.yml new file mode 100644 index 0000000..aed1a07 --- /dev/null +++ b/cert/ores-orm-core-pr12/.github/workflows/ci.yml @@ -0,0 +1,176 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + +jobs: + rust: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout exact revision + uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a + with: + ref: ${{ env.EXPECTED_SHA }} + persist-credentials: false + - name: Verify exact revision + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Rust toolchain + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de + with: + toolchain: 1.88.0 + components: rustfmt,clippy + - name: Formatting + run: cargo fmt --all -- --check + - name: Check + run: cargo check --all-targets + - name: Clippy + # Cargo.toml declares clippy::all=deny and clippy::pedantic=warn. + # Do not promote the intentionally advisory pedantic tier to errors here. + run: cargo clippy --all-targets --all-features + - name: Tests + run: cargo test --all-targets + + generated-runtime-surfaces: + needs: rust + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact revision + uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a + with: + ref: ${{ env.EXPECTED_SHA }} + persist-credentials: false + - name: Verify exact revision + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Rust toolchain + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de + with: + toolchain: 1.88.0 + - name: Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 22.16.0 + - name: Dart + uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d + with: + sdk: 3.13.3 + - name: Gleam and Erlang + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 + with: + otp-version: '29.1' + gleam-version: '1.18.1' + rebar3-version: '3' + - name: Emit exact runtime fixture + env: + ORES_ORM_RUNTIME_FIXTURE_DIR: ${{ runner.temp }}/ores-orm-generated + run: cargo run --quiet --example emit_runtime_fixture + - name: Compile generated Rust DTO + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-rust + run: | + mkdir -p "$WORK/src" + cp "$GEN"/rust/*.rs "$WORK/src/lib.rs" + cat > "$WORK/Cargo.toml" <<'TOML' + [package] + name = "ores-orm-generated-runtime-witness" + version = "0.0.0" + edition = "2024" + publish = false + + [dependencies] + serde = { version = "1.0.228", features = ["derive"] } + serde_json = "1.0.145" + TOML + cargo check --quiet --manifest-path "$WORK/Cargo.toml" + - name: Type-check generated TypeScript and Zod + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-typescript + run: | + mkdir -p "$WORK" + cp "$GEN"/typescript/*.ts "$WORK/index.ts" + cat > "$WORK/package.json" <<'JSON' + { + "private": true, + "type": "module", + "dependencies": { + "zod": "4.6.5" + }, + "devDependencies": { + "typescript": "7.0.2" + } + } + JSON + ( + cd "$WORK" + npm install --ignore-scripts --no-audit --no-fund + ./node_modules/.bin/tsc --noEmit --strict --skipLibCheck false --target ES2022 --module NodeNext --moduleResolution NodeNext index.ts + ) + - name: Analyze generated Dart validator + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-dart + run: | + mkdir -p "$WORK/lib" + cp "$GEN"/dart/*.dart "$WORK/lib/orm_derived.dart" + cat > "$WORK/pubspec.yaml" <<'YAML' + name: ores_orm_generated_runtime_witness + version: 0.0.0 + environment: + sdk: '>=3.13.0 <4.0.0' + YAML + ( + cd "$WORK" + dart pub get + dart analyze --fatal-infos --fatal-warnings + ) + - name: Compile generated Gleam decoder + env: + GEN: ${{ runner.temp }}/ores-orm-generated + WORK: ${{ runner.temp }}/generated-gleam + run: | + mkdir -p "$WORK/src" + cp "$GEN"/gleam/*.gleam "$WORK/src/orm_derived.gleam" + cat > "$WORK/gleam.toml" <<'TOML' + name = "ores_orm_generated_runtime_witness" + version = "0.0.0" + target = "erlang" + + [dependencies] + gleam_stdlib = "1.0.5" + TOML + ( + cd "$WORK" + gleam deps download + gleam check + ) + - name: Parse generated Draft 2020-12 JSON Schema and manifest + env: + GEN: ${{ runner.temp }}/ores-orm-generated + run: | + node --input-type=module <<'NODE' + import { readFileSync, readdirSync } from 'node:fs'; + import { join } from 'node:path'; + const root = process.env.GEN; + const schemaName = readdirSync(join(root, 'json-schema')).find((name) => name.endsWith('.json')); + if (!schemaName) throw new Error('missing JSON Schema artifact'); + const schema = JSON.parse(readFileSync(join(root, 'json-schema', schemaName), 'utf8')); + const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); + if (schema.$schema !== 'https://json-schema.org/draft/2020-12/schema') throw new Error('wrong JSON Schema dialect'); + if (manifest.publication !== 'blocked_pending_contract_admission') throw new Error('public derivative must remain blocked pending TJSV admission'); + NODE diff --git a/cert/ores-orm-core-pr12/.gitignore b/cert/ores-orm-core-pr12/.gitignore new file mode 100644 index 0000000..29ec2dd --- /dev/null +++ b/cert/ores-orm-core-pr12/.gitignore @@ -0,0 +1,6 @@ +/target/ +/generated/ +.ores/ +*.profraw +tmp/ +temp/ diff --git a/cert/ores-orm-core-pr12/AGENTS.md b/cert/ores-orm-core-pr12/AGENTS.md new file mode 100644 index 0000000..060cc8e --- /dev/null +++ b/cert/ores-orm-core-pr12/AGENTS.md @@ -0,0 +1,13 @@ +# ores-orm-core agent notes + +This repository implements ORM-first derivative schema/type/validator tooling. + +- Durable codegen, checks, validators and repository scripts are Rust-first; do not add Python tooling. +- Human-authored TypeSpec and JSON Schema Draft 2020-12 remain independent peer authorities. ORM-derived artifacts are evidence, never a third authored authority. +- Parse Diesel and SeaORM independently. Do not resolve a mismatch by choosing one ORM as canonical. +- Public projections must be explicit allowlists and must be admitted against retained TJSV Contract IR evidence before publication. +- Preserve database/wire names exactly. Escaping a language reserved word must not change the serialized name. +- Fail closed on unsupported or ambiguous ORM constructs; do not silently widen validators. +- Generated output belongs under `generated/` and must carry reproducible provenance. + +For fleet-wide guidance also follow `ORESoftware/my-ai` `AGENTS.md` and `SHARED.md`. diff --git a/cert/ores-orm-core-pr12/Cargo.toml b/cert/ores-orm-core-pr12/Cargo.toml new file mode 100644 index 0000000..26d2a79 --- /dev/null +++ b/cert/ores-orm-core-pr12/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "ores-orm-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "ORM-first derivative schema and validator tooling for ORESoftware" +license = "UNLICENSED" +publish = false + +[lib] +name = "ores_orm_core" +path = "src/lib.rs" + +[dependencies] +hex = "0.4" +proc-macro2 = "1" +quote = "1" +schemars = { version = "1.2", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +syn = { version = "2", features = ["full", "extra-traits", "parsing"] } +thiserror = "2" +toml = "0.9" +walkdir = "2" + +[dev-dependencies] +pretty_assertions = "1" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "warn", priority = -1 } +needless_return = "allow" diff --git a/cert/ores-orm-core-pr12/README.md b/cert/ores-orm-core-pr12/README.md new file mode 100644 index 0000000..b81a200 --- /dev/null +++ b/cert/ores-orm-core-pr12/README.md @@ -0,0 +1,156 @@ +# ores-orm-core + +ORM-first derivative schema and validator tooling for the ORESoftware fleet. + +This repository fills the inverse/code-first lane that is intentionally different from `ORESoftware/ores-contracts`: + +```text +Diesel schema/model definitions -----> ORM IR_D --\ + +--> parity gate --> converged derivative ORM IR +SeaORM entity/model definitions -----> ORM IR_S --/ | + +--> exact policy-derived shapes + +--> Rust Serde types + +--> JSON Schema 2020-12 + +--> TypeScript validators/types + +--> Dart validators/types + +--> Gleam decoders/types + | + +--> TJSV admission before public promotion + | + +--> optional WIT/binding evidence downstream +``` + +It is analogous in purpose to deriving Zod schemas from Drizzle tables, but it is deliberately stricter about provenance and authority. + +## Authority boundary + +Human-authored TypeSpec and human-authored JSON Schema Draft 2020-12 remain independent peer authorities in `*-interfaces` repositories. `ORESoftware/typespec-json-schema-validator` (TJSV) owns their generic semantic parity and Contract IR admission. `ORESoftware/ores-contracts` owns contract-first persistence/codegen convergence for contract families that declare persistence semantics. `ORESoftware/ores-wit` owns WIT validation/canonicalization/binding orchestration. + +`ores-orm-core` does **not** promote Diesel, SeaORM, Serde, generated JSON Schema, or a database catalog into a third authored authority. ORM-first output is derivative evidence. Public output may be published only when it is compatible with the exact admitted public contract surface and its retained Contract IR/receipts. + +Binding Contract IR and receipt bytes into a derivative manifest is provenance only. It does **not** certify admission. The generator always marks a public derivative as `blocked_pending_contract_admission`; a separate TJSV verification step owns the promotion decision. + +## Inputs + +The first-class input lanes are independent and must converge when both are configured: + +- Diesel `table!` schema output, normally produced by `diesel print-schema`, plus explicitly declared model metadata where needed. +- SeaORM entity/model definitions, including SeaORM 2 dense entities and generated entities from `sea-orm-cli generate entity`. +- Optional database catalog evidence for DB-first verification. Catalog evidence strengthens the proof but does not silently override either ORM lane. + +A single ORM lane may be used during bootstrap, but release/publication policy can require both lanes. Unsupported or ambiguous ORM constructs fail closed rather than being guessed. + +## Derived shape families + +Generation is shape-aware rather than emitting one struct for every purpose: + +- `row` / `select`: database-readable representation; every selected column is present even when its value is SQL `NULL`. +- `create` / `insert`: client-settable create fields; generated/default-only/immutable fields are excluded unless explicitly allowed. +- `update`: replace/update shape with field mutability policy applied. +- `patch`: partial update shape; all writable fields optional and the object itself must contain at least one field. +- `public_read`: explicitly public projection of a row. +- `public_create`, `public_update`, and `public_patch`: explicitly public write projections. + +Presence and nullability are separate dimensions. The emitters preserve required/non-null, required/nullable, optional/non-null, and optional/nullable semantics rather than treating `NULL` as “missing.” + +Public/private membership is explicit configuration/metadata. The generator must never decide that a field is secret or public from a name such as `password`, `token`, `_hidden`, or `internal`. + +## Exact lineage + +A derivative bundle is emitted only when all of these agree in one call: + +1. the supplied converged ORM IR validates; +2. the supplied policy validates against that entire IR, including stale/orphan table entries; +3. the evidence's ORM-IR digest matches the exact supplied IR bytes; +4. the supplied shape is re-derived from that exact IR + policy + `ShapeKind`; and +5. the re-derived shape is byte-for-byte semantically equal to the supplied shape. + +This prevents a hand-constructed or stale shape from inheriting provenance from an unrelated ORM snapshot. Artifact paths, type names, source identifiers, shape bytes, source inputs, generator options, and generated files all receive deterministic identities/digests. + +## Naming + +Wire and database names are preserved exactly. Generated language identifiers may escape reserved words, but escaping must not alter the serialized name. Fleet defaults prefer `snake_case`; this repository does not inherit older generators' automatic camelCase conversion. Any name transformation must be explicit, deterministic, and represented in provenance. + +Quoted database identifiers may contain punctuation or path-like characters, so generated artifact paths never interpolate raw table identities. Paths use deterministic collision-free encodings. Generated language identifiers use reversible escaping and fail before emission if a target-specific collision remains. + +## Validation targets + +The canonical derivative validation artifact is Draft 2020-12 JSON Schema plus a normalized ORM IR. Language packages consume the same admitted semantics: + +- Rust: strict `serde` DTOs with unknown-field rejection and explicit presence/nullability deserializers. The sibling Draft 2020-12 JSON Schema is canonical; the generated DTO intentionally does not infer a weaker schema from `Option`. +- TypeScript: generated types plus strict Zod runtime schemas. +- Dart: generated typed models plus presence-aware runtime validation. +- Gleam: generated record/custom types plus `gleam/dynamic` decoders/validators. + +The CI runtime witness compiles/type-checks the emitted Rust, TypeScript/Zod, Dart, and Gleam sources instead of treating source-string assertions as sufficient evidence. + +Cross-runtime domains fail closed when an ORM type does not by itself determine one portable wire representation. This currently includes unrestricted `int64`, `float32`, decimal, UUID, calendar date, date-time, bytes, and unresolved named database types. They require a reviewed admitted mapping before public emission. The deliberately small portable subset currently emitted without extra mapping evidence is Boolean, Int16, Int32, Float64, String, and generic JSON. + +Additional language adapters can be added without changing the ORM IR or authority model. + +## Relationship to product repositories + +Typical flow: + +```text +-interfaces authored TypeSpec + authored JSON Schema + | | + +-------------- TJSV ------------------+ + | + Contract IR + | + public-admission check + ^ + | +-orm-core -- ores-orm-core parity/shape/codegen --> generated derivative artifacts + | + +--> private server-only Serde/model surfaces + +--> admitted server DTOs in -lib-core + +--> admitted client-safe DTOs in -pub-lib-core / -clients +``` + +`*-orm-core` remains the private backend home for executable Diesel/SeaORM behavior. Public packages receive only explicitly admitted generated projections, never executable ORM code or database credentials/configuration. + +## CLI boundary + +Durable codegen, checks, validators, and audit tooling are Rust-first. The configuration contract is `.ores-orm.toml`. A production executable CLI must use the fleet `flags-2-env` argv boundary and `.cli-flags.toml`; ad-hoc argument parsing is not an accepted release path. + +Generated output lives under `generated/` with provenance receipts and is reproducible from exact source bytes, normalized ORM IR, policy/options, tool version/revision, and retained contract evidence. + +## Related repositories + +- `ORESoftware/orm-core-template.rs` +- `ORESoftware/ores-contracts` +- `ORESoftware/ores-interfaces` +- `ORESoftware/typespec-json-schema-validator` +- `ORESoftware/ores-wit` +- `ORESoftware/flags-2-env` + +## Derivative language packages + +The ORM-first lane derives the same shape algebra into five deterministic artifacts: + +- Rust Serde DTO source; +- TypeScript + Zod validators; +- Dart typed models with presence-aware JSON decoding; +- Gleam types plus dynamic decoders; +- the Draft 2020-12 JSON Schema witness. + +Public generation is fail-closed. A table must opt into `public_surface = true`, every storage column must be classified as public, private, or secret, and public shapes are assembled only from explicit `public_read`, `public_create`, and `public_update` allowlists. Serde capability by itself never implies public exposure. + +`emit::bundle` binds the language sources to the exact converged ORM IR, policy-derived shape, source digests, generator-option digest, and per-artifact SHA-256 values. A public bundle remains a **candidate** until TJSV verifies it against the exact owning TypeSpec + authored JSON Schema contract and retained Contract IR/receipt. Generation is not publication or admission. + +## TJSV public admission + +Digest-binding contract files to a public derivative is not semantic admission. The manifest keeps those states separate: + +- `required`: a public shape has no contract evidence attached; +- `evidence_bound`: exact Contract IR and receipt bytes are hashed into generation evidence, but the derivative remains blocked; +- `admitted`: the exact Contract IR, TJSV projection manifest, and self-digesting `projection-verification-receipt/v1` passed local integrity/binding checks, the trusted projection declares the expected contract scope and runtime-validator scope, and the verified output digests exactly equal this bundle's Rust/TypeScript/Dart/Gleam/JSON-Schema bytes; +- `not_applicable`: the shape is private/server-only. + +`TjsvAdmissionBinding::verify` recomputes the Contract IR `irId`, projection `manifestId`, and projection `verificationId` using TJSV's canonical object-key ordering; checks the peer-authority model, mandatory contract-admission coverage, parity-run identity, source digests, declaration scope, projection identity, runtime-validator closure, output closure, and receipt summary; and retains exact byte digests for all three evidence artifacts. The canonical projection id is a bounded TJSV-safe identity derived from SHA-256 of the exact table name plus the shape kind. + +`bundle::emit_admitted` preserves the current exact-lineage gate: it requires the same converged ORM IR and validated policy used to derive the shape, plus an in-process verified admission capability, and proves that the TJSV-verified output path/digest set exactly matches the newly generated five-artifact bundle. Deserializing an admission manifest clears that in-process capability, so stored or fabricated JSON cannot be replayed directly as authorization to publish. + +The admitted manifest format is `ores.orm-core.derivative-manifest/v3`. TypeSpec and independently authored JSON Schema remain the only contract authorities; Contract IR, projection manifests, runtime evidence, projection receipts, ORM policy evidence, and ORM derivatives remain downstream evidence. diff --git a/cert/ores-orm-core-pr12/docs/architecture.md b/cert/ores-orm-core-pr12/docs/architecture.md new file mode 100644 index 0000000..18c92e1 --- /dev/null +++ b/cert/ores-orm-core-pr12/docs/architecture.md @@ -0,0 +1,57 @@ +# Architecture + +## The two directions are complementary + +There are two different derivation directions in the fleet and they must not be collapsed into one source-of-truth story. + +### Contract-first + +`*-interfaces` owns independently authored TypeSpec and JSON Schema. TJSV proves semantic convergence. `ores-contracts` can then project persistence/codegen evidence such as SQL, Diesel, SeaORM and language types. + +### ORM-first + +`*-orm-core` contains executable/private ORM definitions. `ores-orm-core` independently normalizes Diesel and SeaORM into `ores.orm-core.ir/v1`, requires the common structural semantics to converge, then emits derivative candidates such as strict JSON Schema and language validators. + +The ORM-first lane is useful for the same reason `drizzle-zod` is useful: database model changes can mechanically produce matching data-shape validators and DTOs. The difference is that an ORESoftware public artifact has an additional admission boundary. + +## Public vs private output + +Private/server-only derivatives may be produced from converged ORM evidence alone. Public derivatives are candidates until they have been checked against the exact TJSV Contract IR and retained parity receipt for the owning `*-interfaces` contract. + +This prevents a private database column from becoming public by accident and prevents an ORM's weaker type system from erasing business constraints such as patterns, min/max lengths, enum membership, tagged unions, or semantic formats. + +A public-admission lane should compare the ORM-derived Draft 2020-12 schema to the mapped Contract IR assertion schema. Where the contract is stricter than ORM evidence, the public runtime validator must retain the contract constraint; the ORM lane may never weaken it. + +## Diesel and SeaORM are peer witnesses + +Neither ORM wins. The initial structural comparison covers the common subset both lanes can prove reliably: schema/table name, column/storage name, scalar/array/named logical type, nullability and primary-key membership. + +Defaults, generated expressions, checks, indexes, RLS, grants, extensions and vector dimensions are not guessed from ORM source. Use explicit `.ores-orm.toml` policy and/or database-catalog evidence, and retain the evidence source in receipts. + +## Shape algebra + +Presence and nullability are independent axes. For a create field, a non-null field without a default is required; a nullable field may be omitted and may explicitly be null; a defaulted field may be omitted without becoming nullable. + +For a row/read shape every selected property is required, even when its value is nullable. This matters in Rust because plain `Option` often conflates a missing field with a present JSON `null`. Rust DTO generation therefore needs presence-aware helper wrappers or mandatory schema validation before deserialization. + +`update` is replacement-style for writable fields. `patch` is partial. Generated fields are excluded. Immutable fields may be accepted on create when explicitly exposed but are excluded from subsequent writes. + +## Explicit exposure + +The generator does not infer exposure from field names. Public projections are allowlists in policy/contract mapping. An empty allowlist means nothing is public. + +## Naming + +Database/wire names are preserved exactly by default. Language identifiers may be escaped for reserved words, but serialization must keep the original name. The current fleet preference is snake_case; automatic camelCase conversion from older experiments is not inherited here. + +## Language adapters + +The normalized shape IR is the shared semantic input. Adapters must have golden positive/negative fixtures and prove equivalent acceptance/rejection behavior. + +- Rust: Serde DTOs plus strict presence/null handling; Schemars may emit a comparison witness but is not authority. +- TypeScript: Zod schemas/types or a pinned equivalent runtime validator, checked against the canonical Draft 2020-12 derivative schema. +- Dart: generated models plus explicit key-presence/type/format validation. +- Gleam: generated types and `gleam/dynamic` decoders with equivalent missing/null behavior. +- WIT: downstream ABI/interface compatibility evidence through `ores-wit`; WIT does not replace JSON validation constraints. + +Public language emission must bind to admitted contract evidence so DB-only semantics cannot silently redefine the wire contract. diff --git a/cert/ores-orm-core-pr12/examples/emit_runtime_fixture.rs b/cert/ores-orm-core-pr12/examples/emit_runtime_fixture.rs new file mode 100644 index 0000000..bf61447 --- /dev/null +++ b/cert/ores-orm-core-pr12/examples/emit_runtime_fixture.rs @@ -0,0 +1,67 @@ +use ores_orm_core::diesel; +use ores_orm_core::emit::bundle::{self, GenerationEvidence}; +use ores_orm_core::parity; +use ores_orm_core::policy::Policy; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{ShapeKind, derive_shape}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +fn main() -> Result<(), Box> { + let output = std::env::var_os("ORES_ORM_RUNTIME_FIXTURE_DIR") + .map(PathBuf::from) + .ok_or_else(|| std::io::Error::other("ORES_ORM_RUNTIME_FIXTURE_DIR is required"))?; + + let diesel_ir = diesel::parse_schema(DIESEL)?; + let seaorm_ir = seaorm::parse_source(SEAORM)?; + let ir = parity::converge(&diesel_ir, &seaorm_ir)?; + let policy = Policy::from_toml(POLICY)?; + policy.validate_ir(&ir)?; + let table = ir + .table("users") + .ok_or_else(|| std::io::Error::other("users table missing"))?; + let shape = derive_shape(table, &policy, ShapeKind::PublicCreate)?; + let evidence = GenerationEvidence::from_bytes( + &ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"runtime-fixture-public-create-v1", + None, + )?; + let generated = bundle::emit(&ir, &policy, &shape, &evidence)?; + + if output.exists() { + fs::remove_dir_all(&output)?; + } + fs::create_dir_all(&output)?; + for artifact in &generated.artifacts { + let destination = safe_destination(&output, &artifact.path)?; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::write(destination, artifact.content.as_bytes())?; + } + fs::write(output.join("manifest.json"), generated.manifest_json()?)?; + Ok(()) +} + +fn safe_destination(root: &Path, relative: &str) -> Result> { + let path = Path::new(relative); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err( + std::io::Error::other(format!("unsafe generated artifact path: {relative}")).into(), + ); + } + Ok(root.join(path)) +} diff --git a/cert/ores-orm-core-pr12/fixtures/diesel/schema.rs b/cert/ores-orm-core-pr12/fixtures/diesel/schema.rs new file mode 100644 index 0000000..bc5f22e --- /dev/null +++ b/cert/ores-orm-core-pr12/fixtures/diesel/schema.rs @@ -0,0 +1,12 @@ +diesel::table! { + use diesel::sql_types::*; + + users (id) { + id -> Uuid, + email -> Text, + display_name -> Nullable, + password_hash -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} diff --git a/cert/ores-orm-core-pr12/fixtures/ores-orm.toml b/cert/ores-orm-core-pr12/fixtures/ores-orm.toml new file mode 100644 index 0000000..978e6b3 --- /dev/null +++ b/cert/ores-orm-core-pr12/fixtures/ores-orm.toml @@ -0,0 +1,13 @@ +version = 1 +require_diesel = true +require_seaorm = true + +[table.users] +public_surface = true +generated = ["id", "created_at", "updated_at"] +defaulted = ["created_at", "updated_at"] +immutable = ["id", "created_at"] +secret = ["password_hash"] +public_read = ["id", "email", "display_name", "created_at", "updated_at"] +public_create = ["email", "display_name"] +public_update = ["email", "display_name"] diff --git a/cert/ores-orm-core-pr12/fixtures/seaorm/users.rs b/cert/ores-orm-core-pr12/fixtures/seaorm/users.rs new file mode 100644 index 0000000..15408b4 --- /dev/null +++ b/cert/ores-orm-core-pr12/fixtures/seaorm/users.rs @@ -0,0 +1,14 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + #[sea_orm(unique)] + pub email: String, + pub display_name: Option, + pub password_hash: String, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} diff --git a/cert/ores-orm-core-pr12/src/admission.rs b/cert/ores-orm-core-pr12/src/admission.rs new file mode 100644 index 0000000..f44d570 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/admission.rs @@ -0,0 +1,888 @@ +use crate::error::{OrmError, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +pub const TJSV_CONTRACT_IR_SCHEMA: &str = "ores.typespec-json-schema-validator.contract-ir/v1"; +pub const TJSV_PARITY_REPORT_SCHEMA: &str = "ores.typespec-json-schema-validator.report/v1"; +pub const TJSV_PROJECTION_MANIFEST_SCHEMA: &str = + "ores.typespec-json-schema-validator.projection-manifest/v1"; +pub const TJSV_PROJECTION_VERIFICATION_RECEIPT_SCHEMA: &str = + "ores.typespec-json-schema-validator.projection-verification-receipt/v1"; +pub const TJSV_ADMISSION_BINDING_SCHEMA: &str = "ores.orm-core.tjsv-admission-binding/v2"; +pub const REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS: &[&str] = &[ + "dart-json", + "gleam-erlang", + "gleam-js", + "rust-serde", + "typescript-zod", +]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TjsvOutputBinding { + pub path: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TjsvAdmissionBinding { + schema: String, + projection_id: String, + contract_ir_id: String, + contract_ir_sha256: String, + projection_manifest_id: String, + projection_manifest_sha256: String, + projection_verification_id: String, + projection_verification_sha256: String, + parity_receipt_run_id: String, + evidence_digest: String, + declaration_ids: Vec, + runtime_validator_ids: Vec, + outputs: Vec, + #[serde(skip)] + verified: bool, +} + +impl TjsvAdmissionBinding { + #[must_use] + pub const fn is_verified(&self) -> bool { + return self.verified; + } + + #[must_use] + pub fn projection_id(&self) -> &str { + return &self.projection_id; + } + + #[must_use] + pub fn contract_ir_sha256(&self) -> &str { + return &self.contract_ir_sha256; + } + + #[must_use] + pub fn projection_manifest_sha256(&self) -> &str { + return &self.projection_manifest_sha256; + } + + #[must_use] + pub fn projection_verification_sha256(&self) -> &str { + return &self.projection_verification_sha256; + } + + #[must_use] + pub fn declaration_ids(&self) -> &[String] { + return &self.declaration_ids; + } + + #[must_use] + pub fn runtime_validator_ids(&self) -> &[String] { + return &self.runtime_validator_ids; + } + + #[must_use] + pub fn outputs(&self) -> &[TjsvOutputBinding] { + return &self.outputs; + } + + #[must_use] + pub fn projection_manifest_id(&self) -> &str { + return &self.projection_manifest_id; + } + + #[must_use] + pub fn projection_verification_id(&self) -> &str { + return &self.projection_verification_id; + } + + #[must_use] + pub fn contract_ir_id(&self) -> &str { + return &self.contract_ir_id; + } + + #[must_use] + pub fn parity_receipt_run_id(&self) -> &str { + return &self.parity_receipt_run_id; + } + + #[must_use] + pub fn evidence_digest(&self) -> &str { + return &self.evidence_digest; + } + + #[must_use] + pub fn schema(&self) -> &str { + return &self.schema; + } + + pub fn verify( + contract_ir_bytes: &[u8], + projection_manifest_bytes: &[u8], + projection_receipt_bytes: &[u8], + expected_projection_id: &str, + expected_declaration_ids: &[String], + ) -> Result { + if contract_ir_bytes.is_empty() + || projection_manifest_bytes.is_empty() + || projection_receipt_bytes.is_empty() + { + return Err(OrmError::Invalid( + "TJSV admission requires non-empty Contract IR, projection manifest, and projection verification receipt bytes" + .to_owned(), + )); + } + if !valid_identifier(expected_projection_id) { + return Err(OrmError::Invalid( + "expected TJSV projection id is not a valid projection identifier".to_owned(), + )); + } + + let expected_declarations = + normalize_expected_ids(expected_declaration_ids, "expected declaration ids")?; + let expected_runtime_validators = REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS + .iter() + .map(|value| (*value).to_owned()) + .collect::>(); + + let contract_ir = parse_json(contract_ir_bytes, "Contract IR")?; + let contract = contract_ir.as_object().ok_or_else(|| { + OrmError::Invalid("TJSV Contract IR must be a JSON object".to_owned()) + })?; + verify_contract_ir(contract, &contract_ir)?; + + let contract_ir_id = digest_field(contract, "irId", "Contract IR")?; + let contract_ir_digest = canonical_digest(&contract_ir)?; + let admission = object_field(contract, "admission", "Contract IR")?; + let parity = object_field(admission, "receipt", "Contract IR admission")?; + let parity_receipt_run_id = digest_field(parity, "runId", "Contract IR parity binding")?; + let parity_receipt_digest = digest_field(parity, "digest", "Contract IR parity binding")?; + let source_digests = contract_source_digests(contract)?; + + let projection_manifest = parse_json(projection_manifest_bytes, "projection manifest")?; + let manifest = projection_manifest.as_object().ok_or_else(|| { + OrmError::Invalid("TJSV projection manifest must be a JSON object".to_owned()) + })?; + require_string( + manifest, + "schema", + TJSV_PROJECTION_MANIFEST_SCHEMA, + "projection manifest", + )?; + require_string(manifest, "status", "passed", "projection manifest")?; + let projection_manifest_id = digest_field(manifest, "manifestId", "projection manifest")?; + if canonical_digest_without(&projection_manifest, "manifestId")? != projection_manifest_id { + return Err(OrmError::Invalid( + "TJSV projection manifest self-digest does not match manifestId".to_owned(), + )); + } + + let manifest_contract = object_field(manifest, "contract", "projection manifest")?; + require_digest_value( + manifest_contract, + "contractIrId", + &contract_ir_id, + "projection manifest contract", + )?; + require_digest_value( + manifest_contract, + "contractIrDigest", + &contract_ir_digest, + "projection manifest contract", + )?; + require_digest_value( + manifest_contract, + "receiptRunId", + &parity_receipt_run_id, + "projection manifest contract", + )?; + require_digest_value( + manifest_contract, + "receiptDigest", + &parity_receipt_digest, + "projection manifest contract", + )?; + verify_source_digests( + object_field( + manifest_contract, + "sourceDigests", + "projection manifest contract", + )?, + &source_digests, + "projection manifest contract sourceDigests", + )?; + + let manifest_declarations = normalize_ids( + string_array_field(manifest, "declarations", "projection manifest")?, + "projection manifest declarations", + true, + )?; + for declaration_id in &expected_declarations { + if !manifest_declarations.contains(declaration_id) { + return Err(OrmError::Invalid(format!( + "TJSV projection manifest does not contain expected declaration {declaration_id}" + ))); + } + } + + let target = projection_target(manifest, expected_projection_id)?; + let target_declarations = normalize_ids( + string_array_field(target, "declarationIds", "projection target")?, + "projection declaration ids", + false, + )?; + if target_declarations != expected_declarations { + return Err(OrmError::Invalid(format!( + "TJSV projection declaration scope does not match expected scope: expected {expected_declarations:?}, got {target_declarations:?}" + ))); + } + + let runtime_validator_ids = normalize_ids( + string_array_field(target, "runtimeValidatorIds", "projection target")?, + "projection runtime validator ids", + false, + )?; + if runtime_validator_ids != expected_runtime_validators { + return Err(OrmError::Invalid(format!( + "TJSV projection runtime-validator scope does not match expected scope: expected {expected_runtime_validators:?}, got {runtime_validator_ids:?}" + ))); + } + + verify_runtime_validators(manifest, expected_projection_id, &runtime_validator_ids)?; + let outputs = projection_outputs(manifest, target, expected_projection_id)?; + + let projection_receipt = + parse_json(projection_receipt_bytes, "projection verification receipt")?; + let receipt = projection_receipt.as_object().ok_or_else(|| { + OrmError::Invalid( + "TJSV projection verification receipt must be a JSON object".to_owned(), + ) + })?; + require_string( + receipt, + "schema", + TJSV_PROJECTION_VERIFICATION_RECEIPT_SCHEMA, + "projection verification receipt", + )?; + require_string( + receipt, + "status", + "passed", + "projection verification receipt", + )?; + require_bool( + receipt, + "admissible", + true, + "projection verification receipt", + )?; + if receipt.get("failureCode") != Some(&Value::Null) { + return Err(OrmError::Invalid( + "passed TJSV projection verification receipt must have null failureCode".to_owned(), + )); + } + let finding_rule_ids = + string_array_field(receipt, "findingRuleIds", "projection verification receipt")?; + if !finding_rule_ids.is_empty() { + return Err(OrmError::Invalid( + "passed TJSV projection verification receipt must not contain findings".to_owned(), + )); + } + + let projection_verification_id = + digest_field(receipt, "verificationId", "projection verification receipt")?; + if canonical_digest_without(&projection_receipt, "verificationId")? + != projection_verification_id + { + return Err(OrmError::Invalid( + "TJSV projection verification receipt self-digest does not match verificationId" + .to_owned(), + )); + } + require_digest_value( + receipt, + "manifestId", + &projection_manifest_id, + "projection verification receipt", + )?; + require_digest_value( + receipt, + "contractIrId", + &contract_ir_id, + "projection verification receipt", + )?; + require_digest_value( + receipt, + "receiptRunId", + &parity_receipt_run_id, + "projection verification receipt", + )?; + let evidence_digest = + digest_field(receipt, "evidenceDigest", "projection verification receipt")?; + verify_source_digests( + object_field(receipt, "sourceDigests", "projection verification receipt")?, + &source_digests, + "projection verification receipt sourceDigests", + )?; + verify_receipt_summary(receipt, manifest)?; + + return Ok(Self { + schema: TJSV_ADMISSION_BINDING_SCHEMA.to_owned(), + projection_id: expected_projection_id.to_owned(), + contract_ir_id, + contract_ir_sha256: digest(contract_ir_bytes), + projection_manifest_id, + projection_manifest_sha256: digest(projection_manifest_bytes), + projection_verification_id, + projection_verification_sha256: digest(projection_receipt_bytes), + parity_receipt_run_id, + evidence_digest, + declaration_ids: expected_declarations, + runtime_validator_ids, + outputs, + verified: true, + }); + } +} + +fn verify_contract_ir( + contract: &serde_json::Map, + contract_ir: &Value, +) -> Result<()> { + require_string(contract, "schema", TJSV_CONTRACT_IR_SCHEMA, "Contract IR")?; + require_string(contract, "status", "passed", "Contract IR")?; + require_bool(contract, "admissible", true, "Contract IR")?; + require_string( + contract, + "role", + "downstream-derived-parity-artifact", + "Contract IR", + )?; + require_bool(contract, "editableAuthority", false, "Contract IR")?; + + let authorities = object_field(contract, "authorities", "Contract IR")?; + require_string( + authorities, + "typespec", + "independently-authored", + "Contract IR authorities", + )?; + require_string( + authorities, + "jsonSchema", + "independently-authored", + "Contract IR authorities", + )?; + require_string( + authorities, + "generatedJsonSchema", + "comparison-evidence-only", + "Contract IR authorities", + )?; + require_string(authorities, "precedence", "none", "Contract IR authorities")?; + + let contract_ir_id = digest_field(contract, "irId", "Contract IR")?; + if canonical_digest_without(contract_ir, "irId")? != contract_ir_id { + return Err(OrmError::Invalid( + "TJSV Contract IR self-digest does not match irId".to_owned(), + )); + } + + let admission = object_field(contract, "admission", "Contract IR")?; + let parity = object_field(admission, "receipt", "Contract IR admission")?; + require_string( + parity, + "schema", + TJSV_PARITY_REPORT_SCHEMA, + "Contract IR parity binding", + )?; + require_string(parity, "status", "passed", "Contract IR parity binding")?; + require_bool( + parity, + "zeroUnexplainedFindings", + true, + "Contract IR parity binding", + )?; + digest_field(parity, "runId", "Contract IR parity binding")?; + digest_field(parity, "digest", "Contract IR parity binding")?; + + let requirements = object_field(admission, "requirements", "Contract IR admission")?; + for key in [ + "exactInputDigests", + "directDeclarationInventory", + "generatedSchemaComparison", + "differentialInstanceValidation", + "zeroUnexplainedFindings", + ] { + require_bool( + requirements, + key, + true, + "Contract IR admission requirements", + )?; + } + + declaration_ids(contract)?; + contract_source_digests(contract)?; + return Ok(()); +} + +fn projection_target<'a>( + manifest: &'a serde_json::Map, + projection_id: &str, +) -> Result<&'a serde_json::Map> { + let projections = manifest + .get("projections") + .and_then(Value::as_array) + .ok_or_else(|| { + OrmError::Invalid("TJSV projection manifest projections must be an array".to_owned()) + })?; + let mut matches = projections + .iter() + .filter_map(Value::as_object) + .filter(|projection| projection.get("id").and_then(Value::as_str) == Some(projection_id)); + let target = matches.next().ok_or_else(|| { + OrmError::Invalid(format!( + "TJSV projection manifest is missing projection {projection_id}" + )) + })?; + if matches.next().is_some() { + return Err(OrmError::Invalid(format!( + "TJSV projection manifest contains duplicate projection {projection_id}" + ))); + } + return Ok(target); +} + +fn projection_outputs( + manifest: &serde_json::Map, + target: &serde_json::Map, + projection_id: &str, +) -> Result> { + let expected_paths = normalize_ids( + string_array_field(target, "outputPaths", "projection target")?, + "projection output paths", + false, + )?; + if expected_paths.is_empty() { + return Err(OrmError::Invalid( + "admitted TJSV projection must contain at least one output".to_owned(), + )); + } + + let outputs = manifest + .get("outputs") + .and_then(Value::as_array) + .ok_or_else(|| { + OrmError::Invalid("TJSV projection manifest outputs must be an array".to_owned()) + })?; + let mut bindings = Vec::new(); + for output in outputs { + let output = output.as_object().ok_or_else(|| { + OrmError::Invalid("TJSV projection manifest output must be an object".to_owned()) + })?; + if output.get("projection").and_then(Value::as_str) != Some(projection_id) { + continue; + } + let path = nonempty_string_field(output, "path", "projection output")?; + let sha256 = digest_field(output, "sha256", "projection output")?; + bindings.push(TjsvOutputBinding { path, sha256 }); + } + bindings.sort_by(|left, right| left.path.cmp(&right.path)); + let actual_paths = bindings + .iter() + .map(|binding| binding.path.clone()) + .collect::>(); + if actual_paths != expected_paths { + return Err(OrmError::Invalid(format!( + "TJSV projection output closure does not match projection target: expected {expected_paths:?}, got {actual_paths:?}" + ))); + } + return Ok(bindings); +} + +fn verify_runtime_validators( + manifest: &serde_json::Map, + projection_id: &str, + expected_ids: &[String], +) -> Result<()> { + let validators = manifest + .get("runtimeValidators") + .and_then(Value::as_array) + .ok_or_else(|| { + OrmError::Invalid( + "TJSV projection manifest runtimeValidators must be an array".to_owned(), + ) + })?; + let mut actual_ids = Vec::new(); + for validator in validators { + let validator = validator.as_object().ok_or_else(|| { + OrmError::Invalid("TJSV runtime validator must be an object".to_owned()) + })?; + if validator.get("projection").and_then(Value::as_str) != Some(projection_id) { + continue; + } + let id = nonempty_string_field(validator, "id", "runtime validator")?; + if !valid_identifier(&id) { + return Err(OrmError::Invalid(format!( + "TJSV runtime validator id {id:?} is invalid" + ))); + } + digest_field(validator, "artifactDigest", "runtime validator")?; + digest_field(validator, "fixtureDigest", "runtime validator")?; + digest_field( + validator, + "ingressEgressCoverageDigest", + "runtime validator", + )?; + nonempty_string_field(validator, "artifactPath", "runtime validator")?; + actual_ids.push(id); + } + actual_ids = normalize_ids(actual_ids, "runtime validator manifest ids", false)?; + if actual_ids != expected_ids { + return Err(OrmError::Invalid(format!( + "TJSV runtime-validator manifest entries do not match projection target: expected {expected_ids:?}, got {actual_ids:?}" + ))); + } + return Ok(()); +} + +fn verify_receipt_summary( + receipt: &serde_json::Map, + manifest: &serde_json::Map, +) -> Result<()> { + let summary = object_field(receipt, "summary", "projection verification receipt")?; + for (key, manifest_key) in [ + ("declarations", "declarations"), + ("projections", "projections"), + ("outputs", "outputs"), + ("representationDeltas", "representationDeltas"), + ("runtimeValidators", "runtimeValidators"), + ] { + let expected = manifest + .get(manifest_key) + .and_then(Value::as_array) + .map(Vec::len) + .ok_or_else(|| { + OrmError::Invalid(format!( + "TJSV projection manifest {manifest_key} must be an array" + )) + })?; + let actual = summary + .get(key) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| { + OrmError::Invalid(format!( + "TJSV projection verification summary {key} must be a non-negative integer" + )) + })?; + if actual != expected { + return Err(OrmError::Invalid(format!( + "TJSV projection verification summary {key} does not match manifest: expected {expected}, got {actual}" + ))); + } + } + return Ok(()); +} + +fn contract_source_digests(contract: &serde_json::Map) -> Result<[String; 3]> { + let provenance = object_field(contract, "provenance", "Contract IR")?; + let typespec = object_field(provenance, "typespec", "Contract IR provenance")?; + let generated = object_field(provenance, "generatedJsonSchema", "Contract IR provenance")?; + let authored = object_field(provenance, "authoredJsonSchema", "Contract IR provenance")?; + return Ok([ + digest_field(typespec, "digest", "Contract IR TypeSpec provenance")?, + digest_field( + generated, + "digest", + "Contract IR generated JSON Schema provenance", + )?, + digest_field( + authored, + "digest", + "Contract IR authored JSON Schema provenance", + )?, + ]); +} + +fn verify_source_digests( + object: &serde_json::Map, + expected: &[String; 3], + label: &str, +) -> Result<()> { + for (index, key) in ["typespec", "generatedJsonSchema", "authoredJsonSchema"] + .iter() + .enumerate() + { + require_digest_value(object, key, &expected[index], label)?; + } + return Ok(()); +} + +fn parse_json(bytes: &[u8], label: &str) -> Result { + return serde_json::from_slice(bytes) + .map_err(|error| OrmError::Invalid(format!("could not parse TJSV {label}: {error}"))); +} + +fn require_string( + object: &serde_json::Map, + key: &str, + expected: &str, + label: &str, +) -> Result<()> { + let actual = object.get(key).and_then(Value::as_str); + if actual != Some(expected) { + return Err(OrmError::Invalid(format!( + "{label} {key} must be {expected:?}" + ))); + } + return Ok(()); +} + +fn require_bool( + object: &serde_json::Map, + key: &str, + expected: bool, + label: &str, +) -> Result<()> { + let actual = object.get(key).and_then(Value::as_bool); + if actual != Some(expected) { + return Err(OrmError::Invalid(format!( + "{label} {key} must be {expected}" + ))); + } + return Ok(()); +} + +fn require_digest_value( + object: &serde_json::Map, + key: &str, + expected: &str, + label: &str, +) -> Result<()> { + let actual = digest_field(object, key, label)?; + if actual != expected { + return Err(OrmError::Invalid(format!( + "{label} {key} does not match expected digest" + ))); + } + return Ok(()); +} + +fn object_field<'a>( + object: &'a serde_json::Map, + key: &str, + label: &str, +) -> Result<&'a serde_json::Map> { + return object + .get(key) + .and_then(Value::as_object) + .ok_or_else(|| OrmError::Invalid(format!("{label} {key} must be a JSON object"))); +} + +fn nonempty_string_field( + object: &serde_json::Map, + key: &str, + label: &str, +) -> Result { + return object + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| OrmError::Invalid(format!("{label} {key} must be a non-empty string"))); +} + +fn digest_field(object: &serde_json::Map, key: &str, label: &str) -> Result { + let value = object + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| OrmError::Invalid(format!("{label} {key} must be a SHA-256 digest")))?; + if !valid_digest(value) { + return Err(OrmError::Invalid(format!( + "{label} {key} must be a lowercase 64-character SHA-256 digest" + ))); + } + return Ok(value.to_owned()); +} + +fn string_array_field( + object: &serde_json::Map, + key: &str, + label: &str, +) -> Result> { + let values = object + .get(key) + .and_then(Value::as_array) + .ok_or_else(|| OrmError::Invalid(format!("{label} {key} must be an array")))?; + return values + .iter() + .map(|value| { + value + .as_str() + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + OrmError::Invalid(format!("{label} {key} entries must be non-empty strings")) + }) + }) + .collect(); +} + +fn declaration_ids(contract: &serde_json::Map) -> Result> { + let declarations = contract + .get("declarations") + .and_then(Value::as_array) + .ok_or_else(|| { + OrmError::Invalid("TJSV Contract IR declarations must be an array".to_owned()) + })?; + let mut ids = BTreeSet::new(); + for declaration in declarations { + let id = declaration + .as_object() + .and_then(|object| object.get("id")) + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + OrmError::Invalid( + "TJSV Contract IR declaration ids must be non-empty strings".to_owned(), + ) + })?; + if !ids.insert(id.to_owned()) { + return Err(OrmError::Invalid(format!( + "TJSV Contract IR contains duplicate declaration id {id}" + ))); + } + } + return Ok(ids); +} + +fn normalize_expected_ids(values: &[String], label: &str) -> Result> { + if values.is_empty() { + return Err(OrmError::Invalid(format!( + "TJSV admission requires at least one {label}" + ))); + } + return normalize_ids(values.to_vec(), label, false); +} + +fn normalize_ids(mut values: Vec, label: &str, allow_empty: bool) -> Result> { + if !allow_empty && values.is_empty() { + return Err(OrmError::Invalid(format!("{label} must not be empty"))); + } + if values.iter().any(|value| value.is_empty()) { + return Err(OrmError::Invalid(format!( + "{label} must not contain empty values" + ))); + } + values.sort(); + let original_len = values.len(); + values.dedup(); + if values.len() != original_len { + return Err(OrmError::Invalid(format!( + "{label} must not contain duplicates" + ))); + } + return Ok(values); +} + +fn canonical_digest_without(value: &Value, excluded_key: &str) -> Result { + let mut value = value.clone(); + let object = value.as_object_mut().ok_or_else(|| { + OrmError::Invalid("canonical TJSV evidence must be a JSON object".to_owned()) + })?; + if object.remove(excluded_key).is_none() { + return Err(OrmError::Invalid(format!( + "canonical TJSV evidence is missing {excluded_key}" + ))); + } + return canonical_digest(&value); +} + +fn canonical_digest(value: &Value) -> Result { + let canonical = canonical_json(value)?; + return Ok(digest(canonical.as_bytes())); +} + +fn canonical_json(value: &Value) -> Result { + let mut out = String::new(); + write_canonical(value, &mut out)?; + return Ok(out); +} + +fn write_canonical(value: &Value, out: &mut String) -> Result<()> { + match value { + Value::Null => out.push_str("null"), + Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }), + Value::Number(value) => out.push_str(&value.to_string()), + Value::String(value) => { + out.push_str(&serde_json::to_string(value).map_err(|error| { + OrmError::Invalid(format!("could not canonicalize JSON string: {error}")) + })?); + } + Value::Array(values) => { + out.push('['); + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push(','); + } + write_canonical(value, out)?; + } + out.push(']'); + } + Value::Object(object) => { + out.push('{'); + let mut keys = object.keys().collect::>(); + keys.sort(); + for (index, key) in keys.iter().enumerate() { + if index > 0 { + out.push(','); + } + out.push_str(&serde_json::to_string(key).map_err(|error| { + OrmError::Invalid(format!("could not canonicalize JSON object key: {error}")) + })?); + out.push(':'); + write_canonical( + object + .get(key.as_str()) + .expect("canonical object key must exist"), + out, + )?; + } + out.push('}'); + } + } + return Ok(()); +} + +fn valid_identifier(value: &str) -> bool { + if value.is_empty() || value.len() > 128 { + return false; + } + let bytes = value.as_bytes(); + if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() { + return false; + } + if bytes.len() > 1 + && !bytes[bytes.len() - 1].is_ascii_lowercase() + && !bytes[bytes.len() - 1].is_ascii_digit() + { + return false; + } + return bytes.iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(*byte, b'.' | b'_' | b'-') + }); +} + +fn valid_digest(value: &str) -> bool { + return value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()); +} + +fn digest(bytes: &[u8]) -> String { + return hex::encode(Sha256::digest(bytes)); +} diff --git a/cert/ores-orm-core-pr12/src/descriptor.rs b/cert/ores-orm-core-pr12/src/descriptor.rs new file mode 100644 index 0000000..141cc1d --- /dev/null +++ b/cert/ores-orm-core-pr12/src/descriptor.rs @@ -0,0 +1,233 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType, Table}; +use crate::policy::{FieldVisibility, Policy}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const ORM_DESCRIPTOR_SCHEMA: &str = "ores.orm-descriptor/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub enum OrmWitness { + #[serde(rename = "diesel")] + Diesel, + #[serde(rename = "seaorm")] + SeaOrm, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DescriptorSource { + pub repository: String, + pub revision: String, + #[serde(rename = "crate")] + pub crate_name: String, + pub toolchain: String, + pub orm: Vec, +} + +impl DescriptorSource { + pub fn new( + repository: impl Into, + revision: impl Into, + crate_name: impl Into, + toolchain: impl Into, + orm: Vec, + ) -> Result { + let repository = repository.into(); + let revision = revision.into(); + let crate_name = crate_name.into(); + let toolchain = toolchain.into(); + + for (label, value) in [ + ("repository", repository.as_str()), + ("crate", crate_name.as_str()), + ("toolchain", toolchain.as_str()), + ] { + if value.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "ORM descriptor source {label} must not be empty" + ))); + } + } + + if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(OrmError::Invalid( + "ORM descriptor source revision must be a 40-character hexadecimal Git revision" + .to_owned(), + )); + } + if orm.is_empty() { + return Err(OrmError::Invalid( + "ORM descriptor must identify at least one ORM witness".to_owned(), + )); + } + let unique_witnesses = orm + .iter() + .copied() + .collect::>(); + if unique_witnesses.len() != orm.len() { + return Err(OrmError::Invalid( + "ORM descriptor source contains duplicate ORM witnesses".to_owned(), + )); + } + + return Ok(Self { + repository, + revision: revision.to_ascii_lowercase(), + crate_name, + toolchain, + orm, + }); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DescriptorType { + pub kind: DescriptorScalar, + pub array: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum DescriptorScalar { + Boolean, + Int16, + Int32, + Int64, + Float32, + Float64, + Decimal, + String, + Uuid, + PlainDate, + UtcDateTime, + Bytes, + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DescriptorField { + pub name: String, + pub wire_name: String, + pub database_name: String, + pub previous_wire_names: Vec, + pub previous_database_names: Vec, + pub r#type: DescriptorType, + pub nullable: bool, + pub generated: bool, + pub defaulted: bool, + pub selectable: bool, + pub insertable: bool, + pub updatable: bool, + pub visibility: FieldVisibility, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DescriptorModel { + pub namespace: String, + pub name: String, + pub table: String, + pub public_surface: bool, + pub fields: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct OrmDescriptor { + pub schema: String, + pub source: DescriptorSource, + pub model: DescriptorModel, +} + +pub fn describe_table( + table: &Table, + policy: &Policy, + namespace: impl Into, + source: DescriptorSource, +) -> Result { + policy.validate_table(table)?; + let table_policy = policy.table_policy(table); + let fields = table + .columns + .iter() + .map(|column| { + let wire_name = table_policy + .wire_names + .get(&column.db_name) + .cloned() + .unwrap_or_else(|| column.db_name.clone()); + + return descriptor_type(&column.ty).map(|r#type| DescriptorField { + name: column.rust_name.clone(), + wire_name, + database_name: column.db_name.clone(), + previous_wire_names: Vec::new(), + previous_database_names: Vec::new(), + r#type, + nullable: column.nullable, + generated: table_policy.generated.contains(&column.db_name), + defaulted: table_policy.defaulted.contains(&column.db_name), + selectable: true, + insertable: !table_policy.generated.contains(&column.db_name), + updatable: !table_policy.generated.contains(&column.db_name) + && !table_policy.immutable.contains(&column.db_name), + visibility: table_policy.visibility(&column.db_name), + }); + }) + .collect::>>()?; + + return Ok(OrmDescriptor { + schema: ORM_DESCRIPTOR_SCHEMA.to_owned(), + source, + model: DescriptorModel { + namespace: namespace.into(), + name: table.db_name.clone(), + table: table.qualified_name(), + public_surface: table_policy.public_surface, + fields, + }, + }); +} + +fn descriptor_type(ty: &OrmType) -> Result { + let (kind, array) = match ty { + OrmType::Scalar(scalar) => (descriptor_scalar(scalar), false), + OrmType::Array(inner) => { + if matches!(inner.as_ref(), OrmType::Array(_)) { + return Err(OrmError::Unsupported( + "nested ORM arrays are not representable in ores.orm-descriptor/v1".to_owned(), + )); + } + let inner = descriptor_type(inner)?; + (inner.kind, true) + } + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before descriptor emission" + ))); + } + }; + + return Ok(DescriptorType { kind, array }); +} + +fn descriptor_scalar(scalar: &ScalarType) -> DescriptorScalar { + return match scalar { + ScalarType::Boolean => DescriptorScalar::Boolean, + ScalarType::Int16 => DescriptorScalar::Int16, + ScalarType::Int32 => DescriptorScalar::Int32, + ScalarType::Int64 => DescriptorScalar::Int64, + ScalarType::Float32 => DescriptorScalar::Float32, + ScalarType::Float64 => DescriptorScalar::Float64, + ScalarType::Decimal => DescriptorScalar::Decimal, + ScalarType::String => DescriptorScalar::String, + ScalarType::Uuid => DescriptorScalar::Uuid, + ScalarType::Date => DescriptorScalar::PlainDate, + ScalarType::DateTime => DescriptorScalar::UtcDateTime, + ScalarType::Bytes => DescriptorScalar::Bytes, + ScalarType::Json => DescriptorScalar::Json, + }; +} diff --git a/cert/ores-orm-core-pr12/src/diesel.rs b/cert/ores-orm-core-pr12/src/diesel.rs new file mode 100644 index 0000000..d673fbf --- /dev/null +++ b/cert/ores-orm-core-pr12/src/diesel.rs @@ -0,0 +1,259 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{Column, OrmIr, OrmType, ScalarType, SourceKind, Table}; +use quote::ToTokens; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Attribute, Ident, Item, Lit, Meta, Token, Type}; + +/// Parse one `diesel print-schema` style Rust source file. +pub fn parse_schema(source: &str) -> Result { + let file = syn::parse_file(source)?; + let mut tables = Vec::new(); + collect_items(&file.items, &mut tables)?; + if tables.is_empty() { + return Err(OrmError::Invalid( + "Diesel input contained no table! declarations".to_owned(), + )); + } + return OrmIr::try_new(SourceKind::Diesel, tables); +} + +fn collect_items(items: &[Item], tables: &mut Vec) -> Result<()> { + for item in items { + match item { + Item::Macro(item_macro) + if item_macro + .mac + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "table") => + { + let parsed = syn::parse2::(item_macro.mac.tokens.clone())?; + tables.push(parsed.into_table()?); + } + Item::Mod(module) => { + if let Some((_, nested)) = &module.content { + collect_items(nested, tables)?; + } + } + _ => {} + } + } + return Ok(()); +} + +#[derive(Debug)] +struct DieselColumn { + attrs: Vec, + name: Ident, + ty: Type, +} + +#[derive(Debug)] +struct DieselTable { + attrs: Vec, + schema_name: Option, + table_name: Ident, + primary_key: Vec, + columns: Vec, +} + +impl Parse for DieselTable { + fn parse(input: ParseStream<'_>) -> syn::Result { + while input.peek(Token![use]) { + let _: syn::ItemUse = input.parse()?; + } + + let attrs = input.call(Attribute::parse_outer)?; + let first: Ident = input.parse()?; + let (schema_name, table_name) = if input.peek(Token![.]) { + input.parse::()?; + (Some(first), input.parse()?) + } else { + (None, first) + }; + + let content; + syn::parenthesized!(content in input); + let primary_key = Punctuated::::parse_terminated(&content)? + .into_iter() + .collect(); + + let body; + syn::braced!(body in input); + let mut columns = Vec::new(); + while !body.is_empty() { + let attrs = body.call(Attribute::parse_outer)?; + let name: Ident = body.parse()?; + body.parse::]>()?; + let ty: Type = body.parse()?; + if body.peek(Token![,]) { + body.parse::()?; + } + columns.push(DieselColumn { attrs, name, ty }); + } + + if !input.is_empty() { + return Err(input.error("unexpected tokens after Diesel table declaration")); + } + + return Ok(Self { + attrs, + schema_name, + table_name, + primary_key, + columns, + }); + } +} + +impl DieselTable { + fn into_table(self) -> Result
{ + let table_db_name = sql_name(&self.attrs)?.unwrap_or_else(|| self.table_name.to_string()); + let primary_keys: Vec = self.primary_key.iter().map(ToString::to_string).collect(); + if primary_keys.is_empty() { + return Err(OrmError::Invalid(format!( + "Diesel table {table_db_name} declares an empty primary-key list" + ))); + } + for (index, key) in primary_keys.iter().enumerate() { + if primary_keys[..index].contains(key) { + return Err(OrmError::Invalid(format!( + "Diesel table {table_db_name} declares duplicate primary-key column {key}" + ))); + } + } + + let mut columns = Vec::with_capacity(self.columns.len()); + for (ordinal, column) in self.columns.into_iter().enumerate() { + let rust_name = column.name.to_string(); + let db_name = sql_name(&column.attrs)?.unwrap_or_else(|| rust_name.clone()); + let (ty, nullable) = map_diesel_type(&column.ty)?; + columns.push(Column { + rust_name: rust_name.clone(), + db_name, + ordinal, + ty, + nullable, + primary_key: primary_keys.iter().any(|key| key == &rust_name), + unique: false, + }); + } + + for key in &primary_keys { + if !columns.iter().any(|column| column.rust_name == *key) { + return Err(OrmError::Invalid(format!( + "Diesel table {table_db_name} primary-key column {key} is not declared in the table body" + ))); + } + } + + return Ok(Table { + schema_name: self.schema_name.map(|ident| ident.to_string()), + db_name: table_db_name, + columns, + }); + } +} + +fn sql_name(attrs: &[Attribute]) -> Result> { + for attr in attrs { + if !attr.path().is_ident("sql_name") { + continue; + } + match &attr.meta { + Meta::NameValue(name_value) => { + if let syn::Expr::Lit(expr_lit) = &name_value.value + && let Lit::Str(value) = &expr_lit.lit + { + return Ok(Some(value.value())); + } + return Err(OrmError::Unsupported( + "Diesel #[sql_name] must contain a string literal".to_owned(), + )); + } + _ => { + return Err(OrmError::Unsupported( + "unsupported Diesel #[sql_name] syntax".to_owned(), + )); + } + } + } + return Ok(None); +} + +fn map_diesel_type(ty: &Type) -> Result<(OrmType, bool)> { + let Type::Path(type_path) = ty else { + return Err(OrmError::Unsupported(format!( + "Diesel type is not a path: {}", + ty.to_token_stream() + ))); + }; + let segment = + type_path.path.segments.last().ok_or_else(|| { + OrmError::Unsupported("Diesel type path has no final segment".to_owned()) + })?; + let name = segment.ident.to_string(); + + if name == "Nullable" { + let inner = single_generic_type(segment)?; + let (inner, _) = map_diesel_type(inner)?; + return Ok((inner, true)); + } + if name == "Array" { + let inner = single_generic_type(segment)?; + let (inner, inner_nullable) = map_diesel_type(inner)?; + if inner_nullable { + return Err(OrmError::Unsupported( + "nullable array elements require explicit contract evidence".to_owned(), + )); + } + return Ok((OrmType::Array(Box::new(inner)), false)); + } + + let scalar = match name.as_str() { + "Bool" => Some(ScalarType::Boolean), + "SmallInt" | "Int2" => Some(ScalarType::Int16), + "Integer" | "Int4" => Some(ScalarType::Int32), + "BigInt" | "Int8" => Some(ScalarType::Int64), + "Float" | "Float4" => Some(ScalarType::Float32), + "Double" | "Float8" => Some(ScalarType::Float64), + "Numeric" => Some(ScalarType::Decimal), + "Text" | "Varchar" | "VarChar" | "Bpchar" | "Citext" => Some(ScalarType::String), + "Uuid" => Some(ScalarType::Uuid), + "Date" => Some(ScalarType::Date), + "Timestamp" | "Timestamptz" => Some(ScalarType::DateTime), + "Bytea" | "Binary" => Some(ScalarType::Bytes), + "Json" | "Jsonb" => Some(ScalarType::Json), + _ => None, + }; + + return Ok(( + scalar.map_or_else(|| OrmType::Named(name), OrmType::Scalar), + false, + )); +} + +fn single_generic_type(segment: &syn::PathSegment) -> Result<&Type> { + let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return Err(OrmError::Unsupported(format!( + "{} requires one type argument", + segment.ident + ))); + }; + let mut types = arguments.args.iter().filter_map(|argument| match argument { + syn::GenericArgument::Type(ty) => Some(ty), + _ => None, + }); + let first = types.next().ok_or_else(|| { + OrmError::Unsupported(format!("{} requires one type argument", segment.ident)) + })?; + if types.next().is_some() || arguments.args.len() != 1 { + return Err(OrmError::Unsupported(format!( + "{} accepts exactly one type argument in the ORM IR", + segment.ident + ))); + } + return Ok(first); +} diff --git a/cert/ores-orm-core-pr12/src/emit/bundle.rs b/cert/ores-orm-core-pr12/src/emit/bundle.rs new file mode 100644 index 0000000..1960e67 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/emit/bundle.rs @@ -0,0 +1,386 @@ +use super::{dart, gleam, json_schema, naming, rust, typescript}; +use crate::admission::TjsvAdmissionBinding; +use crate::error::{OrmError, Result}; +use crate::ir::OrmIr; +use crate::policy::Policy; +use crate::shapes::{Shape, ShapeKind, derive_shape}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const DERIVATIVE_MANIFEST_SCHEMA: &str = "ores.orm-core.derivative-manifest/v3"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContractEvidenceState { + NotApplicable, + Required, + /// Exact bytes have been digest-bound to the derivative. This does not + /// mean TJSV has admitted the derivative for publication. + EvidenceBound, + /// The exact bound Contract IR and projection-verification receipt were + /// independently verified before bundle promotion. + Admitted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PublicationState { + PrivateDerivative, + BlockedPendingContractAdmission, + PublicAdmitted, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InputDigest { + pub name: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContractEvidenceDigest { + pub contract_ir_sha256: String, + pub receipt_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenerationEvidence { + pub orm_ir_sha256: String, + pub source_digests: Vec, + pub generator_options_sha256: String, + pub contract: Option, +} + +impl GenerationEvidence { + pub fn from_bytes( + orm_ir: &OrmIr, + source_inputs: &[(&str, &[u8])], + generator_options: &[u8], + contract: Option<(&[u8], &[u8])>, + ) -> Result { + orm_ir.validate()?; + if source_inputs.is_empty() { + return Err(OrmError::Invalid( + "generation evidence requires at least one exact source input".to_owned(), + )); + } + + let mut source_digests = source_inputs + .iter() + .map(|(name, bytes)| { + if name.trim().is_empty() { + return Err(OrmError::Invalid( + "generation evidence source names must not be empty".to_owned(), + )); + } + return Ok(InputDigest { + name: (*name).to_owned(), + sha256: digest(bytes), + }); + }) + .collect::>>()?; + source_digests.sort_by(|left, right| left.name.cmp(&right.name)); + for pair in source_digests.windows(2) { + if pair[0].name == pair[1].name { + return Err(OrmError::Invalid(format!( + "generation evidence contains duplicate source name {}", + pair[0].name + ))); + } + } + + let ir_bytes = serde_json::to_vec(orm_ir) + .map_err(|error| OrmError::Invalid(format!("could not encode ORM IR: {error}")))?; + let contract = match contract { + None => None, + Some((contract_ir, receipt)) => { + if contract_ir.is_empty() || receipt.is_empty() { + return Err(OrmError::Invalid( + "contract evidence requires non-empty Contract IR and receipt bytes" + .to_owned(), + )); + } + Some(ContractEvidenceDigest { + contract_ir_sha256: digest(contract_ir), + receipt_sha256: digest(receipt), + }) + } + }; + + return Ok(Self { + orm_ir_sha256: digest(&ir_bytes), + source_digests, + generator_options_sha256: digest(generator_options), + contract, + }); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactDigest { + pub path: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DerivativeManifest { + pub schema: String, + pub generator_version: String, + pub table: String, + pub shape: ShapeKind, + pub projection_identity: String, + pub public_candidate: bool, + pub publication: PublicationState, + pub contract_evidence: ContractEvidenceState, + pub public_admission: Option, + pub shape_sha256: String, + /// SHA-256 of the deterministic JSON serialization of the exact validated + /// `.ores-orm.toml` policy object used to derive this shape. This is kept + /// separate from free-form generator options so provenance cannot claim one + /// policy while emission actually used another. + pub policy_sha256: String, + pub evidence: GenerationEvidence, + pub artifacts: Vec, +} + +impl DerivativeManifest { + #[must_use] + pub fn publishable(&self) -> bool { + return match self.publication { + PublicationState::PrivateDerivative => true, + PublicationState::BlockedPendingContractAdmission => false, + PublicationState::PublicAdmitted => self + .public_admission + .as_ref() + .is_some_and(TjsvAdmissionBinding::is_verified), + }; + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeneratedArtifact { + pub path: String, + pub sha256: String, + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeneratedBundle { + pub manifest: DerivativeManifest, + pub artifacts: Vec, +} + +impl GeneratedBundle { + pub fn manifest_json(&self) -> Result { + return serde_json::to_string_pretty(&self.manifest) + .map_err(|error| OrmError::Invalid(format!("could not encode manifest: {error}"))); + } +} + +/// Emit a derivative only after proving that the supplied shape is exactly the +/// shape derived from the same converged ORM IR and policy bound to this run. +pub fn emit( + orm_ir: &OrmIr, + policy: &Policy, + shape: &Shape, + evidence: &GenerationEvidence, +) -> Result { + return emit_with_admission(orm_ir, policy, shape, evidence, None); +} + +pub fn emit_admitted( + orm_ir: &OrmIr, + policy: &Policy, + shape: &Shape, + evidence: &GenerationEvidence, + admission: &TjsvAdmissionBinding, +) -> Result { + if !shape.kind.is_public() { + return Err(OrmError::Invalid( + "TJSV public admission can only be attached to public ORM shapes".to_owned(), + )); + } + if !admission.is_verified() { + return Err(OrmError::Invalid( + "TJSV public admission binding must be verified in this process before promotion" + .to_owned(), + )); + } + + let contract = evidence.contract.as_ref().ok_or_else(|| { + OrmError::Invalid( + "admitted public ORM bundle requires exact Contract IR and projection-verification receipt bytes in generation evidence" + .to_owned(), + ) + })?; + if contract.contract_ir_sha256.as_str() != admission.contract_ir_sha256() { + return Err(OrmError::Invalid( + "TJSV admission Contract IR digest does not match generation evidence".to_owned(), + )); + } + if contract.receipt_sha256.as_str() != admission.projection_verification_sha256() { + return Err(OrmError::Invalid( + "TJSV projection-verification receipt digest does not match generation evidence" + .to_owned(), + )); + } + + let expected_projection_id = naming::projection_id(shape); + if admission.projection_id() != expected_projection_id.as_str() { + return Err(OrmError::Invalid(format!( + "TJSV admission projection id {} does not match generated projection {expected_projection_id}", + admission.projection_id() + ))); + } + + return emit_with_admission(orm_ir, policy, shape, evidence, Some(admission.clone())); +} + +fn emit_with_admission( + orm_ir: &OrmIr, + policy: &Policy, + shape: &Shape, + evidence: &GenerationEvidence, + admission: Option, +) -> Result { + orm_ir.validate()?; + policy.validate_ir(orm_ir)?; + shape.validate()?; + + let ir_bytes = serde_json::to_vec(orm_ir) + .map_err(|error| OrmError::Invalid(format!("could not encode ORM IR: {error}")))?; + let actual_ir_sha256 = digest(&ir_bytes); + if evidence.orm_ir_sha256 != actual_ir_sha256 { + return Err(OrmError::Invalid(format!( + "generation evidence ORM IR digest {} does not match supplied ORM IR {}", + evidence.orm_ir_sha256, actual_ir_sha256 + ))); + } + + let policy_bytes = serde_json::to_vec(policy) + .map_err(|error| OrmError::Invalid(format!("could not encode ORM policy: {error}")))?; + let policy_sha256 = digest(&policy_bytes); + + let table = orm_ir.table(&shape.table).ok_or_else(|| { + OrmError::Invalid(format!( + "derived shape references table {} that is absent from the bound ORM IR", + shape.table + )) + })?; + let expected = derive_shape(table, policy, shape.kind)?; + if &expected != shape { + return Err(OrmError::Invalid(format!( + "supplied {:?} shape for {} does not match the exact shape derived from the bound ORM IR and policy", + shape.kind, shape.table + ))); + } + + naming::validate_identifiers(shape, &[], "generated")?; + + let stem = naming::safe_artifact_stem(shape); + let schema = serde_json::to_string_pretty(&json_schema::emit(shape)?) + .map_err(|error| OrmError::Invalid(format!("could not encode JSON Schema: {error}")))?; + + let sources = [ + (format!("rust/{stem}.rs"), rust::emit(shape)?), + (format!("typescript/{stem}.ts"), typescript::emit(shape)?), + (format!("dart/{stem}.dart"), dart::emit(shape)?), + (format!("gleam/{stem}.gleam"), gleam::emit(shape)?), + (format!("json-schema/{stem}.schema.json"), schema), + ]; + + let artifacts = sources + .into_iter() + .map(|(path, content)| GeneratedArtifact { + sha256: digest(content.as_bytes()), + path, + content, + }) + .collect::>(); + + if let Some(admission) = admission.as_ref() { + let mut actual = artifacts + .iter() + .map(|artifact| (artifact.path.clone(), artifact.sha256.clone())) + .collect::>(); + actual.sort(); + let mut expected = admission + .outputs() + .iter() + .map(|output| (output.path.clone(), output.sha256.clone())) + .collect::>(); + expected.sort(); + if actual != expected { + return Err(OrmError::Invalid(format!( + "TJSV verified output closure does not match generated bundle: expected {expected:?}, got {actual:?}" + ))); + } + } + + let artifact_digests = artifacts + .iter() + .map(|artifact| ArtifactDigest { + path: artifact.path.clone(), + sha256: artifact.sha256.clone(), + }) + .collect(); + let shape_bytes = serde_json::to_vec(shape) + .map_err(|error| OrmError::Invalid(format!("could not encode shape: {error}")))?; + let contract_evidence = match ( + shape.kind.is_public(), + evidence.contract.is_some(), + admission.is_some(), + ) { + (false, _, false) => ContractEvidenceState::NotApplicable, + (false, _, true) => { + return Err(OrmError::Invalid( + "private ORM bundle cannot carry public TJSV admission".to_owned(), + )); + } + (true, false, false) => ContractEvidenceState::Required, + (true, true, false) => ContractEvidenceState::EvidenceBound, + (true, true, true) => ContractEvidenceState::Admitted, + (true, false, true) => { + return Err(OrmError::Invalid( + "public TJSV admission requires bound contract evidence".to_owned(), + )); + } + }; + let publication = match (shape.kind.is_public(), admission.is_some()) { + (false, false) => PublicationState::PrivateDerivative, + (false, true) => { + return Err(OrmError::Invalid( + "private ORM bundle cannot be promoted through public TJSV admission".to_owned(), + )); + } + (true, false) => PublicationState::BlockedPendingContractAdmission, + (true, true) => PublicationState::PublicAdmitted, + }; + + return Ok(GeneratedBundle { + manifest: DerivativeManifest { + schema: DERIVATIVE_MANIFEST_SCHEMA.to_owned(), + generator_version: env!("CARGO_PKG_VERSION").to_owned(), + table: shape.table.clone(), + shape: shape.kind, + projection_identity: naming::projection_id(shape), + public_candidate: shape.kind.is_public(), + publication, + contract_evidence, + public_admission: admission, + shape_sha256: digest(&shape_bytes), + policy_sha256, + evidence: evidence.clone(), + artifacts: artifact_digests, + }, + artifacts, + }); +} + +fn digest(bytes: &[u8]) -> String { + return hex::encode(Sha256::digest(bytes)); +} diff --git a/cert/ores-orm-core-pr12/src/emit/dart.rs b/cert/ores-orm-core-pr12/src/emit/dart.rs new file mode 100644 index 0000000..d811325 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/emit/dart.rs @@ -0,0 +1,333 @@ +use super::naming; +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType}; +use crate::shapes::{Shape, ShapeField}; + +const DART_RESERVED: &[&str] = &[ + "abstract", + "as", + "assert", + "async", + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "covariant", + "default", + "deferred", + "do", + "dynamic", + "else", + "enum", + "export", + "extends", + "extension", + "external", + "factory", + "false", + "final", + "finally", + "for", + "Function", + "get", + "hide", + "if", + "implements", + "import", + "in", + "interface", + "is", + "late", + "library", + "mixin", + "new", + "null", + "on", + "operator", + "part", + "required", + "rethrow", + "return", + "sealed", + "set", + "show", + "static", + "super", + "switch", + "sync", + "this", + "throw", + "true", + "try", + "typedef", + "var", + "void", + "when", + "while", + "with", + "yield", +]; + +pub fn emit(shape: &Shape) -> Result { + shape.validate()?; + naming::validate_identifiers(shape, DART_RESERVED, "Dart")?; + let type_name = naming::type_name(shape); + let mut out = String::from( + "// GENERATED by ores-orm-core — derivative evidence, do not edit.\n\n\ + final class OresOptional {\n\ + final bool isPresent;\n\ + final T? value;\n\ + const OresOptional.absent() : isPresent = false, value = null;\n\ + const OresOptional.present(T value) : isPresent = true, value = value;\n\ + }\n\n", + ); + + out.push_str(&format!("final class {type_name} {{\n")); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, DART_RESERVED); + out.push_str(&format!( + " final {} {identifier};\n", + dart_field_type(field)? + )); + } + + out.push_str(&format!("\n const {type_name}({{\n")); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, DART_RESERVED); + out.push_str(&format!(" required this.{identifier},\n")); + } + out.push_str(" });\n\n"); + + out.push_str(&format!( + " factory {type_name}.fromJson(Map json) {{\n" + )); + let allowed = shape + .fields + .iter() + .map(|field| dart_string(&field.wire_name)) + .collect::>() + .join(", "); + out.push_str(&format!(" const allowedKeys = {{{allowed}}};\n")); + out.push_str( + " final unknownKeys = json.keys.where((key) => !allowedKeys.contains(key)).toList();\n\ + if (unknownKeys.isNotEmpty) {\n\ + throw FormatException('unknown fields: $unknownKeys');\n\ + }\n", + ); + if shape.kind.requires_non_empty_object() { + out.push_str( + " if (json.isEmpty) {\n\ + throw const FormatException('patch must contain at least one field');\n\ + }\n", + ); + } + + for field in &shape.fields { + emit_dart_field_parse(&mut out, field)?; + } + + out.push_str(&format!("\n return {type_name}(\n")); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, DART_RESERVED); + out.push_str(&format!(" {identifier}: {identifier}Value,\n")); + } + out.push_str(" );\n }\n}\n"); + + return Ok(out); +} + +fn emit_dart_field_parse(out: &mut String, field: &ShapeField) -> Result<()> { + let identifier = naming::identifier(&field.db_name, DART_RESERVED); + let key = dart_string(&field.wire_name); + let raw = format!("{identifier}Raw"); + let check = dart_check(&field.ty, &raw)?; + let nullable_check = if field.nullable { + format!("{raw} == null || ({check})") + } else { + check + }; + let converted = dart_convert(&field.ty, &raw, !field.nullable)?; + let converted = if field.nullable { + format!("{raw} == null ? null : {converted}") + } else { + converted + }; + + if field.required { + out.push_str(&format!( + " if (!json.containsKey({key})) {{\n\ + throw const FormatException('missing required field: {}');\n\ + }}\n\ + final {raw} = json[{key}];\n\ + if (!({nullable_check})) {{\n\ + throw const FormatException('invalid field: {}');\n\ + }}\n\ + final {identifier}Value = {converted};\n", + field.wire_name, field.wire_name + )); + return Ok(()); + } + + let field_type = dart_field_type(field)?; + out.push_str(&format!( + " final {field_type} {identifier}Value = !json.containsKey({key})\n\ + ? const OresOptional.absent()\n\ + : (() {{\n\ + final {raw} = json[{key}];\n\ + if (!({nullable_check})) {{\n\ + throw const FormatException('invalid field: {}');\n\ + }}\n\ + return OresOptional.present({converted});\n\ + }})();\n", + field.wire_name + )); + + return Ok(()); +} + +fn dart_field_type(field: &ShapeField) -> Result { + let base = dart_type(&field.ty)?; + let value = match (field.required, field.nullable) { + (true, false) => base, + (true, true) => format!("{base}?"), + (false, false) => format!("OresOptional<{base}>"), + (false, true) => format!("OresOptional<{base}?>"), + }; + + return Ok(value); +} + +fn unsupported_scalar(name: &str) -> OrmError { + OrmError::Unsupported(format!( + "{name} requires an admitted cross-runtime wire/domain mapping before Dart emission" + )) +} + +fn dart_type(ty: &OrmType) -> Result { + let value = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => "bool".to_owned(), + ScalarType::Int16 | ScalarType::Int32 => "int".to_owned(), + ScalarType::Int64 => return Err(unsupported_scalar("int64")), + ScalarType::Float32 => return Err(unsupported_scalar("float32")), + ScalarType::Float64 => "double".to_owned(), + ScalarType::Decimal => return Err(unsupported_scalar("decimal")), + ScalarType::String => "String".to_owned(), + ScalarType::Uuid => return Err(unsupported_scalar("uuid")), + ScalarType::Date => return Err(unsupported_scalar("date")), + ScalarType::DateTime => return Err(unsupported_scalar("date-time")), + ScalarType::Bytes => return Err(unsupported_scalar("bytes")), + ScalarType::Json => "Object".to_owned(), + }, + OrmType::Array(inner) => format!("List<{}>", dart_type(inner)?), + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Dart emission" + ))); + } + }; + + return Ok(value); +} + +fn dart_check(ty: &OrmType, value: &str) -> Result { + let check = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => format!("{value} is bool"), + ScalarType::Int16 => { + format!("{value} is int && {value} >= -32768 && {value} <= 32767") + } + ScalarType::Int32 => { + format!("{value} is int && {value} >= -2147483648 && {value} <= 2147483647") + } + ScalarType::Int64 => return Err(unsupported_scalar("int64")), + ScalarType::Float32 => return Err(unsupported_scalar("float32")), + ScalarType::Float64 => format!("{value} is num && {value}.isFinite"), + ScalarType::Decimal => return Err(unsupported_scalar("decimal")), + ScalarType::String => format!("{value} is String"), + ScalarType::Uuid => return Err(unsupported_scalar("uuid")), + ScalarType::Date => return Err(unsupported_scalar("date")), + ScalarType::DateTime => return Err(unsupported_scalar("date-time")), + ScalarType::Bytes => return Err(unsupported_scalar("bytes")), + ScalarType::Json => format!("{value} != null"), + }, + OrmType::Array(inner) => { + let inner_check = dart_check(inner, "item")?; + format!("{value} is List && {value}.every((item) => {inner_check})") + } + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Dart emission" + ))); + } + }; + + return Ok(check); +} + +fn dart_convert(ty: &OrmType, value: &str, promoted: bool) -> Result { + let converted = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => { + if promoted { + value.to_owned() + } else { + format!("{value} as bool") + } + } + ScalarType::Int16 | ScalarType::Int32 => { + if promoted { + value.to_owned() + } else { + format!("{value} as int") + } + } + ScalarType::Int64 => return Err(unsupported_scalar("int64")), + ScalarType::Float32 => return Err(unsupported_scalar("float32")), + ScalarType::Float64 => { + if promoted { + format!("{value}.toDouble()") + } else { + format!("({value} as num).toDouble()") + } + } + ScalarType::Decimal => return Err(unsupported_scalar("decimal")), + ScalarType::String => { + if promoted { + value.to_owned() + } else { + format!("{value} as String") + } + } + ScalarType::Uuid => return Err(unsupported_scalar("uuid")), + ScalarType::Date => return Err(unsupported_scalar("date")), + ScalarType::DateTime => return Err(unsupported_scalar("date-time")), + ScalarType::Bytes => return Err(unsupported_scalar("bytes")), + ScalarType::Json => value.to_owned(), + }, + OrmType::Array(inner) => { + let inner_type = dart_type(inner)?; + let item = dart_convert(inner, "item", false)?; + let list = if promoted { + value.to_owned() + } else { + format!("({value} as List)") + }; + format!("{list}.map((item) => {item}).cast<{inner_type}>().toList()") + } + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Dart emission" + ))); + } + }; + + return Ok(converted); +} + +fn dart_string(value: &str) -> String { + return format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")); +} diff --git a/cert/ores-orm-core-pr12/src/emit/gleam.rs b/cert/ores-orm-core-pr12/src/emit/gleam.rs new file mode 100644 index 0000000..79b7c83 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/emit/gleam.rs @@ -0,0 +1,208 @@ +use super::naming; +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType}; +use crate::shapes::{Shape, ShapeField}; + +const GLEAM_RESERVED: &[&str] = &[ + "as", "assert", "case", "const", "echo", "else", "fn", "if", "import", "let", "opaque", + "panic", "pub", "todo", "type", "use", +]; + +pub fn emit(shape: &Shape) -> Result { + shape.validate()?; + naming::validate_identifiers(shape, GLEAM_RESERVED, "Gleam")?; + let type_name = naming::type_name(shape); + let function_name = naming::identifier( + &format!( + "{}_{}", + shape.table.replace('.', "_"), + naming::shape_slug(shape.kind) + ), + GLEAM_RESERVED, + ); + + let mut out = String::from( + "// GENERATED by ores-orm-core — derivative evidence, do not edit.\n\ + import gleam/dict\n\ + import gleam/dynamic\n\ + import gleam/dynamic/decode\n\ + import gleam/list\n\ + import gleam/option.{type Option}\n\n\ + pub type Presence(a) {\n\ + Missing\n\ + Present(a)\n\ + }\n\n\ + fn int16_decoder() -> decode.Decoder(Int) {\n\ + use value <- decode.then(decode.int)\n\ + case value >= -32_768 && value <= 32_767 {\n\ + True -> decode.success(value)\n\ + False -> decode.failure(0, expected: \"Int16\")\n\ + }\n\ + }\n\n\ + fn int32_decoder() -> decode.Decoder(Int) {\n\ + use value <- decode.then(decode.int)\n\ + case value >= -2_147_483_648 && value <= 2_147_483_647 {\n\ + True -> decode.success(value)\n\ + False -> decode.failure(0, expected: \"Int32\")\n\ + }\n\ + }\n\n", + ); + + out.push_str(&format!("pub type {type_name} {{\n {type_name}(\n")); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, GLEAM_RESERVED); + out.push_str(&format!( + " {identifier}: {},\n", + gleam_field_type(field)? + )); + } + out.push_str(" )\n}\n\n"); + + out.push_str(&format!( + "fn {function_name}_decoder() -> decode.Decoder({type_name}) {{\n" + )); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, GLEAM_RESERVED); + let key = gleam_string(&field.wire_name); + let decoder = gleam_decoder(field)?; + if field.required { + out.push_str(&format!( + " use {identifier} <- decode.field({key}, {decoder})\n" + )); + } else { + out.push_str(&format!( + " use {identifier} <- decode.optional_field({key}, Missing, {decoder} |> decode.map(Present))\n" + )); + } + } + out.push_str(&format!(" decode.success({type_name}(\n")); + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, GLEAM_RESERVED); + out.push_str(&format!(" {identifier}:,\n")); + } + out.push_str(" ))\n}\n\n"); + + let allowed = shape + .fields + .iter() + .map(|field| gleam_string(&field.wire_name)) + .collect::>() + .join(", "); + let valid_object = if shape.kind.requires_non_empty_object() { + format!( + "{{\n\ + case dict.size(fields) {{\n\ + 0 -> Error([decode.DecodeError(expected: \"non-empty patch object\", found: \"empty object\", path: [])])\n\ + _ -> decode.run(data, {function_name}_decoder())\n\ + }}\n\ + }}" + ) + } else { + format!("decode.run(data, {function_name}_decoder())") + }; + out.push_str(&format!( + "pub fn decode_{function_name}(data: dynamic.Dynamic) -> Result({type_name}, List(decode.DecodeError)) {{\n\ + case decode.run(data, decode.dict(decode.string, decode.dynamic)) {{\n\ + Error(errors) -> Error(errors)\n\ + Ok(fields) -> {{\n\ + let allowed = [{allowed}]\n\ + let unknown = fields\n\ + |> dict.keys\n\ + |> list.filter(fn(key) {{ !list.contains(allowed, key) }})\n\ + case unknown {{\n\ + [] -> {valid_object}\n\ + [first, ..] -> Error([decode.DecodeError(expected: \"closed object\", found: first, path: [first])])\n\ + }}\n\ + }}\n\ + }}\n\ + }}\n" + )); + + return Ok(out); +} + +fn gleam_field_type(field: &ShapeField) -> Result { + let base = gleam_type(&field.ty)?; + let value = match (field.required, field.nullable) { + (true, false) => base, + (true, true) => format!("Option({base})"), + (false, false) => format!("Presence({base})"), + (false, true) => format!("Presence(Option({base}))"), + }; + + return Ok(value); +} + +fn unsupported_scalar(name: &str) -> OrmError { + OrmError::Unsupported(format!( + "{name} requires an admitted cross-runtime wire/domain mapping before Gleam emission" + )) +} + +fn gleam_type(ty: &OrmType) -> Result { + let value = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => "Bool".to_owned(), + ScalarType::Int16 | ScalarType::Int32 => "Int".to_owned(), + ScalarType::Int64 => return Err(unsupported_scalar("int64")), + ScalarType::Float32 => return Err(unsupported_scalar("float32")), + ScalarType::Float64 => "Float".to_owned(), + ScalarType::Decimal => return Err(unsupported_scalar("decimal")), + ScalarType::String => "String".to_owned(), + ScalarType::Uuid => return Err(unsupported_scalar("uuid")), + ScalarType::Date => return Err(unsupported_scalar("date")), + ScalarType::DateTime => return Err(unsupported_scalar("date-time")), + ScalarType::Bytes => return Err(unsupported_scalar("bytes")), + ScalarType::Json => "dynamic.Dynamic".to_owned(), + }, + OrmType::Array(inner) => format!("List({})", gleam_type(inner)?), + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Gleam emission" + ))); + } + }; + + return Ok(value); +} + +fn gleam_decoder(field: &ShapeField) -> Result { + let base = gleam_decoder_type(&field.ty)?; + if field.nullable { + return Ok(format!("decode.optional({base})")); + } + + return Ok(base); +} + +fn gleam_decoder_type(ty: &OrmType) -> Result { + let value = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => "decode.bool".to_owned(), + ScalarType::Int16 => "int16_decoder()".to_owned(), + ScalarType::Int32 => "int32_decoder()".to_owned(), + ScalarType::Int64 => return Err(unsupported_scalar("int64")), + ScalarType::Float32 => return Err(unsupported_scalar("float32")), + ScalarType::Float64 => "decode.float".to_owned(), + ScalarType::Decimal => return Err(unsupported_scalar("decimal")), + ScalarType::String => "decode.string".to_owned(), + ScalarType::Uuid => return Err(unsupported_scalar("uuid")), + ScalarType::Date => return Err(unsupported_scalar("date")), + ScalarType::DateTime => return Err(unsupported_scalar("date-time")), + ScalarType::Bytes => return Err(unsupported_scalar("bytes")), + ScalarType::Json => "decode.dynamic".to_owned(), + }, + OrmType::Array(inner) => format!("decode.list({})", gleam_decoder_type(inner)?), + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Gleam emission" + ))); + } + }; + + return Ok(value); +} + +fn gleam_string(value: &str) -> String { + return format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")); +} diff --git a/cert/ores-orm-core-pr12/src/emit/json_schema.rs b/cert/ores-orm-core-pr12/src/emit/json_schema.rs new file mode 100644 index 0000000..8417c54 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/emit/json_schema.rs @@ -0,0 +1,122 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType}; +use crate::shapes::{Shape, ShapeField, ShapeKind}; +use serde_json::{Map, Value, json}; + +pub const DRAFT_2020_12: &str = "https://json-schema.org/draft/2020-12/schema"; + +pub fn emit(shape: &Shape) -> Result { + shape.validate()?; + let mut properties = Map::new(); + let mut required = Vec::new(); + for field in &shape.fields { + properties.insert(field.wire_name.clone(), field_schema(field)?); + if field.required { + required.push(Value::String(field.wire_name.clone())); + } + } + + let table_identity = hex::encode(shape.table.as_bytes()); + let mut schema = json!({ + "$schema": DRAFT_2020_12, + "$id": format!("urn:ores:orm:table:{table_identity}:{}", shape_slug(shape.kind)), + "type": "object", + "additionalProperties": false, + "properties": properties, + "required": required, + "x-ores-orm-derivative": true, + "x-ores-table": shape.table + }); + + if shape.kind.requires_non_empty_object() { + schema["minProperties"] = json!(1); + } + + return Ok(schema); +} + +const fn shape_slug(kind: ShapeKind) -> &'static str { + match kind { + ShapeKind::Row => "row", + ShapeKind::Create => "create", + ShapeKind::Update => "update", + ShapeKind::Patch => "patch", + ShapeKind::PublicRead => "public_read", + ShapeKind::PublicCreate => "public_create", + ShapeKind::PublicUpdate => "public_update", + ShapeKind::PublicPatch => "public_patch", + } +} + +fn field_schema(field: &ShapeField) -> Result { + let base = type_schema(&field.ty)?; + if !field.nullable { + return Ok(base); + } + Ok(json!({"anyOf": [base, {"type": "null"}]})) +} + +fn type_schema(ty: &OrmType) -> Result { + match ty { + OrmType::Scalar(scalar) => Ok(match scalar { + ScalarType::Boolean => json!({"type": "boolean"}), + ScalarType::Int16 => json!({ + "type": "integer", + "format": "int16", + "minimum": -32768, + "maximum": 32767 + }), + ScalarType::Int32 => json!({ + "type": "integer", + "format": "int32", + "minimum": -2147483648_i64, + "maximum": 2147483647_i64 + }), + ScalarType::Int64 => { + return Err(OrmError::Unsupported( + "int64 requires an explicit JSON wire mapping".to_owned(), + )); + } + ScalarType::Float32 => { + return Err(OrmError::Unsupported( + "float32 requires an admitted cross-runtime wire/domain mapping before JSON Schema emission".to_owned(), + )); + } + ScalarType::Float64 => json!({"type": "number"}), + ScalarType::Decimal => { + return Err(OrmError::Unsupported( + "decimal requires an explicit JSON wire mapping".to_owned(), + )); + } + ScalarType::String => json!({"type": "string"}), + ScalarType::Uuid => { + return Err(OrmError::Unsupported( + "uuid requires an admitted canonical string/validator mapping before JSON Schema emission; format annotations alone are not a portable assertion guarantee".to_owned(), + )); + } + ScalarType::Date => { + return Err(OrmError::Unsupported( + "date requires an admitted semantic calendar-date mapping before JSON Schema emission; format annotations alone are not a portable assertion guarantee".to_owned(), + )); + } + ScalarType::DateTime => { + return Err(OrmError::Unsupported( + "date-time requires an explicit JSON wire mapping".to_owned(), + )); + } + ScalarType::Bytes => { + return Err(OrmError::Unsupported( + "bytes require an explicit JSON wire mapping".to_owned(), + )); + } + ScalarType::Json => json!({}), + }), + OrmType::Array(inner) => Ok(json!({ + "type": "array", + "items": type_schema(inner)? + })), + OrmType::Named(name) => Err(OrmError::Unsupported(format!( + "named ORM type {name} must be resolved against contract evidence before JSON Schema emission" + ))), + } +} diff --git a/cert/ores-orm-core-pr12/src/emit/mod.rs b/cert/ores-orm-core-pr12/src/emit/mod.rs new file mode 100644 index 0000000..dd2a2ab --- /dev/null +++ b/cert/ores-orm-core-pr12/src/emit/mod.rs @@ -0,0 +1,7 @@ +pub mod bundle; +pub mod dart; +pub mod gleam; +pub mod json_schema; +mod naming; +pub mod rust; +pub mod typescript; diff --git a/cert/ores-orm-core-pr12/src/emit/naming.rs b/cert/ores-orm-core-pr12/src/emit/naming.rs new file mode 100644 index 0000000..88ae213 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/emit/naming.rs @@ -0,0 +1,129 @@ +use crate::error::{OrmError, Result}; +use crate::shapes::{Shape, ShapeKind}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +const ESCAPED_IDENTIFIER_PREFIX: &str = "__ores_hex_"; + +pub(crate) fn type_name(shape: &Shape) -> String { + // Keep a readable prefix while including the complete table identity. The + // full UTF-8 hex suffix prevents `a-b`, `a_b`, `a.b`, and similarly + // normalized names from silently producing the same exported type. + return format!( + "{}T{}{}", + pascal(&shape.table.replace('.', "_")), + hex::encode(shape.table.as_bytes()), + shape_suffix(shape.kind) + ); +} + +pub(crate) fn projection_id(shape: &Shape) -> String { + let table_digest = hex::encode(Sha256::digest(shape.table.as_bytes())); + return format!("orm.{table_digest}.{}", shape_slug(shape.kind)); +} + +pub(crate) fn safe_artifact_stem(shape: &Shape) -> String { + // Database identifiers may legally contain path separators, `..`, spaces, + // or punctuation when quoted. Never interpolate them directly into output + // paths. Hex encoding is reversible, deterministic, and collision-free for + // the exact UTF-8 table identity. + return format!( + "table_{}_{}", + hex::encode(shape.table.as_bytes()), + shape_slug(shape.kind) + ); +} + +pub(crate) fn validate_identifiers(shape: &Shape, reserved: &[&str], language: &str) -> Result<()> { + let mut seen = BTreeMap::::new(); + for field in &shape.fields { + let generated = identifier(&field.db_name, reserved); + if let Some(previous) = seen.insert(generated.clone(), field.db_name.clone()) { + return Err(OrmError::Invalid(format!( + "{language} identifier {generated:?} collides for database fields {previous:?} and {:?}; add an explicit non-colliding mapping before generation", + field.db_name + ))); + } + } + return Ok(()); +} + +pub(crate) const fn shape_slug(kind: ShapeKind) -> &'static str { + return match kind { + ShapeKind::Row => "row", + ShapeKind::Create => "create", + ShapeKind::Update => "update", + ShapeKind::Patch => "patch", + ShapeKind::PublicRead => "public_read", + ShapeKind::PublicCreate => "public_create", + ShapeKind::PublicUpdate => "public_update", + ShapeKind::PublicPatch => "public_patch", + }; +} + +pub(crate) const fn shape_suffix(kind: ShapeKind) -> &'static str { + return match kind { + ShapeKind::Row => "Row", + ShapeKind::Create => "Create", + ShapeKind::Update => "Update", + ShapeKind::Patch => "Patch", + ShapeKind::PublicRead => "PublicRead", + ShapeKind::PublicCreate => "PublicCreate", + ShapeKind::PublicUpdate => "PublicUpdate", + ShapeKind::PublicPatch => "PublicPatch", + }; +} + +pub(crate) fn pascal(value: &str) -> String { + let mut out = String::new(); + let mut upper = true; + + for ch in value.chars() { + if !ch.is_ascii_alphanumeric() { + upper = true; + continue; + } + + if upper { + out.extend(ch.to_uppercase()); + upper = false; + } else { + out.push(ch); + } + } + + if out.is_empty() { + return "Generated".to_owned(); + } + if out.as_bytes()[0].is_ascii_digit() { + return format!("Generated{out}"); + } + + return out; +} + +pub(crate) fn identifier(value: &str, reserved: &[&str]) -> String { + let bytes = value.as_bytes(); + let starts_valid = bytes + .first() + .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_'); + let all_valid = bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_'); + let can_preserve = !value.is_empty() + && starts_valid + && all_valid + && !reserved.contains(&value) + && !value.starts_with(ESCAPED_IDENTIFIER_PREFIX); + + if can_preserve { + return value.to_owned(); + } + + // The escape prefix itself is reserved from pass-through above, so an + // authored identifier can never collide with this reversible encoding. + return format!( + "{ESCAPED_IDENTIFIER_PREFIX}{}", + hex::encode(value.as_bytes()) + ); +} diff --git a/cert/ores-orm-core-pr12/src/emit/rust.rs b/cert/ores-orm-core-pr12/src/emit/rust.rs new file mode 100644 index 0000000..4621dd7 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/emit/rust.rs @@ -0,0 +1,210 @@ +use super::naming; +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType}; +use crate::shapes::{Shape, ShapeField}; + +const RUST_RESERVED: &[&str] = &[ + "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", + "false", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", + "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "try", + "type", "unsafe", "use", "where", "while", +]; + +pub fn emit(shape: &Shape) -> Result { + shape.validate()?; + naming::validate_identifiers(shape, RUST_RESERVED, "Rust")?; + let type_name = naming::type_name(shape); + let needs_required_nullable = shape + .fields + .iter() + .any(|field| field.required && field.nullable); + let needs_optional_non_null = shape + .fields + .iter() + .any(|field| !field.required && !field.nullable); + let needs_optional_nullable = shape + .fields + .iter() + .any(|field| !field.required && field.nullable); + + // Do not derive schemars::JsonSchema here. Rust's Option cannot by + // itself represent all four required/optional × nullable/non-null states + // without custom schema plumbing, and an inferred schema would therefore + // be weaker than the canonical Draft 2020-12 artifact emitted alongside + // this DTO. Serde is the executable Rust admission surface; JSON Schema is + // emitted independently from the same Shape. + let mut out = String::from( + "// GENERATED by ores-orm-core — derivative evidence, do not edit.\n\ + // Canonical JSON Schema is emitted as a sibling artifact; do not infer it from this DTO.\n\ + #![allow(dead_code)]\n\n\ + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\n\ + #[serde(deny_unknown_fields)]\n", + ); + out.push_str(&format!("pub struct {type_name} {{\n")); + + for field in &shape.fields { + let identifier = naming::identifier(&field.db_name, RUST_RESERVED); + let base = rust_type(&field.ty)?; + let field_type = match (field.required, field.nullable) { + (true, false) => base, + (true, true) => format!("Option<{base}>"), + (false, false) => format!("Option<{base}>"), + (false, true) => format!("Option>"), + }; + + if identifier != field.wire_name { + out.push_str(&format!(" #[serde(rename = {:?})]\n", field.wire_name)); + } + match (field.required, field.nullable) { + (true, true) => { + out.push_str( + " #[serde(deserialize_with = \"deserialize_required_nullable\")]\n", + ); + } + (false, false) => { + out.push_str( + " #[serde(default, deserialize_with = \"deserialize_optional_non_null\", skip_serializing_if = \"Option::is_none\")]\n", + ); + } + (false, true) => { + out.push_str( + " #[serde(default, deserialize_with = \"deserialize_optional_nullable\", skip_serializing_if = \"Option::is_none\")]\n", + ); + } + (true, false) => {} + } + out.push_str(&format!(" pub {identifier}: {field_type},\n")); + } + out.push_str("}\n"); + + if shape.kind.requires_non_empty_object() { + let absent = shape + .fields + .iter() + .map(|field| { + format!( + "self.{}.is_none()", + naming::identifier(&field.db_name, RUST_RESERVED) + ) + }) + .collect::>() + .join(" && "); + + out.push_str(&format!("\nimpl {type_name} {{\n")); + out.push_str(" pub fn validate(&self) -> Result<(), &'static str> {\n"); + if absent.is_empty() { + out.push_str(" return Err(\"patch must contain at least one field\");\n"); + } else { + out.push_str(&format!(" if {absent} {{\n")); + out.push_str(" return Err(\"patch must contain at least one field\");\n"); + out.push_str(" }\n"); + out.push_str(" return Ok(());\n"); + } + out.push_str(" }\n}\n"); + } + + if needs_required_nullable { + out.push_str( + "\nfn deserialize_required_nullable<'de, D, T>(deserializer: D) -> Result, D::Error>\n\ + where\n\ + D: serde::Deserializer<'de>,\n\ + T: serde::Deserialize<'de>,\n\ + {\n\ + return as serde::Deserialize>::deserialize(deserializer);\n\ + }\n", + ); + } + + if needs_optional_non_null { + out.push_str( + "\nfn deserialize_optional_non_null<'de, D, T>(deserializer: D) -> Result, D::Error>\n\ + where\n\ + D: serde::Deserializer<'de>,\n\ + T: serde::de::DeserializeOwned,\n\ + {\n\ + let value = ::deserialize(deserializer)?;\n\ + if value.is_null() {\n\ + return Err(::custom(\"expected a non-null value\"));\n\ + }\n\ + let decoded = serde_json::from_value(value)\n\ + .map_err(|error| ::custom(error.to_string()))?;\n\ + return Ok(Some(decoded));\n\ + }\n", + ); + } + + if needs_optional_nullable { + out.push_str( + "\nfn deserialize_optional_nullable<'de, D, T>(deserializer: D) -> Result>, D::Error>\n\ + where\n\ + D: serde::Deserializer<'de>,\n\ + T: serde::Deserialize<'de>,\n\ + {\n\ + let value = as serde::Deserialize>::deserialize(deserializer)?;\n\ + return Ok(Some(value));\n\ + }\n", + ); + } + + return Ok(out); +} + +fn rust_type(ty: &OrmType) -> Result { + let value = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => "bool".to_owned(), + ScalarType::Int16 => "i16".to_owned(), + ScalarType::Int32 => "i32".to_owned(), + ScalarType::Int64 => { + return Err(OrmError::Unsupported( + "int64 requires an explicit Rust wire mapping".to_owned(), + )); + } + ScalarType::Float32 => { + return Err(OrmError::Unsupported( + "float32 requires an admitted cross-runtime wire/domain mapping before Rust emission".to_owned(), + )); + } + ScalarType::Float64 => "f64".to_owned(), + ScalarType::Decimal => { + return Err(OrmError::Unsupported( + "decimal requires an explicit Rust wire mapping".to_owned(), + )); + } + ScalarType::String => "String".to_owned(), + ScalarType::Uuid => { + return Err(OrmError::Unsupported( + "uuid requires an admitted canonical string/validator mapping before Rust emission".to_owned(), + )); + } + ScalarType::Date => { + return Err(OrmError::Unsupported( + "date requires an admitted semantic calendar-date mapping before Rust emission" + .to_owned(), + )); + } + ScalarType::DateTime => { + return Err(OrmError::Unsupported( + "date-time requires an explicit Rust wire mapping".to_owned(), + )); + } + ScalarType::Bytes => { + return Err(OrmError::Unsupported( + "bytes require an explicit Rust wire mapping".to_owned(), + )); + } + ScalarType::Json => "serde_json::Value".to_owned(), + }, + OrmType::Array(inner) => format!("Vec<{}>", rust_type(inner)?), + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before Rust emission" + ))); + } + }; + + return Ok(value); +} + +#[allow(dead_code)] +fn _field_semantics(_: &ShapeField) {} diff --git a/cert/ores-orm-core-pr12/src/emit/typescript.rs b/cert/ores-orm-core-pr12/src/emit/typescript.rs new file mode 100644 index 0000000..e87b51a --- /dev/null +++ b/cert/ores-orm-core-pr12/src/emit/typescript.rs @@ -0,0 +1,97 @@ +use super::naming; +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, ScalarType}; +use crate::shapes::Shape; + +pub fn emit(shape: &Shape) -> Result { + shape.validate()?; + let type_name = naming::type_name(shape); + let mut out = String::from( + "// GENERATED by ores-orm-core — derivative evidence, do not edit.\n\ + import { z } from \"zod\";\n\n", + ); + out.push_str(&format!("export const {type_name}Schema = z.object({{\n")); + + for field in &shape.fields { + let key = serde_json::to_string(&field.wire_name).map_err(|error| { + OrmError::Invalid(format!("could not encode TypeScript field name: {error}")) + })?; + let mut validator = zod_type(&field.ty)?; + if field.nullable { + validator.push_str(".nullable()"); + } + if !field.required { + validator.push_str(".optional()"); + } + out.push_str(&format!(" {key}: {validator},\n")); + } + + if shape.kind.requires_non_empty_object() { + out.push_str( + "}).strict().refine((value) => Object.keys(value).length > 0, { message: \"patch must contain at least one field\" });\n\n", + ); + } else { + out.push_str("}).strict();\n\n"); + } + out.push_str(&format!( + "export type {type_name} = z.infer;\n" + )); + + return Ok(out); +} + +fn zod_type(ty: &OrmType) -> Result { + let value = match ty { + OrmType::Scalar(scalar) => match scalar { + ScalarType::Boolean => "z.boolean()".to_owned(), + ScalarType::Int16 => "z.number().int().min(-32768).max(32767)".to_owned(), + ScalarType::Int32 => "z.number().int().min(-2147483648).max(2147483647)".to_owned(), + ScalarType::Int64 => { + return Err(OrmError::Unsupported( + "int64 requires an explicit TypeScript wire mapping; JavaScript numbers cannot represent the full domain safely".to_owned(), + )); + } + ScalarType::Float32 => { + return Err(OrmError::Unsupported( + "float32 requires an admitted cross-runtime wire/domain mapping before TypeScript emission".to_owned(), + )); + } + ScalarType::Float64 => "z.number().finite()".to_owned(), + ScalarType::Decimal => { + return Err(OrmError::Unsupported( + "decimal requires an explicit TypeScript wire mapping".to_owned(), + )); + } + ScalarType::String => "z.string()".to_owned(), + ScalarType::Uuid => { + return Err(OrmError::Unsupported( + "uuid requires an admitted canonical string/validator mapping before TypeScript emission".to_owned(), + )); + } + ScalarType::Date => { + return Err(OrmError::Unsupported( + "date requires an admitted semantic calendar-date mapping before TypeScript emission".to_owned(), + )); + } + ScalarType::DateTime => { + return Err(OrmError::Unsupported( + "date-time requires an explicit TypeScript wire mapping".to_owned(), + )); + } + ScalarType::Bytes => { + return Err(OrmError::Unsupported( + "bytes require an explicit TypeScript wire mapping".to_owned(), + )); + } + ScalarType::Json => "z.unknown()".to_owned(), + }, + OrmType::Array(inner) => format!("z.array({})", zod_type(inner)?), + OrmType::Named(name) => { + return Err(OrmError::Unsupported(format!( + "named ORM type {name} requires admitted contract mapping before TypeScript emission" + ))); + } + }; + + return Ok(value); +} diff --git a/cert/ores-orm-core-pr12/src/error.rs b/cert/ores-orm-core-pr12/src/error.rs new file mode 100644 index 0000000..871af51 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum OrmError { + #[error("could not parse Rust source: {0}")] + RustParse(#[from] syn::Error), + #[error("could not parse ORM policy: {0}")] + PolicyParse(#[from] toml::de::Error), + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("unsupported ORM construct: {0}")] + Unsupported(String), + #[error("invalid ORM input: {0}")] + Invalid(String), + #[error("ORM parity failed: {0}")] + Parity(String), +} + +pub type Result = std::result::Result; diff --git a/cert/ores-orm-core-pr12/src/ir.rs b/cert/ores-orm-core-pr12/src/ir.rs new file mode 100644 index 0000000..e117bf2 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/ir.rs @@ -0,0 +1,215 @@ +use crate::error::{OrmError, Result}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +pub const ORM_IR_SCHEMA: &str = "ores.orm-core.ir/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + Diesel, + SeaOrm, + Converged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ScalarType { + Boolean, + Int16, + Int32, + Int64, + Float32, + Float64, + Decimal, + String, + Uuid, + Date, + DateTime, + Bytes, + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum OrmType { + Scalar(ScalarType), + Array(Box), + /// A DB/ORM named type whose semantic definition is not present in this + /// structural lane. It may participate in ORM parity, but public validation + /// emission must resolve it against admitted contract evidence first. + Named(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Column { + pub rust_name: String, + pub db_name: String, + pub ordinal: usize, + pub ty: OrmType, + pub nullable: bool, + pub primary_key: bool, + pub unique: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Table { + pub schema_name: Option, + pub db_name: String, + pub columns: Vec, +} + +impl Table { + #[must_use] + pub fn qualified_name(&self) -> String { + return self.schema_name.as_ref().map_or_else( + || self.db_name.clone(), + |schema| format!("{schema}.{}", self.db_name), + ); + } + + #[must_use] + pub fn column(&self, db_name: &str) -> Option<&Column> { + return self.columns.iter().find(|column| column.db_name == db_name); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct OrmIr { + pub schema: String, + pub source: SourceKind, + pub tables: Vec
, +} + +impl OrmIr { + #[must_use] + pub fn new(source: SourceKind, mut tables: Vec
) -> Self { + tables.sort_by_key(Table::qualified_name); + for table in &mut tables { + table.columns.sort_by_key(|column| column.ordinal); + } + return Self { + schema: ORM_IR_SCHEMA.to_owned(), + source, + tables, + }; + } + + pub fn try_new(source: SourceKind, tables: Vec
) -> Result { + let ir = Self::new(source, tables); + ir.validate()?; + return Ok(ir); + } + + pub fn validate(&self) -> Result<()> { + if self.schema != ORM_IR_SCHEMA { + return Err(OrmError::Invalid(format!( + "unsupported ORM IR schema {}; expected {ORM_IR_SCHEMA}", + self.schema + ))); + } + + let mut qualified_table_names = BTreeSet::new(); + for table in &self.tables { + if table.db_name.trim().is_empty() { + return Err(OrmError::Invalid( + "ORM IR table database name must not be empty".to_owned(), + )); + } + if table + .schema_name + .as_ref() + .is_some_and(|schema| schema.trim().is_empty()) + { + return Err(OrmError::Invalid(format!( + "ORM IR table {} has an empty schema name", + table.db_name + ))); + } + + let qualified_name = table.qualified_name(); + if !qualified_table_names.insert(qualified_name.clone()) { + return Err(OrmError::Invalid(format!( + "ORM IR contains duplicate table identity {qualified_name}" + ))); + } + + let mut database_names = BTreeSet::new(); + let mut rust_names = BTreeSet::new(); + let mut ordinals = BTreeSet::new(); + for column in &table.columns { + if column.db_name.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} contains an empty database column name" + ))); + } + if column.rust_name.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} contains an empty Rust column name" + ))); + } + if !database_names.insert(column.db_name.clone()) { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} contains duplicate database column {}", + column.db_name + ))); + } + if !rust_names.insert(column.rust_name.clone()) { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} contains duplicate Rust column {}", + column.rust_name + ))); + } + if !ordinals.insert(column.ordinal) { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} contains duplicate ordinal {}", + column.ordinal + ))); + } + + validate_type(&column.ty, &format!("{qualified_name}.{}", column.db_name))?; + } + + for (expected_ordinal, column) in table.columns.iter().enumerate() { + if column.ordinal != expected_ordinal { + return Err(OrmError::Invalid(format!( + "ORM IR table {qualified_name} has non-contiguous ordinal {} for column {}; expected {expected_ordinal}", + column.ordinal, column.db_name + ))); + } + } + } + + return Ok(()); + } + + #[must_use] + pub fn table(&self, qualified_name: &str) -> Option<&Table> { + return self + .tables + .iter() + .find(|table| table.qualified_name() == qualified_name); + } +} + +fn validate_type(ty: &OrmType, path: &str) -> Result<()> { + match ty { + OrmType::Scalar(_) => {} + OrmType::Array(inner) => { + validate_type(inner, path)?; + } + OrmType::Named(name) => { + if name.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "ORM IR named type at {path} must not be empty" + ))); + } + } + } + + return Ok(()); +} diff --git a/cert/ores-orm-core-pr12/src/lib.rs b/cert/ores-orm-core-pr12/src/lib.rs new file mode 100644 index 0000000..7d497bc --- /dev/null +++ b/cert/ores-orm-core-pr12/src/lib.rs @@ -0,0 +1,32 @@ +//! ORM-first derivative schema and validator tooling. +//! +//! Diesel and SeaORM are parsed independently into a normalized structural IR. +//! The two lanes must converge before derivative shapes are generated. The IR +//! and everything emitted from it are downstream evidence, never authored +//! contract authority. + +pub mod admission; +pub mod descriptor; +pub mod diesel; +pub mod emit; +pub mod error; +pub mod ir; +pub mod parity; +pub mod policy; +pub mod seaorm; +pub mod shapes; + +pub use admission::{ + REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS, TJSV_ADMISSION_BINDING_SCHEMA, TJSV_CONTRACT_IR_SCHEMA, + TJSV_PARITY_REPORT_SCHEMA, TJSV_PROJECTION_MANIFEST_SCHEMA, + TJSV_PROJECTION_VERIFICATION_RECEIPT_SCHEMA, TjsvAdmissionBinding, TjsvOutputBinding, +}; +pub use descriptor::{ + DescriptorField, DescriptorModel, DescriptorScalar, DescriptorSource, DescriptorType, + ORM_DESCRIPTOR_SCHEMA, OrmDescriptor, OrmWitness, describe_table, +}; +pub use error::{OrmError, Result}; +pub use ir::{Column, OrmIr, OrmType, ScalarType, SourceKind, Table}; +pub use parity::{Finding, ParityReport, compare}; +pub use policy::{FieldVisibility, Policy, TablePolicy}; +pub use shapes::{Shape, ShapeField, ShapeKind, derive_shape}; diff --git a/cert/ores-orm-core-pr12/src/parity.rs b/cert/ores-orm-core-pr12/src/parity.rs new file mode 100644 index 0000000..9054390 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/parity.rs @@ -0,0 +1,265 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{Column, OrmIr, SourceKind, Table}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Finding { + pub code: String, + pub path: String, + pub left: Option, + pub right: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ParityReport { + pub passed: bool, + pub findings: Vec, +} + +impl ParityReport { + pub fn require_passed(&self) -> Result<()> { + if self.passed { + return Ok(()); + } + let summary = self + .findings + .iter() + .map(|finding| format!("{} at {}", finding.code, finding.path)) + .collect::>() + .join("; "); + return Err(OrmError::Parity(summary)); + } +} + +type TableIdentity = (Option, String); + +#[must_use] +pub fn compare(left: &OrmIr, right: &OrmIr) -> ParityReport { + let mut findings = Vec::new(); + if let Err(error) = left.validate() { + findings.push(Finding { + code: "invalid_left_ir".to_owned(), + path: "$".to_owned(), + left: Some(error.to_string()), + right: None, + }); + } + if let Err(error) = right.validate() { + findings.push(Finding { + code: "invalid_right_ir".to_owned(), + path: "$".to_owned(), + left: None, + right: Some(error.to_string()), + }); + } + if !findings.is_empty() { + return ParityReport { + passed: false, + findings, + }; + } + + let left_tables = index_tables(left); + let right_tables = index_tables(right); + let names: BTreeSet<_> = left_tables + .keys() + .chain(right_tables.keys()) + .cloned() + .collect(); + + for identity in names { + match (left_tables.get(&identity), right_tables.get(&identity)) { + (Some(left_table), Some(right_table)) => { + compare_table(left_table, right_table, &mut findings); + } + (Some(left_table), None) => findings.push(Finding { + code: "table_missing_right".to_owned(), + path: left_table.qualified_name(), + left: Some("present".to_owned()), + right: None, + }), + (None, Some(right_table)) => findings.push(Finding { + code: "table_missing_left".to_owned(), + path: right_table.qualified_name(), + left: None, + right: Some("present".to_owned()), + }), + (None, None) => unreachable!("identity came from at least one map"), + } + } + + return ParityReport { + passed: findings.is_empty(), + findings, + }; +} + +pub fn converge(left: &OrmIr, right: &OrmIr) -> Result { + let independent_lanes = matches!( + (left.source, right.source), + (SourceKind::Diesel, SourceKind::SeaOrm) | (SourceKind::SeaOrm, SourceKind::Diesel) + ); + if !independent_lanes { + return Err(OrmError::Invalid(format!( + "ORM convergence requires one Diesel witness and one SeaORM witness; got {:?} and {:?}", + left.source, right.source + ))); + } + + let report = compare(left, right); + report.require_passed()?; + + let right_tables = index_tables(right); + let mut tables = Vec::with_capacity(left.tables.len()); + for left_table in &left.tables { + let identity = table_identity(left_table); + let right_table = right_tables.get(&identity).copied().ok_or_else(|| { + OrmError::Parity(format!( + "missing converged table {}", + left_table.qualified_name() + )) + })?; + tables.push(canonical_table(left_table, right_table)?); + } + + return OrmIr::try_new(SourceKind::Converged, tables); +} + +fn table_identity(table: &Table) -> TableIdentity { + return (table.schema_name.clone(), table.db_name.clone()); +} + +fn index_tables(ir: &OrmIr) -> BTreeMap { + return ir + .tables + .iter() + .map(|table| (table_identity(table), table)) + .collect(); +} + +fn canonical_table(left: &Table, right: &Table) -> Result
{ + let right_columns: BTreeMap<_, _> = right + .columns + .iter() + .map(|column| (column.db_name.as_str(), column)) + .collect(); + let mut columns = Vec::with_capacity(left.columns.len()); + + for left_column in &left.columns { + let right_column = right_columns + .get(left_column.db_name.as_str()) + .copied() + .ok_or_else(|| { + OrmError::Parity(format!( + "missing converged column {}.{}", + left.qualified_name(), + left_column.db_name + )) + })?; + + let rust_name = if left_column.rust_name == right_column.rust_name { + left_column.rust_name.clone() + } else { + // Rust-side identifiers are source-specific syntax, not persistence + // semantics. Use the exact database identity when the witnesses use + // different aliases so convergence is independent of argument order. + left_column.db_name.clone() + }; + + columns.push(Column { + rust_name, + db_name: left_column.db_name.clone(), + ordinal: left_column.ordinal, + ty: left_column.ty.clone(), + nullable: left_column.nullable, + primary_key: left_column.primary_key, + // `unique` is not currently proven by both parser lanes (Diesel + // print-schema does not carry it). Retain it only if both witnesses + // independently prove it so source-specific metadata cannot leak + // through whichever lane happened to be passed first. + unique: left_column.unique && right_column.unique, + }); + } + + return Ok(Table { + schema_name: left.schema_name.clone(), + db_name: left.db_name.clone(), + columns, + }); +} + +fn compare_table(left: &Table, right: &Table, findings: &mut Vec) { + let path = left.qualified_name(); + let left_columns: BTreeMap<_, _> = left + .columns + .iter() + .map(|column| (column.db_name.clone(), column)) + .collect(); + let right_columns: BTreeMap<_, _> = right + .columns + .iter() + .map(|column| (column.db_name.clone(), column)) + .collect(); + let names: BTreeSet<_> = left_columns + .keys() + .chain(right_columns.keys()) + .cloned() + .collect(); + + for name in names { + let field_path = format!("{path}.{name}"); + match (left_columns.get(&name), right_columns.get(&name)) { + (Some(left_column), Some(right_column)) => { + if left_column.ordinal != right_column.ordinal { + findings.push(Finding { + code: "column_ordinal_mismatch".to_owned(), + path: field_path.clone(), + left: Some(left_column.ordinal.to_string()), + right: Some(right_column.ordinal.to_string()), + }); + } + if left_column.ty != right_column.ty { + findings.push(Finding { + code: "column_type_mismatch".to_owned(), + path: field_path.clone(), + left: Some(format!("{:?}", left_column.ty)), + right: Some(format!("{:?}", right_column.ty)), + }); + } + if left_column.nullable != right_column.nullable { + findings.push(Finding { + code: "column_nullability_mismatch".to_owned(), + path: field_path.clone(), + left: Some(left_column.nullable.to_string()), + right: Some(right_column.nullable.to_string()), + }); + } + if left_column.primary_key != right_column.primary_key { + findings.push(Finding { + code: "column_primary_key_mismatch".to_owned(), + path: field_path, + left: Some(left_column.primary_key.to_string()), + right: Some(right_column.primary_key.to_string()), + }); + } + } + (Some(_), None) => findings.push(Finding { + code: "column_missing_right".to_owned(), + path: field_path, + left: Some("present".to_owned()), + right: None, + }), + (None, Some(_)) => findings.push(Finding { + code: "column_missing_left".to_owned(), + path: field_path, + left: None, + right: Some("present".to_owned()), + }), + (None, None) => unreachable!("name came from at least one map"), + } + } +} diff --git a/cert/ores-orm-core-pr12/src/policy.rs b/cert/ores-orm-core-pr12/src/policy.rs new file mode 100644 index 0000000..f65670a --- /dev/null +++ b/cert/ores-orm-core-pr12/src/policy.rs @@ -0,0 +1,273 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{OrmIr, Table}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FieldVisibility { + Public, + Private, + Secret, + Unclassified, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct TablePolicy { + pub public_surface: bool, + pub generated: BTreeSet, + pub defaulted: BTreeSet, + pub immutable: BTreeSet, + pub private: BTreeSet, + pub secret: BTreeSet, + pub public_read: BTreeSet, + pub public_create: BTreeSet, + pub public_update: BTreeSet, + pub wire_names: BTreeMap, +} + +impl TablePolicy { + #[must_use] + pub fn visibility(&self, column: &str) -> FieldVisibility { + let is_public = self.public_read.contains(column) + || self.public_create.contains(column) + || self.public_update.contains(column); + + if is_public { + return FieldVisibility::Public; + } + if self.secret.contains(column) { + return FieldVisibility::Secret; + } + if self.private.contains(column) { + return FieldVisibility::Private; + } + + return FieldVisibility::Unclassified; + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Policy { + pub version: u32, + #[serde(default = "default_true")] + pub require_diesel: bool, + #[serde(default = "default_true")] + pub require_seaorm: bool, + #[serde(default)] + pub table: BTreeMap, +} + +const fn default_true() -> bool { + return true; +} + +impl Policy { + pub fn from_toml(source: &str) -> Result { + let policy: Self = toml::from_str(source)?; + if policy.version != 1 { + return Err(OrmError::Invalid(format!( + "unsupported .ores-orm.toml version {}", + policy.version + ))); + } + + return Ok(policy); + } + + #[must_use] + pub fn table_policy(&self, table: &Table) -> TablePolicy { + return self + .table + .get(&table.qualified_name()) + .or_else(|| self.table.get(&table.db_name)) + .cloned() + .unwrap_or_default(); + } + + /// Validate the complete policy against one exact ORM IR. This is the + /// release-facing check: it rejects typo/orphan table keys and ambiguous + /// unqualified names instead of silently leaving those entries unused. + pub fn validate_ir(&self, ir: &OrmIr) -> Result<()> { + ir.validate()?; + + for table in &ir.tables { + self.validate_table(table)?; + } + + for configured_key in self.table.keys() { + let matches = ir + .tables + .iter() + .filter(|table| { + configured_key.as_str() == table.qualified_name() + || configured_key.as_str() == table.db_name + }) + .collect::>(); + + match matches.as_slice() { + [] => { + return Err(OrmError::Invalid(format!( + "policy table {configured_key:?} does not match any ORM IR table" + ))); + } + [_] => {} + [first, second, ..] => { + return Err(OrmError::Invalid(format!( + "policy table {configured_key:?} is ambiguous between {} and {}; use qualified table keys", + first.qualified_name(), + second.qualified_name() + ))); + } + } + } + + return Ok(()); + } + + pub fn validate_table(&self, table: &Table) -> Result<()> { + let qualified_name = table.qualified_name(); + if qualified_name != table.db_name + && self.table.contains_key(&qualified_name) + && self.table.contains_key(&table.db_name) + { + return Err(OrmError::Invalid(format!( + "policy configures table {} through both qualified key {qualified_name:?} and unqualified key {:?}; refusing precedence-based resolution", + table.qualified_name(), + table.db_name + ))); + } + + let policy = self.table_policy(table); + let actual: BTreeSet<_> = table + .columns + .iter() + .map(|column| column.db_name.as_str()) + .collect(); + + for (label, names) in [ + ("generated", &policy.generated), + ("defaulted", &policy.defaulted), + ("immutable", &policy.immutable), + ("private", &policy.private), + ("secret", &policy.secret), + ("public_read", &policy.public_read), + ("public_create", &policy.public_create), + ("public_update", &policy.public_update), + ] { + for name in names { + if !actual.contains(name.as_str()) { + return Err(OrmError::Invalid(format!( + "policy {label} references unknown column {}.{name}", + table.qualified_name() + ))); + } + } + } + + for name in policy.wire_names.keys() { + if !actual.contains(name.as_str()) { + return Err(OrmError::Invalid(format!( + "policy wire_names references unknown column {}.{name}", + table.qualified_name() + ))); + } + } + + let mut effective_wire_names = BTreeMap::new(); + for column in &table.columns { + let wire_name = policy + .wire_names + .get(&column.db_name) + .map_or(column.db_name.as_str(), String::as_str); + if wire_name.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "policy wire_names maps {}.{} to an empty wire name", + table.qualified_name(), + column.db_name + ))); + } + if let Some(previous_column) = + effective_wire_names.insert(wire_name.to_owned(), column.db_name.clone()) + { + return Err(OrmError::Invalid(format!( + "policy wire name {wire_name:?} collides for columns {}.{previous_column} and {}.{}", + table.qualified_name(), + table.qualified_name(), + column.db_name + ))); + } + } + + let has_public_fields = !policy.public_read.is_empty() + || !policy.public_create.is_empty() + || !policy.public_update.is_empty(); + if has_public_fields && !policy.public_surface { + return Err(OrmError::Invalid(format!( + "table {} declares public fields but public_surface is false", + table.qualified_name() + ))); + } + + let public_fields: BTreeSet<_> = policy + .public_read + .iter() + .chain(policy.public_create.iter()) + .chain(policy.public_update.iter()) + .cloned() + .collect(); + + for name in &public_fields { + if policy.private.contains(name) || policy.secret.contains(name) { + return Err(OrmError::Invalid(format!( + "column {}.{name} is both public and private/secret", + table.qualified_name() + ))); + } + } + + for name in &policy.private { + if policy.secret.contains(name) { + return Err(OrmError::Invalid(format!( + "column {}.{name} is both private and secret", + table.qualified_name() + ))); + } + } + + for name in &policy.public_create { + if policy.generated.contains(name) { + return Err(OrmError::Invalid(format!( + "column {}.{name} is both public_create and generated; refusing to silently drop it from create projections", + table.qualified_name() + ))); + } + } + + for name in &policy.public_update { + if policy.generated.contains(name) || policy.immutable.contains(name) { + return Err(OrmError::Invalid(format!( + "column {}.{name} is public_update but generated or immutable; refusing to silently drop it from update projections", + table.qualified_name() + ))); + } + } + + if policy.public_surface { + for column in &table.columns { + if policy.visibility(&column.db_name) == FieldVisibility::Unclassified { + return Err(OrmError::Invalid(format!( + "public table {} leaves column {} unclassified", + table.qualified_name(), + column.db_name + ))); + } + } + } + + return Ok(()); + } +} diff --git a/cert/ores-orm-core-pr12/src/seaorm.rs b/cert/ores-orm-core-pr12/src/seaorm.rs new file mode 100644 index 0000000..3ab8aa8 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/seaorm.rs @@ -0,0 +1,340 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{Column, OrmIr, OrmType, ScalarType, SourceKind, Table}; +use quote::ToTokens; +use std::path::Path; +use syn::{Attribute, Fields, GenericArgument, Item, ItemStruct, LitStr, PathArguments, Type}; +use walkdir::WalkDir; + +/// Parse one Rust source file containing one or more SeaORM entities. +pub fn parse_source(source: &str) -> Result { + let file = syn::parse_file(source)?; + let mut tables = Vec::new(); + collect_items(&file.items, &mut tables)?; + if tables.is_empty() { + return Err(OrmError::Invalid( + "SeaORM input contained no DeriveEntityModel models".to_owned(), + )); + } + return OrmIr::try_new(SourceKind::SeaOrm, tables); +} + +/// Parse every `.rs` file under a generated/entity source directory. +pub fn parse_directory(root: impl AsRef) -> Result { + let mut tables = Vec::new(); + for entry in WalkDir::new(root) { + let entry = entry.map_err(|error| OrmError::Io(std::io::Error::other(error)))?; + if !entry.file_type().is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some("rs") + { + continue; + } + let source = std::fs::read_to_string(entry.path())?; + let file = syn::parse_file(&source)?; + collect_items(&file.items, &mut tables)?; + } + if tables.is_empty() { + return Err(OrmError::Invalid( + "SeaORM directory contained no DeriveEntityModel models".to_owned(), + )); + } + return OrmIr::try_new(SourceKind::SeaOrm, tables); +} + +fn collect_items(items: &[Item], tables: &mut Vec
) -> Result<()> { + for item in items { + match item { + Item::Struct(item_struct) if is_entity_model(item_struct) => { + tables.push(parse_model(item_struct)?); + } + Item::Mod(module) => { + if let Some((_, nested)) = &module.content { + collect_items(nested, tables)?; + } + } + _ => {} + } + } + return Ok(()); +} + +fn is_entity_model(item: &ItemStruct) -> bool { + return item.attrs.iter().any(|attr| { + if !attr.path().is_ident("derive") { + return false; + } + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "DeriveEntityModel") + { + found = true; + } + return Ok(()); + }); + return found; + }); +} + +fn parse_model(item: &ItemStruct) -> Result
{ + let options = sea_orm_options(&item.attrs)?; + ensure_supported_options(&options, &format!("SeaORM model {}", item.ident))?; + if options.ignore { + return Err(OrmError::Unsupported(format!( + "SeaORM model {} cannot use #[sea_orm(ignore)]", + item.ident + ))); + } + + let table_name = options.table_name.ok_or_else(|| { + OrmError::Invalid(format!( + "SeaORM model {} has no #[sea_orm(table_name = ...)]", + item.ident + )) + })?; + let Fields::Named(fields) = &item.fields else { + return Err(OrmError::Unsupported(format!( + "SeaORM model {} is not a named-field struct", + item.ident + ))); + }; + + let mut columns = Vec::new(); + for field in &fields.named { + let Some(ident) = &field.ident else { + continue; + }; + if is_relation_type(&field.ty) { + continue; + } + let field_options = sea_orm_options(&field.attrs)?; + if field_options.ignore { + continue; + } + ensure_supported_options( + &field_options, + &format!("SeaORM field {}.{}", item.ident, ident), + )?; + + let rust_name = ident.to_string(); + let db_name = field_options + .column_name + .clone() + .unwrap_or_else(|| rust_name.clone()); + let (inner_type, nullable) = unwrap_option(&field.ty)?; + if field_options.nullable && !nullable { + return Err(OrmError::Unsupported(format!( + "SeaORM field {}.{} uses #[sea_orm(nullable)] without Option; explicit nullability mapping is required", + item.ident, ident + ))); + } + let ty = map_rust_type(inner_type)?; + columns.push(Column { + rust_name, + db_name, + ordinal: columns.len(), + ty, + nullable, + primary_key: field_options.primary_key, + unique: field_options.unique, + }); + } + + return Ok(Table { + schema_name: options.schema_name, + db_name: table_name, + columns, + }); +} + +#[derive(Default)] +struct SeaOrmOptions { + table_name: Option, + schema_name: Option, + column_name: Option, + primary_key: bool, + unique: bool, + ignore: bool, + nullable: bool, + unsupported_semantic_overrides: Vec, +} + +fn sea_orm_options(attrs: &[Attribute]) -> Result { + let mut options = SeaOrmOptions::default(); + for attr in attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("primary_key") { + options.primary_key = true; + return Ok(()); + } + if meta.path.is_ident("unique") { + options.unique = true; + return Ok(()); + } + if meta.path.is_ident("ignore") { + options.ignore = true; + return Ok(()); + } + if meta.path.is_ident("nullable") { + options.nullable = true; + return Ok(()); + } + if meta.path.is_ident("indexed") { + return Ok(()); + } + if meta.path.is_ident("table_name") { + options.table_name = Some(meta.value()?.parse::()?.value()); + return Ok(()); + } + if meta.path.is_ident("schema_name") { + options.schema_name = Some(meta.value()?.parse::()?.value()); + return Ok(()); + } + if meta.path.is_ident("column_name") { + options.column_name = Some(meta.value()?.parse::()?.value()); + return Ok(()); + } + if meta.path.is_ident("column_type") + || meta.path.is_ident("select_as") + || meta.path.is_ident("save_as") + { + let name = meta.path.to_token_stream().to_string().replace(' ', ""); + let _ = meta.value()?.parse::()?; + options.unsupported_semantic_overrides.push(name); + return Ok(()); + } + if meta.path.is_ident("auto_increment") + || meta.path.is_ident("default_value") + || meta.path.is_ident("default_expr") + || meta.path.is_ident("comment") + { + if meta.input.peek(syn::Token![=]) { + let _ = meta.value()?.parse::()?; + } + return Ok(()); + } + + return Err(meta.error( + "unsupported SeaORM #[sea_orm(...)] option; add explicit ORM IR semantics before accepting it", + )); + })?; + } + return Ok(options); +} + +fn ensure_supported_options(options: &SeaOrmOptions, context: &str) -> Result<()> { + if options.unsupported_semantic_overrides.is_empty() { + return Ok(()); + } + + return Err(OrmError::Unsupported(format!( + "{context} uses SeaORM semantic override(s) {} that can change database type behavior; explicit mapping is required", + options.unsupported_semantic_overrides.join(", ") + ))); +} + +fn is_relation_type(ty: &Type) -> bool { + let Type::Path(path) = ty else { + return false; + }; + return path + .path + .segments + .last() + .is_some_and(|segment| matches!(segment.ident.to_string().as_str(), "HasOne" | "HasMany")); +} + +fn unwrap_option(ty: &Type) -> Result<(&Type, bool)> { + let Type::Path(path) = ty else { + return Ok((ty, false)); + }; + let Some(segment) = path.path.segments.last() else { + return Ok((ty, false)); + }; + if segment.ident != "Option" { + return Ok((ty, false)); + } + let inner = single_generic_type(segment)?; + return Ok((inner, true)); +} + +fn map_rust_type(ty: &Type) -> Result { + let Type::Path(path) = ty else { + return Err(OrmError::Unsupported(format!( + "SeaORM field type is not a path: {}", + ty.to_token_stream() + ))); + }; + let segment = path.path.segments.last().ok_or_else(|| { + OrmError::Unsupported("SeaORM field type path has no final segment".to_owned()) + })?; + let name = segment.ident.to_string(); + + if name == "Vec" { + let inner = single_generic_type(segment)?; + let (inner, element_nullable) = unwrap_option(inner)?; + if element_nullable { + return Err(OrmError::Unsupported( + "nullable SeaORM array elements require explicit contract evidence".to_owned(), + )); + } + if matches!(inner, Type::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "u8")) + { + return Ok(OrmType::Scalar(ScalarType::Bytes)); + } + return Ok(OrmType::Array(Box::new(map_rust_type(inner)?))); + } + + let scalar = match name.as_str() { + "bool" => Some(ScalarType::Boolean), + "i16" => Some(ScalarType::Int16), + "i32" => Some(ScalarType::Int32), + "i64" => Some(ScalarType::Int64), + "f32" => Some(ScalarType::Float32), + "f64" => Some(ScalarType::Float64), + "Decimal" | "BigDecimal" => Some(ScalarType::Decimal), + "String" => Some(ScalarType::String), + "Uuid" => Some(ScalarType::Uuid), + "Date" | "NaiveDate" => Some(ScalarType::Date), + "DateTime" + | "DateTimeUtc" + | "DateTimeWithTimeZone" + | "NaiveDateTime" + | "OffsetDateTime" + | "PrimitiveDateTime" => Some(ScalarType::DateTime), + "Json" | "JsonValue" | "Value" => Some(ScalarType::Json), + _ => None, + }; + + return Ok(scalar.map_or_else( + || OrmType::Named(path.path.to_token_stream().to_string().replace(' ', "")), + OrmType::Scalar, + )); +} + +fn single_generic_type(segment: &syn::PathSegment) -> Result<&Type> { + let PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return Err(OrmError::Unsupported(format!( + "{} requires exactly one type argument", + segment.ident + ))); + }; + if arguments.args.len() != 1 { + return Err(OrmError::Unsupported(format!( + "{} requires exactly one type argument", + segment.ident + ))); + } + let Some(GenericArgument::Type(inner)) = arguments.args.first() else { + return Err(OrmError::Unsupported(format!( + "{} requires exactly one type argument", + segment.ident + ))); + }; + return Ok(inner); +} diff --git a/cert/ores-orm-core-pr12/src/shapes.rs b/cert/ores-orm-core-pr12/src/shapes.rs new file mode 100644 index 0000000..d7f5b73 --- /dev/null +++ b/cert/ores-orm-core-pr12/src/shapes.rs @@ -0,0 +1,177 @@ +use crate::error::{OrmError, Result}; +use crate::ir::{OrmType, Table}; +use crate::policy::Policy; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ShapeKind { + Row, + Create, + Update, + Patch, + PublicRead, + PublicCreate, + PublicUpdate, + PublicPatch, +} + +impl ShapeKind { + #[must_use] + pub const fn is_public(self) -> bool { + return matches!( + self, + Self::PublicRead | Self::PublicCreate | Self::PublicUpdate | Self::PublicPatch + ); + } + + #[must_use] + pub const fn requires_non_empty_object(self) -> bool { + return matches!(self, Self::Patch | Self::PublicPatch); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ShapeField { + pub db_name: String, + pub wire_name: String, + pub ty: OrmType, + pub nullable: bool, + pub required: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Shape { + pub table: String, + pub kind: ShapeKind, + pub fields: Vec, +} + +impl Shape { + pub fn validate(&self) -> Result<()> { + if self.table.trim().is_empty() { + return Err(OrmError::Invalid( + "derived shape table identity must not be empty".to_owned(), + )); + } + if self.fields.is_empty() { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} contains no fields", + self.kind, self.table + ))); + } + + let mut database_names = BTreeSet::new(); + let mut wire_names = BTreeSet::new(); + for field in &self.fields { + if field.db_name.trim().is_empty() || field.wire_name.trim().is_empty() { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} contains an empty database or wire field name", + self.kind, self.table + ))); + } + if !database_names.insert(field.db_name.clone()) { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} contains duplicate database field {}", + self.kind, self.table, field.db_name + ))); + } + if !wire_names.insert(field.wire_name.clone()) { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} contains duplicate wire field {}", + self.kind, self.table, field.wire_name + ))); + } + + if matches!(self.kind, ShapeKind::Row | ShapeKind::PublicRead) && !field.required { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} cannot make row field {} optional", + self.kind, self.table, field.db_name + ))); + } + if matches!(self.kind, ShapeKind::Patch | ShapeKind::PublicPatch) && field.required { + return Err(OrmError::Invalid(format!( + "derived {:?} shape for {} cannot require patch field {}", + self.kind, self.table, field.db_name + ))); + } + } + + return Ok(()); + } +} + +pub fn derive_shape(table: &Table, policy: &Policy, kind: ShapeKind) -> Result { + policy.validate_table(table)?; + let table_policy = policy.table_policy(table); + + if kind.is_public() && !table_policy.public_surface { + return Err(OrmError::Invalid(format!( + "cannot derive public shape for {} without public_surface = true", + table.qualified_name() + ))); + } + + let fields = table + .columns + .iter() + .filter_map(|column| { + let name = &column.db_name; + let include = match kind { + ShapeKind::Row => true, + ShapeKind::Create => !table_policy.generated.contains(name), + ShapeKind::Update | ShapeKind::Patch => { + !table_policy.generated.contains(name) && !table_policy.immutable.contains(name) + } + ShapeKind::PublicRead => table_policy.public_read.contains(name), + ShapeKind::PublicCreate => { + table_policy.public_create.contains(name) + && !table_policy.generated.contains(name) + } + ShapeKind::PublicUpdate | ShapeKind::PublicPatch => { + table_policy.public_update.contains(name) + && !table_policy.generated.contains(name) + && !table_policy.immutable.contains(name) + } + }; + + if !include { + return None; + } + + let required = match kind { + ShapeKind::Row | ShapeKind::PublicRead => true, + ShapeKind::Create | ShapeKind::PublicCreate => { + !column.nullable && !table_policy.defaulted.contains(name) + } + ShapeKind::Update | ShapeKind::PublicUpdate => true, + ShapeKind::Patch | ShapeKind::PublicPatch => false, + }; + let wire_name = table_policy + .wire_names + .get(name) + .cloned() + .unwrap_or_else(|| name.clone()); + + return Some(ShapeField { + db_name: name.clone(), + wire_name, + ty: column.ty.clone(), + nullable: column.nullable, + required, + }); + }) + .collect(); + + let shape = Shape { + table: table.qualified_name(), + kind, + fields, + }; + shape.validate()?; + return Ok(shape); +} diff --git a/cert/ores-orm-core-pr12/tests/admission.rs b/cert/ores-orm-core-pr12/tests/admission.rs new file mode 100644 index 0000000..be253cd --- /dev/null +++ b/cert/ores-orm-core-pr12/tests/admission.rs @@ -0,0 +1,465 @@ +use ores_orm_core::diesel; +use ores_orm_core::emit::bundle::{ + self, ContractEvidenceState, DerivativeManifest, GeneratedBundle, GenerationEvidence, + PublicationState, +}; +use ores_orm_core::parity; +use ores_orm_core::policy::Policy; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{ShapeKind, derive_shape}; +use ores_orm_core::{REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS, TjsvAdmissionBinding}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +const DECLARATION: &str = "Example.Accounts.Users.PublicCreate"; +fn digest(bytes: &[u8]) -> String { + return hex::encode(Sha256::digest(bytes)); +} + +fn canonical_json(value: &Value) -> String { + match value { + Value::Null => "null".to_owned(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => serde_json::to_string(value).expect("JSON string"), + Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(canonical_json) + .collect::>() + .join(",") + ), + Value::Object(object) => { + let mut keys = object.keys().collect::>(); + keys.sort(); + let entries = keys + .into_iter() + .map(|key| { + format!( + "{}:{}", + serde_json::to_string(key).expect("JSON key"), + canonical_json(object.get(key).expect("canonical key")) + ) + }) + .collect::>() + .join(","); + format!("{{{entries}}}") + } + } +} + +fn canonical_digest(value: &Value) -> String { + return digest(canonical_json(value).as_bytes()); +} + +fn converged() -> ores_orm_core::OrmIr { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let seaorm = seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"); + return parity::converge(&diesel, &seaorm).expect("fixtures should converge"); +} + +fn public_create_candidate() -> ( + ores_orm_core::OrmIr, + Policy, + ores_orm_core::Shape, + GeneratedBundle, +) { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy"); + let shape = derive_shape( + ir.table("users").expect("users"), + &policy, + ShapeKind::PublicCreate, + ) + .expect("public create"); + let evidence = GenerationEvidence::from_bytes( + &ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"admission-v2", + None, + ) + .expect("candidate evidence"); + let candidate = bundle::emit(&ir, &policy, &shape, &evidence).expect("candidate bundle"); + return (ir, policy, shape, candidate); +} + +fn media_type(path: &str) -> &'static str { + if path.starts_with("rust/") { + return "text/x-rust"; + } + if path.starts_with("typescript/") { + return "application/typescript"; + } + if path.starts_with("dart/") { + return "text/x-dart"; + } + if path.starts_with("gleam/") { + return "text/x-gleam"; + } + return "application/schema+json"; +} + +fn artifact<'a>( + candidate: &'a GeneratedBundle, + prefix: &str, +) -> &'a ores_orm_core::emit::bundle::GeneratedArtifact { + return candidate + .artifacts + .iter() + .find(|artifact| artifact.path.starts_with(prefix)) + .expect("language artifact"); +} + +fn tjsv_fixture(candidate: &GeneratedBundle) -> (Vec, Vec, Vec, TjsvAdmissionBinding) { + let projection_id = candidate.manifest.projection_identity.clone(); + let run_id = "a".repeat(64); + let parity_digest = "b".repeat(64); + let typespec_digest = "c".repeat(64); + let generated_schema_digest = "d".repeat(64); + let authored_schema_digest = "e".repeat(64); + + let mut contract = json!({ + "schema": "ores.typespec-json-schema-validator.contract-ir/v1", + "status": "passed", + "admissible": true, + "role": "downstream-derived-parity-artifact", + "editableAuthority": false, + "authorities": { + "typespec": "independently-authored", + "jsonSchema": "independently-authored", + "generatedJsonSchema": "comparison-evidence-only", + "precedence": "none" + }, + "admission": { + "receipt": { + "schema": "ores.typespec-json-schema-validator.report/v1", + "runId": run_id.clone(), + "digest": parity_digest.clone(), + "status": "passed", + "zeroUnexplainedFindings": true + }, + "requirements": { + "exactInputDigests": true, + "directDeclarationInventory": true, + "generatedSchemaComparison": true, + "differentialInstanceValidation": true, + "zeroUnexplainedFindings": true + } + }, + "provenance": { + "typespec": { + "role": "independently-authored-authority", + "digest": typespec_digest.clone(), + "files": [] + }, + "generatedJsonSchema": { + "role": "comparison-evidence-only", + "digest": generated_schema_digest.clone(), + "files": [] + }, + "authoredJsonSchema": { + "role": "independently-authored-authority", + "digest": authored_schema_digest.clone(), + "files": [] + } + }, + "declarations": [{ + "id": DECLARATION, + "kind": "model" + }], + "excludedDeclarations": [], + "outOfScopeDeclarations": [] + }); + let ir_id = canonical_digest(&contract); + contract + .as_object_mut() + .expect("contract object") + .insert("irId".to_owned(), Value::String(ir_id.clone())); + let contract_digest = canonical_digest(&contract); + + let outputs = candidate + .artifacts + .iter() + .map(|artifact| { + json!({ + "path": artifact.path.clone(), + "sha256": artifact.sha256.clone(), + "size": artifact.content.len(), + "mediaType": media_type(&artifact.path), + "projection": projection_id.clone() + }) + }) + .collect::>(); + let output_paths = candidate + .artifacts + .iter() + .map(|artifact| artifact.path.clone()) + .collect::>(); + + let rust = artifact(candidate, "rust/"); + let typescript = artifact(candidate, "typescript/"); + let dart = artifact(candidate, "dart/"); + let gleam = artifact(candidate, "gleam/"); + let runtime_validators = vec![ + json!({ + "id": "rust-serde", + "projection": projection_id.clone(), + "artifactPath": rust.path.clone(), + "artifactDigest": rust.sha256.clone(), + "fixtureDigest": "1".repeat(64), + "ingressEgressCoverageDigest": "6".repeat(64) + }), + json!({ + "id": "typescript-zod", + "projection": projection_id.clone(), + "artifactPath": typescript.path.clone(), + "artifactDigest": typescript.sha256.clone(), + "fixtureDigest": "2".repeat(64), + "ingressEgressCoverageDigest": "7".repeat(64) + }), + json!({ + "id": "dart-json", + "projection": projection_id.clone(), + "artifactPath": dart.path.clone(), + "artifactDigest": dart.sha256.clone(), + "fixtureDigest": "3".repeat(64), + "ingressEgressCoverageDigest": "8".repeat(64) + }), + json!({ + "id": "gleam-erlang", + "projection": projection_id.clone(), + "artifactPath": gleam.path.clone(), + "artifactDigest": gleam.sha256.clone(), + "fixtureDigest": "4".repeat(64), + "ingressEgressCoverageDigest": "9".repeat(64) + }), + json!({ + "id": "gleam-js", + "projection": projection_id.clone(), + "artifactPath": gleam.path.clone(), + "artifactDigest": gleam.sha256.clone(), + "fixtureDigest": "5".repeat(64), + "ingressEgressCoverageDigest": "0".repeat(64) + }), + ]; + + let mut manifest = json!({ + "schema": "ores.typespec-json-schema-validator.projection-manifest/v1", + "status": "passed", + "contract": { + "contractIrId": ir_id.clone(), + "contractIrDigest": contract_digest, + "receiptRunId": run_id.clone(), + "receiptDigest": parity_digest, + "sourceDigests": { + "typespec": typespec_digest.clone(), + "generatedJsonSchema": generated_schema_digest.clone(), + "authoredJsonSchema": authored_schema_digest.clone() + } + }, + "inputs": { + "operationInventory": { + "path": "projection/operations.json", + "sha256": "1".repeat(64), + "size": 1 + }, + "projectionMetadata": { + "path": "projection/metadata.json", + "sha256": "2".repeat(64), + "size": 1 + }, + "emitterConfiguration": { + "path": "projection/emitter.json", + "sha256": "3".repeat(64), + "size": 1 + } + }, + "toolchains": [{ + "id": "ores-orm-core", + "version": env!("CARGO_PKG_VERSION"), + "artifactDigest": "f".repeat(64) + }], + "declarations": [DECLARATION], + "projections": [{ + "id": projection_id, + "emitter": "ores-orm-core", + "declarationIds": [DECLARATION], + "outputPaths": output_paths, + "representationDeltaIds": [], + "runtimeValidatorIds": REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS + }], + "outputs": outputs, + "representationDeltas": [], + "runtimeValidators": runtime_validators + }); + let manifest_id = canonical_digest(&manifest); + manifest + .as_object_mut() + .expect("manifest object") + .insert("manifestId".to_owned(), Value::String(manifest_id.clone())); + + let mut receipt = json!({ + "schema": "ores.typespec-json-schema-validator.projection-verification-receipt/v1", + "status": "passed", + "admissible": true, + "manifestId": manifest_id, + "contractIrId": ir_id, + "receiptRunId": run_id, + "evidenceDigest": "f".repeat(64), + "sourceDigests": { + "typespec": typespec_digest, + "generatedJsonSchema": generated_schema_digest, + "authoredJsonSchema": authored_schema_digest + }, + "summary": { + "declarations": 1, + "projections": 1, + "outputs": candidate.artifacts.len(), + "representationDeltas": 0, + "runtimeValidators": REQUIRED_PUBLIC_RUNTIME_VALIDATOR_IDS.len() + }, + "findingRuleIds": [], + "failureCode": null + }); + let verification_id = canonical_digest(&receipt); + receipt + .as_object_mut() + .expect("receipt object") + .insert("verificationId".to_owned(), Value::String(verification_id)); + + let contract_bytes = serde_json::to_vec(&contract).expect("contract bytes"); + let manifest_bytes = serde_json::to_vec(&manifest).expect("manifest bytes"); + let receipt_bytes = serde_json::to_vec(&receipt).expect("receipt bytes"); + let declarations = vec![DECLARATION.to_owned()]; + let binding = TjsvAdmissionBinding::verify( + &contract_bytes, + &manifest_bytes, + &receipt_bytes, + &candidate.manifest.projection_identity, + &declarations, + ) + .expect("fixture should verify"); + + return (contract_bytes, manifest_bytes, receipt_bytes, binding); +} + +#[test] +fn verified_projection_receipt_is_required_for_public_promotion() { + let (ir, policy, shape, candidate) = public_create_candidate(); + let (contract_ir, _manifest, projection_receipt, admission) = tjsv_fixture(&candidate); + let evidence = GenerationEvidence::from_bytes( + &ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"admission-v2", + Some((&contract_ir, &projection_receipt)), + ) + .expect("generation evidence"); + + let bound = bundle::emit(&ir, &policy, &shape, &evidence).expect("bound candidate"); + assert_eq!( + bound.manifest.contract_evidence, + ContractEvidenceState::EvidenceBound + ); + assert_eq!( + bound.manifest.publication, + PublicationState::BlockedPendingContractAdmission + ); + assert!(!bound.manifest.publishable()); + + let admitted = bundle::emit_admitted(&ir, &policy, &shape, &evidence, &admission) + .expect("admitted bundle"); + assert_eq!( + admitted.manifest.contract_evidence, + ContractEvidenceState::Admitted + ); + assert_eq!( + admitted.manifest.publication, + PublicationState::PublicAdmitted + ); + assert!(admitted.manifest.publishable()); + assert_eq!( + admitted.manifest.projection_identity, + admission.projection_id() + ); + assert_eq!(admission.declaration_ids(), &[DECLARATION.to_owned()]); + assert_eq!(admission.runtime_validator_ids().len(), 5); + assert_eq!(admission.outputs().len(), 5); +} + +#[test] +fn projection_admission_rejects_wrong_scope_and_tampered_receipt() { + let (_ir, _policy, _shape, candidate) = public_create_candidate(); + let (contract_ir, manifest, receipt, _) = tjsv_fixture(&candidate); + assert!( + TjsvAdmissionBinding::verify( + &contract_ir, + &manifest, + &receipt, + "orm.bad.public_create", + &[DECLARATION.to_owned()], + ) + .is_err() + ); + + let mut tampered: Value = serde_json::from_slice(&receipt).expect("receipt JSON"); + tampered["summary"]["runtimeValidators"] = json!(4); + let tampered = serde_json::to_vec(&tampered).expect("tampered receipt"); + assert!( + TjsvAdmissionBinding::verify( + &contract_ir, + &manifest, + &tampered, + &candidate.manifest.projection_identity, + &[DECLARATION.to_owned()], + ) + .is_err() + ); +} + +#[test] +fn admitted_bundle_requires_exact_verified_output_and_receipt_bytes() { + let (ir, policy, shape, candidate) = public_create_candidate(); + let (contract_ir, _manifest, receipt, admission) = tjsv_fixture(&candidate); + + let altered_receipt = [receipt.as_slice(), b"\n"].concat(); + let mismatched_evidence = GenerationEvidence::from_bytes( + &ir, + &[("fixtures/diesel/schema.rs", DIESEL.as_bytes())], + b"admission-v2", + Some((&contract_ir, &altered_receipt)), + ) + .expect("digest binding accepts exact supplied bytes"); + assert!(bundle::emit_admitted(&ir, &policy, &shape, &mismatched_evidence, &admission).is_err()); + + let evidence = GenerationEvidence::from_bytes( + &ir, + &[("fixtures/diesel/schema.rs", DIESEL.as_bytes())], + b"admission-v2", + Some((&contract_ir, &receipt)), + ) + .expect("evidence"); + let admitted = bundle::emit_admitted(&ir, &policy, &shape, &evidence, &admission) + .expect("admitted bundle"); + + let serialized = + serde_json::to_string(&admitted.manifest).expect("serialize admitted manifest"); + let replayed: DerivativeManifest = + serde_json::from_str(&serialized).expect("deserialize manifest"); + assert!( + !replayed.publishable(), + "deserialization must clear the in-process verified capability" + ); +} diff --git a/cert/ores-orm-core-pr12/tests/audit_v2.rs b/cert/ores-orm-core-pr12/tests/audit_v2.rs new file mode 100644 index 0000000..9c4bb39 --- /dev/null +++ b/cert/ores-orm-core-pr12/tests/audit_v2.rs @@ -0,0 +1,325 @@ +use ores_orm_core::diesel; +use ores_orm_core::emit::bundle::{ + self, ContractEvidenceState, GenerationEvidence, PublicationState, +}; +use ores_orm_core::emit::json_schema; +use ores_orm_core::ir::{Column, OrmIr, OrmType, ScalarType, SourceKind, Table}; +use ores_orm_core::parity; +use ores_orm_core::policy::{Policy, TablePolicy}; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{Shape, ShapeField, ShapeKind, derive_shape}; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +fn witnesses() -> (OrmIr, OrmIr) { + return ( + diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"), + seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"), + ); +} + +fn converged() -> OrmIr { + let (diesel, seaorm) = witnesses(); + return parity::converge(&diesel, &seaorm).expect("fixtures should converge"); +} + +fn evidence(ir: &OrmIr) -> GenerationEvidence { + return GenerationEvidence::from_bytes( + ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"audit-v2", + None, + ) + .expect("evidence"); +} + +fn empty_policy() -> Policy { + Policy::from_toml("version = 1\nrequire_diesel = true\nrequire_seaorm = true\n") + .expect("empty private policy") +} + +fn private_ir(table_name: &str, fields: &[(&str, &str)]) -> OrmIr { + let columns = fields + .iter() + .enumerate() + .map(|(ordinal, (db_name, rust_name))| Column { + rust_name: (*rust_name).to_owned(), + db_name: (*db_name).to_owned(), + ordinal, + ty: OrmType::Scalar(ScalarType::String), + nullable: false, + primary_key: false, + unique: false, + }) + .collect(); + OrmIr::try_new( + SourceKind::Converged, + vec![Table { + schema_name: None, + db_name: table_name.to_owned(), + columns, + }], + ) + .expect("synthetic private IR") +} + +fn synthetic_evidence(ir: &OrmIr) -> GenerationEvidence { + GenerationEvidence::from_bytes(ir, &[("synthetic", b"synthetic")], b"audit-v2", None) + .expect("synthetic evidence") +} + +#[test] +fn convergence_is_commutative_and_does_not_leak_source_only_unique_metadata() { + let (diesel, seaorm) = witnesses(); + assert!( + !diesel + .table("users") + .unwrap() + .column("email") + .unwrap() + .unique + ); + assert!( + seaorm + .table("users") + .unwrap() + .column("email") + .unwrap() + .unique + ); + + let diesel_first = parity::converge(&diesel, &seaorm).expect("converge"); + let seaorm_first = parity::converge(&seaorm, &diesel).expect("converge"); + assert_eq!(diesel_first, seaorm_first); + assert!( + !diesel_first + .table("users") + .unwrap() + .column("email") + .unwrap() + .unique + ); +} + +#[test] +fn ordinal_drift_is_a_blocking_parity_finding() { + let (diesel, mut seaorm) = witnesses(); + let table = seaorm.tables.first_mut().expect("users table"); + table.columns.swap(1, 2); + for (ordinal, column) in table.columns.iter_mut().enumerate() { + column.ordinal = ordinal; + } + seaorm + .validate() + .expect("drifted witness remains structurally valid"); + + let report = parity::compare(&diesel, &seaorm); + assert!(!report.passed); + assert!( + report + .findings + .iter() + .any(|finding| finding.code == "column_ordinal_mismatch") + ); +} + +#[test] +fn complete_policy_validation_rejects_orphan_table_entries() { + let ir = converged(); + let mut policy = Policy::from_toml(POLICY).expect("policy"); + policy + .table + .insert("typo_users".to_owned(), TablePolicy::default()); + + let error = policy + .validate_ir(&ir) + .expect_err("orphan policy entries must not be silently ignored"); + assert!( + error + .to_string() + .contains("does not match any ORM IR table") + ); +} + +#[test] +fn qualified_and_unqualified_policy_aliases_cannot_compete() { + let mut ir = converged(); + ir.tables[0].schema_name = Some("public".to_owned()); + ir.validate().expect("schema-qualified IR"); + + let mut policy = Policy::from_toml(POLICY).expect("policy"); + let users = policy.table.get("users").cloned().expect("users policy"); + policy.table.insert("public.users".to_owned(), users); + + let error = policy + .validate_table(&ir.tables[0]) + .expect_err("policy aliases must not rely on hidden precedence"); + assert!(error.to_string().contains("both qualified key")); +} + +#[test] +fn artifact_paths_are_safe_for_quoted_database_identifiers() { + let ir = private_ir("../odd/table\\name", &[("body", "body")]); + let policy = empty_policy(); + let shape = derive_shape(&ir.tables[0], &policy, ShapeKind::Row).expect("row shape"); + let bundle = + bundle::emit(&ir, &policy, &shape, &synthetic_evidence(&ir)).expect("bundle should emit"); + + for artifact in &bundle.artifacts { + let mut parts = artifact.path.split('/'); + let directory = parts.next().expect("language directory"); + let filename = parts.next().expect("filename"); + assert!( + parts.next().is_none(), + "unexpected nested artifact path: {}", + artifact.path + ); + assert!(!directory.is_empty()); + assert!(!filename.contains("..")); + assert!(!filename.contains('\\')); + assert!(!filename.contains('/')); + } + + let schema = json_schema::emit(&shape).expect("schema"); + let id = schema["$id"].as_str().expect("schema id"); + assert!(!id.contains("../")); + assert_eq!(schema["x-ores-table"], "../odd/table\\name"); +} + +#[test] +fn source_identifiers_use_reversible_collision_free_escaping() { + let ir = private_ir("collision_test", &[("a-b", "left"), ("a_b", "right")]); + let policy = empty_policy(); + let shape = derive_shape(&ir.tables[0], &policy, ShapeKind::Row).expect("row shape"); + + let bundle = bundle::emit(&ir, &policy, &shape, &synthetic_evidence(&ir)) + .expect("collision-safe bundle"); + let rust = bundle + .artifacts + .iter() + .find(|artifact| artifact.path.starts_with("rust/")) + .expect("Rust artifact"); + assert!(rust.content.contains("__ores_hex_612d62")); + assert!(rust.content.contains("pub a_b:")); +} + +#[test] +fn bundle_rejects_shape_not_derived_from_bound_ir_and_policy() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy"); + let forged = Shape { + table: "users".to_owned(), + kind: ShapeKind::PublicCreate, + fields: vec![ShapeField { + db_name: "email".to_owned(), + wire_name: "email".to_owned(), + ty: OrmType::Scalar(ScalarType::String), + nullable: false, + required: true, + }], + }; + + let error = bundle::emit(&ir, &policy, &forged, &evidence(&ir)) + .expect_err("forged subsets must not inherit unrelated ORM provenance"); + assert!(error.to_string().contains("does not match the exact shape")); +} + +#[test] +fn bundle_rejects_evidence_from_different_ir() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy"); + let shape = + derive_shape(ir.table("users").unwrap(), &policy, ShapeKind::PublicCreate).expect("shape"); + let other = private_ir("other", &[("body", "body")]); + + let error = bundle::emit(&ir, &policy, &shape, &synthetic_evidence(&other)) + .expect_err("evidence from another IR must be rejected"); + assert!(error.to_string().contains("does not match supplied ORM IR")); +} + +#[test] +fn attached_contract_bytes_do_not_make_public_candidates_publishable() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy"); + let shape = derive_shape( + ir.table("users").expect("users"), + &policy, + ShapeKind::PublicCreate, + ) + .expect("public create shape"); + let evidence = GenerationEvidence::from_bytes( + &ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ], + b"audit-v2", + Some(( + b"non-empty-unverified-contract-ir", + b"non-empty-unverified-receipt", + )), + ) + .expect("digest binding is allowed"); + let bundle = bundle::emit(&ir, &policy, &shape, &evidence).expect("candidate bundle"); + + assert_eq!( + bundle.manifest.contract_evidence, + ContractEvidenceState::EvidenceBound + ); + assert_eq!( + bundle.manifest.publication, + PublicationState::BlockedPendingContractAdmission + ); + assert!(!bundle.manifest.publishable()); +} + +#[test] +fn private_derivatives_remain_publishable_inside_the_backend_boundary() { + let ir = private_ir("private_note", &[("body", "body")]); + let policy = empty_policy(); + let shape = derive_shape(&ir.tables[0], &policy, ShapeKind::Row).expect("row shape"); + let bundle = + bundle::emit(&ir, &policy, &shape, &synthetic_evidence(&ir)).expect("private bundle"); + + assert_eq!( + bundle.manifest.publication, + PublicationState::PrivateDerivative + ); + assert!(bundle.manifest.publishable()); +} + +#[test] +fn json_schema_enforces_int16_and_int32_domains_normatively() { + let shape = Shape { + table: "integer_bounds".to_owned(), + kind: ShapeKind::Row, + fields: vec![ + ShapeField { + db_name: "small".to_owned(), + wire_name: "small".to_owned(), + ty: OrmType::Scalar(ScalarType::Int16), + nullable: false, + required: true, + }, + ShapeField { + db_name: "normal".to_owned(), + wire_name: "normal".to_owned(), + ty: OrmType::Scalar(ScalarType::Int32), + nullable: false, + required: true, + }, + ], + }; + let schema = json_schema::emit(&shape).expect("schema"); + + assert_eq!(schema["properties"]["small"]["minimum"], -32768); + assert_eq!(schema["properties"]["small"]["maximum"], 32767); + assert_eq!(schema["properties"]["normal"]["minimum"], -2147483648_i64); + assert_eq!(schema["properties"]["normal"]["maximum"], 2147483647_i64); +} diff --git a/cert/ores-orm-core-pr12/tests/diesel_hardening.rs b/cert/ores-orm-core-pr12/tests/diesel_hardening.rs new file mode 100644 index 0000000..9760231 --- /dev/null +++ b/cert/ores-orm-core-pr12/tests/diesel_hardening.rs @@ -0,0 +1,43 @@ +use ores_orm_core::diesel; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); + +#[test] +fn valid_diesel_primary_key_still_parses() { + let ir = diesel::parse_schema(DIESEL).expect("valid Diesel fixture should parse"); + let users = ir.table("users").expect("users table"); + let id = users.column("id").expect("id column"); + assert!(id.primary_key); +} + +#[test] +fn empty_diesel_primary_key_list_fails_closed() { + let source = DIESEL.replace("users (id)", "users ()"); + let error = diesel::parse_schema(&source) + .expect_err("an explicit empty primary-key list must be rejected"); + assert!(error.to_string().contains("empty primary-key list")); +} + +#[test] +fn duplicate_diesel_primary_key_column_fails_closed() { + let source = DIESEL.replace("users (id)", "users (id, id)"); + let error = diesel::parse_schema(&source) + .expect_err("duplicate primary-key identifiers must be rejected"); + assert!( + error + .to_string() + .contains("duplicate primary-key column id") + ); +} + +#[test] +fn undeclared_diesel_primary_key_column_fails_closed() { + let source = DIESEL.replace("users (id)", "users (missing_pk)"); + let error = diesel::parse_schema(&source) + .expect_err("primary-key identifiers must reference declared columns"); + assert!( + error + .to_string() + .contains("primary-key column missing_pk is not declared") + ); +} diff --git a/cert/ores-orm-core-pr12/tests/emitters.rs b/cert/ores-orm-core-pr12/tests/emitters.rs new file mode 100644 index 0000000..f58a943 --- /dev/null +++ b/cert/ores-orm-core-pr12/tests/emitters.rs @@ -0,0 +1,301 @@ +use ores_orm_core::descriptor::{DescriptorSource, OrmWitness, describe_table}; +use ores_orm_core::diesel; +use ores_orm_core::emit::bundle::{ContractEvidenceState, GenerationEvidence}; +use ores_orm_core::emit::{bundle, dart, gleam, rust, typescript}; +use ores_orm_core::ir::{OrmType, ScalarType}; +use ores_orm_core::parity; +use ores_orm_core::policy::{FieldVisibility, Policy}; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{Shape, ShapeField, ShapeKind, derive_shape}; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +fn converged() -> ores_orm_core::OrmIr { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let seaorm = seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"); + + return parity::converge(&diesel, &seaorm).expect("ORM fixtures should converge"); +} + +fn generation_evidence(ir: &ores_orm_core::OrmIr) -> GenerationEvidence { + return GenerationEvidence::from_bytes( + ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"shape-policy-v1", + None, + ) + .expect("generation evidence should be deterministic"); +} + +#[test] +fn descriptor_keeps_secret_storage_out_of_public_shapes() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = ir.table("users").expect("users table"); + let source = DescriptorSource::new( + "ORESoftware/example-orm-core", + "0123456789abcdef0123456789abcdef01234567", + "example-orm-core", + "rustc 1.88.0", + vec![OrmWitness::Diesel, OrmWitness::SeaOrm], + ) + .expect("source should validate"); + let descriptor = + describe_table(table, &policy, "Example.Accounts", source).expect("descriptor should emit"); + + let password = descriptor + .model + .fields + .iter() + .find(|field| field.database_name == "password_hash") + .expect("secret field"); + assert_eq!(password.visibility, FieldVisibility::Secret); + assert!(descriptor.model.public_surface); + + let public_read = + derive_shape(table, &policy, ShapeKind::PublicRead).expect("public read shape"); + assert!( + public_read + .fields + .iter() + .all(|field| field.db_name != "password_hash") + ); +} + +#[test] +fn public_surface_rejects_unclassified_columns() { + let ir = converged(); + let mut policy = Policy::from_toml(POLICY).expect("policy should parse"); + policy + .table + .get_mut("users") + .expect("users policy") + .secret + .clear(); + + let table = ir.table("users").expect("users table"); + let error = derive_shape(table, &policy, ShapeKind::PublicRead) + .expect_err("unclassified public table field must fail"); + + assert!(error.to_string().contains("password_hash")); + assert!(error.to_string().contains("unclassified")); +} + +#[test] +fn language_emitters_preserve_optional_nullable_and_closed_object_semantics() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = ir.table("users").expect("users table"); + let shape = derive_shape(table, &policy, ShapeKind::PublicCreate).expect("public create shape"); + + let rust = rust::emit(&shape).expect("Rust should emit"); + assert!(rust.contains("#[serde(deny_unknown_fields)]")); + assert!(rust.contains("pub display_name: Option>")); + assert!(rust.contains("deserialize_optional_nullable")); + assert!(rust.contains("Canonical JSON Schema is emitted as a sibling artifact")); + assert!(!rust.contains("schemars::JsonSchema")); + assert!(!rust.contains("password_hash")); + syn::parse_file(&rust).expect("generated Rust should parse as a Rust source file"); + + let typescript = typescript::emit(&shape).expect("TypeScript should emit"); + assert!(typescript.contains("z.object({")); + assert!(typescript.contains("}).strict()")); + assert!(typescript.contains("\"display_name\": z.string().nullable().optional()")); + assert!(!typescript.contains("password_hash")); + + let dart = dart::emit(&shape).expect("Dart should emit"); + assert!(dart.contains("OresOptional display_name")); + assert!(dart.contains("final OresOptional display_nameValue")); + assert!(dart.contains("unknownKeys")); + assert!(dart.contains("missing required field: email")); + assert!(!dart.contains("emailRaw as String")); + assert!(!dart.contains("password_hash")); + + let gleam = gleam::emit(&shape).expect("Gleam should emit"); + assert!(gleam.contains("display_name: Presence(Option(String))")); + assert!(gleam.contains("import gleam/option.{type Option}")); + assert!(gleam.contains("decode.dict(decode.string, decode.dynamic)")); + assert!(gleam.contains("closed object")); + assert!(!gleam.contains("password_hash")); +} + +#[test] +fn bundle_is_deterministic_and_digest_bound() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = ir.table("users").expect("users table"); + let shape = derive_shape(table, &policy, ShapeKind::PublicCreate).expect("public create shape"); + + let evidence = generation_evidence(&ir); + let first = bundle::emit(&ir, &policy, &shape, &evidence).expect("bundle should emit"); + let second = bundle::emit(&ir, &policy, &shape, &evidence) + .expect("bundle should emit deterministically"); + + assert_eq!(first, second); + assert_eq!(first.artifacts.len(), 5); + assert!(first.manifest.public_candidate); + assert_eq!( + first.manifest.contract_evidence, + ContractEvidenceState::Required + ); + assert_eq!(first.manifest.generator_version, env!("CARGO_PKG_VERSION")); + assert_eq!(first.manifest.evidence.source_digests.len(), 3); + assert_eq!(first.manifest.evidence.orm_ir_sha256.len(), 64); + assert_eq!(first.manifest.evidence.generator_options_sha256.len(), 64); + assert_eq!(first.manifest.artifacts.len(), 5); + assert!( + first + .manifest + .artifacts + .iter() + .all(|artifact| artifact.sha256.len() == 64) + ); + assert_eq!( + first.manifest_json().expect("manifest JSON"), + second.manifest_json().expect("manifest JSON") + ); +} + +#[test] +fn unsafe_cross_target_numeric_domains_fail_closed() { + let shape = Shape { + table: "example".to_owned(), + kind: ShapeKind::PublicRead, + fields: vec![ShapeField { + db_name: "counter".to_owned(), + wire_name: "counter".to_owned(), + ty: OrmType::Scalar(ScalarType::Int64), + nullable: false, + required: true, + }], + }; + + assert!(typescript::emit(&shape).is_err()); + assert!(dart::emit(&shape).is_err()); + assert!(gleam::emit(&shape).is_err()); + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy"); + let evidence = generation_evidence(&ir); + assert!(bundle::emit(&ir, &policy, &shape, &evidence).is_err()); + assert!(rust::emit(&shape).is_err()); + assert!(ores_orm_core::emit::json_schema::emit(&shape).is_err()); +} + +#[test] +fn descriptor_source_fails_closed_on_invalid_provenance() { + assert!( + DescriptorSource::new( + "", + "0123456789abcdef0123456789abcdef01234567", + "example-orm-core", + "rustc 1.88.0", + vec![OrmWitness::Diesel], + ) + .is_err() + ); + assert!( + DescriptorSource::new( + "ORESoftware/example-orm-core", + "not-a-revision", + "example-orm-core", + "rustc 1.88.0", + vec![OrmWitness::Diesel], + ) + .is_err() + ); + assert!( + DescriptorSource::new( + "ORESoftware/example-orm-core", + "0123456789abcdef0123456789abcdef01234567", + "example-orm-core", + "rustc 1.88.0", + vec![OrmWitness::Diesel, OrmWitness::Diesel], + ) + .is_err() + ); +} + +#[test] +fn patch_emitters_reject_empty_objects() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = ir.table("users").expect("users table"); + let shape = derive_shape(table, &policy, ShapeKind::PublicPatch).expect("public patch shape"); + + let schema = ores_orm_core::emit::json_schema::emit(&shape).expect("JSON Schema should emit"); + assert_eq!(schema["minProperties"], 1); + + let rust = rust::emit(&shape).expect("Rust should emit"); + assert!(rust.contains("patch must contain at least one field")); + assert!(rust.contains("pub fn validate(&self)")); + + let typescript = typescript::emit(&shape).expect("TypeScript should emit"); + assert!(typescript.contains("Object.keys(value).length > 0")); + + let dart = dart::emit(&shape).expect("Dart should emit"); + assert!(dart.contains("if (json.isEmpty)")); + assert!(dart.contains("patch must contain at least one field")); + + let gleam = gleam::emit(&shape).expect("Gleam should emit"); + assert!(gleam.contains("dict.size(fields)")); + assert!(gleam.contains("non-empty patch object")); +} + +#[test] +fn dart_uuid_emission_fails_closed_without_admitted_wire_mapping() { + let shape = Shape { + table: "example".to_owned(), + kind: ShapeKind::PublicRead, + fields: vec![ShapeField { + db_name: "id".to_owned(), + wire_name: "id".to_owned(), + ty: OrmType::Scalar(ScalarType::Uuid), + nullable: false, + required: true, + }], + }; + + let error = dart::emit(&shape) + .expect_err("UUID must remain blocked until a cross-runtime wire mapping is admitted"); + assert!(error.to_string().contains("uuid")); + assert!(error.to_string().contains("admitted")); +} + +#[test] +fn public_bundle_records_bound_contract_evidence_without_claiming_admission() { + let ir = converged(); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = ir.table("users").expect("users table"); + let shape = derive_shape(table, &policy, ShapeKind::PublicCreate).expect("public create shape"); + let evidence = GenerationEvidence::from_bytes( + &ir, + &[ + ("fixtures/diesel/schema.rs", DIESEL.as_bytes()), + ("fixtures/seaorm/users.rs", SEAORM.as_bytes()), + ("fixtures/ores-orm.toml", POLICY.as_bytes()), + ], + b"shape-policy-v1", + Some((b"exact-tjsv-contract-ir", b"exact-tjsv-receipt")), + ) + .expect("bound evidence"); + + let bundle = bundle::emit(&ir, &policy, &shape, &evidence).expect("bundle should emit"); + assert_eq!( + bundle.manifest.contract_evidence, + ContractEvidenceState::EvidenceBound + ); + let contract = bundle + .manifest + .evidence + .contract + .as_ref() + .expect("contract evidence"); + assert_eq!(contract.contract_ir_sha256.len(), 64); + assert_eq!(contract.receipt_sha256.len(), 64); +} diff --git a/cert/ores-orm-core-pr12/tests/hardening.rs b/cert/ores-orm-core-pr12/tests/hardening.rs new file mode 100644 index 0000000..7db993c --- /dev/null +++ b/cert/ores-orm-core-pr12/tests/hardening.rs @@ -0,0 +1,209 @@ +use ores_orm_core::diesel; +use ores_orm_core::emit::bundle::GenerationEvidence; +use ores_orm_core::emit::{dart, gleam, json_schema, typescript}; +use ores_orm_core::ir::{OrmType, ScalarType}; +use ores_orm_core::parity; +use ores_orm_core::policy::Policy; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{Shape, ShapeField, ShapeKind, derive_shape}; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +fn converged() -> ores_orm_core::OrmIr { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let seaorm = seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"); + return parity::converge(&diesel, &seaorm).expect("ORM fixtures should converge"); +} + +#[test] +fn duplicate_effective_wire_names_fail_closed() { + let ir = converged(); + let table = ir.table("users").expect("users table"); + let mut policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table_policy = policy.table.get_mut("users").expect("users policy"); + table_policy + .wire_names + .insert("email".to_owned(), "identity".to_owned()); + table_policy + .wire_names + .insert("display_name".to_owned(), "identity".to_owned()); + + let error = derive_shape(table, &policy, ShapeKind::PublicRead) + .expect_err("wire-name collisions must be rejected before emission"); + assert!(error.to_string().contains("wire name")); + assert!(error.to_string().contains("collides")); +} + +#[test] +fn direct_shapes_with_duplicate_wire_names_fail_before_emission() { + let shape = Shape { + table: "example".to_owned(), + kind: ShapeKind::Row, + fields: vec![ + ShapeField { + db_name: "first".to_owned(), + wire_name: "same".to_owned(), + ty: OrmType::Scalar(ScalarType::String), + nullable: false, + required: true, + }, + ShapeField { + db_name: "second".to_owned(), + wire_name: "same".to_owned(), + ty: OrmType::Scalar(ScalarType::String), + nullable: false, + required: true, + }, + ], + }; + + let error = json_schema::emit(&shape).expect_err("duplicate wire names must fail closed"); + assert!(error.to_string().contains("duplicate wire field")); +} + +#[test] +fn seaorm_time_offset_datetime_matches_diesel_timestamptz() { + let diesel = diesel::parse_schema( + "diesel::table! { events (id) { id -> Uuid, occurred_at -> Timestamptz, } }", + ) + .expect("Diesel event schema"); + let seaorm = seaorm::parse_source( + r#" + use sea_orm::entity::prelude::*; + use time::OffsetDateTime; + #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] + #[sea_orm(table_name = "events")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub occurred_at: OffsetDateTime, + } + "#, + ) + .expect("SeaORM time crate model"); + + parity::converge(&diesel, &seaorm) + .expect("time::OffsetDateTime must normalize to the same DateTime domain as Timestamptz"); +} + +#[test] +fn public_create_cannot_silently_drop_generated_fields() { + let ir = converged(); + let table = ir.table("users").expect("users table"); + let mut policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table_policy = policy.table.get_mut("users").expect("users policy"); + table_policy.public_create.insert("id".to_owned()); + + let error = derive_shape(table, &policy, ShapeKind::PublicCreate) + .expect_err("generated public-create fields must not disappear silently"); + assert!(error.to_string().contains("public_create")); + assert!(error.to_string().contains("generated")); +} + +#[test] +fn public_update_cannot_silently_drop_immutable_fields() { + let ir = converged(); + let table = ir.table("users").expect("users table"); + let mut policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table_policy = policy.table.get_mut("users").expect("users policy"); + table_policy.public_update.insert("created_at".to_owned()); + + let error = derive_shape(table, &policy, ShapeKind::PublicUpdate) + .expect_err("immutable public-update fields must not disappear silently"); + assert!(error.to_string().contains("public_update")); + assert!(error.to_string().contains("immutable")); +} + +#[test] +fn emitted_numeric_validators_do_not_widen_database_domains() { + let shape = Shape { + table: "numeric_bounds".to_owned(), + kind: ShapeKind::Row, + fields: vec![ + ShapeField { + db_name: "small".to_owned(), + wire_name: "small".to_owned(), + ty: OrmType::Scalar(ScalarType::Int16), + nullable: false, + required: true, + }, + ShapeField { + db_name: "normal".to_owned(), + wire_name: "normal".to_owned(), + ty: OrmType::Scalar(ScalarType::Int32), + nullable: false, + required: true, + }, + ShapeField { + db_name: "ratio".to_owned(), + wire_name: "ratio".to_owned(), + ty: OrmType::Scalar(ScalarType::Float64), + nullable: false, + required: true, + }, + ], + }; + + let ts = typescript::emit(&shape).expect("TypeScript"); + assert!(ts.contains("min(-32768).max(32767)")); + assert!(ts.contains("min(-2147483648).max(2147483647)")); + assert!(ts.contains("z.number().finite()")); + + let dart = dart::emit(&shape).expect("Dart"); + assert!(dart.contains(">= -32768")); + assert!(dart.contains("<= 2147483647")); + assert!(dart.contains(".isFinite")); + + let gleam = gleam::emit(&shape).expect("Gleam"); + assert!(gleam.contains("value >= -32_768 && value <= 32_767")); + assert!(gleam.contains("value >= -2_147_483_648 && value <= 2_147_483_647")); +} + +#[test] +fn provenance_rejects_duplicate_source_names() { + let ir = converged(); + let error = GenerationEvidence::from_bytes( + &ir, + &[ + ("schema.rs", DIESEL.as_bytes()), + ("schema.rs", SEAORM.as_bytes()), + ], + b"hardening-test", + None, + ) + .expect_err("duplicate provenance labels are ambiguous"); + assert!(error.to_string().contains("duplicate source name")); +} + +#[test] +fn empty_contract_evidence_cannot_claim_evidence_bound_state() { + let ir = converged(); + let error = GenerationEvidence::from_bytes( + &ir, + &[("schema.rs", DIESEL.as_bytes())], + b"hardening-test", + Some((b"", b"")), + ) + .expect_err("empty contract evidence must not be digest-bound"); + assert!( + error + .to_string() + .contains("non-empty Contract IR and receipt") + ); +} + +#[test] +fn provenance_rejects_invalid_ir() { + let mut ir = converged(); + ir.tables.push(ir.tables[0].clone()); + let error = GenerationEvidence::from_bytes( + &ir, + &[("schema.rs", DIESEL.as_bytes())], + b"hardening-test", + None, + ) + .expect_err("invalid ORM IR must not receive provenance digests"); + assert!(error.to_string().contains("duplicate table identity")); +} diff --git a/cert/ores-orm-core-pr12/tests/parity.rs b/cert/ores-orm-core-pr12/tests/parity.rs new file mode 100644 index 0000000..00a0cf5 --- /dev/null +++ b/cert/ores-orm-core-pr12/tests/parity.rs @@ -0,0 +1,135 @@ +use ores_orm_core::diesel; +use ores_orm_core::emit::json_schema; +use ores_orm_core::parity; +use ores_orm_core::policy::Policy; +use ores_orm_core::seaorm; +use ores_orm_core::shapes::{ShapeKind, derive_shape}; +use pretty_assertions::assert_eq; + +const DIESEL: &str = include_str!("../fixtures/diesel/schema.rs"); +const SEAORM: &str = include_str!("../fixtures/seaorm/users.rs"); +const POLICY: &str = include_str!("../fixtures/ores-orm.toml"); + +#[test] +fn diesel_and_seaorm_converge_on_structural_shape() { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let seaorm = seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"); + let report = parity::compare(&diesel, &seaorm); + assert_eq!(report.findings, vec![]); + assert!(report.passed); + + let converged = parity::converge(&diesel, &seaorm).expect("ORM lanes should converge"); + assert_eq!(converged.tables.len(), 1); + assert_eq!(converged.tables[0].db_name, "users"); +} + +#[test] +fn public_create_keeps_presence_and_nullability_separate() { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let policy = Policy::from_toml(POLICY).expect("policy should parse"); + let table = diesel.table("users").expect("users table"); + let shape = derive_shape(table, &policy, ShapeKind::PublicCreate).expect("derive shape"); + + assert_eq!(shape.fields.len(), 2); + let email = shape + .fields + .iter() + .find(|field| field.db_name == "email") + .expect("email"); + assert!(email.required); + assert!(!email.nullable); + + let display_name = shape + .fields + .iter() + .find(|field| field.db_name == "display_name") + .expect("display_name"); + assert!(!display_name.required); + assert!(display_name.nullable); + + let schema = json_schema::emit(&shape).expect("schema should emit"); + assert_eq!(schema["additionalProperties"], false); + assert_eq!(schema["required"], serde_json::json!(["email"])); + assert_eq!( + schema["properties"]["display_name"], + serde_json::json!({"anyOf": [{"type": "string"}, {"type": "null"}]}) + ); +} + +#[test] +fn drift_fails_closed() { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let drifted = SEAORM.replace("pub email: String", "pub email: i64"); + let seaorm = seaorm::parse_source(&drifted).expect("SeaORM drift fixture should parse"); + let report = parity::compare(&diesel, &seaorm); + assert!(!report.passed); + assert!(report.findings.iter().any(|finding| { + finding.code == "column_type_mismatch" && finding.path == "users.email" + })); +} + +#[test] +fn convergence_requires_independent_orm_witnesses() { + let diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + let error = parity::converge(&diesel, &diesel) + .expect_err("two Diesel witnesses must not be admitted as convergence"); + assert!( + error + .to_string() + .contains("one Diesel witness and one SeaORM witness") + ); +} + +#[test] +fn seaorm_ignore_fields_are_not_treated_as_database_columns() { + let ignored = SEAORM.replace( + " pub display_name: Option,", + " #[sea_orm(ignore)]\n pub display_name: Option,", + ); + let ir = seaorm::parse_source(&ignored).expect("ignored field should parse"); + let users = ir.table("users").expect("users table"); + assert!(users.column("display_name").is_none()); +} + +#[test] +fn seaorm_database_type_overrides_fail_closed() { + let overridden = SEAORM.replace( + " pub email: String,", + " #[sea_orm(column_type = \"Text\")]\n pub email: String,", + ); + let error = seaorm::parse_source(&overridden) + .expect_err("column_type changes database semantics and must not be ignored"); + assert!(error.to_string().contains("column_type")); + assert!(error.to_string().contains("explicit mapping")); +} + +#[test] +fn duplicate_database_column_names_fail_closed() { + let duplicate = SEAORM.replace( + " pub display_name: Option,", + " #[sea_orm(column_name = \"email\")]\n pub display_name: Option,", + ); + let error = seaorm::parse_source(&duplicate) + .expect_err("duplicate database identities must be rejected"); + assert!( + error + .to_string() + .contains("duplicate database column email") + ); +} + +#[test] +fn invalid_ir_cannot_hide_behind_parity_indexing() { + let mut diesel = diesel::parse_schema(DIESEL).expect("Diesel fixture should parse"); + diesel.tables.push(diesel.tables[0].clone()); + let seaorm = seaorm::parse_source(SEAORM).expect("SeaORM fixture should parse"); + + let report = parity::compare(&diesel, &seaorm); + assert!(!report.passed); + assert!( + report + .findings + .iter() + .any(|finding| finding.code == "invalid_left_ir") + ); +} diff --git a/cert/ores-orm-core-pr12/tests/policy_provenance.rs b/cert/ores-orm-core-pr12/tests/policy_provenance.rs new file mode 100644 index 0000000..774839f --- /dev/null +++ b/cert/ores-orm-core-pr12/tests/policy_provenance.rs @@ -0,0 +1,68 @@ +use ores_orm_core::emit::bundle::{self, GenerationEvidence}; +use ores_orm_core::ir::{Column, OrmIr, OrmType, ScalarType, SourceKind, Table}; +use ores_orm_core::policy::Policy; +use ores_orm_core::shapes::{ShapeKind, derive_shape}; + +fn private_ir() -> OrmIr { + OrmIr::try_new( + SourceKind::Converged, + vec![Table { + schema_name: None, + db_name: "notes".to_owned(), + columns: vec![Column { + rust_name: "body".to_owned(), + db_name: "body".to_owned(), + ordinal: 0, + ty: OrmType::Scalar(ScalarType::String), + nullable: false, + primary_key: false, + unique: false, + }], + }], + ) + .expect("synthetic converged IR") +} + +fn evidence(ir: &OrmIr) -> GenerationEvidence { + GenerationEvidence::from_bytes( + ir, + &[("synthetic/diesel.rs", b"table! notes")], + b"same-generator-options", + None, + ) + .expect("generation evidence") +} + +#[test] +fn derivative_manifest_binds_the_exact_validated_policy_independently_of_options() { + let ir = private_ir(); + let policy_a = Policy::from_toml("version = 1\nrequire_diesel = true\nrequire_seaorm = true\n") + .expect("policy a"); + let policy_b = + Policy::from_toml("version = 1\nrequire_diesel = false\nrequire_seaorm = true\n") + .expect("policy b"); + + // This private row shape is intentionally identical under both policies. + // Provenance still must distinguish which exact policy was supplied to the + // emitter, even when the caller reuses identical generator-options bytes. + let shape_a = derive_shape(&ir.tables[0], &policy_a, ShapeKind::Row).expect("shape a"); + let shape_b = derive_shape(&ir.tables[0], &policy_b, ShapeKind::Row).expect("shape b"); + assert_eq!(shape_a, shape_b); + + let evidence = evidence(&ir); + let bundle_a = bundle::emit(&ir, &policy_a, &shape_a, &evidence).expect("bundle a"); + let bundle_b = bundle::emit(&ir, &policy_b, &shape_b, &evidence).expect("bundle b"); + + assert_eq!( + bundle_a.manifest.evidence.generator_options_sha256, + bundle_b.manifest.evidence.generator_options_sha256 + ); + assert_ne!( + bundle_a.manifest.policy_sha256, bundle_b.manifest.policy_sha256, + "policy provenance must not collapse merely because the derived shape is unchanged" + ); + assert_eq!( + bundle_a.manifest.schema, + "ores.orm-core.derivative-manifest/v3" + ); +} diff --git a/cert/ores-orm-core-pr12/tests/wire_mapping_gates.rs b/cert/ores-orm-core-pr12/tests/wire_mapping_gates.rs new file mode 100644 index 0000000..caf0e78 --- /dev/null +++ b/cert/ores-orm-core-pr12/tests/wire_mapping_gates.rs @@ -0,0 +1,74 @@ +use ores_orm_core::emit::{dart, gleam, json_schema, rust, typescript}; +use ores_orm_core::ir::{OrmType, ScalarType}; +use ores_orm_core::shapes::{Shape, ShapeField, ShapeKind}; + +fn shape(scalar: ScalarType) -> Shape { + Shape { + table: "wire_mapping_gate".to_owned(), + kind: ShapeKind::PublicRead, + fields: vec![ShapeField { + db_name: "value".to_owned(), + wire_name: "value".to_owned(), + ty: OrmType::Scalar(scalar), + nullable: false, + required: true, + }], + } +} + +#[test] +fn ambiguous_cross_runtime_scalars_require_admitted_mappings() { + for scalar in [ + ScalarType::Int64, + ScalarType::Float32, + ScalarType::Decimal, + ScalarType::Uuid, + ScalarType::Date, + ScalarType::DateTime, + ScalarType::Bytes, + ] { + let candidate = shape(scalar.clone()); + assert!( + rust::emit(&candidate).is_err(), + "Rust unexpectedly admitted {scalar:?}" + ); + assert!( + typescript::emit(&candidate).is_err(), + "TypeScript unexpectedly admitted {scalar:?}" + ); + assert!( + dart::emit(&candidate).is_err(), + "Dart unexpectedly admitted {scalar:?}" + ); + assert!( + gleam::emit(&candidate).is_err(), + "Gleam unexpectedly admitted {scalar:?}" + ); + assert!( + json_schema::emit(&candidate).is_err(), + "JSON Schema unexpectedly admitted {scalar:?}" + ); + } +} + +#[test] +fn portable_scalar_subset_remains_generatable() { + for scalar in [ + ScalarType::Boolean, + ScalarType::Int16, + ScalarType::Int32, + ScalarType::Float64, + ScalarType::String, + ScalarType::Json, + ] { + let candidate = shape(scalar.clone()); + rust::emit(&candidate).unwrap_or_else(|error| panic!("Rust rejected {scalar:?}: {error}")); + typescript::emit(&candidate) + .unwrap_or_else(|error| panic!("TypeScript rejected {scalar:?}: {error}")); + dart::emit(&candidate).unwrap_or_else(|error| panic!("Dart rejected {scalar:?}: {error}")); + gleam::emit(&candidate) + .unwrap_or_else(|error| panic!("Gleam rejected {scalar:?}: {error}")); + json_schema::emit(&candidate) + .unwrap_or_else(|error| panic!("JSON Schema rejected {scalar:?}: {error}")); + } +}