diff --git a/.superpowers/sdd/2026-08-29-independent-verifier/task-4-report.md b/.superpowers/sdd/2026-08-29-independent-verifier/task-4-report.md new file mode 100644 index 0000000..89a4a72 --- /dev/null +++ b/.superpowers/sdd/2026-08-29-independent-verifier/task-4-report.md @@ -0,0 +1,122 @@ +# Task 4B1 report: pure Matrix plan core + +## Slice A implementation attempt (2026-08-29) + +- Scope: replaced the duplicate root Matrix config/plan block in `src/matrix.rs` with core re-exports and the compatibility `build_matrix_plan` wrapper; retained execution constants and removed now-unused imports. +- Initial focused tests compiled the library but failed at root integration: core plan accessors now return `ccp_core::matrix::MatrixContractError`, while existing tests and CLI call sites still match/pass `MatrixError`; `prepare_source_snapshot_overlay` is also no longer an inherent method on the re-exported core envelope. +- Exact command: `rtk env CARGO_TARGET_DIR=/private/tmp/ccp-independent-task4-slice-a cargo test --locked --test independent_verifier_contract --test matrix_contract --test receipt_contract` +- Result: FAIL, 18 compile errors (8 test nominal error-type mismatches; 10 binary call-site/method integration errors). No heavy commands run. +- `rtk cargo fmt --all` completed after escalation; `rtk git diff --check` passed. No commit created because focused tests are not green. +- Blocker: root execution/CLI adaptation and/or test contract migration is required before this slice can be green; scope is beyond the mechanical re-export-only edit as currently staged. + +### Fix round 1 (2026-08-29) + +- Added core `LegacyPlanNotRepresentable` error and root adapter mapping; restored root free-function source-overlay preparation and updated CLI/internal callers; updated pure-method assertions to core error variants. +- Focused command reached execution: 11 passed, 5 failed. Failures are legacy profile digest/validation parity (4 tests) plus pinned schema naming (fixed by restoring `EnvironmentConfig` schemars rename; rerun still required). +- Exact command: `rtk cargo fmt --all && rtk env CARGO_TARGET_DIR=/private/tmp/ccp-independent-task4-slice-a cargo test --locked --test independent_verifier_contract --test matrix_contract --test receipt_contract && rtk git diff --check` +- No commit created; required GREEN gates remain outstanding. + +### Packaging correction (2026-08-29) + +- The previously omitted eight-type Matrix identity test was verified independently: 1 passed; `git diff --check` passed. It is packaged in the follow-up commit. +Implemented the first bounded extraction slice in `ccp-core::matrix`. + +## Scope + +- Added Matrix V2 configuration, runtime/check declarations, plan envelope, + normalization, canonical digest binding, and validation. +- Added `MatrixContractError` with source propagation for I/O, TOML, config, + and receipt failures. +- Kept execution, runtime, cache, process, Docker, source-snapshot, receipt + publication, and root `src/matrix.rs` untouched. +- Exposed the module through `ccp_core::matrix`. + +## Verification + +- `rtk env CARGO_TARGET_DIR=/private/tmp/ccp-independent-target cargo check -p ccp-core --locked`: PASS. +- `rtk env CARGO_TARGET_DIR=/private/tmp/ccp-independent-target cargo test -p ccp-core --locked`: blocked by a pre-existing unrelated test compile error in `crates/ccp-core/src/receipt.rs` (`Value` is not imported at line 986). +- `rtk git diff --check`: PASS. +- `cargo fmt --all`: blocked by the environment returning `Operation not permitted` when writing this worktree; source was kept formatted manually. + +## Commit + +Local commit: `refactor: add pure matrix plan core`. + +# Task 4B2 report: pure Matrix receipt/policy core + +## Task 4B1 parity completion (2026-08-29) + +RED fixture (exact pre-B1 HEAD `7bcaeb12d39cf36c5880f7063d9b90df7d88f4f7`): corrected parity used a valid resealed runtime-image mismatch and failed because old core omitted `policy.runtime_image`; invalid-policy and adapter tests also failed as intended. Active GREEN: all three new tests passed. + +Final gate counts: matrix 19/19, verification 21 passed plus 1 explicit ignored, receipt 11/11, independent 7/7, core 46/46, fmt check PASS, diff check PASS. + +- Core Matrix policy validation now enforces project/digest/image/freshness/platform constraints and complete runtime coverage, matching the root policy contract. +- Core receipt verification now preserves root finding order and semantics for repository, commit, dirty state, configuration, runtime set/configuration/image/platform, required checks, and freshness. +- The root adapter preserves the nominal core `VerificationError` directly (`Core::Verification(error) => Self::Verification(error)`). +- `rtk env CARGO_TARGET_DIR=/private/tmp/ccp-independent-task4-b1 cargo test -p ccp-core --locked`: PASS (46 passed, 0 failed). +- `rtk env CARGO_TARGET_DIR=/private/tmp/ccp-independent-task4-b1 cargo test --locked --test matrix_contract --test receipt_contract --test independent_verifier_contract`: PASS (34 passed, 0 failed; existing dead-code warnings only). +- `rtk git diff --check`: PASS. `rtk cargo fmt --all` remains blocked by worktree `Operation not permitted` while writing `crates/ccp-core/src/matrix.rs`; no formatter changes were applied. + +Implemented the bounded receipt, policy, verification, historical digest, and +schema slice in `ccp-core`, retaining root compatibility definitions for B3. + +## Task 4 B2 final integration RED (2026-08-29) + +- Added `matrix_receipt_policy_and_required_check_paths_are_nominally_identical` + to `tests/independent_verifier_contract.rs`, covering the six receipt/policy + nominal identities and source-boundary assertions. +- Exact command: `rtk env CARGO_TARGET_DIR=/private/tmp/ccp-independent-task4-b2 cargo test --locked --test independent_verifier_contract matrix_receipt_policy_and_required_check_paths_are_nominally_identical` +- Result: RED as required. Compilation reported six nominal type mismatches; + root duplicate definitions were identified at `src/matrix.rs` lines 288, + 295, 309, 401, 412, and 419. No production migration was completed in this + slice yet. +- GitNexus attempt was inconclusive: checkout is not indexed and the CLI + reported multiple unrelated indexed repositories. Bounded `rg`/line-number + inspection was used instead. + +## Scope + +- Added `MatrixReceiptEnvelopeV2`, `MatrixReceiptV2`, and + `MatrixRuntimeReceiptV2` with canonical seal/verify and semantic checks. +- Added `MatrixVerificationPolicyV2` and strict parsing/validation plus the + pure `verify_matrix_receipt_document` path. +- Added `MatrixContractError` variants for JSON, receipt-ID mismatch, + verification, and invalid evaluation time. +- Added `ccp_core::matrix_legacy` historical digest projection and + `ccp_core::schema` schema entry points. +- Did not remove root Matrix definitions or alter execution integration. + +## Verification + +- `rtk env CARGO_TARGET_DIR=/private/tmp/ccp-independent-target cargo test -p ccp-core --locked`: PASS (46 passed, 0 failed). +- `rtk git diff --check`: PASS. +- `rtk cargo fmt --all`: blocked by `Operation not permitted` writing `src/lib.rs`; source was manually kept formatter-compatible. + +## Commit + +Local commit: `refactor: add pure matrix receipt policy core`. +## B2 cleanup and focused verification + +- Removed the obsolete root legacy module and updated the source-boundary assertion to the core legacy implementation. +- Removed root-only imports and constants left unused after receipt/policy contract migration; test-only imports remain scoped to the unit-test module. +- Preserved the root schema compatibility bytes and mapped core receipt errors through the existing root adapter. +- Focused verification: identity 8/8, matrix contract 19/19, receipt contract 11/11, verification contract 21 passed/1 ignored, root matrix unit tests 5/5, ccp-core tests 46/46. +- cargo fmt all check and git diff check: PASS. + +## B2 final integration and independent controller verification + +- Replaced the six duplicate root receipt/policy definitions with nominal + `ccp_core::matrix` re-exports. Root execution and `MatrixError` remain + root-owned. +- Retained the public root schema and verification free functions as thin + compatibility wrappers; core `MatrixContractError` is converted through the + exhaustive root adapter. The policy-document dispatcher performs the same + explicit conversion while preserving its public error variant. +- Removed the duplicated root policy evaluator and the dead root legacy module; + the independent-verifier test proves all six cross-crate identities and the + absence of root look-alike definitions. +- Fresh controller command: + `rtk cargo test --locked --test independent_verifier_contract --test matrix_contract --test receipt_contract --test verification_contract && rtk cargo test --locked --lib matrix && rtk cargo test --locked -p ccp-core && rtk cargo fmt --all -- --check && rtk git diff --check`. +- Result: 59 passed plus 1 explicitly ignored across the four integration + suites; 5/5 root Matrix unit tests; 46/46 core tests; formatting and diff + checks PASS. diff --git a/CHANGELOG.md b/CHANGELOG.md index e9548e0..c445b7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,17 @@ Semantic Versioning after its first public release. ### Added -- Unix cache generations now preserve ordinary opaque payload links during inventory and reuse; strict control paths remain link-free, failed preparation owns cleanup before reuse, and native candidate qualification remains pending. +- Added the M2 physically independent verifier boundary: `ccp-core` owns the + protocol and pure verification contracts, while `ccp-verifier` is available + as a bounded local source build with `verify` and schema commands. This does + not claim a published binary, platform qualification, static distribution, + or producer identity; `verify-benchmark` remains in the root CLI and static + multi-platform distribution is deferred to M3. + +- Unix cache generations now preserve ordinary opaque payload links during + inventory and reuse; strict control paths remain link-free, failed + preparation owns cleanup before reuse, and native candidate qualification + remains pending. - Added the adoption-help issue-form source and a 1280 × 640 social-preview PNG upload candidate. GitHub form rendering/live chooser remains unverified until diff --git a/Cargo.lock b/Cargo.lock index 3167483..9698804 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,26 @@ dependencies = [ "objc2", ] +[[package]] +name = "ccp-core" +version = "0.1.0" +dependencies = [ + "schemars", + "serde", + "serde_json", + "sha2", + "toml", +] + +[[package]] +name = "ccp-verifier" +version = "0.1.0" +dependencies = [ + "ccp-core", + "clap", + "serde_json", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -98,6 +118,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" name = "commit-ci-preflight" version = "0.1.0" dependencies = [ + "ccp-core", "clap", "ctrlc", "fs2", diff --git a/Cargo.toml b/Cargo.toml index 0bdfc60..83ff84c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,13 @@ readme = "README.md" keywords = ["ci", "preflight", "containers", "evidence", "github-actions"] categories = ["command-line-utilities", "development-tools::testing"] +[workspace] +members = [".", "crates/ccp-core", "crates/ccp-verifier"] +default-members = ["."] +resolver = "3" + [dependencies] +ccp-core = { path = "crates/ccp-core" } clap = { version = "4.6.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] } ctrlc = { version = "=3.5.2", features = ["termination"] } fs2 = "=0.4.3" @@ -23,5 +29,8 @@ saphyr-parser = { version = "0.0.6", default-features = false } sha2 = { version = "0.11.0", default-features = false } toml = { version = "1.1.4", default-features = false, features = ["parse", "serde", "std"] } +[dev-dependencies] +ccp-core = { path = "crates/ccp-core" } + [target.'cfg(unix)'.dependencies] nix = { version = "=0.31.1", default-features = false, features = ["process", "signal"] } diff --git a/SBOM.spdx.json b/SBOM.spdx.json index 9580ce6..2400241 100644 --- a/SBOM.spdx.json +++ b/SBOM.spdx.json @@ -1,7 +1,7 @@ { "SPDXID": "SPDXRef-DOCUMENT", "creationInfo": { - "comment": "Generated from locked Cargo metadata; Cargo.lock SHA-256: 0955cbe3fe37d43df351ca9e90bc6fb6485ed1d3acdb015c5ad37c7294d21d66", + "comment": "Generated from locked Cargo metadata; Cargo.lock SHA-256: c766c0e70a6964f28097802db428100ca5f1b2d2f5e0da344df4148173ade804", "created": "2026-08-10T00:00:00Z", "creators": [ "Person: Marco Porcellato", @@ -9,7 +9,7 @@ ] }, "dataLicense": "CC0-1.0", - "documentNamespace": "https://github.com/MarcoPorcellato/commit-ci-preflight/sbom/0.1.0/0955cbe3fe37d43df351ca9e90bc6fb6485ed1d3acdb015c5ad37c7294d21d66", + "documentNamespace": "https://github.com/MarcoPorcellato/commit-ci-preflight/sbom/0.1.0/c766c0e70a6964f28097802db428100ca5f1b2d2f5e0da344df4148173ade804", "name": "commit-ci-preflight-0.1.0", "packages": [ { @@ -150,6 +150,40 @@ "name": "block2", "versionInfo": "0.6.2" }, + { + "SPDXID": "SPDXRef-Package-ccp-core-0.1.0", + "copyrightText": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:cargo/ccp-core@0.1.0", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "Apache-2.0", + "licenseDeclared": "Apache-2.0", + "name": "ccp-core", + "versionInfo": "0.1.0" + }, + { + "SPDXID": "SPDXRef-Package-ccp-verifier-0.1.0", + "copyrightText": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:cargo/ccp-verifier@0.1.0", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "Apache-2.0", + "licenseDeclared": "Apache-2.0", + "name": "ccp-verifier", + "versionInfo": "0.1.0" + }, { "SPDXID": "SPDXRef-Package-cfg-if-1.0.4", "checksums": [ @@ -1794,6 +1828,46 @@ "relationshipType": "DEPENDS_ON", "spdxElementId": "SPDXRef-Package-block2-0.6.2" }, + { + "relatedSpdxElement": "SPDXRef-Package-schemars-1.2.2", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Package-ccp-core-0.1.0" + }, + { + "relatedSpdxElement": "SPDXRef-Package-serde-1.0.229", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Package-ccp-core-0.1.0" + }, + { + "relatedSpdxElement": "SPDXRef-Package-serde-json-1.0.151", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Package-ccp-core-0.1.0" + }, + { + "relatedSpdxElement": "SPDXRef-Package-sha2-0.11.0", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Package-ccp-core-0.1.0" + }, + { + "relatedSpdxElement": "SPDXRef-Package-toml-1.1.4-spec-1.1.0", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Package-ccp-core-0.1.0" + }, + { + "relatedSpdxElement": "SPDXRef-Package-ccp-core-0.1.0", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Package-ccp-verifier-0.1.0" + }, + { + "relatedSpdxElement": "SPDXRef-Package-clap-4.6.6", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Package-ccp-verifier-0.1.0" + }, + { + "relatedSpdxElement": "SPDXRef-Package-serde-json-1.0.151", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Package-ccp-verifier-0.1.0" + }, { "relatedSpdxElement": "SPDXRef-Package-clap-builder-4.6.6", "relationshipType": "DEPENDS_ON", @@ -1834,6 +1908,11 @@ "relationshipType": "DEPENDS_ON", "spdxElementId": "SPDXRef-Package-clap-derive-4.6.4" }, + { + "relatedSpdxElement": "SPDXRef-Package-ccp-core-0.1.0", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Package-commit-ci-preflight-0.1.0" + }, { "relatedSpdxElement": "SPDXRef-Package-clap-4.6.6", "relationshipType": "DEPENDS_ON", diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 93d462c..6c6724d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -10,6 +10,8 @@ Generated deterministically from the locked Rust dependency graph for Commit CI | bitflags | 2.13.1 | The Rust Project Developers | MIT OR Apache-2.0 | https://github.com/bitflags/bitflags | b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da | | block-buffer | 0.12.1 | RustCrypto Developers | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa | | block2 | 0.6.2 | Mads Marquart | MIT | https://github.com/madsmtm/objc2 | cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5 | +| ccp-core | 0.1.0 | Not declared | Apache-2.0 | Not declared | Not present | +| ccp-verifier | 0.1.0 | Not declared | Apache-2.0 | Not declared | Not present | | cfg-if | 1.0.4 | Alex Crichton | MIT OR Apache-2.0 | https://github.com/rust-lang/cfg-if | 9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801 | | cfg_aliases | 0.2.2 | Zicklag | MIT | https://github.com/katharostech/cfg_aliases | f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527 | | clap | 4.6.6 | Not declared | MIT OR Apache-2.0 | https://github.com/clap-rs/clap | 473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca | @@ -86,6 +88,8 @@ Generated deterministically from the locked Rust dependency graph for Commit CI The following packaged crates declared a license expression but did not contain a UTF-8 file whose name begins with LICENSE, COPYING, NOTICE, or UNLICENSE. Consult the source link and declared expression above before redistribution. - block2 0.6.2 +- ccp-core 0.1.0 +- ccp-verifier 0.1.0 - dispatch2 0.3.1 - objc2 0.6.4 - objc2-encode 4.1.0 diff --git a/crates/ccp-core/Cargo.toml b/crates/ccp-core/Cargo.toml new file mode 100644 index 0000000..dce21c3 --- /dev/null +++ b/crates/ccp-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ccp-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.87" +description = "Core receipt and verification contracts for Commit CI Preflight." +license = "Apache-2.0" + +[dependencies] +schemars = "1.2.2" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = { version = "0.11.0", default-features = false } +toml = { version = "1.1.4", default-features = false, features = ["parse", "serde", "std"] } diff --git a/crates/ccp-core/src/canonical.rs b/crates/ccp-core/src/canonical.rs new file mode 100644 index 0000000..66f8630 --- /dev/null +++ b/crates/ccp-core/src/canonical.rs @@ -0,0 +1,43 @@ +use std::collections::BTreeMap; + +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::errors::ReceiptError; + +pub fn canonical_json(value: &T) -> Result, ReceiptError> { + let value = serde_json::to_value(value).map_err(ReceiptError::Serialization)?; + let normalized = normalize_json(value); + serde_json::to_vec(&normalized).map_err(ReceiptError::Serialization) +} + +pub fn canonical_digest(value: &T) -> Result { + let bytes = canonical_json(value)?; + let digest = Sha256::digest(bytes); + Ok(format!("{}{}", "sha256:", encode_hex(&digest))) +} + +fn normalize_json(value: Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.into_iter().map(normalize_json).collect()), + Value::Object(items) => { + let sorted: BTreeMap<_, _> = items + .into_iter() + .map(|(key, value)| (key, normalize_json(value))) + .collect(); + Value::Object(sorted.into_iter().collect()) + } + scalar => scalar, + } +} + +fn encode_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} diff --git a/crates/ccp-core/src/config.rs b/crates/ccp-core/src/config.rs new file mode 100644 index 0000000..28faf29 --- /dev/null +++ b/crates/ccp-core/src/config.rs @@ -0,0 +1,1689 @@ +// Copyright 2026 Marco Porcellato +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs; +use std::path::Path; + +use schemars::{JsonSchema, schema_for}; +use serde::{Deserialize, Serialize}; + +use crate::canonical::{canonical_digest, canonical_json}; +use crate::errors::ReceiptError; + +pub const CONFIG_SCHEMA_VERSION: &str = "1.0"; +pub const MAX_CONFIG_BYTES: usize = 1_048_576; +pub const MAX_CHECKS: usize = 128; +pub const MAX_CACHES: usize = 32; +pub const MAX_ARGV_PARTS: usize = 64; +pub const MAX_STRING_BYTES: usize = 4096; +pub const MAX_TIMEOUT_SECONDS: u64 = 86_400; +pub const MAX_MEMORY_MIB: u64 = 262_144; +pub const MAX_CPU_COUNT: u16 = 256; +pub const MAX_PIDS: u32 = 65_536; +pub const MAX_STORAGE_BYTES: u64 = 1_099_511_627_776; +const MIN_RECEIPT_JOURNAL_RESERVE_BYTES: u64 = 4_096; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ConfigV1 { + pub schema_version: String, + pub project: String, + pub runtime: RuntimeConfig, + #[serde(default)] + pub receipt: ReceiptConfig, + #[serde(default)] + pub environment: EnvironmentConfig, + #[serde(default)] + pub caches: Vec, + #[serde(default)] + pub storage: Option, + pub checks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeConfig { + pub kind: RuntimeKind, + pub image: String, + pub cpu_count: u16, + pub memory_mib: u64, + pub pids_limit: u32, + #[serde(default)] + pub network: bool, + #[serde(default)] + pub pull_policy: Option, + #[serde(default)] + pub swap_mode: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeKind { + DockerCompatible, + Host, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimePullPolicy { + Never, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeSwapMode { + Disabled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct ReceiptConfig { + pub output: String, + pub freshness_seconds: u64, +} + +impl Default for ReceiptConfig { + fn default() -> Self { + Self { + output: ".ccp/receipt.json".to_owned(), + freshness_seconds: 86_400, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct EnvironmentConfig { + pub allow: Vec, + pub fixed: BTreeMap, + pub runtime_internal: Vec, + pub remote_secret_only: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeInternalEnvironmentConfig { + pub name: String, + pub cache_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CacheConfig { + pub id: String, + pub mount_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct StorageConfig { + pub min_free_bytes: u64, + pub receipt_journal_reserve_bytes: u64, + pub max_cache_growth_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CheckConfig { + pub id: String, + pub required: bool, + pub argv: Vec, + pub working_directory: String, + pub timeout_seconds: u64, + #[serde(default)] + pub depends_on: Vec, + #[serde(default)] + pub artifacts: Vec, + #[serde(default)] + pub artifact_contracts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ArtifactContractConfig { + pub path: String, + pub kind: ArtifactKind, + pub max_bytes: u64, + pub max_entries: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ArtifactKind { + RegularFile, + Directory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExecutionPlanEnvelopeV1 { + pub plan_digest: String, + pub plan: ExecutionPlanV1, + #[serde(skip)] + pub fixed_environment: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ExecutionPlanV1 { + pub schema_version: String, + pub project: String, + pub runtime: NormalizedRuntime, + pub receipt: NormalizedReceipt, + pub environment: NormalizedEnvironment, + pub caches: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub storage: Option, + pub checks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedRuntime { + pub kind: RuntimeKind, + pub image: String, + pub cpu_count: u16, + pub memory_mib: u64, + pub pids_limit: u32, + pub network: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub pull_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub swap_mode: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedReceipt { + pub output: String, + pub freshness_seconds: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedEnvironment { + pub inherit: Vec, + pub fixed: Vec, + pub runtime_internal: Vec, + pub remote_secret_only: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedFixedEnvironment { + pub name: String, + pub value_digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedRuntimeInternalEnvironment { + pub name: String, + pub cache_id: String, + pub container_target: String, +} + +impl NormalizedEnvironment { + pub fn names(&self) -> Vec { + let mut names = self.inherit.clone(); + names.extend(self.fixed.iter().map(|binding| binding.name.clone())); + names.extend( + self.runtime_internal + .iter() + .map(|binding| binding.name.clone()), + ); + names.sort(); + names + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedCache { + pub id: String, + pub mount_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedStorage { + pub min_free_bytes: u64, + pub receipt_journal_reserve_bytes: u64, + pub max_cache_growth_bytes: u64, + pub max_artifact_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedCheck { + pub id: String, + pub required: bool, + pub argv: Vec, + pub working_directory: String, + pub timeout_seconds: u64, + pub depends_on: Vec, + pub artifacts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifact_contracts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedArtifactContract { + pub path: String, + pub kind: ArtifactKind, + pub max_bytes: u64, + pub max_entries: u64, + pub producer_check: String, +} + +impl ConfigV1 { + pub fn parse(input: &str) -> Result { + if input.len() > MAX_CONFIG_BYTES { + return Err(ConfigError::ConfigTooLarge { + actual: input.len(), + maximum: MAX_CONFIG_BYTES, + }); + } + toml::from_str(input).map_err(ConfigError::Parse) + } + + pub fn load(path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(|source| ConfigError::Io { + path: path.to_path_buf(), + source, + })?; + let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX); + if size > MAX_CONFIG_BYTES { + return Err(ConfigError::ConfigTooLarge { + actual: size, + maximum: MAX_CONFIG_BYTES, + }); + } + let input = fs::read_to_string(path).map_err(|source| ConfigError::Io { + path: path.to_path_buf(), + source, + })?; + Self::parse(&input) + } + + pub fn into_plan(self) -> Result { + self.validate_top_level()?; + let schema_version = self.schema_version.clone(); + let fixed_environment = self.environment.fixed.clone(); + let caches = normalize_caches(self.caches)?; + let runtime = normalize_runtime(&schema_version, self.runtime)?; + let environment = normalize_environment(&schema_version, self.environment, &caches)?; + let checks = normalize_checks(self.checks)?; + let storage = normalize_storage(&schema_version, self.storage, &checks)?; + let receipt = NormalizedReceipt { + output: self.receipt.output, + freshness_seconds: self.receipt.freshness_seconds, + }; + validate_path_isolation(&receipt, &caches, &checks)?; + let plan = ExecutionPlanV1 { + schema_version, + project: self.project, + runtime, + receipt, + environment, + caches, + storage, + checks, + }; + let plan_digest = canonical_digest(&plan).map_err(ConfigError::Receipt)?; + Ok(ExecutionPlanEnvelopeV1 { + plan_digest, + plan, + fixed_environment, + }) + } + + fn validate_top_level(&self) -> Result<(), ConfigError> { + if !matches!( + self.schema_version.as_str(), + CONFIG_SCHEMA_VERSION | "1.1" | "1.2" | "1.3" + ) { + return Err(ConfigError::UnsupportedSchemaVersion( + self.schema_version.clone(), + )); + } + validate_repository_identity(&self.project)?; + validate_image_reference(&self.runtime.image)?; + validate_bounded( + "runtime.cpu_count", + u64::from(self.runtime.cpu_count), + 1, + u64::from(MAX_CPU_COUNT), + )?; + validate_bounded( + "runtime.memory_mib", + self.runtime.memory_mib, + 64, + MAX_MEMORY_MIB, + )?; + validate_bounded( + "runtime.pids_limit", + u64::from(self.runtime.pids_limit), + 1, + u64::from(MAX_PIDS), + )?; + validate_relative_path("receipt.output", &self.receipt.output)?; + if self.receipt.output == "." { + return Err(ConfigError::InvalidField("receipt.output")); + } + validate_bounded( + "receipt.freshness_seconds", + self.receipt.freshness_seconds, + 1, + 31_536_000, + ) + } +} + +fn normalize_runtime( + schema_version: &str, + runtime: RuntimeConfig, +) -> Result { + let (pull_policy, swap_mode) = if schema_version == "1.3" { + ( + Some( + runtime + .pull_policy + .ok_or(ConfigError::MissingRuntimeCapabilityPolicy)?, + ), + Some( + runtime + .swap_mode + .ok_or(ConfigError::MissingRuntimeCapabilityPolicy)?, + ), + ) + } else { + if runtime.pull_policy.is_some() { + return Err(ConfigError::InvalidField("runtime.pull_policy")); + } + if runtime.swap_mode.is_some() { + return Err(ConfigError::InvalidField("runtime.swap_mode")); + } + (None, None) + }; + + Ok(NormalizedRuntime { + kind: runtime.kind, + image: runtime.image, + cpu_count: runtime.cpu_count, + memory_mib: runtime.memory_mib, + pids_limit: runtime.pids_limit, + network: runtime.network, + pull_policy, + swap_mode, + }) +} + +impl ExecutionPlanEnvelopeV1 { + pub fn canonical_bytes(&self) -> Result, ConfigError> { + let expected = canonical_digest(&self.plan).map_err(ConfigError::Receipt)?; + if self.plan_digest != expected { + return Err(ConfigError::PlanDigestMismatch); + } + canonical_json(self).map_err(ConfigError::Receipt) + } +} + +pub fn config_schema_json() -> Result { + let schema = schema_for!(ConfigV1); + serde_json::to_string_pretty(&schema) + .map_err(ReceiptError::Serialization) + .map_err(ConfigError::Receipt) +} + +fn normalize_caches(caches: Vec) -> Result, ConfigError> { + if caches.len() > MAX_CACHES { + return Err(ConfigError::TooManyItems { + field: "caches", + actual: caches.len(), + maximum: MAX_CACHES, + }); + } + let mut by_id = BTreeMap::new(); + let mut mount_paths = BTreeSet::new(); + for cache in caches { + validate_identifier("cache.id", &cache.id)?; + validate_relative_path("cache.mount_path", &cache.mount_path)?; + if cache.mount_path == "." { + return Err(ConfigError::InvalidField("cache.mount_path")); + } + if by_id.contains_key(&cache.id) { + return Err(ConfigError::DuplicateId { + field: "cache.id", + id: cache.id, + }); + } + if !mount_paths.insert(cache.mount_path.clone()) { + return Err(ConfigError::DuplicateValue("cache.mount_path")); + } + by_id.insert( + cache.id.clone(), + NormalizedCache { + id: cache.id, + mount_path: cache.mount_path, + }, + ); + } + Ok(by_id.into_values().collect()) +} + +fn normalize_checks(checks: Vec) -> Result, ConfigError> { + if checks.is_empty() { + return Err(ConfigError::NoChecks); + } + if checks.len() > MAX_CHECKS { + return Err(ConfigError::TooManyItems { + field: "checks", + actual: checks.len(), + maximum: MAX_CHECKS, + }); + } + if !checks.iter().any(|check| check.required) { + return Err(ConfigError::NoRequiredChecks); + } + + let mut by_id = BTreeMap::new(); + for mut check in checks { + validate_check(&check)?; + check.depends_on = unique_sorted("check.depends_on", check.depends_on, |value| { + validate_identifier("check.depends_on", value) + })?; + check.artifacts = unique_sorted("check.artifacts", check.artifacts, |value| { + validate_relative_path("check.artifacts", value) + })?; + if check.artifacts.iter().any(|artifact| artifact == ".") { + return Err(ConfigError::InvalidField("check.artifacts")); + } + validate_artifact_contracts(&check)?; + let id = check.id.clone(); + if by_id.insert(id.clone(), check).is_some() { + return Err(ConfigError::DuplicateId { + field: "check.id", + id, + }); + } + } + + validate_dependencies(&by_id)?; + topological_checks(&by_id) +} + +fn normalize_storage( + schema_version: &str, + storage: Option, + checks: &[NormalizedCheck], +) -> Result, ConfigError> { + if !matches!(schema_version, "1.2" | "1.3") { + if storage.is_some() { + return Err(ConfigError::InvalidField("storage")); + } + return Ok(None); + } + let storage = storage.ok_or(ConfigError::MissingStoragePolicy)?; + validate_bounded( + "storage.min_free_bytes", + storage.min_free_bytes, + 1, + MAX_STORAGE_BYTES, + )?; + validate_bounded( + "storage.receipt_journal_reserve_bytes", + storage.receipt_journal_reserve_bytes, + MIN_RECEIPT_JOURNAL_RESERVE_BYTES, + MAX_STORAGE_BYTES, + )?; + validate_bounded( + "storage.max_cache_growth_bytes", + storage.max_cache_growth_bytes, + 0, + MAX_STORAGE_BYTES, + )?; + let max_artifact_bytes = checks + .iter() + .flat_map(|check| check.artifact_contracts.iter()) + .try_fold(0_u64, |total, artifact| { + total.checked_add(artifact.max_bytes) + }) + .ok_or(ConfigError::InvalidField("storage.max_artifact_bytes"))?; + let required = storage + .min_free_bytes + .checked_add(storage.receipt_journal_reserve_bytes) + .and_then(|total| total.checked_add(storage.max_cache_growth_bytes)) + .and_then(|total| total.checked_add(max_artifact_bytes)) + .ok_or(ConfigError::InvalidField("storage"))?; + if required > MAX_STORAGE_BYTES { + return Err(ConfigError::OutOfRange { + field: "storage.required_free_bytes", + minimum: 1, + maximum: MAX_STORAGE_BYTES, + actual: required, + }); + } + Ok(Some(NormalizedStorage { + min_free_bytes: storage.min_free_bytes, + receipt_journal_reserve_bytes: storage.receipt_journal_reserve_bytes, + max_cache_growth_bytes: storage.max_cache_growth_bytes, + max_artifact_bytes, + })) +} + +fn validate_path_isolation( + receipt: &NormalizedReceipt, + caches: &[NormalizedCache], + checks: &[NormalizedCheck], +) -> Result<(), ConfigError> { + for (index, cache) in caches.iter().enumerate() { + for other in &caches[index + 1..] { + reject_path_overlap(&cache.mount_path, &other.mount_path)?; + } + reject_path_overlap(&cache.mount_path, &receipt.output)?; + } + + let mut artifacts: BTreeSet<&str> = BTreeSet::new(); + for check in checks { + for artifact in &check.artifacts { + if artifacts.contains(artifact.as_str()) { + return Err(ConfigError::DuplicateArtifact(artifact.clone())); + } + for other in &artifacts { + reject_path_overlap(artifact, other)?; + } + reject_path_overlap(artifact, &receipt.output)?; + for cache in caches { + reject_path_overlap(artifact, &cache.mount_path)?; + } + artifacts.insert(artifact.as_str()); + } + } + Ok(()) +} + +fn reject_path_overlap(first: &str, second: &str) -> Result<(), ConfigError> { + let overlap = first == second + || first + .strip_prefix(second) + .is_some_and(|suffix| suffix.starts_with('/')) + || second + .strip_prefix(first) + .is_some_and(|suffix| suffix.starts_with('/')); + if overlap { + Err(ConfigError::PathOverlap { + first: first.to_owned(), + second: second.to_owned(), + }) + } else { + Ok(()) + } +} + +fn validate_dependencies(by_id: &BTreeMap) -> Result<(), ConfigError> { + for check in by_id.values() { + for dependency in &check.depends_on { + if dependency == &check.id { + return Err(ConfigError::SelfDependency(check.id.clone())); + } + if !by_id.contains_key(dependency) { + return Err(ConfigError::UnknownDependency { + check: check.id.clone(), + dependency: dependency.clone(), + }); + } + } + } + Ok(()) +} + +fn topological_checks( + by_id: &BTreeMap, +) -> Result, ConfigError> { + let mut indegree: BTreeMap = by_id + .iter() + .map(|(id, check)| (id.clone(), check.depends_on.len())) + .collect(); + let mut dependents: BTreeMap> = BTreeMap::new(); + for (id, check) in by_id { + for dependency in &check.depends_on { + dependents + .entry(dependency.clone()) + .or_default() + .insert(id.clone()); + } + } + let mut ready: BTreeSet = indegree + .iter() + .filter(|(_, count)| **count == 0) + .map(|(id, _)| id.clone()) + .collect(); + let mut ordered = Vec::with_capacity(by_id.len()); + while let Some(id) = ready.pop_first() { + let check = by_id.get(&id).expect("ready check exists"); + ordered.push(normalize_check(check)); + if let Some(children) = dependents.get(&id) { + for child in children { + let count = indegree.get_mut(child).expect("dependent check exists"); + *count -= 1; + if *count == 0 { + ready.insert(child.clone()); + } + } + } + } + if ordered.len() != by_id.len() { + let cycle = indegree + .into_iter() + .filter(|(_, count)| *count > 0) + .map(|(id, _)| id) + .collect(); + return Err(ConfigError::DependencyCycle(cycle)); + } + Ok(ordered) +} + +fn validate_check(check: &CheckConfig) -> Result<(), ConfigError> { + validate_identifier("check.id", &check.id)?; + if check.argv.is_empty() || check.argv.len() > MAX_ARGV_PARTS { + return Err(ConfigError::InvalidField("check.argv")); + } + for argument in &check.argv { + validate_text("check.argv", argument)?; + } + validate_relative_path("check.working_directory", &check.working_directory)?; + validate_bounded( + "check.timeout_seconds", + check.timeout_seconds, + 1, + MAX_TIMEOUT_SECONDS, + ) +} + +fn normalize_check(check: &CheckConfig) -> NormalizedCheck { + NormalizedCheck { + id: check.id.clone(), + required: check.required, + argv: check.argv.clone(), + working_directory: check.working_directory.clone(), + timeout_seconds: check.timeout_seconds, + depends_on: check.depends_on.clone(), + artifacts: check.artifacts.clone(), + artifact_contracts: check + .artifact_contracts + .iter() + .map(|artifact| NormalizedArtifactContract { + path: artifact.path.clone(), + kind: artifact.kind, + max_bytes: artifact.max_bytes, + max_entries: artifact.max_entries, + producer_check: check.id.clone(), + }) + .collect(), + } +} + +fn validate_artifact_contracts(check: &CheckConfig) -> Result<(), ConfigError> { + let mut paths = BTreeSet::new(); + for artifact in &check.artifact_contracts { + validate_relative_path("check.artifact_contracts.path", &artifact.path)?; + if artifact.path == "." || !check.artifacts.contains(&artifact.path) { + return Err(ConfigError::InvalidField("check.artifact_contracts.path")); + } + if !paths.insert(&artifact.path) { + return Err(ConfigError::DuplicateValue("check.artifact_contracts.path")); + } + validate_bounded( + "check.artifact_contracts.max_bytes", + artifact.max_bytes, + 1, + 1_073_741_824, + )?; + let maximum_entries = match artifact.kind { + ArtifactKind::RegularFile => 1, + ArtifactKind::Directory => 10_000, + }; + validate_bounded( + "check.artifact_contracts.max_entries", + artifact.max_entries, + 1, + maximum_entries, + )?; + if artifact.kind == ArtifactKind::RegularFile && artifact.max_entries != 1 { + return Err(ConfigError::InvalidField( + "check.artifact_contracts.max_entries", + )); + } + } + Ok(()) +} + +fn unique_sorted( + field: &'static str, + values: Vec, + validate: F, +) -> Result, ConfigError> +where + F: Fn(&str) -> Result<(), ConfigError>, +{ + let mut unique = BTreeSet::new(); + for value in values { + validate(&value)?; + if !unique.insert(value) { + return Err(ConfigError::DuplicateValue(field)); + } + } + Ok(unique.into_iter().collect()) +} + +fn validate_text(field: &'static str, value: &str) -> Result<(), ConfigError> { + if value.is_empty() || value.len() > MAX_STRING_BYTES || value.chars().any(char::is_control) { + Err(ConfigError::InvalidField(field)) + } else { + Ok(()) + } +} + +pub fn validate_identifier(field: &'static str, value: &str) -> Result<(), ConfigError> { + validate_text(field, value)?; + if value.len() <= 64 + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + && value != "." + && value != ".." + { + Ok(()) + } else { + Err(ConfigError::InvalidField(field)) + } +} + +fn validate_environment_name(value: &str) -> Result<(), ConfigError> { + validate_text("environment.allow", value)?; + let mut characters = value.chars(); + let valid_start = characters + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()); + if valid_start + && characters.all(|character| character == '_' || character.is_ascii_alphanumeric()) + { + Ok(()) + } else { + Err(ConfigError::InvalidField("environment.allow")) + } +} + +fn normalize_environment( + schema_version: &str, + environment: EnvironmentConfig, + caches: &[NormalizedCache], +) -> Result { + let inherit = unique_sorted( + "environment.allow", + environment.allow, + validate_environment_name, + )?; + let remote_secret_only = unique_sorted( + "environment.remote_secret_only", + environment.remote_secret_only, + validate_environment_name, + )?; + if schema_version == CONFIG_SCHEMA_VERSION + && (!environment.fixed.is_empty() + || !environment.runtime_internal.is_empty() + || !remote_secret_only.is_empty()) + { + return Err(ConfigError::InvalidField("environment")); + } + if schema_version != CONFIG_SCHEMA_VERSION && !inherit.is_empty() { + return Err(ConfigError::InvalidField("environment.allow")); + } + + let mut names = BTreeSet::new(); + for name in &inherit { + names.insert(name.clone()); + } + let mut fixed = Vec::with_capacity(environment.fixed.len()); + for (name, value) in environment.fixed { + validate_environment_name(&name)?; + validate_text("environment.fixed", &value)?; + if !names.insert(name.clone()) { + return Err(ConfigError::DuplicateValue("environment")); + } + fixed.push(NormalizedFixedEnvironment { + value_digest: canonical_digest(&value).map_err(ConfigError::Receipt)?, + name, + }); + } + let cache_targets = caches + .iter() + .map(|cache| (cache.id.as_str(), cache.mount_path.as_str())) + .collect::>(); + let mut runtime_internal = Vec::with_capacity(environment.runtime_internal.len()); + for binding in environment.runtime_internal { + validate_environment_name(&binding.name)?; + validate_identifier("environment.runtime_internal.cache_id", &binding.cache_id)?; + if !names.insert(binding.name.clone()) { + return Err(ConfigError::DuplicateValue("environment")); + } + let mount_path = cache_targets + .get(binding.cache_id.as_str()) + .ok_or_else(|| ConfigError::UnknownEnvironmentCache { + name: binding.name.clone(), + cache_id: binding.cache_id.clone(), + })?; + runtime_internal.push(NormalizedRuntimeInternalEnvironment { + name: binding.name, + cache_id: binding.cache_id, + container_target: format!("/workspace/{mount_path}"), + }); + } + for name in &remote_secret_only { + if !names.insert(name.clone()) { + return Err(ConfigError::DuplicateValue("environment")); + } + } + fixed.sort_by(|left, right| left.name.cmp(&right.name)); + runtime_internal.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(NormalizedEnvironment { + inherit, + fixed, + runtime_internal, + remote_secret_only, + }) +} + +fn validate_repository_identity(value: &str) -> Result<(), ConfigError> { + let mut segments = value.split('/'); + let owner = segments.next().unwrap_or_default(); + let repository = segments.next().unwrap_or_default(); + validate_identifier("project", owner)?; + validate_identifier("project", repository)?; + if segments.next().is_some() { + Err(ConfigError::InvalidField("project")) + } else { + Ok(()) + } +} + +fn validate_relative_path(field: &'static str, value: &str) -> Result<(), ConfigError> { + validate_text(field, value)?; + let safe = value == "." + || (!value.starts_with('/') + && !value.starts_with('~') + && !value.contains('\\') + && !value.contains(':') + && value + .split('/') + .all(|segment| !segment.is_empty() && segment != "." && segment != "..")); + if safe { + Ok(()) + } else { + Err(ConfigError::InvalidField(field)) + } +} + +fn validate_image_reference(value: &str) -> Result<(), ConfigError> { + validate_text("runtime.image", value)?; + let Some((name, digest)) = value.rsplit_once('@') else { + return Err(ConfigError::InvalidField("runtime.image")); + }; + let Some(hex) = digest.strip_prefix("sha256:") else { + return Err(ConfigError::InvalidField("runtime.image")); + }; + if !name.is_empty() + && !name.chars().any(char::is_whitespace) + && !name.contains('@') + && !name.contains("://") + && !name.contains('\\') + && hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(ConfigError::InvalidField("runtime.image")) + } +} + +fn validate_bounded( + field: &'static str, + value: u64, + minimum: u64, + maximum: u64, +) -> Result<(), ConfigError> { + if (minimum..=maximum).contains(&value) { + Ok(()) + } else { + Err(ConfigError::OutOfRange { + field, + minimum, + maximum, + actual: value, + }) + } +} + +#[derive(Debug)] +pub enum ConfigError { + Io { + path: std::path::PathBuf, + source: std::io::Error, + }, + Parse(toml::de::Error), + Receipt(ReceiptError), + UnsupportedSchemaVersion(String), + ConfigTooLarge { + actual: usize, + maximum: usize, + }, + InvalidField(&'static str), + OutOfRange { + field: &'static str, + minimum: u64, + maximum: u64, + actual: u64, + }, + TooManyItems { + field: &'static str, + actual: usize, + maximum: usize, + }, + NoChecks, + NoRequiredChecks, + DuplicateId { + field: &'static str, + id: String, + }, + DuplicateValue(&'static str), + DuplicateArtifact(String), + PathOverlap { + first: String, + second: String, + }, + SelfDependency(String), + UnknownDependency { + check: String, + dependency: String, + }, + UnknownEnvironmentCache { + name: String, + cache_id: String, + }, + MissingStoragePolicy, + MissingRuntimeCapabilityPolicy, + DependencyCycle(Vec), + PlanDigestMismatch, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, source } => write!( + formatter, + "cannot read configuration {}: {source}", + path.display() + ), + Self::Parse(error) => write!(formatter, "invalid TOML configuration: {error}"), + Self::Receipt(error) => { + write!(formatter, "cannot canonicalize execution plan: {error}") + } + Self::UnsupportedSchemaVersion(version) => { + write!( + formatter, + "unsupported configuration schema version: {version}" + ) + } + Self::ConfigTooLarge { actual, maximum } => { + write!( + formatter, + "configuration is {actual} bytes; maximum is {maximum}" + ) + } + Self::InvalidField(field) => { + write!(formatter, "invalid configuration field: {field}") + } + Self::UnknownEnvironmentCache { name, cache_id } => write!( + formatter, + "runtime-internal environment {name} references unknown cache {cache_id}" + ), + Self::MissingStoragePolicy => { + write!( + formatter, + "schemas 1.2 and 1.3 require an explicit storage policy" + ) + } + Self::MissingRuntimeCapabilityPolicy => write!( + formatter, + "schema 1.3 requires pull_policy = never and swap_mode = disabled" + ), + Self::OutOfRange { + field, + minimum, + maximum, + actual, + } => write!( + formatter, + "configuration field {field} is {actual}; expected {minimum}..={maximum}" + ), + Self::TooManyItems { + field, + actual, + maximum, + } => write!( + formatter, + "configuration has {actual} {field}; maximum is {maximum}" + ), + Self::NoChecks => write!(formatter, "configuration contains no checks"), + Self::NoRequiredChecks => { + write!(formatter, "configuration contains no required checks") + } + Self::DuplicateId { field, id } => write!(formatter, "duplicate {field}: {id}"), + Self::DuplicateValue(field) => write!(formatter, "duplicate value in {field}"), + Self::DuplicateArtifact(path) => write!(formatter, "duplicate artifact path: {path}"), + Self::PathOverlap { first, second } => { + write!( + formatter, + "configuration paths overlap: {first} and {second}" + ) + } + Self::SelfDependency(check) => write!(formatter, "check depends on itself: {check}"), + Self::UnknownDependency { check, dependency } => { + write!( + formatter, + "check {check} depends on unknown check {dependency}" + ) + } + Self::DependencyCycle(checks) => write!( + formatter, + "check dependency cycle involves: {}", + checks.join(", ") + ), + Self::PlanDigestMismatch => write!(formatter, "execution plan digest mismatch"), + } + } +} + +impl std::error::Error for ConfigError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::Parse(error) => Some(error), + Self::Receipt(error) => Some(error), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const IMAGE: &str = "ghcr.io/example/ci@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn valid_config(checks: &str) -> String { + format!( + r#" +schema_version = "1.0" +project = "example/project" + +[runtime] +kind = "docker_compatible" +image = "{IMAGE}" +cpu_count = 4 +memory_mib = 4096 +pids_limit = 512 + +{checks} +"# + ) + } + + fn check(id: &str, dependencies: &[&str]) -> String { + let dependencies = dependencies + .iter() + .map(|dependency| format!("\"{dependency}\"")) + .collect::>() + .join(", "); + format!( + r#" +[[checks]] +id = "{id}" +required = true +argv = ["cargo", "test"] +working_directory = "." +timeout_seconds = 300 +depends_on = [{dependencies}] +"# + ) + } + + #[test] + fn equivalent_declaration_orders_produce_identical_plan_bytes() { + let first = valid_config(&(check("test", &["fmt"]) + &check("fmt", &[]))); + let second = valid_config(&(check("fmt", &[]) + &check("test", &["fmt"]))); + let first = ConfigV1::parse(&first) + .and_then(ConfigV1::into_plan) + .expect("first plan"); + let second = ConfigV1::parse(&second) + .and_then(ConfigV1::into_plan) + .expect("second plan"); + + assert_eq!(first, second); + assert_eq!( + first.canonical_bytes().expect("first bytes"), + second.canonical_bytes().expect("second bytes") + ); + assert_eq!(first.plan.checks[0].id, "fmt"); + assert_eq!(first.plan.checks[1].id, "test"); + } + + #[test] + fn dependency_cycles_are_rejected_deterministically() { + let input = valid_config(&(check("a", &["b"]) + &check("b", &["a"]))); + let error = ConfigV1::parse(&input) + .and_then(ConfigV1::into_plan) + .expect_err("cycle must fail"); + assert!(matches!( + error, + ConfigError::DependencyCycle(checks) if checks == ["a", "b"] + )); + } + + #[test] + fn unknown_and_self_dependencies_are_rejected() { + let unknown = valid_config(&check("test", &["missing"])); + assert!(matches!( + ConfigV1::parse(&unknown).and_then(ConfigV1::into_plan), + Err(ConfigError::UnknownDependency { .. }) + )); + + let own = valid_config(&check("test", &["test"])); + assert!(matches!( + ConfigV1::parse(&own).and_then(ConfigV1::into_plan), + Err(ConfigError::SelfDependency(id)) if id == "test" + )); + } + + #[test] + fn duplicate_check_ids_are_rejected() { + let input = valid_config(&(check("test", &[]) + &check("test", &[]))); + assert!(matches!( + ConfigV1::parse(&input).and_then(ConfigV1::into_plan), + Err(ConfigError::DuplicateId { + field: "check.id", + .. + }) + )); + } + + #[test] + fn unknown_toml_fields_are_rejected() { + let input = valid_config(&check("test", &[])) + "\nunknown = true\n"; + assert!(matches!( + ConfigV1::parse(&input), + Err(ConfigError::Parse(_)) + )); + } + + #[test] + fn unsafe_paths_and_unpinned_images_are_rejected() { + for path in ["../escape", "/tmp/output", r"C:\output", "nested//output"] { + let mut input = valid_config(&check("test", &[])); + input.push_str(&format!("\n[receipt]\noutput = '{path}'\n")); + assert!(matches!( + ConfigV1::parse(&input).and_then(ConfigV1::into_plan), + Err(ConfigError::InvalidField("receipt.output")) + )); + } + + let input = valid_config(&check("test", &[])).replace(IMAGE, "ghcr.io/example/ci:latest"); + assert!(matches!( + ConfigV1::parse(&input).and_then(ConfigV1::into_plan), + Err(ConfigError::InvalidField("runtime.image")) + )); + } + + #[test] + fn resource_limits_are_bounded() { + let input = + valid_config(&check("test", &[])).replace("memory_mib = 4096", "memory_mib = 0"); + assert!(matches!( + ConfigV1::parse(&input).and_then(ConfigV1::into_plan), + Err(ConfigError::OutOfRange { + field: "runtime.memory_mib", + .. + }) + )); + } + + #[test] + fn configuration_size_is_bounded_before_parsing() { + let input = "x".repeat(MAX_CONFIG_BYTES + 1); + assert!(matches!( + ConfigV1::parse(&input), + Err(ConfigError::ConfigTooLarge { .. }) + )); + } + + #[test] + fn environment_names_and_duplicate_values_are_rejected() { + let base = valid_config(&check("test", &[])); + let invalid = base.clone() + "\n[environment]\nallow = [\"VALID\", \"NOT-VALID\"]\n"; + assert!(matches!( + ConfigV1::parse(&invalid).and_then(ConfigV1::into_plan), + Err(ConfigError::InvalidField("environment.allow")) + )); + + let duplicate = base + "\n[environment]\nallow = [\"CI\", \"CI\"]\n"; + assert!(matches!( + ConfigV1::parse(&duplicate).and_then(ConfigV1::into_plan), + Err(ConfigError::DuplicateValue("environment.allow")) + )); + } + + #[test] + fn v1_1_environment_classes_normalize_without_host_inheritance() { + let input = r#" +schema_version = "1.1" +project = "owner/project" + +[runtime] +kind = "docker_compatible" +image = "registry.example/ci@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +cpu_count = 2 +memory_mib = 256 +pids_limit = 64 + +[environment] +remote_secret_only = ["DEPLOY_TOKEN"] + +[environment.fixed] +SOURCE_DATE_EPOCH = "0" + +[[environment.runtime_internal]] +name = "CARGO_HOME" +cache_id = "cargo-home" + +[[caches]] +id = "cargo-home" +mount_path = ".ccp-mounts/cargo-home" + +[[checks]] +id = "format" +required = true +argv = ["cargo", "fmt", "--check"] +working_directory = "." +timeout_seconds = 60 +"#; + + let plan = ConfigV1::parse(input) + .and_then(ConfigV1::into_plan) + .expect("v1.1 environment plan"); + + assert!(plan.plan.environment.inherit.is_empty()); + assert_eq!(plan.plan.environment.fixed.len(), 1); + assert_eq!(plan.plan.environment.runtime_internal.len(), 1); + assert_eq!( + plan.plan.environment.runtime_internal[0].container_target, + "/workspace/.ccp-mounts/cargo-home" + ); + assert_eq!( + plan.plan.environment.remote_secret_only, + vec!["DEPLOY_TOKEN".to_owned()] + ); + assert!( + !plan + .canonical_bytes() + .expect("public plan bytes") + .windows(b"SOURCE_DATE_EPOCH=0".len()) + .any(|window| window == b"SOURCE_DATE_EPOCH=0") + ); + } + + #[test] + fn v1_1_runtime_internal_unknown_cache_fails_closed() { + let input = valid_config(&check("format", &[])) + .replace("schema_version = \"1.0\"", "schema_version = \"1.1\"") + + "\n[[environment.runtime_internal]]\nname = \"CARGO_HOME\"\ncache_id = \"missing\"\n"; + assert!(matches!( + ConfigV1::parse(&input).and_then(ConfigV1::into_plan), + Err(ConfigError::UnknownEnvironmentCache { .. }) + )); + } + + #[test] + fn v1_2_storage_policy_is_explicit_and_normalized_into_the_plan() { + let input = valid_config(&check("format", &[])) + .replace("schema_version = \"1.0\"", "schema_version = \"1.2\"") + + r#" + +[storage] +min_free_bytes = 1073741824 +receipt_journal_reserve_bytes = 1048576 +max_cache_growth_bytes = 2147483648 +"#; + + let plan = ConfigV1::parse(&input) + .and_then(ConfigV1::into_plan) + .expect("v1.2 storage plan"); + + let storage = plan.plan.storage.expect("storage policy"); + assert_eq!(storage.min_free_bytes, 1_073_741_824); + assert_eq!(storage.receipt_journal_reserve_bytes, 1_048_576); + assert_eq!(storage.max_cache_growth_bytes, 2_147_483_648); + assert_eq!(storage.max_artifact_bytes, 0); + } + + #[test] + fn v1_2_storage_policy_is_required_and_bounded() { + let missing = valid_config(&check("format", &[])) + .replace("schema_version = \"1.0\"", "schema_version = \"1.2\""); + assert!(matches!( + ConfigV1::parse(&missing).and_then(ConfigV1::into_plan), + Err(ConfigError::MissingStoragePolicy) + )); + + let invalid = valid_config(&check("format", &[])) + .replace("schema_version = \"1.0\"", "schema_version = \"1.2\"") + + r#" + +[storage] +min_free_bytes = 0 +receipt_journal_reserve_bytes = 1048576 +max_cache_growth_bytes = 2147483648 +"#; + assert!(matches!( + ConfigV1::parse(&invalid).and_then(ConfigV1::into_plan), + Err(ConfigError::OutOfRange { + field: "storage.min_free_bytes", + .. + }) + )); + } + + #[test] + fn v1_2_storage_policy_changes_the_plan_digest() { + let base = valid_config(&check("format", &[])) + .replace("schema_version = \"1.0\"", "schema_version = \"1.2\"") + + r#" + +[storage] +min_free_bytes = 1073741824 +receipt_journal_reserve_bytes = 1048576 +max_cache_growth_bytes = 2147483648 +"#; + let changed = base.replace( + "max_cache_growth_bytes = 2147483648", + "max_cache_growth_bytes = 3221225472", + ); + + let first = ConfigV1::parse(&base) + .and_then(ConfigV1::into_plan) + .expect("first plan"); + let second = ConfigV1::parse(&changed) + .and_then(ConfigV1::into_plan) + .expect("second plan"); + + assert_ne!(first.plan_digest, second.plan_digest); + } + + #[test] + fn v1_3_requires_explicit_runtime_capability_policy() { + let input = valid_config(&check("format", &[])) + .replace("schema_version = \"1.0\"", "schema_version = \"1.3\"") + + r#" + +[storage] +min_free_bytes = 1073741824 +receipt_journal_reserve_bytes = 1048576 +max_cache_growth_bytes = 2147483648 +"#; + + assert!(matches!( + ConfigV1::parse(&input).and_then(ConfigV1::into_plan), + Err(ConfigError::MissingRuntimeCapabilityPolicy) + )); + } + + #[test] + fn v1_3_runtime_policy_changes_the_plan_digest() { + let base = valid_config(&check("format", &[])) + .replace("schema_version = \"1.0\"", "schema_version = \"1.3\"") + .replace( + "pids_limit = 512\n", + "pids_limit = 512\npull_policy = \"never\"\nswap_mode = \"disabled\"\n", + ) + + r#" + +[storage] +min_free_bytes = 1073741824 +receipt_journal_reserve_bytes = 1048576 +max_cache_growth_bytes = 2147483648 +"#; + let first = ConfigV1::parse(&base) + .and_then(ConfigV1::into_plan) + .expect("first schema 1.3 plan"); + let mut changed = first.plan.clone(); + changed.runtime.swap_mode = None; + + assert_ne!( + canonical_digest(&first.plan).expect("first digest"), + canonical_digest(&changed).expect("changed digest") + ); + } + + #[test] + fn v1_3_requires_storage_and_historical_schemas_reject_runtime_policy() { + let missing_storage = valid_config(&check("format", &[])) + .replace("schema_version = \"1.0\"", "schema_version = \"1.3\"") + .replace( + "pids_limit = 512\n", + "pids_limit = 512\npull_policy = \"never\"\nswap_mode = \"disabled\"\n", + ); + assert!(matches!( + ConfigV1::parse(&missing_storage).and_then(ConfigV1::into_plan), + Err(ConfigError::MissingStoragePolicy) + )); + + let historical = valid_config(&check("format", &[])).replace( + "pids_limit = 512\n", + "pids_limit = 512\npull_policy = \"never\"\nswap_mode = \"disabled\"\n", + ); + assert!(matches!( + ConfigV1::parse(&historical).and_then(ConfigV1::into_plan), + Err(ConfigError::InvalidField("runtime.pull_policy")) + )); + } + + #[test] + fn v1_2_storage_policy_derives_declared_artifact_allowance() { + let check = check("report", &[]).replace( + "depends_on = []", + r#"depends_on = [] +artifacts = ["results/report.json"] + +[[checks.artifact_contracts]] +path = "results/report.json" +kind = "regular-file" +max_bytes = 4096 +max_entries = 1"#, + ); + let input = valid_config(&check) + .replace("schema_version = \"1.0\"", "schema_version = \"1.2\"") + + r#" + +[storage] +min_free_bytes = 100 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 0 +"#; + + let plan = ConfigV1::parse(&input) + .and_then(ConfigV1::into_plan) + .expect("v1.2 storage plan"); + assert_eq!( + plan.plan + .storage + .expect("storage policy") + .max_artifact_bytes, + 4096 + ); + } + + #[test] + fn storage_policy_is_rejected_by_historical_schema_versions() { + let input = valid_config(&check("format", &[])) + .replace("schema_version = \"1.0\"", "schema_version = \"1.1\"") + + r#" + +[storage] +min_free_bytes = 1073741824 +receipt_journal_reserve_bytes = 1048576 +max_cache_growth_bytes = 0 +"#; + assert!(matches!( + ConfigV1::parse(&input).and_then(ConfigV1::into_plan), + Err(ConfigError::InvalidField("storage")) + )); + } + + #[test] + fn plan_digest_detects_mutation() { + let input = valid_config(&check("test", &[])); + let mut plan = ConfigV1::parse(&input) + .and_then(ConfigV1::into_plan) + .expect("plan"); + plan.plan.runtime.network = true; + assert!(matches!( + plan.canonical_bytes(), + Err(ConfigError::PlanDigestMismatch) + )); + } + + #[test] + fn at_least_one_required_check_is_mandatory() { + let input = + valid_config(&check("test", &[])).replace("required = true", "required = false"); + assert!(matches!( + ConfigV1::parse(&input).and_then(ConfigV1::into_plan), + Err(ConfigError::NoRequiredChecks) + )); + } + + #[test] + fn image_reference_cannot_embed_credentials_or_url_scheme() { + for image in [ + "user:password@registry.example/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "https://registry.example/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ] { + let input = valid_config(&check("test", &[])).replace(IMAGE, image); + assert!(matches!( + ConfigV1::parse(&input).and_then(ConfigV1::into_plan), + Err(ConfigError::InvalidField("runtime.image")) + )); + } + } + + #[test] + fn cache_receipt_and_artifact_paths_cannot_overlap() { + let cache_overlap = valid_config(&check("test", &[])) + + "\n[[caches]]\nid = \"first\"\nmount_path = \"cache\"\n" + + "\n[[caches]]\nid = \"second\"\nmount_path = \"cache/nested\"\n"; + assert!(matches!( + ConfigV1::parse(&cache_overlap).and_then(ConfigV1::into_plan), + Err(ConfigError::PathOverlap { .. }) + )); + + let receipt_overlap = valid_config(&check("test", &[])) + + "\n[[caches]]\nid = \"receipt\"\nmount_path = \".ccp\"\n"; + assert!(matches!( + ConfigV1::parse(&receipt_overlap).and_then(ConfigV1::into_plan), + Err(ConfigError::PathOverlap { .. }) + )); + + let duplicate_artifact = valid_config( + &(check("first", &[]).replace( + "depends_on = []", + "depends_on = []\nartifacts = [\"build/output\"]", + ) + &check("second", &[]).replace( + "depends_on = []", + "depends_on = []\nartifacts = [\"build/output\"]", + )), + ); + assert!(matches!( + ConfigV1::parse(&duplicate_artifact).and_then(ConfigV1::into_plan), + Err(ConfigError::DuplicateArtifact(path)) if path == "build/output" + )); + + let nested_artifacts = valid_config( + &(check("parent", &[]).replace( + "depends_on = []", + "depends_on = []\nartifacts = [\"build/reports\"]", + ) + &check("child", &[]).replace( + "depends_on = []", + "depends_on = []\nartifacts = [\"build/reports/result.json\"]", + )), + ); + assert!(matches!( + ConfigV1::parse(&nested_artifacts).and_then(ConfigV1::into_plan), + Err(ConfigError::PathOverlap { .. }) + )); + } + + #[test] + fn artifact_contract_requires_a_bounded_regular_file_owned_by_its_check() { + let input = valid_config(&check("test", &[])).replace( + "depends_on = []", + "depends_on = []\nartifacts = [\"results/report.json\"]\n\n[[checks.artifact_contracts]]\npath = \"results/report.json\"\nkind = \"regular-file\"\nmax_bytes = 1048576\nmax_entries = 1", + ); + let plan = ConfigV1::parse(&input) + .and_then(ConfigV1::into_plan) + .expect("artifact contract plan"); + assert_eq!(plan.plan.checks[0].artifact_contracts.len(), 1); + assert_eq!( + plan.plan.checks[0].artifact_contracts[0].producer_check, + "test" + ); + } + + #[test] + fn artifact_contract_rejects_undeclared_paths_and_unbounded_directory_shape() { + let base = valid_config(&check("test", &[])).replace( + "depends_on = []", + "depends_on = []\nartifacts = [\"results\"]\n\n[[checks.artifact_contracts]]\npath = \"other\"\nkind = \"regular-file\"\nmax_bytes = 1\nmax_entries = 1", + ); + assert!(matches!( + ConfigV1::parse(&base).and_then(ConfigV1::into_plan), + Err(ConfigError::InvalidField("check.artifact_contracts.path")) + )); + + let directory = base + .replace("path = \"other\"", "path = \"results\"") + .replace("kind = \"regular-file\"", "kind = \"directory\"") + .replace("max_entries = 1", "max_entries = 10001"); + assert!(matches!( + ConfigV1::parse(&directory).and_then(ConfigV1::into_plan), + Err(ConfigError::OutOfRange { + field: "check.artifact_contracts.max_entries", + .. + }) + )); + } +} diff --git a/crates/ccp-core/src/errors.rs b/crates/ccp-core/src/errors.rs new file mode 100644 index 0000000..4f3765d --- /dev/null +++ b/crates/ccp-core/src/errors.rs @@ -0,0 +1,272 @@ +use std::fmt; +use std::io; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::ConfigError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EvidenceStatus { + Pass, + Fail, + Pending, + NotRun, +} + +#[derive(Debug)] +pub enum ReceiptError { + Serialization(serde_json::Error), + UnsupportedSchemaVersion(String), + UnsupportedSourceSnapshotSchemaVersion(String), + EmptyField(&'static str), + ControlCharacter(&'static str), + InvalidCommitSha(String), + InvalidRepositoryIdentity, + InvalidSha256(&'static str), + InvalidTimestamp(&'static str), + InvalidRunWindow, + ImageDigestMismatch, + UnsafePath(&'static str), + NoChecks, + NoRequiredChecks, + DuplicateCheckId(String), + InvalidCommand(String), + InvalidCheckResult(String), + MissingIncompleteReason(&'static str), + UnexpectedIncompleteReason(&'static str), + InvalidSourceSnapshotEntryCount(u64), + OverallStatusMismatch { + expected: EvidenceStatus, + actual: EvidenceStatus, + }, + DigestMismatch { + expected: String, + actual: String, + }, + ExecutionPlanDigestMismatch { + expected: String, + actual: String, + }, + ExecutionPlanCheckMismatch(String), + DuplicateArtifactEvidence(String), + ArtifactManifestMismatch(String), + MissingRuntimeCapabilityEvidence, + UnexpectedRuntimeCapabilityEvidence, + InvalidRuntimeCapabilityEvidence(&'static str), + RuntimeCapabilityEvidenceMismatch, +} + +impl fmt::Display for ReceiptError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Serialization(error) => { + write!(formatter, "receipt serialization failed: {error}") + } + Self::UnsupportedSchemaVersion(version) => { + write!(formatter, "unsupported receipt schema version: {version}") + } + Self::UnsupportedSourceSnapshotSchemaVersion(version) => { + write!( + formatter, + "unsupported source snapshot schema version: {version}" + ) + } + Self::EmptyField(field) => write!(formatter, "receipt field is empty: {field}"), + Self::ControlCharacter(field) => { + write!( + formatter, + "receipt field contains a control character: {field}" + ) + } + Self::InvalidCommitSha(value) => write!(formatter, "invalid commit SHA: {value}"), + Self::InvalidRepositoryIdentity => write!(formatter, "invalid repository identity"), + Self::InvalidSha256(field) => write!(formatter, "invalid SHA-256 value: {field}"), + Self::InvalidTimestamp(field) => write!(formatter, "invalid UTC timestamp: {field}"), + Self::InvalidRunWindow => write!(formatter, "receipt run finishes before it starts"), + Self::ImageDigestMismatch => { + write!(formatter, "image reference is not pinned to image digest") + } + Self::UnsafePath(field) => write!(formatter, "unsafe receipt path: {field}"), + Self::NoChecks => write!(formatter, "receipt contains no checks"), + Self::NoRequiredChecks => write!(formatter, "receipt contains no required checks"), + Self::DuplicateCheckId(id) => write!(formatter, "duplicate check ID: {id}"), + Self::InvalidCommand(id) => write!(formatter, "invalid command for check: {id}"), + Self::InvalidCheckResult(id) => { + write!(formatter, "inconsistent result for check: {id}") + } + Self::MissingIncompleteReason(field) => { + write!(formatter, "missing incomplete reason: {field}") + } + Self::UnexpectedIncompleteReason(field) => { + write!(formatter, "unexpected incomplete reason: {field}") + } + Self::InvalidSourceSnapshotEntryCount(count) => { + write!(formatter, "invalid source snapshot entry count: {count}") + } + Self::OverallStatusMismatch { expected, actual } => write!( + formatter, + "overall status mismatch: expected {expected:?}, found {actual:?}" + ), + Self::DigestMismatch { expected, actual } => { + write!( + formatter, + "receipt digest mismatch: expected {expected}, found {actual}" + ) + } + Self::ExecutionPlanDigestMismatch { expected, actual } => write!( + formatter, + "receipt execution plan digest mismatch: expected {expected}, found {actual}" + ), + Self::ExecutionPlanCheckMismatch(id) => { + write!( + formatter, + "receipt evidence does not match execution plan check: {id}" + ) + } + Self::DuplicateArtifactEvidence(path) => { + write!(formatter, "duplicate artifact evidence path: {path}") + } + Self::ArtifactManifestMismatch(path) => { + write!( + formatter, + "artifact evidence does not match the execution plan: {path}" + ) + } + Self::MissingRuntimeCapabilityEvidence => { + write!( + formatter, + "schema 1.3 receipt lacks runtime capability evidence" + ) + } + Self::UnexpectedRuntimeCapabilityEvidence => write!( + formatter, + "historical receipt unexpectedly contains runtime capability evidence" + ), + Self::InvalidRuntimeCapabilityEvidence(field) => { + write!(formatter, "runtime capability evidence is invalid: {field}") + } + Self::RuntimeCapabilityEvidenceMismatch => write!( + formatter, + "runtime capability evidence does not match the execution plan" + ), + } + } +} + +impl std::error::Error for ReceiptError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Serialization(error) => Some(error), + _ => None, + } + } +} + +#[derive(Debug)] +pub enum PolicyError { + Io(io::Error), + TooLarge, + InvalidUtf8, + Parse(toml::de::Error), + UnsupportedSchemaVersion, + InvalidField(&'static str), + DuplicateValue(&'static str), +} + +impl fmt::Display for PolicyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(_) => f.write_str("cannot read verification policy"), + Self::TooLarge => f.write_str("verification policy exceeds size limit"), + Self::InvalidUtf8 => f.write_str("verification policy is not UTF-8"), + Self::Parse(_) => f.write_str("verification policy is not valid strict TOML"), + Self::UnsupportedSchemaVersion => { + f.write_str("verification policy schema version is unsupported") + } + Self::InvalidField(field) => write!(f, "invalid policy field: {field}"), + Self::DuplicateValue(field) => write!(f, "duplicate policy value: {field}"), + } + } +} +impl std::error::Error for PolicyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + Self::Parse(e) => Some(e), + _ => None, + } + } +} + +#[derive(Debug)] +pub enum TrustedPlanError { + PolicyPath, + Io(io::Error), + UnsafeConfigurationPath, + Config(ConfigError), +} +impl fmt::Display for TrustedPlanError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PolicyPath => f.write_str("trusted policy path has no parent directory"), + Self::Io(_) => f.write_str("cannot read trusted configuration"), + Self::UnsafeConfigurationPath => { + f.write_str("trusted configuration path is not a regular local file") + } + Self::Config(e) => write!(f, "trusted configuration is invalid: {e}"), + } + } +} +impl std::error::Error for TrustedPlanError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + Self::Config(e) => Some(e), + _ => None, + } + } +} + +#[derive(Debug)] +pub enum VerificationError { + Policy(PolicyError), + PolicyDocument(String), + TrustedPlan(TrustedPlanError), + TrustedPolicyPathRequired, + InvalidExpectedCommit, + InvalidEvaluationTime, + Receipt(ReceiptError), + Matrix(String), +} +impl fmt::Display for VerificationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Policy(e) => write!(f, "{e}"), + Self::PolicyDocument(e) => write!(f, "{e}"), + Self::TrustedPlan(e) => write!(f, "{e}"), + Self::TrustedPolicyPathRequired => { + f.write_str("trusted-plan policy verification requires the policy file path") + } + Self::InvalidExpectedCommit => { + f.write_str("expected commit must be lowercase Git SHA-1 or SHA-256") + } + Self::InvalidEvaluationTime => { + f.write_str("verification time is not representable as strict UTC") + } + Self::Receipt(_) => f.write_str("verification report serialization failed"), + Self::Matrix(e) => write!(f, "matrix verification failed: {e}"), + } + } +} +impl std::error::Error for VerificationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Policy(e) => Some(e), + Self::TrustedPlan(e) => Some(e), + Self::Receipt(e) => Some(e), + _ => None, + } + } +} diff --git a/crates/ccp-core/src/lib.rs b/crates/ccp-core/src/lib.rs new file mode 100644 index 0000000..6176bc9 --- /dev/null +++ b/crates/ccp-core/src/lib.rs @@ -0,0 +1,11 @@ +//! Core protocol contracts for Commit CI Preflight. +pub mod canonical; +pub mod config; +pub mod errors; +pub mod matrix; +pub mod matrix_legacy; +pub mod receipt; +pub mod runtime_evidence; +pub mod schema; +pub mod verification_model; +pub mod verify; diff --git a/crates/ccp-core/src/matrix.rs b/crates/ccp-core/src/matrix.rs new file mode 100644 index 0000000..5b80224 --- /dev/null +++ b/crates/ccp-core/src/matrix.rs @@ -0,0 +1,963 @@ +//! Pure Matrix V2 configuration and plan contracts. +//! +//! This module intentionally contains no execution, runtime, cache, or Docker +//! dependencies. It is the protocol nucleus consumed by independent tools. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs; +use std::path::Path; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::canonical::{canonical_digest, canonical_json}; +use crate::config::{ + CacheConfig, CheckConfig, ConfigError, ConfigV1, EnvironmentConfig, ExecutionPlanEnvelopeV1, + ExecutionPlanV1, NormalizedCache, NormalizedCheck, NormalizedEnvironment, NormalizedReceipt, + NormalizedRuntime, ReceiptConfig, RuntimeConfig, RuntimeKind, validate_identifier, +}; +use crate::errors::{EvidenceStatus, ReceiptError}; +use crate::verification_model::{ + AcceptedPlatformV1, VerificationDecision, VerificationFindingV1, VerificationReportV1, + VerificationStatus, finding, parse_utc_seconds, validate_commit, +}; + +pub const MATRIX_RECEIPT_SCHEMA_VERSION: &str = "2.0"; +pub const MATRIX_POLICY_SCHEMA_VERSION: &str = "2.0"; + +pub const MATRIX_CONFIG_SCHEMA_VERSION: &str = "2.0"; +const MAX_MATRIX_RUNTIMES: usize = 32; +const MAX_MATRIX_BYTES: usize = 1_048_576; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MatrixConfigV2 { + pub schema_version: String, + pub project: String, + pub runtimes: Vec, + #[serde(default)] + pub receipt: ReceiptConfig, + #[serde(default)] + pub environment: MatrixEnvironmentConfigV2, + #[serde(default)] + pub caches: Vec, + pub checks: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +#[schemars(rename = "EnvironmentConfig")] +pub struct MatrixEnvironmentConfigV2 { + pub allow: Vec, +} + +impl MatrixEnvironmentConfigV2 { + fn as_v1(&self) -> EnvironmentConfig { + EnvironmentConfig { + allow: self.allow.clone(), + ..EnvironmentConfig::default() + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MatrixRuntimeConfigV2 { + pub id: String, + pub kind: RuntimeKind, + pub image: String, + pub cpu_count: u16, + pub memory_mib: u64, + pub pids_limit: u32, + #[serde(default)] + pub network: bool, +} +impl MatrixRuntimeConfigV2 { + fn as_runtime(&self) -> RuntimeConfig { + RuntimeConfig { + kind: self.kind, + image: self.image.clone(), + cpu_count: self.cpu_count, + memory_mib: self.memory_mib, + pids_limit: self.pids_limit, + network: self.network, + pull_policy: None, + swap_mode: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MatrixCheckConfigV2 { + pub id: String, + pub runtime_id: String, + pub required: bool, + pub argv: Vec, + pub working_directory: String, + pub timeout_seconds: u64, + #[serde(default)] + pub depends_on: Vec, + #[serde(default)] + pub artifacts: Vec, +} +impl MatrixCheckConfigV2 { + fn as_v1(&self) -> CheckConfig { + CheckConfig { + id: self.id.clone(), + required: self.required, + argv: self.argv.clone(), + working_directory: self.working_directory.clone(), + timeout_seconds: self.timeout_seconds, + depends_on: self.depends_on.clone(), + artifacts: self.artifacts.clone(), + artifact_contracts: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MatrixPlanEnvelopeV2 { + pub plan_digest: String, + pub plan: MatrixPlanV2, + #[serde(skip)] + profile: MatrixPlanProfile, + #[serde(skip)] + legacy_basis: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum MatrixPlanProfile { + #[default] + CurrentV2, + LegacyV1, +} + +impl MatrixPlanProfile { + pub const fn producer_version(self) -> &'static str { + match self { + Self::CurrentV2 => env!("CARGO_PKG_VERSION"), + Self::LegacyV1 => concat!(env!("CARGO_PKG_VERSION"), "+matrix-v2-legacy-v1"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MatrixPlanV2 { + pub schema_version: String, + pub project: String, + pub receipt: NormalizedReceipt, + pub environment: NormalizedEnvironment, + pub caches: Vec, + pub runtimes: Vec, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MatrixRuntimePlanV2 { + pub id: String, + pub configuration_digest: String, + pub runtime: NormalizedRuntime, + pub checks: Vec, +} + +impl MatrixConfigV2 { + pub fn parse(input: &str) -> Result { + if input.len() > MAX_MATRIX_BYTES { + return Err(MatrixContractError::ConfigTooLarge); + }; + toml::from_str(input).map_err(MatrixContractError::Parse) + } + pub fn load(path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(MatrixContractError::Io)?; + if metadata.len() > MAX_MATRIX_BYTES as u64 { + return Err(MatrixContractError::ConfigTooLarge); + }; + Self::parse(&fs::read_to_string(path).map_err(MatrixContractError::Io)?) + } + pub fn into_plan(self) -> Result { + build_matrix_plan(self) + } +} +pub fn build_matrix_plan( + config: MatrixConfigV2, +) -> Result { + build_matrix_plan_with_profile(config, MatrixPlanProfile::CurrentV2) +} + +pub fn build_matrix_plan_with_profile( + config: MatrixConfigV2, + profile: MatrixPlanProfile, +) -> Result { + if config.schema_version != MATRIX_CONFIG_SCHEMA_VERSION { + return Err(MatrixContractError::UnsupportedSchemaVersion( + config.schema_version, + )); + } + if !(2..=MAX_MATRIX_RUNTIMES).contains(&config.runtimes.len()) { + return Err(MatrixContractError::InvalidField("runtimes")); + } + let mut runtimes = BTreeMap::new(); + for runtime in config.runtimes { + validate_identifier("runtimes.id", &runtime.id).map_err(MatrixContractError::Config)?; + if runtimes.insert(runtime.id.clone(), runtime).is_some() { + return Err(MatrixContractError::DuplicateValue("runtimes.id")); + } + } + let mut ids = BTreeMap::new(); + let mut grouped: BTreeMap> = BTreeMap::new(); + for check in &config.checks { + validate_identifier("checks.runtime_id", &check.runtime_id) + .map_err(MatrixContractError::Config)?; + if !runtimes.contains_key(&check.runtime_id) { + return Err(MatrixContractError::UnknownRuntime( + check.runtime_id.clone(), + )); + } + if ids + .insert(check.id.clone(), check.runtime_id.clone()) + .is_some() + { + return Err(MatrixContractError::DuplicateValue("checks.id")); + } + grouped + .entry(check.runtime_id.clone()) + .or_default() + .push(check.as_v1()); + } + for check in &config.checks { + for dependency in &check.depends_on { + if let Some(runtime) = ids.get(dependency) + && runtime != &check.runtime_id + { + return Err(MatrixContractError::CrossRuntimeDependency { + check: check.id.clone(), + dependency: dependency.clone(), + }); + } + } + } + let mut plans = Vec::new(); + let mut shared = None; + for (id, runtime) in runtimes { + let checks = grouped.remove(&id).unwrap_or_default(); + if !checks.iter().any(|c| c.required) { + return Err(MatrixContractError::RuntimeWithoutRequiredCheck(id)); + } + let group = ConfigV1 { + schema_version: "1.0".into(), + project: config.project.clone(), + runtime: runtime.as_runtime(), + receipt: config.receipt.clone(), + environment: config.environment.as_v1(), + caches: config.caches.clone(), + storage: None, + checks, + } + .into_plan() + .map_err(MatrixContractError::Config)?; + if shared.is_none() { + shared = Some(( + group.plan.receipt.clone(), + group.plan.environment.clone(), + group.plan.caches.clone(), + )); + } + plans.push(MatrixRuntimePlanV2 { + id, + configuration_digest: group.plan_digest, + runtime: group.plan.runtime, + checks: group.plan.checks, + }); + } + let (receipt, environment, caches) = shared.ok_or(MatrixContractError::InvalidReceipt)?; + let plan = MatrixPlanV2 { + schema_version: MATRIX_CONFIG_SCHEMA_VERSION.into(), + project: config.project, + receipt, + environment, + caches, + runtimes: plans, + }; + let legacy_basis = if profile == MatrixPlanProfile::LegacyV1 { + let basis = crate::matrix_legacy::project_legacy_basis(&plan)?; + let mut plan = plan; + for runtime in &mut plan.runtimes { + runtime.configuration_digest = basis.runtime_digest(&runtime.id)?.to_owned(); + } + let digest = basis.outer_digest()?; + return Ok(MatrixPlanEnvelopeV2 { + plan_digest: digest, + plan, + profile, + legacy_basis: Some(basis), + }); + } else { + None + }; + let digest = canonical_digest(&plan).map_err(MatrixContractError::Receipt)?; + Ok(MatrixPlanEnvelopeV2 { + plan_digest: digest, + plan, + profile, + legacy_basis, + }) +} +impl MatrixPlanEnvelopeV2 { + pub fn profile(&self) -> MatrixPlanProfile { + self.profile + } + pub fn plan_digest(&self) -> Result<&str, MatrixContractError> { + self.validate_profile_binding()?; + Ok(&self.plan_digest) + } + pub fn runtime_configuration_digest(&self, id: &str) -> Result<&str, MatrixContractError> { + self.validate_profile_binding()?; + match self.profile { + MatrixPlanProfile::LegacyV1 => self + .legacy_basis + .as_ref() + .ok_or(MatrixContractError::PlanDigestMismatch)? + .runtime_digest(id), + MatrixPlanProfile::CurrentV2 => self + .plan + .runtimes + .iter() + .find(|r| r.id == id) + .map(|r| r.configuration_digest.as_str()) + .ok_or_else(|| MatrixContractError::UnknownRuntime(id.into())), + } + } + pub fn validate(&self) -> Result<(), MatrixContractError> { + if canonical_digest(&self.plan).map_err(MatrixContractError::Receipt)? != self.plan_digest { + return Err(MatrixContractError::PlanDigestMismatch); + } + Ok(()) + } + pub fn canonical_bytes(&self) -> Result, MatrixContractError> { + self.validate()?; + canonical_json(self).map_err(MatrixContractError::Receipt) + } + + /// Project each runtime into the v1 execution envelope consumed by the + /// runner. This is intentionally a pure operation: the independent + /// verifier can expose the same plan contract without linking execution. + pub fn runtime_envelopes( + &self, + ) -> Result, MatrixContractError> { + self.validate_profile_binding()?; + self.plan + .runtimes + .iter() + .map(|runtime| { + let plan = ExecutionPlanV1 { + schema_version: "1.0".to_owned(), + project: self.plan.project.clone(), + runtime: runtime.runtime.clone(), + receipt: self.plan.receipt.clone(), + environment: self.plan.environment.clone(), + caches: self.plan.caches.clone(), + storage: None, + checks: runtime.checks.clone(), + }; + let plan_digest = match self.profile { + MatrixPlanProfile::CurrentV2 => { + canonical_digest(&plan).map_err(MatrixContractError::Receipt)? + } + MatrixPlanProfile::LegacyV1 => { + self.runtime_configuration_digest(&runtime.id)?.to_owned() + } + }; + if self.profile == MatrixPlanProfile::CurrentV2 + && plan_digest != runtime.configuration_digest + { + return Err(MatrixContractError::PlanDigestMismatch); + } + Ok(( + runtime.id.clone(), + ExecutionPlanEnvelopeV1 { + plan_digest, + plan, + fixed_environment: BTreeMap::new(), + }, + )) + }) + .collect() + } + + pub fn legacy_digest_basis_value( + &self, + ) -> Result, MatrixContractError> { + self.validate_profile_binding()?; + match self.profile { + MatrixPlanProfile::CurrentV2 => Ok(None), + MatrixPlanProfile::LegacyV1 => self + .legacy_basis + .as_ref() + .ok_or(MatrixContractError::PlanDigestMismatch)? + .report_value() + .map(Some), + } + } + + pub fn validate_profile_binding(&self) -> Result<(), MatrixContractError> { + match self.profile { + MatrixPlanProfile::CurrentV2 => self.validate(), + MatrixPlanProfile::LegacyV1 => { + let basis = self + .legacy_basis + .as_ref() + .ok_or(MatrixContractError::PlanDigestMismatch)?; + let projected = crate::matrix_legacy::project_legacy_basis(&self.plan)?; + if &projected != basis || projected.outer_digest()? != self.plan_digest { + return Err(MatrixContractError::PlanDigestMismatch); + } + for runtime in &self.plan.runtimes { + if basis.runtime_digest(&runtime.id)? != runtime.configuration_digest { + return Err(MatrixContractError::PlanDigestMismatch); + } + } + Ok(()) + } + } + } +} + +#[derive(Debug)] +pub enum MatrixContractError { + Io(std::io::Error), + Json(serde_json::Error), + Parse(toml::de::Error), + Config(ConfigError), + Receipt(ReceiptError), + UnsupportedSchemaVersion(String), + ConfigTooLarge, + InvalidField(&'static str), + DuplicateValue(&'static str), + UnknownRuntime(String), + RuntimeWithoutRequiredCheck(String), + CrossRuntimeDependency { check: String, dependency: String }, + PlanDigestMismatch, + InvalidReceipt, + ReceiptIdMismatch, + Verification(crate::errors::VerificationError), + InvalidEvaluationTime, + LegacyPlanNotRepresentable(&'static str), +} +impl fmt::Display for MatrixContractError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "matrix I/O failed: {e}"), + Self::Json(e) => write!(f, "matrix JSON failed: {e}"), + Self::Parse(e) => write!(f, "matrix configuration parse failed: {e}"), + Self::Config(e) => write!(f, "matrix configuration invalid: {e}"), + Self::Receipt(e) => write!(f, "matrix receipt invalid: {e}"), + Self::UnsupportedSchemaVersion(v) => { + write!(f, "unsupported matrix schema version: {v}") + } + Self::ConfigTooLarge => { + f.write_str("matrix configuration exceeds the bounded input size") + } + Self::InvalidField(v) => write!(f, "invalid matrix field: {v}"), + Self::DuplicateValue(v) => write!(f, "duplicate matrix value: {v}"), + Self::UnknownRuntime(v) => write!(f, "matrix check references unknown runtime: {v}"), + Self::RuntimeWithoutRequiredCheck(v) => { + write!(f, "matrix runtime has no required check: {v}") + } + Self::CrossRuntimeDependency { check, dependency } => write!( + f, + "matrix cross-runtime dependency is unsupported: {check} -> {dependency}" + ), + Self::PlanDigestMismatch => f.write_str("matrix plan digest mismatch"), + Self::InvalidReceipt => f.write_str("matrix receipt violates semantic invariants"), + Self::ReceiptIdMismatch => f.write_str("matrix receipt ID mismatch"), + Self::Verification(e) => write!(f, "matrix verification failed: {e}"), + Self::InvalidEvaluationTime => { + f.write_str("verification time is not representable as strict UTC") + } + Self::LegacyPlanNotRepresentable(field) => { + write!(f, "legacy matrix plan cannot represent field: {field}") + } + } + } +} +impl std::error::Error for MatrixContractError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + Self::Json(e) => Some(e), + Self::Parse(e) => Some(e), + Self::Config(e) => Some(e), + Self::Receipt(e) => Some(e), + Self::Verification(e) => Some(e), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MatrixReceiptEnvelopeV2 { + pub receipt_id: String, + pub receipt: MatrixReceiptV2, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MatrixReceiptV2 { + pub schema_version: String, + pub producer: crate::receipt::ProducerEvidence, + pub repository: crate::receipt::RepositoryEvidence, + pub run: crate::receipt::RunEvidence, + pub configuration_digest: String, + pub runtime_receipts: Vec, + pub overall_status: EvidenceStatus, + pub incomplete_reason: Option, + pub redaction_policy_version: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MatrixRuntimeReceiptV2 { + pub runtime_id: String, + pub receipt: crate::receipt::ReceiptEnvelopeV1, +} + +impl MatrixReceiptEnvelopeV2 { + pub fn seal(receipt: MatrixReceiptV2) -> Result { + receipt.validate()?; + let receipt_id = canonical_digest(&receipt).map_err(MatrixContractError::Receipt)?; + Ok(Self { + receipt_id, + receipt, + }) + } + pub fn verify(&self) -> Result<(), MatrixContractError> { + self.receipt.validate()?; + let expected = canonical_digest(&self.receipt).map_err(MatrixContractError::Receipt)?; + if expected != self.receipt_id { + return Err(MatrixContractError::ReceiptIdMismatch); + } + Ok(()) + } + pub fn canonical_bytes(&self) -> Result, MatrixContractError> { + self.verify()?; + canonical_json(self).map_err(MatrixContractError::Receipt) + } +} +impl MatrixReceiptV2 { + pub fn validate(&self) -> Result<(), MatrixContractError> { + if self.schema_version != MATRIX_RECEIPT_SCHEMA_VERSION { + return Err(MatrixContractError::UnsupportedSchemaVersion( + self.schema_version.clone(), + )); + } + if self.repository.dirty || self.runtime_receipts.len() < 2 { + return Err(MatrixContractError::InvalidReceipt); + } + let mut runtime_ids = BTreeSet::new(); + let mut check_ids = BTreeSet::new(); + let mut checks = Vec::new(); + for group in &self.runtime_receipts { + validate_identifier("runtime_receipts.runtime_id", &group.runtime_id) + .map_err(MatrixContractError::Config)?; + if !runtime_ids.insert(group.runtime_id.as_str()) { + return Err(MatrixContractError::DuplicateValue( + "runtime_receipts.runtime_id", + )); + } + group + .receipt + .verify() + .map_err(MatrixContractError::Receipt)?; + let receipt = &group.receipt.receipt; + if receipt.repository != self.repository || receipt.producer != self.producer { + return Err(MatrixContractError::InvalidReceipt); + } + for check in &receipt.checks { + if !check_ids.insert(check.id.as_str()) { + return Err(MatrixContractError::DuplicateValue( + "runtime_receipts.checks.id", + )); + } + checks.push(check.clone()); + } + } + if checks.is_empty() { + return Err(MatrixContractError::InvalidReceipt); + } + crate::receipt::ReceiptV1 { + schema_version: crate::receipt::RECEIPT_SCHEMA_VERSION.to_owned(), + producer: self.producer.clone(), + repository: self.repository.clone(), + run: self.run.clone(), + platform: self.runtime_receipts[0].receipt.receipt.platform.clone(), + configuration_digest: self.configuration_digest.clone(), + checks, + overall_status: self.overall_status, + incomplete_reason: self.incomplete_reason.clone(), + redaction_policy_version: self.redaction_policy_version.clone(), + } + .validate() + .map_err(MatrixContractError::Receipt) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MatrixVerificationPolicyV2 { + pub schema_version: String, + pub project: String, + pub configuration_digest: String, + pub required_checks: Vec, + pub max_age_seconds: u64, + pub runtimes: Vec, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MatrixRequiredCheckV2 { + pub id: String, + pub runtime_id: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MatrixRuntimePolicyV2 { + pub id: String, + pub configuration_digest: String, + pub image_reference: String, + pub platforms: Vec, +} + +impl MatrixVerificationPolicyV2 { + pub fn parse(source: &str) -> Result { + if source.len() > MAX_MATRIX_BYTES { + return Err(MatrixContractError::ConfigTooLarge); + } + let value: Self = toml::from_str(source).map_err(MatrixContractError::Parse)?; + value.validate()?; + Ok(value) + } + pub fn load(path: &Path) -> Result { + Self::parse(&fs::read_to_string(path).map_err(MatrixContractError::Io)?) + } + pub fn validate(&self) -> Result<(), MatrixContractError> { + if self.schema_version != MATRIX_POLICY_SCHEMA_VERSION { + return Err(MatrixContractError::UnsupportedSchemaVersion( + self.schema_version.clone(), + )); + } + if !(2..=MAX_MATRIX_RUNTIMES).contains(&self.runtimes.len()) + || self.required_checks.is_empty() + { + return Err(MatrixContractError::InvalidField( + "runtimes_or_required_checks", + )); + } + validate_project(&self.project)?; + validate_digest(&self.configuration_digest, "configuration_digest")?; + if !(1..=31_536_000).contains(&self.max_age_seconds) { + return Err(MatrixContractError::InvalidField("max_age_seconds")); + } + let mut ids = BTreeSet::new(); + for r in &self.runtimes { + validate_identifier("runtimes.id", &r.id).map_err(MatrixContractError::Config)?; + validate_digest(&r.configuration_digest, "configuration_digest")?; + validate_image_reference(&r.image_reference)?; + if r.platforms.is_empty() || r.platforms.len() > 32 { + return Err(MatrixContractError::InvalidField("platforms")); + } + let mut platforms = BTreeSet::new(); + for platform in &r.platforms { + validate_identifier("platforms.host_os", &platform.host_os) + .map_err(MatrixContractError::Config)?; + validate_identifier("platforms.host_arch", &platform.host_arch) + .map_err(MatrixContractError::Config)?; + validate_identifier("platforms.runtime_kind", &platform.runtime_kind) + .map_err(MatrixContractError::Config)?; + if !platforms.insert(( + &platform.host_os, + &platform.host_arch, + &platform.runtime_kind, + )) { + return Err(MatrixContractError::DuplicateValue("platforms")); + } + } + if !ids.insert(r.id.as_str()) { + return Err(MatrixContractError::DuplicateValue("runtimes.id")); + } + } + let mut checks = BTreeSet::new(); + for c in &self.required_checks { + validate_identifier("required_checks.id", &c.id) + .map_err(MatrixContractError::Config)?; + validate_identifier("required_checks.runtime_id", &c.runtime_id) + .map_err(MatrixContractError::Config)?; + if !ids.contains(c.runtime_id.as_str()) { + return Err(MatrixContractError::UnknownRuntime(c.runtime_id.clone())); + } + if !checks.insert(c.id.as_str()) { + return Err(MatrixContractError::DuplicateValue("required_checks.id")); + } + } + let covered: BTreeSet<_> = self + .required_checks + .iter() + .map(|c| c.runtime_id.as_str()) + .collect(); + if covered.len() != ids.len() { + return Err(MatrixContractError::InvalidField( + "required_checks.runtime_coverage", + )); + } + Ok(()) + } +} + +pub fn verify_matrix_receipt_document( + bytes: &[u8], + policy: &MatrixVerificationPolicyV2, + expected_commit: &str, + evaluated_at_utc: &str, +) -> Result { + policy.validate()?; + validate_commit(expected_commit).map_err(MatrixContractError::Verification)?; + let evaluated_at = + parse_utc_seconds(evaluated_at_utc).ok_or(MatrixContractError::InvalidEvaluationTime)?; + let mut report = VerificationReportV1 { + schema_version: "1.0".into(), + assurance_scope: "integrity_and_repository_policy_only".into(), + evaluated_at_utc: evaluated_at_utc.into(), + expected_commit: expected_commit.into(), + receipt_id: None, + integrity_status: VerificationStatus::Fail, + policy_status: VerificationStatus::NotRun, + decision: VerificationDecision::Fail, + findings: Vec::new(), + }; + let envelope: MatrixReceiptEnvelopeV2 = match serde_json::from_slice(bytes) { + Ok(v) => v, + Err(_) => { + report.findings.push(finding( + "receipt.parse_or_shape", + "receipt", + "receipt is not valid strict schema v2 JSON", + )); + return Ok(report); + } + }; + if envelope.verify().is_err() { + report.findings.push(finding( + "receipt.semantic_or_digest_invalid", + "receipt", + "receipt violates v2 integrity invariants", + )); + return Ok(report); + } + report.receipt_id = Some(envelope.receipt_id.clone()); + report.integrity_status = VerificationStatus::Pass; + report.policy_status = VerificationStatus::Pass; + evaluate_matrix_policy( + &envelope, + policy, + expected_commit, + evaluated_at, + &mut report.findings, + ); + if report.findings.is_empty() { + report.decision = VerificationDecision::Pass; + } else { + report.policy_status = VerificationStatus::Fail; + } + Ok(report) +} + +fn evaluate_matrix_policy( + envelope: &MatrixReceiptEnvelopeV2, + policy: &MatrixVerificationPolicyV2, + expected_commit: &str, + evaluated_at: i64, + findings: &mut Vec, +) { + let r = &envelope.receipt; + equal( + &r.repository.repository, + &policy.project, + "policy.repository", + "repository.repository", + "receipt project does not match repository policy", + findings, + ); + equal( + &r.repository.commit_sha, + expected_commit, + "policy.commit", + "repository.commit_sha", + "receipt commit does not match the externally supplied commit", + findings, + ); + if r.repository.dirty { + findings.push(finding( + "policy.dirty", + "repository.dirty", + "repository policy requires a clean checkout", + )); + } + equal( + &r.configuration_digest, + &policy.configuration_digest, + "policy.configuration", + "configuration_digest", + "receipt configuration digest does not match repository policy", + findings, + ); + if r.overall_status != EvidenceStatus::Pass { + findings.push(finding( + "policy.overall_status", + "overall_status", + "repository policy requires an overall PASS receipt", + )); + } + let expected: BTreeMap<_, _> = policy.runtimes.iter().map(|x| (x.id.as_str(), x)).collect(); + let actual: BTreeMap<_, _> = r + .runtime_receipts + .iter() + .map(|x| (x.runtime_id.as_str(), x)) + .collect(); + if expected.len() != actual.len() || expected.keys().any(|k| !actual.contains_key(k)) { + findings.push(finding( + "policy.runtime_set", + "runtime_receipts", + "receipt runtime set does not exactly match repository policy", + )); + } + for (id, exp) in expected { + if let Some(act) = actual.get(id) { + let p = &act.receipt.receipt.platform; + equal( + &act.receipt.receipt.configuration_digest, + &exp.configuration_digest, + "policy.runtime_configuration", + "runtime_receipts.configuration_digest", + "receipt runtime configuration does not match repository policy", + findings, + ); + equal( + &p.image_reference, + &exp.image_reference, + "policy.runtime_image", + "runtime_receipts.platform.image_reference", + "receipt runtime image does not match repository policy", + findings, + ); + if !exp.platforms.iter().any(|a| { + a.host_os == p.host_os + && a.host_arch == p.host_arch + && a.runtime_kind == p.runtime_kind + }) { + findings.push(finding( + "policy.runtime_platform", + "runtime_receipts.platform", + "receipt runtime platform tuple is not accepted by repository policy", + )); + } + } + } + let checks: BTreeMap<_, _> = policy + .required_checks + .iter() + .map(|c| (c.id.as_str(), c.runtime_id.as_str())) + .collect(); + let mut actual_checks = BTreeMap::new(); + for rt in &r.runtime_receipts { + for c in &rt.receipt.receipt.checks { + if c.required { + actual_checks.insert(c.id.as_str(), (rt.runtime_id.as_str(), c)); + } + } + } + if checks.len() != actual_checks.len() || checks.keys().any(|k| !actual_checks.contains_key(k)) + { + findings.push(finding( + "policy.required_check_set", + "checks", + "required check set does not exactly match repository policy", + )); + } + for (id, rid) in checks { + match actual_checks.get(id) { + Some((ar, c)) if *ar == rid && c.status == EvidenceStatus::Pass => {} + Some((ar, _)) if *ar != rid => findings.push(finding( + "policy.check_runtime", + "checks.runtime_id", + "required check ran in a different runtime than repository policy", + )), + Some(_) => findings.push(finding( + "policy.required_check_result", + "checks.status", + "one or more policy-required checks did not PASS", + )), + None => {} + } + } + match parse_utc_seconds(&r.run.finished_at_utc) { + Some(t) if t > evaluated_at => findings.push(finding( + "policy.future_receipt", + "run.finished_at_utc", + "receipt completion time is later than verification time", + )), + Some(t) if evaluated_at - t > policy.max_age_seconds as i64 => findings.push(finding( + "policy.stale_receipt", + "run.finished_at_utc", + "receipt exceeds repository freshness policy", + )), + Some(_) => {} + None => findings.push(finding( + "policy.invalid_time", + "run.finished_at_utc", + "receipt completion time cannot be evaluated", + )), + } +} +fn equal( + a: &str, + b: &str, + code: &str, + field: &str, + msg: &str, + out: &mut Vec, +) { + if a != b { + out.push(finding(code, field, msg)); + } +} +fn validate_project(v: &str) -> Result<(), MatrixContractError> { + let p = v.split('/').collect::>(); + if p.len() != 2 || p.iter().any(|x| x.is_empty() || !valid_name(x)) { + Err(MatrixContractError::InvalidField("project")) + } else { + Ok(()) + } +} +fn valid_name(v: &str) -> bool { + !v.is_empty() + && v.len() <= 128 + && v.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} +fn validate_digest(v: &str, f: &'static str) -> Result<(), MatrixContractError> { + if v.strip_prefix("sha256:").is_some_and(|x| { + x.len() == 64 + && x.bytes() + .all(|b| b.is_ascii_hexdigit() && !(b'A'..=b'F').contains(&b)) + }) { + Ok(()) + } else { + Err(MatrixContractError::InvalidField(f)) + } +} +fn validate_image_reference(v: &str) -> Result<(), MatrixContractError> { + let Some((n, d)) = v.rsplit_once('@') else { + return Err(MatrixContractError::InvalidField("image_reference")); + }; + if n.is_empty() || n.contains('@') || n.chars().any(char::is_control) { + return Err(MatrixContractError::InvalidField("image_reference")); + } + validate_digest(d, "image_reference") +} diff --git a/crates/ccp-core/src/matrix_legacy.rs b/crates/ccp-core/src/matrix_legacy.rs new file mode 100644 index 0000000..c374547 --- /dev/null +++ b/crates/ccp-core/src/matrix_legacy.rs @@ -0,0 +1,185 @@ +//! Historical Matrix V2 digest projection. +use crate::canonical::canonical_digest; +use crate::matrix::{MatrixContractError, MatrixPlanV2}; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LegacyMatrixDigestBasisV1 { + plan: LegacyMatrixPlanV2, + runtime_digests: BTreeMap, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyMatrixPlanV2 { + schema_version: String, + project: String, + receipt: LegacyNormalizedReceipt, + environment_allow: Vec, + caches: Vec, + runtimes: Vec, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyMatrixRuntimePlanV2 { + id: String, + configuration_digest: String, + runtime: LegacyNormalizedRuntime, + checks: Vec, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyNormalizedReceipt { + output: String, + freshness_seconds: u64, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyNormalizedCache { + id: String, + mount_path: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyNormalizedRuntime { + kind: crate::config::RuntimeKind, + image: String, + cpu_count: u16, + memory_mib: u64, + pids_limit: u32, + network: bool, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyNormalizedCheck { + id: String, + required: bool, + argv: Vec, + working_directory: String, + timeout_seconds: u64, + depends_on: Vec, + artifacts: Vec, +} + +impl LegacyMatrixDigestBasisV1 { + pub fn outer_digest(&self) -> Result { + canonical_digest(&self.plan).map_err(MatrixContractError::Receipt) + } + pub fn runtime_digest(&self, id: &str) -> Result<&str, MatrixContractError> { + self.runtime_digests + .get(id) + .map(String::as_str) + .ok_or_else(|| MatrixContractError::UnknownRuntime(id.into())) + } + pub fn report_value(&self) -> Result { + serde_json::to_value(&self.plan).map_err(MatrixContractError::Json) + } +} +pub fn project_legacy_basis( + plan: &MatrixPlanV2, +) -> Result { + if !plan.environment.fixed.is_empty() { + return Err(MatrixContractError::LegacyPlanNotRepresentable( + "environment.fixed", + )); + } + if !plan.environment.runtime_internal.is_empty() { + return Err(MatrixContractError::LegacyPlanNotRepresentable( + "environment.runtime_internal", + )); + } + if !plan.environment.remote_secret_only.is_empty() { + return Err(MatrixContractError::LegacyPlanNotRepresentable( + "environment.remote_secret_only", + )); + } + let mut runtimes = Vec::new(); + let mut digests = BTreeMap::new(); + let receipt = LegacyNormalizedReceipt { + output: plan.receipt.output.clone(), + freshness_seconds: plan.receipt.freshness_seconds, + }; + let caches = plan + .caches + .iter() + .map(|c| LegacyNormalizedCache { + id: c.id.clone(), + mount_path: c.mount_path.clone(), + }) + .collect::>(); + for r in &plan.runtimes { + if r.runtime.pull_policy.is_some() { + return Err(MatrixContractError::LegacyPlanNotRepresentable( + "runtime.pull_policy", + )); + } + if r.runtime.swap_mode.is_some() { + return Err(MatrixContractError::LegacyPlanNotRepresentable( + "runtime.swap_mode", + )); + } + let runtime = LegacyNormalizedRuntime { + kind: r.runtime.kind, + image: r.runtime.image.clone(), + cpu_count: r.runtime.cpu_count, + memory_mib: r.runtime.memory_mib, + pids_limit: r.runtime.pids_limit, + network: r.runtime.network, + }; + let checks = r + .checks + .iter() + .map(|c| { + if !c.artifact_contracts.is_empty() { + return Err(MatrixContractError::LegacyPlanNotRepresentable( + "checks.artifact_contracts", + )); + } + Ok(c) + }) + .collect::, _>>()? + .into_iter() + .map(|c| LegacyNormalizedCheck { + id: c.id.clone(), + required: c.required, + argv: c.argv.clone(), + working_directory: c.working_directory.clone(), + timeout_seconds: c.timeout_seconds, + depends_on: c.depends_on.clone(), + artifacts: c.artifacts.clone(), + }) + .collect::>(); + let basis = LegacyExecutionPlanV1 { + schema_version: "1.0".into(), + project: plan.project.clone(), + runtime: runtime.clone(), + receipt: receipt.clone(), + environment_allow: plan.environment.inherit.clone(), + caches: caches.clone(), + checks: checks.clone(), + }; + let d = canonical_digest(&basis).map_err(MatrixContractError::Receipt)?; + digests.insert(r.id.clone(), d.clone()); + runtimes.push(LegacyMatrixRuntimePlanV2 { + id: r.id.clone(), + configuration_digest: d, + runtime, + checks, + }); + } + Ok(LegacyMatrixDigestBasisV1 { + plan: LegacyMatrixPlanV2 { + schema_version: plan.schema_version.clone(), + project: plan.project.clone(), + receipt, + environment_allow: plan.environment.inherit.clone(), + caches, + runtimes, + }, + runtime_digests: digests, + }) +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyExecutionPlanV1 { + schema_version: String, + project: String, + runtime: LegacyNormalizedRuntime, + receipt: LegacyNormalizedReceipt, + environment_allow: Vec, + caches: Vec, + checks: Vec, +} diff --git a/crates/ccp-core/src/receipt.rs b/crates/ccp-core/src/receipt.rs new file mode 100644 index 0000000..152a4a0 --- /dev/null +++ b/crates/ccp-core/src/receipt.rs @@ -0,0 +1,1191 @@ +// Copyright 2026 Marco Porcellato +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::{BTreeMap, BTreeSet}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::{ArtifactKind, ExecutionPlanV1, RuntimePullPolicy, RuntimeSwapMode}; +use crate::errors::ReceiptError; +use crate::runtime_evidence::RuntimeCapabilityEvidenceV1; + +pub const RECEIPT_SCHEMA_VERSION: &str = "1.0"; +pub const RECEIPT_V2_SCHEMA_VERSION: &str = "2.0"; +pub const SOURCE_SNAPSHOT_SCHEMA_VERSION: &str = "1.0"; +pub const RECEIPT_ID_PREFIX: &str = "sha256:"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReceiptEnvelopeV1 { + pub receipt_id: String, + pub receipt: ReceiptV1, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReceiptV1 { + pub schema_version: String, + pub producer: ProducerEvidence, + pub repository: RepositoryEvidence, + pub run: RunEvidence, + pub platform: PlatformEvidence, + pub configuration_digest: String, + pub checks: Vec, + pub overall_status: EvidenceStatus, + pub incomplete_reason: Option, + pub redaction_policy_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReceiptEnvelopeV2 { + pub receipt_id: String, + pub receipt: ReceiptV2, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReceiptV2 { + pub schema_version: String, + pub producer: ProducerEvidence, + pub repository: RepositoryEvidence, + pub run: RunEvidence, + pub source_snapshot: SourceSnapshotEvidence, + pub platform: PlatformEvidence, + pub configuration_digest: String, + pub execution_plan: ExecutionPlanV1, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_capability_evidence: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifact_manifest: Vec, + pub checks: Vec, + pub overall_status: EvidenceStatus, + pub incomplete_reason: Option, + pub redaction_policy_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ArtifactEvidence { + pub path: String, + pub kind: ArtifactKind, + pub producer_check: String, + pub entry_count: u64, + pub total_bytes: u64, + pub manifest_digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SourceSnapshotEvidence { + pub schema_version: String, + pub strategy: SourceSnapshotStrategy, + pub manifest_digest: String, + pub entry_count: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum SourceSnapshotStrategy { + GitObject, + GitArchive, + DetachedWorktree, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ProducerEvidence { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RepositoryEvidence { + pub repository: String, + pub commit_sha: String, + pub dirty: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RunEvidence { + pub run_id: String, + pub generation: u64, + pub started_at_utc: String, + pub finished_at_utc: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PlatformEvidence { + pub host_os: String, + pub host_arch: String, + pub runtime_kind: String, + pub runtime_version: String, + pub image_reference: String, + pub image_digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CheckEvidence { + pub id: String, + pub required: bool, + pub argv: Vec, + pub working_directory: String, + pub status: EvidenceStatus, + pub exit_code: Option, + pub duration_ms: u64, + pub timed_out: bool, + pub cancelled: bool, + pub output_digest: Option, + pub incomplete_reason: Option, +} + +pub use crate::errors::EvidenceStatus; + +impl ReceiptEnvelopeV1 { + pub fn seal(receipt: ReceiptV1) -> Result { + receipt.validate()?; + let receipt_id = canonical_digest(&receipt)?; + Ok(Self { + receipt_id, + receipt, + }) + } + + pub fn verify(&self) -> Result<(), ReceiptError> { + self.receipt.validate()?; + let expected = canonical_digest(&self.receipt)?; + if self.receipt_id != expected { + return Err(ReceiptError::DigestMismatch { + expected, + actual: self.receipt_id.clone(), + }); + } + Ok(()) + } + + pub fn canonical_bytes(&self) -> Result, ReceiptError> { + self.verify()?; + canonical_json(self) + } +} + +impl ReceiptEnvelopeV2 { + pub fn seal(receipt: ReceiptV2) -> Result { + receipt.validate()?; + let receipt_id = canonical_digest(&receipt)?; + Ok(Self { + receipt_id, + receipt, + }) + } + + pub fn verify(&self) -> Result<(), ReceiptError> { + self.receipt.validate()?; + let expected = canonical_digest(&self.receipt)?; + if self.receipt_id != expected { + return Err(ReceiptError::DigestMismatch { + expected, + actual: self.receipt_id.clone(), + }); + } + Ok(()) + } + + pub fn canonical_bytes(&self) -> Result, ReceiptError> { + self.verify()?; + canonical_json(self) + } +} + +impl ReceiptV1 { + pub fn validate(&self) -> Result<(), ReceiptError> { + ReceiptCommon { + expected_schema_version: RECEIPT_SCHEMA_VERSION, + schema_version: &self.schema_version, + producer: &self.producer, + repository: &self.repository, + run: &self.run, + platform: &self.platform, + configuration_digest: &self.configuration_digest, + checks: &self.checks, + overall_status: self.overall_status, + incomplete_reason: self.incomplete_reason.as_deref(), + redaction_policy_version: &self.redaction_policy_version, + } + .validate() + } +} + +impl ReceiptV2 { + pub fn validate(&self) -> Result<(), ReceiptError> { + ReceiptCommon { + expected_schema_version: RECEIPT_V2_SCHEMA_VERSION, + schema_version: &self.schema_version, + producer: &self.producer, + repository: &self.repository, + run: &self.run, + platform: &self.platform, + configuration_digest: &self.configuration_digest, + checks: &self.checks, + overall_status: self.overall_status, + incomplete_reason: self.incomplete_reason.as_deref(), + redaction_policy_version: &self.redaction_policy_version, + } + .validate()?; + self.source_snapshot.validate()?; + let expected_plan_digest = canonical_digest(&self.execution_plan)?; + if self.configuration_digest != expected_plan_digest { + return Err(ReceiptError::ExecutionPlanDigestMismatch { + expected: expected_plan_digest, + actual: self.configuration_digest.clone(), + }); + } + self.validate_runtime_capability_evidence()?; + if self.execution_plan.checks.len() != self.checks.len() { + return Err(ReceiptError::ExecutionPlanCheckMismatch( + "check set".to_owned(), + )); + } + for planned in &self.execution_plan.checks { + let evidence = self + .checks + .iter() + .find(|evidence| evidence.id == planned.id) + .ok_or_else(|| ReceiptError::ExecutionPlanCheckMismatch(planned.id.clone()))?; + if evidence.required != planned.required + || evidence.argv != planned.argv + || evidence.working_directory != planned.working_directory + { + return Err(ReceiptError::ExecutionPlanCheckMismatch(planned.id.clone())); + } + } + self.validate_artifact_manifest()?; + Ok(()) + } + + fn validate_runtime_capability_evidence(&self) -> Result<(), ReceiptError> { + if self.execution_plan.schema_version == "1.3" { + if self.execution_plan.runtime.pull_policy != Some(RuntimePullPolicy::Never) + || self.execution_plan.runtime.swap_mode != Some(RuntimeSwapMode::Disabled) + { + return Err(ReceiptError::RuntimeCapabilityEvidenceMismatch); + } + let evidence = self + .runtime_capability_evidence + .as_ref() + .ok_or(ReceiptError::MissingRuntimeCapabilityEvidence)?; + if evidence.schema_version != "1.0" + || !evidence.memory_limit_supported + || !evidence.swap_limit_supported + { + return Err(ReceiptError::InvalidRuntimeCapabilityEvidence( + "schema_version or capability booleans", + )); + } + validate_sha256( + "runtime_capability.context_digest", + &evidence.context_digest, + ) + .map_err(|_| ReceiptError::InvalidRuntimeCapabilityEvidence("context_digest"))?; + validate_sha256( + "runtime_capability.resolved_image_id", + &evidence.resolved_image_id, + ) + .map_err(|_| ReceiptError::InvalidRuntimeCapabilityEvidence("resolved_image_id"))?; + require_text( + "runtime_capability.resolved_image_reference", + &evidence.resolved_image_reference, + )?; + if evidence.resolved_image_reference != self.execution_plan.runtime.image { + return Err(ReceiptError::RuntimeCapabilityEvidenceMismatch); + } + return Ok(()); + } + if self.runtime_capability_evidence.is_some() { + return Err(ReceiptError::UnexpectedRuntimeCapabilityEvidence); + } + Ok(()) + } + + fn validate_artifact_manifest(&self) -> Result<(), ReceiptError> { + let mut expected = BTreeMap::new(); + for check in &self.execution_plan.checks { + for artifact in &check.artifact_contracts { + expected.insert(artifact.path.as_str(), artifact); + } + } + let mut observed = BTreeSet::new(); + for artifact in &self.artifact_manifest { + if !observed.insert(artifact.path.as_str()) { + return Err(ReceiptError::DuplicateArtifactEvidence( + artifact.path.clone(), + )); + } + let planned = expected + .get(artifact.path.as_str()) + .ok_or_else(|| ReceiptError::ArtifactManifestMismatch(artifact.path.clone()))?; + if artifact.kind != planned.kind + || artifact.producer_check != planned.producer_check + || (artifact.kind == ArtifactKind::RegularFile && artifact.entry_count != 1) + || artifact.entry_count > planned.max_entries + || artifact.total_bytes > planned.max_bytes + { + return Err(ReceiptError::ArtifactManifestMismatch( + artifact.path.clone(), + )); + } + validate_sha256("artifact.manifest_digest", &artifact.manifest_digest)?; + } + if observed.len() != expected.len() || expected.keys().any(|path| !observed.contains(path)) + { + return Err(ReceiptError::ArtifactManifestMismatch( + "artifact set".to_owned(), + )); + } + Ok(()) + } +} + +impl SourceSnapshotEvidence { + fn validate(&self) -> Result<(), ReceiptError> { + if self.schema_version != SOURCE_SNAPSHOT_SCHEMA_VERSION { + return Err(ReceiptError::UnsupportedSourceSnapshotSchemaVersion( + self.schema_version.clone(), + )); + } + + require_text("source_snapshot.manifest_digest", &self.manifest_digest)?; + validate_sha256("source_snapshot.manifest_digest", &self.manifest_digest)?; + if self.entry_count == 0 { + return Err(ReceiptError::InvalidSourceSnapshotEntryCount( + self.entry_count, + )); + } + + Ok(()) + } +} + +impl CheckEvidence { + fn validate(&self) -> Result<(), ReceiptError> { + require_text("check.id", &self.id)?; + if self.argv.is_empty() || self.argv.iter().any(|part| part.is_empty()) { + return Err(ReceiptError::InvalidCommand(self.id.clone())); + } + validate_relative_path("check.working_directory", &self.working_directory)?; + validate_incomplete_reason( + "check.incomplete_reason", + self.status, + self.incomplete_reason.as_deref(), + )?; + + match self.status { + EvidenceStatus::Pass => { + if self.exit_code != Some(0) || self.timed_out || self.cancelled { + return Err(ReceiptError::InvalidCheckResult(self.id.clone())); + } + } + EvidenceStatus::Fail => { + if matches!(self.exit_code, None | Some(0)) && !self.timed_out && !self.cancelled { + return Err(ReceiptError::InvalidCheckResult(self.id.clone())); + } + } + EvidenceStatus::Pending | EvidenceStatus::NotRun => { + if self.exit_code.is_some() + || self.duration_ms != 0 + || self.timed_out + || self.cancelled + || self.output_digest.is_some() + { + return Err(ReceiptError::InvalidCheckResult(self.id.clone())); + } + } + } + + if let Some(digest) = &self.output_digest { + validate_sha256("check.output_digest", digest)?; + } + Ok(()) + } +} + +struct ReceiptCommon<'a> { + expected_schema_version: &'static str, + schema_version: &'a str, + producer: &'a ProducerEvidence, + repository: &'a RepositoryEvidence, + run: &'a RunEvidence, + platform: &'a PlatformEvidence, + configuration_digest: &'a str, + checks: &'a [CheckEvidence], + overall_status: EvidenceStatus, + incomplete_reason: Option<&'a str>, + redaction_policy_version: &'a str, +} + +impl ReceiptCommon<'_> { + fn validate(self) -> Result<(), ReceiptError> { + if self.schema_version != self.expected_schema_version { + return Err(ReceiptError::UnsupportedSchemaVersion( + self.schema_version.to_owned(), + )); + } + + require_text("producer.name", &self.producer.name)?; + require_text("producer.version", &self.producer.version)?; + validate_repository_identity(&self.repository.repository)?; + validate_commit_sha(&self.repository.commit_sha)?; + require_text("run.run_id", &self.run.run_id)?; + require_timestamp("run.started_at_utc", &self.run.started_at_utc)?; + require_timestamp("run.finished_at_utc", &self.run.finished_at_utc)?; + if self.run.finished_at_utc < self.run.started_at_utc { + return Err(ReceiptError::InvalidRunWindow); + } + require_text("platform.host_os", &self.platform.host_os)?; + require_text("platform.host_arch", &self.platform.host_arch)?; + require_text("platform.runtime_kind", &self.platform.runtime_kind)?; + require_text("platform.runtime_version", &self.platform.runtime_version)?; + require_text("platform.image_reference", &self.platform.image_reference)?; + validate_sha256("platform.image_digest", &self.platform.image_digest)?; + let image_reference_digest = self + .platform + .image_reference + .rsplit_once('@') + .map(|(_, digest)| digest); + if image_reference_digest != Some(self.platform.image_digest.as_str()) { + return Err(ReceiptError::ImageDigestMismatch); + } + validate_sha256("configuration_digest", self.configuration_digest)?; + require_text("redaction_policy_version", self.redaction_policy_version)?; + + if self.checks.is_empty() { + return Err(ReceiptError::NoChecks); + } + + let mut check_ids = BTreeSet::new(); + let mut required_statuses = Vec::new(); + for check in self.checks { + check.validate()?; + if !check_ids.insert(check.id.as_str()) { + return Err(ReceiptError::DuplicateCheckId(check.id.clone())); + } + if check.required { + required_statuses.push(check.status); + } + } + + if required_statuses.is_empty() { + return Err(ReceiptError::NoRequiredChecks); + } + + let expected_status = derive_overall_status(&required_statuses); + if self.overall_status != expected_status { + return Err(ReceiptError::OverallStatusMismatch { + expected: expected_status, + actual: self.overall_status, + }); + } + + validate_incomplete_reason( + "receipt.incomplete_reason", + self.overall_status, + self.incomplete_reason, + )?; + Ok(()) + } +} + +pub use crate::canonical::{canonical_digest, canonical_json}; + +pub fn receipt_schema_json() -> Result { + let schema = schemars::schema_for!(ReceiptEnvelopeV1); + serde_json::to_string_pretty(&schema).map_err(ReceiptError::Serialization) +} + +fn derive_overall_status(statuses: &[EvidenceStatus]) -> EvidenceStatus { + if statuses.contains(&EvidenceStatus::Fail) { + EvidenceStatus::Fail + } else if statuses + .iter() + .any(|status| matches!(status, EvidenceStatus::Pending | EvidenceStatus::NotRun)) + { + EvidenceStatus::Pending + } else { + EvidenceStatus::Pass + } +} + +fn validate_incomplete_reason( + field: &'static str, + status: EvidenceStatus, + reason: Option<&str>, +) -> Result<(), ReceiptError> { + let requires_reason = matches!(status, EvidenceStatus::Pending | EvidenceStatus::NotRun); + match (requires_reason, reason.map(str::trim)) { + (true, None | Some("")) => Err(ReceiptError::MissingIncompleteReason(field)), + (false, Some(reason)) if !reason.is_empty() => { + Err(ReceiptError::UnexpectedIncompleteReason(field)) + } + _ => Ok(()), + } +} + +fn require_text(field: &'static str, value: &str) -> Result<(), ReceiptError> { + if value.trim().is_empty() { + Err(ReceiptError::EmptyField(field)) + } else if value.chars().any(char::is_control) { + Err(ReceiptError::ControlCharacter(field)) + } else { + Ok(()) + } +} + +fn validate_commit_sha(value: &str) -> Result<(), ReceiptError> { + if matches!(value.len(), 40 | 64) + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(ReceiptError::InvalidCommitSha(value.to_owned())) + } +} + +fn validate_repository_identity(value: &str) -> Result<(), ReceiptError> { + require_text("repository.repository", value)?; + let mut segments = value.split('/'); + let owner = segments.next().unwrap_or_default(); + let repository = segments.next().unwrap_or_default(); + let allowed = + |character: char| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'); + if !owner.is_empty() + && !repository.is_empty() + && segments.next().is_none() + && owner.chars().all(allowed) + && repository.chars().all(allowed) + { + Ok(()) + } else { + Err(ReceiptError::InvalidRepositoryIdentity) + } +} + +fn validate_sha256(field: &'static str, value: &str) -> Result<(), ReceiptError> { + let Some(hex) = value.strip_prefix(RECEIPT_ID_PREFIX) else { + return Err(ReceiptError::InvalidSha256(field)); + }; + if hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(ReceiptError::InvalidSha256(field)) + } +} + +fn require_timestamp(field: &'static str, value: &str) -> Result<(), ReceiptError> { + require_text(field, value)?; + let bytes = value.as_bytes(); + let separators_are_valid = bytes.len() == 20 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[10] == b'T' + && bytes[13] == b':' + && bytes[16] == b':' + && bytes[19] == b'Z'; + if !separators_are_valid { + return Err(ReceiptError::InvalidTimestamp(field)); + } + + let year = parse_decimal(&bytes[0..4]).ok_or(ReceiptError::InvalidTimestamp(field))?; + let month = parse_decimal(&bytes[5..7]).ok_or(ReceiptError::InvalidTimestamp(field))?; + let day = parse_decimal(&bytes[8..10]).ok_or(ReceiptError::InvalidTimestamp(field))?; + let hour = parse_decimal(&bytes[11..13]).ok_or(ReceiptError::InvalidTimestamp(field))?; + let minute = parse_decimal(&bytes[14..16]).ok_or(ReceiptError::InvalidTimestamp(field))?; + let second = parse_decimal(&bytes[17..19]).ok_or(ReceiptError::InvalidTimestamp(field))?; + + let leap_year = divisible_by(year, 4) && (!divisible_by(year, 100) || divisible_by(year, 400)); + let days_in_month = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap_year => 29, + 2 => 28, + _ => 0, + }; + if year > 0 && (1..=days_in_month).contains(&day) && hour <= 23 && minute <= 59 && second <= 59 + { + Ok(()) + } else { + Err(ReceiptError::InvalidTimestamp(field)) + } +} + +fn parse_decimal(bytes: &[u8]) -> Option { + bytes.iter().try_fold(0_u32, |value, byte| { + byte.is_ascii_digit() + .then(|| value * 10 + u32::from(byte - b'0')) + }) +} + +fn divisible_by(value: u32, divisor: u32) -> bool { + value / divisor * divisor == value +} + +fn validate_relative_path(field: &'static str, value: &str) -> Result<(), ReceiptError> { + require_text(field, value)?; + let is_safe = value == "." + || (!value.starts_with('/') + && !value.starts_with('~') + && !value.contains('\\') + && !value.contains(':') + && value + .split('/') + .all(|segment| !segment.is_empty() && segment != "." && segment != "..")); + if is_safe { + Ok(()) + } else { + Err(ReceiptError::UnsafePath(field)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ArtifactKind, ConfigV1, NormalizedArtifactContract}; + use serde_json::Value; + + fn digest(fill: char) -> String { + format!("sha256:{}", fill.to_string().repeat(64)) + } + + fn passing_receipt() -> ReceiptV1 { + ReceiptV1 { + schema_version: RECEIPT_SCHEMA_VERSION.to_owned(), + producer: ProducerEvidence { + name: "commit-ci-preflight".to_owned(), + version: "0.1.0".to_owned(), + }, + repository: RepositoryEvidence { + repository: "example/project".to_owned(), + commit_sha: "0123456789abcdef0123456789abcdef01234567".to_owned(), + dirty: false, + }, + run: RunEvidence { + run_id: "fixture-run-0001".to_owned(), + generation: 1, + started_at_utc: "2026-08-08T12:00:00Z".to_owned(), + finished_at_utc: "2026-08-08T12:00:01Z".to_owned(), + }, + platform: PlatformEvidence { + host_os: "macos".to_owned(), + host_arch: "aarch64".to_owned(), + runtime_kind: "orbstack".to_owned(), + runtime_version: "fixture-1".to_owned(), + image_reference: format!("example.invalid/ci@{}", digest('a')), + image_digest: digest('a'), + }, + configuration_digest: digest('b'), + checks: vec![CheckEvidence { + id: "rust-test".to_owned(), + required: true, + argv: vec!["cargo".to_owned(), "test".to_owned()], + working_directory: ".".to_owned(), + status: EvidenceStatus::Pass, + exit_code: Some(0), + duration_ms: 1000, + timed_out: false, + cancelled: false, + output_digest: Some(digest('c')), + incomplete_reason: None, + }], + overall_status: EvidenceStatus::Pass, + incomplete_reason: None, + redaction_policy_version: "1".to_owned(), + } + } + + fn source_snapshot() -> SourceSnapshotEvidence { + SourceSnapshotEvidence { + schema_version: SOURCE_SNAPSHOT_SCHEMA_VERSION.to_owned(), + strategy: SourceSnapshotStrategy::GitObject, + manifest_digest: digest('d'), + entry_count: 1, + } + } + + fn execution_plan_v2() -> ExecutionPlanV1 { + ConfigV1::parse( + r#" +schema_version = "1.0" +project = "example/project" + +[runtime] +kind = "docker_compatible" +image = "example.invalid/ci@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +cpu_count = 1 +memory_mib = 128 +pids_limit = 16 + +[[checks]] +id = "rust-test" +required = true +argv = ["cargo", "test"] +working_directory = "." +timeout_seconds = 60 +"#, + ) + .expect("config") + .into_plan() + .expect("plan") + .plan + } + + fn passing_receipt_v2() -> ReceiptV2 { + let execution_plan = execution_plan_v2(); + ReceiptV2 { + schema_version: RECEIPT_V2_SCHEMA_VERSION.to_owned(), + producer: ProducerEvidence { + name: "commit-ci-preflight".to_owned(), + version: "0.1.0".to_owned(), + }, + repository: RepositoryEvidence { + repository: "example/project".to_owned(), + commit_sha: "0123456789abcdef0123456789abcdef01234567".to_owned(), + dirty: false, + }, + run: RunEvidence { + run_id: "fixture-run-0001".to_owned(), + generation: 1, + started_at_utc: "2026-08-08T12:00:00Z".to_owned(), + finished_at_utc: "2026-08-08T12:00:01Z".to_owned(), + }, + source_snapshot: source_snapshot(), + platform: PlatformEvidence { + host_os: "macos".to_owned(), + host_arch: "aarch64".to_owned(), + runtime_kind: "orbstack".to_owned(), + runtime_version: "fixture-1".to_owned(), + image_reference: format!("example.invalid/ci@{}", digest('a')), + image_digest: digest('a'), + }, + configuration_digest: canonical_digest(&execution_plan).expect("plan digest"), + execution_plan, + runtime_capability_evidence: None, + artifact_manifest: Vec::new(), + checks: vec![CheckEvidence { + id: "rust-test".to_owned(), + required: true, + argv: vec!["cargo".to_owned(), "test".to_owned()], + working_directory: ".".to_owned(), + status: EvidenceStatus::Pass, + exit_code: Some(0), + duration_ms: 1000, + timed_out: false, + cancelled: false, + output_digest: Some(digest('c')), + incomplete_reason: None, + }], + overall_status: EvidenceStatus::Pass, + incomplete_reason: None, + redaction_policy_version: "1".to_owned(), + } + } + + fn passing_schema_1_3_receipt() -> ReceiptV2 { + let execution_plan = ConfigV1::parse( + r#" +schema_version = "1.3" +project = "example/project" + +[runtime] +kind = "docker_compatible" +image = "example.invalid/ci@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +cpu_count = 1 +memory_mib = 128 +pids_limit = 16 +pull_policy = "never" +swap_mode = "disabled" + +[storage] +min_free_bytes = 1 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 0 + +[[checks]] +id = "rust-test" +required = true +argv = ["cargo", "test"] +working_directory = "." +timeout_seconds = 60 +"#, + ) + .expect("config") + .into_plan() + .expect("plan") + .plan; + let mut receipt = passing_receipt_v2(); + receipt.configuration_digest = canonical_digest(&execution_plan).expect("plan digest"); + receipt.execution_plan = execution_plan; + receipt.runtime_capability_evidence = Some(RuntimeCapabilityEvidenceV1 { + schema_version: "1.0".to_owned(), + memory_limit_supported: true, + swap_limit_supported: true, + context_digest: digest('b'), + resolved_image_id: digest('c'), + resolved_image_reference: receipt.execution_plan.runtime.image.clone(), + }); + receipt + } + + #[test] + fn schema_1_3_receipt_requires_matching_runtime_capability_evidence() { + let mut receipt = passing_schema_1_3_receipt(); + receipt.runtime_capability_evidence = None; + + assert!(matches!( + receipt.validate(), + Err(ReceiptError::MissingRuntimeCapabilityEvidence) + )); + } + + #[test] + fn historical_receipt_rejects_runtime_capability_evidence() { + let mut receipt = passing_receipt_v2(); + receipt.runtime_capability_evidence = Some(RuntimeCapabilityEvidenceV1 { + schema_version: "1.0".to_owned(), + memory_limit_supported: true, + swap_limit_supported: true, + context_digest: digest('b'), + resolved_image_id: digest('c'), + resolved_image_reference: receipt.execution_plan.runtime.image.clone(), + }); + + assert!(matches!( + receipt.validate(), + Err(ReceiptError::UnexpectedRuntimeCapabilityEvidence) + )); + } + + #[test] + fn schema_1_3_receipt_rejects_invalid_or_mismatched_runtime_capability_evidence() { + let mut receipt = passing_schema_1_3_receipt(); + receipt + .runtime_capability_evidence + .as_mut() + .expect("evidence") + .resolved_image_reference = "example.invalid/other@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(); + assert!(matches!( + receipt.validate(), + Err(ReceiptError::RuntimeCapabilityEvidenceMismatch) + )); + + let mut receipt = passing_schema_1_3_receipt(); + receipt + .runtime_capability_evidence + .as_mut() + .expect("evidence") + .context_digest = "sha256:not-a-digest".to_owned(); + assert!(matches!( + receipt.validate(), + Err(ReceiptError::InvalidRuntimeCapabilityEvidence(_)) + )); + } + + #[test] + fn v2_receipt_rejects_a_declared_artifact_without_observed_evidence() { + let mut receipt = passing_receipt_v2(); + receipt.execution_plan.checks[0] + .artifact_contracts + .push(NormalizedArtifactContract { + path: "results/report.json".to_owned(), + kind: ArtifactKind::RegularFile, + max_bytes: 1024, + max_entries: 1, + producer_check: "rust-test".to_owned(), + }); + receipt.configuration_digest = + canonical_digest(&receipt.execution_plan).expect("updated plan digest"); + + assert!(receipt.validate().is_err()); + } + + #[test] + fn v2_receipt_rejects_regular_file_evidence_without_one_file_entry() { + let mut receipt = passing_receipt_v2(); + receipt.execution_plan.checks[0] + .artifact_contracts + .push(NormalizedArtifactContract { + path: "results/report.json".to_owned(), + kind: ArtifactKind::RegularFile, + max_bytes: 1024, + max_entries: 1, + producer_check: "rust-test".to_owned(), + }); + receipt.configuration_digest = + canonical_digest(&receipt.execution_plan).expect("updated plan digest"); + receipt.artifact_manifest.push(ArtifactEvidence { + path: "results/report.json".to_owned(), + kind: ArtifactKind::RegularFile, + producer_check: "rust-test".to_owned(), + entry_count: 0, + total_bytes: 0, + manifest_digest: digest('d'), + }); + + assert!(receipt.validate().is_err()); + } + + #[test] + fn deterministic_replay_produces_identical_bytes_and_id() { + let first = ReceiptEnvelopeV1::seal(passing_receipt()).expect("first receipt"); + let second = ReceiptEnvelopeV1::seal(passing_receipt()).expect("second receipt"); + + assert_eq!(first, second); + assert_eq!( + first.canonical_bytes().expect("first bytes"), + second.canonical_bytes().expect("second bytes") + ); + } + + #[test] + fn tampering_is_rejected() { + let mut envelope = ReceiptEnvelopeV1::seal(passing_receipt()).expect("receipt"); + envelope.receipt.run.generation += 1; + + assert!(matches!( + envelope.verify(), + Err(ReceiptError::DigestMismatch { .. }) + )); + } + + #[test] + fn unknown_fields_are_rejected() { + let envelope = ReceiptEnvelopeV1::seal(passing_receipt()).expect("receipt"); + let mut value = serde_json::to_value(envelope).expect("JSON value"); + value + .as_object_mut() + .expect("root object") + .insert("unexpected".to_owned(), Value::Bool(true)); + + let result = serde_json::from_value::(value); + assert!(result.is_err()); + } + + #[test] + fn duplicate_check_ids_are_rejected() { + let mut receipt = passing_receipt(); + receipt.checks.push(receipt.checks[0].clone()); + + assert!(matches!( + receipt.validate(), + Err(ReceiptError::DuplicateCheckId(_)) + )); + } + + #[test] + fn pending_required_check_requires_truthful_reason() { + let mut receipt = passing_receipt(); + let check = &mut receipt.checks[0]; + check.status = EvidenceStatus::Pending; + check.exit_code = None; + check.duration_ms = 0; + check.output_digest = None; + receipt.overall_status = EvidenceStatus::Pending; + receipt.incomplete_reason = Some("Windows-native execution is pending".to_owned()); + + assert!(matches!( + receipt.validate(), + Err(ReceiptError::MissingIncompleteReason( + "check.incomplete_reason" + )) + )); + + receipt.checks[0].incomplete_reason = + Some("Windows-native execution is pending".to_owned()); + receipt.validate().expect("truthful pending receipt"); + } + + #[test] + fn absolute_and_parent_paths_are_rejected() { + for path in [ + "/Users/example/project", + "../project", + "nested/../../escape", + r"C:\Users\example\project", + "C:/Users/example/project", + "~/project", + "nested//project", + "nested/./project", + ] { + let mut receipt = passing_receipt(); + receipt.checks[0].working_directory = path.to_owned(); + assert!(matches!( + receipt.validate(), + Err(ReceiptError::UnsafePath("check.working_directory")) + )); + } + } + + #[test] + fn failed_check_requires_failure_evidence() { + let mut receipt = passing_receipt(); + receipt.checks[0].status = EvidenceStatus::Fail; + receipt.checks[0].exit_code = None; + receipt.checks[0].output_digest = None; + receipt.overall_status = EvidenceStatus::Fail; + + assert!(matches!( + receipt.validate(), + Err(ReceiptError::InvalidCheckResult(id)) if id == "rust-test" + )); + + receipt.checks[0].timed_out = true; + receipt.validate().expect("timeout is failure evidence"); + } + + #[test] + fn uppercase_or_short_digests_are_rejected() { + for value in [digest('A'), "sha256:abcd".to_owned(), "abcd".to_owned()] { + let mut receipt = passing_receipt(); + receipt.configuration_digest = value; + assert!(matches!( + receipt.validate(), + Err(ReceiptError::InvalidSha256("configuration_digest")) + )); + } + } + + #[test] + fn overall_status_is_derived_from_required_checks() { + let mut receipt = passing_receipt(); + receipt.overall_status = EvidenceStatus::Pending; + receipt.incomplete_reason = Some("incorrectly marked pending".to_owned()); + + assert!(matches!( + receipt.validate(), + Err(ReceiptError::OverallStatusMismatch { + expected: EvidenceStatus::Pass, + actual: EvidenceStatus::Pending + }) + )); + } + + #[test] + fn timestamps_are_strict_utc_and_ordered() { + for timestamp in [ + "2026-02-29T12:00:00Z", + "2026-13-01T12:00:00Z", + "2026-08-08 12:00:00Z", + "2026-08-08T24:00:00Z", + "2026-08-08T12:00:00+00:00", + ] { + let mut receipt = passing_receipt(); + receipt.run.started_at_utc = timestamp.to_owned(); + assert!(matches!( + receipt.validate(), + Err(ReceiptError::InvalidTimestamp("run.started_at_utc")) + )); + } + + let mut receipt = passing_receipt(); + receipt.run.started_at_utc = "2026-08-08T12:00:02Z".to_owned(); + assert!(matches!( + receipt.validate(), + Err(ReceiptError::InvalidRunWindow) + )); + } + + #[test] + fn repository_identity_cannot_embed_credentials_or_urls() { + for repository in [ + "https://github.com/example/project", + "user:token@example/project", + "example/project/extra", + "/example/project", + ] { + let mut receipt = passing_receipt(); + receipt.repository.repository = repository.to_owned(); + assert!(matches!( + receipt.validate(), + Err(ReceiptError::InvalidRepositoryIdentity) + )); + } + } + + #[test] + fn image_reference_must_match_pinned_digest() { + let mut receipt = passing_receipt(); + receipt.platform.image_reference = "example.invalid/ci:latest".to_owned(); + assert!(matches!( + receipt.validate(), + Err(ReceiptError::ImageDigestMismatch) + )); + } + + #[test] + fn schema_is_generated_and_versioned() { + let schema = receipt_schema_json().expect("schema JSON"); + assert!(schema.contains("ReceiptEnvelopeV1")); + assert!(schema.contains("receipt_id")); + assert!(!schema.contains("SCREAMING_SNAKE_CASE")); + } + + #[test] + fn v2_deterministic_replay_produces_identical_bytes_and_id() { + let first = ReceiptEnvelopeV2::seal(passing_receipt_v2()).expect("first receipt"); + let second = ReceiptEnvelopeV2::seal(passing_receipt_v2()).expect("second receipt"); + + assert_eq!(first, second); + assert_eq!( + first.canonical_bytes().expect("first bytes"), + second.canonical_bytes().expect("second bytes") + ); + } + + #[test] + fn v2_rejects_check_evidence_that_disagrees_with_its_execution_plan() { + let mut receipt = passing_receipt_v2(); + receipt.checks[0].argv.push("--release".to_owned()); + + assert!(matches!( + ReceiptEnvelopeV2::seal(receipt), + Err(ReceiptError::ExecutionPlanCheckMismatch(id)) if id == "rust-test" + )); + } + + #[test] + fn v2_source_snapshot_validation_is_strict() { + let mut receipt = passing_receipt_v2(); + receipt.source_snapshot.schema_version = "9.9".to_owned(); + assert!(matches!( + receipt.validate(), + Err(ReceiptError::UnsupportedSourceSnapshotSchemaVersion(version)) if version == "9.9" + )); + + let mut receipt = passing_receipt_v2(); + receipt.source_snapshot.entry_count = 0; + assert!(matches!( + receipt.validate(), + Err(ReceiptError::InvalidSourceSnapshotEntryCount(0)) + )); + } +} diff --git a/crates/ccp-core/src/runtime_evidence.rs b/crates/ccp-core/src/runtime_evidence.rs new file mode 100644 index 0000000..e04d5b1 --- /dev/null +++ b/crates/ccp-core/src/runtime_evidence.rs @@ -0,0 +1,13 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeCapabilityEvidenceV1 { + pub schema_version: String, + pub memory_limit_supported: bool, + pub swap_limit_supported: bool, + pub context_digest: String, + pub resolved_image_id: String, + pub resolved_image_reference: String, +} diff --git a/src/schema_contract.rs b/crates/ccp-core/src/schema.rs similarity index 70% rename from src/schema_contract.rs rename to crates/ccp-core/src/schema.rs index 9f5a195..5259651 100644 --- a/src/schema_contract.rs +++ b/crates/ccp-core/src/schema.rs @@ -1,30 +1,25 @@ -// Copyright 2026 Marco Porcellato -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! The canonical v2 receipt schema shared by single-runtime and matrix receipts. -//! -//! The two receipt families remain separate strict Rust contracts. This module -//! only composes their generated JSON Schema documents so one pinned public -//! schema cannot silently invalidate either family during a staged upgrade. - +//! Stable schema entry points for independent consumers. +use crate::matrix::{MatrixConfigV2, MatrixReceiptEnvelopeV2, MatrixVerificationPolicyV2}; +use crate::receipt::ReceiptEnvelopeV2; use schemars::schema_for; use serde_json::{Map, Value}; -use crate::matrix::MatrixReceiptEnvelopeV2; -use crate::receipt::ReceiptEnvelopeV2; +pub fn matrix_config_schema() -> Value { + serde_json::to_value(schema_for!(MatrixConfigV2)).expect("schema serialization") +} +pub fn matrix_receipt_schema() -> Value { + serde_json::to_value(schema_for!(MatrixReceiptEnvelopeV2)).expect("schema serialization") +} +pub fn matrix_policy_schema() -> Value { + serde_json::to_value(schema_for!(MatrixVerificationPolicyV2)).expect("schema serialization") +} +pub fn matrix_policy_schema_json() -> Result { + serde_json::to_string_pretty(&schema_for!(MatrixVerificationPolicyV2)) +} -pub(crate) fn combined_receipt_v2_schema_json() -> Result { +/// Return the canonical combined v2 schema shared by single-runtime and +/// matrix receipts. +pub fn combined_receipt_v2_schema_json() -> Result { let matrix = serde_json::to_value(schema_for!(MatrixReceiptEnvelopeV2))?; let single_runtime = serde_json::to_value(schema_for!(ReceiptEnvelopeV2))?; let mut root = object_field(matrix, "root")?; diff --git a/crates/ccp-core/src/verification_model.rs b/crates/ccp-core/src/verification_model.rs new file mode 100644 index 0000000..5de7ee0 --- /dev/null +++ b/crates/ccp-core/src/verification_model.rs @@ -0,0 +1,156 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::canonical::canonical_json; +use crate::errors::VerificationError; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum VerificationStatus { + Pass, + Fail, + NotRun, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum VerificationDecision { + Pass, + Fail, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AcceptedPlatformV1 { + pub host_os: String, + pub host_arch: String, + pub runtime_kind: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VerificationFindingV1 { + pub code: String, + pub field: String, + pub message: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VerificationReportV1 { + pub schema_version: String, + pub assurance_scope: String, + pub evaluated_at_utc: String, + pub expected_commit: String, + pub receipt_id: Option, + pub integrity_status: VerificationStatus, + pub policy_status: VerificationStatus, + pub decision: VerificationDecision, + pub findings: Vec, +} +impl VerificationReportV1 { + pub fn canonical_bytes(&self) -> Result, VerificationError> { + canonical_json(self).map_err(VerificationError::Receipt) + } + pub fn exit_code(&self) -> i32 { + match self.decision { + VerificationDecision::Pass => 0, + VerificationDecision::Fail => 3, + } + } +} +#[doc(hidden)] +pub fn finding(code: &str, field: &str, message: &str) -> VerificationFindingV1 { + VerificationFindingV1 { + code: code.to_owned(), + field: field.to_owned(), + message: message.to_owned(), + } +} +#[doc(hidden)] +pub fn validate_commit(value: &str) -> Result<(), VerificationError> { + if !matches!(value.len(), 40 | 64) + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(VerificationError::InvalidExpectedCommit); + } + Ok(()) +} +#[doc(hidden)] +pub fn parse_utc_seconds(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() != 20 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + || bytes[19] != b'Z' + { + return None; + } + let decimal = |slice: &[u8]| { + slice.iter().try_fold(0_u32, |value, byte| { + byte.is_ascii_digit() + .then(|| value * 10 + u32::from(byte - b'0')) + }) + }; + let year = decimal(&bytes[0..4])? as i64; + let month = decimal(&bytes[5..7])? as i64; + let day = decimal(&bytes[8..10])? as i64; + let hour = decimal(&bytes[11..13])? as i64; + let minute = decimal(&bytes[14..16])? as i64; + let second = decimal(&bytes[17..19])? as i64; + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days_in_month = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => 0, + }; + if year == 0 || !(1..=days_in_month).contains(&day) || hour > 23 || minute > 59 || second > 59 { + return None; + } + let adjusted_year = year - i64::from(month <= 2); + let era = adjusted_year.div_euclid(400); + let year_of_era = adjusted_year - era * 400; + let shifted_month = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * shifted_month + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + let days = era * 146_097 + day_of_era - 719_468; + days.checked_mul(86_400)? + .checked_add(hour * 3_600 + minute * 60 + second) +} +#[doc(hidden)] +pub fn format_unix_utc(seconds: u64) -> Option { + let days = i64::try_from(seconds / 86_400).ok()?; + let seconds_of_day = seconds % 86_400; + let (year, month, day) = civil_from_days(days)?; + if !(1..=9999).contains(&year) { + return None; + } + let hour = seconds_of_day / 3_600; + let minute = (seconds_of_day % 3_600) / 60; + let second = seconds_of_day % 60; + Some(format!( + "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z" + )) +} +fn civil_from_days(days_since_epoch: i64) -> Option<(i64, u64, u64)> { + let shifted = days_since_epoch.checked_add(719_468)?; + let era = if shifted >= 0 { + shifted + } else { + shifted.checked_sub(146_096)? + } / 146_097; + let day_of_era = shifted.checked_sub(era.checked_mul(146_097)?)?; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era.checked_add(era.checked_mul(400)?)?; + let day_of_year = + day_of_era.checked_sub(365 * year_of_era + year_of_era / 4 - year_of_era / 100)?; + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + Some((year, u64::try_from(month).ok()?, u64::try_from(day).ok()?)) +} diff --git a/crates/ccp-core/src/verify.rs b/crates/ccp-core/src/verify.rs new file mode 100644 index 0000000..d561e46 --- /dev/null +++ b/crates/ccp-core/src/verify.rs @@ -0,0 +1,956 @@ +// Copyright 2026 Marco Porcellato +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::BTreeSet; +use std::fmt; +use std::fs; +use std::io; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use schemars::{JsonSchema, schema_for}; +use serde::Deserialize; + +use crate::config::{ConfigV1, ExecutionPlanEnvelopeV1, ExecutionPlanV1}; +use crate::errors::ReceiptError; +pub use crate::errors::{PolicyError, TrustedPlanError, VerificationError}; +use crate::receipt::{ + CheckEvidence, EvidenceStatus, PlatformEvidence, ReceiptEnvelopeV1, ReceiptEnvelopeV2, + RepositoryEvidence, RunEvidence, SourceSnapshotStrategy, +}; +use crate::verification_model::format_unix_utc; +pub use crate::verification_model::{ + AcceptedPlatformV1, VerificationDecision, VerificationFindingV1, VerificationReportV1, + VerificationStatus, +}; +pub(crate) use crate::verification_model::{finding, parse_utc_seconds, validate_commit}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerificationPolicyDocument { + V1(VerificationPolicyV1), + V1_1(VerificationPolicyV1_1), + V2(crate::matrix::MatrixVerificationPolicyV2), +} + +#[derive(Debug)] +pub enum VerificationPolicyDocumentError { + Io(io::Error), + TooLarge, + InvalidUtf8, + Parse(toml::de::Error), + UnsupportedSchemaVersion, + V1(PolicyError), + V1_1(PolicyError), + V2(crate::matrix::MatrixContractError), +} + +impl fmt::Display for VerificationPolicyDocumentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(_) => formatter.write_str("cannot read verification policy"), + Self::TooLarge => formatter.write_str("verification policy exceeds size limit"), + Self::InvalidUtf8 => formatter.write_str("verification policy is not UTF-8"), + Self::Parse(_) => formatter.write_str("verification policy is not valid TOML"), + Self::UnsupportedSchemaVersion => { + formatter.write_str("verification policy schema version is unsupported") + } + Self::V1(error) => write!(formatter, "{error}"), + Self::V1_1(error) => write!(formatter, "{error}"), + Self::V2(error) => write!(formatter, "{error}"), + } + } +} + +impl std::error::Error for VerificationPolicyDocumentError {} + +pub fn load_verification_policy_document( + path: &Path, +) -> Result { + let metadata = fs::metadata(path).map_err(VerificationPolicyDocumentError::Io)?; + if metadata.len() > MAX_POLICY_BYTES as u64 { + return Err(VerificationPolicyDocumentError::TooLarge); + } + let bytes = fs::read(path).map_err(VerificationPolicyDocumentError::Io)?; + let source = + std::str::from_utf8(&bytes).map_err(|_| VerificationPolicyDocumentError::InvalidUtf8)?; + let value: toml::Value = + toml::from_str(source).map_err(VerificationPolicyDocumentError::Parse)?; + let version = value.get("schema_version").and_then(toml::Value::as_str); + match version { + Some(POLICY_SCHEMA_VERSION) => VerificationPolicyV1::parse(&bytes) + .map(VerificationPolicyDocument::V1) + .map_err(VerificationPolicyDocumentError::V1), + Some(TRUSTED_PLAN_POLICY_SCHEMA_VERSION) => VerificationPolicyV1_1::parse(&bytes) + .map(VerificationPolicyDocument::V1_1) + .map_err(VerificationPolicyDocumentError::V1_1), + Some(crate::matrix::MATRIX_POLICY_SCHEMA_VERSION) => { + crate::matrix::MatrixVerificationPolicyV2::parse(source) + .map(VerificationPolicyDocument::V2) + .map_err(VerificationPolicyDocumentError::V2) + } + _ => Err(VerificationPolicyDocumentError::UnsupportedSchemaVersion), + } +} + +pub const POLICY_SCHEMA_VERSION: &str = "1.0"; +pub const TRUSTED_PLAN_POLICY_SCHEMA_VERSION: &str = "1.1"; +pub const VERIFICATION_REPORT_SCHEMA_VERSION: &str = "1.0"; +const MAX_POLICY_BYTES: usize = 1024 * 1024; +const MAX_RECEIPT_BYTES: usize = 4 * 1024 * 1024; +const MAX_REQUIRED_CHECKS: usize = 128; +const MAX_PLATFORMS: usize = 32; +const MAX_FRESHNESS_SECONDS: u64 = 31_536_000; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VerificationPolicyV1 { + pub schema_version: String, + pub project: String, + pub configuration_digest: String, + pub required_checks: Vec, + pub image_reference: String, + pub max_age_seconds: u64, + pub platforms: Vec, +} + +impl VerificationPolicyV1 { + pub fn load(path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(PolicyError::Io)?; + if metadata.len() > MAX_POLICY_BYTES as u64 { + return Err(PolicyError::TooLarge); + } + let bytes = fs::read(path).map_err(PolicyError::Io)?; + Self::parse(&bytes) + } + + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_POLICY_BYTES { + return Err(PolicyError::TooLarge); + } + let source = std::str::from_utf8(bytes).map_err(|_| PolicyError::InvalidUtf8)?; + let policy: Self = toml::from_str(source).map_err(PolicyError::Parse)?; + policy.validate()?; + Ok(policy) + } + + pub fn validate(&self) -> Result<(), PolicyError> { + if self.schema_version != POLICY_SCHEMA_VERSION { + return Err(PolicyError::UnsupportedSchemaVersion); + } + validate_project(&self.project)?; + validate_digest(&self.configuration_digest)?; + validate_image_reference(&self.image_reference)?; + if !(1..=MAX_FRESHNESS_SECONDS).contains(&self.max_age_seconds) { + return Err(PolicyError::InvalidField("max_age_seconds")); + } + if self.required_checks.is_empty() || self.required_checks.len() > MAX_REQUIRED_CHECKS { + return Err(PolicyError::InvalidField("required_checks")); + } + let mut checks = BTreeSet::new(); + for check in &self.required_checks { + validate_name("required_checks", check)?; + if !checks.insert(check) { + return Err(PolicyError::DuplicateValue("required_checks")); + } + } + if self.platforms.is_empty() || self.platforms.len() > MAX_PLATFORMS { + return Err(PolicyError::InvalidField("platforms")); + } + let mut platforms = BTreeSet::new(); + for platform in &self.platforms { + validate_name("platforms.host_os", &platform.host_os)?; + validate_name("platforms.host_arch", &platform.host_arch)?; + validate_name("platforms.runtime_kind", &platform.runtime_kind)?; + if !platforms.insert(( + &platform.host_os, + &platform.host_arch, + &platform.runtime_kind, + )) { + return Err(PolicyError::DuplicateValue("platforms")); + } + } + Ok(()) + } +} + +/// Strict policy version that reconstructs the normalized execution plan from +/// the trusted checkout before accepting a v2 receipt. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VerificationPolicyV1_1 { + pub schema_version: String, + pub project: String, + pub configuration_digest: String, + pub required_checks: Vec, + pub image_reference: String, + pub max_age_seconds: u64, + pub platforms: Vec, + pub trusted_config: String, + pub source_snapshot_strategy: SourceSnapshotStrategy, + pub supported_producers: Vec, + #[serde(default)] + pub revoked_producers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ProducerContractV1_1 { + pub name: String, + pub version: String, +} + +impl VerificationPolicyV1_1 { + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_POLICY_BYTES { + return Err(PolicyError::TooLarge); + } + let source = std::str::from_utf8(bytes).map_err(|_| PolicyError::InvalidUtf8)?; + let policy: Self = toml::from_str(source).map_err(PolicyError::Parse)?; + policy.validate()?; + Ok(policy) + } + + pub fn validate(&self) -> Result<(), PolicyError> { + if self.schema_version != TRUSTED_PLAN_POLICY_SCHEMA_VERSION { + return Err(PolicyError::UnsupportedSchemaVersion); + } + self.baseline().validate()?; + validate_trusted_config_path(&self.trusted_config)?; + if self.supported_producers.is_empty() { + return Err(PolicyError::InvalidField("supported_producers")); + } + let mut supported = BTreeSet::new(); + for producer in &self.supported_producers { + validate_producer_contract(producer)?; + if !supported.insert((&producer.name, &producer.version)) { + return Err(PolicyError::DuplicateValue("supported_producers")); + } + } + let mut revoked = BTreeSet::new(); + for producer in &self.revoked_producers { + validate_producer_contract(producer)?; + let key = (&producer.name, &producer.version); + if !revoked.insert(key) { + return Err(PolicyError::DuplicateValue("revoked_producers")); + } + if supported.contains(&key) { + return Err(PolicyError::DuplicateValue("producer_contracts")); + } + } + Ok(()) + } + + fn baseline(&self) -> VerificationPolicyV1 { + VerificationPolicyV1 { + schema_version: POLICY_SCHEMA_VERSION.to_owned(), + project: self.project.clone(), + configuration_digest: self.configuration_digest.clone(), + required_checks: self.required_checks.clone(), + image_reference: self.image_reference.clone(), + max_age_seconds: self.max_age_seconds, + platforms: self.platforms.clone(), + } + } + + fn load_trusted_plan( + &self, + policy_path: &Path, + ) -> Result { + let parent = policy_path.parent().ok_or(TrustedPlanError::PolicyPath)?; + let config_path = parent.join(&self.trusted_config); + let metadata = fs::symlink_metadata(&config_path).map_err(TrustedPlanError::Io)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(TrustedPlanError::UnsafeConfigurationPath); + } + ConfigV1::load(&config_path) + .and_then(ConfigV1::into_plan) + .map_err(TrustedPlanError::Config) + } +} + +pub fn verify_receipt_document( + bytes: &[u8], + policy: &VerificationPolicyV1, + expected_commit: &str, + evaluated_at_utc: &str, +) -> Result { + policy.validate().map_err(VerificationError::Policy)?; + validate_commit(expected_commit)?; + let evaluated_at = + parse_utc_seconds(evaluated_at_utc).ok_or(VerificationError::InvalidEvaluationTime)?; + let mut report = VerificationReportV1 { + schema_version: VERIFICATION_REPORT_SCHEMA_VERSION.to_owned(), + assurance_scope: "integrity_and_repository_policy_only".to_owned(), + evaluated_at_utc: evaluated_at_utc.to_owned(), + expected_commit: expected_commit.to_owned(), + receipt_id: None, + integrity_status: VerificationStatus::Fail, + policy_status: VerificationStatus::NotRun, + decision: VerificationDecision::Fail, + findings: Vec::new(), + }; + + if bytes.len() > MAX_RECEIPT_BYTES { + report.findings.push(finding( + "receipt.too_large", + "receipt", + "receipt exceeds the bounded verification input size", + )); + return Ok(report); + } + let document = match serde_json::from_slice::(bytes) { + Ok(value) => value, + Err(_) => { + report.findings.push(finding( + "receipt.parse_or_shape", + "receipt", + "receipt is not valid strict schema v1 or v2 JSON", + )); + return Ok(report); + } + }; + let Some(schema_version) = document + .pointer("/receipt/schema_version") + .and_then(serde_json::Value::as_str) + else { + report.findings.push(finding( + "receipt.parse_or_shape", + "receipt.schema_version", + "receipt schema version is missing or invalid", + )); + return Ok(report); + }; + let parsed = match schema_version { + "1.0" => serde_json::from_slice::(bytes).map(VerifiedReceipt::V1), + "2.0" => serde_json::from_slice::(bytes).map(VerifiedReceipt::V2), + _ => { + report.findings.push(finding( + "receipt.unsupported_schema", + "receipt.schema_version", + "receipt schema version is unsupported", + )); + return Ok(report); + } + }; + let envelope = match parsed { + Ok(envelope) => envelope, + Err(_) => { + report.findings.push(finding( + "receipt.parse_or_shape", + "receipt", + "receipt is not valid strict schema v1 or v2 JSON", + )); + return Ok(report); + } + }; + if let Err(error) = envelope.verify() { + let (code, message) = match error { + ReceiptError::UnsupportedSchemaVersion(_) => ( + "receipt.unsupported_schema", + "receipt schema version is unsupported", + ), + ReceiptError::DigestMismatch { .. } => ( + "receipt.digest_mismatch", + "receipt payload does not match its integrity identifier", + ), + _ => ( + "receipt.semantic_invalid", + "receipt violates schema semantic invariants", + ), + }; + report.findings.push(finding(code, "receipt", message)); + return Ok(report); + } + + report.receipt_id = Some(envelope.receipt_id().to_owned()); + report.integrity_status = VerificationStatus::Pass; + report.policy_status = VerificationStatus::Pass; + evaluate_policy( + envelope.view(), + policy, + expected_commit, + evaluated_at, + &mut report.findings, + ); + if !report.findings.is_empty() { + report.policy_status = VerificationStatus::Fail; + } + report.decision = if report.integrity_status == VerificationStatus::Pass + && report.policy_status == VerificationStatus::Pass + { + VerificationDecision::Pass + } else { + VerificationDecision::Fail + }; + Ok(report) +} + +/// Verify a receipt against the strict policy version selected from trusted +/// policy bytes. V1 keeps its original parser and behaviour; V2 is an +/// explicitly opt-in multi-runtime contract. +pub fn verify_receipt_document_for_policy( + bytes: &[u8], + policy: &VerificationPolicyDocument, + expected_commit: &str, + evaluated_at_utc: &str, +) -> Result { + match policy { + VerificationPolicyDocument::V1(policy) => { + verify_receipt_document(bytes, policy, expected_commit, evaluated_at_utc) + } + VerificationPolicyDocument::V1_1(_) => Err(VerificationError::TrustedPolicyPathRequired), + VerificationPolicyDocument::V2(policy) => crate::matrix::verify_matrix_receipt_document( + bytes, + policy, + expected_commit, + evaluated_at_utc, + ) + .map_err(|error| VerificationError::Matrix(error.to_string())), + } +} + +/// Verify a receipt against a policy loaded from a trusted checkout. Policy +/// version 1.1 resolves its configuration only relative to that policy file; +/// callers cannot substitute an arbitrary configuration path. +pub fn verify_receipt_document_for_policy_path( + bytes: &[u8], + policy_path: &Path, + expected_commit: &str, + evaluated_at_utc: &str, +) -> Result { + let policy = load_verification_policy_document(policy_path) + .map_err(|error| VerificationError::PolicyDocument(error.to_string()))?; + match &policy { + VerificationPolicyDocument::V1_1(policy) => { + let trusted_plan = policy + .load_trusted_plan(policy_path) + .map_err(VerificationError::TrustedPlan)?; + verify_trusted_plan_receipt_document( + bytes, + policy, + &trusted_plan, + expected_commit, + evaluated_at_utc, + ) + } + _ => verify_receipt_document_for_policy(bytes, &policy, expected_commit, evaluated_at_utc), + } +} + +/// Validate every trusted input selected by a policy path before receipt I/O. +/// This prevents a missing or malformed receipt from bypassing policy v1.1's +/// trusted-configuration requirement. +pub fn validate_verification_policy_path(policy_path: &Path) -> Result<(), VerificationError> { + let policy = load_verification_policy_document(policy_path) + .map_err(|error| VerificationError::PolicyDocument(error.to_string()))?; + if let VerificationPolicyDocument::V1_1(policy) = policy { + policy + .load_trusted_plan(policy_path) + .map_err(VerificationError::TrustedPlan)?; + } + Ok(()) +} + +fn verify_trusted_plan_receipt_document( + bytes: &[u8], + policy: &VerificationPolicyV1_1, + trusted_plan: &ExecutionPlanEnvelopeV1, + expected_commit: &str, + evaluated_at_utc: &str, +) -> Result { + let mut report = + verify_receipt_document(bytes, &policy.baseline(), expected_commit, evaluated_at_utc)?; + report.assurance_scope = "integrity_and_trusted_plan_policy".to_owned(); + if report.integrity_status != VerificationStatus::Pass { + return Ok(report); + } + if trusted_plan.plan_digest != policy.configuration_digest { + report.findings.push(finding( + "policy.trusted_config_digest", + "trusted_config", + "trusted configuration does not reconstruct the policy execution-plan digest", + )); + } + let receipt = match serde_json::from_slice::(bytes) { + Ok(receipt) => receipt, + Err(_) => { + report.findings.push(finding( + "policy.receipt_schema", + "receipt.schema_version", + "trusted-plan policy requires a strict receipt v2", + )); + finalize_trusted_plan_report(&mut report); + return Ok(report); + } + }; + compare_execution_plan( + &trusted_plan.plan, + &receipt.receipt.execution_plan, + &mut report.findings, + )?; + if receipt.receipt.source_snapshot.strategy != policy.source_snapshot_strategy { + report.findings.push(finding( + "policy.source_snapshot_strategy", + "source_snapshot.strategy", + "receipt source snapshot strategy is not accepted by trusted policy", + )); + } + let producer = ( + &receipt.receipt.producer.name, + &receipt.receipt.producer.version, + ); + if policy + .revoked_producers + .iter() + .any(|candidate| (&candidate.name, &candidate.version) == producer) + { + report.findings.push(finding( + "policy.producer_revoked", + "producer", + "receipt producer is explicitly revoked by trusted policy", + )); + } else if !policy + .supported_producers + .iter() + .any(|candidate| (&candidate.name, &candidate.version) == producer) + { + report.findings.push(finding( + "policy.producer_unsupported", + "producer", + "receipt producer is not supported by trusted policy", + )); + } + finalize_trusted_plan_report(&mut report); + Ok(report) +} + +fn finalize_trusted_plan_report(report: &mut VerificationReportV1) { + if !report.findings.is_empty() { + report.policy_status = VerificationStatus::Fail; + } + report.decision = if report.integrity_status == VerificationStatus::Pass + && report.policy_status == VerificationStatus::Pass + { + VerificationDecision::Pass + } else { + VerificationDecision::Fail + }; +} + +pub fn receipt_input_failure_report( + expected_commit: &str, + evaluated_at_utc: &str, +) -> Result { + validate_commit(expected_commit)?; + parse_utc_seconds(evaluated_at_utc).ok_or(VerificationError::InvalidEvaluationTime)?; + Ok(VerificationReportV1 { + schema_version: VERIFICATION_REPORT_SCHEMA_VERSION.to_owned(), + assurance_scope: "integrity_and_repository_policy_only".to_owned(), + evaluated_at_utc: evaluated_at_utc.to_owned(), + expected_commit: expected_commit.to_owned(), + receipt_id: None, + integrity_status: VerificationStatus::Fail, + policy_status: VerificationStatus::NotRun, + decision: VerificationDecision::Fail, + findings: vec![finding( + "receipt.read_failed", + "receipt", + "receipt could not be read from the caller-supplied path", + )], + }) +} + +struct ReceiptPolicyView<'a> { + repository: &'a RepositoryEvidence, + run: &'a RunEvidence, + platform: &'a PlatformEvidence, + configuration_digest: &'a str, + checks: &'a [CheckEvidence], + overall_status: EvidenceStatus, +} + +// Direct ownership keeps both versioned receipt validation paths uniform. +#[allow(clippy::large_enum_variant)] +enum VerifiedReceipt { + V1(ReceiptEnvelopeV1), + V2(ReceiptEnvelopeV2), +} + +impl VerifiedReceipt { + fn verify(&self) -> Result<(), ReceiptError> { + match self { + Self::V1(value) => value.verify(), + Self::V2(value) => value.verify(), + } + } + + fn receipt_id(&self) -> &str { + match self { + Self::V1(value) => &value.receipt_id, + Self::V2(value) => &value.receipt_id, + } + } + + fn view(&self) -> ReceiptPolicyView<'_> { + match self { + Self::V1(value) => ReceiptPolicyView { + repository: &value.receipt.repository, + run: &value.receipt.run, + platform: &value.receipt.platform, + configuration_digest: &value.receipt.configuration_digest, + checks: &value.receipt.checks, + overall_status: value.receipt.overall_status, + }, + Self::V2(value) => ReceiptPolicyView { + repository: &value.receipt.repository, + run: &value.receipt.run, + platform: &value.receipt.platform, + configuration_digest: &value.receipt.configuration_digest, + checks: &value.receipt.checks, + overall_status: value.receipt.overall_status, + }, + } + } +} + +fn evaluate_policy( + receipt: ReceiptPolicyView<'_>, + policy: &VerificationPolicyV1, + expected_commit: &str, + evaluated_at: i64, + findings: &mut Vec, +) { + check_equal( + &receipt.repository.repository, + &policy.project, + "policy.repository", + "repository.repository", + "receipt project does not match repository policy", + findings, + ); + check_equal( + &receipt.repository.commit_sha, + expected_commit, + "policy.commit", + "repository.commit_sha", + "receipt commit does not match the externally supplied commit", + findings, + ); + if receipt.repository.dirty { + findings.push(finding( + "policy.dirty", + "repository.dirty", + "repository policy requires a clean checkout", + )); + } + check_equal( + receipt.configuration_digest, + &policy.configuration_digest, + "policy.configuration", + "configuration_digest", + "receipt configuration digest does not match repository policy", + findings, + ); + check_equal( + &receipt.platform.image_reference, + &policy.image_reference, + "policy.image", + "platform.image_reference", + "receipt image does not match repository policy", + findings, + ); + if !policy.platforms.iter().any(|accepted| { + accepted.host_os == receipt.platform.host_os + && accepted.host_arch == receipt.platform.host_arch + && accepted.runtime_kind == receipt.platform.runtime_kind + }) { + findings.push(finding( + "policy.platform", + "platform", + "receipt platform tuple is not accepted by repository policy", + )); + } + if receipt.overall_status != EvidenceStatus::Pass { + findings.push(finding( + "policy.overall_status", + "overall_status", + "repository policy requires an overall PASS receipt", + )); + } + + let actual_required: BTreeSet<_> = receipt + .checks + .iter() + .filter(|check| check.required) + .map(|check| check.id.as_str()) + .collect(); + let expected_required: BTreeSet<_> = + policy.required_checks.iter().map(String::as_str).collect(); + if actual_required != expected_required { + findings.push(finding( + "policy.required_check_set", + "checks", + "required check set does not exactly match repository policy", + )); + } + if receipt.checks.iter().any(|check| { + expected_required.contains(check.id.as_str()) && check.status != EvidenceStatus::Pass + }) { + findings.push(finding( + "policy.required_check_result", + "checks.status", + "one or more policy-required checks did not PASS", + )); + } + + if let Some(finished_at) = parse_utc_seconds(&receipt.run.finished_at_utc) { + if finished_at > evaluated_at { + findings.push(finding( + "policy.future_receipt", + "run.finished_at_utc", + "receipt completion time is later than verification time", + )); + } else if evaluated_at - finished_at > policy.max_age_seconds as i64 { + findings.push(finding( + "policy.stale_receipt", + "run.finished_at_utc", + "receipt exceeds repository freshness policy", + )); + } + } else { + findings.push(finding( + "policy.invalid_time", + "run.finished_at_utc", + "receipt completion time cannot be evaluated", + )); + } +} + +fn check_equal( + actual: &str, + expected: &str, + code: &str, + field: &str, + message: &str, + findings: &mut Vec, +) { + if actual != expected { + findings.push(finding(code, field, message)); + } +} + +const MAX_PLAN_FINDINGS: usize = 128; + +fn compare_execution_plan( + trusted: &ExecutionPlanV1, + receipt: &ExecutionPlanV1, + findings: &mut Vec, +) -> Result<(), VerificationError> { + let trusted = serde_json::to_value(trusted) + .map_err(ReceiptError::Serialization) + .map_err(VerificationError::Receipt)?; + let receipt = serde_json::to_value(receipt) + .map_err(ReceiptError::Serialization) + .map_err(VerificationError::Receipt)?; + compare_plan_value(&trusted, &receipt, "", findings); + Ok(()) +} + +fn compare_plan_value( + trusted: &serde_json::Value, + receipt: &serde_json::Value, + path: &str, + findings: &mut Vec, +) { + if findings.len() >= MAX_PLAN_FINDINGS || trusted == receipt { + return; + } + match (trusted, receipt) { + (serde_json::Value::Object(trusted), serde_json::Value::Object(receipt)) => { + let keys: BTreeSet<_> = trusted.keys().chain(receipt.keys()).collect(); + for key in keys { + let child_path = format!("{path}/{}", escape_json_pointer(key)); + match (trusted.get(key), receipt.get(key)) { + (Some(trusted), Some(receipt)) => { + compare_plan_value(trusted, receipt, &child_path, findings) + } + _ => push_plan_finding(&child_path, findings), + } + if findings.len() >= MAX_PLAN_FINDINGS { + break; + } + } + } + (serde_json::Value::Array(trusted), serde_json::Value::Array(receipt)) => { + let longest = trusted.len().max(receipt.len()); + for index in 0..longest { + let child_path = format!("{path}/{index}"); + match (trusted.get(index), receipt.get(index)) { + (Some(trusted), Some(receipt)) => { + compare_plan_value(trusted, receipt, &child_path, findings) + } + _ => push_plan_finding(&child_path, findings), + } + if findings.len() >= MAX_PLAN_FINDINGS { + break; + } + } + } + _ => push_plan_finding(path, findings), + } +} + +fn escape_json_pointer(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +fn push_plan_finding(path: &str, findings: &mut Vec) { + if findings.len() < MAX_PLAN_FINDINGS { + findings.push(finding( + "policy.execution_plan.field_mismatch", + &format!("execution_plan{path}"), + "receipt execution-plan field does not match trusted configuration", + )); + } +} + +pub fn verification_policy_schema_json() -> Result { + serde_json::to_string_pretty(&schema_for!(VerificationPolicyV1)) + .map_err(ReceiptError::Serialization) + .map_err(VerificationError::Receipt) +} + +pub fn trusted_plan_policy_schema_json() -> Result { + serde_json::to_string_pretty(&schema_for!(VerificationPolicyV1_1)) + .map_err(ReceiptError::Serialization) + .map_err(VerificationError::Receipt) +} + +pub fn verification_report_schema_json() -> Result { + serde_json::to_string_pretty(&schema_for!(VerificationReportV1)) + .map_err(ReceiptError::Serialization) + .map_err(VerificationError::Receipt) +} + +pub fn system_evaluated_at_utc() -> Result { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| VerificationError::InvalidEvaluationTime)? + .as_secs(); + format_unix_utc(seconds).ok_or(VerificationError::InvalidEvaluationTime) +} + +fn validate_project(value: &str) -> Result<(), PolicyError> { + if value.len() > 255 || value.chars().any(char::is_control) { + return Err(PolicyError::InvalidField("project")); + } + let mut parts = value.split('/'); + let owner = parts.next().unwrap_or_default(); + let repository = parts.next().unwrap_or_default(); + if owner.is_empty() + || repository.is_empty() + || parts.next().is_some() + || !valid_name(owner) + || !valid_name(repository) + { + return Err(PolicyError::InvalidField("project")); + } + Ok(()) +} + +fn validate_name(field: &'static str, value: &str) -> Result<(), PolicyError> { + if value.len() > 128 || !valid_name(value) { + return Err(PolicyError::InvalidField(field)); + } + Ok(()) +} + +fn valid_name(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn validate_digest(value: &str) -> Result<(), PolicyError> { + let Some(hex) = value.strip_prefix("sha256:") else { + return Err(PolicyError::InvalidField("configuration_digest")); + }; + if hex.len() != 64 + || !hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(PolicyError::InvalidField("configuration_digest")); + } + Ok(()) +} + +fn validate_image_reference(value: &str) -> Result<(), PolicyError> { + let Some((name, digest)) = value.rsplit_once('@') else { + return Err(PolicyError::InvalidField("image_reference")); + }; + if name.is_empty() || name.contains('@') || name.chars().any(char::is_control) { + return Err(PolicyError::InvalidField("image_reference")); + } + validate_digest(digest).map_err(|_| PolicyError::InvalidField("image_reference")) +} + +fn validate_trusted_config_path(value: &str) -> Result<(), PolicyError> { + if value.is_empty() + || value.len() > 255 + || value.starts_with('/') + || value.starts_with('~') + || value.contains('\\') + || value.contains(':') + || value + .split('/') + .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) + { + Err(PolicyError::InvalidField("trusted_config")) + } else { + Ok(()) + } +} + +fn validate_producer_contract(value: &ProducerContractV1_1) -> Result<(), PolicyError> { + validate_name("producers.name", &value.name)?; + if value.version.is_empty() + || value.version.len() > 128 + || value.version.chars().any(char::is_control) + { + Err(PolicyError::InvalidField("producers.version")) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{format_unix_utc, parse_utc_seconds}; + + #[test] + fn strict_utc_conversion_round_trips_known_instants() { + for (seconds, timestamp) in [ + (0, "1970-01-01T00:00:00Z"), + (951_827_696, "2000-02-29T12:34:56Z"), + (1_785_542_400, "2026-08-01T00:00:00Z"), + ] { + assert_eq!(format_unix_utc(seconds).as_deref(), Some(timestamp)); + assert_eq!(parse_utc_seconds(timestamp), Some(seconds as i64)); + } + assert_eq!(parse_utc_seconds("2026-02-29T00:00:00Z"), None); + assert_eq!(parse_utc_seconds("2026-08-01T00:00:00+00:00"), None); + } +} diff --git a/crates/ccp-verifier/Cargo.toml b/crates/ccp-verifier/Cargo.toml new file mode 100644 index 0000000..9a19586 --- /dev/null +++ b/crates/ccp-verifier/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ccp-verifier" +version = "0.1.0" +edition = "2024" +rust-version = "1.87" +description = "Standalone receipt verifier for Commit CI Preflight." +license = "Apache-2.0" + +[dependencies] +ccp-core = { path = "../ccp-core" } +clap = { version = "4.6.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] } + +[dev-dependencies] +serde_json = "1.0.151" diff --git a/crates/ccp-verifier/src/main.rs b/crates/ccp-verifier/src/main.rs new file mode 100644 index 0000000..d873162 --- /dev/null +++ b/crates/ccp-verifier/src/main.rs @@ -0,0 +1,187 @@ +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +#[derive(Debug, Parser)] +#[command( + name = "ccp-verifier", + version, + about = "Verify Commit CI Preflight receipts" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Verify(VerifyArgs), + Schema(SchemaArgs), +} + +#[derive(Debug, Args)] +struct VerifyArgs { + #[arg( + long, + default_value = ".ccp/receipt.json", + help = "receipt path (default: .ccp/receipt.json)" + )] + receipt: PathBuf, + #[arg( + long, + default_value = ".commit-ci-policy.toml", + help = "policy path (default: .commit-ci-policy.toml)" + )] + policy: PathBuf, + #[arg(long)] + expected_commit: String, + #[arg(long)] + evaluated_at_utc: Option, + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct SchemaArgs { + #[arg(long, value_enum)] + kind: SchemaKind, +} + +#[derive(Clone, Debug, ValueEnum)] +#[value(rename_all = "kebab-case")] +enum SchemaKind { + ReceiptV1, + ReceiptV2, + PolicyV1, + PolicyV1_1, + PolicyV2, + VerificationReportV1, +} + +fn main() { + let result = match Cli::parse().command { + Command::Verify(args) => verify(args), + Command::Schema(args) => schema(args), + }; + if let Err((code, message)) = result { + eprintln!("error: {message}"); + std::process::exit(code); + } +} + +fn verify(args: VerifyArgs) -> Result<(), (i32, String)> { + ccp_core::verify::validate_verification_policy_path(&args.policy) + .map_err(|error| (verification_error_exit_code(&error), error.to_string()))?; + let evaluated = match args.evaluated_at_utc { + Some(value) => value, + None => ccp_core::verify::system_evaluated_at_utc() + .map_err(|error| (verification_error_exit_code(&error), error.to_string()))?, + }; + let bytes = std::fs::read(&args.receipt); + let report = match bytes { + Ok(bytes) => ccp_core::verify::verify_receipt_document_for_policy_path( + &bytes, + &args.policy, + &args.expected_commit, + &evaluated, + ) + .map_err(|error| (verification_error_exit_code(&error), error.to_string()))?, + Err(_) => ccp_core::verify::receipt_input_failure_report(&args.expected_commit, &evaluated) + .map_err(|error| (verification_error_exit_code(&error), error.to_string()))?, + }; + let output = if args.json { + let mut bytes = report + .canonical_bytes() + .map_err(|error| (70, error.to_string()))?; + bytes.push(b'\n'); + bytes + } else { + let mut text = format!( + "Integrity: {:?}\nPolicy: {:?}\n", + report.integrity_status, report.policy_status + ); + for finding in &report.findings { + text.push_str(&format!( + " - {} [{}]: {}\n", + finding.code, finding.field, finding.message + )); + } + text.push_str(&format!("Decision: {:?}\n", report.decision)); + text.into_bytes() + }; + print_bytes(&output).map_err(|error| (70, error.to_string()))?; + if report.decision == ccp_core::verify::VerificationDecision::Pass { + Ok(()) + } else { + Err((3, "verification completed with Fail".to_owned())) + } +} + +fn verification_error_exit_code(error: &ccp_core::verify::VerificationError) -> i32 { + match error { + ccp_core::verify::VerificationError::Receipt(_) => 70, + ccp_core::verify::VerificationError::Policy(_) + | ccp_core::verify::VerificationError::PolicyDocument(_) + | ccp_core::verify::VerificationError::TrustedPlan(_) + | ccp_core::verify::VerificationError::TrustedPolicyPathRequired + | ccp_core::verify::VerificationError::InvalidExpectedCommit + | ccp_core::verify::VerificationError::InvalidEvaluationTime + | ccp_core::verify::VerificationError::Matrix(_) => 2, + } +} + +fn schema(args: SchemaArgs) -> Result<(), (i32, String)> { + let result = match args.kind { + SchemaKind::ReceiptV1 => { + ccp_core::receipt::receipt_schema_json().map_err(|e| e.to_string()) + } + SchemaKind::ReceiptV2 => { + ccp_core::schema::combined_receipt_v2_schema_json().map_err(|e| e.to_string()) + } + SchemaKind::PolicyV1 => { + ccp_core::verify::verification_policy_schema_json().map_err(|e| e.to_string()) + } + SchemaKind::PolicyV1_1 => { + ccp_core::verify::trusted_plan_policy_schema_json().map_err(|e| e.to_string()) + } + SchemaKind::PolicyV2 => { + ccp_core::schema::matrix_policy_schema_json().map_err(|e| e.to_string()) + } + SchemaKind::VerificationReportV1 => { + ccp_core::verify::verification_report_schema_json().map_err(|e| e.to_string()) + } + } + .map_err(|error| (70, error))?; + print_bytes(result.as_bytes()).map_err(|error| (70, error.to_string())) +} + +fn print_bytes(bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + std::io::stdout().write_all(bytes) +} + +#[cfg(test)] +mod tests { + #[test] + fn verification_exit_code_preserves_root_mapping() { + use ccp_core::errors::{PolicyError, TrustedPlanError}; + use ccp_core::verify::VerificationError; + assert_eq!( + super::verification_error_exit_code(&VerificationError::Receipt( + ccp_core::errors::ReceiptError::EmptyField("x"), + )), + 70 + ); + for error in [ + VerificationError::Policy(PolicyError::TooLarge), + VerificationError::PolicyDocument("x".into()), + VerificationError::TrustedPlan(TrustedPlanError::PolicyPath), + VerificationError::TrustedPolicyPathRequired, + VerificationError::InvalidExpectedCommit, + VerificationError::InvalidEvaluationTime, + VerificationError::Matrix("x".into()), + ] { + assert_eq!(super::verification_error_exit_code(&error), 2); + } + } +} diff --git a/crates/ccp-verifier/tests/schema_cli.rs b/crates/ccp-verifier/tests/schema_cli.rs new file mode 100644 index 0000000..3d3588d --- /dev/null +++ b/crates/ccp-verifier/tests/schema_cli.rs @@ -0,0 +1,41 @@ +use std::process::Command; + +fn run(kind: &str) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_ccp-verifier")) + .args(["schema", "--kind", kind]) + .output() + .expect("schema CLI") +} + +#[test] +fn schema_kinds_emit_checked_in_bytes() { + for (kind, path) in [ + ("receipt-v1", "../../schema/receipt-v1.schema.json"), + ("receipt-v2", "../../schema/receipt-v2.schema.json"), + ("policy-v1", "../../schema/policy-v1.schema.json"), + ("policy-v1-1", "../../schema/policy-v1_1.schema.json"), + ("policy-v2", "../../schema/policy-v2.schema.json"), + ( + "verification-report-v1", + "../../schema/verification-report-v1.schema.json", + ), + ] { + let output = run(kind); + assert_eq!(output.status.code(), Some(0), "{kind}"); + let expected = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path.strip_prefix("../../").unwrap_or(path)); + assert_eq!( + output.stdout, + std::fs::read(expected).expect("schema fixture") + ); + assert!(output.stderr.is_empty()); + } +} + +#[test] +fn unknown_schema_kind_is_usage_error() { + let output = run("unknown"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); +} diff --git a/crates/ccp-verifier/tests/verify_cli.rs b/crates/ccp-verifier/tests/verify_cli.rs new file mode 100644 index 0000000..1ce73d7 --- /dev/null +++ b/crates/ccp-verifier/tests/verify_cli.rs @@ -0,0 +1,51 @@ +use std::process::Command; + +#[test] +fn help_exposes_only_verification_commands() { + let output = Command::new(env!("CARGO_BIN_EXE_ccp-verifier")) + .arg("--help") + .output() + .expect("verifier help"); + assert_eq!(output.status.code(), Some(0)); + let help = String::from_utf8(output.stdout).expect("help UTF-8"); + assert!(help.contains("verify")); + assert!(help.contains("schema")); + for forbidden in [ + "run", + "plan", + "doctor", + "dry-run", + "benchmark", + "guard", + "migrate", + ] { + assert!(!help.contains(forbidden), "unexpected command: {forbidden}"); + } +} + +#[test] +fn verify_help_documents_compatibility_defaults() { + let output = Command::new(env!("CARGO_BIN_EXE_ccp-verifier")) + .args(["verify", "--help"]) + .output() + .expect("verify help"); + let help = String::from_utf8(output.stdout).expect("help UTF-8"); + assert!(help.contains(".ccp/receipt.json")); + assert!(help.contains(".commit-ci-policy.toml")); +} + +#[test] +fn verify_requires_explicit_commit_and_reports_usage_error() { + let output = Command::new(env!("CARGO_BIN_EXE_ccp-verifier")) + .args([ + "verify", + "--receipt", + "receipt.json", + "--policy", + "policy.toml", + ]) + .output() + .expect("verify CLI"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 46cdf25..2e3b102 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,6 +14,13 @@ immutable source snapshot work is documented in [`docs/INVARIANT_EVIDENCE_MATRIX.md`](INVARIANT_EVIDENCE_MATRIX.md). This page includes the implemented 0.1.0 source candidate and T2 source boundary. +The M2 workspace also provides `ccp-core` as the canonical protocol and pure +verification library and `ccp-verifier` as a bounded local source-build +binary. The latter exposes only `verify` and schema commands; it is not a +published binary or a distribution claim. `verify-benchmark` remains a root +`commit-ci-preflight` command. Static and multi-platform distribution are +deferred to M3. + ## System purpose Commit CI Preflight moves deterministic, resource-intensive checks to diff --git a/docs/BETA_SUPPORT.md b/docs/BETA_SUPPORT.md index e13a666..625a39d 100644 --- a/docs/BETA_SUPPORT.md +++ b/docs/BETA_SUPPORT.md @@ -38,6 +38,9 @@ The beta candidate implements: - a lightweight GitHub receipt gate; - a read-only, non-executing GitHub Actions migration assistant; - deterministic benchmark and native evidence contracts. +- a bounded `ccp-verifier` local source-build surface for `verify` and schema + inspection; no published verifier binary or static multi-platform artifact + is claimed (distribution is deferred to M3). ## Intentional limitations diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 17c8db8..35d938b 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -112,6 +112,15 @@ argv and mounts but does not execute checks. Follow the [end-to-end tutorial](TUTORIAL.md) before using the tool on an important repository. +## Local verifier source build + +The workspace package `ccp-verifier` can be built locally with +`cargo build --locked --bin ccp-verifier`. Its bounded `verify` and `schema` +commands are for local inspection and CI experiments. This is not a published +binary or a platform-availability claim; static and multi-platform +distribution remain M3 work. `verify-benchmark` remains a root +`commit-ci-preflight` command. + ## Uninstall For the default Cargo installation: diff --git a/docs/PRODUCT_ROADMAP.md b/docs/PRODUCT_ROADMAP.md index c7d3e54..25373a7 100644 --- a/docs/PRODUCT_ROADMAP.md +++ b/docs/PRODUCT_ROADMAP.md @@ -190,8 +190,8 @@ Deliverables: - add a dedicated `ccp-verifier` binary with only `verify` and schema surfaces; - prohibit runner/runtime dependencies from its resolved dependency graph; - add equivalence, maximum-input, malformed-input, and binary-size receipts; -- produce a static `x86_64-unknown-linux-musl` verifier candidate locally or in - an explicitly authorized release workflow. +- record a local verifier candidate only; static `x86_64-unknown-linux-musl` + and multi-platform distribution are deferred to M3. Exit gate: @@ -201,6 +201,10 @@ Exit gate: from the verifier binary; - no receipt v1 or policy v1 compatibility break. +M2 availability is source-build availability only. It does not imply binary +distribution, platform qualification, or producer identity. The +`verify-benchmark` command remains part of the root CLI. + ### PR 3 — Slim trusted GitHub gate Deliverables: diff --git a/docs/RELIABILITY_HARDENING_PLAN.md b/docs/RELIABILITY_HARDENING_PLAN.md index a9285e7..0ea05db 100644 --- a/docs/RELIABILITY_HARDENING_PLAN.md +++ b/docs/RELIABILITY_HARDENING_PLAN.md @@ -268,7 +268,7 @@ The target workspace is: ```text crates/ - ccp-contract/ normalized plans, receipts, policy, schemas, digests + ccp-core/ normalized plans, receipts, policy, schemas, digests ccp-verifier/ bounded trusted verification only ccp-git-snapshot/ immutable commit materialization and source manifest ccp-supervisor/ process containment, output drain, deadlines, cleanup @@ -281,12 +281,12 @@ crates/ Dependency rules: ```text -ccp-contract <- ccp-verifier -ccp-contract <- ccp-git-snapshot -ccp-contract <- ccp-supervisor -ccp-contract <- ccp-runtime-docker -ccp-contract <- ccp-state -ccp-contract + adapters <- ccp-runner <- ccp-cli +ccp-core <- ccp-verifier +ccp-core <- ccp-git-snapshot +ccp-core <- ccp-supervisor +ccp-core <- ccp-runtime-docker +ccp-core <- ccp-state +ccp-core + adapters <- ccp-runner <- ccp-cli ``` `ccp-verifier` must not depend on the runner or any runtime/state adapter. The @@ -738,7 +738,7 @@ Exit gate: Deliverables: -- incremental workspace split beginning with `ccp-contract` and `ccp-verifier`; +- incremental workspace split beginning with `ccp-core` and `ccp-verifier`; - minimal verifier binary with a reviewed dependency allowlist; - GitHub gate builds or retrieves only the trusted verifier artifact; - evidence status records source commit, evidence commit/blob digest, receipt ID, diff --git a/docs/superpowers/plans/2026-08-29-independent-verifier.md b/docs/superpowers/plans/2026-08-29-independent-verifier.md new file mode 100644 index 0000000..6bf4b13 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-independent-verifier.md @@ -0,0 +1,622 @@ +# M2 Physically Independent Verifier Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract CCP's receipt and policy verification trust root into `ccp-core` and ship a dedicated `ccp-verifier` binary without changing existing receipt bytes, schemas, public Rust paths, root CLI output, or exit behavior. + +**Architecture:** Keep `commit-ci-preflight` as the root workspace package and compatibility facade. Define protocol and verification types once in `ccp-core`; make the root runner and thin `ccp-verifier` binary depend inward on that crate. Split pure Matrix contract/verification code from Matrix execution before moving the verification dispatcher. + +**Tech Stack:** Rust edition 2024, MSRV 1.87, Cargo resolver 3 workspace, Clap, Serde, Schemars, SHA-256, TOML, golden JSON/TOML/schema fixtures. + +**Spec:** `docs/superpowers/specs/2026-08-29-independent-verifier-design.md` + +## Global Constraints + +- All shell commands begin with `rtk`. +- Work only in `/Users/marco1/Documents/CODICE con VS CODE/ccp-worktrees/independent-verifier-v1`, based on exact source `6ff736b1e2a1dfde8778330efdd4b82c845d45e7`. +- Use TDD for every behavior or boundary change: focused RED, minimal GREEN, focused review, then proportional validation. +- Preserve all existing receipt, policy, report, schema, canonical-byte, public-error, root-CLI, and exit-code behavior. +- Approved Task 4 exception: pure Matrix methods change their pre-1.0 Rust + error return from root `MatrixError` to core `MatrixContractError`. Root + `MatrixError` variants/displays, CLI output/exit codes, and all wire/schema + bytes remain unchanged through an exhaustive root adapter. +- Define every protocol type once. Compatibility uses `pub use`, never duplicated look-alike structs or enums. +- `ccp-core` direct dependencies are limited to `schemars`, `serde`, `serde_json`, `sha2`, and `toml`. +- `ccp-verifier` normal dependencies are limited to `ccp-core` and narrowly + configured `clap`; `serde_json` is allowed only as a dev-dependency for CLI + output assertions. +- The verifier dependency closure must exclude the root package, Docker/runtime, process, cache, admission, resource, benchmark, GitHub migration, `ctrlc`, `fs2`, `process-wrap`, `nix`, `saphyr`, and `saphyr-parser`. +- Keep `verify-benchmark`, runner config/plan commands, GitHub Actions migration, publication, and all heavy execution out of `ccp-verifier`. +- User-visible changes update `CHANGELOG.md`. +- Local commits, push, PR, CCP, evidence publication, ready transition, and merge remain separate gates. +- Deterministic validation precedes any Docker, network, native, CCP, or publication action. +- Full workspace suites and release builds remain behind the exact live-host + `guard exec` authorization required by the global CCP operator contract. + +--- + +### Task 1: Freeze the M2 compatibility envelope + +**Files:** +- Create: `tests/fixtures/m2-compatibility-envelope-v1.json` +- Create: `tests/independent_verifier_contract.rs` +- Create: `tests/public_api_compat_contract.rs` +- Modify: `tests/receipt_contract.rs` +- Modify: `tests/verification_contract.rs` + +**Interfaces:** +- Consumes: existing fixture/schema files and root public modules. +- Produces: `M2_COMPATIBILITY_ENVELOPE_V1`, a checked-in hash manifest and executable pre-extraction baseline. + +- [ ] **Step 1: Add the fixture/hash manifest** + + Record the exact SHA-256 values inventoried at the base head, including: + + ```json + { + "schema_version": "1.0", + "source_head": "6ff736b1e2a1dfde8778330efdd4b82c845d45e7", + "files": { + "tests/fixtures/receipt-v1-pass.json": "f6824b28ded398d26620f5cc53c8e891997f909ff83b6ff7e17eef2ca821b017", + "tests/fixtures/receipt-v2-pass.json": "35d15b81c281f13fe07a49cf20a1aa942ab07f3e8b2ddbd400d9747e37eba8fe", + "tests/fixtures/policy-v1.toml": "21d0f45765ba8bc4ea1f443ad0fa76093cf5edeb7b98f181e50e34052ca8bac3", + "tests/fixtures/policy-v1_1-trusted-plan.toml": "f9c3293ba7ddeb100a85680d1b931b2246621fcc62314dffd06c4a4e16d8ca79", + "tests/fixtures/policy-v1_1-trusted-plan-altered.toml": "39798543fa24b779b415472188d588f3901f142b216424af4f340275bd4e36d3", + "tests/fixtures/policy-v1_1-missing-config.toml": "f30f7caea1cd37bd3bcd206abfbc70b8041df2ff7a0b437d17d01f7cfd9fffa1", + "tests/fixtures/policy-v2-legacy-compatible.toml": "846a94bd6b298ca1a35a4c0f39bed31ae08d1b94e448fa82fc058ea16550afec", + "tests/fixtures/plan-v2-current-default.stdout.json": "6c86ecad1b7c213945656282aa1f2844909302a13e1a585c3a2c93be6081458a", + "tests/fixtures/historical-verifier-044697.provenance.json": "cd75dea3593307e6dbf72abe5773e3031056a3c61033caeba6c7bea2a4a79838", + "tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json": "dc6f4f409fe31003cf1f50fe895ca7a114a614ca4eb3c7f526da02981abd9e1f", + "schema/receipt-v1.schema.json": "684b81b685a6013181e3fdd4bcf29573c01959da5e510bc158d5f94b8aeb8abc", + "schema/receipt-v2.schema.json": "bf315a2609366adc94520f5e8966d3048529e86759e66701a544be10eca9adbf", + "schema/policy-v1.schema.json": "e76d92f8a328c714ace3aab8c7c88e1ef58c4092ca48961fd2e57ca347b53d79", + "schema/policy-v1_1.schema.json": "b06ee6753cab21db0a4cca5bfbdbd90ec2cdeccd2a7d2eaaed68b826d0944b00", + "schema/policy-v2.schema.json": "2cc87c4bbfdbe787144a4ff96ff2a20dd004525f704f9f1cbd80117225426316", + "schema/verification-report-v1.schema.json": "67ec9524749d471d6a23e22ba0f9d52377273448f5c25ef318fbbd1a9f8f9b23" + } + } + ``` + +- [ ] **Step 2: Add a green baseline hash test** + + Parse the manifest, hash each repository-relative file with `sha2::Sha256`, + and assert exact equality. Reject absolute paths and duplicate entries. + Negative fixtures not listed above remain behavior-pinned by the explicit + malformed/oversized/stale/mutation tests rather than byte-pinned. + +- [ ] **Step 3: Freeze root API/error behavior** + + Add compile assertions that import the frozen receipt, normalized-plan, + policy, report, Matrix, and error families from `commit_ci_preflight`. + Record an explicit path/type matrix in the test. Pattern-match every adopted + public error variant and assert the adopted `Display`, `source()`, and + `Send + Sync` behavior for all constructible cases. This task does not import + `ccp_core`; cross-crate type identity begins only after Task 2 adds that + package as a root dev-dependency. + +- [ ] **Step 4: Run the focused baseline** + + ```console + rtk cargo test --locked --test independent_verifier_contract --test public_api_compat_contract --test receipt_contract --test verification_contract + ``` + + Expected: PASS on the pre-extraction root package. + +- [ ] **Step 5: Request the local commit gate** + + Proposed commit: `test: freeze verifier compatibility envelope`. + +### Task 2: Introduce the workspace and dependency-boundary RED/GREEN cycle + +**Files:** +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Create: `crates/ccp-core/Cargo.toml` +- Create: `crates/ccp-core/src/lib.rs` +- Create: `crates/ccp-verifier/Cargo.toml` +- Create: `crates/ccp-verifier/src/main.rs` +- Modify: `tests/independent_verifier_contract.rs` + +**Interfaces:** +- Consumes: Task 1 compatibility manifest. +- Produces: workspace members named `ccp-core` and `ccp-verifier`; no verifier behavior yet. + +- [ ] **Step 1: Write the failing workspace-boundary test** + + Add assertions equivalent to: + + ```rust + assert_eq!(workspace_manifest_paths(), [ + "Cargo.toml", + "crates/ccp-core/Cargo.toml", + "crates/ccp-verifier/Cargo.toml", + ]); + assert_eq!(workspace_default_member_names(), ["commit-ci-preflight"]); + assert_eq!(workspace_resolver(), "3"); + assert_allowed_dependencies("crates/ccp-core/Cargo.toml", CORE_ALLOWED); + assert_allowed_dependencies("crates/ccp-verifier/Cargo.toml", VERIFIER_ALLOWED); + assert_no_forbidden_source_imports("crates/ccp-verifier"); + ``` + +- [ ] **Step 2: Run the test and verify RED** + + ```console + rtk cargo test --locked --test independent_verifier_contract workspace_members_are_explicit_and_verifier_dependencies_are_bounded -- --exact + ``` + + Expected: FAIL because the two package manifests do not exist. + +- [ ] **Step 3: Add minimal package scaffolding** + + Keep the root `[package]`; add explicit workspace members/default member and + `resolver = "3"`. Add `ccp-core = { path = "crates/ccp-core" }` to the root + dev-dependencies so later cross-path integration tests name the core crate. + Both new packages use `edition = "2024"` and `rust-version = "1.87"`. The + temporary verifier binary prints no success claim; invoking it returns usage + exit `2` until Task 6 adds commands. + +- [ ] **Step 4: Run focused GREEN and metadata inspection** + + ```console + rtk cargo test --locked --test independent_verifier_contract workspace_members_are_explicit_and_verifier_dependencies_are_bounded -- --exact + rtk cargo metadata --locked --format-version 1 --no-deps + ``` + + Expected: PASS; member-only metadata lists exactly the root, `ccp-core`, and + `ccp-verifier` packages and resolves the default member to the root manifest. + No dependency-closure conclusion is drawn from `--no-deps`. + +- [ ] **Step 5: Request the local commit gate** + + Proposed commit: `refactor: establish verifier workspace boundary`. + +### Task 3: Extract the protocol nucleus into `ccp-core` + +**Files:** +- Create: `crates/ccp-core/src/canonical.rs` +- Create: `crates/ccp-core/src/errors.rs` +- Create: `crates/ccp-core/src/config.rs` +- Create: `crates/ccp-core/src/runtime_evidence.rs` +- Create: `crates/ccp-core/src/receipt.rs` +- Modify: `crates/ccp-core/src/lib.rs` +- Replace with compatibility facades: `src/config.rs`, `src/receipt.rs` +- Modify: `src/runtime.rs` +- Modify: `tests/independent_verifier_contract.rs` + +**Interfaces:** +- Consumes: existing config/plan and receipt wire definitions. +- Produces: one nominal definition for config, normalized plan, receipt, + canonicalization, `ReceiptError`, and `RuntimeCapabilityEvidenceV1`. + +- [ ] **Step 1: Add failing type-identity assertions** + + Import the same type through both paths and require assignment without + conversion: + + ```rust + fn root_to_core(value: commit_ci_preflight::receipt::ReceiptEnvelopeV2) + -> ccp_core::receipt::ReceiptEnvelopeV2 { value } + + fn core_to_root(value: ccp_core::config::ExecutionPlanV1) + -> commit_ci_preflight::config::ExecutionPlanV1 { value } + ``` + +- [ ] **Step 2: Verify RED** + + Run the focused test. Expected: compilation fails because `ccp_core` does not + yet expose these definitions. + +- [ ] **Step 3: Move the cyclic contract cluster together** + + Define `ReceiptError` once in core `errors`; re-export it from core/root + `receipt`. Make both `canonical` and `config` depend on `errors`, never on the + receipt module. Move canonical JSON/digest, config parse/validate/normalize + types, receipt models/validation, and the runtime capability evidence wire + type into the core. Keep Docker probes and runtime adapters in root. Replace root + modules with explicit `pub use ccp_core::...::*`; re-export the capability + evidence from root `runtime`. + +- [ ] **Step 4: Prove GREEN and byte compatibility** + + ```console + rtk cargo test --locked --test independent_verifier_contract --test public_api_compat_contract --test plan_cli --test receipt_contract --test matrix_contract + ``` + + Expected: PASS with unchanged fixture/schema hashes, Matrix still compiling + against the re-exported receipt/plan types, and cross-path type assignment. + +- [ ] **Step 5: Request the local commit gate** + + Proposed commit: `refactor: extract canonical receipt core`. + +### Task 4: Separate Matrix contracts from Matrix execution + +**Files:** +- Create: `crates/ccp-core/src/verification_model.rs` +- Create: `crates/ccp-core/src/matrix.rs` +- Create: `crates/ccp-core/src/matrix_legacy.rs` +- Create: `crates/ccp-core/src/schema.rs` +- Modify: `crates/ccp-core/src/lib.rs` +- Modify: `src/matrix.rs` +- Remove after migration: `src/matrix_legacy.rs`, `src/schema_contract.rs` +- Modify: `tests/matrix_contract.rs`, `tests/receipt_contract.rs` + +**Interfaces:** +- Consumes: core plan/receipt types and root Matrix behavior. +- Produces: core-owned Matrix config/plan/receipt/policy/legacy digest and + verification-model primitives; root Matrix retains execution only. + +- [ ] **Step 1: Write the failing Matrix dependency test** + + Assert that `crates/ccp-core/src/matrix.rs` contains no imports or tokens for + `cache`, `process`, `run`, `runtime`, `source_snapshot`, `workspace`, Docker, + or admission. Require cross-path identity for `MatrixConfigV2`, + `MatrixPlanEnvelopeV2`, `MatrixReceiptEnvelopeV2`, `MatrixReceiptV2`, + `MatrixVerificationPolicyV2`, and the shared verification model types. + Separately require exhaustive construction and conversion of every + `MatrixContractError` variant into the existing root `MatrixError`; the two + error types must not be nominally identical. + +- [ ] **Step 2: Verify RED** + + Expected: FAIL because Matrix contract types still live in runner-owned + `src/matrix.rs`. + +- [ ] **Step 3: Establish the complete temporary verification-model boundary** + + Move `AcceptedPlatformV1`, `VerificationStatus`, `VerificationDecision`, + `VerificationFindingV1`, `VerificationReportV1`, `finding`, + `parse_utc_seconds`, and `validate_commit` into core `verification_model`. + Also move the nominal `PolicyError`, `TrustedPlanError`, and + `VerificationError` enums into core `errors` now, because pure Matrix + verification needs `VerificationError` without a core-to-root dependency; + Task 5 still owns moving their policy/dispatcher implementations. + Re-export the currently public model types from root `verify` immediately; + keep `finding`, `parse_utc_seconds`, and `validate_commit` `pub(crate)` inside + `ccp-core` because only core Matrix verification consumes them. This gives + Matrix a one-way dependency before the policy dispatcher moves in Task 5 + without expanding the public API. + +- [ ] **Step 4: Move pure Matrix code** + + Move config normalization, plan sealing, receipt sealing, policy parsing, + legacy digest compatibility, Matrix receipt verification, and schema + assembly into core. These pure APIs return `MatrixContractError`, which + contains no `RuntimeError` or `RunError`. The root Matrix module retains the + existing public `MatrixError` and + `MatrixRunOutcomeV2`, `MatrixRunMaterialV2`, `MatrixRunRequestV2`, + `execute_matrix_run_v2`, `seal_matrix_run_material`, and + `write_matrix_receipt`, plus source snapshots, cache, process execution, + runtime probes, receipt writing, and run lifecycle. Add one exhaustive + `From for MatrixError` adapter preserving exact root + `Display` behavior. In the same GREEN slice, change those root execution + functions to import the new core contract and verification-model paths; no + import of the old root verifier remains. + +- [ ] **Step 5: Prove Matrix and schema parity** + + ```console + rtk cargo test --locked --test matrix_contract --test receipt_contract --test independent_verifier_contract + ``` + + Expected: PASS; Matrix mutation/ordering/legacy fixtures and combined receipt + schema remain byte-identical; root CLI/exit behavior is unchanged. Add a + schema dependency assertion proving that core schema generation cannot import + runner Matrix execution code, plus exhaustive adapter tests proving every + pure core error maps to the intended unchanged root error variant. + +- [ ] **Step 6: Request the local commit gate** + + Proposed commit: `refactor: isolate matrix verification contracts`. + +### Task 5: Extract policy dispatch and verification into the core + +**Files:** +- Create: `crates/ccp-core/src/verify.rs` +- Modify: `crates/ccp-core/src/verification_model.rs` +- Replace with compatibility facade: `src/verify.rs` +- Modify: `tests/verification_contract.rs` +- Modify: `tests/github_gate_contract.rs` +- Modify: `tests/independent_verifier_contract.rs` + +**Interfaces:** +- Consumes: core V1/V2/Matrix receipt and policy contracts. +- Produces: core `verify_receipt_document*`, policy loading/validation, + trusted-plan reconstruction, reports, and errors; root facade preserves paths. + +- [ ] **Step 1: Add the failing root/core verification identity test** + + Require that root and core verification functions accept the same policy and + return the same nominal `VerificationReportV1`. Add representative exact + `Display` and exit-decision assertions. + +- [ ] **Step 2: Verify RED** + + Expected: FAIL because core has no verification dispatcher. + +- [ ] **Step 3: Move bounded verification logic** + + Move policy document dispatch, strict bounded file/byte parsing, V1/V1.1 and + Matrix verification, report construction, evaluation-time parsing, and + trusted-config reconstruction. Do not move CLI rendering, benchmark + verification, GitHub migration, or runner filesystem mutation. + + `ccp-core::verify` must expose `validate_verification_policy_path`, + `system_evaluated_at_utc`, `verify_receipt_document`, + `verify_receipt_document_for_policy`, + `verify_receipt_document_for_policy_path`, and + `receipt_input_failure_report`. `VerificationReportV1::canonical_bytes` + remains the only runtime JSON serialization path used by the dedicated CLI. + +- [ ] **Step 4: Run the complete verifier fixture matrix** + + ```console + rtk cargo test --locked --test verification_contract --test github_gate_contract --test independent_verifier_contract + ``` + + Expected: PASS for positive, stale, wrong-head, malformed, oversized, + unknown-field, missing-input, trusted-plan, revoked-producer, and legacy + Matrix cases. + +- [ ] **Step 5: Request the local commit gate** + + Proposed commit: `refactor: extract policy verification core`. + +### Task 6: Add the dedicated `ccp-verifier` CLI with parity tests + +**Files:** +- Modify: `crates/ccp-verifier/src/main.rs` +- Create: `crates/ccp-verifier/tests/verify_cli.rs` +- Create: `crates/ccp-verifier/tests/schema_cli.rs` +- Modify: `tests/verify_cli.rs` +- Modify: `tests/independent_verifier_contract.rs` + +**Interfaces:** +- Consumes: core verification and schema APIs. +- Produces: exactly `verify` and `schema` subcommands; exit codes `0`, `2`, `3`, + and `70` with root-compatible output. + +- [ ] **Step 1: Add failing old/new parity tests** + + For each positive and negative fixture, run both binaries with the same + explicit `--evaluated-at-utc`; assert exact stdout bytes and exit code. + Normalize nothing. Stderr must match for stable errors and may differ only in + the executable name inside Clap-generated usage text. + +- [ ] **Step 2: Add failing schema inventory tests** + + Require the following exact mapping and compare stdout directly with each + checked-in file. Reject unknown kinds with exit `2`. + + | Kind | File | + |---|---| + | `receipt-v1` | `schema/receipt-v1.schema.json` | + | `receipt-v2` | `schema/receipt-v2.schema.json` | + | `policy-v1` | `schema/policy-v1.schema.json` | + | `policy-v1-1` | `schema/policy-v1_1.schema.json` | + | `policy-v2` | `schema/policy-v2.schema.json` | + | `verification-report-v1` | `schema/verification-report-v1.schema.json` | + +- [ ] **Step 3: Verify RED** + + ```console + rtk cargo test --locked -p ccp-verifier + ``` + + Expected: FAIL because the temporary binary exposes no commands. + +- [ ] **Step 4: Implement the thin CLI** + + Parse arguments with narrowly configured Clap, call only `ccp_core::verify` + and `ccp_core::schema`, render reports exactly like the root CLI, and preserve + failure mapping. Do not add runner imports or commands. Add a manifest/source + assertion that production `ccp-verifier` has no `serde_json` dependency or + import; JSON bytes come from core APIs. + +- [ ] **Step 5: Prove GREEN and absence of extra commands** + + ```console + rtk cargo test --locked -p ccp-verifier + rtk cargo test --locked --test verify_cli --test independent_verifier_contract + ``` + + Expected: PASS; `ccp-verifier --help` contains `verify` and `schema` and none + of `run`, `plan`, `doctor`, `dry-run`, `benchmark`, `guard`, or `migrate`. + +- [ ] **Step 6: Request the local commit gate** + + Proposed commit: `feat: add independent receipt verifier`. + +### Task 7: Prove physical dependency and public API independence + +**Files:** +- Modify: `tests/independent_verifier_contract.rs` +- Create: `tests/fixtures/verifier-dependency-policy-v1.json` +- Create: `tests/fixtures/cargo-metadata-verifier-pass-v1.json` +- Create: `tests/fixtures/cargo-metadata-verifier-forbidden-v1.json` +- Modify: `Cargo.lock` + +**Interfaces:** +- Consumes: completed crate split. +- Produces: machine-readable allowed/forbidden dependency policy and terminal + Cargo graph evidence. + +- [ ] **Step 1: Add the dependency-policy fixture** + + Record separate rules: exact allowed direct dependencies, explicit forbidden + transitive package names/IDs, and allowed registry/path source classes. + Platform-conditional normal dependencies are traversed and checked; unrelated + workspace lockfile packages are ignored. + +- [ ] **Step 2: Add the failing resolved-graph assertion** + + In the integration-test helper, parse synthetic PASS and forbidden metadata + fixtures first. Then run `cargo metadata --locked --format-version 1`, locate + the package whose name is exactly `ccp-verifier`, resolve its package ID to a + node in `resolve.nodes`, and recursively walk only `deps` entries having at + least one `dep_kinds` item with `kind == null`. Fail on the root package, + forbidden package IDs/names, invalid sources, or an unexpected direct edge. + Do not treat unrelated packages elsewhere in the workspace lockfile as linked. + +- [ ] **Step 3: Run graph proof and correct only real leakage** + + ```console + rtk cargo tree --locked -p ccp-verifier --edges normal + rtk cargo tree --locked -p ccp-verifier --depth 1 + rtk cargo metadata --locked --format-version 1 + rtk cargo test --locked --test independent_verifier_contract + ``` + + Expected: only the designed verifier closure is reachable; no root package or + runner-only dependency appears. The parsed graph is controlling evidence; + `cargo tree` is human-readable corroboration. Review exported signatures and + every `pub use` path separately because metadata cannot prove API exposure. + +- [ ] **Step 4: Prepare the bounded size-evidence gate** + + Prepare the exact `cargo build --locked --release -p ccp-verifier` guard + envelope and record it in the local report, but do not execute it in this + task. The release build runs in Task 9 only after the complete deterministic + workspace suite has passed. Do not enforce an arbitrary threshold or claim + another platform. + +- [ ] **Step 5: Request the local commit gate** + + Proposed commit: `test: enforce verifier dependency isolation`. + +### Task 8: Reconcile documentation and release metadata + +**Files:** +- Modify: `CHANGELOG.md` +- Modify: `docs/PRODUCT_ROADMAP.md` +- Modify: `docs/RELIABILITY_HARDENING_PLAN.md` +- Modify: `docs/ARCHITECTURE.md` +- Modify: `docs/INSTALLATION.md` +- Modify: `docs/BETA_SUPPORT.md` +- Modify: `README.md` only if a current command path must be documented +- Modify: release metadata sources generated by `examples/generate_release_metadata.rs` + +**Interfaces:** +- Consumes: verified crate/binary behavior. +- Produces: truthful M2 documentation using `ccp-core` consistently and keeping + availability, qualification, distribution, and identity separate. + +- [ ] **Step 1: Write documentation contract failures** + + Extend repository/release hardening tests to require the actual workspace + graph, verifier command scope, rollback/non-replacement warning, and explicit + M3 deferral of static/multi-platform distribution. + +- [ ] **Step 2: Verify RED** + + ```console + rtk cargo test --locked --test repository_hygiene_contract --test release_hardening_contract + ``` + +- [ ] **Step 3: Update documentation minimally** + + Replace the older `ccp-contract` name with canonical `ccp-core`; document + local source-build use of `ccp-verifier` without claiming a published binary; + record that `verify-benchmark` remains in the root CLI and static MUSL work is + M3. Add the user-visible M2 entry to `CHANGELOG.md`. + +- [ ] **Step 4: Prove docs and metadata GREEN** + + ```console + rtk cargo test --locked --test repository_hygiene_contract --test release_hardening_contract + rtk cargo run --locked --quiet --example generate_release_metadata -- --check + ``` + +- [ ] **Step 5: Request the local commit gate** + + Proposed commit: `docs: document independent verifier boundary`. + +### Task 9: Full deterministic qualification and review closure + +**Files:** +- Modify only findings proven by review; no opportunistic refactor. +- Create ignored local report under `.superpowers/sdd/2026-08-29-independent-verifier/`. + +**Interfaces:** +- Consumes: Tasks 1-8. +- Produces: exact-head deterministic evidence and a hash-bound CCP authorization envelope. + +- [ ] **Step 1: Run formatting and focused compatibility checks** + + ```console + rtk cargo fmt --all -- --check + rtk cargo test --locked --test independent_verifier_contract --test receipt_contract --test verification_contract --test matrix_contract --test verify_cli + ``` + +- [ ] **Step 2: Run full deterministic workspace gates** + + First materialize the exact launcher/argv, worktree HEAD, stable CCP path and + SHA-256, resource profile, timeout, maximum guard count, and stop boundary; + stop for the required `guard exec` authorization. Within that authorized + heavy-work envelope run: + + ```console + rtk cargo test --locked --workspace --all-targets --all-features + rtk cargo clippy --locked --workspace --all-targets --all-features -- -D warnings + rtk cargo doc --locked --workspace --no-deps + rtk cargo run --locked --quiet --example generate_release_metadata -- --check + rtk cargo build --locked --release -p ccp-verifier + ``` + +- [ ] **Step 3: Re-run dependency and hash evidence** + + ```console + rtk cargo tree --locked -p ccp-verifier --edges normal + rtk cargo metadata --locked --format-version 1 + rtk shasum -a 256 tests/fixtures/receipt-v1-pass.json tests/fixtures/receipt-v2-pass.json schema/receipt-v1.schema.json schema/receipt-v2.schema.json schema/policy-v1.schema.json schema/policy-v1_1.schema.json schema/verification-report-v1.schema.json + rtk shasum -a 256 target/release/ccp-verifier + rtk stat -f '%z' target/release/ccp-verifier + rtk rustc -vV + ``` + +- [ ] **Step 4: Perform task-scoped and whole-branch review** + + Review crate ownership, type identity, compatibility, trust-boundary leakage, + error/CLI parity, fixture/schema bytes, docs claims, and the complete diff from + base `6ff736b1e2a1dfde8778330efdd4b82c845d45e7`. + +- [ ] **Step 5: Freeze the exact-head CCP envelope and stop** + + Record worktree, complete HEAD, clean status, stable CCP absolute path and + complete SHA-256, configuration digest, generation, maximum run count `1`, + expected receipt path, preserved journal boundary, and stop point. Do not run + CCP until the operator issues that exact authorization. + +### Task 10: Stabilize and publish the post-M2 release before new development + +**Sequencing gate:** This task starts only after Tasks 1-9 are complete, the +M2 pull request is receipt-green and merged, and the exact resulting `main` +commit is reverified. No unrelated feature, refactor, or roadmap implementation +may begin between that merge and the terminal release decision. + +- [ ] Audit the repository's current version, release runbook, changelog, + artifacts, signing/attestation requirements, and GitHub settings; propose + the exact SemVer without assuming it in advance. +- [ ] Prepare a hash-bound release envelope for the exact merged commit, + toolchain, qualified binaries, checksums, rollback material, tag, and stop + boundaries. Build/CCP, tag, push, GitHub Release, and publication remain + separately authorized external mutations. +- [ ] Run the documented deterministic and platform qualification required for + the selected release, then independently verify every artifact and + checksum before publication. +- [ ] Publish only after exact-head evidence is green; verify the immutable tag, + release assets, checksums, installation path, and rollback instructions. +- [ ] Record the released version as the stable baseline for personal use and + only then reopen subsequent development tasks. + +## Plan completion audit + +- [ ] Every design requirement maps to one task and one terminal check. +- [ ] No task duplicates protocol types or weakens root compatibility. +- [ ] No verifier dependency reaches runner/runtime/state code. +- [ ] No static, native, distribution, identity, publication, or savings claim + exceeds M2 evidence. +- [ ] Source changes, local commits, heavy qualification, and remote actions are + separately authorized and reported. +- [ ] No post-M2 development begins before Task 10 reaches a terminal published + release or an explicit operator-approved NO-GO. diff --git a/docs/superpowers/specs/2026-08-29-independent-verifier-design.md b/docs/superpowers/specs/2026-08-29-independent-verifier-design.md new file mode 100644 index 0000000..5447825 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-independent-verifier-design.md @@ -0,0 +1,277 @@ +--- +type: architecture-design +title: "Physically independent Commit CI Preflight verifier" +status: proposed-for-review +last_verified: 2026-08-29 +source_head: 6ff736b1e2a1dfde8778330efdd4b82c845d45e7 +--- + +# Physically independent verifier design + +## Decision + +Convert the existing root package into a non-virtual Cargo workspace without +moving the root runner package. Add `ccp-core` as the single owner of protocol +types and pure verification logic, and add `ccp-verifier` as a thin binary that +depends only on `ccp-core`, `clap`, and `serde_json`. The existing +`commit-ci-preflight` package remains at the repository root and preserves its +public module paths and CLI behavior through explicit re-exports. + +This is the behavior-preserving workspace approach selected over copying +protocol types or feature-gating the monolith. Rust types are nominal: copying +a struct or enum into another crate would create a different type. Public +re-exports preserve the single defining type and its identity. + +## Evidence base + +The design is bound to clean source +`6ff736b1e2a1dfde8778330efdd4b82c845d45e7`. Read-only local inventory found: + +- one Cargo package containing both runner and verifier concerns; +- `config -> receipt` and `receipt -> config/runtime` dependency cycles; +- `verify -> matrix` and `matrix -> verify/runner` dependency cycles; +- schema assembly coupled to both receipt and the runner-owned Matrix module; +- no existing standalone verifier or physical crate-boundary test; existing + receipt, schema, Matrix, verification, and CLI behavior tests remain the + compatibility baseline. + +Primary references: + +- Cargo workspaces and default members: + +- Cargo dependency and public-dependency behavior: + +- Cargo resolver behavior: + +- Cargo metadata and dependency-tree inspection: + and + +- Rust visibility and re-exports: + +- Rust nominal types and layout limits: + and + +- Cargo `rust-version` contract: + + +## Goals + +1. `ccp-verifier` has no runner, Docker, process, cache, admission, resource, + benchmark, migration, snapshot-materialization, or workspace dependency. +2. Receipt V1/V2, policy V1/V1.1/Matrix V2, canonical JSON, digest, schema, + report, error, and exit-code behavior remain compatible. +3. Existing Rust paths such as `commit_ci_preflight::receipt::*`, + `commit_ci_preflight::verify::*`, and verifier-facing Matrix types continue + to name the same underlying definitions through `pub use`. +4. The root `commit-ci-preflight verify` and new `ccp-verifier verify` commands + emit byte-identical stdout and the same exit code for the same fixture, + arguments, and evaluation time. +5. The extraction is delivered as small red/green slices. No feature change is + mixed into protocol movement. + +## Non-goals + +- No global CCP producer replacement, release, package publication, signing, + static cross-platform claim, or GitHub gate migration occurs in M2. +- `verify-benchmark` and GitHub Actions migration remain root-runner concerns. +- M2 records binary size and dependency closure but does not invent a size + threshold or claim a platform that was not built natively. +- M2 does not rename receipt, policy, schema, or report versions. + +## Workspace and dependency graph + +The root manifest keeps `[package]` and gains: + +```toml +[workspace] +members = [".", "crates/ccp-core", "crates/ccp-verifier"] +default-members = ["."] +resolver = "3" +``` + +All packages retain `edition = "2024"` and `rust-version = "1.87"`. +Edition 2024 implies resolver 3. The workspace records `resolver = "3"` +explicitly for auditability; it is valid because the declared MSRV is 1.87. +If the MSRV is ever lowered below Rust 1.84, resolver 2 becomes the fallback +and the complete locked graph must be requalified. + +```text +ccp-core + ^ + |-- commit-ci-preflight (root runner and compatibility facade) + `-- ccp-verifier (bounded verify/schema CLI) +``` + +`ccp-core` direct dependencies are limited to `schemars`, `serde`, +`serde_json`, `sha2`, and `toml`. `ccp-verifier` directly depends on +`ccp-core` and narrowly configured `clap`; `serde_json` is test-only in that +package. It must not depend on +`ctrlc`, `fs2`, `process-wrap`, `nix`, `saphyr`, or `saphyr-parser`. + +One workspace lockfile is expected. Its containing runner-only packages does +not imply verifier linkage; the selected `ccp-verifier` resolved normal +dependency closure is the controlling evidence. + +## Core ownership + +| Core module | Owns | Must not own | +|---|---|---| +| `errors` | the single nominal `ReceiptError`, `PolicyError`, `TrustedPlanError`, and `VerificationError` definitions used by pure core code | runner or CLI execution errors | +| `canonical` | canonical JSON recursion, byte encoding, SHA-256 digest using core `ReceiptError` | files, clocks, runner state | +| `config` | V1 config contract, normalized execution-plan types, pure parse/validate/normalize | Docker probing or execution | +| `runtime_evidence` | `RuntimeCapabilityEvidenceV1` wire type | runtime adapters or commands | +| `receipt` | receipt V1/V2 and shared evidence types, validation, sealing | receipt writing or atomic filesystem operations | +| `verification_model` | accepted-platform, status, decision, finding, report, and the pure validation/time helpers required by Matrix verification | policy dispatch or CLI rendering | +| `matrix` | pure Matrix config/plan/receipt/policy types, legacy digest compatibility, Matrix verification, and `MatrixContractError` | cache, process, `RunError`, `RuntimeError`, source materialization | +| `verify` | bounded policy/receipt loading, V1/V1.1/Matrix V2 dispatch, trusted-plan reconstruction | runner commands or publication | +| `schema` | receipt, policy, report, and combined Matrix schema generation | runner configuration schema unrelated to verification | + +The root `config`, `receipt`, and `verify` modules become compatibility +facades. The root `matrix` module re-exports core contract types while retaining +only execution composition. The root `runtime` module re-exports +`RuntimeCapabilityEvidenceV1` from the core so its old public path remains +valid. + +`MatrixError` is the deliberate exception to cross-crate nominal identity. It +remains root-owned because its public `Runtime(RuntimeError)` and +`Run(RunError)` variants belong to the runner dependency graph. Pure Matrix +methods on core-owned contract types return the new +`ccp_core::matrix::MatrixContractError`; the root execution adapter implements +an explicit, exhaustive conversion into the existing root `MatrixError` for +all pure variants. Moving runner errors into `ccp-core`, duplicating Matrix +wire types, or hiding the dependency behind features is prohibited. + +## Compatibility contract + +The following are explicitly adopted as project compatibility contracts and +frozen before movement; Rust itself does not guarantee their wire or display +stability: + +- fixture and schema SHA-256 values recorded in a versioned manifest; +- receipt IDs and canonical bytes for V1, V2, and Matrix V2; +- policy parsing and dispatch for versions `1.0`, `1.1`, and `2.0`; +- trusted-config path resolution relative to the policy file; +- malformed, unknown-field, oversized, stale, wrong-head, revoked-producer, + and missing-input behavior; +- public error variants, payload types, adopted `Display` strings, source-chain + behavior, and `Send + Sync` behavior; +- root verifier JSON/human output and exit codes: usage/policy error `2`, + verification decision failure `3`, internal receipt error `70`; +- checked-in schema bytes and historical verifier compatibility fixtures. + +A compile-time consumer matrix imports every frozen receipt, config/plan, +policy, report, and Matrix wire family through both the root facade and +`ccp_core`, then assigns values across the two paths. This proves re-exports +preserve type identity rather than merely providing look-alike types. Root +`MatrixError` and core `MatrixContractError` are instead checked by exhaustive +conversion and exact `Display`/source tests. A public surface review also +checks all `pub use` paths because dependency graph evidence alone cannot prove +public API compatibility. + +This is an approved narrow pre-1.0 Rust API change: pure methods on re-exported +Matrix contract types return `MatrixContractError` rather than root +`MatrixError`. Explicitly typed Rust callers may need to use `?`/`From` or name +the new error. Root CLI output, exit codes, receipt and schema bytes, and the +existing root `MatrixError` variants and displays remain unchanged. + +Rust layout and enum discriminants are not wire-format guarantees. Byte +compatibility comes only from the existing canonical encoder and is proven by +golden bytes, field-order cases, old-version decode, and schema fixtures. + +## Dedicated verifier CLI + +The binary name is `ccp-verifier`. It exposes exactly two subcommands: + +```text +ccp-verifier verify --receipt --policy + --expected-commit <40-or-64-lowercase-hex> + [--evaluated-at-utc ] [--json] + +ccp-verifier schema --kind +``` + +`verify` shares the same library entry point as the root CLI. All runtime JSON +serialization is performed by `ccp-core`, so the verifier binary never imports +`serde_json` outside tests. `schema` writes +the exact checked-in schema bytes to stdout and has no filesystem input beyond +normal stdout. It does not expose config, plan, run, benchmark, migration, +cache, resource, or publication commands. + +## TDD and migration order + +1. Pin fixture hashes, public paths, type identity, CLI parity, and forbidden + dependencies with failing tests. +2. Introduce the workspace and empty package boundaries without moving behavior. +3. Extract canonicalization and pure config/plan contracts. +4. Extract runtime evidence, receipt contracts, and schema generation. +5. Split Matrix pure contract/verification/legacy logic from runner execution. +6. Extract common verification models and policy dispatch, breaking the final + Matrix/verifier cycle. +7. Add the dedicated verifier CLI and prove old/new output and exit parity. +8. Remove duplicated definitions, leave explicit facades, and prove the final + resolved dependency closure. +9. Reconcile public architecture documentation, changelog, release metadata, + and M2 evidence without claiming distribution or identity. + +Each step first runs a focused test that fails for the intended missing or +coupled boundary, then applies the smallest movement needed, then reruns the +focused contract before broader validation. + +## Verification evidence + +Required deterministic gates: + +```console +rtk cargo fmt --all -- --check +rtk cargo test --locked --workspace --all-targets --all-features +rtk cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +rtk cargo doc --locked --workspace --no-deps +rtk cargo tree --locked -p ccp-verifier --edges normal +rtk cargo metadata --locked --format-version 1 +rtk cargo run --locked --quiet --example generate_release_metadata -- --check +``` + +The dependency gate parses `cargo metadata` format 1, locates the exact +`ccp-verifier` package ID, walks only normal edges in `resolve.nodes[*].deps` +where a dependency kind is `null`, and rejects any reachable root or forbidden +package ID. `cargo tree -p ccp-verifier` is human-readable corroboration rather +than the parser. The gate also reviews public signatures/re-exports because +metadata cannot prove public API exposure. Optional +`cargo-semver-checks`, `cargo-public-api`, or `cargo-deny` may be evaluated +later as supplemental evidence; M2 does not require installing a new tool. + +Full workspace validation and the release-only local verifier build must obey +the current global heavy-work contract and receive an exact `guard exec` +authorization if they qualify as heavy on the live host. The exact-head CCP +qualification is a separate heavy-run gate after deterministic review. Push, +PR, evidence publication, ready transition, and merge remain separate external +gates. + +## Documentation rulings + +- `ccp-core` is the canonical M2 package name because the approved programme + specification and current product roadmap use it. The older + `ccp-contract` name in `RELIABILITY_HARDENING_PLAN.md` is reconciled during + documentation closure; no alias package is created. +- Static `x86_64-unknown-linux-musl` distribution belongs to M3. M2 may record a + local candidate's size but cannot claim static or native availability without + the corresponding target evidence. +- Benchmark verification remains outside the dedicated verifier until a future + design proves it belongs in the trusted receipt gate. + +## Completion criteria + +M2 is complete only when: + +1. the root API and CLI compatibility contracts pass; +2. old fixture/schema hashes and canonical bytes remain unchanged; +3. the new verifier passes the full positive and negative fixture matrix; +4. Cargo metadata/tree evidence proves physical independence; +5. full deterministic workspace tests, Clippy, docs, and release metadata pass; +6. exact-head review has no unresolved material finding; +7. a separately authorized CCP run produces a terminal independently verified + receipt for the exact M2 head. + +No release, distribution, identity, publication, or platform claim follows +from M2 alone. diff --git a/src/config.rs b/src/config.rs index 7fac7b1..43400e0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,1688 +1,2 @@ -// Copyright 2026 Marco Porcellato -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; -use std::fs; -use std::path::Path; - -use schemars::{JsonSchema, schema_for}; -use serde::{Deserialize, Serialize}; - -use crate::receipt::{ReceiptError, canonical_digest, canonical_json}; - -pub const CONFIG_SCHEMA_VERSION: &str = "1.0"; -pub const MAX_CONFIG_BYTES: usize = 1_048_576; -pub const MAX_CHECKS: usize = 128; -pub const MAX_CACHES: usize = 32; -pub const MAX_ARGV_PARTS: usize = 64; -pub const MAX_STRING_BYTES: usize = 4096; -pub const MAX_TIMEOUT_SECONDS: u64 = 86_400; -pub const MAX_MEMORY_MIB: u64 = 262_144; -pub const MAX_CPU_COUNT: u16 = 256; -pub const MAX_PIDS: u32 = 65_536; -pub const MAX_STORAGE_BYTES: u64 = 1_099_511_627_776; -const MIN_RECEIPT_JOURNAL_RESERVE_BYTES: u64 = 4_096; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ConfigV1 { - pub schema_version: String, - pub project: String, - pub runtime: RuntimeConfig, - #[serde(default)] - pub receipt: ReceiptConfig, - #[serde(default)] - pub environment: EnvironmentConfig, - #[serde(default)] - pub caches: Vec, - #[serde(default)] - pub storage: Option, - pub checks: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RuntimeConfig { - pub kind: RuntimeKind, - pub image: String, - pub cpu_count: u16, - pub memory_mib: u64, - pub pids_limit: u32, - #[serde(default)] - pub network: bool, - #[serde(default)] - pub pull_policy: Option, - #[serde(default)] - pub swap_mode: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RuntimeKind { - DockerCompatible, - Host, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub enum RuntimePullPolicy { - Never, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub enum RuntimeSwapMode { - Disabled, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(default, deny_unknown_fields)] -pub struct ReceiptConfig { - pub output: String, - pub freshness_seconds: u64, -} - -impl Default for ReceiptConfig { - fn default() -> Self { - Self { - output: ".ccp/receipt.json".to_owned(), - freshness_seconds: 86_400, - } - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(default, deny_unknown_fields)] -pub struct EnvironmentConfig { - pub allow: Vec, - pub fixed: BTreeMap, - pub runtime_internal: Vec, - pub remote_secret_only: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RuntimeInternalEnvironmentConfig { - pub name: String, - pub cache_id: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CacheConfig { - pub id: String, - pub mount_path: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct StorageConfig { - pub min_free_bytes: u64, - pub receipt_journal_reserve_bytes: u64, - pub max_cache_growth_bytes: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CheckConfig { - pub id: String, - pub required: bool, - pub argv: Vec, - pub working_directory: String, - pub timeout_seconds: u64, - #[serde(default)] - pub depends_on: Vec, - #[serde(default)] - pub artifacts: Vec, - #[serde(default)] - pub artifact_contracts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ArtifactContractConfig { - pub path: String, - pub kind: ArtifactKind, - pub max_bytes: u64, - pub max_entries: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub enum ArtifactKind { - RegularFile, - Directory, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ExecutionPlanEnvelopeV1 { - pub plan_digest: String, - pub plan: ExecutionPlanV1, - #[serde(skip)] - pub fixed_environment: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ExecutionPlanV1 { - pub schema_version: String, - pub project: String, - pub runtime: NormalizedRuntime, - pub receipt: NormalizedReceipt, - pub environment: NormalizedEnvironment, - pub caches: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub storage: Option, - pub checks: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NormalizedRuntime { - pub kind: RuntimeKind, - pub image: String, - pub cpu_count: u16, - pub memory_mib: u64, - pub pids_limit: u32, - pub network: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub pull_policy: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub swap_mode: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NormalizedReceipt { - pub output: String, - pub freshness_seconds: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NormalizedEnvironment { - pub inherit: Vec, - pub fixed: Vec, - pub runtime_internal: Vec, - pub remote_secret_only: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NormalizedFixedEnvironment { - pub name: String, - pub value_digest: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NormalizedRuntimeInternalEnvironment { - pub name: String, - pub cache_id: String, - pub container_target: String, -} - -impl NormalizedEnvironment { - pub fn names(&self) -> Vec { - let mut names = self.inherit.clone(); - names.extend(self.fixed.iter().map(|binding| binding.name.clone())); - names.extend( - self.runtime_internal - .iter() - .map(|binding| binding.name.clone()), - ); - names.sort(); - names - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NormalizedCache { - pub id: String, - pub mount_path: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NormalizedStorage { - pub min_free_bytes: u64, - pub receipt_journal_reserve_bytes: u64, - pub max_cache_growth_bytes: u64, - pub max_artifact_bytes: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NormalizedCheck { - pub id: String, - pub required: bool, - pub argv: Vec, - pub working_directory: String, - pub timeout_seconds: u64, - pub depends_on: Vec, - pub artifacts: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artifact_contracts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NormalizedArtifactContract { - pub path: String, - pub kind: ArtifactKind, - pub max_bytes: u64, - pub max_entries: u64, - pub producer_check: String, -} - -impl ConfigV1 { - pub fn parse(input: &str) -> Result { - if input.len() > MAX_CONFIG_BYTES { - return Err(ConfigError::ConfigTooLarge { - actual: input.len(), - maximum: MAX_CONFIG_BYTES, - }); - } - toml::from_str(input).map_err(ConfigError::Parse) - } - - pub fn load(path: &Path) -> Result { - let metadata = fs::metadata(path).map_err(|source| ConfigError::Io { - path: path.to_path_buf(), - source, - })?; - let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX); - if size > MAX_CONFIG_BYTES { - return Err(ConfigError::ConfigTooLarge { - actual: size, - maximum: MAX_CONFIG_BYTES, - }); - } - let input = fs::read_to_string(path).map_err(|source| ConfigError::Io { - path: path.to_path_buf(), - source, - })?; - Self::parse(&input) - } - - pub fn into_plan(self) -> Result { - self.validate_top_level()?; - let schema_version = self.schema_version.clone(); - let fixed_environment = self.environment.fixed.clone(); - let caches = normalize_caches(self.caches)?; - let runtime = normalize_runtime(&schema_version, self.runtime)?; - let environment = normalize_environment(&schema_version, self.environment, &caches)?; - let checks = normalize_checks(self.checks)?; - let storage = normalize_storage(&schema_version, self.storage, &checks)?; - let receipt = NormalizedReceipt { - output: self.receipt.output, - freshness_seconds: self.receipt.freshness_seconds, - }; - validate_path_isolation(&receipt, &caches, &checks)?; - let plan = ExecutionPlanV1 { - schema_version, - project: self.project, - runtime, - receipt, - environment, - caches, - storage, - checks, - }; - let plan_digest = canonical_digest(&plan).map_err(ConfigError::Receipt)?; - Ok(ExecutionPlanEnvelopeV1 { - plan_digest, - plan, - fixed_environment, - }) - } - - fn validate_top_level(&self) -> Result<(), ConfigError> { - if !matches!( - self.schema_version.as_str(), - CONFIG_SCHEMA_VERSION | "1.1" | "1.2" | "1.3" - ) { - return Err(ConfigError::UnsupportedSchemaVersion( - self.schema_version.clone(), - )); - } - validate_repository_identity(&self.project)?; - validate_image_reference(&self.runtime.image)?; - validate_bounded( - "runtime.cpu_count", - u64::from(self.runtime.cpu_count), - 1, - u64::from(MAX_CPU_COUNT), - )?; - validate_bounded( - "runtime.memory_mib", - self.runtime.memory_mib, - 64, - MAX_MEMORY_MIB, - )?; - validate_bounded( - "runtime.pids_limit", - u64::from(self.runtime.pids_limit), - 1, - u64::from(MAX_PIDS), - )?; - validate_relative_path("receipt.output", &self.receipt.output)?; - if self.receipt.output == "." { - return Err(ConfigError::InvalidField("receipt.output")); - } - validate_bounded( - "receipt.freshness_seconds", - self.receipt.freshness_seconds, - 1, - 31_536_000, - ) - } -} - -fn normalize_runtime( - schema_version: &str, - runtime: RuntimeConfig, -) -> Result { - let (pull_policy, swap_mode) = if schema_version == "1.3" { - ( - Some( - runtime - .pull_policy - .ok_or(ConfigError::MissingRuntimeCapabilityPolicy)?, - ), - Some( - runtime - .swap_mode - .ok_or(ConfigError::MissingRuntimeCapabilityPolicy)?, - ), - ) - } else { - if runtime.pull_policy.is_some() { - return Err(ConfigError::InvalidField("runtime.pull_policy")); - } - if runtime.swap_mode.is_some() { - return Err(ConfigError::InvalidField("runtime.swap_mode")); - } - (None, None) - }; - - Ok(NormalizedRuntime { - kind: runtime.kind, - image: runtime.image, - cpu_count: runtime.cpu_count, - memory_mib: runtime.memory_mib, - pids_limit: runtime.pids_limit, - network: runtime.network, - pull_policy, - swap_mode, - }) -} - -impl ExecutionPlanEnvelopeV1 { - pub fn canonical_bytes(&self) -> Result, ConfigError> { - let expected = canonical_digest(&self.plan).map_err(ConfigError::Receipt)?; - if self.plan_digest != expected { - return Err(ConfigError::PlanDigestMismatch); - } - canonical_json(self).map_err(ConfigError::Receipt) - } -} - -pub fn config_schema_json() -> Result { - let schema = schema_for!(ConfigV1); - serde_json::to_string_pretty(&schema) - .map_err(ReceiptError::Serialization) - .map_err(ConfigError::Receipt) -} - -fn normalize_caches(caches: Vec) -> Result, ConfigError> { - if caches.len() > MAX_CACHES { - return Err(ConfigError::TooManyItems { - field: "caches", - actual: caches.len(), - maximum: MAX_CACHES, - }); - } - let mut by_id = BTreeMap::new(); - let mut mount_paths = BTreeSet::new(); - for cache in caches { - validate_identifier("cache.id", &cache.id)?; - validate_relative_path("cache.mount_path", &cache.mount_path)?; - if cache.mount_path == "." { - return Err(ConfigError::InvalidField("cache.mount_path")); - } - if by_id.contains_key(&cache.id) { - return Err(ConfigError::DuplicateId { - field: "cache.id", - id: cache.id, - }); - } - if !mount_paths.insert(cache.mount_path.clone()) { - return Err(ConfigError::DuplicateValue("cache.mount_path")); - } - by_id.insert( - cache.id.clone(), - NormalizedCache { - id: cache.id, - mount_path: cache.mount_path, - }, - ); - } - Ok(by_id.into_values().collect()) -} - -fn normalize_checks(checks: Vec) -> Result, ConfigError> { - if checks.is_empty() { - return Err(ConfigError::NoChecks); - } - if checks.len() > MAX_CHECKS { - return Err(ConfigError::TooManyItems { - field: "checks", - actual: checks.len(), - maximum: MAX_CHECKS, - }); - } - if !checks.iter().any(|check| check.required) { - return Err(ConfigError::NoRequiredChecks); - } - - let mut by_id = BTreeMap::new(); - for mut check in checks { - validate_check(&check)?; - check.depends_on = unique_sorted("check.depends_on", check.depends_on, |value| { - validate_identifier("check.depends_on", value) - })?; - check.artifacts = unique_sorted("check.artifacts", check.artifacts, |value| { - validate_relative_path("check.artifacts", value) - })?; - if check.artifacts.iter().any(|artifact| artifact == ".") { - return Err(ConfigError::InvalidField("check.artifacts")); - } - validate_artifact_contracts(&check)?; - let id = check.id.clone(); - if by_id.insert(id.clone(), check).is_some() { - return Err(ConfigError::DuplicateId { - field: "check.id", - id, - }); - } - } - - validate_dependencies(&by_id)?; - topological_checks(&by_id) -} - -fn normalize_storage( - schema_version: &str, - storage: Option, - checks: &[NormalizedCheck], -) -> Result, ConfigError> { - if !matches!(schema_version, "1.2" | "1.3") { - if storage.is_some() { - return Err(ConfigError::InvalidField("storage")); - } - return Ok(None); - } - let storage = storage.ok_or(ConfigError::MissingStoragePolicy)?; - validate_bounded( - "storage.min_free_bytes", - storage.min_free_bytes, - 1, - MAX_STORAGE_BYTES, - )?; - validate_bounded( - "storage.receipt_journal_reserve_bytes", - storage.receipt_journal_reserve_bytes, - MIN_RECEIPT_JOURNAL_RESERVE_BYTES, - MAX_STORAGE_BYTES, - )?; - validate_bounded( - "storage.max_cache_growth_bytes", - storage.max_cache_growth_bytes, - 0, - MAX_STORAGE_BYTES, - )?; - let max_artifact_bytes = checks - .iter() - .flat_map(|check| check.artifact_contracts.iter()) - .try_fold(0_u64, |total, artifact| { - total.checked_add(artifact.max_bytes) - }) - .ok_or(ConfigError::InvalidField("storage.max_artifact_bytes"))?; - let required = storage - .min_free_bytes - .checked_add(storage.receipt_journal_reserve_bytes) - .and_then(|total| total.checked_add(storage.max_cache_growth_bytes)) - .and_then(|total| total.checked_add(max_artifact_bytes)) - .ok_or(ConfigError::InvalidField("storage"))?; - if required > MAX_STORAGE_BYTES { - return Err(ConfigError::OutOfRange { - field: "storage.required_free_bytes", - minimum: 1, - maximum: MAX_STORAGE_BYTES, - actual: required, - }); - } - Ok(Some(NormalizedStorage { - min_free_bytes: storage.min_free_bytes, - receipt_journal_reserve_bytes: storage.receipt_journal_reserve_bytes, - max_cache_growth_bytes: storage.max_cache_growth_bytes, - max_artifact_bytes, - })) -} - -fn validate_path_isolation( - receipt: &NormalizedReceipt, - caches: &[NormalizedCache], - checks: &[NormalizedCheck], -) -> Result<(), ConfigError> { - for (index, cache) in caches.iter().enumerate() { - for other in &caches[index + 1..] { - reject_path_overlap(&cache.mount_path, &other.mount_path)?; - } - reject_path_overlap(&cache.mount_path, &receipt.output)?; - } - - let mut artifacts: BTreeSet<&str> = BTreeSet::new(); - for check in checks { - for artifact in &check.artifacts { - if artifacts.contains(artifact.as_str()) { - return Err(ConfigError::DuplicateArtifact(artifact.clone())); - } - for other in &artifacts { - reject_path_overlap(artifact, other)?; - } - reject_path_overlap(artifact, &receipt.output)?; - for cache in caches { - reject_path_overlap(artifact, &cache.mount_path)?; - } - artifacts.insert(artifact.as_str()); - } - } - Ok(()) -} - -fn reject_path_overlap(first: &str, second: &str) -> Result<(), ConfigError> { - let overlap = first == second - || first - .strip_prefix(second) - .is_some_and(|suffix| suffix.starts_with('/')) - || second - .strip_prefix(first) - .is_some_and(|suffix| suffix.starts_with('/')); - if overlap { - Err(ConfigError::PathOverlap { - first: first.to_owned(), - second: second.to_owned(), - }) - } else { - Ok(()) - } -} - -fn validate_dependencies(by_id: &BTreeMap) -> Result<(), ConfigError> { - for check in by_id.values() { - for dependency in &check.depends_on { - if dependency == &check.id { - return Err(ConfigError::SelfDependency(check.id.clone())); - } - if !by_id.contains_key(dependency) { - return Err(ConfigError::UnknownDependency { - check: check.id.clone(), - dependency: dependency.clone(), - }); - } - } - } - Ok(()) -} - -fn topological_checks( - by_id: &BTreeMap, -) -> Result, ConfigError> { - let mut indegree: BTreeMap = by_id - .iter() - .map(|(id, check)| (id.clone(), check.depends_on.len())) - .collect(); - let mut dependents: BTreeMap> = BTreeMap::new(); - for (id, check) in by_id { - for dependency in &check.depends_on { - dependents - .entry(dependency.clone()) - .or_default() - .insert(id.clone()); - } - } - let mut ready: BTreeSet = indegree - .iter() - .filter(|(_, count)| **count == 0) - .map(|(id, _)| id.clone()) - .collect(); - let mut ordered = Vec::with_capacity(by_id.len()); - while let Some(id) = ready.pop_first() { - let check = by_id.get(&id).expect("ready check exists"); - ordered.push(normalize_check(check)); - if let Some(children) = dependents.get(&id) { - for child in children { - let count = indegree.get_mut(child).expect("dependent check exists"); - *count -= 1; - if *count == 0 { - ready.insert(child.clone()); - } - } - } - } - if ordered.len() != by_id.len() { - let cycle = indegree - .into_iter() - .filter(|(_, count)| *count > 0) - .map(|(id, _)| id) - .collect(); - return Err(ConfigError::DependencyCycle(cycle)); - } - Ok(ordered) -} - -fn validate_check(check: &CheckConfig) -> Result<(), ConfigError> { - validate_identifier("check.id", &check.id)?; - if check.argv.is_empty() || check.argv.len() > MAX_ARGV_PARTS { - return Err(ConfigError::InvalidField("check.argv")); - } - for argument in &check.argv { - validate_text("check.argv", argument)?; - } - validate_relative_path("check.working_directory", &check.working_directory)?; - validate_bounded( - "check.timeout_seconds", - check.timeout_seconds, - 1, - MAX_TIMEOUT_SECONDS, - ) -} - -fn normalize_check(check: &CheckConfig) -> NormalizedCheck { - NormalizedCheck { - id: check.id.clone(), - required: check.required, - argv: check.argv.clone(), - working_directory: check.working_directory.clone(), - timeout_seconds: check.timeout_seconds, - depends_on: check.depends_on.clone(), - artifacts: check.artifacts.clone(), - artifact_contracts: check - .artifact_contracts - .iter() - .map(|artifact| NormalizedArtifactContract { - path: artifact.path.clone(), - kind: artifact.kind, - max_bytes: artifact.max_bytes, - max_entries: artifact.max_entries, - producer_check: check.id.clone(), - }) - .collect(), - } -} - -fn validate_artifact_contracts(check: &CheckConfig) -> Result<(), ConfigError> { - let mut paths = BTreeSet::new(); - for artifact in &check.artifact_contracts { - validate_relative_path("check.artifact_contracts.path", &artifact.path)?; - if artifact.path == "." || !check.artifacts.contains(&artifact.path) { - return Err(ConfigError::InvalidField("check.artifact_contracts.path")); - } - if !paths.insert(&artifact.path) { - return Err(ConfigError::DuplicateValue("check.artifact_contracts.path")); - } - validate_bounded( - "check.artifact_contracts.max_bytes", - artifact.max_bytes, - 1, - 1_073_741_824, - )?; - let maximum_entries = match artifact.kind { - ArtifactKind::RegularFile => 1, - ArtifactKind::Directory => 10_000, - }; - validate_bounded( - "check.artifact_contracts.max_entries", - artifact.max_entries, - 1, - maximum_entries, - )?; - if artifact.kind == ArtifactKind::RegularFile && artifact.max_entries != 1 { - return Err(ConfigError::InvalidField( - "check.artifact_contracts.max_entries", - )); - } - } - Ok(()) -} - -fn unique_sorted( - field: &'static str, - values: Vec, - validate: F, -) -> Result, ConfigError> -where - F: Fn(&str) -> Result<(), ConfigError>, -{ - let mut unique = BTreeSet::new(); - for value in values { - validate(&value)?; - if !unique.insert(value) { - return Err(ConfigError::DuplicateValue(field)); - } - } - Ok(unique.into_iter().collect()) -} - -fn validate_text(field: &'static str, value: &str) -> Result<(), ConfigError> { - if value.is_empty() || value.len() > MAX_STRING_BYTES || value.chars().any(char::is_control) { - Err(ConfigError::InvalidField(field)) - } else { - Ok(()) - } -} - -pub(crate) fn validate_identifier(field: &'static str, value: &str) -> Result<(), ConfigError> { - validate_text(field, value)?; - if value.len() <= 64 - && value.chars().all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') - }) - && value != "." - && value != ".." - { - Ok(()) - } else { - Err(ConfigError::InvalidField(field)) - } -} - -fn validate_environment_name(value: &str) -> Result<(), ConfigError> { - validate_text("environment.allow", value)?; - let mut characters = value.chars(); - let valid_start = characters - .next() - .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()); - if valid_start - && characters.all(|character| character == '_' || character.is_ascii_alphanumeric()) - { - Ok(()) - } else { - Err(ConfigError::InvalidField("environment.allow")) - } -} - -fn normalize_environment( - schema_version: &str, - environment: EnvironmentConfig, - caches: &[NormalizedCache], -) -> Result { - let inherit = unique_sorted( - "environment.allow", - environment.allow, - validate_environment_name, - )?; - let remote_secret_only = unique_sorted( - "environment.remote_secret_only", - environment.remote_secret_only, - validate_environment_name, - )?; - if schema_version == CONFIG_SCHEMA_VERSION - && (!environment.fixed.is_empty() - || !environment.runtime_internal.is_empty() - || !remote_secret_only.is_empty()) - { - return Err(ConfigError::InvalidField("environment")); - } - if schema_version != CONFIG_SCHEMA_VERSION && !inherit.is_empty() { - return Err(ConfigError::InvalidField("environment.allow")); - } - - let mut names = BTreeSet::new(); - for name in &inherit { - names.insert(name.clone()); - } - let mut fixed = Vec::with_capacity(environment.fixed.len()); - for (name, value) in environment.fixed { - validate_environment_name(&name)?; - validate_text("environment.fixed", &value)?; - if !names.insert(name.clone()) { - return Err(ConfigError::DuplicateValue("environment")); - } - fixed.push(NormalizedFixedEnvironment { - value_digest: canonical_digest(&value).map_err(ConfigError::Receipt)?, - name, - }); - } - let cache_targets = caches - .iter() - .map(|cache| (cache.id.as_str(), cache.mount_path.as_str())) - .collect::>(); - let mut runtime_internal = Vec::with_capacity(environment.runtime_internal.len()); - for binding in environment.runtime_internal { - validate_environment_name(&binding.name)?; - validate_identifier("environment.runtime_internal.cache_id", &binding.cache_id)?; - if !names.insert(binding.name.clone()) { - return Err(ConfigError::DuplicateValue("environment")); - } - let mount_path = cache_targets - .get(binding.cache_id.as_str()) - .ok_or_else(|| ConfigError::UnknownEnvironmentCache { - name: binding.name.clone(), - cache_id: binding.cache_id.clone(), - })?; - runtime_internal.push(NormalizedRuntimeInternalEnvironment { - name: binding.name, - cache_id: binding.cache_id, - container_target: format!("/workspace/{mount_path}"), - }); - } - for name in &remote_secret_only { - if !names.insert(name.clone()) { - return Err(ConfigError::DuplicateValue("environment")); - } - } - fixed.sort_by(|left, right| left.name.cmp(&right.name)); - runtime_internal.sort_by(|left, right| left.name.cmp(&right.name)); - Ok(NormalizedEnvironment { - inherit, - fixed, - runtime_internal, - remote_secret_only, - }) -} - -fn validate_repository_identity(value: &str) -> Result<(), ConfigError> { - let mut segments = value.split('/'); - let owner = segments.next().unwrap_or_default(); - let repository = segments.next().unwrap_or_default(); - validate_identifier("project", owner)?; - validate_identifier("project", repository)?; - if segments.next().is_some() { - Err(ConfigError::InvalidField("project")) - } else { - Ok(()) - } -} - -fn validate_relative_path(field: &'static str, value: &str) -> Result<(), ConfigError> { - validate_text(field, value)?; - let safe = value == "." - || (!value.starts_with('/') - && !value.starts_with('~') - && !value.contains('\\') - && !value.contains(':') - && value - .split('/') - .all(|segment| !segment.is_empty() && segment != "." && segment != "..")); - if safe { - Ok(()) - } else { - Err(ConfigError::InvalidField(field)) - } -} - -fn validate_image_reference(value: &str) -> Result<(), ConfigError> { - validate_text("runtime.image", value)?; - let Some((name, digest)) = value.rsplit_once('@') else { - return Err(ConfigError::InvalidField("runtime.image")); - }; - let Some(hex) = digest.strip_prefix("sha256:") else { - return Err(ConfigError::InvalidField("runtime.image")); - }; - if !name.is_empty() - && !name.chars().any(char::is_whitespace) - && !name.contains('@') - && !name.contains("://") - && !name.contains('\\') - && hex.len() == 64 - && hex - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(()) - } else { - Err(ConfigError::InvalidField("runtime.image")) - } -} - -fn validate_bounded( - field: &'static str, - value: u64, - minimum: u64, - maximum: u64, -) -> Result<(), ConfigError> { - if (minimum..=maximum).contains(&value) { - Ok(()) - } else { - Err(ConfigError::OutOfRange { - field, - minimum, - maximum, - actual: value, - }) - } -} - -#[derive(Debug)] -pub enum ConfigError { - Io { - path: std::path::PathBuf, - source: std::io::Error, - }, - Parse(toml::de::Error), - Receipt(ReceiptError), - UnsupportedSchemaVersion(String), - ConfigTooLarge { - actual: usize, - maximum: usize, - }, - InvalidField(&'static str), - OutOfRange { - field: &'static str, - minimum: u64, - maximum: u64, - actual: u64, - }, - TooManyItems { - field: &'static str, - actual: usize, - maximum: usize, - }, - NoChecks, - NoRequiredChecks, - DuplicateId { - field: &'static str, - id: String, - }, - DuplicateValue(&'static str), - DuplicateArtifact(String), - PathOverlap { - first: String, - second: String, - }, - SelfDependency(String), - UnknownDependency { - check: String, - dependency: String, - }, - UnknownEnvironmentCache { - name: String, - cache_id: String, - }, - MissingStoragePolicy, - MissingRuntimeCapabilityPolicy, - DependencyCycle(Vec), - PlanDigestMismatch, -} - -impl fmt::Display for ConfigError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Io { path, source } => write!( - formatter, - "cannot read configuration {}: {source}", - path.display() - ), - Self::Parse(error) => write!(formatter, "invalid TOML configuration: {error}"), - Self::Receipt(error) => { - write!(formatter, "cannot canonicalize execution plan: {error}") - } - Self::UnsupportedSchemaVersion(version) => { - write!( - formatter, - "unsupported configuration schema version: {version}" - ) - } - Self::ConfigTooLarge { actual, maximum } => { - write!( - formatter, - "configuration is {actual} bytes; maximum is {maximum}" - ) - } - Self::InvalidField(field) => { - write!(formatter, "invalid configuration field: {field}") - } - Self::UnknownEnvironmentCache { name, cache_id } => write!( - formatter, - "runtime-internal environment {name} references unknown cache {cache_id}" - ), - Self::MissingStoragePolicy => { - write!( - formatter, - "schemas 1.2 and 1.3 require an explicit storage policy" - ) - } - Self::MissingRuntimeCapabilityPolicy => write!( - formatter, - "schema 1.3 requires pull_policy = never and swap_mode = disabled" - ), - Self::OutOfRange { - field, - minimum, - maximum, - actual, - } => write!( - formatter, - "configuration field {field} is {actual}; expected {minimum}..={maximum}" - ), - Self::TooManyItems { - field, - actual, - maximum, - } => write!( - formatter, - "configuration has {actual} {field}; maximum is {maximum}" - ), - Self::NoChecks => write!(formatter, "configuration contains no checks"), - Self::NoRequiredChecks => { - write!(formatter, "configuration contains no required checks") - } - Self::DuplicateId { field, id } => write!(formatter, "duplicate {field}: {id}"), - Self::DuplicateValue(field) => write!(formatter, "duplicate value in {field}"), - Self::DuplicateArtifact(path) => write!(formatter, "duplicate artifact path: {path}"), - Self::PathOverlap { first, second } => { - write!( - formatter, - "configuration paths overlap: {first} and {second}" - ) - } - Self::SelfDependency(check) => write!(formatter, "check depends on itself: {check}"), - Self::UnknownDependency { check, dependency } => { - write!( - formatter, - "check {check} depends on unknown check {dependency}" - ) - } - Self::DependencyCycle(checks) => write!( - formatter, - "check dependency cycle involves: {}", - checks.join(", ") - ), - Self::PlanDigestMismatch => write!(formatter, "execution plan digest mismatch"), - } - } -} - -impl std::error::Error for ConfigError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io { source, .. } => Some(source), - Self::Parse(error) => Some(error), - Self::Receipt(error) => Some(error), - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const IMAGE: &str = "ghcr.io/example/ci@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - fn valid_config(checks: &str) -> String { - format!( - r#" -schema_version = "1.0" -project = "example/project" - -[runtime] -kind = "docker_compatible" -image = "{IMAGE}" -cpu_count = 4 -memory_mib = 4096 -pids_limit = 512 - -{checks} -"# - ) - } - - fn check(id: &str, dependencies: &[&str]) -> String { - let dependencies = dependencies - .iter() - .map(|dependency| format!("\"{dependency}\"")) - .collect::>() - .join(", "); - format!( - r#" -[[checks]] -id = "{id}" -required = true -argv = ["cargo", "test"] -working_directory = "." -timeout_seconds = 300 -depends_on = [{dependencies}] -"# - ) - } - - #[test] - fn equivalent_declaration_orders_produce_identical_plan_bytes() { - let first = valid_config(&(check("test", &["fmt"]) + &check("fmt", &[]))); - let second = valid_config(&(check("fmt", &[]) + &check("test", &["fmt"]))); - let first = ConfigV1::parse(&first) - .and_then(ConfigV1::into_plan) - .expect("first plan"); - let second = ConfigV1::parse(&second) - .and_then(ConfigV1::into_plan) - .expect("second plan"); - - assert_eq!(first, second); - assert_eq!( - first.canonical_bytes().expect("first bytes"), - second.canonical_bytes().expect("second bytes") - ); - assert_eq!(first.plan.checks[0].id, "fmt"); - assert_eq!(first.plan.checks[1].id, "test"); - } - - #[test] - fn dependency_cycles_are_rejected_deterministically() { - let input = valid_config(&(check("a", &["b"]) + &check("b", &["a"]))); - let error = ConfigV1::parse(&input) - .and_then(ConfigV1::into_plan) - .expect_err("cycle must fail"); - assert!(matches!( - error, - ConfigError::DependencyCycle(checks) if checks == ["a", "b"] - )); - } - - #[test] - fn unknown_and_self_dependencies_are_rejected() { - let unknown = valid_config(&check("test", &["missing"])); - assert!(matches!( - ConfigV1::parse(&unknown).and_then(ConfigV1::into_plan), - Err(ConfigError::UnknownDependency { .. }) - )); - - let own = valid_config(&check("test", &["test"])); - assert!(matches!( - ConfigV1::parse(&own).and_then(ConfigV1::into_plan), - Err(ConfigError::SelfDependency(id)) if id == "test" - )); - } - - #[test] - fn duplicate_check_ids_are_rejected() { - let input = valid_config(&(check("test", &[]) + &check("test", &[]))); - assert!(matches!( - ConfigV1::parse(&input).and_then(ConfigV1::into_plan), - Err(ConfigError::DuplicateId { - field: "check.id", - .. - }) - )); - } - - #[test] - fn unknown_toml_fields_are_rejected() { - let input = valid_config(&check("test", &[])) + "\nunknown = true\n"; - assert!(matches!( - ConfigV1::parse(&input), - Err(ConfigError::Parse(_)) - )); - } - - #[test] - fn unsafe_paths_and_unpinned_images_are_rejected() { - for path in ["../escape", "/tmp/output", r"C:\output", "nested//output"] { - let mut input = valid_config(&check("test", &[])); - input.push_str(&format!("\n[receipt]\noutput = '{path}'\n")); - assert!(matches!( - ConfigV1::parse(&input).and_then(ConfigV1::into_plan), - Err(ConfigError::InvalidField("receipt.output")) - )); - } - - let input = valid_config(&check("test", &[])).replace(IMAGE, "ghcr.io/example/ci:latest"); - assert!(matches!( - ConfigV1::parse(&input).and_then(ConfigV1::into_plan), - Err(ConfigError::InvalidField("runtime.image")) - )); - } - - #[test] - fn resource_limits_are_bounded() { - let input = - valid_config(&check("test", &[])).replace("memory_mib = 4096", "memory_mib = 0"); - assert!(matches!( - ConfigV1::parse(&input).and_then(ConfigV1::into_plan), - Err(ConfigError::OutOfRange { - field: "runtime.memory_mib", - .. - }) - )); - } - - #[test] - fn configuration_size_is_bounded_before_parsing() { - let input = "x".repeat(MAX_CONFIG_BYTES + 1); - assert!(matches!( - ConfigV1::parse(&input), - Err(ConfigError::ConfigTooLarge { .. }) - )); - } - - #[test] - fn environment_names_and_duplicate_values_are_rejected() { - let base = valid_config(&check("test", &[])); - let invalid = base.clone() + "\n[environment]\nallow = [\"VALID\", \"NOT-VALID\"]\n"; - assert!(matches!( - ConfigV1::parse(&invalid).and_then(ConfigV1::into_plan), - Err(ConfigError::InvalidField("environment.allow")) - )); - - let duplicate = base + "\n[environment]\nallow = [\"CI\", \"CI\"]\n"; - assert!(matches!( - ConfigV1::parse(&duplicate).and_then(ConfigV1::into_plan), - Err(ConfigError::DuplicateValue("environment.allow")) - )); - } - - #[test] - fn v1_1_environment_classes_normalize_without_host_inheritance() { - let input = r#" -schema_version = "1.1" -project = "owner/project" - -[runtime] -kind = "docker_compatible" -image = "registry.example/ci@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -cpu_count = 2 -memory_mib = 256 -pids_limit = 64 - -[environment] -remote_secret_only = ["DEPLOY_TOKEN"] - -[environment.fixed] -SOURCE_DATE_EPOCH = "0" - -[[environment.runtime_internal]] -name = "CARGO_HOME" -cache_id = "cargo-home" - -[[caches]] -id = "cargo-home" -mount_path = ".ccp-mounts/cargo-home" - -[[checks]] -id = "format" -required = true -argv = ["cargo", "fmt", "--check"] -working_directory = "." -timeout_seconds = 60 -"#; - - let plan = ConfigV1::parse(input) - .and_then(ConfigV1::into_plan) - .expect("v1.1 environment plan"); - - assert!(plan.plan.environment.inherit.is_empty()); - assert_eq!(plan.plan.environment.fixed.len(), 1); - assert_eq!(plan.plan.environment.runtime_internal.len(), 1); - assert_eq!( - plan.plan.environment.runtime_internal[0].container_target, - "/workspace/.ccp-mounts/cargo-home" - ); - assert_eq!( - plan.plan.environment.remote_secret_only, - vec!["DEPLOY_TOKEN".to_owned()] - ); - assert!( - !plan - .canonical_bytes() - .expect("public plan bytes") - .windows(b"SOURCE_DATE_EPOCH=0".len()) - .any(|window| window == b"SOURCE_DATE_EPOCH=0") - ); - } - - #[test] - fn v1_1_runtime_internal_unknown_cache_fails_closed() { - let input = valid_config(&check("format", &[])) - .replace("schema_version = \"1.0\"", "schema_version = \"1.1\"") - + "\n[[environment.runtime_internal]]\nname = \"CARGO_HOME\"\ncache_id = \"missing\"\n"; - assert!(matches!( - ConfigV1::parse(&input).and_then(ConfigV1::into_plan), - Err(ConfigError::UnknownEnvironmentCache { .. }) - )); - } - - #[test] - fn v1_2_storage_policy_is_explicit_and_normalized_into_the_plan() { - let input = valid_config(&check("format", &[])) - .replace("schema_version = \"1.0\"", "schema_version = \"1.2\"") - + r#" - -[storage] -min_free_bytes = 1073741824 -receipt_journal_reserve_bytes = 1048576 -max_cache_growth_bytes = 2147483648 -"#; - - let plan = ConfigV1::parse(&input) - .and_then(ConfigV1::into_plan) - .expect("v1.2 storage plan"); - - let storage = plan.plan.storage.expect("storage policy"); - assert_eq!(storage.min_free_bytes, 1_073_741_824); - assert_eq!(storage.receipt_journal_reserve_bytes, 1_048_576); - assert_eq!(storage.max_cache_growth_bytes, 2_147_483_648); - assert_eq!(storage.max_artifact_bytes, 0); - } - - #[test] - fn v1_2_storage_policy_is_required_and_bounded() { - let missing = valid_config(&check("format", &[])) - .replace("schema_version = \"1.0\"", "schema_version = \"1.2\""); - assert!(matches!( - ConfigV1::parse(&missing).and_then(ConfigV1::into_plan), - Err(ConfigError::MissingStoragePolicy) - )); - - let invalid = valid_config(&check("format", &[])) - .replace("schema_version = \"1.0\"", "schema_version = \"1.2\"") - + r#" - -[storage] -min_free_bytes = 0 -receipt_journal_reserve_bytes = 1048576 -max_cache_growth_bytes = 2147483648 -"#; - assert!(matches!( - ConfigV1::parse(&invalid).and_then(ConfigV1::into_plan), - Err(ConfigError::OutOfRange { - field: "storage.min_free_bytes", - .. - }) - )); - } - - #[test] - fn v1_2_storage_policy_changes_the_plan_digest() { - let base = valid_config(&check("format", &[])) - .replace("schema_version = \"1.0\"", "schema_version = \"1.2\"") - + r#" - -[storage] -min_free_bytes = 1073741824 -receipt_journal_reserve_bytes = 1048576 -max_cache_growth_bytes = 2147483648 -"#; - let changed = base.replace( - "max_cache_growth_bytes = 2147483648", - "max_cache_growth_bytes = 3221225472", - ); - - let first = ConfigV1::parse(&base) - .and_then(ConfigV1::into_plan) - .expect("first plan"); - let second = ConfigV1::parse(&changed) - .and_then(ConfigV1::into_plan) - .expect("second plan"); - - assert_ne!(first.plan_digest, second.plan_digest); - } - - #[test] - fn v1_3_requires_explicit_runtime_capability_policy() { - let input = valid_config(&check("format", &[])) - .replace("schema_version = \"1.0\"", "schema_version = \"1.3\"") - + r#" - -[storage] -min_free_bytes = 1073741824 -receipt_journal_reserve_bytes = 1048576 -max_cache_growth_bytes = 2147483648 -"#; - - assert!(matches!( - ConfigV1::parse(&input).and_then(ConfigV1::into_plan), - Err(ConfigError::MissingRuntimeCapabilityPolicy) - )); - } - - #[test] - fn v1_3_runtime_policy_changes_the_plan_digest() { - let base = valid_config(&check("format", &[])) - .replace("schema_version = \"1.0\"", "schema_version = \"1.3\"") - .replace( - "pids_limit = 512\n", - "pids_limit = 512\npull_policy = \"never\"\nswap_mode = \"disabled\"\n", - ) - + r#" - -[storage] -min_free_bytes = 1073741824 -receipt_journal_reserve_bytes = 1048576 -max_cache_growth_bytes = 2147483648 -"#; - let first = ConfigV1::parse(&base) - .and_then(ConfigV1::into_plan) - .expect("first schema 1.3 plan"); - let mut changed = first.plan.clone(); - changed.runtime.swap_mode = None; - - assert_ne!( - canonical_digest(&first.plan).expect("first digest"), - canonical_digest(&changed).expect("changed digest") - ); - } - - #[test] - fn v1_3_requires_storage_and_historical_schemas_reject_runtime_policy() { - let missing_storage = valid_config(&check("format", &[])) - .replace("schema_version = \"1.0\"", "schema_version = \"1.3\"") - .replace( - "pids_limit = 512\n", - "pids_limit = 512\npull_policy = \"never\"\nswap_mode = \"disabled\"\n", - ); - assert!(matches!( - ConfigV1::parse(&missing_storage).and_then(ConfigV1::into_plan), - Err(ConfigError::MissingStoragePolicy) - )); - - let historical = valid_config(&check("format", &[])).replace( - "pids_limit = 512\n", - "pids_limit = 512\npull_policy = \"never\"\nswap_mode = \"disabled\"\n", - ); - assert!(matches!( - ConfigV1::parse(&historical).and_then(ConfigV1::into_plan), - Err(ConfigError::InvalidField("runtime.pull_policy")) - )); - } - - #[test] - fn v1_2_storage_policy_derives_declared_artifact_allowance() { - let check = check("report", &[]).replace( - "depends_on = []", - r#"depends_on = [] -artifacts = ["results/report.json"] - -[[checks.artifact_contracts]] -path = "results/report.json" -kind = "regular-file" -max_bytes = 4096 -max_entries = 1"#, - ); - let input = valid_config(&check) - .replace("schema_version = \"1.0\"", "schema_version = \"1.2\"") - + r#" - -[storage] -min_free_bytes = 100 -receipt_journal_reserve_bytes = 4096 -max_cache_growth_bytes = 0 -"#; - - let plan = ConfigV1::parse(&input) - .and_then(ConfigV1::into_plan) - .expect("v1.2 storage plan"); - assert_eq!( - plan.plan - .storage - .expect("storage policy") - .max_artifact_bytes, - 4096 - ); - } - - #[test] - fn storage_policy_is_rejected_by_historical_schema_versions() { - let input = valid_config(&check("format", &[])) - .replace("schema_version = \"1.0\"", "schema_version = \"1.1\"") - + r#" - -[storage] -min_free_bytes = 1073741824 -receipt_journal_reserve_bytes = 1048576 -max_cache_growth_bytes = 0 -"#; - assert!(matches!( - ConfigV1::parse(&input).and_then(ConfigV1::into_plan), - Err(ConfigError::InvalidField("storage")) - )); - } - - #[test] - fn plan_digest_detects_mutation() { - let input = valid_config(&check("test", &[])); - let mut plan = ConfigV1::parse(&input) - .and_then(ConfigV1::into_plan) - .expect("plan"); - plan.plan.runtime.network = true; - assert!(matches!( - plan.canonical_bytes(), - Err(ConfigError::PlanDigestMismatch) - )); - } - - #[test] - fn at_least_one_required_check_is_mandatory() { - let input = - valid_config(&check("test", &[])).replace("required = true", "required = false"); - assert!(matches!( - ConfigV1::parse(&input).and_then(ConfigV1::into_plan), - Err(ConfigError::NoRequiredChecks) - )); - } - - #[test] - fn image_reference_cannot_embed_credentials_or_url_scheme() { - for image in [ - "user:password@registry.example/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "https://registry.example/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ] { - let input = valid_config(&check("test", &[])).replace(IMAGE, image); - assert!(matches!( - ConfigV1::parse(&input).and_then(ConfigV1::into_plan), - Err(ConfigError::InvalidField("runtime.image")) - )); - } - } - - #[test] - fn cache_receipt_and_artifact_paths_cannot_overlap() { - let cache_overlap = valid_config(&check("test", &[])) - + "\n[[caches]]\nid = \"first\"\nmount_path = \"cache\"\n" - + "\n[[caches]]\nid = \"second\"\nmount_path = \"cache/nested\"\n"; - assert!(matches!( - ConfigV1::parse(&cache_overlap).and_then(ConfigV1::into_plan), - Err(ConfigError::PathOverlap { .. }) - )); - - let receipt_overlap = valid_config(&check("test", &[])) - + "\n[[caches]]\nid = \"receipt\"\nmount_path = \".ccp\"\n"; - assert!(matches!( - ConfigV1::parse(&receipt_overlap).and_then(ConfigV1::into_plan), - Err(ConfigError::PathOverlap { .. }) - )); - - let duplicate_artifact = valid_config( - &(check("first", &[]).replace( - "depends_on = []", - "depends_on = []\nartifacts = [\"build/output\"]", - ) + &check("second", &[]).replace( - "depends_on = []", - "depends_on = []\nartifacts = [\"build/output\"]", - )), - ); - assert!(matches!( - ConfigV1::parse(&duplicate_artifact).and_then(ConfigV1::into_plan), - Err(ConfigError::DuplicateArtifact(path)) if path == "build/output" - )); - - let nested_artifacts = valid_config( - &(check("parent", &[]).replace( - "depends_on = []", - "depends_on = []\nartifacts = [\"build/reports\"]", - ) + &check("child", &[]).replace( - "depends_on = []", - "depends_on = []\nartifacts = [\"build/reports/result.json\"]", - )), - ); - assert!(matches!( - ConfigV1::parse(&nested_artifacts).and_then(ConfigV1::into_plan), - Err(ConfigError::PathOverlap { .. }) - )); - } - - #[test] - fn artifact_contract_requires_a_bounded_regular_file_owned_by_its_check() { - let input = valid_config(&check("test", &[])).replace( - "depends_on = []", - "depends_on = []\nartifacts = [\"results/report.json\"]\n\n[[checks.artifact_contracts]]\npath = \"results/report.json\"\nkind = \"regular-file\"\nmax_bytes = 1048576\nmax_entries = 1", - ); - let plan = ConfigV1::parse(&input) - .and_then(ConfigV1::into_plan) - .expect("artifact contract plan"); - assert_eq!(plan.plan.checks[0].artifact_contracts.len(), 1); - assert_eq!( - plan.plan.checks[0].artifact_contracts[0].producer_check, - "test" - ); - } - - #[test] - fn artifact_contract_rejects_undeclared_paths_and_unbounded_directory_shape() { - let base = valid_config(&check("test", &[])).replace( - "depends_on = []", - "depends_on = []\nartifacts = [\"results\"]\n\n[[checks.artifact_contracts]]\npath = \"other\"\nkind = \"regular-file\"\nmax_bytes = 1\nmax_entries = 1", - ); - assert!(matches!( - ConfigV1::parse(&base).and_then(ConfigV1::into_plan), - Err(ConfigError::InvalidField("check.artifact_contracts.path")) - )); - - let directory = base - .replace("path = \"other\"", "path = \"results\"") - .replace("kind = \"regular-file\"", "kind = \"directory\"") - .replace("max_entries = 1", "max_entries = 10001"); - assert!(matches!( - ConfigV1::parse(&directory).and_then(ConfigV1::into_plan), - Err(ConfigError::OutOfRange { - field: "check.artifact_contracts.max_entries", - .. - }) - )); - } -} +pub use ccp_core::config::*; +pub use ccp_core::errors::ReceiptError; diff --git a/src/lib.rs b/src/lib.rs index 969179d..b017dbb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,6 @@ pub mod config; pub mod durable_fs; pub mod github_actions; pub mod matrix; -mod matrix_legacy; pub mod process; pub mod receipt; pub mod resource; @@ -29,7 +28,6 @@ pub mod resource_history; pub mod run; pub mod run_journal; pub mod runtime; -mod schema_contract; pub mod source_snapshot; pub mod storage; pub mod verify; diff --git a/src/main.rs b/src/main.rs index 37d42ea..eae47f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,7 @@ use commit_ci_preflight::github_actions::{ use commit_ci_preflight::matrix::{ MatrixConfigV2, MatrixError, MatrixPlanEnvelopeV2, MatrixPlanProfile, MatrixPlanV2, MatrixRunOutcomeV2, MatrixRunRequestV2, build_matrix_plan, execute_matrix_run_v2, - seal_matrix_run_material, write_matrix_receipt, + prepare_source_snapshot_overlay, seal_matrix_run_material, write_matrix_receipt, }; use commit_ci_preflight::process::{ CancellationReason, CancellationToken, GenerationGuard, OutputMode, ProcessRequest, @@ -1278,7 +1278,7 @@ fn after_validated_matrix_profile_binding( ) -> Result { envelope .validate_profile_binding() - .map_err(CliError::Matrix)?; + .map_err(|error| CliError::Matrix(error.into()))?; operation() } @@ -1292,7 +1292,10 @@ fn print_matrix_run( ) -> Result<(), CliError> { let envelope = load_matrix_plan(path, profile)?; let root = after_validated_matrix_profile_binding(&envelope, || resolve_cache_root(location))?; - let plan_digest = envelope.plan_digest().map_err(CliError::Matrix)?.to_owned(); + let plan_digest = envelope + .plan_digest() + .map_err(|error| CliError::Matrix(error.into()))? + .to_owned(); let cache = ManagedCache::initialize(root).map_err(CliError::Cache)?; let journal = RunJournalStore::initialize(&cache.root().path).map_err(CliError::RunJournal)?; let journal_id = new_journal_id(&plan_digest, generation)?; @@ -1367,7 +1370,7 @@ fn print_matrix_run( return Err(CliError::Run(RunError::SourceSnapshot(error))); } }; - if let Err(error) = envelope.prepare_source_snapshot_overlay(&mut source_snapshot) { + if let Err(error) = prepare_source_snapshot_overlay(&envelope, &mut source_snapshot) { lifecycle .borrow_mut() .fail(RunFailureKindV1::PreparationFailed)?; @@ -1394,7 +1397,7 @@ fn print_matrix_run( lifecycle .borrow_mut() .fail(RunFailureKindV1::PreparationFailed)?; - return Err(CliError::Matrix(error)); + return Err(CliError::Matrix(error.into())); } Ok(()) }, @@ -1533,7 +1536,7 @@ fn print_matrix_run( let bytes = outcome .receipt .canonical_bytes() - .map_err(CliError::Matrix)?; + .map_err(|error| CliError::Matrix(error.into()))?; println!("{}", String::from_utf8(bytes).map_err(CliError::internal)?); } else { if profile == MatrixPlanProfile::LegacyV1 { @@ -1675,7 +1678,7 @@ fn load_matrix_plan( if config_schema_version(path)?.as_deref() != Some("2.0") { return Err(CliError::usage(MatrixPlanProfileSchemaError)); } - let config = MatrixConfigV2::load(path).map_err(CliError::Matrix)?; + let config = MatrixConfigV2::load(path).map_err(|error| CliError::Matrix(error.into()))?; build_matrix_plan(config, profile).map_err(CliError::Matrix) } @@ -1703,11 +1706,14 @@ fn legacy_matrix_plan_report( ) -> Result { let legacy_digest_basis = envelope .legacy_digest_basis_value() - .map_err(CliError::Matrix)? + .map_err(|error| CliError::Matrix(error.into()))? .ok_or_else(|| CliError::internal(MatrixPlanProfileSchemaError))?; Ok(LegacyMatrixPlanReportV1 { matrix_plan_profile: "matrix-v2-legacy-v1", - plan_digest: envelope.plan_digest().map_err(CliError::Matrix)?.to_owned(), + plan_digest: envelope + .plan_digest() + .map_err(|error| CliError::Matrix(error.into()))? + .to_owned(), plan: envelope.plan.clone(), legacy_digest_basis, }) @@ -1726,7 +1732,9 @@ fn print_plan(path: &Path, profile: MatrixPlanProfile, json: bool) -> Result<(), .map_err(CliError::internal)? ); } else { - let bytes = envelope.canonical_bytes().map_err(CliError::Matrix)?; + let bytes = envelope + .canonical_bytes() + .map_err(|error| CliError::Matrix(error.into()))?; println!("{}", String::from_utf8(bytes).map_err(CliError::internal)?); } } else { @@ -1812,7 +1820,10 @@ fn print_matrix_dry_run( let envelope = load_matrix_plan(path, profile)?; let cache = resolve_cache_root(location)?; let mut runtimes = Vec::with_capacity(envelope.plan.runtimes.len()); - for (runtime_id, runtime_envelope) in envelope.runtime_envelopes().map_err(CliError::Matrix)? { + for (runtime_id, runtime_envelope) in envelope + .runtime_envelopes() + .map_err(|error| CliError::Matrix(error.into()))? + { let workspace = WorkspacePlanV1::build(&runtime_envelope, &location.repository, &cache) .map_err(CliError::Workspace)?; let runtime = runtime_for(runtime_envelope.plan.runtime.kind).map_err(CliError::Runtime)?; @@ -2716,7 +2727,10 @@ fn collect_matrix_doctor_report( mut probe_runtime: impl FnMut(&ExecutionPlanEnvelopeV1) -> Result, ) -> Result { let mut runtimes = Vec::with_capacity(envelope.plan.runtimes.len()); - for (runtime_id, runtime_envelope) in envelope.runtime_envelopes().map_err(CliError::Matrix)? { + for (runtime_id, runtime_envelope) in envelope + .runtime_envelopes() + .map_err(|error| CliError::Matrix(error.into()))? + { let configuration_digest = runtime_envelope.plan_digest.clone(); let probe = probe_runtime(&runtime_envelope)?; runtimes.push(MatrixRuntimeDoctorV2 { diff --git a/src/matrix.rs b/src/matrix.rs index 7bd7742..27d043d 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -19,25 +19,16 @@ //! runtime that executed it, while v1 inputs and historical evidence retain //! their exact parser and verifier behaviour. -use std::collections::{BTreeMap, BTreeSet}; use std::fmt; -use std::fs; use std::path::{Path, PathBuf}; -use schemars::{JsonSchema, schema_for}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use crate::cache::ManagedCache; -use crate::config::{ - CacheConfig, CheckConfig, ConfigError, ConfigV1, EnvironmentConfig, ExecutionPlanEnvelopeV1, - ExecutionPlanV1, NormalizedCache, NormalizedEnvironment, NormalizedReceipt, NormalizedRuntime, - ReceiptConfig, RuntimeConfig, RuntimeKind, validate_identifier, -}; -use crate::matrix_legacy::{LegacyMatrixDigestBasisV1, project_legacy_basis}; +use crate::config::ConfigError; use crate::process::{CancellationToken, SupervisorPort}; use crate::receipt::{ - EvidenceStatus, ProducerEvidence, ReceiptEnvelopeV1, ReceiptError, ReceiptV1, - RepositoryEvidence, RunEvidence, canonical_digest, canonical_json, + EvidenceStatus, ProducerEvidence, ReceiptError, RunEvidence, canonical_digest, }; use crate::run::{ Clock, CompletionBarrier, NoopCompletionBarrier, NoopRunLifecycleObserver, RunError, @@ -46,443 +37,52 @@ use crate::run::{ }; use crate::runtime::runtime_for; use crate::source_snapshot::SourceSnapshot; -use crate::verify::{ - AcceptedPlatformV1, VerificationDecision, VerificationFindingV1, VerificationPolicyV1, - VerificationReportV1, VerificationStatus, finding, parse_utc_seconds, validate_commit, +use crate::verify::VerificationReportV1; + +pub use ccp_core::matrix::{ + MATRIX_CONFIG_SCHEMA_VERSION, MATRIX_POLICY_SCHEMA_VERSION, MATRIX_RECEIPT_SCHEMA_VERSION, + MatrixCheckConfigV2, MatrixConfigV2, MatrixEnvironmentConfigV2, MatrixPlanEnvelopeV2, + MatrixPlanProfile, MatrixPlanV2, MatrixReceiptEnvelopeV2, MatrixReceiptV2, + MatrixRequiredCheckV2, MatrixRuntimeConfigV2, MatrixRuntimePlanV2, MatrixRuntimePolicyV2, + MatrixRuntimeReceiptV2, MatrixVerificationPolicyV2, }; -pub const MATRIX_CONFIG_SCHEMA_VERSION: &str = "2.0"; -pub const MATRIX_RECEIPT_SCHEMA_VERSION: &str = "2.0"; -pub const MATRIX_POLICY_SCHEMA_VERSION: &str = "2.0"; -const MAX_MATRIX_RUNTIMES: usize = 32; -const MAX_MATRIX_BYTES: usize = 1_048_576; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MatrixConfigV2 { - pub schema_version: String, - pub project: String, - pub runtimes: Vec, - #[serde(default)] - pub receipt: ReceiptConfig, - #[serde(default)] - pub environment: MatrixEnvironmentConfigV2, - #[serde(default)] - pub caches: Vec, - pub checks: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(default, deny_unknown_fields)] -#[schemars(rename = "EnvironmentConfig")] -pub struct MatrixEnvironmentConfigV2 { - pub allow: Vec, -} - -impl MatrixEnvironmentConfigV2 { - fn as_v1(&self) -> EnvironmentConfig { - EnvironmentConfig { - allow: self.allow.clone(), - ..EnvironmentConfig::default() - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MatrixRuntimeConfigV2 { - pub id: String, - pub kind: RuntimeKind, - pub image: String, - pub cpu_count: u16, - pub memory_mib: u64, - pub pids_limit: u32, - #[serde(default)] - pub network: bool, -} - -impl MatrixRuntimeConfigV2 { - fn as_runtime(&self) -> RuntimeConfig { - RuntimeConfig { - kind: self.kind, - image: self.image.clone(), - cpu_count: self.cpu_count, - memory_mib: self.memory_mib, - pids_limit: self.pids_limit, - network: self.network, - pull_policy: None, - swap_mode: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MatrixCheckConfigV2 { - pub id: String, - pub runtime_id: String, - pub required: bool, - pub argv: Vec, - pub working_directory: String, - pub timeout_seconds: u64, - #[serde(default)] - pub depends_on: Vec, - #[serde(default)] - pub artifacts: Vec, -} - -impl MatrixCheckConfigV2 { - fn as_v1(&self) -> CheckConfig { - CheckConfig { - id: self.id.clone(), - required: self.required, - argv: self.argv.clone(), - working_directory: self.working_directory.clone(), - timeout_seconds: self.timeout_seconds, - depends_on: self.depends_on.clone(), - artifacts: self.artifacts.clone(), - artifact_contracts: Vec::new(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MatrixPlanEnvelopeV2 { - pub plan_digest: String, - pub plan: MatrixPlanV2, - #[serde(skip)] - profile: MatrixPlanProfile, - #[serde(skip)] - legacy_basis: Option, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum MatrixPlanProfile { - #[default] - CurrentV2, - LegacyV1, -} - -impl MatrixPlanProfile { - pub const fn producer_version(self) -> &'static str { - match self { - Self::CurrentV2 => env!("CARGO_PKG_VERSION"), - Self::LegacyV1 => concat!(env!("CARGO_PKG_VERSION"), "+matrix-v2-legacy-v1"), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MatrixPlanV2 { - pub schema_version: String, - pub project: String, - pub receipt: NormalizedReceipt, - pub environment: NormalizedEnvironment, - pub caches: Vec, - pub runtimes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MatrixRuntimePlanV2 { - pub id: String, - pub configuration_digest: String, - pub runtime: NormalizedRuntime, - pub checks: Vec, -} - -impl MatrixConfigV2 { - pub fn parse(input: &str) -> Result { - if input.len() > MAX_MATRIX_BYTES { - return Err(MatrixError::ConfigTooLarge); - } - toml::from_str(input).map_err(MatrixError::Parse) - } - - pub fn load(path: &Path) -> Result { - let metadata = fs::metadata(path).map_err(MatrixError::Io)?; - if metadata.len() > MAX_MATRIX_BYTES as u64 { - return Err(MatrixError::ConfigTooLarge); - } - let source = fs::read_to_string(path).map_err(MatrixError::Io)?; - Self::parse(&source) - } - - pub fn into_plan(self) -> Result { - build_matrix_plan(self, MatrixPlanProfile::default()) - } -} - pub fn build_matrix_plan( config: MatrixConfigV2, profile: MatrixPlanProfile, ) -> Result { - config.build_plan_with_profile(profile) + ccp_core::matrix::build_matrix_plan_with_profile(config, profile).map_err(MatrixError::from) } -impl MatrixConfigV2 { - fn build_plan_with_profile( - self, - profile: MatrixPlanProfile, - ) -> Result { - if self.schema_version != MATRIX_CONFIG_SCHEMA_VERSION { - return Err(MatrixError::UnsupportedSchemaVersion(self.schema_version)); - } - if !(2..=MAX_MATRIX_RUNTIMES).contains(&self.runtimes.len()) { - return Err(MatrixError::InvalidField("runtimes")); - } - let mut runtime_by_id = BTreeMap::new(); - for runtime in self.runtimes { - validate_identifier("runtimes.id", &runtime.id).map_err(MatrixError::Config)?; - if runtime_by_id.insert(runtime.id.clone(), runtime).is_some() { - return Err(MatrixError::DuplicateValue("runtimes.id")); - } - } - let mut check_runtime = BTreeMap::new(); - let mut checks_by_runtime: BTreeMap> = BTreeMap::new(); - for check in &self.checks { - validate_identifier("checks.runtime_id", &check.runtime_id) - .map_err(MatrixError::Config)?; - if !runtime_by_id.contains_key(&check.runtime_id) { - return Err(MatrixError::UnknownRuntime(check.runtime_id.clone())); - } - if check_runtime - .insert(check.id.clone(), check.runtime_id.clone()) - .is_some() - { - return Err(MatrixError::DuplicateValue("checks.id")); - } - checks_by_runtime - .entry(check.runtime_id.clone()) - .or_default() - .push(check.as_v1()); - } - for check in &self.checks { - for dependency in &check.depends_on { - if let Some(runtime) = check_runtime.get(dependency) - && runtime != &check.runtime_id - { - return Err(MatrixError::CrossRuntimeDependency { - check: check.id.clone(), - dependency: dependency.clone(), - }); - } - } - } - - let mut runtime_plans = Vec::with_capacity(runtime_by_id.len()); - let mut shared_receipt = None; - let mut shared_environment = None; - let mut shared_caches = None; - for (id, runtime) in runtime_by_id { - let group_checks = checks_by_runtime.remove(&id).unwrap_or_default(); - if !group_checks.iter().any(|check| check.required) { - return Err(MatrixError::RuntimeWithoutRequiredCheck(id)); - } - let group = ConfigV1 { - schema_version: "1.0".to_owned(), - project: self.project.clone(), - runtime: runtime.as_runtime(), - receipt: self.receipt.clone(), - environment: self.environment.as_v1(), - caches: self.caches.clone(), - storage: None, - checks: group_checks, - } - .into_plan() - .map_err(MatrixError::Config)?; - if shared_environment.is_none() { - shared_receipt = Some(group.plan.receipt.clone()); - shared_environment = Some(group.plan.environment.clone()); - shared_caches = Some(group.plan.caches.clone()); - } - runtime_plans.push(MatrixRuntimePlanV2 { - id, - configuration_digest: group.plan_digest, - runtime: group.plan.runtime, - checks: group.plan.checks, - }); - } - let mut plan = MatrixPlanV2 { - schema_version: MATRIX_CONFIG_SCHEMA_VERSION.to_owned(), - project: self.project, - receipt: shared_receipt.expect("at least two runtimes"), - environment: shared_environment.expect("at least two runtimes"), - caches: shared_caches.expect("at least two runtimes"), - runtimes: runtime_plans, - }; - match profile { - MatrixPlanProfile::CurrentV2 => { - let plan_digest = canonical_digest(&plan).map_err(MatrixError::Receipt)?; - Ok(MatrixPlanEnvelopeV2 { - plan_digest, - plan, - profile, - legacy_basis: None, - }) - } - MatrixPlanProfile::LegacyV1 => { - let legacy_basis = project_legacy_basis(&plan)?; - for runtime in &mut plan.runtimes { - runtime.configuration_digest = - legacy_basis.runtime_digest(&runtime.id)?.to_owned(); - } - let plan_digest = legacy_basis.outer_digest()?; - Ok(MatrixPlanEnvelopeV2 { - plan_digest, - plan, - profile, - legacy_basis: Some(legacy_basis), - }) - } - } - } -} - -impl MatrixPlanEnvelopeV2 { - pub fn profile(&self) -> MatrixPlanProfile { - self.profile - } - - pub fn plan_digest(&self) -> Result<&str, MatrixError> { - self.validate_profile_binding()?; - Ok(&self.plan_digest) - } - - pub fn runtime_configuration_digest(&self, id: &str) -> Result<&str, MatrixError> { - self.validate_profile_binding()?; - match self.profile { - MatrixPlanProfile::LegacyV1 => self - .legacy_basis - .as_ref() - .ok_or(MatrixError::PlanDigestMismatch)? - .runtime_digest(id), - MatrixPlanProfile::CurrentV2 => self - .plan - .runtimes - .iter() - .find(|runtime| runtime.id == id) - .map(|runtime| runtime.configuration_digest.as_str()) - .ok_or_else(|| MatrixError::UnknownRuntime(id.to_owned())), - } - } - - pub fn legacy_digest_basis_value(&self) -> Result, MatrixError> { - self.validate_profile_binding()?; - match self.profile { - MatrixPlanProfile::CurrentV2 => Ok(None), - MatrixPlanProfile::LegacyV1 => self - .legacy_basis - .as_ref() - .ok_or(MatrixError::PlanDigestMismatch)? - .report_value() - .map(Some), - } - } - - pub fn validate_profile_binding(&self) -> Result<(), MatrixError> { - match self.profile { - MatrixPlanProfile::CurrentV2 => { - if self.legacy_basis.is_some() - || canonical_digest(&self.plan).map_err(MatrixError::Receipt)? - != self.plan_digest - { - return Err(MatrixError::PlanDigestMismatch); - } - } - MatrixPlanProfile::LegacyV1 => { - let basis = self - .legacy_basis - .as_ref() - .ok_or(MatrixError::PlanDigestMismatch)?; - let projected = project_legacy_basis(&self.plan)?; - if &projected != basis || projected.outer_digest()? != self.plan_digest { - return Err(MatrixError::PlanDigestMismatch); - } - for runtime in &self.plan.runtimes { - if projected.runtime_digest(&runtime.id)? != runtime.configuration_digest { - return Err(MatrixError::PlanDigestMismatch); - } - } - } - } - Ok(()) - } - - pub fn canonical_bytes(&self) -> Result, MatrixError> { - self.validate_profile_binding()?; - canonical_json(self).map_err(MatrixError::Receipt) - } - - pub fn runtime_envelopes(&self) -> Result, MatrixError> { - self.validate_profile_binding()?; - let mut result = Vec::with_capacity(self.plan.runtimes.len()); - for runtime in &self.plan.runtimes { - let plan = ExecutionPlanV1 { - schema_version: "1.0".to_owned(), - project: self.plan.project.clone(), - runtime: runtime.runtime.clone(), - receipt: self.plan.receipt.clone(), - environment: self.plan.environment.clone(), - caches: self.plan.caches.clone(), - storage: None, - checks: runtime.checks.clone(), - }; - let plan_digest = match self.profile { - MatrixPlanProfile::CurrentV2 => { - let digest = canonical_digest(&plan).map_err(MatrixError::Receipt)?; - if digest != runtime.configuration_digest { - return Err(MatrixError::PlanDigestMismatch); - } - digest - } - MatrixPlanProfile::LegacyV1 => { - self.runtime_configuration_digest(&runtime.id)?.to_owned() - } - }; - result.push(( - runtime.id.clone(), - ExecutionPlanEnvelopeV1 { - plan_digest, - plan, - fixed_environment: BTreeMap::new(), - }, - )); - } - Ok(result) - } - - /// Prepare the one source-snapshot overlay shared by every Matrix runtime. - /// Cache, environment, storage, and fixed-environment fields must be - /// identical because the overlay is materialized once before admission. - pub fn prepare_source_snapshot_overlay( - &self, - snapshot: &mut SourceSnapshot, - ) -> Result<(), MatrixError> { - self.validate_profile_binding()?; - let runtime_envelopes = self.runtime_envelopes()?; - let (_, first) = runtime_envelopes - .first() - .ok_or(MatrixError::InvalidReceipt)?; - let mut overlay = first.clone(); - overlay.plan.checks.clear(); - for (_, runtime) in runtime_envelopes { - if runtime.plan.caches != overlay.plan.caches - || runtime.plan.environment != overlay.plan.environment - || runtime.plan.storage != overlay.plan.storage - || runtime.fixed_environment != overlay.fixed_environment - { - return Err(MatrixError::PlanDigestMismatch); - } - overlay.plan.checks.extend(runtime.plan.checks); +pub fn prepare_source_snapshot_overlay( + envelope: &MatrixPlanEnvelopeV2, + snapshot: &mut SourceSnapshot, +) -> Result<(), MatrixError> { + envelope + .validate_profile_binding() + .map_err(MatrixError::from)?; + let runtime_envelopes = envelope.runtime_envelopes().map_err(MatrixError::from)?; + let (_, first) = runtime_envelopes + .first() + .ok_or(MatrixError::InvalidReceipt)?; + let mut overlay = first.clone(); + overlay.plan.checks.clear(); + for (_, runtime) in runtime_envelopes { + if runtime.plan.caches != overlay.plan.caches + || runtime.plan.environment != overlay.plan.environment + || runtime.plan.storage != overlay.plan.storage + || runtime.fixed_environment != overlay.fixed_environment + { + return Err(MatrixError::PlanDigestMismatch); } - snapshot - .prepare_mount_overlay(&overlay) - .map_err(RunError::SourceSnapshot) - .map_err(MatrixError::Run) + overlay.plan.checks.extend(runtime.plan.checks); } + snapshot + .prepare_mount_overlay(&overlay) + .map_err(RunError::SourceSnapshot) + .map_err(MatrixError::Run) } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MatrixRunOutcomeV2 { pub receipt: MatrixReceiptEnvelopeV2, pub receipt_path: PathBuf, @@ -611,7 +211,7 @@ pub fn seal_matrix_run_material( material: MatrixRunMaterialV2, ) -> Result { validate_matrix_receipts_for_seal(envelope, &material.receipt.runtime_receipts)?; - MatrixReceiptEnvelopeV2::seal(material.receipt) + MatrixReceiptEnvelopeV2::seal(material.receipt).map_err(MatrixError::from) } /// Atomically publish a previously sealed Matrix receipt. Callers must invoke @@ -676,216 +276,16 @@ struct MatrixRunIdInput<'a> { started_at_utc: &'a str, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MatrixReceiptEnvelopeV2 { - pub receipt_id: String, - pub receipt: MatrixReceiptV2, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MatrixReceiptV2 { - pub schema_version: String, - pub producer: ProducerEvidence, - pub repository: RepositoryEvidence, - pub run: RunEvidence, - pub configuration_digest: String, - pub runtime_receipts: Vec, - pub overall_status: EvidenceStatus, - pub incomplete_reason: Option, - pub redaction_policy_version: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MatrixRuntimeReceiptV2 { - pub runtime_id: String, - pub receipt: ReceiptEnvelopeV1, -} - -impl MatrixReceiptEnvelopeV2 { - pub fn seal(receipt: MatrixReceiptV2) -> Result { - receipt.validate()?; - let receipt_id = canonical_digest(&receipt).map_err(MatrixError::Receipt)?; - Ok(Self { - receipt_id, - receipt, - }) - } - - pub fn verify(&self) -> Result<(), MatrixError> { - self.receipt.validate()?; - let expected = canonical_digest(&self.receipt).map_err(MatrixError::Receipt)?; - if expected != self.receipt_id { - return Err(MatrixError::ReceiptIdMismatch); - } - Ok(()) - } - - pub fn canonical_bytes(&self) -> Result, MatrixError> { - self.verify()?; - canonical_json(self).map_err(MatrixError::Receipt) - } -} - -impl MatrixReceiptV2 { - pub fn validate(&self) -> Result<(), MatrixError> { - if self.schema_version != MATRIX_RECEIPT_SCHEMA_VERSION { - return Err(MatrixError::UnsupportedSchemaVersion( - self.schema_version.clone(), - )); - } - if self.repository.dirty || self.runtime_receipts.len() < 2 { - return Err(MatrixError::InvalidReceipt); - } - let mut runtime_ids = BTreeSet::new(); - let mut check_ids = BTreeSet::new(); - let mut all_checks = Vec::new(); - for group in &self.runtime_receipts { - validate_identifier("runtime_receipts.runtime_id", &group.runtime_id) - .map_err(MatrixError::Config)?; - if !runtime_ids.insert(group.runtime_id.as_str()) { - return Err(MatrixError::DuplicateValue("runtime_receipts.runtime_id")); - } - group.receipt.verify().map_err(MatrixError::Receipt)?; - let receipt = &group.receipt.receipt; - if receipt.repository != self.repository || receipt.producer != self.producer { - return Err(MatrixError::InvalidReceipt); - } - for check in &receipt.checks { - if !check_ids.insert(check.id.as_str()) { - return Err(MatrixError::DuplicateValue("runtime_receipts.checks.id")); - } - all_checks.push(check.clone()); - } - } - if all_checks.is_empty() { - return Err(MatrixError::InvalidReceipt); - } - let platform = self.runtime_receipts[0].receipt.receipt.platform.clone(); - ReceiptV1 { - schema_version: crate::receipt::RECEIPT_SCHEMA_VERSION.to_owned(), - producer: self.producer.clone(), - repository: self.repository.clone(), - run: self.run.clone(), - platform, - configuration_digest: self.configuration_digest.clone(), - checks: all_checks, - overall_status: self.overall_status, - incomplete_reason: self.incomplete_reason.clone(), - redaction_policy_version: self.redaction_policy_version.clone(), - } - .validate() - .map_err(MatrixError::Receipt) - } -} - pub fn matrix_config_schema_json() -> Result { - serde_json::to_string_pretty(&schema_for!(MatrixConfigV2)).map_err(MatrixError::Json) + Ok(include_str!("../schema/config-v2.schema.json").to_owned()) } pub fn matrix_receipt_schema_json() -> Result { - crate::schema_contract::combined_receipt_v2_schema_json().map_err(MatrixError::Json) -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MatrixVerificationPolicyV2 { - pub schema_version: String, - pub project: String, - pub configuration_digest: String, - pub required_checks: Vec, - pub max_age_seconds: u64, - pub runtimes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MatrixRequiredCheckV2 { - pub id: String, - pub runtime_id: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MatrixRuntimePolicyV2 { - pub id: String, - pub configuration_digest: String, - pub image_reference: String, - pub platforms: Vec, -} - -impl MatrixVerificationPolicyV2 { - pub fn load(path: &Path) -> Result { - let source = fs::read_to_string(path).map_err(MatrixError::Io)?; - Self::parse(&source) - } - - pub fn parse(source: &str) -> Result { - if source.len() > MAX_MATRIX_BYTES { - return Err(MatrixError::ConfigTooLarge); - } - let policy: Self = toml::from_str(source).map_err(MatrixError::Parse)?; - policy.validate()?; - Ok(policy) - } - - pub fn validate(&self) -> Result<(), MatrixError> { - if self.schema_version != MATRIX_POLICY_SCHEMA_VERSION { - return Err(MatrixError::UnsupportedSchemaVersion( - self.schema_version.clone(), - )); - } - if !(2..=MAX_MATRIX_RUNTIMES).contains(&self.runtimes.len()) - || self.required_checks.is_empty() - { - return Err(MatrixError::InvalidField("runtimes_or_required_checks")); - } - let mut runtime_ids = BTreeSet::new(); - for runtime in &self.runtimes { - validate_identifier("runtimes.id", &runtime.id).map_err(MatrixError::Config)?; - if !runtime_ids.insert(runtime.id.as_str()) { - return Err(MatrixError::DuplicateValue("runtimes.id")); - } - VerificationPolicyV1 { - schema_version: "1.0".to_owned(), - project: self.project.clone(), - configuration_digest: runtime.configuration_digest.clone(), - required_checks: vec![runtime.id.clone()], - image_reference: runtime.image_reference.clone(), - max_age_seconds: self.max_age_seconds, - platforms: runtime.platforms.clone(), - } - .validate() - .map_err(|error| MatrixError::Policy(error.to_string()))?; - } - let mut check_ids = BTreeSet::new(); - let mut coverage = BTreeSet::new(); - for check in &self.required_checks { - validate_identifier("required_checks.id", &check.id).map_err(MatrixError::Config)?; - validate_identifier("required_checks.runtime_id", &check.runtime_id) - .map_err(MatrixError::Config)?; - if !runtime_ids.contains(check.runtime_id.as_str()) { - return Err(MatrixError::UnknownRuntime(check.runtime_id.clone())); - } - if !check_ids.insert(check.id.as_str()) { - return Err(MatrixError::DuplicateValue("required_checks.id")); - } - coverage.insert(check.runtime_id.as_str()); - } - if coverage.len() != runtime_ids.len() { - return Err(MatrixError::InvalidField( - "required_checks.runtime_coverage", - )); - } - Ok(()) - } + Ok(include_str!("../schema/receipt-v2.schema.json").to_owned()) } pub fn matrix_policy_schema_json() -> Result { - serde_json::to_string_pretty(&schema_for!(MatrixVerificationPolicyV2)) - .map_err(MatrixError::Json) + Ok(include_str!("../schema/policy-v2.schema.json").to_owned()) } pub fn verify_matrix_receipt_document( @@ -894,238 +294,20 @@ pub fn verify_matrix_receipt_document( expected_commit: &str, evaluated_at_utc: &str, ) -> Result { - policy.validate()?; - validate_commit(expected_commit).map_err(MatrixError::Verification)?; - let evaluated_at = - parse_utc_seconds(evaluated_at_utc).ok_or(MatrixError::InvalidEvaluationTime)?; - let mut report = VerificationReportV1 { - schema_version: crate::verify::VERIFICATION_REPORT_SCHEMA_VERSION.to_owned(), - assurance_scope: "integrity_and_repository_policy_only".to_owned(), - evaluated_at_utc: evaluated_at_utc.to_owned(), - expected_commit: expected_commit.to_owned(), - receipt_id: None, - integrity_status: VerificationStatus::Fail, - policy_status: VerificationStatus::NotRun, - decision: VerificationDecision::Fail, - findings: Vec::new(), - }; - let envelope: MatrixReceiptEnvelopeV2 = match serde_json::from_slice(bytes) { - Ok(value) => value, - Err(_) => { - report.findings.push(finding( - "receipt.parse_or_shape", - "receipt", - "receipt is not valid strict schema v2 JSON", - )); - return Ok(report); - } - }; - if envelope.verify().is_err() { - report.findings.push(finding( - "receipt.semantic_or_digest_invalid", - "receipt", - "receipt violates v2 integrity invariants", - )); - return Ok(report); - } - report.receipt_id = Some(envelope.receipt_id.clone()); - report.integrity_status = VerificationStatus::Pass; - report.policy_status = VerificationStatus::Pass; - evaluate_matrix_policy( - &envelope, + ccp_core::matrix::verify_matrix_receipt_document( + bytes, policy, expected_commit, - evaluated_at, - &mut report.findings, - ); - if !report.findings.is_empty() { - report.policy_status = VerificationStatus::Fail; - } - if report.policy_status == VerificationStatus::Pass { - report.decision = VerificationDecision::Pass; - } - Ok(report) -} - -fn evaluate_matrix_policy( - envelope: &MatrixReceiptEnvelopeV2, - policy: &MatrixVerificationPolicyV2, - expected_commit: &str, - evaluated_at: i64, - findings: &mut Vec, -) { - let receipt = &envelope.receipt; - equal( - &receipt.repository.repository, - &policy.project, - "policy.repository", - "repository.repository", - "receipt project does not match repository policy", - findings, - ); - equal( - &receipt.repository.commit_sha, - expected_commit, - "policy.commit", - "repository.commit_sha", - "receipt commit does not match the externally supplied commit", - findings, - ); - if receipt.repository.dirty { - findings.push(finding( - "policy.dirty", - "repository.dirty", - "repository policy requires a clean checkout", - )); - } - equal( - &receipt.configuration_digest, - &policy.configuration_digest, - "policy.configuration", - "configuration_digest", - "receipt configuration digest does not match repository policy", - findings, - ); - if receipt.overall_status != EvidenceStatus::Pass { - findings.push(finding( - "policy.overall_status", - "overall_status", - "repository policy requires an overall PASS receipt", - )); - } - let policy_runtimes: BTreeMap<_, _> = policy - .runtimes - .iter() - .map(|runtime| (runtime.id.as_str(), runtime)) - .collect(); - let receipt_runtimes: BTreeMap<_, _> = receipt - .runtime_receipts - .iter() - .map(|runtime| (runtime.runtime_id.as_str(), runtime)) - .collect(); - if policy_runtimes.len() != receipt_runtimes.len() - || policy_runtimes - .keys() - .any(|id| !receipt_runtimes.contains_key(id)) - { - findings.push(finding( - "policy.runtime_set", - "runtime_receipts", - "receipt runtime set does not exactly match repository policy", - )); - } - for (runtime_id, expected) in policy_runtimes { - let Some(actual) = receipt_runtimes.get(runtime_id) else { - continue; - }; - let platform = &actual.receipt.receipt.platform; - equal( - &actual.receipt.receipt.configuration_digest, - &expected.configuration_digest, - "policy.runtime_configuration", - "runtime_receipts.configuration_digest", - "receipt runtime configuration does not match repository policy", - findings, - ); - equal( - &platform.image_reference, - &expected.image_reference, - "policy.runtime_image", - "runtime_receipts.platform.image_reference", - "receipt runtime image does not match repository policy", - findings, - ); - if !expected.platforms.iter().any(|accepted| { - accepted.host_os == platform.host_os - && accepted.host_arch == platform.host_arch - && accepted.runtime_kind == platform.runtime_kind - }) { - findings.push(finding( - "policy.runtime_platform", - "runtime_receipts.platform", - "receipt runtime platform tuple is not accepted by repository policy", - )); - } - } - let expected_checks: BTreeMap<_, _> = policy - .required_checks - .iter() - .map(|check| (check.id.as_str(), check.runtime_id.as_str())) - .collect(); - let mut actual_checks = BTreeMap::new(); - for runtime in &receipt.runtime_receipts { - for check in &runtime.receipt.receipt.checks { - if check.required { - actual_checks.insert(check.id.as_str(), (runtime.runtime_id.as_str(), check)); - } - } - } - if actual_checks.len() != expected_checks.len() - || expected_checks - .keys() - .any(|id| !actual_checks.contains_key(id)) - { - findings.push(finding( - "policy.required_check_set", - "checks", - "required check set does not exactly match repository policy", - )); - } - for (id, runtime_id) in expected_checks { - match actual_checks.get(id) { - Some((actual_runtime, check)) - if *actual_runtime == runtime_id && check.status == EvidenceStatus::Pass => {} - Some((actual_runtime, _)) if *actual_runtime != runtime_id => findings.push(finding( - "policy.check_runtime", - "checks.runtime_id", - "required check ran in a different runtime than repository policy", - )), - Some(_) => findings.push(finding( - "policy.required_check_result", - "checks.status", - "one or more policy-required checks did not PASS", - )), - None => {} - } - } - match parse_utc_seconds(&receipt.run.finished_at_utc) { - Some(finished) if finished > evaluated_at => findings.push(finding( - "policy.future_receipt", - "run.finished_at_utc", - "receipt completion time is later than verification time", - )), - Some(finished) if evaluated_at - finished > policy.max_age_seconds as i64 => { - findings.push(finding( - "policy.stale_receipt", - "run.finished_at_utc", - "receipt exceeds repository freshness policy", - )) - } - Some(_) => {} - None => findings.push(finding( - "policy.invalid_time", - "run.finished_at_utc", - "receipt completion time cannot be evaluated", - )), - } -} - -fn equal( - actual: &str, - expected: &str, - code: &str, - field: &str, - message: &str, - findings: &mut Vec, -) { - if actual != expected { - findings.push(finding(code, field, message)); - } + evaluated_at_utc, + ) + .map_err(MatrixError::from) } #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeMap; + use std::fs; use std::sync::Mutex; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; @@ -1134,7 +316,9 @@ mod tests { CapturedStream, CleanupStatus, ExitOutcome, GenerationGuard, ProcessError, ProcessRequest, ProcessResult, ProcessTermination, RunIdentity, }; - use crate::receipt::{CheckEvidence, PlatformEvidence}; + use crate::receipt::{ + CheckEvidence, PlatformEvidence, ReceiptEnvelopeV1, ReceiptV1, RepositoryEvidence, + }; use crate::run::SystemClock; use crate::source_snapshot::SourceSnapshot; @@ -1550,9 +734,7 @@ pids_limit = 16 &identity, ) .expect("source snapshot"); - envelope - .prepare_source_snapshot_overlay(&mut snapshot) - .expect("matrix snapshot overlay"); + prepare_source_snapshot_overlay(&envelope, &mut snapshot).expect("matrix snapshot overlay"); let mut barrier = NoopCompletionBarrier; let outcome = execute_matrix_run_v2( @@ -1630,9 +812,7 @@ pids_limit = 16 &identity, ) .expect("source snapshot"); - envelope - .prepare_source_snapshot_overlay(&mut snapshot) - .expect("matrix snapshot overlay"); + prepare_source_snapshot_overlay(&envelope, &mut snapshot).expect("matrix snapshot overlay"); envelope.plan.project = "owner/tampered".to_owned(); let supervisor = CountingSupervisor::default(); let mut barrier = NoopCompletionBarrier; @@ -1729,3 +909,31 @@ impl fmt::Display for MatrixError { } impl std::error::Error for MatrixError {} + +impl From for MatrixError { + fn from(error: ccp_core::matrix::MatrixContractError) -> Self { + use ccp_core::matrix::MatrixContractError as Core; + match error { + Core::Io(error) => Self::Io(error), + Core::Json(error) => Self::Json(error), + Core::Parse(error) => Self::Parse(error), + Core::Config(error) => Self::Config(error), + Core::Receipt(error) => Self::Receipt(error), + Core::Verification(error) => Self::Verification(error), + Core::UnsupportedSchemaVersion(value) => Self::UnsupportedSchemaVersion(value), + Core::ConfigTooLarge => Self::ConfigTooLarge, + Core::InvalidField(value) => Self::InvalidField(value), + Core::DuplicateValue(value) => Self::DuplicateValue(value), + Core::UnknownRuntime(value) => Self::UnknownRuntime(value), + Core::RuntimeWithoutRequiredCheck(value) => Self::RuntimeWithoutRequiredCheck(value), + Core::CrossRuntimeDependency { check, dependency } => { + Self::CrossRuntimeDependency { check, dependency } + } + Core::LegacyPlanNotRepresentable(field) => Self::LegacyPlanNotRepresentable(field), + Core::PlanDigestMismatch => Self::PlanDigestMismatch, + Core::InvalidReceipt => Self::InvalidReceipt, + Core::ReceiptIdMismatch => Self::ReceiptIdMismatch, + Core::InvalidEvaluationTime => Self::InvalidEvaluationTime, + } + } +} diff --git a/src/matrix_legacy.rs b/src/matrix_legacy.rs deleted file mode 100644 index 7bdf7c3..0000000 --- a/src/matrix_legacy.rs +++ /dev/null @@ -1,287 +0,0 @@ -// Copyright 2026 Marco Porcellato -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use std::collections::BTreeMap; - -use serde::Serialize; - -use crate::config::{ - NormalizedArtifactContract, NormalizedCache, NormalizedCheck, NormalizedEnvironment, - NormalizedFixedEnvironment, NormalizedReceipt, NormalizedRuntime, - NormalizedRuntimeInternalEnvironment, -}; -use crate::matrix::{MatrixError, MatrixPlanV2, MatrixRuntimePlanV2}; -use crate::receipt::canonical_digest; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct LegacyMatrixDigestBasisV1 { - plan: LegacyMatrixPlanV2, - runtime_digests: BTreeMap, -} - -impl LegacyMatrixDigestBasisV1 { - pub(crate) fn outer_digest(&self) -> Result { - canonical_digest(&self.plan).map_err(MatrixError::Receipt) - } - - pub(crate) fn runtime_digest(&self, id: &str) -> Result<&str, MatrixError> { - self.runtime_digests - .get(id) - .map(String::as_str) - .ok_or_else(|| MatrixError::UnknownRuntime(id.to_owned())) - } - - pub(crate) fn report_value(&self) -> Result { - serde_json::to_value(&self.plan).map_err(MatrixError::Json) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct LegacyExecutionPlanV1 { - schema_version: String, - project: String, - runtime: LegacyNormalizedRuntime, - receipt: LegacyNormalizedReceipt, - environment_allow: Vec, - caches: Vec, - checks: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct LegacyMatrixPlanV2 { - schema_version: String, - project: String, - receipt: LegacyNormalizedReceipt, - environment_allow: Vec, - caches: Vec, - runtimes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct LegacyNormalizedRuntime { - kind: crate::config::RuntimeKind, - image: String, - cpu_count: u16, - memory_mib: u64, - pids_limit: u32, - network: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct LegacyNormalizedReceipt { - output: String, - freshness_seconds: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct LegacyNormalizedCache { - id: String, - mount_path: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct LegacyNormalizedCheck { - id: String, - required: bool, - argv: Vec, - working_directory: String, - timeout_seconds: u64, - depends_on: Vec, - artifacts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct LegacyMatrixRuntimePlanV2 { - id: String, - configuration_digest: String, - runtime: LegacyNormalizedRuntime, - checks: Vec, -} - -pub(crate) fn project_legacy_basis( - plan: &MatrixPlanV2, -) -> Result { - let MatrixPlanV2 { - schema_version, - project, - receipt, - environment, - caches, - runtimes, - } = plan; - // Matrix V2 has no storage field today. Exhaustive destructuring at every - // current nested boundary makes a future field addition fail compilation - // until it is either represented in the historical shape or rejected. - let environment_allow = legacy_environment(environment)?; - let legacy_receipt = legacy_receipt(receipt); - let legacy_caches = caches.iter().map(legacy_cache).collect::>(); - let mut runtime_digests = BTreeMap::new(); - let mut legacy_runtimes = Vec::with_capacity(runtimes.len()); - for runtime_plan in runtimes { - let MatrixRuntimePlanV2 { - id, - configuration_digest: _, - runtime, - checks, - } = runtime_plan; - let checks = checks - .iter() - .map(legacy_check) - .collect::, MatrixError>>()?; - let legacy_runtime = legacy_runtime(runtime)?; - let configuration_digest = canonical_digest(&LegacyExecutionPlanV1 { - schema_version: "1.0".to_owned(), - project: project.clone(), - runtime: legacy_runtime.clone(), - receipt: legacy_receipt.clone(), - environment_allow: environment_allow.clone(), - caches: legacy_caches.clone(), - checks: checks.clone(), - }) - .map_err(MatrixError::Receipt)?; - runtime_digests.insert(id.clone(), configuration_digest.clone()); - legacy_runtimes.push(LegacyMatrixRuntimePlanV2 { - id: id.clone(), - configuration_digest, - runtime: legacy_runtime, - checks, - }); - } - - Ok(LegacyMatrixDigestBasisV1 { - plan: LegacyMatrixPlanV2 { - schema_version: schema_version.clone(), - project: project.clone(), - receipt: legacy_receipt, - environment_allow, - caches: legacy_caches, - runtimes: legacy_runtimes, - }, - runtime_digests, - }) -} - -fn legacy_environment(environment: &NormalizedEnvironment) -> Result, MatrixError> { - let NormalizedEnvironment { - inherit, - fixed, - runtime_internal, - remote_secret_only, - } = environment; - if let Some(binding) = fixed.first() { - let NormalizedFixedEnvironment { - name: _, - value_digest: _, - } = binding; - return Err(MatrixError::LegacyPlanNotRepresentable("environment.fixed")); - } - if let Some(binding) = runtime_internal.first() { - let NormalizedRuntimeInternalEnvironment { - name: _, - cache_id: _, - container_target: _, - } = binding; - return Err(MatrixError::LegacyPlanNotRepresentable( - "environment.runtime_internal", - )); - } - if !remote_secret_only.is_empty() { - return Err(MatrixError::LegacyPlanNotRepresentable( - "environment.remote_secret_only", - )); - } - Ok(inherit.clone()) -} - -fn legacy_receipt(receipt: &NormalizedReceipt) -> LegacyNormalizedReceipt { - let NormalizedReceipt { - output, - freshness_seconds, - } = receipt; - LegacyNormalizedReceipt { - output: output.clone(), - freshness_seconds: *freshness_seconds, - } -} - -fn legacy_cache(cache: &NormalizedCache) -> LegacyNormalizedCache { - let NormalizedCache { id, mount_path } = cache; - LegacyNormalizedCache { - id: id.clone(), - mount_path: mount_path.clone(), - } -} - -fn legacy_runtime(runtime: &NormalizedRuntime) -> Result { - let NormalizedRuntime { - kind, - image, - cpu_count, - memory_mib, - pids_limit, - network, - pull_policy, - swap_mode, - } = runtime; - if pull_policy.is_some() { - return Err(MatrixError::LegacyPlanNotRepresentable( - "runtime.pull_policy", - )); - } - if swap_mode.is_some() { - return Err(MatrixError::LegacyPlanNotRepresentable("runtime.swap_mode")); - } - Ok(LegacyNormalizedRuntime { - kind: *kind, - image: image.clone(), - cpu_count: *cpu_count, - memory_mib: *memory_mib, - pids_limit: *pids_limit, - network: *network, - }) -} - -fn legacy_check(check: &NormalizedCheck) -> Result { - let NormalizedCheck { - id, - required, - argv, - working_directory, - timeout_seconds, - depends_on, - artifacts, - artifact_contracts, - } = check; - if let Some(contract) = artifact_contracts.first() { - let NormalizedArtifactContract { - path: _, - kind: _, - max_bytes: _, - max_entries: _, - producer_check: _, - } = contract; - return Err(MatrixError::LegacyPlanNotRepresentable( - "checks.artifact_contracts", - )); - } - Ok(LegacyNormalizedCheck { - id: id.clone(), - required: *required, - argv: argv.clone(), - working_directory: working_directory.clone(), - timeout_seconds: *timeout_seconds, - depends_on: depends_on.clone(), - artifacts: artifacts.clone(), - }) -} diff --git a/src/receipt.rs b/src/receipt.rs index 7d7c8f9..0b62306 100644 --- a/src/receipt.rs +++ b/src/receipt.rs @@ -1,1386 +1,7 @@ -// Copyright 2026 Marco Porcellato -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; - -use schemars::{JsonSchema, schema_for}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; - -use crate::config::{ArtifactKind, ExecutionPlanV1, RuntimePullPolicy, RuntimeSwapMode}; -use crate::runtime::RuntimeCapabilityEvidenceV1; - -pub const RECEIPT_SCHEMA_VERSION: &str = "1.0"; -pub const RECEIPT_V2_SCHEMA_VERSION: &str = "2.0"; -pub const SOURCE_SNAPSHOT_SCHEMA_VERSION: &str = "1.0"; -pub const RECEIPT_ID_PREFIX: &str = "sha256:"; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ReceiptEnvelopeV1 { - pub receipt_id: String, - pub receipt: ReceiptV1, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ReceiptV1 { - pub schema_version: String, - pub producer: ProducerEvidence, - pub repository: RepositoryEvidence, - pub run: RunEvidence, - pub platform: PlatformEvidence, - pub configuration_digest: String, - pub checks: Vec, - pub overall_status: EvidenceStatus, - pub incomplete_reason: Option, - pub redaction_policy_version: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ReceiptEnvelopeV2 { - pub receipt_id: String, - pub receipt: ReceiptV2, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ReceiptV2 { - pub schema_version: String, - pub producer: ProducerEvidence, - pub repository: RepositoryEvidence, - pub run: RunEvidence, - pub source_snapshot: SourceSnapshotEvidence, - pub platform: PlatformEvidence, - pub configuration_digest: String, - pub execution_plan: ExecutionPlanV1, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub runtime_capability_evidence: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artifact_manifest: Vec, - pub checks: Vec, - pub overall_status: EvidenceStatus, - pub incomplete_reason: Option, - pub redaction_policy_version: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ArtifactEvidence { - pub path: String, - pub kind: ArtifactKind, - pub producer_check: String, - pub entry_count: u64, - pub total_bytes: u64, - pub manifest_digest: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SourceSnapshotEvidence { - pub schema_version: String, - pub strategy: SourceSnapshotStrategy, - pub manifest_digest: String, - pub entry_count: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub enum SourceSnapshotStrategy { - GitObject, - GitArchive, - DetachedWorktree, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ProducerEvidence { - pub name: String, - pub version: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RepositoryEvidence { - pub repository: String, - pub commit_sha: String, - pub dirty: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RunEvidence { - pub run_id: String, - pub generation: u64, - pub started_at_utc: String, - pub finished_at_utc: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct PlatformEvidence { - pub host_os: String, - pub host_arch: String, - pub runtime_kind: String, - pub runtime_version: String, - pub image_reference: String, - pub image_digest: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CheckEvidence { - pub id: String, - pub required: bool, - pub argv: Vec, - pub working_directory: String, - pub status: EvidenceStatus, - pub exit_code: Option, - pub duration_ms: u64, - pub timed_out: bool, - pub cancelled: bool, - pub output_digest: Option, - pub incomplete_reason: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum EvidenceStatus { - Pass, - Fail, - Pending, - NotRun, -} - -impl ReceiptEnvelopeV1 { - pub fn seal(receipt: ReceiptV1) -> Result { - receipt.validate()?; - let receipt_id = canonical_digest(&receipt)?; - Ok(Self { - receipt_id, - receipt, - }) - } - - pub fn verify(&self) -> Result<(), ReceiptError> { - self.receipt.validate()?; - let expected = canonical_digest(&self.receipt)?; - if self.receipt_id != expected { - return Err(ReceiptError::DigestMismatch { - expected, - actual: self.receipt_id.clone(), - }); - } - Ok(()) - } - - pub fn canonical_bytes(&self) -> Result, ReceiptError> { - self.verify()?; - canonical_json(self) - } -} - -impl ReceiptEnvelopeV2 { - pub fn seal(receipt: ReceiptV2) -> Result { - receipt.validate()?; - let receipt_id = canonical_digest(&receipt)?; - Ok(Self { - receipt_id, - receipt, - }) - } - - pub fn verify(&self) -> Result<(), ReceiptError> { - self.receipt.validate()?; - let expected = canonical_digest(&self.receipt)?; - if self.receipt_id != expected { - return Err(ReceiptError::DigestMismatch { - expected, - actual: self.receipt_id.clone(), - }); - } - Ok(()) - } - - pub fn canonical_bytes(&self) -> Result, ReceiptError> { - self.verify()?; - canonical_json(self) - } -} - -impl ReceiptV1 { - pub fn validate(&self) -> Result<(), ReceiptError> { - ReceiptCommon { - expected_schema_version: RECEIPT_SCHEMA_VERSION, - schema_version: &self.schema_version, - producer: &self.producer, - repository: &self.repository, - run: &self.run, - platform: &self.platform, - configuration_digest: &self.configuration_digest, - checks: &self.checks, - overall_status: self.overall_status, - incomplete_reason: self.incomplete_reason.as_deref(), - redaction_policy_version: &self.redaction_policy_version, - } - .validate() - } -} - -impl ReceiptV2 { - pub fn validate(&self) -> Result<(), ReceiptError> { - ReceiptCommon { - expected_schema_version: RECEIPT_V2_SCHEMA_VERSION, - schema_version: &self.schema_version, - producer: &self.producer, - repository: &self.repository, - run: &self.run, - platform: &self.platform, - configuration_digest: &self.configuration_digest, - checks: &self.checks, - overall_status: self.overall_status, - incomplete_reason: self.incomplete_reason.as_deref(), - redaction_policy_version: &self.redaction_policy_version, - } - .validate()?; - self.source_snapshot.validate()?; - let expected_plan_digest = canonical_digest(&self.execution_plan)?; - if self.configuration_digest != expected_plan_digest { - return Err(ReceiptError::ExecutionPlanDigestMismatch { - expected: expected_plan_digest, - actual: self.configuration_digest.clone(), - }); - } - self.validate_runtime_capability_evidence()?; - if self.execution_plan.checks.len() != self.checks.len() { - return Err(ReceiptError::ExecutionPlanCheckMismatch( - "check set".to_owned(), - )); - } - for planned in &self.execution_plan.checks { - let evidence = self - .checks - .iter() - .find(|evidence| evidence.id == planned.id) - .ok_or_else(|| ReceiptError::ExecutionPlanCheckMismatch(planned.id.clone()))?; - if evidence.required != planned.required - || evidence.argv != planned.argv - || evidence.working_directory != planned.working_directory - { - return Err(ReceiptError::ExecutionPlanCheckMismatch(planned.id.clone())); - } - } - self.validate_artifact_manifest()?; - Ok(()) - } - - fn validate_runtime_capability_evidence(&self) -> Result<(), ReceiptError> { - if self.execution_plan.schema_version == "1.3" { - if self.execution_plan.runtime.pull_policy != Some(RuntimePullPolicy::Never) - || self.execution_plan.runtime.swap_mode != Some(RuntimeSwapMode::Disabled) - { - return Err(ReceiptError::RuntimeCapabilityEvidenceMismatch); - } - let evidence = self - .runtime_capability_evidence - .as_ref() - .ok_or(ReceiptError::MissingRuntimeCapabilityEvidence)?; - if evidence.schema_version != "1.0" - || !evidence.memory_limit_supported - || !evidence.swap_limit_supported - { - return Err(ReceiptError::InvalidRuntimeCapabilityEvidence( - "schema_version or capability booleans", - )); - } - validate_sha256( - "runtime_capability.context_digest", - &evidence.context_digest, - ) - .map_err(|_| ReceiptError::InvalidRuntimeCapabilityEvidence("context_digest"))?; - validate_sha256( - "runtime_capability.resolved_image_id", - &evidence.resolved_image_id, - ) - .map_err(|_| ReceiptError::InvalidRuntimeCapabilityEvidence("resolved_image_id"))?; - require_text( - "runtime_capability.resolved_image_reference", - &evidence.resolved_image_reference, - )?; - if evidence.resolved_image_reference != self.execution_plan.runtime.image { - return Err(ReceiptError::RuntimeCapabilityEvidenceMismatch); - } - return Ok(()); - } - if self.runtime_capability_evidence.is_some() { - return Err(ReceiptError::UnexpectedRuntimeCapabilityEvidence); - } - Ok(()) - } - - fn validate_artifact_manifest(&self) -> Result<(), ReceiptError> { - let mut expected = BTreeMap::new(); - for check in &self.execution_plan.checks { - for artifact in &check.artifact_contracts { - expected.insert(artifact.path.as_str(), artifact); - } - } - let mut observed = BTreeSet::new(); - for artifact in &self.artifact_manifest { - if !observed.insert(artifact.path.as_str()) { - return Err(ReceiptError::DuplicateArtifactEvidence( - artifact.path.clone(), - )); - } - let planned = expected - .get(artifact.path.as_str()) - .ok_or_else(|| ReceiptError::ArtifactManifestMismatch(artifact.path.clone()))?; - if artifact.kind != planned.kind - || artifact.producer_check != planned.producer_check - || (artifact.kind == ArtifactKind::RegularFile && artifact.entry_count != 1) - || artifact.entry_count > planned.max_entries - || artifact.total_bytes > planned.max_bytes - { - return Err(ReceiptError::ArtifactManifestMismatch( - artifact.path.clone(), - )); - } - validate_sha256("artifact.manifest_digest", &artifact.manifest_digest)?; - } - if observed.len() != expected.len() || expected.keys().any(|path| !observed.contains(path)) - { - return Err(ReceiptError::ArtifactManifestMismatch( - "artifact set".to_owned(), - )); - } - Ok(()) - } -} - -impl SourceSnapshotEvidence { - fn validate(&self) -> Result<(), ReceiptError> { - if self.schema_version != SOURCE_SNAPSHOT_SCHEMA_VERSION { - return Err(ReceiptError::UnsupportedSourceSnapshotSchemaVersion( - self.schema_version.clone(), - )); - } - - require_text("source_snapshot.manifest_digest", &self.manifest_digest)?; - validate_sha256("source_snapshot.manifest_digest", &self.manifest_digest)?; - if self.entry_count == 0 { - return Err(ReceiptError::InvalidSourceSnapshotEntryCount( - self.entry_count, - )); - } - - Ok(()) - } -} - -impl CheckEvidence { - fn validate(&self) -> Result<(), ReceiptError> { - require_text("check.id", &self.id)?; - if self.argv.is_empty() || self.argv.iter().any(|part| part.is_empty()) { - return Err(ReceiptError::InvalidCommand(self.id.clone())); - } - validate_relative_path("check.working_directory", &self.working_directory)?; - validate_incomplete_reason( - "check.incomplete_reason", - self.status, - self.incomplete_reason.as_deref(), - )?; - - match self.status { - EvidenceStatus::Pass => { - if self.exit_code != Some(0) || self.timed_out || self.cancelled { - return Err(ReceiptError::InvalidCheckResult(self.id.clone())); - } - } - EvidenceStatus::Fail => { - if matches!(self.exit_code, None | Some(0)) && !self.timed_out && !self.cancelled { - return Err(ReceiptError::InvalidCheckResult(self.id.clone())); - } - } - EvidenceStatus::Pending | EvidenceStatus::NotRun => { - if self.exit_code.is_some() - || self.duration_ms != 0 - || self.timed_out - || self.cancelled - || self.output_digest.is_some() - { - return Err(ReceiptError::InvalidCheckResult(self.id.clone())); - } - } - } - - if let Some(digest) = &self.output_digest { - validate_sha256("check.output_digest", digest)?; - } - Ok(()) - } -} - -struct ReceiptCommon<'a> { - expected_schema_version: &'static str, - schema_version: &'a str, - producer: &'a ProducerEvidence, - repository: &'a RepositoryEvidence, - run: &'a RunEvidence, - platform: &'a PlatformEvidence, - configuration_digest: &'a str, - checks: &'a [CheckEvidence], - overall_status: EvidenceStatus, - incomplete_reason: Option<&'a str>, - redaction_policy_version: &'a str, -} - -impl ReceiptCommon<'_> { - fn validate(self) -> Result<(), ReceiptError> { - if self.schema_version != self.expected_schema_version { - return Err(ReceiptError::UnsupportedSchemaVersion( - self.schema_version.to_owned(), - )); - } - - require_text("producer.name", &self.producer.name)?; - require_text("producer.version", &self.producer.version)?; - validate_repository_identity(&self.repository.repository)?; - validate_commit_sha(&self.repository.commit_sha)?; - require_text("run.run_id", &self.run.run_id)?; - require_timestamp("run.started_at_utc", &self.run.started_at_utc)?; - require_timestamp("run.finished_at_utc", &self.run.finished_at_utc)?; - if self.run.finished_at_utc < self.run.started_at_utc { - return Err(ReceiptError::InvalidRunWindow); - } - require_text("platform.host_os", &self.platform.host_os)?; - require_text("platform.host_arch", &self.platform.host_arch)?; - require_text("platform.runtime_kind", &self.platform.runtime_kind)?; - require_text("platform.runtime_version", &self.platform.runtime_version)?; - require_text("platform.image_reference", &self.platform.image_reference)?; - validate_sha256("platform.image_digest", &self.platform.image_digest)?; - let image_reference_digest = self - .platform - .image_reference - .rsplit_once('@') - .map(|(_, digest)| digest); - if image_reference_digest != Some(self.platform.image_digest.as_str()) { - return Err(ReceiptError::ImageDigestMismatch); - } - validate_sha256("configuration_digest", self.configuration_digest)?; - require_text("redaction_policy_version", self.redaction_policy_version)?; - - if self.checks.is_empty() { - return Err(ReceiptError::NoChecks); - } - - let mut check_ids = BTreeSet::new(); - let mut required_statuses = Vec::new(); - for check in self.checks { - check.validate()?; - if !check_ids.insert(check.id.as_str()) { - return Err(ReceiptError::DuplicateCheckId(check.id.clone())); - } - if check.required { - required_statuses.push(check.status); - } - } - - if required_statuses.is_empty() { - return Err(ReceiptError::NoRequiredChecks); - } - - let expected_status = derive_overall_status(&required_statuses); - if self.overall_status != expected_status { - return Err(ReceiptError::OverallStatusMismatch { - expected: expected_status, - actual: self.overall_status, - }); - } - - validate_incomplete_reason( - "receipt.incomplete_reason", - self.overall_status, - self.incomplete_reason, - )?; - Ok(()) - } -} - -pub fn canonical_json(value: &T) -> Result, ReceiptError> { - let value = serde_json::to_value(value).map_err(ReceiptError::Serialization)?; - let normalized = normalize_json(value); - serde_json::to_vec(&normalized).map_err(ReceiptError::Serialization) -} - -pub fn receipt_schema_json() -> Result { - let schema = schema_for!(ReceiptEnvelopeV1); - serde_json::to_string_pretty(&schema).map_err(ReceiptError::Serialization) -} +pub use ccp_core::canonical::{canonical_digest, canonical_json}; +pub use ccp_core::errors::ReceiptError; +pub use ccp_core::receipt::*; pub fn receipt_v2_schema_json() -> Result { - crate::schema_contract::combined_receipt_v2_schema_json().map_err(ReceiptError::Serialization) -} - -pub fn canonical_digest(value: &T) -> Result { - let bytes = canonical_json(value)?; - let digest = Sha256::digest(bytes); - Ok(format!("{RECEIPT_ID_PREFIX}{}", encode_hex(&digest))) -} - -fn normalize_json(value: Value) -> Value { - match value { - Value::Array(items) => Value::Array(items.into_iter().map(normalize_json).collect()), - Value::Object(items) => { - let sorted: BTreeMap<_, _> = items - .into_iter() - .map(|(key, value)| (key, normalize_json(value))) - .collect(); - Value::Object(sorted.into_iter().collect()) - } - scalar => scalar, - } -} - -fn encode_hex(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(bytes.len() * 2); - for byte in bytes { - output.push(HEX[(byte >> 4) as usize] as char); - output.push(HEX[(byte & 0x0f) as usize] as char); - } - output -} - -fn derive_overall_status(statuses: &[EvidenceStatus]) -> EvidenceStatus { - if statuses.contains(&EvidenceStatus::Fail) { - EvidenceStatus::Fail - } else if statuses - .iter() - .any(|status| matches!(status, EvidenceStatus::Pending | EvidenceStatus::NotRun)) - { - EvidenceStatus::Pending - } else { - EvidenceStatus::Pass - } -} - -fn validate_incomplete_reason( - field: &'static str, - status: EvidenceStatus, - reason: Option<&str>, -) -> Result<(), ReceiptError> { - let requires_reason = matches!(status, EvidenceStatus::Pending | EvidenceStatus::NotRun); - match (requires_reason, reason.map(str::trim)) { - (true, None | Some("")) => Err(ReceiptError::MissingIncompleteReason(field)), - (false, Some(reason)) if !reason.is_empty() => { - Err(ReceiptError::UnexpectedIncompleteReason(field)) - } - _ => Ok(()), - } -} - -fn require_text(field: &'static str, value: &str) -> Result<(), ReceiptError> { - if value.trim().is_empty() { - Err(ReceiptError::EmptyField(field)) - } else if value.chars().any(char::is_control) { - Err(ReceiptError::ControlCharacter(field)) - } else { - Ok(()) - } -} - -fn validate_commit_sha(value: &str) -> Result<(), ReceiptError> { - if matches!(value.len(), 40 | 64) - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(()) - } else { - Err(ReceiptError::InvalidCommitSha(value.to_owned())) - } -} - -fn validate_repository_identity(value: &str) -> Result<(), ReceiptError> { - require_text("repository.repository", value)?; - let mut segments = value.split('/'); - let owner = segments.next().unwrap_or_default(); - let repository = segments.next().unwrap_or_default(); - let allowed = - |character: char| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'); - if !owner.is_empty() - && !repository.is_empty() - && segments.next().is_none() - && owner.chars().all(allowed) - && repository.chars().all(allowed) - { - Ok(()) - } else { - Err(ReceiptError::InvalidRepositoryIdentity) - } -} - -fn validate_sha256(field: &'static str, value: &str) -> Result<(), ReceiptError> { - let Some(hex) = value.strip_prefix(RECEIPT_ID_PREFIX) else { - return Err(ReceiptError::InvalidSha256(field)); - }; - if hex.len() == 64 - && hex - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(()) - } else { - Err(ReceiptError::InvalidSha256(field)) - } -} - -fn require_timestamp(field: &'static str, value: &str) -> Result<(), ReceiptError> { - require_text(field, value)?; - let bytes = value.as_bytes(); - let separators_are_valid = bytes.len() == 20 - && bytes[4] == b'-' - && bytes[7] == b'-' - && bytes[10] == b'T' - && bytes[13] == b':' - && bytes[16] == b':' - && bytes[19] == b'Z'; - if !separators_are_valid { - return Err(ReceiptError::InvalidTimestamp(field)); - } - - let year = parse_decimal(&bytes[0..4]).ok_or(ReceiptError::InvalidTimestamp(field))?; - let month = parse_decimal(&bytes[5..7]).ok_or(ReceiptError::InvalidTimestamp(field))?; - let day = parse_decimal(&bytes[8..10]).ok_or(ReceiptError::InvalidTimestamp(field))?; - let hour = parse_decimal(&bytes[11..13]).ok_or(ReceiptError::InvalidTimestamp(field))?; - let minute = parse_decimal(&bytes[14..16]).ok_or(ReceiptError::InvalidTimestamp(field))?; - let second = parse_decimal(&bytes[17..19]).ok_or(ReceiptError::InvalidTimestamp(field))?; - - let leap_year = divisible_by(year, 4) && (!divisible_by(year, 100) || divisible_by(year, 400)); - let days_in_month = match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if leap_year => 29, - 2 => 28, - _ => 0, - }; - if year > 0 && (1..=days_in_month).contains(&day) && hour <= 23 && minute <= 59 && second <= 59 - { - Ok(()) - } else { - Err(ReceiptError::InvalidTimestamp(field)) - } -} - -fn parse_decimal(bytes: &[u8]) -> Option { - bytes.iter().try_fold(0_u32, |value, byte| { - byte.is_ascii_digit() - .then(|| value * 10 + u32::from(byte - b'0')) - }) -} - -fn divisible_by(value: u32, divisor: u32) -> bool { - value / divisor * divisor == value -} - -fn validate_relative_path(field: &'static str, value: &str) -> Result<(), ReceiptError> { - require_text(field, value)?; - let is_safe = value == "." - || (!value.starts_with('/') - && !value.starts_with('~') - && !value.contains('\\') - && !value.contains(':') - && value - .split('/') - .all(|segment| !segment.is_empty() && segment != "." && segment != "..")); - if is_safe { - Ok(()) - } else { - Err(ReceiptError::UnsafePath(field)) - } -} - -#[derive(Debug)] -pub enum ReceiptError { - Serialization(serde_json::Error), - UnsupportedSchemaVersion(String), - UnsupportedSourceSnapshotSchemaVersion(String), - EmptyField(&'static str), - ControlCharacter(&'static str), - InvalidCommitSha(String), - InvalidRepositoryIdentity, - InvalidSha256(&'static str), - InvalidTimestamp(&'static str), - InvalidRunWindow, - ImageDigestMismatch, - UnsafePath(&'static str), - NoChecks, - NoRequiredChecks, - DuplicateCheckId(String), - InvalidCommand(String), - InvalidCheckResult(String), - MissingIncompleteReason(&'static str), - UnexpectedIncompleteReason(&'static str), - InvalidSourceSnapshotEntryCount(u64), - OverallStatusMismatch { - expected: EvidenceStatus, - actual: EvidenceStatus, - }, - DigestMismatch { - expected: String, - actual: String, - }, - ExecutionPlanDigestMismatch { - expected: String, - actual: String, - }, - ExecutionPlanCheckMismatch(String), - DuplicateArtifactEvidence(String), - ArtifactManifestMismatch(String), - MissingRuntimeCapabilityEvidence, - UnexpectedRuntimeCapabilityEvidence, - InvalidRuntimeCapabilityEvidence(&'static str), - RuntimeCapabilityEvidenceMismatch, -} - -impl fmt::Display for ReceiptError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Serialization(error) => { - write!(formatter, "receipt serialization failed: {error}") - } - Self::UnsupportedSchemaVersion(version) => { - write!(formatter, "unsupported receipt schema version: {version}") - } - Self::UnsupportedSourceSnapshotSchemaVersion(version) => { - write!( - formatter, - "unsupported source snapshot schema version: {version}" - ) - } - Self::EmptyField(field) => write!(formatter, "receipt field is empty: {field}"), - Self::ControlCharacter(field) => { - write!( - formatter, - "receipt field contains a control character: {field}" - ) - } - Self::InvalidCommitSha(value) => write!(formatter, "invalid commit SHA: {value}"), - Self::InvalidRepositoryIdentity => write!(formatter, "invalid repository identity"), - Self::InvalidSha256(field) => write!(formatter, "invalid SHA-256 value: {field}"), - Self::InvalidTimestamp(field) => write!(formatter, "invalid UTC timestamp: {field}"), - Self::InvalidRunWindow => write!(formatter, "receipt run finishes before it starts"), - Self::ImageDigestMismatch => { - write!(formatter, "image reference is not pinned to image digest") - } - Self::UnsafePath(field) => write!(formatter, "unsafe receipt path: {field}"), - Self::NoChecks => write!(formatter, "receipt contains no checks"), - Self::NoRequiredChecks => write!(formatter, "receipt contains no required checks"), - Self::DuplicateCheckId(id) => write!(formatter, "duplicate check ID: {id}"), - Self::InvalidCommand(id) => write!(formatter, "invalid command for check: {id}"), - Self::InvalidCheckResult(id) => { - write!(formatter, "inconsistent result for check: {id}") - } - Self::MissingIncompleteReason(field) => { - write!(formatter, "missing incomplete reason: {field}") - } - Self::UnexpectedIncompleteReason(field) => { - write!(formatter, "unexpected incomplete reason: {field}") - } - Self::InvalidSourceSnapshotEntryCount(count) => { - write!(formatter, "invalid source snapshot entry count: {count}") - } - Self::OverallStatusMismatch { expected, actual } => write!( - formatter, - "overall status mismatch: expected {expected:?}, found {actual:?}" - ), - Self::DigestMismatch { expected, actual } => { - write!( - formatter, - "receipt digest mismatch: expected {expected}, found {actual}" - ) - } - Self::ExecutionPlanDigestMismatch { expected, actual } => write!( - formatter, - "receipt execution plan digest mismatch: expected {expected}, found {actual}" - ), - Self::ExecutionPlanCheckMismatch(id) => { - write!( - formatter, - "receipt evidence does not match execution plan check: {id}" - ) - } - Self::DuplicateArtifactEvidence(path) => { - write!(formatter, "duplicate artifact evidence path: {path}") - } - Self::ArtifactManifestMismatch(path) => { - write!( - formatter, - "artifact evidence does not match the execution plan: {path}" - ) - } - Self::MissingRuntimeCapabilityEvidence => { - write!( - formatter, - "schema 1.3 receipt lacks runtime capability evidence" - ) - } - Self::UnexpectedRuntimeCapabilityEvidence => write!( - formatter, - "historical receipt unexpectedly contains runtime capability evidence" - ), - Self::InvalidRuntimeCapabilityEvidence(field) => { - write!(formatter, "runtime capability evidence is invalid: {field}") - } - Self::RuntimeCapabilityEvidenceMismatch => write!( - formatter, - "runtime capability evidence does not match the execution plan" - ), - } - } -} - -impl std::error::Error for ReceiptError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Serialization(error) => Some(error), - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{ArtifactKind, ConfigV1, NormalizedArtifactContract}; - - fn digest(fill: char) -> String { - format!("sha256:{}", fill.to_string().repeat(64)) - } - - fn passing_receipt() -> ReceiptV1 { - ReceiptV1 { - schema_version: RECEIPT_SCHEMA_VERSION.to_owned(), - producer: ProducerEvidence { - name: "commit-ci-preflight".to_owned(), - version: "0.1.0".to_owned(), - }, - repository: RepositoryEvidence { - repository: "example/project".to_owned(), - commit_sha: "0123456789abcdef0123456789abcdef01234567".to_owned(), - dirty: false, - }, - run: RunEvidence { - run_id: "fixture-run-0001".to_owned(), - generation: 1, - started_at_utc: "2026-08-08T12:00:00Z".to_owned(), - finished_at_utc: "2026-08-08T12:00:01Z".to_owned(), - }, - platform: PlatformEvidence { - host_os: "macos".to_owned(), - host_arch: "aarch64".to_owned(), - runtime_kind: "orbstack".to_owned(), - runtime_version: "fixture-1".to_owned(), - image_reference: format!("example.invalid/ci@{}", digest('a')), - image_digest: digest('a'), - }, - configuration_digest: digest('b'), - checks: vec![CheckEvidence { - id: "rust-test".to_owned(), - required: true, - argv: vec!["cargo".to_owned(), "test".to_owned()], - working_directory: ".".to_owned(), - status: EvidenceStatus::Pass, - exit_code: Some(0), - duration_ms: 1000, - timed_out: false, - cancelled: false, - output_digest: Some(digest('c')), - incomplete_reason: None, - }], - overall_status: EvidenceStatus::Pass, - incomplete_reason: None, - redaction_policy_version: "1".to_owned(), - } - } - - fn source_snapshot() -> SourceSnapshotEvidence { - SourceSnapshotEvidence { - schema_version: SOURCE_SNAPSHOT_SCHEMA_VERSION.to_owned(), - strategy: SourceSnapshotStrategy::GitObject, - manifest_digest: digest('d'), - entry_count: 1, - } - } - - fn execution_plan_v2() -> ExecutionPlanV1 { - ConfigV1::parse( - r#" -schema_version = "1.0" -project = "example/project" - -[runtime] -kind = "docker_compatible" -image = "example.invalid/ci@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -cpu_count = 1 -memory_mib = 128 -pids_limit = 16 - -[[checks]] -id = "rust-test" -required = true -argv = ["cargo", "test"] -working_directory = "." -timeout_seconds = 60 -"#, - ) - .expect("config") - .into_plan() - .expect("plan") - .plan - } - - fn passing_receipt_v2() -> ReceiptV2 { - let execution_plan = execution_plan_v2(); - ReceiptV2 { - schema_version: RECEIPT_V2_SCHEMA_VERSION.to_owned(), - producer: ProducerEvidence { - name: "commit-ci-preflight".to_owned(), - version: "0.1.0".to_owned(), - }, - repository: RepositoryEvidence { - repository: "example/project".to_owned(), - commit_sha: "0123456789abcdef0123456789abcdef01234567".to_owned(), - dirty: false, - }, - run: RunEvidence { - run_id: "fixture-run-0001".to_owned(), - generation: 1, - started_at_utc: "2026-08-08T12:00:00Z".to_owned(), - finished_at_utc: "2026-08-08T12:00:01Z".to_owned(), - }, - source_snapshot: source_snapshot(), - platform: PlatformEvidence { - host_os: "macos".to_owned(), - host_arch: "aarch64".to_owned(), - runtime_kind: "orbstack".to_owned(), - runtime_version: "fixture-1".to_owned(), - image_reference: format!("example.invalid/ci@{}", digest('a')), - image_digest: digest('a'), - }, - configuration_digest: canonical_digest(&execution_plan).expect("plan digest"), - execution_plan, - runtime_capability_evidence: None, - artifact_manifest: Vec::new(), - checks: vec![CheckEvidence { - id: "rust-test".to_owned(), - required: true, - argv: vec!["cargo".to_owned(), "test".to_owned()], - working_directory: ".".to_owned(), - status: EvidenceStatus::Pass, - exit_code: Some(0), - duration_ms: 1000, - timed_out: false, - cancelled: false, - output_digest: Some(digest('c')), - incomplete_reason: None, - }], - overall_status: EvidenceStatus::Pass, - incomplete_reason: None, - redaction_policy_version: "1".to_owned(), - } - } - - fn passing_schema_1_3_receipt() -> ReceiptV2 { - let execution_plan = ConfigV1::parse( - r#" -schema_version = "1.3" -project = "example/project" - -[runtime] -kind = "docker_compatible" -image = "example.invalid/ci@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -cpu_count = 1 -memory_mib = 128 -pids_limit = 16 -pull_policy = "never" -swap_mode = "disabled" - -[storage] -min_free_bytes = 1 -receipt_journal_reserve_bytes = 4096 -max_cache_growth_bytes = 0 - -[[checks]] -id = "rust-test" -required = true -argv = ["cargo", "test"] -working_directory = "." -timeout_seconds = 60 -"#, - ) - .expect("config") - .into_plan() - .expect("plan") - .plan; - let mut receipt = passing_receipt_v2(); - receipt.configuration_digest = canonical_digest(&execution_plan).expect("plan digest"); - receipt.execution_plan = execution_plan; - receipt.runtime_capability_evidence = Some(RuntimeCapabilityEvidenceV1 { - schema_version: "1.0".to_owned(), - memory_limit_supported: true, - swap_limit_supported: true, - context_digest: digest('b'), - resolved_image_id: digest('c'), - resolved_image_reference: receipt.execution_plan.runtime.image.clone(), - }); - receipt - } - - #[test] - fn schema_1_3_receipt_requires_matching_runtime_capability_evidence() { - let mut receipt = passing_schema_1_3_receipt(); - receipt.runtime_capability_evidence = None; - - assert!(matches!( - receipt.validate(), - Err(ReceiptError::MissingRuntimeCapabilityEvidence) - )); - } - - #[test] - fn historical_receipt_rejects_runtime_capability_evidence() { - let mut receipt = passing_receipt_v2(); - receipt.runtime_capability_evidence = Some(RuntimeCapabilityEvidenceV1 { - schema_version: "1.0".to_owned(), - memory_limit_supported: true, - swap_limit_supported: true, - context_digest: digest('b'), - resolved_image_id: digest('c'), - resolved_image_reference: receipt.execution_plan.runtime.image.clone(), - }); - - assert!(matches!( - receipt.validate(), - Err(ReceiptError::UnexpectedRuntimeCapabilityEvidence) - )); - } - - #[test] - fn schema_1_3_receipt_rejects_invalid_or_mismatched_runtime_capability_evidence() { - let mut receipt = passing_schema_1_3_receipt(); - receipt - .runtime_capability_evidence - .as_mut() - .expect("evidence") - .resolved_image_reference = "example.invalid/other@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(); - assert!(matches!( - receipt.validate(), - Err(ReceiptError::RuntimeCapabilityEvidenceMismatch) - )); - - let mut receipt = passing_schema_1_3_receipt(); - receipt - .runtime_capability_evidence - .as_mut() - .expect("evidence") - .context_digest = "sha256:not-a-digest".to_owned(); - assert!(matches!( - receipt.validate(), - Err(ReceiptError::InvalidRuntimeCapabilityEvidence(_)) - )); - } - - #[test] - fn v2_receipt_rejects_a_declared_artifact_without_observed_evidence() { - let mut receipt = passing_receipt_v2(); - receipt.execution_plan.checks[0] - .artifact_contracts - .push(NormalizedArtifactContract { - path: "results/report.json".to_owned(), - kind: ArtifactKind::RegularFile, - max_bytes: 1024, - max_entries: 1, - producer_check: "rust-test".to_owned(), - }); - receipt.configuration_digest = - canonical_digest(&receipt.execution_plan).expect("updated plan digest"); - - assert!(receipt.validate().is_err()); - } - - #[test] - fn v2_receipt_rejects_regular_file_evidence_without_one_file_entry() { - let mut receipt = passing_receipt_v2(); - receipt.execution_plan.checks[0] - .artifact_contracts - .push(NormalizedArtifactContract { - path: "results/report.json".to_owned(), - kind: ArtifactKind::RegularFile, - max_bytes: 1024, - max_entries: 1, - producer_check: "rust-test".to_owned(), - }); - receipt.configuration_digest = - canonical_digest(&receipt.execution_plan).expect("updated plan digest"); - receipt.artifact_manifest.push(ArtifactEvidence { - path: "results/report.json".to_owned(), - kind: ArtifactKind::RegularFile, - producer_check: "rust-test".to_owned(), - entry_count: 0, - total_bytes: 0, - manifest_digest: digest('d'), - }); - - assert!(receipt.validate().is_err()); - } - - #[test] - fn deterministic_replay_produces_identical_bytes_and_id() { - let first = ReceiptEnvelopeV1::seal(passing_receipt()).expect("first receipt"); - let second = ReceiptEnvelopeV1::seal(passing_receipt()).expect("second receipt"); - - assert_eq!(first, second); - assert_eq!( - first.canonical_bytes().expect("first bytes"), - second.canonical_bytes().expect("second bytes") - ); - } - - #[test] - fn tampering_is_rejected() { - let mut envelope = ReceiptEnvelopeV1::seal(passing_receipt()).expect("receipt"); - envelope.receipt.run.generation += 1; - - assert!(matches!( - envelope.verify(), - Err(ReceiptError::DigestMismatch { .. }) - )); - } - - #[test] - fn unknown_fields_are_rejected() { - let envelope = ReceiptEnvelopeV1::seal(passing_receipt()).expect("receipt"); - let mut value = serde_json::to_value(envelope).expect("JSON value"); - value - .as_object_mut() - .expect("root object") - .insert("unexpected".to_owned(), Value::Bool(true)); - - let result = serde_json::from_value::(value); - assert!(result.is_err()); - } - - #[test] - fn duplicate_check_ids_are_rejected() { - let mut receipt = passing_receipt(); - receipt.checks.push(receipt.checks[0].clone()); - - assert!(matches!( - receipt.validate(), - Err(ReceiptError::DuplicateCheckId(_)) - )); - } - - #[test] - fn pending_required_check_requires_truthful_reason() { - let mut receipt = passing_receipt(); - let check = &mut receipt.checks[0]; - check.status = EvidenceStatus::Pending; - check.exit_code = None; - check.duration_ms = 0; - check.output_digest = None; - receipt.overall_status = EvidenceStatus::Pending; - receipt.incomplete_reason = Some("Windows-native execution is pending".to_owned()); - - assert!(matches!( - receipt.validate(), - Err(ReceiptError::MissingIncompleteReason( - "check.incomplete_reason" - )) - )); - - receipt.checks[0].incomplete_reason = - Some("Windows-native execution is pending".to_owned()); - receipt.validate().expect("truthful pending receipt"); - } - - #[test] - fn absolute_and_parent_paths_are_rejected() { - for path in [ - "/Users/example/project", - "../project", - "nested/../../escape", - r"C:\Users\example\project", - "C:/Users/example/project", - "~/project", - "nested//project", - "nested/./project", - ] { - let mut receipt = passing_receipt(); - receipt.checks[0].working_directory = path.to_owned(); - assert!(matches!( - receipt.validate(), - Err(ReceiptError::UnsafePath("check.working_directory")) - )); - } - } - - #[test] - fn failed_check_requires_failure_evidence() { - let mut receipt = passing_receipt(); - receipt.checks[0].status = EvidenceStatus::Fail; - receipt.checks[0].exit_code = None; - receipt.checks[0].output_digest = None; - receipt.overall_status = EvidenceStatus::Fail; - - assert!(matches!( - receipt.validate(), - Err(ReceiptError::InvalidCheckResult(id)) if id == "rust-test" - )); - - receipt.checks[0].timed_out = true; - receipt.validate().expect("timeout is failure evidence"); - } - - #[test] - fn uppercase_or_short_digests_are_rejected() { - for value in [digest('A'), "sha256:abcd".to_owned(), "abcd".to_owned()] { - let mut receipt = passing_receipt(); - receipt.configuration_digest = value; - assert!(matches!( - receipt.validate(), - Err(ReceiptError::InvalidSha256("configuration_digest")) - )); - } - } - - #[test] - fn overall_status_is_derived_from_required_checks() { - let mut receipt = passing_receipt(); - receipt.overall_status = EvidenceStatus::Pending; - receipt.incomplete_reason = Some("incorrectly marked pending".to_owned()); - - assert!(matches!( - receipt.validate(), - Err(ReceiptError::OverallStatusMismatch { - expected: EvidenceStatus::Pass, - actual: EvidenceStatus::Pending - }) - )); - } - - #[test] - fn timestamps_are_strict_utc_and_ordered() { - for timestamp in [ - "2026-02-29T12:00:00Z", - "2026-13-01T12:00:00Z", - "2026-08-08 12:00:00Z", - "2026-08-08T24:00:00Z", - "2026-08-08T12:00:00+00:00", - ] { - let mut receipt = passing_receipt(); - receipt.run.started_at_utc = timestamp.to_owned(); - assert!(matches!( - receipt.validate(), - Err(ReceiptError::InvalidTimestamp("run.started_at_utc")) - )); - } - - let mut receipt = passing_receipt(); - receipt.run.started_at_utc = "2026-08-08T12:00:02Z".to_owned(); - assert!(matches!( - receipt.validate(), - Err(ReceiptError::InvalidRunWindow) - )); - } - - #[test] - fn repository_identity_cannot_embed_credentials_or_urls() { - for repository in [ - "https://github.com/example/project", - "user:token@example/project", - "example/project/extra", - "/example/project", - ] { - let mut receipt = passing_receipt(); - receipt.repository.repository = repository.to_owned(); - assert!(matches!( - receipt.validate(), - Err(ReceiptError::InvalidRepositoryIdentity) - )); - } - } - - #[test] - fn image_reference_must_match_pinned_digest() { - let mut receipt = passing_receipt(); - receipt.platform.image_reference = "example.invalid/ci:latest".to_owned(); - assert!(matches!( - receipt.validate(), - Err(ReceiptError::ImageDigestMismatch) - )); - } - - #[test] - fn schema_is_generated_and_versioned() { - let schema = receipt_schema_json().expect("schema JSON"); - assert!(schema.contains("ReceiptEnvelopeV1")); - assert!(schema.contains("receipt_id")); - assert!(!schema.contains("SCREAMING_SNAKE_CASE")); - } - - #[test] - fn v2_deterministic_replay_produces_identical_bytes_and_id() { - let first = ReceiptEnvelopeV2::seal(passing_receipt_v2()).expect("first receipt"); - let second = ReceiptEnvelopeV2::seal(passing_receipt_v2()).expect("second receipt"); - - assert_eq!(first, second); - assert_eq!( - first.canonical_bytes().expect("first bytes"), - second.canonical_bytes().expect("second bytes") - ); - } - - #[test] - fn v2_rejects_check_evidence_that_disagrees_with_its_execution_plan() { - let mut receipt = passing_receipt_v2(); - receipt.checks[0].argv.push("--release".to_owned()); - - assert!(matches!( - ReceiptEnvelopeV2::seal(receipt), - Err(ReceiptError::ExecutionPlanCheckMismatch(id)) if id == "rust-test" - )); - } - - #[test] - fn v2_source_snapshot_validation_is_strict() { - let mut receipt = passing_receipt_v2(); - receipt.source_snapshot.schema_version = "9.9".to_owned(); - assert!(matches!( - receipt.validate(), - Err(ReceiptError::UnsupportedSourceSnapshotSchemaVersion(version)) if version == "9.9" - )); - - let mut receipt = passing_receipt_v2(); - receipt.source_snapshot.entry_count = 0; - assert!(matches!( - receipt.validate(), - Err(ReceiptError::InvalidSourceSnapshotEntryCount(0)) - )); - } + ccp_core::schema::combined_receipt_v2_schema_json().map_err(ReceiptError::Serialization) } diff --git a/src/runtime.rs b/src/runtime.rs index dd1e6e7..93a662c 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -18,8 +18,7 @@ use std::fmt; use std::path::Path; use std::time::Duration; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -31,11 +30,12 @@ use crate::process::{ CancellationToken, CleanupStatus, GenerationGuard, ProcessError, ProcessRequest, ProcessResult, ProcessTermination, RunIdentity, SupervisorPort, }; -use crate::receipt::canonical_digest; use crate::workspace::{ MountAccess, MountBinding, WorkspaceError, WorkspacePlanV1, validate_container_mount_target, validate_host_path, }; +use ccp_core::canonical::canonical_digest; +pub use ccp_core::runtime_evidence::RuntimeCapabilityEvidenceV1; const DOCTOR_TIMEOUT: Duration = Duration::from_secs(5); const DOCTOR_CAPTURE_BYTES: usize = 65_536; @@ -841,17 +841,6 @@ pub struct RuntimeProbe { pub graceful_stop: GracefulStopCapability, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RuntimeCapabilityEvidenceV1 { - pub schema_version: String, - pub memory_limit_supported: bool, - pub swap_limit_supported: bool, - pub context_digest: String, - pub resolved_image_id: String, - pub resolved_image_reference: String, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct DryRunPlan { pub schema_version: &'static str, diff --git a/src/verify.rs b/src/verify.rs index 9e67fff..5261efd 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -12,29 +12,27 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::collections::BTreeSet; +//! Compatibility facade for the physically independent verifier core. + use std::fmt; -use std::fs; use std::io; use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; - -use schemars::{JsonSchema, schema_for}; -use serde::{Deserialize, Serialize}; -use crate::config::{ConfigError, ConfigV1, ExecutionPlanEnvelopeV1, ExecutionPlanV1}; -use crate::receipt::{ - CheckEvidence, EvidenceStatus, PlatformEvidence, ReceiptEnvelopeV1, ReceiptEnvelopeV2, - ReceiptError, RepositoryEvidence, RunEvidence, SourceSnapshotStrategy, canonical_json, +pub use ccp_core::errors::{PolicyError, TrustedPlanError, VerificationError}; +pub use ccp_core::verification_model::{ + AcceptedPlatformV1, VerificationDecision, VerificationFindingV1, VerificationReportV1, + VerificationStatus, +}; +pub use ccp_core::verify::{ + POLICY_SCHEMA_VERSION, ProducerContractV1_1, TRUSTED_PLAN_POLICY_SCHEMA_VERSION, + VERIFICATION_REPORT_SCHEMA_VERSION, VerificationPolicyDocument, VerificationPolicyV1, + VerificationPolicyV1_1, receipt_input_failure_report, system_evaluated_at_utc, + trusted_plan_policy_schema_json, validate_verification_policy_path, + verification_policy_schema_json, verification_report_schema_json, verify_receipt_document, + verify_receipt_document_for_policy, verify_receipt_document_for_policy_path, }; -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VerificationPolicyDocument { - V1(VerificationPolicyV1), - V1_1(VerificationPolicyV1_1), - V2(crate::matrix::MatrixVerificationPolicyV2), -} - +/// Compatibility error preserving the adopted root `V2(MatrixError)` payload. #[derive(Debug)] pub enum VerificationPolicyDocumentError { Io(io::Error), @@ -69,1150 +67,30 @@ impl std::error::Error for VerificationPolicyDocumentError {} pub fn load_verification_policy_document( path: &Path, ) -> Result { - let metadata = fs::metadata(path).map_err(VerificationPolicyDocumentError::Io)?; - if metadata.len() > MAX_POLICY_BYTES as u64 { - return Err(VerificationPolicyDocumentError::TooLarge); - } - let bytes = fs::read(path).map_err(VerificationPolicyDocumentError::Io)?; - let source = - std::str::from_utf8(&bytes).map_err(|_| VerificationPolicyDocumentError::InvalidUtf8)?; - let value: toml::Value = - toml::from_str(source).map_err(VerificationPolicyDocumentError::Parse)?; - let version = value.get("schema_version").and_then(toml::Value::as_str); - match version { - Some(POLICY_SCHEMA_VERSION) => VerificationPolicyV1::parse(&bytes) - .map(VerificationPolicyDocument::V1) - .map_err(VerificationPolicyDocumentError::V1), - Some(TRUSTED_PLAN_POLICY_SCHEMA_VERSION) => VerificationPolicyV1_1::parse(&bytes) - .map(VerificationPolicyDocument::V1_1) - .map_err(VerificationPolicyDocumentError::V1_1), - Some(crate::matrix::MATRIX_POLICY_SCHEMA_VERSION) => { - crate::matrix::MatrixVerificationPolicyV2::parse(source) - .map(VerificationPolicyDocument::V2) - .map_err(VerificationPolicyDocumentError::V2) - } - _ => Err(VerificationPolicyDocumentError::UnsupportedSchemaVersion), - } -} - -pub const POLICY_SCHEMA_VERSION: &str = "1.0"; -pub const TRUSTED_PLAN_POLICY_SCHEMA_VERSION: &str = "1.1"; -pub const VERIFICATION_REPORT_SCHEMA_VERSION: &str = "1.0"; -const MAX_POLICY_BYTES: usize = 1024 * 1024; -const MAX_RECEIPT_BYTES: usize = 4 * 1024 * 1024; -const MAX_REQUIRED_CHECKS: usize = 128; -const MAX_PLATFORMS: usize = 32; -const MAX_FRESHNESS_SECONDS: u64 = 31_536_000; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct VerificationPolicyV1 { - pub schema_version: String, - pub project: String, - pub configuration_digest: String, - pub required_checks: Vec, - pub image_reference: String, - pub max_age_seconds: u64, - pub platforms: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct AcceptedPlatformV1 { - pub host_os: String, - pub host_arch: String, - pub runtime_kind: String, -} - -impl VerificationPolicyV1 { - pub fn load(path: &Path) -> Result { - let metadata = fs::metadata(path).map_err(PolicyError::Io)?; - if metadata.len() > MAX_POLICY_BYTES as u64 { - return Err(PolicyError::TooLarge); - } - let bytes = fs::read(path).map_err(PolicyError::Io)?; - Self::parse(&bytes) - } - - pub fn parse(bytes: &[u8]) -> Result { - if bytes.len() > MAX_POLICY_BYTES { - return Err(PolicyError::TooLarge); - } - let source = std::str::from_utf8(bytes).map_err(|_| PolicyError::InvalidUtf8)?; - let policy: Self = toml::from_str(source).map_err(PolicyError::Parse)?; - policy.validate()?; - Ok(policy) - } - - pub fn validate(&self) -> Result<(), PolicyError> { - if self.schema_version != POLICY_SCHEMA_VERSION { - return Err(PolicyError::UnsupportedSchemaVersion); - } - validate_project(&self.project)?; - validate_digest(&self.configuration_digest)?; - validate_image_reference(&self.image_reference)?; - if !(1..=MAX_FRESHNESS_SECONDS).contains(&self.max_age_seconds) { - return Err(PolicyError::InvalidField("max_age_seconds")); - } - if self.required_checks.is_empty() || self.required_checks.len() > MAX_REQUIRED_CHECKS { - return Err(PolicyError::InvalidField("required_checks")); - } - let mut checks = BTreeSet::new(); - for check in &self.required_checks { - validate_name("required_checks", check)?; - if !checks.insert(check) { - return Err(PolicyError::DuplicateValue("required_checks")); - } - } - if self.platforms.is_empty() || self.platforms.len() > MAX_PLATFORMS { - return Err(PolicyError::InvalidField("platforms")); - } - let mut platforms = BTreeSet::new(); - for platform in &self.platforms { - validate_name("platforms.host_os", &platform.host_os)?; - validate_name("platforms.host_arch", &platform.host_arch)?; - validate_name("platforms.runtime_kind", &platform.runtime_kind)?; - if !platforms.insert(( - &platform.host_os, - &platform.host_arch, - &platform.runtime_kind, - )) { - return Err(PolicyError::DuplicateValue("platforms")); - } - } - Ok(()) - } -} - -/// Strict policy version that reconstructs the normalized execution plan from -/// the trusted checkout before accepting a v2 receipt. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct VerificationPolicyV1_1 { - pub schema_version: String, - pub project: String, - pub configuration_digest: String, - pub required_checks: Vec, - pub image_reference: String, - pub max_age_seconds: u64, - pub platforms: Vec, - pub trusted_config: String, - pub source_snapshot_strategy: SourceSnapshotStrategy, - pub supported_producers: Vec, - #[serde(default)] - pub revoked_producers: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ProducerContractV1_1 { - pub name: String, - pub version: String, -} - -impl VerificationPolicyV1_1 { - pub fn parse(bytes: &[u8]) -> Result { - if bytes.len() > MAX_POLICY_BYTES { - return Err(PolicyError::TooLarge); - } - let source = std::str::from_utf8(bytes).map_err(|_| PolicyError::InvalidUtf8)?; - let policy: Self = toml::from_str(source).map_err(PolicyError::Parse)?; - policy.validate()?; - Ok(policy) - } - - pub fn validate(&self) -> Result<(), PolicyError> { - if self.schema_version != TRUSTED_PLAN_POLICY_SCHEMA_VERSION { - return Err(PolicyError::UnsupportedSchemaVersion); - } - self.baseline().validate()?; - validate_trusted_config_path(&self.trusted_config)?; - if self.supported_producers.is_empty() { - return Err(PolicyError::InvalidField("supported_producers")); - } - let mut supported = BTreeSet::new(); - for producer in &self.supported_producers { - validate_producer_contract(producer)?; - if !supported.insert((&producer.name, &producer.version)) { - return Err(PolicyError::DuplicateValue("supported_producers")); - } - } - let mut revoked = BTreeSet::new(); - for producer in &self.revoked_producers { - validate_producer_contract(producer)?; - let key = (&producer.name, &producer.version); - if !revoked.insert(key) { - return Err(PolicyError::DuplicateValue("revoked_producers")); - } - if supported.contains(&key) { - return Err(PolicyError::DuplicateValue("producer_contracts")); - } - } - Ok(()) - } - - fn baseline(&self) -> VerificationPolicyV1 { - VerificationPolicyV1 { - schema_version: POLICY_SCHEMA_VERSION.to_owned(), - project: self.project.clone(), - configuration_digest: self.configuration_digest.clone(), - required_checks: self.required_checks.clone(), - image_reference: self.image_reference.clone(), - max_age_seconds: self.max_age_seconds, - platforms: self.platforms.clone(), - } - } - - fn load_trusted_plan( - &self, - policy_path: &Path, - ) -> Result { - let parent = policy_path.parent().ok_or(TrustedPlanError::PolicyPath)?; - let config_path = parent.join(&self.trusted_config); - let metadata = fs::symlink_metadata(&config_path).map_err(TrustedPlanError::Io)?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(TrustedPlanError::UnsafeConfigurationPath); - } - ConfigV1::load(&config_path) - .and_then(ConfigV1::into_plan) - .map_err(TrustedPlanError::Config) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum VerificationStatus { - Pass, - Fail, - NotRun, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum VerificationDecision { - Pass, - Fail, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct VerificationFindingV1 { - pub code: String, - pub field: String, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct VerificationReportV1 { - pub schema_version: String, - pub assurance_scope: String, - pub evaluated_at_utc: String, - pub expected_commit: String, - pub receipt_id: Option, - pub integrity_status: VerificationStatus, - pub policy_status: VerificationStatus, - pub decision: VerificationDecision, - pub findings: Vec, -} - -impl VerificationReportV1 { - pub fn canonical_bytes(&self) -> Result, VerificationError> { - canonical_json(self).map_err(VerificationError::Receipt) - } - - pub fn exit_code(&self) -> i32 { - match self.decision { - VerificationDecision::Pass => 0, - VerificationDecision::Fail => 3, - } - } -} - -pub fn verify_receipt_document( - bytes: &[u8], - policy: &VerificationPolicyV1, - expected_commit: &str, - evaluated_at_utc: &str, -) -> Result { - policy.validate().map_err(VerificationError::Policy)?; - validate_commit(expected_commit)?; - let evaluated_at = - parse_utc_seconds(evaluated_at_utc).ok_or(VerificationError::InvalidEvaluationTime)?; - let mut report = VerificationReportV1 { - schema_version: VERIFICATION_REPORT_SCHEMA_VERSION.to_owned(), - assurance_scope: "integrity_and_repository_policy_only".to_owned(), - evaluated_at_utc: evaluated_at_utc.to_owned(), - expected_commit: expected_commit.to_owned(), - receipt_id: None, - integrity_status: VerificationStatus::Fail, - policy_status: VerificationStatus::NotRun, - decision: VerificationDecision::Fail, - findings: Vec::new(), - }; - - if bytes.len() > MAX_RECEIPT_BYTES { - report.findings.push(finding( - "receipt.too_large", - "receipt", - "receipt exceeds the bounded verification input size", - )); - return Ok(report); - } - let document = match serde_json::from_slice::(bytes) { - Ok(value) => value, - Err(_) => { - report.findings.push(finding( - "receipt.parse_or_shape", - "receipt", - "receipt is not valid strict schema v1 or v2 JSON", - )); - return Ok(report); - } - }; - let Some(schema_version) = document - .pointer("/receipt/schema_version") - .and_then(serde_json::Value::as_str) - else { - report.findings.push(finding( - "receipt.parse_or_shape", - "receipt.schema_version", - "receipt schema version is missing or invalid", - )); - return Ok(report); - }; - let parsed = match schema_version { - "1.0" => serde_json::from_slice::(bytes).map(VerifiedReceipt::V1), - "2.0" => serde_json::from_slice::(bytes).map(VerifiedReceipt::V2), - _ => { - report.findings.push(finding( - "receipt.unsupported_schema", - "receipt.schema_version", - "receipt schema version is unsupported", - )); - return Ok(report); + ccp_core::verify::load_verification_policy_document(path).map_err(|error| match error { + ccp_core::verify::VerificationPolicyDocumentError::Io(error) => { + VerificationPolicyDocumentError::Io(error) } - }; - let envelope = match parsed { - Ok(envelope) => envelope, - Err(_) => { - report.findings.push(finding( - "receipt.parse_or_shape", - "receipt", - "receipt is not valid strict schema v1 or v2 JSON", - )); - return Ok(report); - } - }; - if let Err(error) = envelope.verify() { - let (code, message) = match error { - ReceiptError::UnsupportedSchemaVersion(_) => ( - "receipt.unsupported_schema", - "receipt schema version is unsupported", - ), - ReceiptError::DigestMismatch { .. } => ( - "receipt.digest_mismatch", - "receipt payload does not match its integrity identifier", - ), - _ => ( - "receipt.semantic_invalid", - "receipt violates schema semantic invariants", - ), - }; - report.findings.push(finding(code, "receipt", message)); - return Ok(report); - } - - report.receipt_id = Some(envelope.receipt_id().to_owned()); - report.integrity_status = VerificationStatus::Pass; - report.policy_status = VerificationStatus::Pass; - evaluate_policy( - envelope.view(), - policy, - expected_commit, - evaluated_at, - &mut report.findings, - ); - if !report.findings.is_empty() { - report.policy_status = VerificationStatus::Fail; - } - report.decision = if report.integrity_status == VerificationStatus::Pass - && report.policy_status == VerificationStatus::Pass - { - VerificationDecision::Pass - } else { - VerificationDecision::Fail - }; - Ok(report) -} - -/// Verify a receipt against the strict policy version selected from trusted -/// policy bytes. V1 keeps its original parser and behaviour; V2 is an -/// explicitly opt-in multi-runtime contract. -pub fn verify_receipt_document_for_policy( - bytes: &[u8], - policy: &VerificationPolicyDocument, - expected_commit: &str, - evaluated_at_utc: &str, -) -> Result { - match policy { - VerificationPolicyDocument::V1(policy) => { - verify_receipt_document(bytes, policy, expected_commit, evaluated_at_utc) + ccp_core::verify::VerificationPolicyDocumentError::TooLarge => { + VerificationPolicyDocumentError::TooLarge } - VerificationPolicyDocument::V1_1(_) => Err(VerificationError::TrustedPolicyPathRequired), - VerificationPolicyDocument::V2(policy) => crate::matrix::verify_matrix_receipt_document( - bytes, - policy, - expected_commit, - evaluated_at_utc, - ) - .map_err(|error| VerificationError::Matrix(error.to_string())), - } -} - -/// Verify a receipt against a policy loaded from a trusted checkout. Policy -/// version 1.1 resolves its configuration only relative to that policy file; -/// callers cannot substitute an arbitrary configuration path. -pub fn verify_receipt_document_for_policy_path( - bytes: &[u8], - policy_path: &Path, - expected_commit: &str, - evaluated_at_utc: &str, -) -> Result { - let policy = load_verification_policy_document(policy_path) - .map_err(|error| VerificationError::PolicyDocument(error.to_string()))?; - match &policy { - VerificationPolicyDocument::V1_1(policy) => { - let trusted_plan = policy - .load_trusted_plan(policy_path) - .map_err(VerificationError::TrustedPlan)?; - verify_trusted_plan_receipt_document( - bytes, - policy, - &trusted_plan, - expected_commit, - evaluated_at_utc, - ) - } - _ => verify_receipt_document_for_policy(bytes, &policy, expected_commit, evaluated_at_utc), - } -} - -/// Validate every trusted input selected by a policy path before receipt I/O. -/// This prevents a missing or malformed receipt from bypassing policy v1.1's -/// trusted-configuration requirement. -pub fn validate_verification_policy_path(policy_path: &Path) -> Result<(), VerificationError> { - let policy = load_verification_policy_document(policy_path) - .map_err(|error| VerificationError::PolicyDocument(error.to_string()))?; - if let VerificationPolicyDocument::V1_1(policy) = policy { - policy - .load_trusted_plan(policy_path) - .map_err(VerificationError::TrustedPlan)?; - } - Ok(()) -} - -fn verify_trusted_plan_receipt_document( - bytes: &[u8], - policy: &VerificationPolicyV1_1, - trusted_plan: &ExecutionPlanEnvelopeV1, - expected_commit: &str, - evaluated_at_utc: &str, -) -> Result { - let mut report = - verify_receipt_document(bytes, &policy.baseline(), expected_commit, evaluated_at_utc)?; - report.assurance_scope = "integrity_and_trusted_plan_policy".to_owned(); - if report.integrity_status != VerificationStatus::Pass { - return Ok(report); - } - if trusted_plan.plan_digest != policy.configuration_digest { - report.findings.push(finding( - "policy.trusted_config_digest", - "trusted_config", - "trusted configuration does not reconstruct the policy execution-plan digest", - )); - } - let receipt = match serde_json::from_slice::(bytes) { - Ok(receipt) => receipt, - Err(_) => { - report.findings.push(finding( - "policy.receipt_schema", - "receipt.schema_version", - "trusted-plan policy requires a strict receipt v2", - )); - finalize_trusted_plan_report(&mut report); - return Ok(report); - } - }; - compare_execution_plan( - &trusted_plan.plan, - &receipt.receipt.execution_plan, - &mut report.findings, - )?; - if receipt.receipt.source_snapshot.strategy != policy.source_snapshot_strategy { - report.findings.push(finding( - "policy.source_snapshot_strategy", - "source_snapshot.strategy", - "receipt source snapshot strategy is not accepted by trusted policy", - )); - } - let producer = ( - &receipt.receipt.producer.name, - &receipt.receipt.producer.version, - ); - if policy - .revoked_producers - .iter() - .any(|candidate| (&candidate.name, &candidate.version) == producer) - { - report.findings.push(finding( - "policy.producer_revoked", - "producer", - "receipt producer is explicitly revoked by trusted policy", - )); - } else if !policy - .supported_producers - .iter() - .any(|candidate| (&candidate.name, &candidate.version) == producer) - { - report.findings.push(finding( - "policy.producer_unsupported", - "producer", - "receipt producer is not supported by trusted policy", - )); - } - finalize_trusted_plan_report(&mut report); - Ok(report) -} - -fn finalize_trusted_plan_report(report: &mut VerificationReportV1) { - if !report.findings.is_empty() { - report.policy_status = VerificationStatus::Fail; - } - report.decision = if report.integrity_status == VerificationStatus::Pass - && report.policy_status == VerificationStatus::Pass - { - VerificationDecision::Pass - } else { - VerificationDecision::Fail - }; -} - -pub fn receipt_input_failure_report( - expected_commit: &str, - evaluated_at_utc: &str, -) -> Result { - validate_commit(expected_commit)?; - parse_utc_seconds(evaluated_at_utc).ok_or(VerificationError::InvalidEvaluationTime)?; - Ok(VerificationReportV1 { - schema_version: VERIFICATION_REPORT_SCHEMA_VERSION.to_owned(), - assurance_scope: "integrity_and_repository_policy_only".to_owned(), - evaluated_at_utc: evaluated_at_utc.to_owned(), - expected_commit: expected_commit.to_owned(), - receipt_id: None, - integrity_status: VerificationStatus::Fail, - policy_status: VerificationStatus::NotRun, - decision: VerificationDecision::Fail, - findings: vec![finding( - "receipt.read_failed", - "receipt", - "receipt could not be read from the caller-supplied path", - )], - }) -} - -struct ReceiptPolicyView<'a> { - repository: &'a RepositoryEvidence, - run: &'a RunEvidence, - platform: &'a PlatformEvidence, - configuration_digest: &'a str, - checks: &'a [CheckEvidence], - overall_status: EvidenceStatus, -} - -// Direct ownership keeps both versioned receipt validation paths uniform. -#[allow(clippy::large_enum_variant)] -enum VerifiedReceipt { - V1(ReceiptEnvelopeV1), - V2(ReceiptEnvelopeV2), -} - -impl VerifiedReceipt { - fn verify(&self) -> Result<(), ReceiptError> { - match self { - Self::V1(value) => value.verify(), - Self::V2(value) => value.verify(), + ccp_core::verify::VerificationPolicyDocumentError::InvalidUtf8 => { + VerificationPolicyDocumentError::InvalidUtf8 } - } - - fn receipt_id(&self) -> &str { - match self { - Self::V1(value) => &value.receipt_id, - Self::V2(value) => &value.receipt_id, + ccp_core::verify::VerificationPolicyDocumentError::Parse(error) => { + VerificationPolicyDocumentError::Parse(error) } - } - - fn view(&self) -> ReceiptPolicyView<'_> { - match self { - Self::V1(value) => ReceiptPolicyView { - repository: &value.receipt.repository, - run: &value.receipt.run, - platform: &value.receipt.platform, - configuration_digest: &value.receipt.configuration_digest, - checks: &value.receipt.checks, - overall_status: value.receipt.overall_status, - }, - Self::V2(value) => ReceiptPolicyView { - repository: &value.receipt.repository, - run: &value.receipt.run, - platform: &value.receipt.platform, - configuration_digest: &value.receipt.configuration_digest, - checks: &value.receipt.checks, - overall_status: value.receipt.overall_status, - }, + ccp_core::verify::VerificationPolicyDocumentError::UnsupportedSchemaVersion => { + VerificationPolicyDocumentError::UnsupportedSchemaVersion } - } -} - -fn evaluate_policy( - receipt: ReceiptPolicyView<'_>, - policy: &VerificationPolicyV1, - expected_commit: &str, - evaluated_at: i64, - findings: &mut Vec, -) { - check_equal( - &receipt.repository.repository, - &policy.project, - "policy.repository", - "repository.repository", - "receipt project does not match repository policy", - findings, - ); - check_equal( - &receipt.repository.commit_sha, - expected_commit, - "policy.commit", - "repository.commit_sha", - "receipt commit does not match the externally supplied commit", - findings, - ); - if receipt.repository.dirty { - findings.push(finding( - "policy.dirty", - "repository.dirty", - "repository policy requires a clean checkout", - )); - } - check_equal( - receipt.configuration_digest, - &policy.configuration_digest, - "policy.configuration", - "configuration_digest", - "receipt configuration digest does not match repository policy", - findings, - ); - check_equal( - &receipt.platform.image_reference, - &policy.image_reference, - "policy.image", - "platform.image_reference", - "receipt image does not match repository policy", - findings, - ); - if !policy.platforms.iter().any(|accepted| { - accepted.host_os == receipt.platform.host_os - && accepted.host_arch == receipt.platform.host_arch - && accepted.runtime_kind == receipt.platform.runtime_kind - }) { - findings.push(finding( - "policy.platform", - "platform", - "receipt platform tuple is not accepted by repository policy", - )); - } - if receipt.overall_status != EvidenceStatus::Pass { - findings.push(finding( - "policy.overall_status", - "overall_status", - "repository policy requires an overall PASS receipt", - )); - } - - let actual_required: BTreeSet<_> = receipt - .checks - .iter() - .filter(|check| check.required) - .map(|check| check.id.as_str()) - .collect(); - let expected_required: BTreeSet<_> = - policy.required_checks.iter().map(String::as_str).collect(); - if actual_required != expected_required { - findings.push(finding( - "policy.required_check_set", - "checks", - "required check set does not exactly match repository policy", - )); - } - if receipt.checks.iter().any(|check| { - expected_required.contains(check.id.as_str()) && check.status != EvidenceStatus::Pass - }) { - findings.push(finding( - "policy.required_check_result", - "checks.status", - "one or more policy-required checks did not PASS", - )); - } - - if let Some(finished_at) = parse_utc_seconds(&receipt.run.finished_at_utc) { - if finished_at > evaluated_at { - findings.push(finding( - "policy.future_receipt", - "run.finished_at_utc", - "receipt completion time is later than verification time", - )); - } else if evaluated_at - finished_at > policy.max_age_seconds as i64 { - findings.push(finding( - "policy.stale_receipt", - "run.finished_at_utc", - "receipt exceeds repository freshness policy", - )); + ccp_core::verify::VerificationPolicyDocumentError::V1(error) => { + VerificationPolicyDocumentError::V1(error) } - } else { - findings.push(finding( - "policy.invalid_time", - "run.finished_at_utc", - "receipt completion time cannot be evaluated", - )); - } -} - -fn check_equal( - actual: &str, - expected: &str, - code: &str, - field: &str, - message: &str, - findings: &mut Vec, -) { - if actual != expected { - findings.push(finding(code, field, message)); - } -} - -const MAX_PLAN_FINDINGS: usize = 128; - -fn compare_execution_plan( - trusted: &ExecutionPlanV1, - receipt: &ExecutionPlanV1, - findings: &mut Vec, -) -> Result<(), VerificationError> { - let trusted = serde_json::to_value(trusted) - .map_err(ReceiptError::Serialization) - .map_err(VerificationError::Receipt)?; - let receipt = serde_json::to_value(receipt) - .map_err(ReceiptError::Serialization) - .map_err(VerificationError::Receipt)?; - compare_plan_value(&trusted, &receipt, "", findings); - Ok(()) -} - -fn compare_plan_value( - trusted: &serde_json::Value, - receipt: &serde_json::Value, - path: &str, - findings: &mut Vec, -) { - if findings.len() >= MAX_PLAN_FINDINGS || trusted == receipt { - return; - } - match (trusted, receipt) { - (serde_json::Value::Object(trusted), serde_json::Value::Object(receipt)) => { - let keys: BTreeSet<_> = trusted.keys().chain(receipt.keys()).collect(); - for key in keys { - let child_path = format!("{path}/{}", escape_json_pointer(key)); - match (trusted.get(key), receipt.get(key)) { - (Some(trusted), Some(receipt)) => { - compare_plan_value(trusted, receipt, &child_path, findings) - } - _ => push_plan_finding(&child_path, findings), - } - if findings.len() >= MAX_PLAN_FINDINGS { - break; - } - } + ccp_core::verify::VerificationPolicyDocumentError::V1_1(error) => { + VerificationPolicyDocumentError::V1_1(error) } - (serde_json::Value::Array(trusted), serde_json::Value::Array(receipt)) => { - let longest = trusted.len().max(receipt.len()); - for index in 0..longest { - let child_path = format!("{path}/{index}"); - match (trusted.get(index), receipt.get(index)) { - (Some(trusted), Some(receipt)) => { - compare_plan_value(trusted, receipt, &child_path, findings) - } - _ => push_plan_finding(&child_path, findings), - } - if findings.len() >= MAX_PLAN_FINDINGS { - break; - } - } + ccp_core::verify::VerificationPolicyDocumentError::V2(error) => { + VerificationPolicyDocumentError::V2(crate::matrix::MatrixError::from(error)) } - _ => push_plan_finding(path, findings), - } -} - -fn escape_json_pointer(value: &str) -> String { - value.replace('~', "~0").replace('/', "~1") -} - -fn push_plan_finding(path: &str, findings: &mut Vec) { - if findings.len() < MAX_PLAN_FINDINGS { - findings.push(finding( - "policy.execution_plan.field_mismatch", - &format!("execution_plan{path}"), - "receipt execution-plan field does not match trusted configuration", - )); - } -} - -pub(crate) fn finding(code: &str, field: &str, message: &str) -> VerificationFindingV1 { - VerificationFindingV1 { - code: code.to_owned(), - field: field.to_owned(), - message: message.to_owned(), - } -} - -pub fn verification_policy_schema_json() -> Result { - serde_json::to_string_pretty(&schema_for!(VerificationPolicyV1)) - .map_err(ReceiptError::Serialization) - .map_err(VerificationError::Receipt) -} - -pub fn trusted_plan_policy_schema_json() -> Result { - serde_json::to_string_pretty(&schema_for!(VerificationPolicyV1_1)) - .map_err(ReceiptError::Serialization) - .map_err(VerificationError::Receipt) -} - -pub fn verification_report_schema_json() -> Result { - serde_json::to_string_pretty(&schema_for!(VerificationReportV1)) - .map_err(ReceiptError::Serialization) - .map_err(VerificationError::Receipt) -} - -pub fn system_evaluated_at_utc() -> Result { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| VerificationError::InvalidEvaluationTime)? - .as_secs(); - format_unix_utc(seconds).ok_or(VerificationError::InvalidEvaluationTime) -} - -fn validate_project(value: &str) -> Result<(), PolicyError> { - if value.len() > 255 || value.chars().any(char::is_control) { - return Err(PolicyError::InvalidField("project")); - } - let mut parts = value.split('/'); - let owner = parts.next().unwrap_or_default(); - let repository = parts.next().unwrap_or_default(); - if owner.is_empty() - || repository.is_empty() - || parts.next().is_some() - || !valid_name(owner) - || !valid_name(repository) - { - return Err(PolicyError::InvalidField("project")); - } - Ok(()) -} - -fn validate_name(field: &'static str, value: &str) -> Result<(), PolicyError> { - if value.len() > 128 || !valid_name(value) { - return Err(PolicyError::InvalidField(field)); - } - Ok(()) -} - -fn valid_name(value: &str) -> bool { - !value.is_empty() - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) -} - -fn validate_digest(value: &str) -> Result<(), PolicyError> { - let Some(hex) = value.strip_prefix("sha256:") else { - return Err(PolicyError::InvalidField("configuration_digest")); - }; - if hex.len() != 64 - || !hex - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(PolicyError::InvalidField("configuration_digest")); - } - Ok(()) -} - -fn validate_image_reference(value: &str) -> Result<(), PolicyError> { - let Some((name, digest)) = value.rsplit_once('@') else { - return Err(PolicyError::InvalidField("image_reference")); - }; - if name.is_empty() || name.contains('@') || name.chars().any(char::is_control) { - return Err(PolicyError::InvalidField("image_reference")); - } - validate_digest(digest).map_err(|_| PolicyError::InvalidField("image_reference")) -} - -fn validate_trusted_config_path(value: &str) -> Result<(), PolicyError> { - if value.is_empty() - || value.len() > 255 - || value.starts_with('/') - || value.starts_with('~') - || value.contains('\\') - || value.contains(':') - || value - .split('/') - .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) - { - Err(PolicyError::InvalidField("trusted_config")) - } else { - Ok(()) - } -} - -fn validate_producer_contract(value: &ProducerContractV1_1) -> Result<(), PolicyError> { - validate_name("producers.name", &value.name)?; - if value.version.is_empty() - || value.version.len() > 128 - || value.version.chars().any(char::is_control) - { - Err(PolicyError::InvalidField("producers.version")) - } else { - Ok(()) - } -} - -pub(crate) fn validate_commit(value: &str) -> Result<(), VerificationError> { - if !matches!(value.len(), 40 | 64) - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(VerificationError::InvalidExpectedCommit); - } - Ok(()) -} - -pub(crate) fn parse_utc_seconds(value: &str) -> Option { - let bytes = value.as_bytes(); - if bytes.len() != 20 - || bytes[4] != b'-' - || bytes[7] != b'-' - || bytes[10] != b'T' - || bytes[13] != b':' - || bytes[16] != b':' - || bytes[19] != b'Z' - { - return None; - } - let year = decimal(&bytes[0..4])? as i64; - let month = decimal(&bytes[5..7])? as i64; - let day = decimal(&bytes[8..10])? as i64; - let hour = decimal(&bytes[11..13])? as i64; - let minute = decimal(&bytes[14..16])? as i64; - let second = decimal(&bytes[17..19])? as i64; - let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - let days_in_month = match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if leap => 29, - 2 => 28, - _ => 0, - }; - if year == 0 || !(1..=days_in_month).contains(&day) || hour > 23 || minute > 59 || second > 59 { - return None; - } - let adjusted_year = year - i64::from(month <= 2); - let era = adjusted_year.div_euclid(400); - let year_of_era = adjusted_year - era * 400; - let shifted_month = month + if month > 2 { -3 } else { 9 }; - let day_of_year = (153 * shifted_month + 2) / 5 + day - 1; - let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; - let days = era * 146_097 + day_of_era - 719_468; - days.checked_mul(86_400)? - .checked_add(hour * 3_600 + minute * 60 + second) -} - -fn decimal(bytes: &[u8]) -> Option { - bytes.iter().try_fold(0_u32, |value, byte| { - byte.is_ascii_digit() - .then(|| value * 10 + u32::from(byte - b'0')) }) } - -fn format_unix_utc(seconds: u64) -> Option { - let days = i64::try_from(seconds / 86_400).ok()?; - let seconds_of_day = seconds % 86_400; - let (year, month, day) = civil_from_days(days)?; - if !(1..=9999).contains(&year) { - return None; - } - let hour = seconds_of_day / 3_600; - let minute = (seconds_of_day % 3_600) / 60; - let second = seconds_of_day % 60; - Some(format!( - "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z" - )) -} - -fn civil_from_days(days_since_epoch: i64) -> Option<(i64, u64, u64)> { - let shifted = days_since_epoch.checked_add(719_468)?; - let era = if shifted >= 0 { - shifted - } else { - shifted.checked_sub(146_096)? - } / 146_097; - let day_of_era = shifted.checked_sub(era.checked_mul(146_097)?)?; - let year_of_era = - (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; - let mut year = year_of_era.checked_add(era.checked_mul(400)?)?; - let day_of_year = - day_of_era.checked_sub(365 * year_of_era + year_of_era / 4 - year_of_era / 100)?; - let month_prime = (5 * day_of_year + 2) / 153; - let day = day_of_year - (153 * month_prime + 2) / 5 + 1; - let month = month_prime + if month_prime < 10 { 3 } else { -9 }; - year += i64::from(month <= 2); - Some((year, u64::try_from(month).ok()?, u64::try_from(day).ok()?)) -} - -#[derive(Debug)] -pub enum PolicyError { - Io(io::Error), - TooLarge, - InvalidUtf8, - Parse(toml::de::Error), - UnsupportedSchemaVersion, - InvalidField(&'static str), - DuplicateValue(&'static str), -} - -#[derive(Debug)] -pub enum TrustedPlanError { - PolicyPath, - Io(io::Error), - UnsafeConfigurationPath, - Config(ConfigError), -} - -impl fmt::Display for TrustedPlanError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::PolicyPath => formatter.write_str("trusted policy path has no parent directory"), - Self::Io(_) => formatter.write_str("cannot read trusted configuration"), - Self::UnsafeConfigurationPath => { - formatter.write_str("trusted configuration path is not a regular local file") - } - Self::Config(error) => write!(formatter, "trusted configuration is invalid: {error}"), - } - } -} - -impl std::error::Error for TrustedPlanError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(error) => Some(error), - Self::Config(error) => Some(error), - _ => None, - } - } -} - -impl fmt::Display for PolicyError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Io(_) => formatter.write_str("cannot read verification policy"), - Self::TooLarge => formatter.write_str("verification policy exceeds size limit"), - Self::InvalidUtf8 => formatter.write_str("verification policy is not UTF-8"), - Self::Parse(_) => formatter.write_str("verification policy is not valid strict TOML"), - Self::UnsupportedSchemaVersion => { - formatter.write_str("verification policy schema version is unsupported") - } - Self::InvalidField(field) => write!(formatter, "invalid policy field: {field}"), - Self::DuplicateValue(field) => write!(formatter, "duplicate policy value: {field}"), - } - } -} - -impl std::error::Error for PolicyError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(error) => Some(error), - Self::Parse(error) => Some(error), - _ => None, - } - } -} - -#[derive(Debug)] -pub enum VerificationError { - Policy(PolicyError), - PolicyDocument(String), - TrustedPlan(TrustedPlanError), - TrustedPolicyPathRequired, - InvalidExpectedCommit, - InvalidEvaluationTime, - Receipt(ReceiptError), - Matrix(String), -} - -impl fmt::Display for VerificationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Policy(error) => write!(formatter, "{error}"), - Self::PolicyDocument(error) => write!(formatter, "{error}"), - Self::TrustedPlan(error) => write!(formatter, "{error}"), - Self::TrustedPolicyPathRequired => formatter - .write_str("trusted-plan policy verification requires the policy file path"), - Self::InvalidExpectedCommit => { - formatter.write_str("expected commit must be lowercase Git SHA-1 or SHA-256") - } - Self::InvalidEvaluationTime => { - formatter.write_str("verification time is not representable as strict UTC") - } - Self::Receipt(_) => formatter.write_str("verification report serialization failed"), - Self::Matrix(error) => write!(formatter, "matrix verification failed: {error}"), - } - } -} - -impl std::error::Error for VerificationError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Policy(error) => Some(error), - Self::TrustedPlan(error) => Some(error), - Self::Receipt(error) => Some(error), - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use super::{format_unix_utc, parse_utc_seconds}; - - #[test] - fn strict_utc_conversion_round_trips_known_instants() { - for (seconds, timestamp) in [ - (0, "1970-01-01T00:00:00Z"), - (951_827_696, "2000-02-29T12:34:56Z"), - (1_785_542_400, "2026-08-01T00:00:00Z"), - ] { - assert_eq!(format_unix_utc(seconds).as_deref(), Some(timestamp)); - assert_eq!(parse_utc_seconds(timestamp), Some(seconds as i64)); - } - assert_eq!(parse_utc_seconds("2026-02-29T00:00:00Z"), None); - assert_eq!(parse_utc_seconds("2026-08-01T00:00:00+00:00"), None); - } -} diff --git a/tests/fixtures/cargo-metadata-verifier-forbidden-v1.json b/tests/fixtures/cargo-metadata-verifier-forbidden-v1.json new file mode 100644 index 0000000..b6df929 --- /dev/null +++ b/tests/fixtures/cargo-metadata-verifier-forbidden-v1.json @@ -0,0 +1,10 @@ +{ + "packages": [ + {"id":"path+file:///workspace#ccp-verifier@0.1.0","name":"ccp-verifier","source":null}, + {"id":"registry+https://github.com/rust-lang/crates.io-index#ctrlc@3.5.2","name":"ctrlc","source":"registry+https://github.com/rust-lang/crates.io-index"} + ], + "resolve":{"nodes":[ + {"id":"path+file:///workspace#ccp-verifier@0.1.0","deps":[{"name":"ctrlc","pkg":"registry+https://github.com/rust-lang/crates.io-index#ctrlc@3.5.2","dep_kinds":[{"kind":null}]}]}, + {"id":"registry+https://github.com/rust-lang/crates.io-index#ctrlc@3.5.2","deps":[]} + ]} +} diff --git a/tests/fixtures/cargo-metadata-verifier-pass-v1.json b/tests/fixtures/cargo-metadata-verifier-pass-v1.json new file mode 100644 index 0000000..0f65fab --- /dev/null +++ b/tests/fixtures/cargo-metadata-verifier-pass-v1.json @@ -0,0 +1,15 @@ +{ + "packages": [ + {"id":"path+file:///workspace#ccp-verifier@0.1.0","name":"ccp-verifier","source":null}, + {"id":"path+file:///workspace#ccp-core@0.1.0","name":"ccp-core","source":null}, + {"id":"registry+https://github.com/rust-lang/crates.io-index#clap@4.6.6","name":"clap","source":"registry+https://github.com/rust-lang/crates.io-index"}, + {"id":"registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0","name":"serde","source":"registry+https://github.com/rust-lang/crates.io-index"}, + {"id":"path+file:///workspace#commit-ci-preflight@0.1.0","name":"commit-ci-preflight","source":null} + ], + "resolve":{"nodes":[ + {"id":"path+file:///workspace#ccp-verifier@0.1.0","deps":[{"name":"ccp_core","pkg":"path+file:///workspace#ccp-core@0.1.0","dep_kinds":[{"kind":null}]},{"name":"clap","pkg":"registry+https://github.com/rust-lang/crates.io-index#clap@4.6.6","dep_kinds":[{"kind":null}]},{"name":"root","pkg":"path+file:///workspace#commit-ci-preflight@0.1.0","dep_kinds":[{"kind":"dev"}]}]}, + {"id":"path+file:///workspace#ccp-core@0.1.0","deps":[{"name":"serde","pkg":"registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0","dep_kinds":[{"kind":null,"target":"cfg(any(target_os = \"linux\", target_os = \"macos\"))"}]}]}, + {"id":"registry+https://github.com/rust-lang/crates.io-index#clap@4.6.6","deps":[]}, + {"id":"registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0","deps":[]} + ]} +} diff --git a/tests/fixtures/m2-compatibility-envelope-v1.json b/tests/fixtures/m2-compatibility-envelope-v1.json new file mode 100644 index 0000000..a5ae95a --- /dev/null +++ b/tests/fixtures/m2-compatibility-envelope-v1.json @@ -0,0 +1,22 @@ +{ + "schema_version": "1.0", + "source_head": "6ff736b1e2a1dfde8778330efdd4b82c845d45e7", + "files": { + "tests/fixtures/receipt-v1-pass.json": "f6824b28ded398d26620f5cc53c8e891997f909ff83b6ff7e17eef2ca821b017", + "tests/fixtures/receipt-v2-pass.json": "35d15b81c281f13fe07a49cf20a1aa942ab07f3e8b2ddbd400d9747e37eba8fe", + "tests/fixtures/policy-v1.toml": "21d0f45765ba8bc4ea1f443ad0fa76093cf5edeb7b98f181e50e34052ca8bac3", + "tests/fixtures/policy-v1_1-trusted-plan.toml": "f9c3293ba7ddeb100a85680d1b931b2246621fcc62314dffd06c4a4e16d8ca79", + "tests/fixtures/policy-v1_1-trusted-plan-altered.toml": "39798543fa24b779b415472188d588f3901f142b216424af4f340275bd4e36d3", + "tests/fixtures/policy-v1_1-missing-config.toml": "f30f7caea1cd37bd3bcd206abfbc70b8041df2ff7a0b437d17d01f7cfd9fffa1", + "tests/fixtures/policy-v2-legacy-compatible.toml": "846a94bd6b298ca1a35a4c0f39bed31ae08d1b94e448fa82fc058ea16550afec", + "tests/fixtures/plan-v2-current-default.stdout.json": "6c86ecad1b7c213945656282aa1f2844909302a13e1a585c3a2c93be6081458a", + "tests/fixtures/historical-verifier-044697.provenance.json": "cd75dea3593307e6dbf72abe5773e3031056a3c61033caeba6c7bea2a4a79838", + "tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json": "dc6f4f409fe31003cf1f50fe895ca7a114a614ca4eb3c7f526da02981abd9e1f", + "schema/receipt-v1.schema.json": "684b81b685a6013181e3fdd4bcf29573c01959da5e510bc158d5f94b8aeb8abc", + "schema/receipt-v2.schema.json": "bf315a2609366adc94520f5e8966d3048529e86759e66701a544be10eca9adbf", + "schema/policy-v1.schema.json": "e76d92f8a328c714ace3aab8c7c88e1ef58c4092ca48961fd2e57ca347b53d79", + "schema/policy-v1_1.schema.json": "b06ee6753cab21db0a4cca5bfbdbd90ec2cdeccd2a7d2eaaed68b826d0944b00", + "schema/policy-v2.schema.json": "2cc87c4bbfdbe787144a4ff96ff2a20dd004525f704f9f1cbd80117225426316", + "schema/verification-report-v1.schema.json": "67ec9524749d471d6a23e22ba0f9d52377273448f5c25ef318fbbd1a9f8f9b23" + } +} diff --git a/tests/fixtures/verifier-dependency-policy-v1.json b/tests/fixtures/verifier-dependency-policy-v1.json new file mode 100644 index 0000000..be8dbcc --- /dev/null +++ b/tests/fixtures/verifier-dependency-policy-v1.json @@ -0,0 +1,7 @@ +{ + "verifier": "ccp-verifier", + "allowed_direct": ["ccp_core", "clap"], + "forbidden_names": ["commit-ci-preflight", "ctrlc", "fs2", "process-wrap", "nix", "saphyr", "saphyr-parser"], + "forbidden_ids": ["path+file:///workspace#commit-ci-preflight"], + "allowed_source_prefixes": ["registry+https://github.com/rust-lang/crates.io-index", "path+"] +} diff --git a/tests/independent_verifier_contract.rs b/tests/independent_verifier_contract.rs new file mode 100644 index 0000000..9942e5b --- /dev/null +++ b/tests/independent_verifier_contract.rs @@ -0,0 +1,715 @@ +use serde::{ + Deserializer, + de::{self, MapAccess, Visitor}, +}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::{ + collections::{BTreeSet, HashSet}, + fs, + path::{Path, PathBuf}, +}; + +fn root_to_core( + value: commit_ci_preflight::receipt::ReceiptEnvelopeV2, +) -> ccp_core::receipt::ReceiptEnvelopeV2 { + value +} +fn core_to_root( + value: ccp_core::config::ExecutionPlanV1, +) -> commit_ci_preflight::config::ExecutionPlanV1 { + value +} +fn root_error_to_core( + value: commit_ci_preflight::receipt::ReceiptError, +) -> ccp_core::errors::ReceiptError { + value +} +fn core_runtime_to_root( + value: ccp_core::runtime_evidence::RuntimeCapabilityEvidenceV1, +) -> commit_ci_preflight::runtime::RuntimeCapabilityEvidenceV1 { + value +} + +fn root_model_to_core( + value: commit_ci_preflight::verify::VerificationReportV1, +) -> ccp_core::verification_model::VerificationReportV1 { + value +} +fn core_model_to_root( + value: ccp_core::verification_model::AcceptedPlatformV1, +) -> commit_ci_preflight::verify::AcceptedPlatformV1 { + value +} +fn core_status_to_root( + value: ccp_core::verification_model::VerificationStatus, +) -> commit_ci_preflight::verify::VerificationStatus { + value +} +fn core_decision_to_root( + value: ccp_core::verification_model::VerificationDecision, +) -> commit_ci_preflight::verify::VerificationDecision { + value +} +fn core_finding_to_root( + value: ccp_core::verification_model::VerificationFindingV1, +) -> commit_ci_preflight::verify::VerificationFindingV1 { + value +} +fn root_policy_error_to_core( + value: commit_ci_preflight::verify::PolicyError, +) -> ccp_core::errors::PolicyError { + value +} +fn root_trusted_error_to_core( + value: commit_ci_preflight::verify::TrustedPlanError, +) -> ccp_core::errors::TrustedPlanError { + value +} +fn root_verification_error_to_core( + value: commit_ci_preflight::verify::VerificationError, +) -> ccp_core::errors::VerificationError { + value +} + +#[test] +fn protocol_types_are_nominally_identical_across_root_and_core_paths() { + let _: fn( + commit_ci_preflight::receipt::ReceiptEnvelopeV2, + ) -> ccp_core::receipt::ReceiptEnvelopeV2 = root_to_core; + let _: fn(ccp_core::config::ExecutionPlanV1) -> commit_ci_preflight::config::ExecutionPlanV1 = + core_to_root; + let _: fn(commit_ci_preflight::receipt::ReceiptError) -> ccp_core::errors::ReceiptError = + root_error_to_core; + let _: fn( + ccp_core::runtime_evidence::RuntimeCapabilityEvidenceV1, + ) -> commit_ci_preflight::runtime::RuntimeCapabilityEvidenceV1 = core_runtime_to_root; + let _: fn( + commit_ci_preflight::verify::VerificationReportV1, + ) -> ccp_core::verification_model::VerificationReportV1 = root_model_to_core; + let _: fn( + ccp_core::verification_model::AcceptedPlatformV1, + ) -> commit_ci_preflight::verify::AcceptedPlatformV1 = core_model_to_root; + let _: fn( + ccp_core::verification_model::VerificationStatus, + ) -> commit_ci_preflight::verify::VerificationStatus = core_status_to_root; + let _: fn( + ccp_core::verification_model::VerificationDecision, + ) -> commit_ci_preflight::verify::VerificationDecision = core_decision_to_root; + let _: fn( + ccp_core::verification_model::VerificationFindingV1, + ) -> commit_ci_preflight::verify::VerificationFindingV1 = core_finding_to_root; + let _: fn(commit_ci_preflight::verify::PolicyError) -> ccp_core::errors::PolicyError = + root_policy_error_to_core; + let _: fn(commit_ci_preflight::verify::TrustedPlanError) -> ccp_core::errors::TrustedPlanError = + root_trusted_error_to_core; + let _: fn( + commit_ci_preflight::verify::VerificationError, + ) -> ccp_core::errors::VerificationError = root_verification_error_to_core; +} + +#[test] +fn matrix_config_and_plan_paths_are_nominally_identical() { + fn same(_: Option, _: Option) {} + same( + None::, + None::, + ); + same( + None::, + None::, + ); + same( + None::, + None::, + ); + same( + None::, + None::, + ); + same( + None::, + None::, + ); + same( + None::, + None::, + ); + same( + None::, + None::, + ); + same( + None::, + None::, + ); +} + +#[test] +fn matrix_receipt_policy_and_required_check_paths_are_nominally_identical() { + fn same(_: Option, _: Option) {} + same( + None::, + None::, + ); + same( + None::, + None::, + ); + same( + None::, + None::, + ); + same( + None::, + None::, + ); + same( + None::, + None::, + ); + same( + None::, + None::, + ); + let source = + fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/matrix.rs")).unwrap(); + for token in [ + "pub struct MatrixReceiptEnvelopeV2", + "pub struct MatrixReceiptV2", + "pub struct MatrixRuntimeReceiptV2", + "pub struct MatrixVerificationPolicyV2", + "pub struct MatrixRequiredCheckV2", + "pub struct MatrixRuntimePolicyV2", + ] { + assert!( + !source.contains(token), + "duplicate definition remains: {token}" + ); + } +} + +#[test] +fn root_verify_is_a_compatibility_facade_for_moved_models_and_errors() { + let source = + fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/verify.rs")).unwrap(); + for token in [ + "pub enum VerificationStatus", + "pub enum VerificationDecision", + "pub struct VerificationReportV1", + "pub enum PolicyError", + "pub enum TrustedPlanError", + "pub enum VerificationError", + ] { + assert!( + !source.contains(token), + "duplicate definition remains: {token}" + ); + } +} + +#[test] +fn core_protocol_module_dag_has_no_back_edges_or_duplicate_evidence_status() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let canonical = fs::read_to_string(root.join("crates/ccp-core/src/canonical.rs")).unwrap(); + let errors = fs::read_to_string(root.join("crates/ccp-core/src/errors.rs")).unwrap(); + let config = fs::read_to_string(root.join("crates/ccp-core/src/config.rs")).unwrap(); + let receipt = fs::read_to_string(root.join("crates/ccp-core/src/receipt.rs")).unwrap(); + assert!(!canonical.contains("crate::receipt")); + assert!(!errors.contains("crate::receipt")); + assert!(!config.contains("crate::receipt")); + assert!(!receipt.contains("pub enum EvidenceStatus")); + assert!(errors.contains("pub enum EvidenceStatus")); +} + +const MANIFEST: &str = include_str!("fixtures/m2-compatibility-envelope-v1.json"); + +fn reject_duplicate_keys(input: &str) -> Result<(), serde_json::Error> { + struct V; + struct S; + impl<'de> de::DeserializeSeed<'de> for S { + type Value = (); + fn deserialize>(self, d: D) -> Result<(), D::Error> { + d.deserialize_any(V) + } + } + impl<'de> Visitor<'de> for V { + type Value = (); + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("object") + } + fn visit_map>(self, mut m: A) -> Result<(), A::Error> { + let mut seen = HashSet::new(); + while let Some(k) = m.next_key::()? { + if !seen.insert(k.clone()) { + return Err(de::Error::custom(format!("duplicate key: {k}"))); + } + m.next_value_seed(S)?; + } + Ok(()) + } + fn visit_seq>(self, mut s: A) -> Result<(), A::Error> { + while s.next_element_seed(S)?.is_some() {} + Ok(()) + } + fn visit_str(self, _: &str) -> Result<(), E> { + Ok(()) + } + fn visit_string(self, _: String) -> Result<(), E> { + Ok(()) + } + fn visit_bool(self, _: bool) -> Result<(), E> { + Ok(()) + } + fn visit_i64(self, _: i64) -> Result<(), E> { + Ok(()) + } + fn visit_u64(self, _: u64) -> Result<(), E> { + Ok(()) + } + fn visit_f64(self, _: f64) -> Result<(), E> { + Ok(()) + } + fn visit_unit(self) -> Result<(), E> { + Ok(()) + } + } + let mut d = serde_json::Deserializer::from_str(input); + d.deserialize_map(V) +} + +#[test] +fn m2_compatibility_envelope_hashes_are_frozen() { + reject_duplicate_keys(MANIFEST).unwrap(); + let value: serde_json::Value = serde_json::from_str(MANIFEST).unwrap(); + let files = value["files"].as_object().unwrap(); + assert_eq!(value["schema_version"], "1.0"); + assert_eq!( + value["source_head"], + "6ff736b1e2a1dfde8778330efdd4b82c845d45e7" + ); + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut seen = HashSet::new(); + for (name, expected) in files { + assert!(Path::new(name).is_relative() && !name.contains("..")); + assert!( + expected.as_str().unwrap().len() == 64 + && expected + .as_str() + .unwrap() + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + ); + assert!(seen.insert(name)); + let bytes = fs::read(root.join(name)).unwrap(); + let actual = Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + assert_eq!(actual, expected.as_str().unwrap(), "{name}"); + } +} + +#[test] +fn duplicate_manifest_keys_are_rejected_by_contract_fixture() { + let duplicate = r#"{"schema_version":"1.0","source_head":"x","files":{"a":"1","a":"2"}}"#; + assert!(reject_duplicate_keys(duplicate).is_err()); +} + +#[test] +fn workspace_members_are_explicit_and_verifier_dependencies_are_bounded() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let manifest = fs::read_to_string(root.join("Cargo.toml")).unwrap(); + let value: toml::Value = toml::from_str(&manifest).unwrap(); + let workspace = value.get("workspace").unwrap().as_table().unwrap(); + assert_eq!(workspace["members"].as_array().unwrap().len(), 3); + assert_eq!( + workspace["members"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect::>(), + vec![".", "crates/ccp-core", "crates/ccp-verifier"] + ); + assert_eq!(workspace["default-members"].as_array().unwrap().len(), 1); + assert_eq!( + workspace["default-members"].as_array().unwrap()[0] + .as_str() + .unwrap(), + "." + ); + assert_eq!(workspace["resolver"].as_str(), Some("3")); + let root_dev = value["dev-dependencies"].as_table().unwrap(); + assert_eq!( + root_dev["ccp-core"]["path"].as_str(), + Some("crates/ccp-core") + ); + assert_package_contract( + &root.join("crates/ccp-core/Cargo.toml"), + "ccp-core", + ["schemars", "serde", "serde_json", "sha2", "toml"] + .into_iter() + .collect(), + BTreeSet::new(), + None, + ); + assert_package_contract( + &root.join("crates/ccp-verifier/Cargo.toml"), + "ccp-verifier", + ["ccp-core", "clap"].into_iter().collect(), + ["serde_json"].into_iter().collect(), + Some( + ["derive", "error-context", "help", "std", "usage"] + .into_iter() + .collect(), + ), + ); + assert_no_forbidden_sources(&root.join("crates/ccp-verifier/src")); +} + +fn assert_package_contract( + path: &Path, + name: &str, + normal: BTreeSet<&str>, + dev: BTreeSet<&str>, + clap_features: Option>, +) { + let value: toml::Value = toml::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(value["package"]["name"].as_str(), Some(name)); + assert_eq!(value["package"]["edition"].as_str(), Some("2024")); + assert_eq!(value["package"]["rust-version"].as_str(), Some("1.87")); + let actual: BTreeSet<_> = value["dependencies"] + .as_table() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(actual, normal); + let actual_dev: BTreeSet<_> = value + .get("dev-dependencies") + .and_then(toml::Value::as_table) + .map(|t| t.keys().map(String::as_str).collect()) + .unwrap_or_default(); + assert_eq!(actual_dev, dev); + if let Some(expected) = clap_features { + let clap = &value["dependencies"]["clap"]; + assert_eq!(clap["default-features"].as_bool(), Some(false)); + let features: BTreeSet<_> = clap["features"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(features, expected); + } +} + +fn assert_no_forbidden_sources(root: &Path) { + let mut files = Vec::new(); + collect_rs(root, &mut files); + assert!(!files.is_empty()); + for path in files { + let source = fs::read_to_string(path).unwrap().to_ascii_lowercase(); + for forbidden in [ + "commit_ci_preflight", + "docker", + "cache", + "admission", + "resource", + "benchmark", + "github", + "serde_json", + ] { + assert!( + !source.contains(forbidden), + "forbidden source import/token: {forbidden}" + ); + } + } +} + +fn collect_rs(root: &Path, files: &mut Vec) { + for entry in fs::read_dir(root).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + collect_rs(&path, files); + } else if path.extension().and_then(|x| x.to_str()) == Some("rs") { + files.push(path); + } + } +} +#[test] +fn core_and_root_verification_share_report_identity() { + use ccp_core::verification_model::VerificationReportV1 as CoreReport; + use commit_ci_preflight::verify::VerificationReportV1 as RootReport; + fn assert_same_type() {} + assert_same_type::(); + assert_same_type::(); + let _: fn( + &[u8], + &commit_ci_preflight::verify::VerificationPolicyV1, + &str, + &str, + ) -> Result = commit_ci_preflight::verify::verify_receipt_document; + let _: fn( + &[u8], + &commit_ci_preflight::verify::VerificationPolicyV1, + &str, + &str, + ) -> Result = ccp_core::verify::verify_receipt_document; + + let root_error = commit_ci_preflight::verify::VerificationError::InvalidExpectedCommit; + let core_error = ccp_core::errors::VerificationError::InvalidExpectedCommit; + assert_eq!(root_error.to_string(), core_error.to_string()); + assert_eq!( + root_error.to_string(), + "expected commit must be lowercase Git SHA-1 or SHA-256" + ); + + let report = ccp_core::verification_model::VerificationReportV1 { + schema_version: "1.0".to_owned(), + assurance_scope: "test".to_owned(), + evaluated_at_utc: "2026-08-29T00:00:00Z".to_owned(), + expected_commit: "0".repeat(40), + receipt_id: None, + integrity_status: ccp_core::verification_model::VerificationStatus::Fail, + policy_status: ccp_core::verification_model::VerificationStatus::NotRun, + decision: ccp_core::verification_model::VerificationDecision::Fail, + findings: Vec::new(), + }; + assert_eq!(report.exit_code(), 3); +} + +#[test] +fn policy_document_error_source_and_display_are_compatible() { + use std::error::Error; + + let root_io = commit_ci_preflight::verify::VerificationPolicyDocumentError::Io( + std::io::Error::other("synthetic"), + ); + assert!(root_io.source().is_none()); + assert_eq!(root_io.to_string(), "cannot read verification policy"); + + let core_io = + ccp_core::verify::VerificationPolicyDocumentError::Io(std::io::Error::other("synthetic")); + assert!(core_io.source().is_none()); + + let root_v2 = commit_ci_preflight::verify::VerificationPolicyDocumentError::V2( + commit_ci_preflight::matrix::MatrixError::InvalidReceipt, + ); + assert!(root_v2.source().is_none()); + let core_v2 = ccp_core::verify::VerificationPolicyDocumentError::V2( + ccp_core::matrix::MatrixContractError::InvalidReceipt, + ); + assert!(core_v2.source().is_none()); +} + +#[derive(serde::Deserialize)] +struct DependencyPolicy { + verifier: String, + allowed_direct: BTreeSet, + forbidden_names: BTreeSet, + forbidden_ids: BTreeSet, + allowed_source_prefixes: Vec, +} + +#[derive(serde::Deserialize)] +struct Metadata { + packages: Vec, + resolve: Resolve, +} +#[derive(serde::Deserialize)] +struct Package { + id: String, + name: String, + source: Option, +} +#[derive(serde::Deserialize)] +struct Resolve { + nodes: Vec, +} +#[derive(serde::Deserialize)] +struct Node { + id: String, + deps: Vec, +} +#[derive(serde::Deserialize)] +struct Dep { + name: String, + pkg: String, + dep_kinds: Vec, +} +#[derive(serde::Deserialize)] +struct DepKind { + kind: Option, +} + +fn check_dependency_graph(metadata: &str, policy: &DependencyPolicy) -> Result<(), String> { + let graph: Metadata = + serde_json::from_str(metadata).map_err(|e| format!("invalid metadata: {e}"))?; + let packages: std::collections::HashMap<_, _> = + graph.packages.iter().map(|p| (p.id.as_str(), p)).collect(); + if packages.len() != graph.packages.len() { + return Err("duplicate package ID".into()); + } + let matches: Vec<_> = graph + .packages + .iter() + .filter(|p| p.name == policy.verifier) + .collect(); + if matches.is_empty() { + return Err("missing verifier package".into()); + } + if matches.len() > 1 { + return Err("duplicate verifier package".into()); + } + let package = matches[0]; + let nodes: std::collections::HashMap<_, _> = graph + .resolve + .nodes + .iter() + .map(|n| (n.id.as_str(), n)) + .collect(); + if nodes.len() != graph.resolve.nodes.len() { + return Err("duplicate node ID".into()); + } + let mut seen = BTreeSet::new(); + let mut direct = BTreeSet::new(); + let mut stack = vec![package.id.as_str()]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + let pkg = packages + .get(id) + .ok_or_else(|| format!("missing package for {id}"))?; + if policy.forbidden_names.contains(&pkg.name) { + return Err(format!("forbidden dependency {}", pkg.name)); + } + if policy.forbidden_ids.contains(id) { + return Err(format!("forbidden dependency id {id}")); + } + let node = nodes + .get(id) + .ok_or_else(|| format!("missing node for {id}"))?; + for dep in &node.deps { + if dep.dep_kinds.is_empty() { + return Err(format!("empty dep_kinds for {}", dep.name)); + } + if !dep.dep_kinds.iter().any(|k| k.kind.is_none()) { + continue; + } + let target = packages + .get(dep.pkg.as_str()) + .ok_or_else(|| format!("missing package for {}", dep.pkg))?; + if id == package.id { + if !direct.insert(dep.name.clone()) { + return Err(format!("duplicate direct dependency {}", dep.name)); + } + if !policy.allowed_direct.contains(&dep.name) { + return Err(format!("unexpected direct dependency {}", dep.name)); + } + } + if let Some(source) = &target.source { + if !policy + .allowed_source_prefixes + .iter() + .any(|p| source.starts_with(p)) + { + return Err(format!("invalid source {source}")); + } + } else if !target.id.starts_with("path+") { + return Err(format!("missing source for {}", target.name)); + } + stack.push(target.id.as_str()); + } + } + if direct != policy.allowed_direct { + return Err("direct dependency set mismatch".into()); + } + Ok(()) +} + +fn assert_dependency_graph(metadata: &str, policy: &DependencyPolicy) { + check_dependency_graph(metadata, policy).unwrap(); +} + +#[test] +fn verifier_dependency_policy_rejects_forbidden_and_accepts_normal_graph() { + let policy: DependencyPolicy = + serde_json::from_str(include_str!("fixtures/verifier-dependency-policy-v1.json")).unwrap(); + assert_dependency_graph( + include_str!("fixtures/cargo-metadata-verifier-pass-v1.json"), + &policy, + ); + let forbidden = check_dependency_graph( + include_str!("fixtures/cargo-metadata-verifier-forbidden-v1.json"), + &DependencyPolicy { + allowed_direct: ["ctrlc".to_owned()].into_iter().collect(), + ..policy + }, + ) + .unwrap_err(); + assert!(forbidden.contains("forbidden dependency ctrlc")); +} + +#[test] +fn verifier_dependency_policy_controls_fail_independently() { + let policy: DependencyPolicy = + serde_json::from_str(include_str!("fixtures/verifier-dependency-policy-v1.json")).unwrap(); + let forbidden_id = DependencyPolicy { + allowed_direct: ["ctrlc".to_owned()].into_iter().collect(), + forbidden_names: BTreeSet::new(), + forbidden_ids: [ + "registry+https://github.com/rust-lang/crates.io-index#ctrlc@3.5.2".to_owned(), + ] + .into_iter() + .collect(), + ..policy + }; + let id_message = check_dependency_graph( + include_str!("fixtures/cargo-metadata-verifier-forbidden-v1.json"), + &forbidden_id, + ) + .unwrap_err(); + assert!(id_message.contains("forbidden dependency id")); + + let invalid_source = DependencyPolicy { + allowed_source_prefixes: vec!["path+".to_owned()], + ..serde_json::from_str(include_str!("fixtures/verifier-dependency-policy-v1.json")).unwrap() + }; + let source_message = check_dependency_graph( + include_str!("fixtures/cargo-metadata-verifier-pass-v1.json"), + &invalid_source, + ) + .unwrap_err(); + assert!(source_message.contains("invalid source")); + + let unexpected_direct = DependencyPolicy { + allowed_direct: ["ccp-core".to_owned()].into_iter().collect(), + ..serde_json::from_str(include_str!("fixtures/verifier-dependency-policy-v1.json")).unwrap() + }; + let direct_message = check_dependency_graph( + include_str!("fixtures/cargo-metadata-verifier-pass-v1.json"), + &unexpected_direct, + ) + .unwrap_err(); + assert!(direct_message.contains("unexpected direct dependency")); +} + +#[test] +fn verifier_dependency_policy_checks_live_locked_metadata() { + let policy: DependencyPolicy = + serde_json::from_str(include_str!("fixtures/verifier-dependency-policy-v1.json")).unwrap(); + let output = std::process::Command::new(env!("CARGO")) + .args(["metadata", "--locked", "--format-version", "1"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "cargo metadata failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_dependency_graph(std::str::from_utf8(&output.stdout).unwrap(), &policy); +} diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index dc02410..cd6c8cb 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -3,6 +3,8 @@ // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. +use ccp_core::errors::VerificationError; +use ccp_core::matrix::MatrixContractError; use commit_ci_preflight::config::{ ArtifactKind, NormalizedArtifactContract, NormalizedFixedEnvironment, NormalizedRuntimeInternalEnvironment, RuntimePullPolicy, RuntimeSwapMode, @@ -159,6 +161,91 @@ fn platforms() -> Vec { }] } +fn core_policy() -> ccp_core::matrix::MatrixVerificationPolicyV2 { + ccp_core::matrix::MatrixVerificationPolicyV2 { + schema_version: MATRIX_POLICY_SCHEMA_VERSION.to_owned(), + project: "example/project".to_owned(), + configuration_digest: DIGEST.to_owned(), + required_checks: vec![ + ccp_core::matrix::MatrixRequiredCheckV2 { + id: "compat-py311".to_owned(), + runtime_id: "python311".to_owned(), + }, + ccp_core::matrix::MatrixRequiredCheckV2 { + id: "repository-check".to_owned(), + runtime_id: "python312".to_owned(), + }, + ], + max_age_seconds: 300, + runtimes: vec![ + ccp_core::matrix::MatrixRuntimePolicyV2 { + id: "python311".to_owned(), + configuration_digest: DIGEST.to_owned(), + image_reference: IMAGE_311.to_owned(), + platforms: platforms(), + }, + ccp_core::matrix::MatrixRuntimePolicyV2 { + id: "python312".to_owned(), + configuration_digest: DIGEST.to_owned(), + image_reference: IMAGE_312.to_owned(), + platforms: platforms(), + }, + ], + } +} + +#[test] +fn root_and_core_reject_invalid_policy() { + let invalid = LEGACY_COMPATIBLE_POLICY.replace("@sha256:", ""); + assert!(MatrixVerificationPolicyV2::parse(&invalid).is_err()); + assert!(ccp_core::matrix::MatrixVerificationPolicyV2::parse(&invalid).is_err()); +} + +#[test] +fn root_and_core_matrix_reports_are_identical() { + let unexpected_digest = + "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + let unexpected_image = format!("example.invalid/unexpected@{unexpected_digest}"); + let mut envelope = receipt(); + let mut first = envelope.receipt.runtime_receipts[0].receipt.receipt.clone(); + first.platform.image_reference = unexpected_image; + first.platform.image_digest = unexpected_digest.to_owned(); + envelope.receipt.runtime_receipts[0].receipt = + ReceiptEnvelopeV1::seal(first).expect("reseal inner receipt"); + let envelope = MatrixReceiptEnvelopeV2::seal(envelope.receipt).expect("reseal matrix receipt"); + let bytes = envelope.canonical_bytes().expect("receipt bytes"); + let root = verify_matrix_receipt_document(&bytes, &policy(), COMMIT, "2026-08-16T10:01:00Z") + .expect("root report"); + let core = ccp_core::matrix::verify_matrix_receipt_document( + &bytes, + &core_policy(), + COMMIT, + "2026-08-16T10:01:00Z", + ) + .expect("core report"); + assert_eq!( + serde_json::to_vec(&root).unwrap(), + serde_json::to_vec(&core).unwrap() + ); +} + +#[test] +fn core_verification_error_adapter_preserves_variant() { + let core = MatrixContractError::Verification(VerificationError::InvalidExpectedCommit); + let root: MatrixError = core.into(); + match root { + MatrixError::Verification(error) => { + assert!(matches!(error, VerificationError::InvalidExpectedCommit)); + assert_eq!( + error.to_string(), + "expected commit must be lowercase Git SHA-1 or SHA-256" + ); + assert!(std::error::Error::source(&error).is_none()); + } + other => panic!("unexpected adapter result: {other}"), + } +} + #[test] fn v2_policy_binds_each_required_check_to_its_named_runtime() { let envelope = receipt(); @@ -304,7 +391,7 @@ fn legacy_profile_reproduces_historical_plan() { } assert!(matches!( envelope.runtime_configuration_digest("unknown"), - Err(MatrixError::UnknownRuntime(id)) if id == "unknown" + Err(MatrixContractError::UnknownRuntime(id)) if id == "unknown" )); } @@ -459,7 +546,7 @@ fn legacy_receipt_provenance_is_uniform() { mixed.runtime_receipts[1].receipt = ReceiptEnvelopeV1::seal(inner).expect("reseal mixed inner"); assert!(matches!( MatrixReceiptEnvelopeV2::seal(mixed), - Err(MatrixError::InvalidReceipt) + Err(MatrixContractError::InvalidReceipt) )); } @@ -499,15 +586,15 @@ fn legacy_profile_accessors_reject_mutated_public_plan() { assert!(matches!( envelope.plan_digest(), - Err(MatrixError::PlanDigestMismatch) + Err(MatrixContractError::PlanDigestMismatch) )); assert!(matches!( envelope.runtime_configuration_digest("python311"), - Err(MatrixError::PlanDigestMismatch) + Err(MatrixContractError::PlanDigestMismatch) )); assert!(matches!( envelope.canonical_bytes(), - Err(MatrixError::PlanDigestMismatch) + Err(MatrixContractError::PlanDigestMismatch) )); } @@ -525,7 +612,7 @@ fn cli_boundary_can_validate_profile_binding_before_mutation() { envelope.plan.project = "example/mutated-before-run".to_owned(); assert!(matches!( envelope.validate_profile_binding(), - Err(MatrixError::PlanDigestMismatch) + Err(MatrixContractError::PlanDigestMismatch) )); } @@ -580,7 +667,8 @@ fn legacy_runtime_envelopes_recheck_projection() { assert!( matches!( envelope.runtime_envelopes(), - Err(MatrixError::PlanDigestMismatch | MatrixError::LegacyPlanNotRepresentable(_)) + Err(MatrixContractError::PlanDigestMismatch + | MatrixContractError::LegacyPlanNotRepresentable(_)) ), "runtime conversion must reject mutated {field}" ); @@ -645,7 +733,7 @@ fn legacy_profile_rejects_each_non_representable_current_field() { mutate(&mut envelope); assert!(matches!( envelope.plan_digest(), - Err(MatrixError::LegacyPlanNotRepresentable(actual)) if actual == field + Err(MatrixContractError::LegacyPlanNotRepresentable(actual)) if actual == field )); } } @@ -673,7 +761,7 @@ fn production_sources_do_not_embed_adopter_expected_digests() { "eff5b7d55bb0220890dbfb050bb68a1e0fbba8f9a30a69e2f66085354fcc8562", "7afb3e6dd435d9d5a317e4d9d85e80527431044312bbe299e9a70b6ba9e994c8", ]; - for path in ["src/matrix.rs", "src/matrix_legacy.rs"] { + for path in ["src/matrix.rs", "crates/ccp-core/src/matrix_legacy.rs"] { let source = std::fs::read_to_string(format!("{}/{}", env!("CARGO_MANIFEST_DIR"), path)) .expect("production source"); for digest in prohibited { diff --git a/tests/public_api_compat_contract.rs b/tests/public_api_compat_contract.rs new file mode 100644 index 0000000..d136563 --- /dev/null +++ b/tests/public_api_compat_contract.rs @@ -0,0 +1,654 @@ +use commit_ci_preflight::{ + config::{ConfigError, ExecutionPlanV1, NormalizedRuntime}, + matrix::{MatrixError, MatrixPlanV2}, + receipt::{ReceiptEnvelopeV1, ReceiptError}, + verify::{ + PolicyError, TrustedPlanError, VerificationError, VerificationPolicyDocument, + VerificationPolicyV1, VerificationPolicyV1_1, VerificationReportV1, + }, +}; +use std::error::Error; +use std::path::PathBuf; + +fn assert_error(error: E, expected: &str, has_source: bool) { + assert_eq!(error.to_string(), expected); + assert_eq!(error.source().is_some(), has_source); +} + +// Exhaustive witnesses intentionally keep future enum additions compile-visible. +fn receipt_witness(e: ReceiptError) { + match e { + ReceiptError::Serialization(_) + | ReceiptError::UnsupportedSchemaVersion(_) + | ReceiptError::UnsupportedSourceSnapshotSchemaVersion(_) + | ReceiptError::EmptyField(_) + | ReceiptError::ControlCharacter(_) + | ReceiptError::InvalidCommitSha(_) + | ReceiptError::InvalidRepositoryIdentity + | ReceiptError::InvalidSha256(_) + | ReceiptError::InvalidTimestamp(_) + | ReceiptError::InvalidRunWindow + | ReceiptError::ImageDigestMismatch + | ReceiptError::UnsafePath(_) + | ReceiptError::NoChecks + | ReceiptError::NoRequiredChecks + | ReceiptError::DuplicateCheckId(_) + | ReceiptError::InvalidCommand(_) + | ReceiptError::InvalidCheckResult(_) + | ReceiptError::MissingIncompleteReason(_) + | ReceiptError::UnexpectedIncompleteReason(_) + | ReceiptError::InvalidSourceSnapshotEntryCount(_) + | ReceiptError::OverallStatusMismatch { .. } + | ReceiptError::DigestMismatch { .. } + | ReceiptError::ExecutionPlanDigestMismatch { .. } + | ReceiptError::ExecutionPlanCheckMismatch(_) + | ReceiptError::DuplicateArtifactEvidence(_) + | ReceiptError::ArtifactManifestMismatch(_) + | ReceiptError::MissingRuntimeCapabilityEvidence + | ReceiptError::UnexpectedRuntimeCapabilityEvidence + | ReceiptError::InvalidRuntimeCapabilityEvidence(_) + | ReceiptError::RuntimeCapabilityEvidenceMismatch => {} + } +} +fn policy_witness(e: PolicyError) { + match e { + PolicyError::Io(_) + | PolicyError::TooLarge + | PolicyError::InvalidUtf8 + | PolicyError::Parse(_) + | PolicyError::UnsupportedSchemaVersion + | PolicyError::InvalidField(_) + | PolicyError::DuplicateValue(_) => {} + } +} +fn verification_witness(e: VerificationError) { + match e { + VerificationError::Policy(_) + | VerificationError::PolicyDocument(_) + | VerificationError::TrustedPlan(_) + | VerificationError::TrustedPolicyPathRequired + | VerificationError::InvalidExpectedCommit + | VerificationError::InvalidEvaluationTime + | VerificationError::Receipt(_) + | VerificationError::Matrix(_) => {} + } +} +fn config_witness(e: ConfigError) { + match e { + ConfigError::Io { .. } + | ConfigError::Parse(_) + | ConfigError::Receipt(_) + | ConfigError::UnsupportedSchemaVersion(_) + | ConfigError::ConfigTooLarge { .. } + | ConfigError::InvalidField(_) + | ConfigError::OutOfRange { .. } + | ConfigError::TooManyItems { .. } + | ConfigError::NoChecks + | ConfigError::NoRequiredChecks + | ConfigError::DuplicateId { .. } + | ConfigError::DuplicateValue(_) + | ConfigError::DuplicateArtifact(_) + | ConfigError::PathOverlap { .. } + | ConfigError::SelfDependency(_) + | ConfigError::UnknownDependency { .. } + | ConfigError::UnknownEnvironmentCache { .. } + | ConfigError::MissingStoragePolicy + | ConfigError::MissingRuntimeCapabilityPolicy + | ConfigError::DependencyCycle(_) + | ConfigError::PlanDigestMismatch => {} + } +} +fn matrix_witness(e: MatrixError) { + match e { + MatrixError::Io(_) + | MatrixError::Parse(_) + | MatrixError::Json(_) + | MatrixError::Config(_) + | MatrixError::Receipt(_) + | MatrixError::Policy(_) + | MatrixError::Verification(_) + | MatrixError::Runtime(_) + | MatrixError::Run(_) + | MatrixError::UnsupportedSchemaVersion(_) + | MatrixError::ConfigTooLarge + | MatrixError::InvalidField(_) + | MatrixError::DuplicateValue(_) + | MatrixError::UnknownRuntime(_) + | MatrixError::RuntimeWithoutRequiredCheck(_) + | MatrixError::CrossRuntimeDependency { .. } + | MatrixError::LegacyPlanNotRepresentable(_) + | MatrixError::PlanDigestMismatch + | MatrixError::ReceiptIdMismatch + | MatrixError::InvalidReceipt + | MatrixError::InvalidEvaluationTime => {} + } +} +fn trusted_witness(e: TrustedPlanError) { + match e { + TrustedPlanError::PolicyPath + | TrustedPlanError::Io(_) + | TrustedPlanError::UnsafeConfigurationPath + | TrustedPlanError::Config(_) => {} + } +} + +fn assert_type_contracts() { + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); +} + +fn assert_all_error_families_are_constructible_and_stable() { + let json = serde_json::from_str::("[").unwrap_err(); + let toml = toml::from_str::("[").unwrap_err(); + let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing"); + let receipt_cases = [ + ( + ReceiptError::Serialization(json), + "receipt serialization failed: EOF while parsing a list at line 1 column 1", + ), + ( + ReceiptError::UnsupportedSchemaVersion("9".into()), + "unsupported receipt schema version: 9", + ), + ( + ReceiptError::UnsupportedSourceSnapshotSchemaVersion("9".into()), + "unsupported source snapshot schema version: 9", + ), + (ReceiptError::EmptyField("x"), "receipt field is empty: x"), + ( + ReceiptError::ControlCharacter("x"), + "receipt field contains a control character: x", + ), + ( + ReceiptError::InvalidCommitSha("x".into()), + "invalid commit SHA: x", + ), + ( + ReceiptError::InvalidRepositoryIdentity, + "invalid repository identity", + ), + (ReceiptError::InvalidSha256("x"), "invalid SHA-256 value: x"), + ( + ReceiptError::InvalidTimestamp("x"), + "invalid UTC timestamp: x", + ), + ( + ReceiptError::InvalidRunWindow, + "receipt run finishes before it starts", + ), + ( + ReceiptError::ImageDigestMismatch, + "image reference is not pinned to image digest", + ), + (ReceiptError::UnsafePath("x"), "unsafe receipt path: x"), + (ReceiptError::NoChecks, "receipt contains no checks"), + ( + ReceiptError::NoRequiredChecks, + "receipt contains no required checks", + ), + ( + ReceiptError::DuplicateCheckId("x".into()), + "duplicate check ID: x", + ), + ( + ReceiptError::InvalidCommand("x".into()), + "invalid command for check: x", + ), + ( + ReceiptError::InvalidCheckResult("x".into()), + "inconsistent result for check: x", + ), + ( + ReceiptError::MissingIncompleteReason("x"), + "missing incomplete reason: x", + ), + ( + ReceiptError::UnexpectedIncompleteReason("x"), + "unexpected incomplete reason: x", + ), + ( + ReceiptError::InvalidSourceSnapshotEntryCount(2), + "invalid source snapshot entry count: 2", + ), + ( + ReceiptError::OverallStatusMismatch { + expected: commit_ci_preflight::receipt::EvidenceStatus::Pass, + actual: commit_ci_preflight::receipt::EvidenceStatus::Fail, + }, + "overall status mismatch: expected Pass, found Fail", + ), + ( + ReceiptError::DigestMismatch { + expected: "a".into(), + actual: "b".into(), + }, + "receipt digest mismatch: expected a, found b", + ), + ( + ReceiptError::ExecutionPlanDigestMismatch { + expected: "a".into(), + actual: "b".into(), + }, + "receipt execution plan digest mismatch: expected a, found b", + ), + ( + ReceiptError::ExecutionPlanCheckMismatch("x".into()), + "receipt evidence does not match execution plan check: x", + ), + ( + ReceiptError::DuplicateArtifactEvidence("x".into()), + "duplicate artifact evidence path: x", + ), + ( + ReceiptError::ArtifactManifestMismatch("x".into()), + "artifact evidence does not match the execution plan: x", + ), + ( + ReceiptError::MissingRuntimeCapabilityEvidence, + "schema 1.3 receipt lacks runtime capability evidence", + ), + ( + ReceiptError::UnexpectedRuntimeCapabilityEvidence, + "historical receipt unexpectedly contains runtime capability evidence", + ), + ( + ReceiptError::InvalidRuntimeCapabilityEvidence("x"), + "runtime capability evidence is invalid: x", + ), + ( + ReceiptError::RuntimeCapabilityEvidenceMismatch, + "runtime capability evidence does not match the execution plan", + ), + ]; + for (error, expected) in receipt_cases { + let source = matches!(&error, ReceiptError::Serialization(_)); + assert_error(error, expected, source); + } + let config_cases = vec![ + ( + ConfigError::Io { + path: PathBuf::from("x"), + source: std::io::Error::other("e"), + }, + "cannot read configuration x: e", + true, + ), + ( + ConfigError::Parse(toml::from_str::("[").unwrap_err()), + "invalid TOML configuration: TOML parse error at line 1, column 2\n |\n1 | [\n | ^\nunquoted keys cannot be empty, expected letters, numbers, `-`, `_`\n", + true, + ), + ( + ConfigError::Receipt(ReceiptError::NoChecks), + "cannot canonicalize execution plan: receipt contains no checks", + true, + ), + ( + ConfigError::UnsupportedSchemaVersion("x".into()), + "unsupported configuration schema version: x", + false, + ), + ( + ConfigError::ConfigTooLarge { + actual: 2, + maximum: 1, + }, + "configuration is 2 bytes; maximum is 1", + false, + ), + ( + ConfigError::InvalidField("x"), + "invalid configuration field: x", + false, + ), + ( + ConfigError::OutOfRange { + field: "x", + minimum: 1, + maximum: 2, + actual: 3, + }, + "configuration field x is 3; expected 1..=2", + false, + ), + ( + ConfigError::TooManyItems { + field: "x", + actual: 3, + maximum: 2, + }, + "configuration has 3 x; maximum is 2", + false, + ), + ( + ConfigError::NoChecks, + "configuration contains no checks", + false, + ), + ( + ConfigError::NoRequiredChecks, + "configuration contains no required checks", + false, + ), + ( + ConfigError::DuplicateId { + field: "x", + id: "y".into(), + }, + "duplicate x: y", + false, + ), + ( + ConfigError::DuplicateValue("x"), + "duplicate value in x", + false, + ), + ( + ConfigError::DuplicateArtifact("x".into()), + "duplicate artifact path: x", + false, + ), + ( + ConfigError::PathOverlap { + first: "a".into(), + second: "b".into(), + }, + "configuration paths overlap: a and b", + false, + ), + ( + ConfigError::SelfDependency("x".into()), + "check depends on itself: x", + false, + ), + ( + ConfigError::UnknownDependency { + check: "a".into(), + dependency: "b".into(), + }, + "check a depends on unknown check b", + false, + ), + ( + ConfigError::UnknownEnvironmentCache { + name: "a".into(), + cache_id: "b".into(), + }, + "runtime-internal environment a references unknown cache b", + false, + ), + ( + ConfigError::MissingStoragePolicy, + "schemas 1.2 and 1.3 require an explicit storage policy", + false, + ), + ( + ConfigError::MissingRuntimeCapabilityPolicy, + "schema 1.3 requires pull_policy = never and swap_mode = disabled", + false, + ), + ( + ConfigError::DependencyCycle(vec!["a".into(), "b".into()]), + "check dependency cycle involves: a, b", + false, + ), + ( + ConfigError::PlanDigestMismatch, + "execution plan digest mismatch", + false, + ), + ]; + for (error, expected, source) in config_cases { + assert_error(error, expected, source); + } + let policy_cases = [ + (PolicyError::Io(io), "cannot read verification policy", true), + ( + PolicyError::TooLarge, + "verification policy exceeds size limit", + false, + ), + ( + PolicyError::InvalidUtf8, + "verification policy is not UTF-8", + false, + ), + ( + PolicyError::Parse(toml), + "verification policy is not valid strict TOML", + true, + ), + ( + PolicyError::UnsupportedSchemaVersion, + "verification policy schema version is unsupported", + false, + ), + ( + PolicyError::InvalidField("x"), + "invalid policy field: x", + false, + ), + ( + PolicyError::DuplicateValue("x"), + "duplicate policy value: x", + false, + ), + ]; + for (error, expected, source) in policy_cases { + assert_error(error, expected, source); + } + let trusted_plan_cases = [ + ( + TrustedPlanError::PolicyPath, + "trusted policy path has no parent directory", + false, + ), + ( + TrustedPlanError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "missing")), + "cannot read trusted configuration", + true, + ), + ( + TrustedPlanError::UnsafeConfigurationPath, + "trusted configuration path is not a regular local file", + false, + ), + ( + TrustedPlanError::Config(ConfigError::NoChecks), + "trusted configuration is invalid: configuration contains no checks", + true, + ), + ]; + for (error, expected, source) in trusted_plan_cases { + assert_error(error, expected, source); + } + let verification_cases = [ + VerificationError::Policy(PolicyError::TooLarge), + VerificationError::PolicyDocument("x".into()), + VerificationError::TrustedPlan(TrustedPlanError::PolicyPath), + VerificationError::TrustedPolicyPathRequired, + VerificationError::InvalidExpectedCommit, + VerificationError::InvalidEvaluationTime, + VerificationError::Receipt(ReceiptError::NoChecks), + VerificationError::Matrix("x".into()), + ]; + let expected = [ + "verification policy exceeds size limit", + "x", + "trusted policy path has no parent directory", + "trusted-plan policy verification requires the policy file path", + "expected commit must be lowercase Git SHA-1 or SHA-256", + "verification time is not representable as strict UTC", + "verification report serialization failed", + "matrix verification failed: x", + ]; + for (error, expected) in verification_cases.into_iter().zip(expected) { + assert_error( + error, + expected, + matches!( + expected, + "verification policy exceeds size limit" + | "trusted policy path has no parent directory" + | "verification report serialization failed" + ), + ); + } + // MatrixError has no source() implementation; every variant is publicly constructible. + let matrix_toml = toml::from_str::("[").unwrap_err(); + let matrix_toml_display = format!("matrix configuration parse failed: {matrix_toml}"); + let matrix_json = serde_json::from_str::("[").unwrap_err(); + let matrix_json_display = format!("matrix JSON serialization failed: {matrix_json}"); + let matrix_cases = + vec![ + ( + MatrixError::Io(std::io::Error::other("x")), + "matrix I/O failed: x".to_owned(), + ), + (MatrixError::Parse(matrix_toml), matrix_toml_display), + (MatrixError::Json(matrix_json), matrix_json_display), + ( + MatrixError::Config(ConfigError::NoChecks), + "matrix configuration invalid: configuration contains no checks".to_owned(), + ), + ( + MatrixError::Receipt(ReceiptError::NoChecks), + "matrix receipt invalid: receipt contains no checks".to_owned(), + ), + (MatrixError::Policy("x".into()), "matrix policy invalid: x".to_owned()), + ( + MatrixError::Verification(VerificationError::InvalidExpectedCommit), + "matrix verification invalid: expected commit must be lowercase Git SHA-1 or SHA-256" + .to_owned(), + ), + ( + MatrixError::Runtime(commit_ci_preflight::runtime::RuntimeError::Unavailable), + "matrix runtime invalid: Docker-compatible runtime is unavailable".to_owned(), + ), + ( + MatrixError::Run(commit_ci_preflight::run::RunError::InvalidCommit), + "matrix run failed: Git returned an invalid commit identifier".to_owned(), + ), + ( + MatrixError::UnsupportedSchemaVersion("x".into()), + "unsupported matrix schema version: x".to_owned(), + ), + ( + MatrixError::ConfigTooLarge, + "matrix configuration exceeds the bounded input size".to_owned(), + ), + (MatrixError::InvalidField("x"), "invalid matrix field: x".to_owned()), + (MatrixError::DuplicateValue("x"), "duplicate matrix value: x".to_owned()), + ( + MatrixError::UnknownRuntime("x".into()), + "matrix check references unknown runtime: x".to_owned(), + ), + ( + MatrixError::RuntimeWithoutRequiredCheck("x".into()), + "matrix runtime has no required check: x".to_owned(), + ), + ( + MatrixError::CrossRuntimeDependency { + check: "a".into(), + dependency: "b".into(), + }, + "matrix cross-runtime dependency is unsupported: a -> b".to_owned(), + ), + ( + MatrixError::LegacyPlanNotRepresentable("x"), + "matrix legacy plan cannot represent: x".to_owned(), + ), + (MatrixError::PlanDigestMismatch, "matrix plan digest mismatch".to_owned()), + ( + MatrixError::ReceiptIdMismatch, + "matrix receipt identifier mismatch".to_owned(), + ), + ( + MatrixError::InvalidReceipt, + "matrix receipt violates semantic invariants".to_owned(), + ), + ( + MatrixError::InvalidEvaluationTime, + "matrix evaluation time is invalid".to_owned(), + ), + ]; + for (error, expected) in matrix_cases { + assert_error(error, &expected, false); + } + let _ = PathBuf::new(); +} + +#[test] +fn root_public_api_and_error_contract_is_compile_checked() { + let _: Option<( + ReceiptEnvelopeV1, + ExecutionPlanV1, + NormalizedRuntime, + VerificationPolicyDocument, + )> = None; + let _: Option<( + VerificationPolicyV1, + VerificationPolicyV1_1, + MatrixPlanV2, + VerificationReportV1, + )> = None; + let _: Option<(ReceiptError, ConfigError, PolicyError)> = None; + let _: Option<(TrustedPlanError, VerificationError, MatrixError)> = None; + assert_type_contracts(); + receipt_witness(ReceiptError::NoChecks); + config_witness(ConfigError::NoChecks); + policy_witness(PolicyError::TooLarge); + trusted_witness(TrustedPlanError::PolicyPath); + verification_witness(VerificationError::InvalidExpectedCommit); + matrix_witness(MatrixError::PlanDigestMismatch); + assert_all_error_families_are_constructible_and_stable(); + assert_error(ReceiptError::NoChecks, "receipt contains no checks", false); + assert_error( + PolicyError::TooLarge, + "verification policy exceeds size limit", + false, + ); + assert_error( + VerificationError::TrustedPolicyPathRequired, + "trusted-plan policy verification requires the policy file path", + false, + ); + assert_error( + ConfigError::NoChecks, + "configuration contains no checks", + false, + ); + assert_error( + MatrixError::PlanDigestMismatch, + "matrix plan digest mismatch", + false, + ); + let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing"); + assert_error(PolicyError::Io(io), "cannot read verification policy", true); + let parse = toml::from_str::("[").unwrap_err(); + assert_error( + PolicyError::Parse(parse), + "verification policy is not valid strict TOML", + true, + ); + assert_error( + VerificationError::Policy(PolicyError::TooLarge), + "verification policy exceeds size limit", + true, + ); + assert_error( + VerificationError::TrustedPlan(TrustedPlanError::PolicyPath), + "trusted policy path has no parent directory", + true, + ); + assert_error( + VerificationError::Receipt(ReceiptError::NoChecks), + "verification report serialization failed", + true, + ); +} diff --git a/tests/receipt_contract.rs b/tests/receipt_contract.rs index 341ee11..687fc9a 100644 --- a/tests/receipt_contract.rs +++ b/tests/receipt_contract.rs @@ -21,6 +21,13 @@ const PINNED_SCHEMA: &str = include_str!("../schema/receipt-v1.schema.json"); const PASS_V2_FIXTURE: &[u8] = include_bytes!("fixtures/receipt-v2-pass.json"); const PINNED_V2_SCHEMA: &str = include_str!("../schema/receipt-v2.schema.json"); +#[test] +fn receipt_contract_types_are_thread_safe() { + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::(); +} + #[test] fn pinned_pass_fixture_round_trips_byte_for_byte() { let envelope: ReceiptEnvelopeV1 = diff --git a/tests/release_hardening_contract.rs b/tests/release_hardening_contract.rs index ce69054..636536c 100644 --- a/tests/release_hardening_contract.rs +++ b/tests/release_hardening_contract.rs @@ -26,6 +26,12 @@ const THREAT_MODEL: &str = include_str!("../docs/THREAT_MODEL.md"); const BETA_SUPPORT: &str = include_str!("../docs/BETA_SUPPORT.md"); const TUTORIAL: &str = include_str!("../docs/TUTORIAL.md"); const DEMO_RECEIPT: &str = include_str!("../docs/evidence/pr10/demo-rust-receipt.json"); +const ROADMAP: &str = include_str!("../docs/PRODUCT_ROADMAP.md"); +const HARDENING: &str = include_str!("../docs/RELIABILITY_HARDENING_PLAN.md"); +const ARCHITECTURE: &str = include_str!("../docs/ARCHITECTURE.md"); +const M2_SPEC: &str = + include_str!("../docs/superpowers/specs/2026-08-29-independent-verifier-design.md"); +const M2_PLAN: &str = include_str!("../docs/superpowers/plans/2026-08-29-independent-verifier.md"); #[test] fn public_readme_is_human_first_and_truthfully_differentiated() { @@ -184,6 +190,32 @@ fn beta_documents_keep_release_and_security_boundaries_explicit() { assert!(TUTORIAL.contains("does not prove who ran the command")); } +#[test] +fn independent_verifier_documentation_keeps_m2_boundaries_truthful() { + for document in [ROADMAP, HARDENING, ARCHITECTURE] { + assert!(document.contains("ccp-core")); + assert!(!document.contains("ccp-contract")); + assert!(document.contains("ccp-verifier")); + } + assert!(ROADMAP.contains("verify-benchmark")); + assert!(ROADMAP.contains("M3")); + assert!(ROADMAP.contains("static")); + assert!(HARDENING.contains("static")); + assert!(ARCHITECTURE.contains("local source-build")); + assert!(ARCHITECTURE.contains("published binary")); +} + +#[test] +fn m2_dependency_command_uses_a_valid_cargo_tree_selector() { + let invalid_selector = "--edges normal,no-dev,no-build"; + let valid_command = "cargo tree --locked -p ccp-verifier --edges normal"; + + for document in [M2_SPEC, M2_PLAN] { + assert!(!document.contains(invalid_selector)); + assert!(document.contains(valid_command)); + } +} + #[test] fn release_metadata_generator_is_wired_into_the_local_preflight() { let config = include_str!("../.commit-ci-preflight.toml"); diff --git a/tests/verification_contract.rs b/tests/verification_contract.rs index 74aac06..f1a2379 100644 --- a/tests/verification_contract.rs +++ b/tests/verification_contract.rs @@ -27,6 +27,7 @@ use commit_ci_preflight::receipt::{ CheckEvidence, EvidenceStatus, PlatformEvidence, ProducerEvidence, ReceiptEnvelopeV1, ReceiptEnvelopeV2, ReceiptV1, RepositoryEvidence, RunEvidence, }; +use commit_ci_preflight::verify::VerificationReportV1; use commit_ci_preflight::verify::{ PolicyError, VerificationDecision, VerificationError, VerificationPolicyDocument, VerificationPolicyV1, VerificationPolicyV1_1, VerificationStatus, @@ -44,7 +45,7 @@ const EVALUATED_AT: &str = "2026-08-08T12:30:00Z"; const POLICY_SCHEMA: &str = include_str!("../schema/policy-v1.schema.json"); const TRUSTED_PLAN_POLICY_SCHEMA: &str = include_str!("../schema/policy-v1_1.schema.json"); const REPORT_SCHEMA: &str = include_str!("../schema/verification-report-v1.schema.json"); -const VERIFY_SOURCE: &str = include_str!("../src/verify.rs"); +const VERIFY_SOURCE: &str = include_str!("../crates/ccp-core/src/verify.rs"); const TRUSTED_PLAN_POLICY: &str = "tests/fixtures/policy-v1_1-trusted-plan.toml"; const ALTERED_TRUSTED_PLAN_POLICY: &str = "tests/fixtures/policy-v1_1-trusted-plan-altered.toml"; const ROOT_POLICY: &str = ".commit-ci-policy.toml"; @@ -57,6 +58,14 @@ const LEGACY_MATRIX_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; const LEGACY_MATRIX_EVALUATED_AT: &str = "2026-08-16T10:01:00Z"; static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); +#[test] +fn verification_contract_types_are_thread_safe() { + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); +} + fn policy() -> VerificationPolicyV1 { VerificationPolicyV1::parse(POLICY).expect("policy") } @@ -260,6 +269,7 @@ fn legacy_matrix_receipt_from_production_plan( incomplete_reason: None, redaction_policy_version: "1.0".to_owned(), }) + .map_err(MatrixError::from) } fn legacy_matrix_receipt() -> MatrixReceiptEnvelopeV2 { diff --git a/tests/verify_cli.rs b/tests/verify_cli.rs index 4940cd1..3a389a7 100644 --- a/tests/verify_cli.rs +++ b/tests/verify_cli.rs @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::process::Command; const RECEIPT: &str = "tests/fixtures/receipt-v1-pass.json"; @@ -23,6 +26,207 @@ const LEGACY_MATRIX_POLICY: &str = "tests/fixtures/policy-v2-legacy-compatible.t const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; const EVALUATED_AT: &str = "2026-08-08T12:30:00Z"; +struct TempReceipt(PathBuf); +impl TempReceipt { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "ccp-verifier-malformed-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .expect("create unique malformed receipt"); + file.write_all(b"{").expect("write malformed receipt"); + Self(path) + } + fn path(&self) -> &Path { + &self.0 + } +} +impl Drop for TempReceipt { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +fn build_verifier_candidate() -> PathBuf { + let output = Command::new(env!("CARGO")) + .args([ + "build", + "--locked", + "-p", + "ccp-verifier", + "--bin", + "ccp-verifier", + ]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .expect("build independent verifier candidate"); + assert!( + output.status.success(), + "ccp-verifier candidate build failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let path = + PathBuf::from(env!("CARGO_BIN_EXE_commit-ci-preflight")).with_file_name("ccp-verifier"); + assert!(path.is_file(), "Cargo did not produce {}", path.display()); + path +} + +fn verifier_parity(verifier_path: &Path, args: &[&str]) { + let root = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")) + .args(args) + .output() + .expect("root verify"); + let verifier = Command::new(verifier_path) + .args(args) + .output() + .expect("independent verifier"); + assert_eq!(root.status.code(), verifier.status.code()); + assert_eq!(root.stdout, verifier.stdout); + if root.stderr != verifier.stderr { + let root_text = String::from_utf8_lossy(&root.stderr); + let verifier_text = String::from_utf8_lossy(&verifier.stderr); + assert!(root_text.contains("Usage:") && verifier_text.contains("Usage:")); + let root_stderr = root_text.replace("commit-ci-preflight", "VERIFIER"); + let verifier_stderr = verifier_text.replace("ccp-verifier", "VERIFIER"); + assert_eq!(root_stderr, verifier_stderr); + } +} + +#[test] +fn independent_verifier_matches_root_for_representative_outcomes() { + let verifier = build_verifier_candidate(); + verifier_parity( + &verifier, + &[ + "verify", + "--receipt", + RECEIPT, + "--policy", + POLICY, + "--expected-commit", + COMMIT, + "--evaluated-at-utc", + EVALUATED_AT, + "--json", + ], + ); + verifier_parity( + &verifier, + &[ + "verify", + "--receipt", + RECEIPT, + "--policy", + POLICY, + "--expected-commit", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "--evaluated-at-utc", + EVALUATED_AT, + "--json", + ], + ); + verifier_parity( + &verifier, + &[ + "verify", + "--receipt", + "tests/fixtures/does-not-exist.json", + "--policy", + POLICY, + "--expected-commit", + COMMIT, + "--evaluated-at-utc", + EVALUATED_AT, + "--json", + ], + ); + verifier_parity( + &verifier, + &[ + "verify", + "--receipt", + RECEIPT, + "--policy", + POLICY, + "--expected-commit", + "HEAD", + "--evaluated-at-utc", + EVALUATED_AT, + ], + ); + verifier_parity( + &verifier, + &[ + "verify", + "--receipt", + RECEIPT_V2, + "--policy", + TRUSTED_PLAN_POLICY, + "--expected-commit", + COMMIT, + "--evaluated-at-utc", + EVALUATED_AT, + "--json", + ], + ); + verifier_parity( + &verifier, + &[ + "verify", + "--receipt", + "tests/fixtures/does-not-exist.json", + "--policy", + LEGACY_MATRIX_POLICY, + "--expected-commit", + COMMIT, + "--evaluated-at-utc", + EVALUATED_AT, + "--json", + ], + ); + verifier_parity( + &verifier, + &[ + "verify", + "--receipt", + "tests/fixtures/does-not-exist.json", + "--policy", + INVALID_TRUSTED_PLAN_POLICY, + "--expected-commit", + COMMIT, + "--evaluated-at-utc", + EVALUATED_AT, + "--json", + ], + ); + + let malformed = TempReceipt::new(); + let malformed_path = malformed.path().to_str().expect("temporary path UTF-8"); + verifier_parity( + &verifier, + &[ + "verify", + "--receipt", + malformed_path, + "--policy", + POLICY, + "--expected-commit", + COMMIT, + "--evaluated-at-utc", + EVALUATED_AT, + "--json", + ], + ); +} + fn verify_command(expected_commit: &str) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")); command.args([