diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index f181f48..b1ec3b0 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -13,14 +13,14 @@ jobs: name: Criterion benchmarks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Run benchmarks run: cargo bench --package dpp-benches - name: Upload Criterion reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: criterion-reports path: target/criterion/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cee872f..a07c8fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: name: Format check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt @@ -26,7 +26,7 @@ jobs: name: Clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -38,7 +38,7 @@ jobs: name: Tests (unit + integration) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Install cargo-nextest @@ -57,7 +57,7 @@ jobs: name: Security audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Install cargo-audit diff --git a/.github/workflows/wasm-build.yml b/.github/workflows/wasm-build.yml index da6c260..8c2f144 100644 --- a/.github/workflows/wasm-build.yml +++ b/.github/workflows/wasm-build.yml @@ -8,7 +8,6 @@ on: paths: - "crates/dpp-registry/**" - "crates/dpp-domain/**" - - "crates/dpp-evidence/**" - "plugins/**" env: @@ -17,10 +16,10 @@ env: jobs: wasm-build: - name: WASM build — dpp-registry + dpp-domain + dpp-evidence + name: WASM build — dpp-registry + dpp-domain runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Rust toolchain with wasm32 target uses: dtolnay/rust-toolchain@stable @@ -39,15 +38,11 @@ jobs: working-directory: crates/dpp-domain run: cargo build --target wasm32-unknown-unknown --release - - name: Build dpp-evidence (wasm32) - working-directory: crates/dpp-evidence - run: cargo build --target wasm32-unknown-unknown --release - sector-plugins: name: WASM build — sector plugins runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Rust toolchain with wasm32-wasip1 target uses: dtolnay/rust-toolchain@stable diff --git a/CHANGELOG.md b/CHANGELOG.md index 87452fd..86a77c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,24 @@ This file was started retroactively on 2026-07-03 at v0.4.0; entries for ## [Unreleased] +New **`dpp-rules::lint`** module: a non-binding plausibility lint pack — +arithmetic and physical-plausibility checks distinct from the crate's binding +regulatory rules (e.g. `ratedEnergyWh` vs. `nominalVoltageV × nominalCapacityAh`, +material-composition sums, implausible date/range values). First pack covers +battery, textile, and unsold-goods (15 lints total); each finding carries a +`LintSeverity::{Warning,Notice}` and is phrased as a question, never a +verdict. `no_std`, wasm32-safe. `dpp-domain::Passport` gains +`lint_result: Option` plus a `lint_sector_data()` dispatcher and +`LintResult::compute()` adapter mapping `SectorData` onto the pack. + +### Removed + +- **`dpp-evidence`** removed from the workspace and dissolved into + `dpp-engine` — the evidence dossier format and its verification engine are + a DB-backed engine feature, not a core standard. `AuditEntry` and its hash-chain algorithm + (`chain_hash`/`verify_audit_chain`) return to `dpp-engine`'s `dpp-types` + crate. No other published core crate depended on `dpp-evidence`. + ## [0.7.0] - 2026-07-07 New crate **`dpp-evidence`** — the evidence dossier wire format and offline diff --git a/Cargo.toml b/Cargo.toml index c31f66e..dfcdd4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ members = [ "crates/dpp-plugin-sdk", "crates/dpp-registry", "crates/dpp-calc", - "crates/dpp-evidence", "crates/dpp-tests", "benches", ] @@ -50,14 +49,13 @@ dpp-domain = { path = "crates/dpp-domain", version = "0.7.0" } dpp-plugin-traits = { path = "crates/dpp-plugin-traits", version = "0.7.0" } dpp-crypto = { path = "crates/dpp-crypto", version = "0.7.0" } dpp-digital-link = { path = "crates/dpp-digital-link", version = "0.7.0" } -dpp-evidence = { path = "crates/dpp-evidence", version = "0.7.0" } # Serialisation serde = { version = "1", features = ["derive"] } serde_json = "1" serde_with = "3" # RFC 8785 JSON Canonicalization Scheme (JCS) — the signing/content-binding contract -serde_jcs = "0.1" +serde_jcs = "0.2" # GZIP — decoding W3C Bitstring Status List `encodedList` (pure, no infra) flate2 = "1" @@ -70,14 +68,14 @@ async-trait = "0.1" # Identity & cryptography base64 = "0.22" hex = "0.4" -sha2 = "0.10" -ed25519-dalek = { version = "2", features = ["rand_core"] } +sha2 = "0.11" +ed25519-dalek = { version = "3", features = ["rand_core"] } zeroize = { version = "1", features = ["derive"] } -aes-gcm = "0.10" -rand = "0.8" +aes-gcm = "0.11" +rand = "0.10" # Schema validation -jsonschema = "0.17" +jsonschema = "0.46" semver = "1" # Telemetry @@ -86,13 +84,13 @@ tracing = "0.1" # Utilities uuid = { version = "1", features = ["v7", "serde"] } anyhow = "1" -thiserror = "1" +thiserror = "2" # KDF argon2 = "0.5" # MAC -hmac = "0.12" +hmac = "0.13" # Benchmarking criterion = { version = "0.5", features = ["html_reports"] } diff --git a/README.md b/README.md index 33bb5b4..9982788 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,6 @@ dpp-core/ dpp-rules ........... Pure no_std cross-field regulatory rules, shared by dpp-domain and plugins dpp-registry ........ EU Central Registry interface types (wasm32-safe) dpp-calc ............ EU-methodology calculators (CO2e, repairability), pure functions - dpp-evidence ........ Evidence dossier wire format (DossierV1) + offline verification engine — - deliberately free of BSL-licensed and wasm-unsafe dependencies (spec/) dpp-tests ........... Cross-crate integration tests (domain + crypto + gs1) plugins/ .............. 10 Wasm sector plugins (wasm32-wasip1, excluded from workspace) ``` @@ -95,9 +93,9 @@ When a product undergoes remanufacturing, repurposing, or preparation for reuse, - Dual-signature transfer records (JWS from both parties) - Rejection of invalid transfers (wrong operator, duplicate pending) -### Evidence Dossiers & Offline Verification +### Evidence Dossiers -`dpp-evidence` defines a self-contained, signed **evidence dossier** (`DossierV1`) — passport, both JWS proofs, the issuer's DID document, the hash-chained audit trail, and the transfer chain in one canonical JSON file — plus the verification engine that checks all of it **fully offline**: no resolver, no network, no trust in Odal. An auditor, customs officer, or skeptical buyer runs the independent checks (manifest signature, content integrity, both JWS, audit-chain linkage, transfer signatures) and gets a named verdict per check. The crate is deliberately free of BSL-licensed and wasm-unsafe dependencies. Wire format specification: [`crates/dpp-evidence/spec/dossier-v1.md`](crates/dpp-evidence/spec/dossier-v1.md). +A self-contained, signed **evidence dossier** — passport, both JWS proofs, the issuer's DID document, the hash-chained audit trail, and the transfer chain in one canonical document — and its verification engine (independent checks: manifest signature, content integrity, both JWS, audit-chain linkage, transfer signatures) are a `dpp-engine` feature: dossiers are generated and persisted by the node, and checked via its API or the `odal verify` CLI command. Core contributes the primitives this depends on — `dpp-crypto`'s Ed25519/JWS and the domain types the dossier snapshots. ### Schema Validation @@ -190,7 +188,6 @@ cargo run -p dpp-digital-link --example gs1_and_aas # Parse GS1 lin | [PLUGIN-HOST.md](docs/architecture/PLUGIN-HOST.md) | Wasm plugin sandbox design and ABI contract | | [DESIGN-PATTERNS.md](docs/architecture/DESIGN-PATTERNS.md) | Hexagonal architecture, open-core boundary patterns | | [CONFORMITY.md](docs/regulatory/CONFORMITY.md) | Regulatory alignment statement for assessment bodies | -| [dossier-v1.md](crates/dpp-evidence/spec/dossier-v1.md) | Evidence dossier wire-format specification (offline verification) | | [CONTRIBUTING.md](CONTRIBUTING.md) | Contributor guide: setup, conventions, PR workflow | | [SECURITY.md](SECURITY.md) | Vulnerability disclosure policy | | [GOVERNANCE.md](GOVERNANCE.md) | Decision-making structure and maintainer authority | diff --git a/benches/src/aas.rs b/benches/src/aas.rs index 47097fe..30da2ec 100644 --- a/benches/src/aas.rs +++ b/benches/src/aas.rs @@ -35,6 +35,7 @@ fn battery_passport() -> Passport { co2e_per_unit: Some(CarbonFootprint::from_kg(73.0)), repairability_score: None, compliance_result: None, + lint_result: None, sector_data: Some(SectorData::Battery(BatteryData { gtin: Gtin::parse(GTIN).unwrap(), battery_chemistry: BatteryChemistry::Lfp, diff --git a/benches/src/validation.rs b/benches/src/validation.rs index 14cc75a..065ef84 100644 --- a/benches/src/validation.rs +++ b/benches/src/validation.rs @@ -43,6 +43,7 @@ fn valid_battery() -> SectorData { fn valid_textile() -> SectorData { SectorData::Textile(TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![ FibreEntry { fibre: "cotton".into(), diff --git a/crates/dpp-calc/src/co2e/calculator.rs b/crates/dpp-calc/src/co2e/calculator.rs index 6311b25..b612599 100644 --- a/crates/dpp-calc/src/co2e/calculator.rs +++ b/crates/dpp-calc/src/co2e/calculator.rs @@ -18,6 +18,7 @@ //! country-specific grid factors, allocation rules) refines those factors and is //! gated on a signed data license (`real-factors` feature). +use chrono::Utc; use serde::{Deserialize, Serialize}; use super::parameters::Co2eInputs; @@ -80,6 +81,12 @@ pub struct Co2eResult { pub fn calculate(inputs: &Co2eInputs, ruleset: &dyn Co2eRuleset) -> Result { validate_inputs(inputs)?; + // A signed, dated receipt must never be computed from a ruleset that is not + // legally in force today (crate-wide invariant; see repairability::calculate). + ruleset + .effective_dates() + .ensure_active_on(ruleset.id(), Utc::now().date_naive())?; + let material_breakdown: Vec = inputs .materials .iter() @@ -94,6 +101,16 @@ pub fn calculate(inputs: &Co2eInputs, ruleset: &dyn Co2eRuleset) -> Result MaterialFootprint { MaterialFootprint { @@ -157,6 +177,68 @@ mod tests { } } + /// A CO₂e ruleset whose effective period starts in 2100 — not yet in force. + struct FutureCo2eRuleset; + static FUT_ID: OnceLock = OnceLock::new(); + static FUT_VER: OnceLock = OnceLock::new(); + static FUT_DATES: OnceLock = OnceLock::new(); + static FUT_BASIS: RegulatoryBasis = RegulatoryBasis { + regulation: "test", + article: "test", + standard: None, + technical_study: None, + source_url: None, + superseded_by: None, + }; + impl Ruleset for FutureCo2eRuleset { + fn id(&self) -> &RulesetId { + FUT_ID.get_or_init(|| RulesetId("co2e-future".into())) + } + fn version(&self) -> &RulesetVersion { + FUT_VER.get_or_init(|| RulesetVersion("1.0.0".into())) + } + fn effective_dates(&self) -> &EffectiveDateBound { + FUT_DATES.get_or_init(|| { + EffectiveDateBound::open(NaiveDate::from_ymd_opt(2100, 1, 1).unwrap()) + }) + } + fn regulatory_basis(&self) -> &RegulatoryBasis { + &FUT_BASIS + } + } + impl Co2eRuleset for FutureCo2eRuleset { + fn declared_stages(&self) -> &[LifecycleStage] { + &[] + } + } + + #[test] + fn rejects_ruleset_not_yet_in_force() { + let inputs = Co2eInputs { + materials: vec![material(1.0, 2.0)], + energy_kwh: 1.0, + grid_factor_kg_co2e_per_kwh: 0.4, + }; + assert!(matches!( + calculate(&inputs, &FutureCo2eRuleset), + Err(CalcError::RulesetNotYetEffective { .. }) + )); + } + + #[test] + fn rejects_overflow_to_non_finite() { + // Each input is finite and in range, but the product overflows f64. + let inputs = Co2eInputs { + materials: vec![material(1e200, 1e200)], + energy_kwh: 0.0, + grid_factor_kg_co2e_per_kwh: 0.0, + }; + assert!(matches!( + calculate(&inputs, &CradleToGateRuleset), + Err(CalcError::Overflow(_)) + )); + } + #[test] fn rejects_negative_energy() { let inputs = Co2eInputs { diff --git a/crates/dpp-calc/src/kernel/error.rs b/crates/dpp-calc/src/kernel/error.rs index 8a22482..138dea9 100644 --- a/crates/dpp-calc/src/kernel/error.rs +++ b/crates/dpp-calc/src/kernel/error.rs @@ -9,6 +9,16 @@ pub enum CalcError { #[error("ruleset '{id}' expired on {until}")] RulesetExpired { id: String, until: String }, + /// The ruleset's effective period has not started yet (e.g. a pending + /// delegated act using the `2100-01-01` sentinel). + #[error("ruleset '{id}' is not yet effective (in force from {from})")] + RulesetNotYetEffective { id: String, from: String }, + + /// A computation overflowed to a non-finite value despite finite, in-range + /// inputs — a legally cited figure must never silently become Infinity. + #[error("calculation overflow: {0}")] + Overflow(String), + /// The requested activity UUID is not present in the injected factor dataset. #[error("emission factor not found for activity '{0}'")] FactorNotFound(String), diff --git a/crates/dpp-calc/src/kernel/ruleset.rs b/crates/dpp-calc/src/kernel/ruleset.rs index 17ee73d..72dcf29 100644 --- a/crates/dpp-calc/src/kernel/ruleset.rs +++ b/crates/dpp-calc/src/kernel/ruleset.rs @@ -31,6 +31,32 @@ impl EffectiveDateBound { pub fn is_active_on(&self, date: NaiveDate) -> bool { date >= self.from && self.until.is_none_or(|u| date <= u) } + + /// Error if `date` falls outside the effective period, distinguishing a + /// ruleset that is **not yet effective** (before `from`) from one that has + /// **expired** (after `until`) — so a pending `2100` stub is never reported + /// as "expired". + pub fn ensure_active_on( + &self, + id: &RulesetId, + date: NaiveDate, + ) -> Result<(), crate::error::CalcError> { + if date < self.from { + return Err(crate::error::CalcError::RulesetNotYetEffective { + id: id.0.clone(), + from: self.from.to_string(), + }); + } + if let Some(until) = self.until + && date > until + { + return Err(crate::error::CalcError::RulesetExpired { + id: id.0.clone(), + until: until.to_string(), + }); + } + Ok(()) + } } /// Structured legal citation for a regulatory ruleset. diff --git a/crates/dpp-calc/src/kernel/tests.rs b/crates/dpp-calc/src/kernel/tests.rs index ec0e8d5..2db4254 100644 --- a/crates/dpp-calc/src/kernel/tests.rs +++ b/crates/dpp-calc/src/kernel/tests.rs @@ -1,6 +1,7 @@ //! Unit tests for the methodology-agnostic spine. -use super::ruleset::EffectiveDateBound; +use super::ruleset::{EffectiveDateBound, RulesetId}; +use crate::error::CalcError; use chrono::NaiveDate; fn make(from: (i32, u32, u32), until: Option<(i32, u32, u32)>) -> EffectiveDateBound { @@ -39,3 +40,26 @@ fn open_ended_always_active_after_from() { let b = make((2020, 1, 1), None); assert!(b.is_active_on(NaiveDate::from_ymd_opt(2099, 12, 31).unwrap())); } + +#[test] +fn ensure_active_on_distinguishes_not_yet_effective_from_expired() { + let id = RulesetId("test".into()); + let day = |y, m, d| NaiveDate::from_ymd_opt(y, m, d).unwrap(); + + // Before `from` → not yet effective (not "expired"). + let future = make((2100, 1, 1), None); + assert!(matches!( + future.ensure_active_on(&id, day(2026, 1, 1)), + Err(CalcError::RulesetNotYetEffective { .. }) + )); + + // After `until` → expired. + let past = make((2020, 1, 1), Some((2021, 1, 1))); + assert!(matches!( + past.ensure_active_on(&id, day(2026, 1, 1)), + Err(CalcError::RulesetExpired { .. }) + )); + + // Within the period → ok. + assert!(past.ensure_active_on(&id, day(2020, 6, 1)).is_ok()); +} diff --git a/crates/dpp-calc/src/repairability/calculator.rs b/crates/dpp-calc/src/repairability/calculator.rs index 4ab3d4d..174f5e8 100644 --- a/crates/dpp-calc/src/repairability/calculator.rs +++ b/crates/dpp-calc/src/repairability/calculator.rs @@ -89,17 +89,9 @@ pub fn calculate( validate_inputs(inputs)?; ruleset.validate_cross_fields(inputs)?; - let today = Utc::now().date_naive(); - if !ruleset.effective_dates().is_active_on(today) { - return Err(CalcError::RulesetExpired { - id: ruleset.id().0.clone(), - until: ruleset - .effective_dates() - .until - .map(|d| d.to_string()) - .unwrap_or_else(|| "open-ended".into()), - }); - } + ruleset + .effective_dates() + .ensure_active_on(ruleset.id(), Utc::now().date_naive())?; let w = ruleset.weights(); let scale = 5.0; // scale 0–2 ordinals to 0–10 diff --git a/crates/dpp-calc/src/repairability/thresholds/tests.rs b/crates/dpp-calc/src/repairability/thresholds/tests.rs index f0bc2e7..95f6c13 100644 --- a/crates/dpp-calc/src/repairability/thresholds/tests.rs +++ b/crates/dpp-calc/src/repairability/thresholds/tests.rs @@ -43,13 +43,17 @@ fn stub_rulesets_expose_consistent_metadata() { #[test] fn calculating_with_a_not_yet_in_force_ruleset_is_rejected() { - // Laptop/Displays/Washing all carry the 2100 effective-date sentinel, - // so calculate() must refuse them via the RulesetExpired guard. + // Laptop/Displays/Washing all carry the 2100 effective-date sentinel, so + // calculate() must refuse them — and as *not yet effective*, not "expired" + // (the from=2100/until=None period has not started, it has not ended). for result in [ calculate(&valid_inputs(), &LaptopRuleset), calculate(&valid_inputs(), &DisplaysRuleset), calculate(&valid_inputs(), &WashingMachineRuleset), ] { - assert!(matches!(result, Err(CalcError::RulesetExpired { .. }))); + assert!(matches!( + result, + Err(CalcError::RulesetNotYetEffective { .. }) + )); } } diff --git a/crates/dpp-crypto/src/jws/tests.rs b/crates/dpp-crypto/src/jws/tests.rs index ba2601c..1b97b06 100644 --- a/crates/dpp-crypto/src/jws/tests.rs +++ b/crates/dpp-crypto/src/jws/tests.rs @@ -120,7 +120,7 @@ fn pub_key_b64(signing_key: &SigningKey) -> String { #[test] fn valid_jws_verifies() { - let key = SigningKey::generate(&mut rand::rngs::OsRng); + let key = SigningKey::generate(&mut crate::os_rng()); let payload = json!({"id": "test", "status": "published"}); let jws = make_jws(&key, &payload); assert!(super::verifier::verify_jws(&jws, &pub_key_b64(&key)).unwrap()); @@ -128,7 +128,7 @@ fn valid_jws_verifies() { #[test] fn tampered_signature_fails() { - let key = SigningKey::generate(&mut rand::rngs::OsRng); + let key = SigningKey::generate(&mut crate::os_rng()); let payload = json!({"id": "test"}); let mut jws = make_jws(&key, &payload); let last = jws.pop().unwrap(); @@ -144,8 +144,8 @@ fn tampered_signature_fails() { #[test] fn wrong_key_fails() { - let key1 = SigningKey::generate(&mut rand::rngs::OsRng); - let key2 = SigningKey::generate(&mut rand::rngs::OsRng); + let key1 = SigningKey::generate(&mut crate::os_rng()); + let key2 = SigningKey::generate(&mut crate::os_rng()); let payload = json!({"id": "test"}); let jws = make_jws(&key1, &payload); assert!(!super::verifier::verify_jws(&jws, &pub_key_b64(&key2)).unwrap()); @@ -154,7 +154,7 @@ fn wrong_key_fails() { /// Regression (red-team ATK-4): the header `alg` is pinned to EdDSA. #[test] fn non_eddsa_alg_is_rejected() { - let key = SigningKey::generate(&mut rand::rngs::OsRng); + let key = SigningKey::generate(&mut crate::os_rng()); let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; let payload_b64 = b64.encode(serde_json::to_vec(&json!({"id": "x"})).unwrap()); for alg in ["none", "HS256", "RS256", "ES256"] { @@ -229,7 +229,7 @@ fn key_not_in_assertion_method_is_rejected() { #[test] fn extract_kid_returns_kid_when_present() { - let key = SigningKey::generate(&mut rand::rngs::OsRng); + let key = SigningKey::generate(&mut crate::os_rng()); let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; let payload = json!({"test": 1}); let payload_b64 = b64.encode(serde_json::to_vec(&payload).unwrap()); @@ -246,7 +246,7 @@ fn extract_kid_returns_kid_when_present() { #[test] fn extract_kid_returns_none_when_absent() { - let key = SigningKey::generate(&mut rand::rngs::OsRng); + let key = SigningKey::generate(&mut crate::os_rng()); let payload = json!({"test": 1}); let jws = make_jws(&key, &payload); assert_eq!(super::verifier::extract_kid_from_jws(&jws), None); @@ -254,8 +254,8 @@ fn extract_kid_returns_none_when_absent() { #[test] fn extract_key_by_fingerprint_finds_archived_key() { - let key1 = SigningKey::generate(&mut rand::rngs::OsRng); - let key2 = SigningKey::generate(&mut rand::rngs::OsRng); + let key1 = SigningKey::generate(&mut crate::os_rng()); + let key2 = SigningKey::generate(&mut crate::os_rng()); let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; let key1_x = b64.encode(key1.verifying_key().as_bytes()); diff --git a/crates/dpp-crypto/src/keystore/crypto.rs b/crates/dpp-crypto/src/keystore/crypto.rs index 8d8d6d9..c00c698 100644 --- a/crates/dpp-crypto/src/keystore/crypto.rs +++ b/crates/dpp-crypto/src/keystore/crypto.rs @@ -7,7 +7,7 @@ use aes_gcm::{Aes256Gcm, Key}; use anyhow::{Context, Result}; use argon2::Argon2; -use hmac::{Hmac, Mac}; +use hmac::{Hmac, KeyInit, Mac}; use sha2::{Digest, Sha256}; use zeroize::Zeroize; @@ -31,7 +31,7 @@ pub(crate) fn derive_aes_key_argon2(passphrase: &str, salt: &[u8]) -> Result::from_slice(&key_bytes); + let key = Key::::from(key_bytes); key_bytes.zeroize(); Ok(key) } @@ -40,7 +40,7 @@ pub(crate) fn derive_aes_key_argon2(passphrase: &str, salt: &[u8]) -> Result Key { let digest = Sha256::digest(passphrase.as_bytes()); - *Key::::from_slice(&digest) + Key::::try_from(digest.as_slice()).expect("SHA-256 digest is 32 bytes") } /// Derive a 32-byte integrity key for HMAC-SHA256 file integrity checks. @@ -136,7 +136,7 @@ fn envelope_mac( ); let canonical = serde_json::to_vec(&outer).context("Failed to serialise envelope for HMAC")?; - let mut mac = ::new_from_slice(integrity_key) + let mut mac = ::new_from_slice(integrity_key) .expect("HMAC key length is always valid"); mac.update(&canonical); Ok(mac) diff --git a/crates/dpp-crypto/src/keystore/migration.rs b/crates/dpp-crypto/src/keystore/migration.rs index b0cc50d..0e36ef1 100644 --- a/crates/dpp-crypto/src/keystore/migration.rs +++ b/crates/dpp-crypto/src/keystore/migration.rs @@ -1,9 +1,9 @@ use aes_gcm::{ Aes256Gcm, Nonce, - aead::{Aead, KeyInit, OsRng}, + aead::{Aead, KeyInit, consts::U12}, }; use anyhow::{Context, Result}; -use rand::RngCore; +use rand::Rng; use zeroize::Zeroize; use super::crypto::derive_aes_key_argon2; @@ -50,7 +50,12 @@ impl KeyStore { for (id, record) in map.iter() { // Decrypt with legacy cipher. - let nonce = Nonce::from_slice(&record.nonce); + let nonce = <&Nonce>::try_from(record.nonce.as_slice()).map_err(|_| { + anyhow::anyhow!( + "stored nonce is not 12 bytes ({} bytes) for key {id} — corrupt or legacy record", + record.nonce.len() + ) + })?; let mut raw = self .cipher .decrypt(nonce, record.encrypted_signing_key.as_ref()) @@ -60,8 +65,8 @@ impl KeyStore { // Re-encrypt with new cipher + fresh nonce. let mut nonce_bytes = [0u8; 12]; - OsRng.fill_bytes(&mut nonce_bytes); - let new_nonce = Nonce::from_slice(&nonce_bytes); + crate::os_rng().fill_bytes(&mut nonce_bytes); + let new_nonce = <&Nonce>::from(&nonce_bytes); let encrypted = new_cipher .encrypt(new_nonce, raw.as_ref()) .map_err(|_| anyhow::anyhow!("AES-GCM encrypt failed during migration"))?; diff --git a/crates/dpp-crypto/src/keystore/rotation.rs b/crates/dpp-crypto/src/keystore/rotation.rs index 0caef25..0f8dc44 100644 --- a/crates/dpp-crypto/src/keystore/rotation.rs +++ b/crates/dpp-crypto/src/keystore/rotation.rs @@ -1,9 +1,9 @@ use aes_gcm::{ Nonce, - aead::{Aead, OsRng}, + aead::{Aead, consts::U12}, }; use anyhow::Result; -use rand::RngCore; +use rand::Rng; use sha2::{Digest, Sha256}; use zeroize::Zeroize; @@ -72,13 +72,13 @@ impl KeyStore { // Prepare the new key material up front so the lock-held section is just // the in-memory swap + single persist. - let signing_key = SigningKey::generate(&mut OsRng); + let signing_key = SigningKey::generate(&mut crate::os_rng()); let verifying_key = signing_key.verifying_key(); let fingerprint = hex::encode(Sha256::digest(verifying_key.as_bytes())); let verifying_key_hex = hex::encode(verifying_key.as_bytes()); let mut nonce_bytes = [0u8; 12]; - rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); + crate::os_rng().fill_bytes(&mut nonce_bytes); + let nonce = <&Nonce>::from(&nonce_bytes); let mut raw = signing_key.to_bytes(); let encrypted = self .cipher diff --git a/crates/dpp-crypto/src/keystore/store.rs b/crates/dpp-crypto/src/keystore/store.rs index 0215d9c..c2458c5 100644 --- a/crates/dpp-crypto/src/keystore/store.rs +++ b/crates/dpp-crypto/src/keystore/store.rs @@ -6,11 +6,11 @@ use std::sync::RwLock; use aes_gcm::{ Aes256Gcm, Nonce, - aead::{Aead, KeyInit, OsRng}, + aead::{Aead, KeyInit, consts::U12}, }; use anyhow::{Context, Result}; use ed25519_dalek::SigningKey; -use rand::RngCore; +use rand::Rng; use sha2::{Digest, Sha256}; use zeroize::Zeroize; @@ -180,7 +180,7 @@ impl KeyStore { // Generate a new salt for the eventual migration. let mut salt = [0u8; ARGON2_SALT_LEN]; - OsRng.fill_bytes(&mut salt); + crate::os_rng().fill_bytes(&mut salt); // Integrity key will be derived properly after migration. let integrity_key = derive_integrity_key(passphrase, &salt)?; @@ -197,7 +197,7 @@ impl KeyStore { } else { // Brand new store — generate a fresh salt. let mut salt = [0u8; ARGON2_SALT_LEN]; - OsRng.fill_bytes(&mut salt); + crate::os_rng().fill_bytes(&mut salt); let cipher_key = derive_aes_key_argon2(passphrase, &salt)?; let cipher = Aes256Gcm::new(&cipher_key); let integrity_key = derive_integrity_key(passphrase, &salt)?; @@ -220,14 +220,14 @@ impl KeyStore { call migrate_if_needed() first" ); } - let signing_key = SigningKey::generate(&mut OsRng); + let signing_key = SigningKey::generate(&mut crate::os_rng()); let verifying_key = signing_key.verifying_key(); let fingerprint = hex::encode(Sha256::digest(verifying_key.as_bytes())); let verifying_key_hex = hex::encode(verifying_key.as_bytes()); let mut nonce_bytes = [0u8; 12]; - rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); + crate::os_rng().fill_bytes(&mut nonce_bytes); + let nonce = <&Nonce>::from(&nonce_bytes); let mut raw = signing_key.to_bytes(); let encrypted = self @@ -298,7 +298,12 @@ impl KeyStore { } fn decrypt_record(&self, record: &KeyRecord) -> Result { - let nonce = Nonce::from_slice(&record.nonce); + let nonce = <&Nonce>::try_from(record.nonce.as_slice()).map_err(|_| { + anyhow::anyhow!( + "stored nonce is not 12 bytes ({} bytes) — corrupt or legacy key record", + record.nonce.len() + ) + })?; let mut raw = self .cipher .decrypt(nonce, record.encrypted_signing_key.as_ref()) diff --git a/crates/dpp-crypto/src/keystore/tests.rs b/crates/dpp-crypto/src/keystore/tests.rs index 70cf321..aea9ad1 100644 --- a/crates/dpp-crypto/src/keystore/tests.rs +++ b/crates/dpp-crypto/src/keystore/tests.rs @@ -2,10 +2,10 @@ use std::collections::HashMap; use aes_gcm::{ Aes256Gcm, Nonce, - aead::{Aead, KeyInit, OsRng}, + aead::{Aead, KeyInit, consts::U12}, }; use ed25519_dalek::SigningKey; -use rand::RngCore; +use rand::Rng; use sha2::{Digest, Sha256}; use zeroize::Zeroize; @@ -94,13 +94,13 @@ fn legacy_sha256_store_can_be_opened_and_migrated() { let legacy_key = derive_aes_key_sha256(passphrase); let cipher = Aes256Gcm::new(&legacy_key); - let signing_key = SigningKey::generate(&mut OsRng); + let signing_key = SigningKey::generate(&mut crate::os_rng()); let verifying_key = signing_key.verifying_key(); let fingerprint = hex::encode(Sha256::digest(verifying_key.as_bytes())); let mut nonce_bytes = [0u8; 12]; - OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); + crate::os_rng().fill_bytes(&mut nonce_bytes); + let nonce = <&Nonce>::from(&nonce_bytes); let mut raw = signing_key.to_bytes(); let encrypted = cipher.encrypt(nonce, raw.as_ref()).expect("encrypt"); raw.zeroize(); @@ -133,6 +133,36 @@ fn legacy_sha256_store_can_be_opened_and_migrated() { assert!(raw_file.contains("argon2id"), "file must be migrated"); } +#[test] +fn malformed_nonce_returns_error_not_panic() { + // A legacy V1 store (raw HashMap, no HMAC) whose record carries a truncated + // nonce. Opening must succeed; loading must return an error rather than + // panicking on the 12-byte nonce check. + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "test-keystore-badnonce-{}.json", + uuid::Uuid::now_v7() + )); + + let record = KeyRecord { + encrypted_signing_key: vec![0u8; 48], + nonce: vec![0u8; 11], // truncated — not 12 bytes + fingerprint: "deadbeef".to_string(), + verifying_key_hex: format!("{:064x}", 0), + revoked: false, + algorithm: default_algorithm(), + }; + let mut map: KeyRecordMap = HashMap::new(); + map.insert("bad-key".to_string(), record); + std::fs::write(&path, serde_json::to_vec(&map).expect("serialize")).expect("write"); + + let store = KeyStore::open(&path, "any-pass").expect("open legacy store"); + assert!( + store.load_key("bad-key").is_err(), + "malformed nonce must return Err, not panic" + ); +} + #[test] fn archive_key_creates_archived_entry() { let store = temp_store(); diff --git a/crates/dpp-crypto/src/lib.rs b/crates/dpp-crypto/src/lib.rs index 85ff4ef..c038176 100644 --- a/crates/dpp-crypto/src/lib.rs +++ b/crates/dpp-crypto/src/lib.rs @@ -9,6 +9,14 @@ pub mod identity; pub mod jws; pub mod keystore; +/// Infallible OS randomness source, used for Ed25519 key generation and +/// AES-GCM nonce/salt generation. `SysRng` is fallible (`TryCryptoRng`); +/// `UnwrapErr` panics on the (practically unreachable) OS-entropy-source +/// failure instead of threading a `Result` through every call site. +pub(crate) fn os_rng() -> rand::rand_core::UnwrapErr { + rand::rand_core::UnwrapErr(rand::rngs::SysRng) +} + // ── Flat re-exports — maintain stable paths for external callers ───────────── pub use access::{ diff --git a/crates/dpp-digital-link/examples/gs1_and_aas.rs b/crates/dpp-digital-link/examples/gs1_and_aas.rs index ac8d3ca..b33c387 100644 --- a/crates/dpp-digital-link/examples/gs1_and_aas.rs +++ b/crates/dpp-digital-link/examples/gs1_and_aas.rs @@ -111,7 +111,9 @@ fn main() { co2e_per_unit: Some(CarbonFootprint::from_kg(8.2)), repairability_score: Some(RepairabilityScore::from_scalar(7.0)), compliance_result: None, + lint_result: None, sector_data: Some(SectorData::Textile(TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![ FibreEntry { fibre: "organic cotton".into(), diff --git a/crates/dpp-digital-link/src/aas/tests.rs b/crates/dpp-digital-link/src/aas/tests.rs index 54c51d9..985b8a0 100644 --- a/crates/dpp-digital-link/src/aas/tests.rs +++ b/crates/dpp-digital-link/src/aas/tests.rs @@ -30,6 +30,7 @@ fn minimal_passport(sector: Sector) -> Passport { co2e_per_unit: Some(CarbonFootprint::from_kg(12.5)), repairability_score: Some(RepairabilityScore::from_scalar(8.0)), compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Draft, qr_code_url: None, @@ -268,6 +269,7 @@ fn build_aas_with_battery_sector_data_adds_sixth_submodel() { fn build_aas_textile_has_fibre_composition_collection() { let mut passport = minimal_passport(Sector::Textile); passport.sector_data = Some(SectorData::Textile(TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![ FibreEntry { fibre: "organic cotton".into(), diff --git a/crates/dpp-digital-link/src/digital_link/error.rs b/crates/dpp-digital-link/src/digital_link/error.rs index 523fa46..2be06f5 100644 --- a/crates/dpp-digital-link/src/digital_link/error.rs +++ b/crates/dpp-digital-link/src/digital_link/error.rs @@ -28,6 +28,18 @@ pub enum DigitalLinkError { after: String, after_ord: u8, }, + #[error("URI contains more than one '/01/' GTIN (primary key) segment")] + DuplicatePrimaryKey, + #[error("URI has a trailing Application Identifier '{0}' with no value")] + TrailingUnpairedSegment(String), + #[error( + "Application Identifier '{code}' value exceeds its maximum length of {max_len} (got {actual})" + )] + ValueTooLong { + code: String, + max_len: usize, + actual: usize, + }, } impl From for DigitalLinkError { diff --git a/crates/dpp-digital-link/src/digital_link/link.rs b/crates/dpp-digital-link/src/digital_link/link.rs index f0c6fa1..f0b0906 100644 --- a/crates/dpp-digital-link/src/digital_link/link.rs +++ b/crates/dpp-digital-link/src/digital_link/link.rs @@ -83,8 +83,24 @@ impl DigitalLink { let raw_value = ai_segments[i + 1]; let value = percent_decode(raw_value); + // GS1 mandates a maximum length per AI; enforce it so an untrusted + // URI cannot smuggle an unbounded value downstream. + let value_len = value.chars().count(); + if value_len > desc.max_len { + return Err(DigitalLinkError::ValueTooLong { + code: code.to_owned(), + max_len: desc.max_len, + actual: value_len, + }); + } + match desc.role { AiRole::PrimaryKey => { + // A second '01' segment must not silently overwrite the + // GTIN parsed from the first. + if gtin.is_some() { + return Err(DigitalLinkError::DuplicatePrimaryKey); + } let padded = normalize_gtin_to_14(&value)?; gtin = Some(Gtin::parse(&padded)?); } @@ -116,6 +132,14 @@ impl DigitalLink { i += 2; } + // An odd segment count leaves a trailing AI code with no value — reject + // it rather than silently dropping the dangling qualifier. + if i < ai_segments.len() { + return Err(DigitalLinkError::TrailingUnpairedSegment( + ai_segments[i].to_owned(), + )); + } + let gtin = gtin.ok_or(DigitalLinkError::MissingGtin)?; Ok(Self { diff --git a/crates/dpp-digital-link/src/digital_link/tests.rs b/crates/dpp-digital-link/src/digital_link/tests.rs index 6108e83..d17a457 100644 --- a/crates/dpp-digital-link/src/digital_link/tests.rs +++ b/crates/dpp-digital-link/src/digital_link/tests.rs @@ -293,6 +293,38 @@ fn misordered_qualifiers_rejected() { )); } +#[test] +fn trailing_unpaired_ai_rejected() { + // AI 21 with no following value must error, not silently drop the serial. + let uri = "https://id.odal-node.io/01/09506000134352/21"; + assert!(matches!( + DigitalLink::parse(uri), + Err(DigitalLinkError::TrailingUnpairedSegment(_)) + )); +} + +#[test] +fn duplicate_primary_key_rejected() { + // A second '01' segment must not overwrite the GTIN from the first. + let uri = "https://id.odal-node.io/01/09506000134352/21/SN1/01/00000000000001"; + assert!(matches!( + DigitalLink::parse(uri), + Err(DigitalLinkError::DuplicatePrimaryKey) + )); +} + +#[test] +fn oversized_ai_value_rejected() { + // AI 21 (serial) has a GS1 max length of 20; a 21-char value must error. + let long_serial = "X".repeat(21); + let uri = format!("https://id.odal-node.io/01/09506000134352/21/{long_serial}"); + assert!(matches!( + DigitalLink::parse(&uri), + Err(DigitalLinkError::ValueTooLong { code, max_len, actual }) + if code == "21" && max_len == 20 && actual == 21 + )); +} + // ── 3.4a: percent-encode / decode ──────────────────────────── #[test] diff --git a/crates/dpp-digital-link/src/jsonld/context.rs b/crates/dpp-digital-link/src/jsonld/context.rs index dee5ede..5381d25 100644 --- a/crates/dpp-digital-link/src/jsonld/context.rs +++ b/crates/dpp-digital-link/src/jsonld/context.rs @@ -30,14 +30,21 @@ pub fn passport_context() -> Value { } /// Wrap a passport JSON value in a JSON-LD envelope. +/// +/// A non-object payload cannot be merged into the `@context` object; it is +/// returned **unchanged** rather than silently discarded into a bare, empty +/// envelope. pub fn frame_passport(passport: Value) -> Value { - let mut framed = passport_context(); - if let Value::Object(ref mut ctx_map) = framed - && let Value::Object(passport_map) = passport - { - ctx_map.extend(passport_map); + match passport { + Value::Object(passport_map) => { + let mut framed = passport_context(); + if let Value::Object(ref mut ctx_map) = framed { + ctx_map.extend(passport_map); + } + framed + } + other => other, } - framed } /// Extract the plain data from a JSON-LD framed passport (strip `@context`). diff --git a/crates/dpp-digital-link/src/jsonld/tests.rs b/crates/dpp-digital-link/src/jsonld/tests.rs index 8200967..36bc5f5 100644 --- a/crates/dpp-digital-link/src/jsonld/tests.rs +++ b/crates/dpp-digital-link/src/jsonld/tests.rs @@ -14,11 +14,11 @@ fn frame_and_strip_round_trip() { } #[test] -fn frame_passport_ignores_non_object_payload() { - // A non-object payload can't be merged into the context map — the framed - // result is just the bare context. +fn frame_passport_preserves_non_object_payload() { + // A non-object payload can't be merged into the context map, but it must be + // returned intact rather than silently discarded into a bare envelope. let framed = frame_passport(json!("not-an-object")); - assert!(framed.get("@context").is_some()); + assert_eq!(framed, json!("not-an-object")); } #[test] diff --git a/crates/dpp-domain/examples/create_passport.rs b/crates/dpp-domain/examples/create_passport.rs index 39fd4d4..c53eb29 100644 --- a/crates/dpp-domain/examples/create_passport.rs +++ b/crates/dpp-domain/examples/create_passport.rs @@ -11,6 +11,7 @@ use dpp_domain::{ fn main() { // 1. Build sector-specific data (Textile DPP) let textile_data = TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![ FibreEntry { fibre: "organic cotton".into(), @@ -79,6 +80,7 @@ fn main() { co2e_per_unit: Some(CarbonFootprint::from_kg(8.2)), repairability_score: Some(RepairabilityScore::from_scalar(7.0)), compliance_result: None, + lint_result: None, sector_data: Some(SectorData::Textile(textile_data)), status: PassportStatus::Draft, qr_code_url: None, diff --git a/crates/dpp-domain/schemas/textile/v1.0.0.json b/crates/dpp-domain/schemas/textile/v1.0.0.json index 7498fcf..6d5e298 100644 --- a/crates/dpp-domain/schemas/textile/v1.0.0.json +++ b/crates/dpp-domain/schemas/textile/v1.0.0.json @@ -5,12 +5,18 @@ "description": "Textile-specific fields for EU textile DPP compliance (anticipated 2025-2026 delegated act).", "type": "object", "required": [ + "gtin", "fibreComposition", "countryOfManufacturing", "careInstructions", "chemicalComplianceStandard" ], "properties": { + "gtin": { + "type": "string", + "pattern": "^[0-9]{14}$", + "description": "14-digit Global Trade Item Number." + }, "fibreComposition": { "type": "array", "minItems": 1, diff --git a/crates/dpp-domain/schemas/textile/v1.1.0.json b/crates/dpp-domain/schemas/textile/v1.1.0.json index d2ec91f..cd90773 100644 --- a/crates/dpp-domain/schemas/textile/v1.1.0.json +++ b/crates/dpp-domain/schemas/textile/v1.1.0.json @@ -5,12 +5,18 @@ "description": "Textile-specific fields for EU textile DPP compliance. v1.1.0 adds SCIP/SVHC substance disclosure, per-fibre origin traceability, durability metrics, and microplastic shedding data.", "type": "object", "required": [ + "gtin", "fibreComposition", "countryOfManufacturing", "careInstructions", "chemicalComplianceStandard" ], "properties": { + "gtin": { + "type": "string", + "pattern": "^[0-9]{14}$", + "description": "14-digit Global Trade Item Number." + }, "fibreComposition": { "type": "array", "minItems": 1, diff --git a/crates/dpp-domain/src/catalog/catalog.rs b/crates/dpp-domain/src/catalog/catalog.rs index dfcfe7f..d1dcfa4 100644 --- a/crates/dpp-domain/src/catalog/catalog.rs +++ b/crates/dpp-domain/src/catalog/catalog.rs @@ -156,12 +156,43 @@ impl SectorCatalog { keys } - /// Register a new sector at runtime. Returns `AlreadyExists` if the key is - /// taken. + /// Register a new sector at runtime. + /// + /// Enforces the descriptor invariant the schema-resolution path relies on: + /// `current_schema_version` must be valid semver **and** one of + /// `schema_versions`. A descriptor violating either would otherwise let a + /// caller register a sector whose version fails to parse downstream, which + /// silently skips JSON-Schema validation for every passport in that sector. + /// + /// # Errors + /// - [`CatalogError::AlreadyExists`] if the key is already taken. + /// - [`CatalogError::InvalidSchemaVersion`] if `current_schema_version` is + /// not valid semver. + /// - [`CatalogError::CurrentVersionNotListed`] if `current_schema_version` + /// is not present in `schema_versions`. pub fn register(&mut self, descriptor: SectorDescriptor) -> Result<(), CatalogError> { if self.get(&descriptor.key).is_some() { return Err(CatalogError::AlreadyExists(descriptor.key)); } + if descriptor + .current_schema_version + .parse::() + .is_err() + { + return Err(CatalogError::InvalidSchemaVersion { + key: descriptor.key, + version: descriptor.current_schema_version, + }); + } + if !descriptor + .schema_versions + .contains(&descriptor.current_schema_version) + { + return Err(CatalogError::CurrentVersionNotListed { + key: descriptor.key, + version: descriptor.current_schema_version, + }); + } self.entries.push(descriptor); Ok(()) } diff --git a/crates/dpp-domain/src/catalog/error.rs b/crates/dpp-domain/src/catalog/error.rs index c6bca0c..a63e189 100644 --- a/crates/dpp-domain/src/catalog/error.rs +++ b/crates/dpp-domain/src/catalog/error.rs @@ -6,12 +6,24 @@ pub enum CatalogError { /// A descriptor for this key already exists. AlreadyExists(String), + /// `current_schema_version` is not a valid semver string. + InvalidSchemaVersion { key: String, version: String }, + /// `current_schema_version` is not listed in `schema_versions`. + CurrentVersionNotListed { key: String, version: String }, } impl std::fmt::Display for CatalogError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::AlreadyExists(key) => write!(f, "sector '{key}' already in catalog"), + Self::InvalidSchemaVersion { key, version } => write!( + f, + "sector '{key}' currentSchemaVersion '{version}' is not valid semver" + ), + Self::CurrentVersionNotListed { key, version } => write!( + f, + "sector '{key}' currentSchemaVersion '{version}' is not in its schemaVersions list" + ), } } } diff --git a/crates/dpp-domain/src/catalog/tests.rs b/crates/dpp-domain/src/catalog/tests.rs index aa18a51..98d1d6d 100644 --- a/crates/dpp-domain/src/catalog/tests.rs +++ b/crates/dpp-domain/src/catalog/tests.rs @@ -98,6 +98,48 @@ fn register_runtime_sector() { )); } +fn provisional_descriptor(current: &str, versions: Vec) -> SectorDescriptor { + SectorDescriptor { + key: "plastics".into(), + title: "Plastics".into(), + status: RegulatoryStatus::Provisional, + legal_basis: vec!["ESPR Working Plan".into()], + dpp_applies_from: None, + retention_years: 10, + schema_versions: versions, + current_schema_version: current.into(), + product_categories: vec![], + access_tiers: std::collections::HashMap::new(), + plugin: None, + notes: None, + } +} + +#[test] +fn register_rejects_invalid_current_schema_version() { + let mut catalog = SectorCatalog::new(); + let descriptor = provisional_descriptor("not-semver", vec!["not-semver".into()]); + assert!(matches!( + catalog.register(descriptor), + Err(CatalogError::InvalidSchemaVersion { .. }) + )); + // A rejected descriptor must never reach the catalog — otherwise every + // passport in that sector silently skips schema validation. + assert_eq!(catalog.len(), 11); +} + +#[test] +fn register_rejects_current_version_not_in_list() { + let mut catalog = SectorCatalog::new(); + // Valid semver, but not one of the declared schema_versions. + let descriptor = provisional_descriptor("2.0.0", vec!["1.0.0".into()]); + assert!(matches!( + catalog.register(descriptor), + Err(CatalogError::CurrentVersionNotListed { .. }) + )); + assert_eq!(catalog.len(), 11); +} + /// Parity guard: the closed [`Sector`](crate::domain::sector::Sector) enum /// and the open [`SectorCatalog`] must describe the same set of /// *compile-time* sectors. Runtime-registered sectors degrade to diff --git a/crates/dpp-domain/src/compliance/passthrough_registry.rs b/crates/dpp-domain/src/compliance/passthrough_registry.rs index 48606e9..6f40d9a 100644 --- a/crates/dpp-domain/src/compliance/passthrough_registry.rs +++ b/crates/dpp-domain/src/compliance/passthrough_registry.rs @@ -93,6 +93,7 @@ mod tests { fn textile_data() -> SectorData { SectorData::Textile(TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![FibreEntry { fibre: "cotton".into(), pct: 100.0, diff --git a/crates/dpp-domain/src/domain/lint.rs b/crates/dpp-domain/src/domain/lint.rs new file mode 100644 index 0000000..943e3bd --- /dev/null +++ b/crates/dpp-domain/src/domain/lint.rs @@ -0,0 +1,276 @@ +//! Passport plausibility lint dispatch — maps [`SectorData`] onto the +//! `dpp-rules::lint` pack and carries the owned, serialisable wire types the +//! engine persists on [`crate::domain::passport::Passport::lint_result`]. +//! +//! Unlike [`crate::ports::compliance`], there is no pluggable strategy here: +//! the lint pack ships directly in `dpp-rules` and is not an extension seam. + +use chrono::{DateTime, Datelike, Utc}; +use serde::{Deserialize, Serialize}; + +use super::sector::{SectorData, UnsoldGoodsDestination}; + +/// How strongly a lint finding should be read. Neither variant blocks +/// publish — the distinction is tone, not gating. Mirrors +/// [`dpp_rules::lint::LintSeverity`] in an owned, serialisable form. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum LintSeverity { + Warning, + Notice, +} + +/// A single plausibility finding. Mirrors [`dpp_rules::lint::LintFinding`] in +/// an owned, serialisable form. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LintFinding { + pub code: String, + pub field: String, + pub severity: LintSeverity, + pub message: String, +} + +/// The result of running the plausibility lint pack against a passport's +/// sector data. Never gates publish — see +/// [`crate::domain::passport::Passport::lint_result`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LintResult { + /// The `dpp_rules::lint::LINT_PACK_VERSION` that produced `findings`. + pub pack_version: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub findings: Vec, + pub assessed_at: DateTime, +} + +impl LintResult { + /// Run the plausibility lint pack against `data`, stamping `assessed_at` + /// as `Utc::now()`. + #[must_use] + pub fn compute(data: &SectorData) -> Self { + let now = Utc::now(); + Self { + pack_version: dpp_rules::lint::LINT_PACK_VERSION.to_owned(), + findings: lint_sector_data(data, now), + assessed_at: now, + } + } +} + +fn convert(f: dpp_rules::lint::LintFinding) -> LintFinding { + LintFinding { + code: f.code.to_owned(), + field: f.field.to_owned(), + severity: match f.severity { + dpp_rules::lint::LintSeverity::Warning => LintSeverity::Warning, + dpp_rules::lint::LintSeverity::Notice => LintSeverity::Notice, + }, + message: f.message, + } +} + +fn unsold_goods_destination_code(d: &UnsoldGoodsDestination) -> &'static str { + match d { + UnsoldGoodsDestination::Donation => "donation", + UnsoldGoodsDestination::Recycling => "recycling", + UnsoldGoodsDestination::Repurposing => "repurposing", + UnsoldGoodsDestination::SupplierReturn => "supplier_return", + UnsoldGoodsDestination::ExemptDestruction => "exempt_destruction", + } +} + +/// Dispatch to the sector-specific lint pack. Sectors with no lint pack yet +/// (everything but battery/textile/unsold-goods in the first ruleset) +/// produce no findings. +#[must_use] +pub fn lint_sector_data(data: &SectorData, as_of: DateTime) -> Vec { + match data { + SectorData::Battery(b) => { + let cathode: Vec = b + .cathode_material + .as_deref() + .unwrap_or(&[]) + .iter() + .map(|m| m.weight_pct) + .collect(); + let anode: Vec = b + .anode_material + .as_deref() + .unwrap_or(&[]) + .iter() + .map(|m| m.weight_pct) + .collect(); + let electrolyte: Vec = b + .electrolyte_material + .as_deref() + .unwrap_or(&[]) + .iter() + .map(|m| m.weight_pct) + .collect(); + let input = dpp_rules::lint::battery::BatteryLintInput { + nominal_voltage_v: b.nominal_voltage_v, + nominal_capacity_ah: b.nominal_capacity_ah, + rated_energy_wh: b.rated_energy_wh, + rated_capacity_kwh: b.rated_capacity_kwh, + operating_temp_min_c: b.operating_temp_min_c, + operating_temp_max_c: b.operating_temp_max_c, + manufacturing_date_unix: b.manufacturing_date.map(|d| d.timestamp()), + as_of_unix: as_of.timestamp(), + cathode_material_pct: &cathode, + anode_material_pct: &anode, + electrolyte_material_pct: &electrolyte, + }; + dpp_rules::lint::battery::lint_battery(&input) + .into_iter() + .map(convert) + .collect() + } + SectorData::Textile(t) => { + let fibres: Vec<&str> = t + .fibre_composition + .iter() + .map(|f| f.fibre.as_str()) + .collect(); + let input = dpp_rules::lint::textile::TextileLintInput { + durability_score: t.durability_score, + expected_wash_cycles: t.expected_wash_cycles, + repair_count: t.repair_count, + repair_history_url: t.repair_history_url.as_deref(), + prior_use_cycles: t.prior_use_cycles, + reuse_condition: t.reuse_condition.as_deref(), + repair_score: t.repair_score, + disassembly_instructions: t.disassembly_instructions.as_deref(), + spare_parts_available: t.spare_parts_available, + microplastic_shedding_mg_per_wash: t.microplastic_shedding_mg_per_wash, + fibres: &fibres, + }; + dpp_rules::lint::textile::lint_textile(&input) + .into_iter() + .map(convert) + .collect() + } + SectorData::UnsoldGoods(u) => { + let input = dpp_rules::lint::unsold_goods::UnsoldGoodsLintInput { + reporting_period: &u.reporting_period, + volume_kg: u.volume_kg, + destination: unsold_goods_destination_code(&u.destination), + operator_name: u.operator_name.as_deref(), + destruction_justification: u.destruction_justification.as_deref(), + as_of_year: as_of.year().max(0) as u32, + as_of_month: as_of.month(), + }; + dpp_rules::lint::unsold_goods::lint_unsold_goods(&input) + .into_iter() + .map(convert) + .collect() + } + _ => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::gtin::Gtin; + use crate::domain::sector::{ + BatteryChemistry, BatteryData, UnsoldGoodsReason, UnsoldGoodsReport, + }; + + fn battery() -> BatteryData { + BatteryData { + gtin: Gtin::parse("09506000134352").unwrap(), + battery_chemistry: BatteryChemistry::Lfp, + nominal_voltage_v: 3.7, + nominal_capacity_ah: 10.0, + expected_lifetime_cycles: 500, + co2e_per_unit_kg: 5.0, + recycled_content_cobalt_pct: None, + recycled_content_lithium_pct: None, + recycled_content_nickel_pct: None, + state_of_health_pct: None, + rated_capacity_kwh: None, + carbon_footprint_class: None, + due_diligence_url: None, + cathode_material: None, + anode_material: None, + electrolyte_material: None, + critical_raw_materials: None, + disassembly_instructions_url: None, + soh_methodology: None, + operating_temp_min_c: None, + operating_temp_max_c: None, + rated_energy_wh: Some(37.0), + recycled_content_lead_pct: None, + battery_weight_kg: None, + battery_type: None, + round_trip_efficiency_pct: None, + internal_resistance_mohm: None, + manufacturing_date: None, + manufacturing_place: None, + battery_model_id: None, + battery_passport_number: None, + } + } + + #[test] + fn clean_battery_produces_no_findings() { + let data = SectorData::Battery(battery()); + assert!(lint_sector_data(&data, Utc::now()).is_empty()); + } + + #[test] + fn battery_energy_mismatch_surfaces_as_domain_finding() { + let mut b = battery(); + b.rated_energy_wh = Some(500.0); // 3.7 * 10.0 = 37.0 expected + let data = SectorData::Battery(b); + let findings = lint_sector_data(&data, Utc::now()); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].code, "battery.energy_capacity_mismatch"); + assert_eq!(findings[0].severity, LintSeverity::Notice); + } + + #[test] + fn unsold_goods_third_party_without_operator_name_triggers() { + let report = UnsoldGoodsReport { + reporting_period: "2026-Q2".into(), + volume_kg: 500.0, + product_category: "apparel".into(), + reason: UnsoldGoodsReason::EndOfSeason, + destination: UnsoldGoodsDestination::Donation, + destruction_justification: None, + country_of_disposal: "MK".into(), + operator_name: None, + }; + let data = SectorData::UnsoldGoods(report); + let findings = lint_sector_data(&data, Utc::now()); + assert!( + findings + .iter() + .any(|f| f.code == "unsold_goods.operator_name_missing_for_third_party_destination") + ); + } + + #[test] + fn other_sector_produces_no_findings() { + let data = SectorData::Other(serde_json::json!({"sector": "toy"})); + assert!(lint_sector_data(&data, Utc::now()).is_empty()); + } + + #[test] + fn lint_result_compute_stamps_pack_version_and_timestamp() { + let data = SectorData::Battery(battery()); + let result = LintResult::compute(&data); + assert_eq!(result.pack_version, dpp_rules::lint::LINT_PACK_VERSION); + assert!(result.findings.is_empty()); + } + + #[test] + fn lint_result_serde_round_trip() { + let data = SectorData::Battery(battery()); + let result = LintResult::compute(&data); + let json = serde_json::to_value(&result).unwrap(); + let back: LintResult = serde_json::from_value(json).unwrap(); + assert_eq!(back, result); + } +} diff --git a/crates/dpp-domain/src/domain/mod.rs b/crates/dpp-domain/src/domain/mod.rs index 4be4ade..6d13369 100644 --- a/crates/dpp-domain/src/domain/mod.rs +++ b/crates/dpp-domain/src/domain/mod.rs @@ -6,7 +6,9 @@ pub mod error; pub mod field_error; pub mod gtin; pub mod identity; +pub mod lint; pub mod passport; +pub mod product_identity; pub mod sector; pub mod status; pub mod transfer; diff --git a/crates/dpp-domain/src/domain/passport/passport.rs b/crates/dpp-domain/src/domain/passport/passport.rs index f6aaf03..7e5b206 100644 --- a/crates/dpp-domain/src/domain/passport/passport.rs +++ b/crates/dpp-domain/src/domain/passport/passport.rs @@ -9,6 +9,7 @@ use super::{ }; use crate::domain::{ identity::AccessTier, + lint::LintResult, sector::{CarbonFootprint, RepairabilityScore, Sector, SectorData}, status::PassportStatus, }; @@ -44,6 +45,14 @@ pub struct Passport { /// until a determination is computed (e.g. a sector with no plugin loaded). #[serde(default, skip_serializing_if = "Option::is_none")] pub compliance_result: Option, + /// Non-binding plausibility findings from the `dpp-rules` lint pack — + /// arithmetic and physical-plausibility checks distinct from binding + /// compliance rules. Never gates publish and may be recomputed at any + /// time after publish (a lint re-check), unlike `compliance_result` — + /// see the vault's `POST /dpp/{id}/lint` endpoint. `None` until a lint + /// pass has run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lint_result: Option, /// Typed, sector-specific DPP data (EU Battery Regulation, Textile DPP, etc.). /// /// `None` for passports where sector-specific data has not yet been supplied. @@ -152,14 +161,12 @@ impl Passport { issues.push("manufacturer.address must not be empty"); } - // Semver: digits.digits.digits, optionally with pre-release suffix - let sv = &self.schema_version; - let parts: Vec<&str> = sv.split('.').collect(); - if parts.len() < 3 - || !parts[0].chars().all(|c| c.is_ascii_digit()) - || !parts[1].chars().all(|c| c.is_ascii_digit()) - || parts[2].is_empty() - { + // Must parse as strict semver (major.minor.patch, optional pre-release + // / build metadata). A hand-rolled digit check let ".5.0" (empty major) + // and "1.0.abc" (non-numeric patch) through — both then fail + // `semver::Version` parsing at schema resolution and silently skip + // schema validation, so reject them here rather than downstream. + if self.schema_version.parse::().is_err() { issues.push("schema_version must be valid semver (e.g. 1.0.0)"); } @@ -263,9 +270,11 @@ impl Passport { /// /// `sectorData`, when present, is independently redacted via /// [`crate::domain::sector::redact_sector_data`] against the sector descriptor - /// from `catalog`. If the sector is not in the catalog, sector data is left - /// unfiltered (fail-open at the domain layer; the vault layer applies - /// `filter_by_access_tier` as defence-in-depth). + /// from `catalog`. If the sector is not in the catalog, sector data is + /// **withheld** from viewers below `Confidential` (fail-closed): without the + /// descriptor's per-field access tiers the domain layer cannot tell which + /// fields are safe to expose, so it exposes none. `Confidential` viewers — + /// who may see every field anyway — still receive the full data. pub fn redact( &self, viewer_tier: AccessTier, @@ -289,8 +298,15 @@ impl Passport { let key = sd.sector().catalog_key(); let redacted = if let Some(descriptor) = catalog.get(key) { crate::domain::sector::redact_sector_data(sd, viewer_tier, descriptor) - } else { + } else if viewer_tier >= AccessTier::Confidential { + // Unknown sector: the full payload is only safe for the tier + // that already sees every field. serde_json::to_value(sd).unwrap_or(serde_json::Value::Null) + } else { + // Fail closed: without per-field tiers we cannot tell which + // fields are confidential, so withhold sector data entirely + // rather than leak it to a lower tier. + serde_json::Value::Null }; obj.insert("sectorData".into(), redacted); } else { diff --git a/crates/dpp-domain/src/domain/passport/tests.rs b/crates/dpp-domain/src/domain/passport/tests.rs index 4076ee6..41e2984 100644 --- a/crates/dpp-domain/src/domain/passport/tests.rs +++ b/crates/dpp-domain/src/domain/passport/tests.rs @@ -31,6 +31,7 @@ fn make_passport() -> Passport { co2e_per_unit: Some(CarbonFootprint::from_kg(2.5)), repairability_score: Some(RepairabilityScore::from_scalar(7.5)), compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Draft, qr_code_url: None, @@ -233,6 +234,30 @@ fn validate_invalid_semver() { assert!(err.contains("schema_version"), "got: {err}"); } +#[test] +fn validate_rejects_vacuous_semver() { + // ".5.0" (empty major) and "1.0.abc" (non-numeric patch) previously slipped + // past the hand-rolled digit check and then silently skipped schema + // validation downstream. + for bad in [".5.0", "1.0.abc", "1.0", "1"] { + let mut p = make_passport(); + p.schema_version = bad.to_owned(); + let err = p.validate().unwrap_err().to_string(); + assert!( + err.contains("schema_version"), + "schema_version '{bad}' should be rejected, got: {err}" + ); + } + + // Pre-release / build metadata are still valid semver and must be accepted. + let mut p = make_passport(); + p.schema_version = "1.0.0-alpha".to_owned(); + assert!( + p.validate().is_ok(), + "pre-release semver should be accepted" + ); +} + #[test] fn validate_negative_co2e() { let mut p = make_passport(); @@ -337,6 +362,7 @@ fn validate_wires_sector_data_validation() { let mut p = make_passport(); p.sector = Sector::Textile; p.sector_data = Some(SectorData::Textile(TextileData { + gtin: "09506000134352".into(), // fibre sum = 50% — cross-field rule must catch this fibre_composition: vec![FibreEntry { fibre: "cotton".into(), @@ -550,3 +576,31 @@ fn redact_no_sector_data_leaves_passport_fields() { assert!(view.get("productName").is_some()); assert!(view.get("sectorData").is_none()); } + +#[test] +fn redact_unknown_sector_withholds_sector_data_below_confidential() { + let catalog = crate::catalog::SectorCatalog::new(); + let mut p = make_passport(); + // `Other` maps to catalog key "other", which is absent from the embedded + // catalog — so there are no per-field access tiers to redact against. + p.sector = Sector::Other; + p.sector_data = Some(SectorData::Other( + serde_json::json!({ "secretField": "leak-me" }), + )); + + // Public: no descriptor → withhold sector data entirely (fail closed). + let public = p.redact(AccessTier::Public, &catalog).into_value(); + assert!( + public["sectorData"].is_null(), + "unknown-sector data must be withheld below Confidential, got: {}", + public["sectorData"] + ); + assert!( + !public.to_string().contains("leak-me"), + "confidential sector field leaked to a Public viewer" + ); + + // Confidential: sees every field anyway → full data. + let conf = p.redact(AccessTier::Confidential, &catalog).into_value(); + assert_eq!(conf["sectorData"]["secretField"], "leak-me"); +} diff --git a/crates/dpp-domain/src/domain/product_identity.rs b/crates/dpp-domain/src/domain/product_identity.rs new file mode 100644 index 0000000..2679d69 --- /dev/null +++ b/crates/dpp-domain/src/domain/product_identity.rs @@ -0,0 +1,166 @@ +//! [`ProductIdentity`] — the compound key the import delta-matcher looks up by. + +use serde::{Deserialize, Serialize}; + +use super::passport::Passport; +use super::sector::Sector; + +/// Compound identity for matching an import row against an existing passport: +/// sector (dispatch key) + GTIN + optional batch. +/// +/// Not a validated GS1 type — `gtin` is whatever string the sector's typed +/// data carries (only `Battery` validates it as a [`super::gtin::Gtin`]; the +/// rest store it unchecked, and `UnsoldGoods`/`Other` carry none at all — +/// see [`super::sector::SectorData::gtin`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProductIdentity { + pub sector: Sector, + pub gtin: String, + pub batch_id: Option, +} + +impl ProductIdentity { + /// Derive the compound identity from a passport, or `None` if it has no + /// sector data or its sector carries no GTIN field. + pub fn from_passport(passport: &Passport) -> Option { + let gtin = passport.sector_data.as_ref()?.gtin()?.to_owned(); + Some(Self { + sector: passport.sector.clone(), + gtin, + batch_id: passport.batch_id.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::gtin::Gtin; + use crate::domain::passport::ManufacturerInfo; + use crate::domain::sector::{BatteryChemistry, BatteryData, SectorData, TextileData}; + use crate::domain::status::PassportStatus; + + fn base_passport(sector: Sector, sector_data: Option) -> Passport { + Passport { + id: crate::domain::passport::PassportId::new(), + batch_id: Some("BATCH-1".into()), + product_name: "Test".into(), + sector, + product_category: None, + manufacturer: ManufacturerInfo { + name: "Acme".into(), + address: "1 Street".into(), + did_web_url: None, + }, + materials: vec![], + co2e_per_unit: None, + repairability_score: None, + compliance_result: None, + lint_result: None, + sector_data, + status: PassportStatus::Draft, + qr_code_url: None, + jws_signature: None, + public_jws_signature: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + published_at: None, + schema_version: "1.0.0".into(), + retention_locked: false, + version: 1, + supersedes_id: None, + retention_until: None, + product_id: None, + operator_identifier: None, + facility: None, + seal: None, + } + } + + fn battery_data() -> SectorData { + SectorData::Battery(BatteryData { + gtin: Gtin::parse("09506000134352").unwrap(), + battery_chemistry: BatteryChemistry::Lfp, + nominal_voltage_v: 3.2, + nominal_capacity_ah: 100.0, + expected_lifetime_cycles: 3000, + co2e_per_unit_kg: 85.4, + recycled_content_cobalt_pct: None, + recycled_content_lithium_pct: None, + recycled_content_nickel_pct: None, + state_of_health_pct: None, + rated_capacity_kwh: None, + carbon_footprint_class: None, + due_diligence_url: None, + cathode_material: None, + anode_material: None, + electrolyte_material: None, + critical_raw_materials: None, + disassembly_instructions_url: None, + soh_methodology: None, + operating_temp_min_c: None, + operating_temp_max_c: None, + rated_energy_wh: None, + recycled_content_lead_pct: None, + battery_weight_kg: None, + battery_type: None, + round_trip_efficiency_pct: None, + internal_resistance_mohm: None, + manufacturing_date: None, + manufacturing_place: None, + battery_model_id: None, + battery_passport_number: None, + }) + } + + #[test] + fn battery_passport_yields_identity() { + let p = base_passport(Sector::Battery, Some(battery_data())); + let id = ProductIdentity::from_passport(&p).expect("battery has a gtin"); + assert_eq!(id.sector, Sector::Battery); + assert_eq!(id.gtin, "09506000134352"); + assert_eq!(id.batch_id.as_deref(), Some("BATCH-1")); + } + + #[test] + fn textile_passport_yields_identity() { + let textile_data = SectorData::Textile(TextileData { + gtin: "09506000134352".into(), + fibre_composition: vec![], + country_of_manufacturing: "BD".into(), + care_instructions: "wash".into(), + chemical_compliance_standard: "OEKO-TEX 100".into(), + recycled_content_pct: None, + carbon_footprint_kg_co2e: None, + water_use_litres: None, + microplastic_shedding_mg_per_wash: None, + repair_score: None, + durability_score: None, + expected_wash_cycles: None, + country_of_raw_material_origin: None, + svhc_substances: None, + allergens: None, + substances_of_concern: None, + recyclability_class: None, + end_of_life_instructions: None, + reuse_condition: None, + prior_use_cycles: None, + disassembly_instructions: None, + spare_parts_available: None, + product_weight_grams: None, + repair_history_url: None, + repair_count: None, + pef_score: None, + }); + let p = base_passport(Sector::Textile, Some(textile_data)); + let id = ProductIdentity::from_passport(&p).expect("textile has a gtin"); + assert_eq!(id.sector, Sector::Textile); + assert_eq!(id.gtin, "09506000134352"); + } + + #[test] + fn no_sector_data_yields_no_identity() { + let p = base_passport(Sector::Battery, None); + assert!(ProductIdentity::from_passport(&p).is_none()); + } +} diff --git a/crates/dpp-domain/src/domain/sector/data/sector_data.rs b/crates/dpp-domain/src/domain/sector/data/sector_data.rs index da36949..978207c 100644 --- a/crates/dpp-domain/src/domain/sector/data/sector_data.rs +++ b/crates/dpp-domain/src/domain/sector/data/sector_data.rs @@ -61,6 +61,27 @@ impl SectorData { SectorData::Other(_) => Sector::Other, } } + + /// The GTIN carried by this sector's typed data, if any. + /// + /// `UnsoldGoods` and `Other` carry no GTIN field — a discard-event report + /// and an untyped catch-all respectively, neither of which identifies a + /// trade item the way every other sector does. + pub fn gtin(&self) -> Option<&str> { + match self { + SectorData::Battery(d) => Some(d.gtin.as_str()), + SectorData::Textile(d) => Some(d.gtin.as_str()), + SectorData::Steel(d) => Some(d.gtin.as_str()), + SectorData::Electronics(d) => Some(d.gtin.as_str()), + SectorData::Construction(d) => Some(d.gtin.as_str()), + SectorData::Tyre(d) => Some(d.gtin.as_str()), + SectorData::Toy(d) => Some(d.gtin.as_str()), + SectorData::Aluminium(d) => Some(d.gtin.as_str()), + SectorData::Furniture(d) => Some(d.gtin.as_str()), + SectorData::Detergent(d) => Some(d.gtin.as_str()), + SectorData::UnsoldGoods(_) | SectorData::Other(_) => None, + } + } } /// Serialize `data` to a JSON object and strip any top-level field whose diff --git a/crates/dpp-domain/src/domain/sector/data/textile.rs b/crates/dpp-domain/src/domain/sector/data/textile.rs index dab43dc..12c0492 100644 --- a/crates/dpp-domain/src/domain/sector/data/textile.rs +++ b/crates/dpp-domain/src/domain/sector/data/textile.rs @@ -33,6 +33,8 @@ pub struct FibreEntry { #[serde(rename_all = "camelCase")] pub struct TextileData { // ── Mandatory fields (v1.0.0) ────────────────────────────────────────── + /// 14-digit GTIN identifying the textile product. + pub gtin: String, /// List of fibres and their percentage composition. Must sum to ~100%. pub fibre_composition: Vec, /// ISO 3166-1 alpha-2 country code where the textile was manufactured. diff --git a/crates/dpp-domain/src/domain/sector/tests.rs b/crates/dpp-domain/src/domain/sector/tests.rs index 846253a..1740d7b 100644 --- a/crates/dpp-domain/src/domain/sector/tests.rs +++ b/crates/dpp-domain/src/domain/sector/tests.rs @@ -146,6 +146,7 @@ fn polyester_fibre(pct: f64) -> FibreEntry { fn test_textile_data() -> TextileData { TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![cotton_fibre(60.0), polyester_fibre(40.0)], country_of_manufacturing: "BD".into(), care_instructions: "Machine wash 40°C".into(), @@ -456,6 +457,7 @@ fn textile_v1_data_deserializes_with_defaults() { // v1.0.0 JSON (without new fields) must still deserialize into the expanded struct let v1_json = serde_json::json!({ "sector": "textile", + "gtin": "09506000134352", "fibreComposition": [{"fibre": "cotton", "pct": 100.0}], "countryOfManufacturing": "PT", "careInstructions": "Hand wash", diff --git a/crates/dpp-domain/src/domain/validation/functions.rs b/crates/dpp-domain/src/domain/validation/functions.rs index 1689549..e55cd33 100644 --- a/crates/dpp-domain/src/domain/validation/functions.rs +++ b/crates/dpp-domain/src/domain/validation/functions.rs @@ -30,6 +30,12 @@ fn default_catalog() -> &'static SectorCatalog { /// Validate `sector_data` against the appropriate JSON Schema and any /// sector-specific cross-field rules (e.g. fibre composition sum). /// +/// The JSON-Schema step resolves against the crate's **embedded** schema +/// registry and catalog (built once at first use). Schemas registered at +/// runtime into a separate [`VersionedSchemaRegistry`] are not visible here — +/// validate those through that registry directly (its fail-closed +/// `validate_strict`). +/// /// `SectorData::Other` is a **hard error** here — pass a /// [`SectorValidatorRegistry`] via [`validate_sector_data_with_registry`] to /// handle runtime-registered sectors. @@ -100,11 +106,22 @@ pub fn validate_raw_sector_data( let catalog = default_catalog(); let has_schema = catalog.current_schema_version(sector_key).is_some(); - if let Some(version_str) = catalog.current_schema_version(sector_key) - && let Ok(version) = version_str.parse::() - && let Err(ve) = default_registry().validate(sector_key, &version, data) - { - errors.extend(ve.errors); + if let Some(version_str) = catalog.current_schema_version(sector_key) { + match version_str.parse::() { + Ok(version) => { + if let Err(ve) = default_registry().validate(sector_key, &version, data) { + errors.extend(ve.errors); + } + } + // Fail closed: a registered sector with an unparseable current + // version must not silently skip schema validation. + Err(_) => errors.push(FieldError { + field: "/schemaVersion".to_owned(), + message: format!( + "sector '{sector_key}' has an invalid current schema version '{version_str}'" + ), + }), + } } match registry.get(sector_key) { @@ -138,8 +155,19 @@ fn schema_errors(sector_data: &SectorData, errors: &mut Vec) { let Some(version_str) = default_catalog().current_schema_version(key) else { return; }; - let Ok(version) = version_str.parse::() else { - return; + // A catalog entry whose current version won't parse is a misconfiguration, + // not a reason to skip validation — surface it rather than fail open. + let version = match version_str.parse::() { + Ok(v) => v, + Err(_) => { + errors.push(FieldError { + field: "/schemaVersion".to_owned(), + message: format!( + "sector '{key}' has an invalid current schema version '{version_str}'" + ), + }); + return; + } }; let instance = sector_data_instance(sector_data); if let Err(ve) = default_registry().validate(key, &version, &instance) { diff --git a/crates/dpp-domain/src/domain/validation/mod.rs b/crates/dpp-domain/src/domain/validation/mod.rs index b9a2b01..c61fd42 100644 --- a/crates/dpp-domain/src/domain/validation/mod.rs +++ b/crates/dpp-domain/src/domain/validation/mod.rs @@ -1,10 +1,15 @@ //! JSON Schema + cross-field validation for sector-specific DPP data. //! -//! The schema step routes through the shared [`VersionedSchemaRegistry`](crate::schemas::VersionedSchemaRegistry) at the -//! version the [`SectorCatalog`](crate::SectorCatalog) marks current for the sector — there are no -//! per-sector validators and no hardcoded versions here. Cross-field regulatory -//! rules (which JSON Schema cannot express, e.g. "fibre percentages sum to -//! ~100%") come from `dpp-rules` via the `dpp-domain` adapters. +//! The schema step resolves against the crate's **embedded** +//! [`VersionedSchemaRegistry`](crate::schemas::VersionedSchemaRegistry) — built +//! once from the compile-time schemas — at the version the +//! [`SectorCatalog`](crate::SectorCatalog) marks current for the sector; there +//! are no per-sector validators and no hardcoded versions here. Schemas +//! registered at runtime into a separate registry instance are **not** seen by +//! these free functions (nor by `Passport::validate`); validate against those +//! through that registry directly (its fail-closed `validate_strict`). +//! Cross-field regulatory rules (which JSON Schema cannot express, e.g. "fibre +//! percentages sum to ~100%") come from `dpp-rules` via the `dpp-domain` adapters. //! See `docs/architecture/SECTOR-MODEL-CONSOLIDATION.md` (step C2). //! //! **Note**: excluded from wasm32 builds since jsonschema depends on reqwest's diff --git a/crates/dpp-domain/src/domain/validation/tests.rs b/crates/dpp-domain/src/domain/validation/tests.rs index 0be377a..79d84c1 100644 --- a/crates/dpp-domain/src/domain/validation/tests.rs +++ b/crates/dpp-domain/src/domain/validation/tests.rs @@ -46,6 +46,7 @@ fn valid_battery() -> SectorData { fn valid_textile() -> SectorData { SectorData::Textile(TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![ FibreEntry { fibre: "cotton".into(), @@ -210,6 +211,7 @@ fn textile_empty_fibre_composition_fails() { fn textile_fibre_sum_not_100_fails() { // Schema passes (pct 0–100 individually); the cross-field rule fails. let data = SectorData::Textile(TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![ FibreEntry { fibre: "cotton".into(), @@ -349,6 +351,7 @@ fn batch_validation_mixed_results() { valid_textile(), // Invalid: fibre sum != 100 SectorData::Textile(TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![FibreEntry { fibre: "cotton".into(), pct: 50.0, diff --git a/crates/dpp-domain/src/lib.rs b/crates/dpp-domain/src/lib.rs index e782f75..ea4f431 100644 --- a/crates/dpp-domain/src/lib.rs +++ b/crates/dpp-domain/src/lib.rs @@ -17,10 +17,12 @@ pub use domain::{ error::DppError, gtin::{Gln, GlnError, Gtin, GtinError, gs1_check_digit}, identity::{AccessTier, PassportCredential, PassportCredentialSubject, SignedCredential}, + lint::{LintFinding, LintResult, LintSeverity, lint_sector_data}, passport::{ FacilitySnapshot, ManufacturerInfo, MaterialEntry, Passport, PassportId, PassportView, ProductCategory, }, + product_identity::ProductIdentity, sector::{ AluminiumData, BatteryChemistry, BatteryData, BatteryType, CarbonFootprint, CarbonFootprintClass, ConstructionData, DetergentData, ElectronicsData, diff --git a/crates/dpp-domain/src/ports/archive.rs b/crates/dpp-domain/src/ports/archive.rs index 4258ab7..7d60a18 100644 --- a/crates/dpp-domain/src/ports/archive.rs +++ b/crates/dpp-domain/src/ports/archive.rs @@ -253,6 +253,7 @@ mod tests { co2e_per_unit: Some(CarbonFootprint::from_kg(3.5)), repairability_score: Some(RepairabilityScore::from_scalar(7.0)), compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Published, qr_code_url: None, diff --git a/crates/dpp-domain/src/ports/identity_port.rs b/crates/dpp-domain/src/ports/identity_port.rs index e52296f..ee41288 100644 --- a/crates/dpp-domain/src/ports/identity_port.rs +++ b/crates/dpp-domain/src/ports/identity_port.rs @@ -23,7 +23,7 @@ pub trait IdentityPort: Send + Sync { /// Fetch this node's own current `did:web` document — the same document /// external verifiers resolve, snapshotted for embedding in an evidence - /// dossier (`GET /dpp/{id}/evidence`) so offline verification never has - /// to trust the issuing node again after export. + /// dossier so a verifier checks signatures against the exact document + /// current at generation time. async fn own_did_document(&self) -> Result; } diff --git a/crates/dpp-domain/src/ports/passport_repo.rs b/crates/dpp-domain/src/ports/passport_repo.rs index af95e62..8dca22b 100644 --- a/crates/dpp-domain/src/ports/passport_repo.rs +++ b/crates/dpp-domain/src/ports/passport_repo.rs @@ -9,9 +9,31 @@ use async_trait::async_trait; use crate::domain::{ error::DppError, passport::{Passport, PassportId}, + product_identity::ProductIdentity, status::PassportStatus, }; +/// Fields governed by the state machine, the retention lock, the publish/seal +/// pipeline, or record identity — none of which is a user-editable content +/// field. `patch_fields` rejects any delta touching one of these so it cannot +/// be used to bypass `transition_to`/`update_status` (e.g. flip +/// `retentionLocked` back to `false` or forge a `jwsSignature`). Serialized +/// (camelCase) field names, matching the `Passport` JSON representation. +const PROTECTED_PATCH_FIELDS: [&str; 12] = [ + "id", + "status", + "retentionLocked", + "retentionUntil", + "jwsSignature", + "publicJwsSignature", + "seal", + "version", + "publishedAt", + "createdAt", + "supersedesId", + "schemaVersion", +]; + /// Port trait for all DPP persistence operations. /// /// **No physical delete method is defined by design.** EU ESPR Article 9 and @@ -39,19 +61,68 @@ pub trait PassportRepository: Send + Sync { /// Used by public endpoints to distinguish between 404 and 410 (suspended). async fn find_by_id_any_status(&self, id: PassportId) -> Result, DppError>; + /// Find a passport by exact compound identity — sector, GTIN, and batch — + /// across `Draft` and `Published`. Used by the import delta-matcher to + /// classify a row as create/update_draft/conflict_published before any + /// write. Returns `None` on no match; `batch_id: None` matches only + /// passports with no batch set. + /// + /// Default implementation is an unindexed `list()` scan — correctness + /// only, suitable for tests and small in-memory stores. `PgPassportRepo` + /// overrides this with a real indexed query. + async fn find_by_identity( + &self, + identity: &ProductIdentity, + ) -> Result, DppError> { + let drafts = self + .list(Some(PassportStatus::Draft), None, None, u32::MAX, 0) + .await?; + let published = self + .list(Some(PassportStatus::Published), None, None, u32::MAX, 0) + .await?; + Ok(drafts + .into_iter() + .chain(published) + .find(|p| ProductIdentity::from_passport(p).as_ref() == Some(identity))) + } + async fn update(&self, passport: Passport) -> Result; /// Merge a JSON delta into an existing passport, touching only the /// specified fields. Safer than `update()` for user-initiated field /// edits: concurrent patches to different fields do not clobber each - /// other (B-03 fix). The default implementation falls back to the - /// read-modify-write pattern — implementations should override with a - /// targeted MERGE statement for real concurrent-write safety. + /// other. The default implementation falls back to the read-modify-write + /// pattern — implementations should override with a targeted MERGE + /// statement for real concurrent-write safety. + /// + /// A delta that tries to set any `PROTECTED_PATCH_FIELDS` key (status, + /// retention lock, signatures, seal, identity, …) is rejected with + /// [`DppError::Validation`]: those transitions belong to the state machine + /// (`transition_to`/`update_status`) and the publish pipeline, never to a + /// free-form field patch. async fn patch_fields( &self, id: PassportId, delta: serde_json::Value, ) -> Result { + if let Some(obj) = delta.as_object() { + let mut forbidden: Vec<&str> = PROTECTED_PATCH_FIELDS + .iter() + .copied() + .filter(|k| obj.contains_key(*k)) + .collect(); + if !forbidden.is_empty() { + forbidden.sort_unstable(); + return Err(DppError::Validation( + format!( + "patch_fields cannot modify protected field(s): {}", + forbidden.join(", ") + ) + .into(), + )); + } + } + let Some(mut passport) = self.find_by_id(id).await? else { return Err(DppError::NotFound(id.to_string())); }; @@ -224,6 +295,7 @@ mod tests { co2e_per_unit: None, repairability_score: None, compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Draft, qr_code_url: None, @@ -258,6 +330,32 @@ mod tests { assert_eq!(patched.id, p.id); } + #[tokio::test] + async fn default_patch_fields_rejects_protected_fields() { + let repo = InMemoryRepo::default(); + let p = repo.create(draft_passport("Original")).await.unwrap(); + + // A delta that tries to escape the state machine / forge integrity fields. + let err = repo + .patch_fields( + p.id, + serde_json::json!({ + "status": "active", + "retentionLocked": false, + "jwsSignature": "forged", + }), + ) + .await + .unwrap_err(); + assert!(matches!(err, DppError::Validation(_)), "got: {err:?}"); + + // The passport must be untouched — still a retention-unlocked draft. + let stored = repo.find_by_id(p.id).await.unwrap().unwrap(); + assert_eq!(stored.status, PassportStatus::Draft); + assert!(!stored.retention_locked); + assert!(stored.jws_signature.is_none()); + } + #[tokio::test] async fn default_patch_fields_unknown_id_is_not_found() { let repo = InMemoryRepo::default(); @@ -268,6 +366,66 @@ mod tests { assert!(matches!(err, DppError::NotFound(_))); } + #[tokio::test] + async fn default_find_by_identity_matches_across_draft_and_published() { + use crate::domain::gtin::Gtin; + use crate::domain::sector::{BatteryChemistry, BatteryData, SectorData}; + + let repo = InMemoryRepo::default(); + let mut p = draft_passport("Battery A"); + p.sector = Sector::Battery; + p.sector_data = Some(SectorData::Battery(BatteryData { + gtin: Gtin::parse("09506000134352").unwrap(), + battery_chemistry: BatteryChemistry::Lfp, + nominal_voltage_v: 3.2, + nominal_capacity_ah: 100.0, + expected_lifetime_cycles: 3000, + co2e_per_unit_kg: 85.4, + recycled_content_cobalt_pct: None, + recycled_content_lithium_pct: None, + recycled_content_nickel_pct: None, + state_of_health_pct: None, + rated_capacity_kwh: None, + carbon_footprint_class: None, + due_diligence_url: None, + cathode_material: None, + anode_material: None, + electrolyte_material: None, + critical_raw_materials: None, + disassembly_instructions_url: None, + soh_methodology: None, + operating_temp_min_c: None, + operating_temp_max_c: None, + rated_energy_wh: None, + recycled_content_lead_pct: None, + battery_weight_kg: None, + battery_type: None, + round_trip_efficiency_pct: None, + internal_resistance_mohm: None, + manufacturing_date: None, + manufacturing_place: None, + battery_model_id: None, + battery_passport_number: None, + })); + p.batch_id = Some("BATCH-1".into()); + let created = repo.create(p).await.unwrap(); + + let identity = ProductIdentity { + sector: Sector::Battery, + gtin: "09506000134352".into(), + batch_id: Some("BATCH-1".into()), + }; + let found = repo.find_by_identity(&identity).await.unwrap(); + assert_eq!(found.map(|p| p.id), Some(created.id)); + + let no_match = ProductIdentity { + sector: Sector::Battery, + gtin: "00000000000000".into(), + batch_id: None, + }; + assert!(repo.find_by_identity(&no_match).await.unwrap().is_none()); + } + #[tokio::test] async fn default_create_and_update_batch_run_sequentially() { let repo = InMemoryRepo::default(); diff --git a/crates/dpp-domain/src/ports/registry_sync.rs b/crates/dpp-domain/src/ports/registry_sync.rs index 7d3e034..31f34a1 100644 --- a/crates/dpp-domain/src/ports/registry_sync.rs +++ b/crates/dpp-domain/src/ports/registry_sync.rs @@ -203,6 +203,7 @@ mod tests { co2e_per_unit: None, repairability_score: None, compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Published, qr_code_url: Some("https://id.odal-node.io/01/09506000134352".into()), diff --git a/crates/dpp-domain/src/schemas/tests.rs b/crates/dpp-domain/src/schemas/tests.rs index c8b9b01..6a7b2a2 100644 --- a/crates/dpp-domain/src/schemas/tests.rs +++ b/crates/dpp-domain/src/schemas/tests.rs @@ -321,6 +321,7 @@ fn validate_textile_v1_1_with_new_fields() { let reg = VersionedSchemaRegistry::new(); let v11: Version = "1.1.0".parse().unwrap(); let data = serde_json::json!({ + "gtin": "09506000134352", "fibreComposition": [ { "fibre": "cotton", "pct": 70.0, "countryOfOrigin": "IN" }, { "fibre": "polyester", "pct": 30.0, "countryOfOrigin": "CN" } @@ -408,6 +409,7 @@ fn conformance_textile_v1_valid() { let reg = VersionedSchemaRegistry::new(); let v: Version = "1.0.0".parse().unwrap(); let data = serde_json::json!({ + "gtin": "09506000134352", "fibreComposition": [{"fibre": "cotton", "pct": 100.0}], "countryOfManufacturing": "MK", "careInstructions": "Machine wash 30°C", diff --git a/crates/dpp-domain/src/schemas/versioned.rs b/crates/dpp-domain/src/schemas/versioned.rs index aa91469..a192594 100644 --- a/crates/dpp-domain/src/schemas/versioned.rs +++ b/crates/dpp-domain/src/schemas/versioned.rs @@ -36,7 +36,7 @@ pub struct VersionedSchemaRegistry { /// `&mut self` mutators evict the affected key. #[cfg(not(target_arch = "wasm32"))] compiled: std::sync::RwLock< - std::collections::HashMap<(String, Version), std::sync::Arc>, + std::collections::HashMap<(String, Version), std::sync::Arc>, >, } @@ -59,7 +59,7 @@ impl VersionedSchemaRegistry { let value: serde_json::Value = serde_json::from_str(json) .map_err(|e| SchemaRegistrationError::InvalidJson(e.to_string()))?; #[cfg(not(target_arch = "wasm32"))] - jsonschema::JSONSchema::compile(&value).map_err(|e| { + jsonschema::validator_for(&value).map_err(|e| { SchemaRegistrationError::InvalidJson(format!("schema does not compile: {e}")) })?; #[cfg(target_arch = "wasm32")] @@ -306,18 +306,17 @@ impl VersionedSchemaRegistry { }], })?; - // Edition 2024 drops the trailing-expression temporary (the borrowing - // error iterator) before `compiled`, so the match can be returned directly. - match compiled.validate(data) { - Ok(()) => Ok(()), - Err(errors) => Err(ValidationErrors { - errors: errors - .map(|e| FieldError { - field: e.instance_path.to_string(), - message: e.to_string(), - }) - .collect(), - }), + let errors: Vec = compiled + .iter_errors(data) + .map(|e| FieldError { + field: e.instance_path().to_string(), + message: e.to_string(), + }) + .collect(); + if errors.is_empty() { + Ok(()) + } else { + Err(ValidationErrors { errors }) } } @@ -328,7 +327,7 @@ impl VersionedSchemaRegistry { &self, sector: &str, version: &Version, - ) -> Option> { + ) -> Option> { let key = (sector.to_owned(), version.clone()); if let Some(cached) = self .compiled @@ -344,7 +343,7 @@ impl VersionedSchemaRegistry { // a future broken schema returns None (→ "no schema found" error) rather // than panicking the process. let value = serde_json::from_str::(json).ok()?; - let compiled = std::sync::Arc::new(jsonschema::JSONSchema::compile(&value).ok()?); + let compiled = std::sync::Arc::new(jsonschema::validator_for(&value).ok()?); self.compiled .write() .expect("schema cache not poisoned") diff --git a/crates/dpp-evidence/Cargo.toml b/crates/dpp-evidence/Cargo.toml deleted file mode 100644 index dde01e0..0000000 --- a/crates/dpp-evidence/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "dpp-evidence" -description = "Evidence dossier format and offline verification for EU DPP — the proof surface of the Odal Node standard" -keywords = ["dpp", "espr", "verification", "offline", "audit-trail"] -categories = ["cryptography", "data-structures"] -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -rust-version.workspace = true - -[lib] -name = "dpp_evidence" -path = "src/lib.rs" - -[dependencies] -dpp-domain = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -serde_jcs = { workspace = true } -chrono = { workspace = true } -base64 = { workspace = true } -hex = { workspace = true } -sha2 = { workspace = true } -ed25519-dalek = { workspace = true } -thiserror = { workspace = true } -# v7+serde+js: v7 generates time-sortable UUIDs (audit entries are ordered); -# js enables getrandom's JS backend so crypto.getRandomValues is used on -# wasm32-unknown-unknown — same declaration as dpp-domain, and the reason this -# crate stays wasm-safe despite `AuditEntry::new` generating an id. -uuid = { version = "1", features = ["v7", "serde", "js"] } diff --git a/crates/dpp-evidence/LICENSE b/crates/dpp-evidence/LICENSE deleted file mode 100644 index 5cebd50..0000000 --- a/crates/dpp-evidence/LICENSE +++ /dev/null @@ -1,190 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by the Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding any notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - Copyright 2024-2026 Aleksandar Temelkov (Odal Node) - - 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. diff --git a/crates/dpp-evidence/README.md b/crates/dpp-evidence/README.md deleted file mode 100644 index 9339ef5..0000000 --- a/crates/dpp-evidence/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# dpp-evidence - -[![crates.io](https://img.shields.io/crates/v/dpp-evidence.svg)](https://crates.io/crates/dpp-evidence) -[![docs.rs](https://img.shields.io/docsrs/dpp-evidence)](https://docs.rs/dpp-evidence) -[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](../../LICENSE) - -Evidence dossier format and offline verification for the [Odal Node](https://odal-node.io) -Digital Product Passport system: a self-contained, signed export of a passport's full proof -chain — JWS signatures, hash-chained audit trail, transfer-chain signatures — verifiable by -anyone with zero trust in the issuing node. - -## When to use this crate - -- You are assembling an evidence dossier for a passport (an engine/platform adapter). -- You are building a verifier — CLI, wasm/browser, or otherwise — that checks a dossier - fully offline. -- You need the audit-trail hash-chain type (`AuditEntry`) or its chain-verification - algorithm independent of any particular storage backend. - -## Example - -```rust -use dpp_evidence::{VerifyMode, verify_dossier_json}; - -let bytes = std::fs::read("dossier.json").unwrap(); -let report = verify_dossier_json(&bytes, VerifyMode::Embedded).unwrap(); - -if report.all_verified() { - println!("VERIFIED — {}", report.trust_anchor_note); -} else { - for check in &report.checks { - println!("{}: {:?}", check.name, check.status); - } -} -``` - -## Relationship to other crates - -| Crate | Role | -|---|---| -| `dpp-domain` | Provides `TransferChain`/`TransferRecord` and passport identifiers — required by this crate | - -## Minimum Rust version - -1.96 (MSRV is enforced in CI) - -## License - -Apache-2.0 — see [LICENSE](../../LICENSE) diff --git a/crates/dpp-evidence/examples/generate_fixture.rs b/crates/dpp-evidence/examples/generate_fixture.rs deleted file mode 100644 index 85f7963..0000000 --- a/crates/dpp-evidence/examples/generate_fixture.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Generates a small, fully valid, deterministically-signed dossier with a -//! transfer chain and EOL event — used as a black-box fixture for external -//! verifiers (e.g. `dpp-engine`'s `odal verify` CLI tests). Run with: -//! -//! cargo run -p dpp-evidence --example generate_fixture > valid-dossier.json - -use std::collections::BTreeMap; - -use base64::Engine; -use chrono::{TimeZone, Utc}; -use dpp_domain::domain::passport::PassportId; -use dpp_domain::domain::transfer::{ - OperatorRole, ResponsibleOperator, TransferChain, TransferReason, TransferRecord, -}; -use dpp_evidence::audit::AuditEntry; -use dpp_evidence::dossier::{DossierManifest, DossierV1, SignedLayer, compute_content_hashes}; -use ed25519_dalek::{Signer, SigningKey}; -use uuid::Uuid; - -fn sign(signing_key: &SigningKey, payload: &serde_json::Value) -> String { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header = b64.encode(serde_json::to_vec(&serde_json::json!({"alg": "EdDSA"})).unwrap()); - let body = b64.encode(serde_jcs::to_vec(payload).unwrap()); - let signing_input = format!("{header}.{body}"); - let sig = signing_key.sign(signing_input.as_bytes()); - format!("{signing_input}.{}", b64.encode(sig.to_bytes())) -} - -fn did_doc_for(signing_key: &SigningKey, did: &str) -> serde_json::Value { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let x = b64.encode(signing_key.verifying_key().to_bytes()); - serde_json::json!({ - "id": did, - "verificationMethod": [{ - "id": format!("{did}#root"), - "type": "JsonWebKey2020", - "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": x }, - }], - "assertionMethod": [format!("{did}#root")], - }) -} - -fn operator(did: &str, name: &str) -> ResponsibleOperator { - ResponsibleOperator { - did: did.to_owned(), - name: name.to_owned(), - role: OperatorRole::Manufacturer, - eu_operator_id: None, - country: "DE".into(), - } -} - -fn chain_entries(mut entries: Vec) -> Vec { - let mut prev = String::new(); - for e in &mut entries { - let h = e.chain_hash(&prev); - e.prev_hash = Some(prev.clone()); - e.entry_hash = Some(h.clone()); - prev = h; - } - entries -} - -fn entry(passport_id: &str, action: &str, ts: chrono::DateTime) -> AuditEntry { - AuditEntry { - id: Uuid::now_v7(), - passport_id: passport_id.to_owned(), - actor: "demo-user".into(), - action: action.into(), - previous_status: None, - new_status: Some("active".into()), - metadata: None, - timestamp: ts, - prev_hash: None, - entry_hash: None, - } -} - -fn main() { - let issuer_key = SigningKey::from_bytes(&[42u8; 32]); - let issuer_did = "did:web:demo.odal-node.io".to_string(); - - let passport_id = "01HXYZDEMO0000000000000001".to_string(); - let full_payload = serde_json::json!({ - "id": passport_id, - "productName": "Demo Battery Pack", - "status": "active", - "sector": "battery", - }); - let public_payload = serde_json::json!({ - "id": passport_id, - "productName": "Demo Battery Pack", - "sector": "battery", - }); - - let now = Utc.with_ymd_and_hms(2026, 7, 1, 12, 0, 0).unwrap(); - let audit_entries = chain_entries(vec![ - entry(&passport_id, "created", now), - entry( - &passport_id, - "published", - now + chrono::Duration::minutes(5), - ), - ]); - - let from_key = SigningKey::from_bytes(&[7u8; 32]); - let to_key = SigningKey::from_bytes(&[8u8; 32]); - let from_did = "did:web:from.example".to_string(); - let to_did = "did:web:to.example".to_string(); - - let mut record = TransferRecord { - transfer_id: Uuid::now_v7(), - passport_id: PassportId::new(), - from_operator: operator(&from_did, "From Operator GmbH"), - to_operator: operator(&to_did, "To Operator SARL"), - reason: TransferReason::Sale, - from_signature: None, - to_signature: None, - initiated_at: now + chrono::Duration::hours(1), - completed_at: Some(now + chrono::Duration::hours(2)), - rejected_at: None, - cancelled_at: None, - notes: Some("demo fixture transfer".into()), - }; - let transfer_payload = record.signing_payload(); - record.from_signature = Some(sign(&from_key, &transfer_payload)); - record.to_signature = Some(sign(&to_key, &transfer_payload)); - - let transfer_chain = TransferChain { - passport_id: record.passport_id, - original_operator: operator(&from_did, "From Operator GmbH"), - transfers: vec![record], - }; - - let mut did_documents = BTreeMap::new(); - did_documents.insert(issuer_did.clone(), did_doc_for(&issuer_key, &issuer_did)); - did_documents.insert(from_did.clone(), did_doc_for(&from_key, &from_did)); - did_documents.insert(to_did.clone(), did_doc_for(&to_key, &to_did)); - - let mut dossier = DossierV1 { - manifest: DossierManifest { - format_version: "1".into(), - passport_id: passport_id.clone(), - issuer_did: issuer_did.clone(), - created_at: now + chrono::Duration::hours(3), - node_version: "0.7.0-demo".into(), - ruleset_version: None, - content_hashes: BTreeMap::new(), - }, - manifest_jws: String::new(), - full_view: SignedLayer { - payload: full_payload.clone(), - jws: sign(&issuer_key, &full_payload), - }, - public_view: SignedLayer { - payload: public_payload.clone(), - jws: sign(&issuer_key, &public_payload), - }, - did_documents, - audit_entries, - transfer_chain: Some(transfer_chain), - eol_event: None, - checkpoint: None, - calc_receipts: Vec::new(), - }; - - dossier.manifest.content_hashes = compute_content_hashes(&dossier); - let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); - dossier.manifest_jws = sign(&issuer_key, &manifest_value); - - println!("{}", serde_json::to_string_pretty(&dossier).unwrap()); -} diff --git a/crates/dpp-evidence/spec/dossier-v1.md b/crates/dpp-evidence/spec/dossier-v1.md deleted file mode 100644 index ef7306d..0000000 --- a/crates/dpp-evidence/spec/dossier-v1.md +++ /dev/null @@ -1,72 +0,0 @@ -# Evidence Dossier — format v1 - -The evidence dossier is a self-contained, signed export of a passport's full proof chain, verifiable offline with zero trust in the issuing node. It is produced by an Odal Node engine (`GET /dpp/{id}/evidence`) and verified by `dpp-evidence`'s `verify_dossier_json` (used by the `odal verify` CLI command in `dpp-engine`, and a future wasm/browser build). - -`format_version` is currently `"1"`. This document describes that version. - -## Members - -| Field | Type | Meaning | -|---|---|---| -| `manifest` | `DossierManifest` | Signed metadata binding every other member into one atomic unit. | -| `manifestJws` | string | Compact EdDSA JWS over the JCS-canonical bytes of `manifest`. | -| `fullView` | `SignedLayer` | The full (non-redacted) passport payload as it was actually signed, plus that signature. | -| `publicView` | `SignedLayer` | The public (redacted) passport payload as it was actually signed, plus that signature. | -| `didDocuments` | map\ | Snapshot of every DID document needed to verify every signature in the dossier — the issuer's own, plus any transfer-chain counterparties'. | -| `auditEntries` | `AuditEntry[]` | The full hash-chained audit trail, oldest first. | -| `transferChain` | `TransferChain?` | Present iff the passport has ever changed responsible operator. | -| `eolEvent` | object? | Present iff the passport was declared end-of-life. | -| `checkpoint` | object? | **Always `null` in v1** — the signed-checkpoint layer is not yet built. | -| `calcReceipts` | array | **Always `[]` in v1** — `dpp-calc` invocation is not yet wired end to end (pending a licensed emission-factor data source). | - -### `DossierManifest` - -| Field | Meaning | -|---|---| -| `formatVersion` | `"1"`. | -| `passportId` | The passport this dossier is for. | -| `issuerDid` | The `did:web` DID that signed `manifest`, `fullView`, and `publicView` — the node operator's own identity. Transfer-chain signatures carry their own signer DIDs on each record instead. | -| `createdAt` | When this dossier was assembled. | -| `nodeVersion` | The engine version that produced it. | -| `rulesetVersion` | The `dpp-calc` ruleset version, when a determination ran. Omitted for passthrough-only passports. | -| `contentHashes` | map\ — see below. | - -### `SignedLayer` - -`{ payload, jws }` — the exact JSON value that was signed, alongside the JWS over it. The dossier embeds this pair directly rather than have a verifier reconstruct "what should have been signed": engine-side transforms (e.g. the full-view payload forces `status` to `"active"`; the public-view payload is redacted) are engine-internal, and reconstructing them independently is both extra surface for the two sides to disagree on and, in practice, was found to be a source of a real bug (see "Why `SignedLayer` embeds the payload" below). - -## Why the manifest hashes every member (`contentHashes`) - -Each member is independently verifiable on its own — its own JWS, or its own hash chain. That is not sufficient by itself: without a binding mechanism, an attacker could mix genuinely-signed-but-stale members from two different exports of the *same* passport — for example, pairing a current, validly-signed `fullView` with an older `auditEntries` array that omits a later suspension event. Each individual signature would still verify. - -`manifest.contentHashes` is a map from member name to the hex SHA-256 of that member's JCS-canonical bytes. Because the manifest itself is signed (`manifestJws`), tampering with any member's content — even by swapping in another genuinely-signed artifact — is caught: the recomputed hash won't match what the signed manifest commits to. - -## Why `SignedLayer` embeds the payload - -An earlier version of this format had the verifier reconstruct the full/public view payloads from the passport record itself. This was found to be unreliable: `jws_signature`/`public_jws_signature` are frozen at publish time, but a passport's `status` (and other fields) mutate afterward on suspend/archive/end-of-life — those transitions never re-sign. A verifier reconstructing "the current record" would produce a payload that no longer matches what was actually signed, and falsely report tamper on a perfectly legitimate, unmodified signature. Embedding the exact signed payload sidesteps this: verification only has to confirm the signature covers *this* payload, never derive what the payload should be. - -## Trust model - -- **What a green report proves**: no member was altered after export; every signature covers exactly the payload it's paired with; the audit trail's hash chain is unbroken; every present transfer-chain signature is valid; the whole bundle is atomically bound (no mix-and-match). -- **Embedded mode** (`VerifyMode::Embedded`, the default) anchors trust to the DID documents embedded in the dossier at export time. This is honest, not exhaustive: it does not independently confirm those DID documents are still the operator's *real*, current keys. The verifier prints "trust anchored to embedded key snapshot dated ``" for exactly this reason. -- **Online mode** (`VerifyMode::Online`, native CLI only) re-fetches every DID document live over `did:web` resolution before verifying, closing that gap at the cost of requiring network access. -- **What it does not prove, in either mode**: that the *issuing* operator didn't rewrite their own audit history before exporting. An operator who regenerates their entire hash chain from genesis with altered content produces a dossier that is internally, perfectly self-consistent. Closing this requires a third party to have independently observed the chain head at an earlier point in time — the signed-checkpoint layer (`checkpoint`, currently always `null`) exists for exactly this and is not yet built. Anyone relying on a dossier for a high-stakes claim should know the honest line: *tamper-evident against everyone except the issuer, until checkpoints ship.* - -## Strictness - -Every dossier-owned type (`DossierV1`, `DossierManifest`, `SignedLayer`, `AuditEntry`) rejects unknown fields at deserialization. An unrecognised field anywhere in one of these types is treated as malformed input (verifier exit code 2), not silently ignored — it may mean the dossier was produced by a newer format version this verifier doesn't understand, and a verifier must never silently pass over content it didn't check. - -This alone is not sufficient: `didDocuments`, `checkpoint`, and `calcReceipts` are untyped JSON by design (forward-compatible placeholders), and `transferChain` embeds `dpp-domain` types (`TransferChain`, `TransferRecord`, `ResponsibleOperator`) that are *not* strict, because they are core domain types shared with contexts where tolerance is correct. An unknown field nested inside one of those would parse successfully and be silently dropped by serde — and because content hashes are computed from the *parsed* structure, the dropped content would not be reflected in a hash mismatch either. - -`verify_dossier_json` closes this gap with a 9th check, **`input_fidelity`**: it compares the canonical (JCS) bytes of the raw input against the canonical bytes of the parsed dossier, re-serialized. Any content lost anywhere in the tree — not just in the strict types — fails this check. - -**Corollary rule: optional fields are omitted, never emitted as explicit `null`.** Every optional field in this format uses `skip_serializing_if`, so a value that's genuinely absent doesn't appear in the JSON at all. This is what makes the fidelity round-trip exact — an explicit `"field": null` versus an omitted field would otherwise be indistinguishable content that still produces different canonical bytes. - -## Versioning - -A breaking change to this format bumps `format_version`. A verifier that doesn't recognise a `format_version` (or, per the strictness rule above, encounters any field it doesn't understand) must refuse the dossier as malformed rather than attempt a best-effort partial verification. An honest refusal ("this dossier may require a newer verifier") is always the correct failure mode — never a false green. - -## See also - -- [`dossier-v1.schema.json`](dossier-v1.schema.json) — machine-readable JSON Schema for this format. -- `dpp-evidence/src/verify/engine.rs` — the reference implementation of every check described above. diff --git a/crates/dpp-evidence/spec/dossier-v1.schema.json b/crates/dpp-evidence/spec/dossier-v1.schema.json deleted file mode 100644 index d9b2ceb..0000000 --- a/crates/dpp-evidence/spec/dossier-v1.schema.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://schema.odal-node.io/evidence/dossier-v1.schema.json", - "title": "Odal Node Evidence Dossier v1", - "description": "A self-contained, signed export of a passport's full proof chain — see dossier-v1.md for the full specification, trust model, and rationale.", - "type": "object", - "additionalProperties": false, - "required": ["manifest", "manifestJws", "fullView", "publicView", "didDocuments", "auditEntries"], - "properties": { - "manifest": { "$ref": "#/$defs/dossierManifest" }, - "manifestJws": { "$ref": "#/$defs/compactJws" }, - "fullView": { "$ref": "#/$defs/signedLayer" }, - "publicView": { "$ref": "#/$defs/signedLayer" }, - "didDocuments": { - "type": "object", - "description": "Keyed by DID. Always contains at least manifest.issuerDid.", - "additionalProperties": { "type": "object" } - }, - "auditEntries": { - "type": "array", - "description": "The full hash-chained audit trail, ascending timestamp order.", - "items": { "$ref": "#/$defs/auditEntry" } - }, - "transferChain": { - "description": "Present iff the passport has ever changed responsible operator. A dpp-domain core type — not strict here; see dossier-v1.md's Strictness section on why input_fidelity exists.", - "type": ["object", "null"] - }, - "eolEvent": { - "description": "Present iff the passport was declared end-of-life.", - "type": ["object", "null"] - }, - "checkpoint": { - "description": "Always null in v1 — the signed-checkpoint layer is not yet built. Present as a field for forward compatibility.", - "type": "null" - }, - "calcReceipts": { - "description": "Always empty in v1 — dpp-calc invocation is not yet wired end to end.", - "type": "array", - "maxItems": 0 - } - }, - "$defs": { - "compactJws": { - "type": "string", - "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$", - "description": "Compact EdDSA JWS: base64url(header).base64url(JCS-canonical payload).base64url(signature)." - }, - "dossierManifest": { - "type": "object", - "additionalProperties": false, - "required": ["formatVersion", "passportId", "issuerDid", "createdAt", "nodeVersion", "contentHashes"], - "properties": { - "formatVersion": { "const": "1" }, - "passportId": { "type": "string" }, - "issuerDid": { "type": "string", "pattern": "^did:web:" }, - "createdAt": { "type": "string", "format": "date-time" }, - "nodeVersion": { "type": "string" }, - "rulesetVersion": { "type": "string" }, - "contentHashes": { - "type": "object", - "additionalProperties": { "type": "string", "pattern": "^[0-9a-f]{64}$" } - } - } - }, - "signedLayer": { - "type": "object", - "additionalProperties": false, - "required": ["payload", "jws"], - "properties": { - "payload": { "type": "object" }, - "jws": { "$ref": "#/$defs/compactJws" } - } - }, - "auditEntry": { - "type": "object", - "additionalProperties": false, - "required": ["id", "passportId", "actor", "action", "timestamp"], - "properties": { - "id": { "type": "string", "format": "uuid" }, - "passportId": { "type": "string" }, - "actor": { "type": "string" }, - "action": { "type": "string" }, - "previousStatus": { "type": ["string", "null"] }, - "newStatus": { "type": ["string", "null"] }, - "metadata": { "type": ["object", "null"] }, - "timestamp": { "type": "string", "format": "date-time" }, - "prevHash": { "type": "string" }, - "entryHash": { "type": "string" } - } - } - } -} diff --git a/crates/dpp-evidence/src/audit/entry.rs b/crates/dpp-evidence/src/audit/entry.rs deleted file mode 100644 index 9933835..0000000 --- a/crates/dpp-evidence/src/audit/entry.rs +++ /dev/null @@ -1,105 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use uuid::Uuid; - -/// The `prev_hash` of the first (genesis) entry in a passport's chain. -pub const GENESIS_PREV_HASH: &str = ""; - -/// A single immutable audit record for a passport state change. -/// -/// Entries are append-only at the storage layer (the engine's Postgres -/// schema raises on any `UPDATE`/`DELETE`), making the trail tamper-evident -/// independent of this crate. `#[serde(deny_unknown_fields)]` — an unknown -/// field here must fail loudly, not silently vanish from an otherwise-valid -/// content hash. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AuditEntry { - /// Unique identifier for this audit record. - pub id: Uuid, - /// The passport this entry is for (stringified UUID for forward-compat). - pub passport_id: String, - /// Who triggered this change. The engine stamps this from `AuthContext`; - /// this crate only needs the resulting string. - pub actor: String, - /// Machine-readable action code, e.g. `"create"`, `"publish"`, `"archive"`. - pub action: String, - /// Passport status before the transition, if applicable. - pub previous_status: Option, - /// Passport status after the transition, if applicable. - pub new_status: Option, - /// Optional structured metadata (e.g. field diffs, a stamped EOL event, - /// or — for a `"published"` entry — the exact payloads that were signed; - /// see `dpp-vault`'s `publish.rs`/`evidence.rs`). - pub metadata: Option, - /// Wall-clock timestamp of the operation (UUIDv7 source; sub-millisecond ordered). - pub timestamp: DateTime, - /// Hash-chain link to the previous entry in this passport's chain. - /// `""`/`None` for the genesis entry. Set by the storage layer on append. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prev_hash: Option, - /// SHA-256 (hex) over the JCS-canonicalised content of this entry folded - /// with `prev_hash` — the chain link the next entry points back to. Set - /// by the storage layer on append; `None` on an in-memory entry not yet - /// persisted. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub entry_hash: Option, -} - -impl AuditEntry { - /// Construct an audit entry from an action and its actor. - /// - /// `id` is a new UUIDv7 so entries are time-ordered within a passport. - /// Takes a plain actor string — the engine's `AuthContext`-aware call - /// sites pass `&auth.user_id` (single-tenant: no operator scope to - /// include, DECISION-0002). - pub fn new( - passport_id: &str, - action: &str, - actor: impl Into, - previous_status: Option<&str>, - new_status: Option<&str>, - ) -> Self { - Self { - id: Uuid::now_v7(), - passport_id: passport_id.to_owned(), - actor: actor.into(), - action: action.to_owned(), - previous_status: previous_status.map(|s| s.to_owned()), - new_status: new_status.map(|s| s.to_owned()), - metadata: None, - timestamp: Utc::now(), - prev_hash: None, - entry_hash: None, - } - } - - /// Attach structured metadata to this entry (builder-style). - pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self { - self.metadata = Some(metadata); - self - } - - /// The chain hash for this entry given its predecessor's hash: SHA-256 (hex) - /// over the JCS-canonicalised content **and** `prev_hash`. Excludes the - /// `prev_hash`/`entry_hash` columns themselves (prev is folded in as - /// `prevHash`). Deterministic — the same content + prev always hashes equal. - #[must_use] - pub fn chain_hash(&self, prev_hash: &str) -> String { - let canonical = serde_json::json!({ - "id": self.id, - "passportId": self.passport_id, - "actor": self.actor, - "action": self.action, - "previousStatus": self.previous_status, - "newStatus": self.new_status, - "metadata": self.metadata, - "timestamp": self.timestamp, - "prevHash": prev_hash, - }); - let bytes = serde_jcs::to_vec(&canonical) - .expect("JCS canonicalisation of audit content is infallible"); - hex::encode(Sha256::digest(&bytes)) - } -} diff --git a/crates/dpp-evidence/src/audit/mod.rs b/crates/dpp-evidence/src/audit/mod.rs deleted file mode 100644 index b038bdb..0000000 --- a/crates/dpp-evidence/src/audit/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Audit trail wire type and hash-chain verification. -//! -//! Promoted from `dpp-engine`'s `dpp-types::audit` (2026-07-07) — that -//! module's own doc comment flagged this shape as a "core-candidate": the -//! hash-chain format is third-party-verifiable, making it part of the -//! proof-bound standard rather than engine plumbing. `dpp-types::audit` now -//! re-exports [`AuditEntry`] from here and keeps only what is legitimately -//! engine-side: the `AuditRepository` persistence port and the -//! `AuthContext`-aware constructor convenience. - -mod entry; -mod verify; - -#[cfg(test)] -mod tests; - -pub use entry::{AuditEntry, GENESIS_PREV_HASH}; -pub use verify::{AuditChainBreak, verify_audit_chain}; diff --git a/crates/dpp-evidence/src/audit/tests.rs b/crates/dpp-evidence/src/audit/tests.rs deleted file mode 100644 index f7f535d..0000000 --- a/crates/dpp-evidence/src/audit/tests.rs +++ /dev/null @@ -1,77 +0,0 @@ -use super::*; -use chrono::Utc; -use uuid::Uuid; - -fn entry(action: &str) -> AuditEntry { - AuditEntry { - id: Uuid::now_v7(), - passport_id: "p1".into(), - actor: "actor".into(), - action: action.into(), - previous_status: None, - new_status: None, - metadata: None, - timestamp: Utc::now(), - prev_hash: None, - entry_hash: None, - } -} - -/// Link a slice into a chain exactly as the engine's storage layer does on append. -fn chain(entries: &mut [AuditEntry]) { - let mut prev = GENESIS_PREV_HASH.to_owned(); - for e in entries.iter_mut() { - let h = e.chain_hash(&prev); - e.prev_hash = Some(prev.clone()); - e.entry_hash = Some(h.clone()); - prev = h; - } -} - -#[test] -fn chain_hash_is_deterministic_and_prev_sensitive() { - let e = entry("created"); - assert_eq!(e.chain_hash(""), e.chain_hash("")); - assert_ne!(e.chain_hash(""), e.chain_hash("deadbeef")); -} - -#[test] -fn new_builds_from_a_plain_actor_string() { - let e = AuditEntry::new("p1", "created", "user-123", None, Some("draft")); - assert_eq!(e.actor, "user-123"); - assert_eq!(e.action, "created"); - assert_eq!(e.new_status.as_deref(), Some("draft")); -} - -#[test] -fn intact_chain_verifies() { - let mut es = [entry("created"), entry("published"), entry("suspended")]; - chain(&mut es); - assert!(verify_audit_chain(&es).is_ok()); -} - -#[test] -fn tampered_content_breaks_at_exact_index() { - let mut es = [entry("created"), entry("published"), entry("archived")]; - chain(&mut es); - es[1].new_status = Some("suspended".into()); // flip content, keep stored hash - let brk = verify_audit_chain(&es).expect_err("tamper must be detected"); - assert_eq!(brk.index, 1); - assert!(brk.reason.contains("tampered")); -} - -#[test] -fn broken_prev_link_detected() { - let mut es = [entry("created"), entry("published")]; - chain(&mut es); - es[1].prev_hash = Some("0000".into()); - assert_eq!(verify_audit_chain(&es).expect_err("break").index, 1); -} - -#[test] -fn unknown_field_is_rejected_at_deserialize() { - let mut value = serde_json::to_value(entry("created")).unwrap(); - value["notARealField"] = serde_json::json!("sneaky"); - let result: Result = serde_json::from_value(value); - assert!(result.is_err(), "unknown field must fail to deserialize"); -} diff --git a/crates/dpp-evidence/src/audit/verify.rs b/crates/dpp-evidence/src/audit/verify.rs deleted file mode 100644 index 0f61246..0000000 --- a/crates/dpp-evidence/src/audit/verify.rs +++ /dev/null @@ -1,52 +0,0 @@ -use super::entry::{AuditEntry, GENESIS_PREV_HASH}; - -/// The first broken link found while verifying a passport's audit chain. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuditChainBreak { - /// 0-based position of the offending entry in the ordered chain. - pub index: usize, - /// The passport whose chain broke. - pub passport_id: String, - /// Human-readable reason (prev-link mismatch vs. content tamper). - pub reason: String, -} - -/// Verify a passport's audit entries form an intact hash chain. -/// -/// `entries` must be in chain order (ascending timestamp). Returns the first -/// break: either a `prev_hash` that doesn't point at the prior entry, or an -/// `entry_hash` that doesn't match the entry's recomputed content hash (a -/// tampered row). `Ok(())` means every link verifies. -/// -/// This detects any tamper that does not re-hash the *entire forward chain*; -/// pinning the head with a signed checkpoint is what makes a full re-hash -/// detectable by a third party without access to the issuing node. -/// -/// # Errors -/// [`AuditChainBreak`] at the first inconsistent entry. -pub fn verify_audit_chain(entries: &[AuditEntry]) -> Result<(), AuditChainBreak> { - let mut expected_prev = GENESIS_PREV_HASH.to_owned(); - for (index, entry) in entries.iter().enumerate() { - let stored_prev = entry.prev_hash.as_deref().unwrap_or(GENESIS_PREV_HASH); - if stored_prev != expected_prev { - return Err(AuditChainBreak { - index, - passport_id: entry.passport_id.clone(), - reason: format!( - "prev_hash link broken: stored {stored_prev:?}, expected {expected_prev:?}" - ), - }); - } - let recomputed = entry.chain_hash(&expected_prev); - let stored_hash = entry.entry_hash.as_deref().unwrap_or(""); - if stored_hash != recomputed { - return Err(AuditChainBreak { - index, - passport_id: entry.passport_id.clone(), - reason: "entry_hash mismatch — content tampered".to_owned(), - }); - } - expected_prev = recomputed; - } - Ok(()) -} diff --git a/crates/dpp-evidence/src/dossier/hash.rs b/crates/dpp-evidence/src/dossier/hash.rs deleted file mode 100644 index a38b5d3..0000000 --- a/crates/dpp-evidence/src/dossier/hash.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::collections::BTreeMap; - -use sha2::{Digest, Sha256}; - -use super::types::DossierV1; - -/// Canonical SHA-256 (hex) of a JSON value (RFC 8785 / JCS bytes). -/// -/// Exposed so the assembler builds the exact same hash a verifier will -/// later recompute and check against — one hash function, two call sites, -/// mirroring `dpp-rules::bundle::verify::content_hash`. -#[must_use] -pub fn content_hash(value: &serde_json::Value) -> String { - let bytes = serde_jcs::to_vec(value).expect("JCS canonicalisation is infallible"); - hex::encode(Sha256::digest(&bytes)) -} - -/// Compute the `content_hashes` map for a dossier's members, in the shape -/// [`super::DossierManifest::content_hashes`] expects. Both the assembler (to -/// build the manifest before signing) and the verifier (to recompute and -/// compare) call this on the same dossier shape, so the two can never drift. -#[must_use] -pub fn compute_content_hashes(dossier: &DossierV1) -> BTreeMap { - let mut hashes = BTreeMap::new(); - hashes.insert( - "fullView".to_string(), - content_hash(&dossier.full_view.payload), - ); - hashes.insert( - "publicView".to_string(), - content_hash(&dossier.public_view.payload), - ); - hashes.insert( - "auditEntries".to_string(), - content_hash( - &serde_json::to_value(&dossier.audit_entries).expect("audit entries serialise"), - ), - ); - if let Some(chain) = &dossier.transfer_chain { - hashes.insert( - "transferChain".to_string(), - content_hash(&serde_json::to_value(chain).expect("TransferChain serialises")), - ); - } - if let Some(eol) = &dossier.eol_event { - hashes.insert("eolEvent".to_string(), content_hash(eol)); - } - hashes -} diff --git a/crates/dpp-evidence/src/dossier/mod.rs b/crates/dpp-evidence/src/dossier/mod.rs deleted file mode 100644 index 92a0a46..0000000 --- a/crates/dpp-evidence/src/dossier/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! The evidence dossier wire format — defined once, here, and consumed by -//! both the offline verifier and the engine-side exporter as a dependency. - -mod hash; -mod types; - -pub use hash::{compute_content_hashes, content_hash}; -pub use types::{DossierManifest, DossierV1, SignedLayer}; diff --git a/crates/dpp-evidence/src/dossier/types.rs b/crates/dpp-evidence/src/dossier/types.rs deleted file mode 100644 index f8a45c2..0000000 --- a/crates/dpp-evidence/src/dossier/types.rs +++ /dev/null @@ -1,84 +0,0 @@ -use std::collections::BTreeMap; - -use chrono::{DateTime, Utc}; -use dpp_domain::domain::transfer::TransferChain; -use serde::{Deserialize, Serialize}; - -use crate::audit::AuditEntry; - -/// A JWS alongside the exact JSON payload it was signed over. -/// -/// The engine-side signer applies transforms before signing (e.g. the -/// full-view payload forces `status` to `"active"`; the public-view payload -/// is a redacted projection). Rather than have this crate reimplement those -/// engine-internal transforms to reconstruct what *should* have been signed, -/// the dossier assembler — which already has that exact value in hand — -/// embeds it directly. Verification then only has to confirm the signature -/// covers *this* payload, not derive the payload itself. -/// -/// `deny_unknown_fields`: an unrecognised member here fails deserialization -/// (exit 2 / malformed) rather than being silently dropped and still -/// verifying green — see [`crate::verify::verify_dossier_json`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct SignedLayer { - pub payload: serde_json::Value, - pub jws: String, -} - -/// Signed description of a dossier — the manifest JWS payload. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct DossierManifest { - /// Dossier wire format version, `"1"`. - pub format_version: String, - pub passport_id: String, - /// The DID that signed the manifest, `full_view`, and `public_view` — - /// the node operator's own identity. Transfer-chain signatures carry - /// their own signer DIDs on each record instead. - pub issuer_did: String, - pub created_at: DateTime, - pub node_version: String, - /// The `dpp-calc` ruleset version, when a determination ran. `None` for - /// passthrough-only passports. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ruleset_version: Option, - /// member name -> hex SHA-256 over the JCS-canonicalised member content. - /// Binds every dossier member into one atomic, tamper-evident unit — an - /// attacker cannot swap in a genuinely-signed-but-stale member (e.g. an - /// older audit trail that omits a later suspend event) without the - /// manifest's own signature catching the mismatch. - pub content_hashes: BTreeMap, -} - -/// A complete evidence dossier: everything needed to verify a passport's -/// full proof chain offline, with zero trust in the issuing node. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct DossierV1 { - pub manifest: DossierManifest, - pub manifest_jws: String, - pub full_view: SignedLayer, - pub public_view: SignedLayer, - /// DID document snapshots, keyed by DID. Always contains at least - /// `manifest.issuer_did`; may contain other operators' DIDs when a - /// transfer chain is present. - pub did_documents: BTreeMap, - /// Ordered ascending by timestamp (chain order). - pub audit_entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub transfer_chain: Option, - /// Present iff the passport was deactivated (End-of-Life declared). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub eol_event: Option, - /// Always `None` in v1 — the signed-checkpoint layer is not yet built. - /// Present as a field (not omitted) so the format doesn't need a - /// breaking version bump when checkpoints ship. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub checkpoint: Option, - /// Always empty in v1 — `dpp-calc` invocation is not yet wired end to - /// end (see the roadmap note on licensed factor data). Present as a - /// field for the same forward-compatibility reason as `checkpoint`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub calc_receipts: Vec, -} diff --git a/crates/dpp-evidence/src/jws/mod.rs b/crates/dpp-evidence/src/jws/mod.rs deleted file mode 100644 index 3c450ca..0000000 --- a/crates/dpp-evidence/src/jws/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! JWS compact serialisation verification — EdDSA/Ed25519, algorithm-pinned. -//! -//! Vendored from `dpp-crypto/src/jws/verifier.rs` rather than depended on -//! directly: `dpp-crypto` pulls in `rand`/`argon2`/`aes-gcm` for its keystore -//! and encryption paths, which break the `wasm32-unknown-unknown` build this -//! crate targets. Verification itself needs none of that — only Ed25519 -//! signature checking, which is reproduced verbatim in this module. If -//! `dpp-crypto`'s verifier ever changes, mirror the change here too — the -//! cross-verification tests in `dpp-tests/tests/jws_cross_verification.rs` -//! exist to catch drift between the two implementations. - -mod verify; - -#[cfg(test)] -mod tests; - -pub use verify::{ - VerifyError, decode_payload_bytes, extract_key_by_fingerprint, extract_kid_from_jws, - extract_primary_public_key, resolve_public_key, verify_jws, verify_jws_content, -}; diff --git a/crates/dpp-evidence/src/jws/tests.rs b/crates/dpp-evidence/src/jws/tests.rs deleted file mode 100644 index e0e73e3..0000000 --- a/crates/dpp-evidence/src/jws/tests.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Unit tests for the vendored JWS verifier. - -use super::*; -use base64::Engine; -use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; - -fn sign_compact( - signing_key: &SigningKey, - header: &serde_json::Value, - payload: &serde_json::Value, -) -> String { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header_b64 = b64.encode(serde_json::to_vec(header).unwrap()); - let payload_b64 = b64.encode(serde_json::to_vec(payload).unwrap()); - let signing_input = format!("{header_b64}.{payload_b64}"); - let sig = signing_key.sign(signing_input.as_bytes()); - format!("{signing_input}.{}", b64.encode(sig.to_bytes())) -} - -fn did_doc_for(verifying_key: &VerifyingKey) -> serde_json::Value { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let x = b64.encode(verifying_key.to_bytes()); - serde_json::json!({ - "verificationMethod": [{ - "id": "did:web:example.com#root", - "type": "JsonWebKey2020", - "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": x }, - }], - "assertionMethod": ["did:web:example.com#root"], - }) -} - -#[test] -fn valid_signature_verifies() { - let signing_key = SigningKey::from_bytes(&[7u8; 32]); - let header = serde_json::json!({"alg": "EdDSA", "typ": "JWT"}); - let payload = serde_json::json!({"hello": "world"}); - let jws = sign_compact(&signing_key, &header, &payload); - let did_doc = did_doc_for(&signing_key.verifying_key()); - let key = extract_primary_public_key(&did_doc).unwrap(); - assert!(verify_jws(&jws, &key).unwrap()); -} - -#[test] -fn tampered_payload_fails() { - let signing_key = SigningKey::from_bytes(&[7u8; 32]); - let header = serde_json::json!({"alg": "EdDSA", "typ": "JWT"}); - let payload = serde_json::json!({"hello": "world"}); - let jws = sign_compact(&signing_key, &header, &payload); - let did_doc = did_doc_for(&signing_key.verifying_key()); - let key = extract_primary_public_key(&did_doc).unwrap(); - - // Flip one byte in the payload segment. - let parts: Vec<&str> = jws.splitn(3, '.').collect(); - let tampered = format!("{}.{}x.{}", parts[0], parts[1], parts[2]); - assert!(!verify_jws(&tampered, &key).unwrap()); -} - -#[test] -fn alg_none_is_rejected() { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header = b64.encode(serde_json::to_vec(&serde_json::json!({"alg": "none"})).unwrap()); - let payload = b64.encode(serde_json::to_vec(&serde_json::json!({"a": 1})).unwrap()); - let fake_jws = format!("{header}.{payload}."); - assert!(!verify_jws(&fake_jws, "AAAA").unwrap()); -} diff --git a/crates/dpp-evidence/src/jws/verify.rs b/crates/dpp-evidence/src/jws/verify.rs deleted file mode 100644 index 87d231a..0000000 --- a/crates/dpp-evidence/src/jws/verify.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! The vendored EdDSA verifier and DID-document key-extraction helpers. - -use base64::Engine; -use ed25519_dalek::{Signature, VerifyingKey}; -use sha2::{Digest, Sha256}; - -/// Verify an EdDSA compact JWS given a base64url-encoded public key. -/// -/// Returns `Ok(true)` when the signature is valid, `Ok(false)` when it is not. -/// Returns `Err` only on malformed input (bad base64, wrong key/sig length). -pub fn verify_jws(jws: &str, public_key_b64: &str) -> Result { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - - let parts: Vec<&str> = jws.splitn(3, '.').collect(); - if parts.len() != 3 { - return Ok(false); - } - - // Pin the algorithm: only EdDSA is accepted. Reject `alg:none` and any other - // algorithm in the header to prevent algorithm-substitution downgrade. - if !header_alg_is_eddsa(&b64, parts[0]) { - return Ok(false); - } - - let signing_input = format!("{}.{}", parts[0], parts[1]); - - let sig_bytes = b64 - .decode(parts[2]) - .map_err(|e| VerifyError::Malformed(format!("base64 signature: {e}")))?; - let sig_arr: [u8; 64] = sig_bytes - .as_slice() - .try_into() - .map_err(|_| VerifyError::Malformed("Ed25519 signature must be 64 bytes".into()))?; - let signature = Signature::from_bytes(&sig_arr); - - let key_bytes = b64 - .decode(public_key_b64) - .map_err(|e| VerifyError::Malformed(format!("base64 public key: {e}")))?; - let key_arr: [u8; 32] = key_bytes - .as_slice() - .try_into() - .map_err(|_| VerifyError::Malformed("Ed25519 public key must be 32 bytes".into()))?; - let verifying_key = VerifyingKey::from_bytes(&key_arr) - .map_err(|e| VerifyError::Malformed(format!("invalid key: {e}")))?; - - // Strict verification (RFC 8032 §8): rejects the signature-malleability / - // small-order/cofactor edge cases that the non-strict `verify` admits — - // undesirable when the signature is the trust anchor. - Ok(verifying_key - .verify_strict(signing_input.as_bytes(), &signature) - .is_ok()) -} - -/// Error from a malformed JWS or key — distinct from "signature did not verify", -/// which is a plain `Ok(false)`. -#[derive(Debug, thiserror::Error)] -pub enum VerifyError { - #[error("malformed input: {0}")] - Malformed(String), -} - -/// Extract the base64url-encoded `x` from a JWK, but only if it is a genuine -/// Ed25519 key (`kty:"OKP"`, `crv:"Ed25519"`). Returns `None` for any other -/// key type/curve so a malformed or wrong-curve JWK can't be mis-selected. -fn jwk_ed25519_x(jwk: &serde_json::Value) -> Option { - if jwk.get("kty")?.as_str()? != "OKP" { - return None; - } - if jwk.get("crv")?.as_str()? != "Ed25519" { - return None; - } - jwk.get("x")?.as_str().map(String::from) -} - -/// IDs the DID document authorizes via `assertionMethod` — the verification -/// relationship that permits signing credentials/passports. -fn assertion_method_ids(did_document: &serde_json::Value) -> Vec { - did_document - .get("assertionMethod") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|e| e.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default() -} - -/// Whether a verification-method entry is referenced by `assertionMethod`. -fn vm_is_assertion_authorized(vm: &serde_json::Value, authorized: &[String]) -> bool { - vm.get("id") - .and_then(|v| v.as_str()) - .is_some_and(|id| authorized.iter().any(|a| a == id)) -} - -/// Decode the JWS protected header and confirm `alg == "EdDSA"`. -fn header_alg_is_eddsa( - b64: &base64::engine::general_purpose::GeneralPurpose, - header_b64: &str, -) -> bool { - b64.decode(header_b64) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .and_then(|h| h.get("alg").and_then(|v| v.as_str()).map(str::to_owned)) - .is_some_and(|alg| alg == "EdDSA") -} - -/// Extract the base64url-encoded primary Ed25519 public key (`x`) from a DID document. -/// -/// Looks at `verificationMethod[0].publicKeyJwk.x`. -pub fn extract_primary_public_key(did_document: &serde_json::Value) -> Option { - let authorized = assertion_method_ids(did_document); - did_document["verificationMethod"] - .as_array()? - .iter() - .find_map(|vm| { - if vm_is_assertion_authorized(vm, &authorized) { - jwk_ed25519_x(vm.get("publicKeyJwk")?) - } else { - None - } - }) -} - -/// Extract the `kid` field from the JWS protected header. -/// -/// Returns `None` if the JWS is malformed or the header contains no `kid`. -pub fn extract_kid_from_jws(jws: &str) -> Option { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header_b64 = jws.split('.').next()?; - let header_bytes = b64.decode(header_b64).ok()?; - let header: serde_json::Value = serde_json::from_slice(&header_bytes).ok()?; - header.get("kid")?.as_str().map(String::from) -} - -/// Find the base64url-encoded Ed25519 public key (`x`) in a DID document -/// whose SHA-256 fingerprint (hex) matches `kid`. -/// -/// The `kid` embedded in the JWS protected header is -/// `hex::encode(Sha256::digest(verifying_key_bytes))`. This iterates all -/// `verificationMethod` entries and returns the `x` value of the first one -/// whose decoded public key produces the same fingerprint — allowing -/// verification against any rotation-archived key. -pub fn extract_key_by_fingerprint(did_document: &serde_json::Value, kid: &str) -> Option { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let authorized = assertion_method_ids(did_document); - did_document["verificationMethod"] - .as_array()? - .iter() - .find_map(|vm| { - if !vm_is_assertion_authorized(vm, &authorized) { - return None; - } - let x = jwk_ed25519_x(vm.get("publicKeyJwk")?)?; - let raw = b64.decode(&x).ok()?; - if hex::encode(Sha256::digest(&raw)) == kid { - Some(x) - } else { - None - } - }) -} - -/// Resolve the public key to verify `jws` against, from a DID document: try -/// the `kid`-fingerprint match first (supports rotation-archived keys), then -/// fall back to the primary key for JWS tokens signed before `kid` was added. -pub fn resolve_public_key(jws: &str, did_document: &serde_json::Value) -> Option { - if let Some(kid) = extract_kid_from_jws(jws) - && let Some(key) = extract_key_by_fingerprint(did_document, &kid) - { - return Some(key); - } - extract_primary_public_key(did_document) -} - -/// Decode the payload segment of a compact JWS to raw bytes (post-base64, -/// pre-JSON-parse). -pub fn decode_payload_bytes(jws: &str) -> Result, VerifyError> { - let payload_b64 = jws - .split('.') - .nth(1) - .ok_or_else(|| VerifyError::Malformed("JWS has no payload segment".into()))?; - base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .map_err(|e| VerifyError::Malformed(format!("payload base64: {e}"))) -} - -/// Verify both that `jws` is validly signed under `public_key_b64` **and** -/// that its embedded payload is exactly the JCS-canonical bytes of -/// `expected`. -/// -/// The plain [`verify_jws`] only checks the signature is internally -/// consistent — it says nothing about *what* was signed. Every signer in -/// this system embeds `base64url(JCS(payload))` as the payload segment -/// (`dpp-crypto/src/jws/signer.rs`), so content-binding means recomputing -/// those same canonical bytes and comparing. Without this step a validly -/// signed JWS over the *wrong* content would incorrectly verify. -pub fn verify_jws_content( - jws: &str, - public_key_b64: &str, - expected: &serde_json::Value, -) -> Result { - if !verify_jws(jws, public_key_b64)? { - return Ok(false); - } - let actual = decode_payload_bytes(jws)?; - let expected_bytes = serde_jcs::to_vec(expected) - .map_err(|e| VerifyError::Malformed(format!("JCS canonicalisation: {e}")))?; - Ok(actual == expected_bytes) -} diff --git a/crates/dpp-evidence/src/lib.rs b/crates/dpp-evidence/src/lib.rs deleted file mode 100644 index caf56ec..0000000 --- a/crates/dpp-evidence/src/lib.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Evidence dossier format and offline verification for EU DPP — the proof -//! surface of the Odal Node standard. -//! -//! A dossier ([`DossierV1`]) is a self-contained, signed export of a -//! passport's full proof chain — JWS signatures, hash-chained audit trail, -//! transfer-chain signatures — verifiable by anyone with zero trust in the -//! issuing node. This crate defines the wire format once and provides the -//! verification engine both the engine-side exporter (`dpp-vault`) and the -//! `odal verify` CLI command (`dpp-engine`) depend on. -//! -//! Deliberately free of any BSL-licensed or wasm-unsafe dependency — see -//! [`jws`]'s module doc for what is vendored from `dpp-crypto` (and why) and -//! [`audit`]'s module doc for what was promoted from `dpp-engine`'s -//! `dpp-types` crate. -//! -//! See `spec/dossier-v1.md` for the full format specification. - -pub mod audit; -pub mod dossier; -pub mod jws; -pub mod verify; - -pub use audit::AuditEntry; -pub use dossier::{DossierManifest, DossierV1, SignedLayer, compute_content_hashes, content_hash}; -pub use verify::{ - CheckResult, CheckStatus, DossierParseError, VerificationReport, VerifyMode, did_web_url, - verify_dossier, verify_dossier_json, -}; diff --git a/crates/dpp-evidence/src/verify/did_web.rs b/crates/dpp-evidence/src/verify/did_web.rs deleted file mode 100644 index f5b3856..0000000 --- a/crates/dpp-evidence/src/verify/did_web.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! `did:web` DID-to-URL resolution (W3C did:web method spec), shared by the -//! CLI's `--online` mode and the engine-side evidence exporter's -//! transfer-chain counterparty lookup. Pure string transform — no I/O here, -//! callers do their own HTTP fetch. - -/// Resolve a `did:web` DID to the URL its document is published at. -/// -/// `did:web:example.com` -> `https://example.com/.well-known/did.json`. -/// `did:web:example.com:path:to:id` -> `https://example.com/path/to/id/did.json`. -/// A `:port` encoded as `%3A` in the host segment is decoded back to `:`. -/// -/// # Errors -/// A plain string reason if `did` is not a `did:web` DID at all. -pub fn did_web_url(did: &str) -> Result { - let rest = did - .strip_prefix("did:web:") - .ok_or_else(|| format!("not a did:web DID: {did}"))?; - let mut segments = rest.split(':'); - let host = segments - .next() - .ok_or_else(|| format!("empty did:web host in {did}"))?; - let host = host.replace("%3A", ":"); - let path_segments: Vec<&str> = segments.collect(); - if path_segments.is_empty() { - Ok(format!("https://{host}/.well-known/did.json")) - } else { - Ok(format!( - "https://{host}/{}/did.json", - path_segments.join("/") - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pathless_did_resolves_to_well_known() { - assert_eq!( - did_web_url("did:web:example.com").unwrap(), - "https://example.com/.well-known/did.json" - ); - } - - #[test] - fn path_did_resolves_to_path_plus_did_json() { - assert_eq!( - did_web_url("did:web:example.com:operators:acme").unwrap(), - "https://example.com/operators/acme/did.json" - ); - } - - #[test] - fn encoded_port_is_decoded() { - assert_eq!( - did_web_url("did:web:example.com%3A8443").unwrap(), - "https://example.com:8443/.well-known/did.json" - ); - } - - #[test] - fn non_did_web_is_rejected() { - assert!(did_web_url("did:key:z6Mk...").is_err()); - } -} diff --git a/crates/dpp-evidence/src/verify/engine.rs b/crates/dpp-evidence/src/verify/engine.rs deleted file mode 100644 index 8237f76..0000000 --- a/crates/dpp-evidence/src/verify/engine.rs +++ /dev/null @@ -1,611 +0,0 @@ -//! The verification engine: runs every check independently against a -//! [`DossierV1`] and produces a [`VerificationReport`] — a single tamper -//! flips exactly one named check, never cascades into unrelated failures. - -use crate::audit::verify_audit_chain; -use crate::dossier::{DossierV1, compute_content_hashes}; -use crate::jws::{resolve_public_key, verify_jws_content}; - -use super::report::{CheckResult, CheckStatus, VerificationReport, VerifyMode}; -use super::transfer_chain::verify_transfer_chain; - -/// A dossier that could not even be parsed — distinct from a dossier that -/// parsed fine but failed one or more checks (that's a [`VerificationReport`] -/// with `all_verified() == false`, exit code 1). This is exit code 2: the -/// input is not a dossier this verifier understands at all, including an -/// unrecognised field (`deny_unknown_fields`) — which may mean the dossier -/// was produced by a newer format version than this verifier knows. -#[derive(Debug, thiserror::Error)] -pub enum DossierParseError { - #[error("not a valid dossier — malformed JSON or an unrecognised field: {0}")] - Json(#[from] serde_json::Error), -} - -/// Run every check against `dossier` and produce a full report. -/// -/// For typed callers that already hold a `DossierV1` (e.g. the engine's own -/// round-trip test, which never serialises to bytes). File/network consumers -/// should use [`verify_dossier_json`] instead — it additionally runs the -/// `input_fidelity` check, which needs the original raw bytes. -pub fn verify_dossier(dossier: &DossierV1, mode: VerifyMode) -> VerificationReport { - let mut checks = Vec::new(); - - let issuer_key = dossier - .did_documents - .get(&dossier.manifest.issuer_did) - .and_then(|doc| resolve_public_key(&dossier.manifest_jws, doc)); - - // 1. Manifest authenticity. - checks.push(CheckResult { - name: "manifest_signature", - status: match &issuer_key { - None => CheckStatus::Fail(format!( - "no DID document available for issuer {}", - dossier.manifest.issuer_did - )), - Some(key) => { - let manifest_value = serde_json::to_value(&dossier.manifest) - .expect("DossierManifest serialises"); - match verify_jws_content(&dossier.manifest_jws, key, &manifest_value) { - Ok(true) => CheckStatus::Pass, - Ok(false) => CheckStatus::Fail( - "manifest signature invalid, or covers different content than this manifest".into(), - ), - Err(e) => CheckStatus::Fail(format!("malformed manifest signature: {e}")), - } - } - }, - }); - - // 2. Content integrity — every member hashes to what the signed - // manifest commits to. - checks.push(CheckResult { - name: "content_integrity", - status: { - let recomputed = compute_content_hashes(dossier); - if recomputed == dossier.manifest.content_hashes { - CheckStatus::Pass - } else { - let mismatched: Vec<&String> = recomputed - .keys() - .filter(|k| recomputed.get(*k) != dossier.manifest.content_hashes.get(*k)) - .collect(); - CheckStatus::Fail(format!( - "content hash mismatch for: {}", - mismatched - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - )) - } - }, - }); - - // 3. Full-view passport JWS. - checks.push(layer_check( - "full_view_signature", - &issuer_key, - &dossier.manifest.issuer_did, - &dossier.full_view.jws, - &dossier.full_view.payload, - )); - - // 4. Public-view passport JWS. - checks.push(layer_check( - "public_view_signature", - &issuer_key, - &dossier.manifest.issuer_did, - &dossier.public_view.jws, - &dossier.public_view.payload, - )); - - // 5. Audit chain. - checks.push(CheckResult { - name: "audit_chain", - status: match verify_audit_chain(&dossier.audit_entries) { - Ok(()) => CheckStatus::Pass, - Err(brk) => CheckStatus::Fail(format!("broken at entry {}: {}", brk.index, brk.reason)), - }, - }); - - // 6. Transfer chain. - checks.push(CheckResult { - name: "transfer_chain", - status: match &dossier.transfer_chain { - None => CheckStatus::Absent("no transfer chain on this passport".into()), - Some(chain) => match verify_transfer_chain(chain, &dossier.did_documents) { - Ok(()) => CheckStatus::Pass, - Err(brk) => { - CheckStatus::Fail(format!("broken at transfer {}: {:?}", brk.index, brk.issue)) - } - }, - }, - }); - - // 7. Checkpoint — always absent in v1, said honestly, never a failure. - checks.push(CheckResult { - name: "checkpoint", - status: match &dossier.checkpoint { - None => CheckStatus::Absent( - "checkpoint layer not yet implemented — audit chain integrity is checked, \ - but nothing pins the chain head against third-party re-hash" - .into(), - ), - Some(_) => CheckStatus::Fail( - "checkpoint present but this verifier build does not yet check it".into(), - ), - }, - }); - - // 8. Calc receipts — always absent in v1, said honestly, never a failure. - checks.push(CheckResult { - name: "calc_receipts", - status: if dossier.calc_receipts.is_empty() { - CheckStatus::Absent( - "no calculation receipts — dpp-calc invocation is not yet wired (licensed \ - factor data pending)" - .into(), - ) - } else { - CheckStatus::Fail( - "calc receipts present but this verifier build does not yet check them".into(), - ) - }, - }); - - let trust_anchor_note = match mode { - VerifyMode::Embedded => format!( - "trust anchored to embedded key snapshot dated {}", - dossier.manifest.created_at - ), - VerifyMode::Online => "trust anchored to live-fetched DID documents".to_string(), - }; - - VerificationReport { - mode, - trust_anchor_note, - checks, - } -} - -/// Parse raw dossier bytes and verify them — the entry point real consumers -/// (CLI, wasm) should use. -/// -/// Two distinct failure modes: -/// - Malformed JSON, a missing required field, or an unrecognised field -/// anywhere in a strict (`deny_unknown_fields`) type — returns `Err` -/// before any check runs (exit 2). An unrecognised field is treated the -/// same as malformed input on purpose: it may mean this dossier was -/// produced by a newer format version this verifier doesn't know about, -/// and silently ignoring unknown content is exactly what a verifier must -/// never do. -/// - A recognised, well-formed dossier that fails one or more checks — -/// returns `Ok(report)` with `report.all_verified() == false` (exit 1), -/// including a possible `input_fidelity` failure: an unknown field nested -/// inside a *tolerant* type (e.g. a `TransferRecord`, a core `dpp-domain` -/// type this crate doesn't make strict) parses fine but is silently -/// dropped by serde — `deny_unknown_fields` on our own types can't catch -/// that, so `input_fidelity` recomputes the canonical bytes of what was -/// *actually parsed* and compares them against the canonical bytes of what -/// was *received*. Any content lost in between fails this check. -/// -/// # Errors -/// [`DossierParseError`] — see above. -pub fn verify_dossier_json( - bytes: &[u8], - mode: VerifyMode, -) -> Result { - let raw: serde_json::Value = serde_json::from_slice(bytes)?; - let dossier: DossierV1 = serde_json::from_value(raw.clone())?; - - let mut report = verify_dossier(&dossier, mode); - report.checks.push(input_fidelity_check(&raw, &dossier)); - Ok(report) -} - -/// `JCS(raw) == JCS(reserialize(parse(raw)))` — catches content silently -/// dropped anywhere in the tree, including inside tolerant nested types -/// `deny_unknown_fields` doesn't reach. See `verify_dossier_json`'s doc for -/// the full rationale. -fn input_fidelity_check(raw: &serde_json::Value, parsed: &DossierV1) -> CheckResult { - let status = (|| -> Result { - let raw_bytes = serde_jcs::to_vec(raw).map_err(|e| format!("canonicalising input: {e}"))?; - let reparsed_value = serde_json::to_value(parsed) - .map_err(|e| format!("reserialising parsed dossier: {e}"))?; - let reparsed_bytes = serde_jcs::to_vec(&reparsed_value) - .map_err(|e| format!("canonicalising reserialised dossier: {e}"))?; - if reparsed_bytes == raw_bytes { - Ok(CheckStatus::Pass) - } else { - Ok(CheckStatus::Fail( - "dossier content changed after parsing — a field was likely dropped silently \ - (e.g. an unknown field nested inside a tolerant type such as a transfer record)" - .into(), - )) - } - })() - .unwrap_or_else(CheckStatus::Fail); - - CheckResult { - name: "input_fidelity", - status, - } -} - -fn layer_check( - name: &'static str, - issuer_key: &Option, - issuer_did: &str, - jws: &str, - payload: &serde_json::Value, -) -> CheckResult { - let status = match issuer_key { - None => CheckStatus::Fail(format!("no DID document available for issuer {issuer_did}")), - Some(key) => match verify_jws_content(jws, key, payload) { - Ok(true) => CheckStatus::Pass, - Ok(false) => CheckStatus::Fail( - "signature invalid, or covers different content than this payload".into(), - ), - Err(e) => CheckStatus::Fail(format!("malformed signature: {e}")), - }, - }; - CheckResult { name, status } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::audit::AuditEntry; - use crate::dossier::{DossierManifest, SignedLayer}; - use base64::Engine; - use chrono::Utc; - use ed25519_dalek::{Signer, SigningKey}; - use std::collections::BTreeMap; - use uuid::Uuid; - - fn did_doc_for(signing_key: &SigningKey) -> serde_json::Value { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let x = b64.encode(signing_key.verifying_key().to_bytes()); - serde_json::json!({ - "verificationMethod": [{ - "id": "did:web:node.example#root", - "type": "JsonWebKey2020", - "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": x }, - }], - "assertionMethod": ["did:web:node.example#root"], - }) - } - - fn sign(signing_key: &SigningKey, payload: &serde_json::Value) -> String { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header = b64.encode(serde_json::to_vec(&serde_json::json!({"alg": "EdDSA"})).unwrap()); - let body = b64.encode(serde_jcs::to_vec(payload).unwrap()); - let signing_input = format!("{header}.{body}"); - let sig = signing_key.sign(signing_input.as_bytes()); - format!("{signing_input}.{}", b64.encode(sig.to_bytes())) - } - - fn genesis_entry(action: &str) -> AuditEntry { - AuditEntry { - id: Uuid::now_v7(), - passport_id: "p1".into(), - actor: "actor".into(), - action: action.into(), - previous_status: None, - new_status: None, - metadata: None, - timestamp: Utc::now(), - prev_hash: None, - entry_hash: None, - } - } - - fn chain_entries(mut entries: Vec) -> Vec { - let mut prev = String::new(); - for e in &mut entries { - let h = e.chain_hash(&prev); - e.prev_hash = Some(prev.clone()); - e.entry_hash = Some(h.clone()); - prev = h; - } - entries - } - - fn valid_dossier(signing_key: &SigningKey) -> DossierV1 { - let full_payload = serde_json::json!({"passportId": "p1", "status": "active"}); - let public_payload = serde_json::json!({"passportId": "p1"}); - let audit_entries = - chain_entries(vec![genesis_entry("created"), genesis_entry("published")]); - - let mut dossier = DossierV1 { - manifest: DossierManifest { - format_version: "1".into(), - passport_id: "p1".into(), - issuer_did: "did:web:node.example".into(), - created_at: Utc::now(), - node_version: "test".into(), - ruleset_version: None, - content_hashes: BTreeMap::new(), - }, - manifest_jws: String::new(), - full_view: SignedLayer { - payload: full_payload.clone(), - jws: sign(signing_key, &full_payload), - }, - public_view: SignedLayer { - payload: public_payload.clone(), - jws: sign(signing_key, &public_payload), - }, - did_documents: BTreeMap::from([( - "did:web:node.example".to_string(), - did_doc_for(signing_key), - )]), - audit_entries, - transfer_chain: None, - eol_event: None, - checkpoint: None, - calc_receipts: Vec::new(), - }; - - dossier.manifest.content_hashes = compute_content_hashes(&dossier); - let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); - dossier.manifest_jws = sign(signing_key, &manifest_value); - dossier - } - - fn by_name<'a>(report: &'a VerificationReport, n: &str) -> &'a CheckStatus { - &report.checks.iter().find(|c| c.name == n).unwrap().status - } - - #[test] - fn clean_dossier_verifies_fully() { - let signing_key = SigningKey::from_bytes(&[9u8; 32]); - let dossier = valid_dossier(&signing_key); - let report = verify_dossier(&dossier, VerifyMode::Embedded); - assert!(report.all_verified(), "{report:?}"); - assert_eq!(report.exit_code(), 0); - } - - #[test] - fn tampered_full_view_payload_flips_only_that_check() { - let signing_key = SigningKey::from_bytes(&[9u8; 32]); - let mut dossier = valid_dossier(&signing_key); - dossier.full_view.payload["status"] = serde_json::json!("draft"); // tamper, jws not re-signed - let report = verify_dossier(&dossier, VerifyMode::Embedded); - - assert!(matches!( - by_name(&report, "full_view_signature"), - CheckStatus::Fail(_) - )); - assert!(matches!( - by_name(&report, "content_integrity"), - CheckStatus::Fail(_) - )); - // Unrelated checks stay green. - assert_eq!( - *by_name(&report, "public_view_signature"), - CheckStatus::Pass - ); - assert_eq!(*by_name(&report, "audit_chain"), CheckStatus::Pass); - } - - #[test] - fn tampered_jws_flips_only_that_signature_check() { - let signing_key = SigningKey::from_bytes(&[9u8; 32]); - let mut dossier = valid_dossier(&signing_key); - dossier.public_view.jws = format!("{}x", dossier.public_view.jws); - let report = verify_dossier(&dossier, VerifyMode::Embedded); - - assert!(matches!( - by_name(&report, "public_view_signature"), - CheckStatus::Fail(_) - )); - assert_eq!(*by_name(&report, "full_view_signature"), CheckStatus::Pass); - } - - #[test] - fn tampered_audit_row_flips_only_audit_chain() { - let signing_key = SigningKey::from_bytes(&[9u8; 32]); - let mut dossier = valid_dossier(&signing_key); - dossier.audit_entries[0].action = "tampered".into(); - // Re-derive content_hashes/manifest_jws to isolate the audit-chain - // check specifically (otherwise content_integrity also flips, which - // is correct but not what this test is isolating). - dossier.manifest.content_hashes = compute_content_hashes(&dossier); - let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); - dossier.manifest_jws = sign(&signing_key, &manifest_value); - - let report = verify_dossier(&dossier, VerifyMode::Embedded); - assert!(matches!( - by_name(&report, "audit_chain"), - CheckStatus::Fail(_) - )); - assert_eq!(*by_name(&report, "full_view_signature"), CheckStatus::Pass); - assert_eq!(*by_name(&report, "manifest_signature"), CheckStatus::Pass); - } - - #[test] - fn absent_checkpoint_and_receipts_are_informational_not_failures() { - let signing_key = SigningKey::from_bytes(&[9u8; 32]); - let dossier = valid_dossier(&signing_key); - let report = verify_dossier(&dossier, VerifyMode::Embedded); - assert!(matches!( - by_name(&report, "checkpoint"), - CheckStatus::Absent(_) - )); - assert!(matches!( - by_name(&report, "calc_receipts"), - CheckStatus::Absent(_) - )); - assert!(report.all_verified()); - } - - #[test] - fn tampered_transfer_signature_flips_only_transfer_chain() { - use dpp_domain::domain::passport::PassportId; - use dpp_domain::domain::transfer::{ - OperatorRole, ResponsibleOperator, TransferChain, TransferReason, TransferRecord, - }; - - let signing_key = SigningKey::from_bytes(&[9u8; 32]); - let from_key = SigningKey::from_bytes(&[3u8; 32]); - let to_key = SigningKey::from_bytes(&[4u8; 32]); - let mut dossier = valid_dossier(&signing_key); - - let operator = |did: &str| ResponsibleOperator { - did: did.to_owned(), - name: "Acme".into(), - role: OperatorRole::Distributor, - eu_operator_id: None, - country: "DE".into(), - }; - let mut record = TransferRecord { - transfer_id: Uuid::now_v7(), - passport_id: PassportId::new(), - from_operator: operator("did:web:from.example"), - to_operator: operator("did:web:to.example"), - reason: TransferReason::Sale, - from_signature: None, - to_signature: None, - initiated_at: Utc::now(), - completed_at: None, - rejected_at: None, - cancelled_at: None, - notes: None, - }; - let payload = record.signing_payload(); - record.from_signature = Some(sign(&from_key, &payload)); - record.to_signature = Some(sign(&to_key, &payload)); - - dossier.transfer_chain = Some(TransferChain { - passport_id: record.passport_id, - original_operator: operator("did:web:from.example"), - transfers: vec![record], - }); - dossier - .did_documents - .insert("did:web:from.example".to_string(), did_doc_for(&from_key)); - dossier - .did_documents - .insert("did:web:to.example".to_string(), did_doc_for(&to_key)); - dossier.manifest.content_hashes = compute_content_hashes(&dossier); - let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); - dossier.manifest_jws = sign(&signing_key, &manifest_value); - - // Sanity: clean chain verifies before we tamper it. - let clean_report = verify_dossier(&dossier, VerifyMode::Embedded); - assert!(clean_report.all_verified(), "{clean_report:?}"); - - // Tamper the to_signature, then re-sign the manifest so only the - // transfer-chain check (not content_integrity) is isolated. - if let Some(chain) = &mut dossier.transfer_chain { - chain.transfers[0].to_signature = chain.transfers[0] - .to_signature - .clone() - .map(|s| format!("{s}x")); - } - dossier.manifest.content_hashes = compute_content_hashes(&dossier); - let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); - dossier.manifest_jws = sign(&signing_key, &manifest_value); - - let report = verify_dossier(&dossier, VerifyMode::Embedded); - assert!(matches!( - by_name(&report, "transfer_chain"), - CheckStatus::Fail(_) - )); - assert_eq!(*by_name(&report, "full_view_signature"), CheckStatus::Pass); - assert_eq!(*by_name(&report, "audit_chain"), CheckStatus::Pass); - } - - // ── verify_dossier_json / strictness / fidelity ──────────────────────── - - fn valid_dossier_bytes() -> Vec { - let signing_key = SigningKey::from_bytes(&[9u8; 32]); - let dossier = valid_dossier(&signing_key); - serde_json::to_vec(&dossier).unwrap() - } - - #[test] - fn clean_json_round_trips_through_verify_dossier_json() { - let bytes = valid_dossier_bytes(); - let report = verify_dossier_json(&bytes, VerifyMode::Embedded).expect("parses"); - assert!(report.all_verified(), "{report:?}"); - assert_eq!(*by_name(&report, "input_fidelity"), CheckStatus::Pass); - } - - #[test] - fn unknown_top_level_field_is_a_parse_error() { - let bytes = valid_dossier_bytes(); - let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - value["notARealField"] = serde_json::json!("sneaky"); - let bytes = serde_json::to_vec(&value).unwrap(); - - let err = verify_dossier_json(&bytes, VerifyMode::Embedded) - .expect_err("unknown field must be a hard parse error, not a report"); - assert!(matches!(err, DossierParseError::Json(_))); - } - - #[test] - fn unknown_field_nested_in_a_tolerant_type_fails_input_fidelity() { - // TransferRecord (a dpp-domain type) is not `deny_unknown_fields` — - // an unknown field inside it parses fine and is silently dropped by - // serde. `deny_unknown_fields` on our own types can't catch this; - // `input_fidelity` must. - use dpp_domain::domain::passport::PassportId; - use dpp_domain::domain::transfer::{ - OperatorRole, ResponsibleOperator, TransferChain, TransferReason, TransferRecord, - }; - - let signing_key = SigningKey::from_bytes(&[9u8; 32]); - let mut dossier = valid_dossier(&signing_key); - let operator = |did: &str| ResponsibleOperator { - did: did.to_owned(), - name: "Acme".into(), - role: OperatorRole::Distributor, - eu_operator_id: None, - country: "DE".into(), - }; - let record = TransferRecord { - transfer_id: Uuid::now_v7(), - passport_id: PassportId::new(), - from_operator: operator("did:web:from.example"), - to_operator: operator("did:web:to.example"), - reason: TransferReason::Sale, - from_signature: None, - to_signature: None, - initiated_at: Utc::now(), - completed_at: None, - rejected_at: None, - cancelled_at: None, - notes: None, - }; - dossier.transfer_chain = Some(TransferChain { - passport_id: record.passport_id, - original_operator: operator("did:web:from.example"), - transfers: vec![record], - }); - dossier.manifest.content_hashes = compute_content_hashes(&dossier); - let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); - dossier.manifest_jws = sign(&signing_key, &manifest_value); - - let mut value = serde_json::to_value(&dossier).unwrap(); - value["transferChain"]["transfers"][0]["certificationStatus"] = - serde_json::json!("approved"); - let bytes = serde_json::to_vec(&value).unwrap(); - - let report = verify_dossier_json(&bytes, VerifyMode::Embedded) - .expect("TransferRecord tolerates unknown fields, so this must parse"); - assert!(matches!( - by_name(&report, "input_fidelity"), - CheckStatus::Fail(_) - )); - assert!(!report.all_verified()); - } - - #[test] - fn malformed_json_is_a_parse_error() { - let err = verify_dossier_json(b"not json", VerifyMode::Embedded).unwrap_err(); - assert!(matches!(err, DossierParseError::Json(_))); - } -} diff --git a/crates/dpp-evidence/src/verify/mod.rs b/crates/dpp-evidence/src/verify/mod.rs deleted file mode 100644 index 7f33216..0000000 --- a/crates/dpp-evidence/src/verify/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! The dossier verification engine, its report types, and the two support -//! checks that need more than a single-crate view: whole transfer-chain -//! signature verification, and `did:web` DID-to-URL resolution. - -mod did_web; -mod engine; -mod report; -mod transfer_chain; - -pub use did_web::did_web_url; -pub use engine::{DossierParseError, verify_dossier, verify_dossier_json}; -pub use report::{CheckResult, CheckStatus, VerificationReport, VerifyMode}; -pub use transfer_chain::{TransferChainBreak, TransferSignatureIssue, verify_transfer_chain}; diff --git a/crates/dpp-evidence/src/verify/report.rs b/crates/dpp-evidence/src/verify/report.rs deleted file mode 100644 index 495aa8e..0000000 --- a/crates/dpp-evidence/src/verify/report.rs +++ /dev/null @@ -1,66 +0,0 @@ -/// Whether DID documents come from the dossier's own embedded snapshot, or -/// were re-fetched live. `Online` re-fetching is a native-CLI-only concern -/// (this crate's core stays offline-only); see the `odal verify` command in -/// `dpp-engine`'s CLI. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum VerifyMode { - #[default] - Embedded, - Online, -} - -/// Outcome of a single named check. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CheckStatus { - Pass, - Fail(String), - /// Not a failure — the layer is legitimately absent in v1 (checkpoint, - /// calc receipts) or not applicable to this passport (no transfer chain). - Absent(String), -} - -impl CheckStatus { - #[must_use] - pub fn is_failure(&self) -> bool { - matches!(self, CheckStatus::Fail(_)) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CheckResult { - pub name: &'static str, - pub status: CheckStatus, -} - -impl CheckResult { - #[must_use] - fn is_failure(&self) -> bool { - self.status.is_failure() - } -} - -/// The full result of verifying a dossier. -#[derive(Debug, Clone)] -pub struct VerificationReport { - pub mode: VerifyMode, - pub trust_anchor_note: String, - pub checks: Vec, -} - -impl VerificationReport { - /// `true` iff every check passed (informational `Absent` checks don't - /// count against this). - #[must_use] - pub fn all_verified(&self) -> bool { - !self.checks.iter().any(CheckResult::is_failure) - } - - /// Exit-code convention: `0` verified, `1` any tamper, `2` never - /// returned from here — malformed/incomplete input (including an - /// unrecognised field) is a hard parse error before a report can even be - /// built; see `verify_dossier_json`. - #[must_use] - pub fn exit_code(&self) -> i32 { - if self.all_verified() { 0 } else { 1 } - } -} diff --git a/crates/dpp-evidence/src/verify/transfer_chain.rs b/crates/dpp-evidence/src/verify/transfer_chain.rs deleted file mode 100644 index b05b1a1..0000000 --- a/crates/dpp-evidence/src/verify/transfer_chain.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Transfer-chain signature verification. -//! -//! Nothing in the engine verifies a whole [`TransferChain`] standalone — -//! signature checking normally happens inline, one record at a time, as -//! part of accepting a transfer. This is the first "verify the whole chain -//! after the fact" implementation; it reuses `TransferRecord::signing_payload` -//! (the exact bytes both operators sign) and the same JWS verification used -//! everywhere else, just applied per-record. - -use std::collections::BTreeMap; - -use dpp_domain::domain::transfer::TransferChain; - -use crate::jws::{resolve_public_key, verify_jws_content}; - -/// Which signature(s) on a transfer record failed to verify. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TransferSignatureIssue { - /// The `from_operator`'s signature is missing, or failed to verify, or - /// their DID document was not available to check against. - From(String), - /// The `to_operator`'s signature is missing, or failed to verify, or - /// their DID document was not available to check against. - To(String), -} - -/// The first broken record found while verifying a transfer chain. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TransferChainBreak { - /// 0-based position of the offending record in `chain.transfers`. - pub index: usize, - pub issue: TransferSignatureIssue, -} - -/// Verify every completed transfer record's signatures against the DID -/// documents available in `did_documents` (keyed by DID). -/// -/// A record with no signature yet (still `Initiated`) is skipped — it has -/// nothing to verify. A record whose signer's DID document is missing from -/// `did_documents` fails closed (reported, not silently skipped) so a -/// verifier never reports false-green on an unresolvable cross-operator DID. -/// -/// # Errors -/// [`TransferChainBreak`] at the first record with a bad or unverifiable -/// signature. -pub fn verify_transfer_chain( - chain: &TransferChain, - did_documents: &BTreeMap, -) -> Result<(), TransferChainBreak> { - for (index, record) in chain.transfers.iter().enumerate() { - let payload = record.signing_payload(); - - if let Some(sig) = &record.from_signature { - check_signature(&record.from_operator.did, sig, &payload, did_documents).map_err( - |reason| TransferChainBreak { - index, - issue: TransferSignatureIssue::From(reason), - }, - )?; - } - if let Some(sig) = &record.to_signature { - check_signature(&record.to_operator.did, sig, &payload, did_documents).map_err( - |reason| TransferChainBreak { - index, - issue: TransferSignatureIssue::To(reason), - }, - )?; - } - } - Ok(()) -} - -fn check_signature( - did: &str, - jws: &str, - payload: &serde_json::Value, - did_documents: &BTreeMap, -) -> Result<(), String> { - let did_doc = did_documents - .get(did) - .ok_or_else(|| format!("no DID document available for {did} — cannot verify"))?; - let key = resolve_public_key(jws, did_doc) - .ok_or_else(|| format!("no usable assertion key found in DID document for {did}"))?; - - match verify_jws_content(jws, &key, payload) { - Ok(true) => Ok(()), - Ok(false) => Err(format!( - "signature does not verify against {did}'s key, or covers different content than the transfer terms" - )), - Err(e) => Err(format!("malformed signature: {e}")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::Engine; - use chrono::Utc; - use dpp_domain::domain::{ - passport::PassportId, - transfer::{OperatorRole, ResponsibleOperator, TransferReason, TransferRecord}, - }; - use ed25519_dalek::{Signer, SigningKey}; - use uuid::Uuid; - - fn operator(did: &str) -> ResponsibleOperator { - ResponsibleOperator { - did: did.to_owned(), - name: "Acme".into(), - role: OperatorRole::Distributor, - eu_operator_id: None, - country: "DE".into(), - } - } - - fn did_doc_for(signing_key: &SigningKey) -> serde_json::Value { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let x = b64.encode(signing_key.verifying_key().to_bytes()); - serde_json::json!({ - "verificationMethod": [{ - "id": "did:web:example.com#root", - "type": "JsonWebKey2020", - "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": x }, - }], - "assertionMethod": ["did:web:example.com#root"], - }) - } - - fn sign(signing_key: &SigningKey, payload: &serde_json::Value) -> String { - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header = b64.encode(serde_json::to_vec(&serde_json::json!({"alg": "EdDSA"})).unwrap()); - let body = b64.encode(serde_jcs::to_vec(payload).unwrap()); - let signing_input = format!("{header}.{body}"); - let sig = signing_key.sign(signing_input.as_bytes()); - format!("{signing_input}.{}", b64.encode(sig.to_bytes())) - } - - fn record_with_signatures( - from_key: &SigningKey, - to_key: &SigningKey, - from_did: &str, - to_did: &str, - ) -> TransferRecord { - let mut record = TransferRecord { - transfer_id: Uuid::now_v7(), - passport_id: PassportId::new(), - from_operator: operator(from_did), - to_operator: operator(to_did), - reason: TransferReason::Sale, - from_signature: None, - to_signature: None, - initiated_at: Utc::now(), - completed_at: None, - rejected_at: None, - cancelled_at: None, - notes: None, - }; - let payload = record.signing_payload(); - record.from_signature = Some(sign(from_key, &payload)); - record.to_signature = Some(sign(to_key, &payload)); - record - } - - #[test] - fn intact_chain_verifies() { - let from_key = SigningKey::from_bytes(&[1u8; 32]); - let to_key = SigningKey::from_bytes(&[2u8; 32]); - let record = record_with_signatures( - &from_key, - &to_key, - "did:web:from.example", - "did:web:to.example", - ); - let chain = TransferChain { - passport_id: record.passport_id, - original_operator: operator("did:web:from.example"), - transfers: vec![record], - }; - let mut docs = BTreeMap::new(); - docs.insert("did:web:from.example".to_string(), did_doc_for(&from_key)); - docs.insert("did:web:to.example".to_string(), did_doc_for(&to_key)); - - assert!(verify_transfer_chain(&chain, &docs).is_ok()); - } - - #[test] - fn tampered_to_signature_is_detected() { - let from_key = SigningKey::from_bytes(&[1u8; 32]); - let to_key = SigningKey::from_bytes(&[2u8; 32]); - let mut record = record_with_signatures( - &from_key, - &to_key, - "did:web:from.example", - "did:web:to.example", - ); - record.to_signature = record.to_signature.map(|s| format!("{s}tampered")); - let chain = TransferChain { - passport_id: record.passport_id, - original_operator: operator("did:web:from.example"), - transfers: vec![record], - }; - let mut docs = BTreeMap::new(); - docs.insert("did:web:from.example".to_string(), did_doc_for(&from_key)); - docs.insert("did:web:to.example".to_string(), did_doc_for(&to_key)); - - let brk = verify_transfer_chain(&chain, &docs).expect_err("must detect tamper"); - assert_eq!(brk.index, 0); - assert!(matches!(brk.issue, TransferSignatureIssue::To(_))); - } - - #[test] - fn missing_did_document_fails_closed() { - let from_key = SigningKey::from_bytes(&[1u8; 32]); - let to_key = SigningKey::from_bytes(&[2u8; 32]); - let record = record_with_signatures( - &from_key, - &to_key, - "did:web:from.example", - "did:web:to.example", - ); - let chain = TransferChain { - passport_id: record.passport_id, - original_operator: operator("did:web:from.example"), - transfers: vec![record], - }; - // Only the "from" DID document is available. - let mut docs = BTreeMap::new(); - docs.insert("did:web:from.example".to_string(), did_doc_for(&from_key)); - - let brk = verify_transfer_chain(&chain, &docs).expect_err("must fail closed"); - assert!(matches!(brk.issue, TransferSignatureIssue::To(_))); - } -} diff --git a/crates/dpp-plugin-sdk/src/abi.rs b/crates/dpp-plugin-sdk/src/abi.rs index 68b1e9b..bf2aacf 100644 --- a/crates/dpp-plugin-sdk/src/abi.rs +++ b/crates/dpp-plugin-sdk/src/abi.rs @@ -5,23 +5,40 @@ use std::alloc::{Layout, alloc as mem_alloc, dealloc as mem_dealloc}; /// Allocate `len` bytes in the module's linear memory and return the /// pointer as a `u32`. Returns `0` for a zero-length request. +/// +/// Returns `0` (allocation failure) rather than panicking on an oversized +/// request (`len ≥ isize::MAX`), and never hands out a *truncated* pointer: on +/// a 64-bit host (e.g. a plugin's own native test build) a real heap address +/// does not fit in `u32`, so such an allocation is freed and `0` returned. On +/// wasm32 every address fits, so that guard is inert. #[must_use] pub fn host_alloc(len: u32) -> u32 { if len == 0 { return 0; } - let layout = Layout::from_size_align(len as usize, 1).expect("valid layout"); + let Ok(layout) = Layout::from_size_align(len as usize, 1) else { + return 0; + }; // SAFETY: `layout` has non-zero size; a null return is handled by the host. - unsafe { mem_alloc(layout) as u32 } + let ptr = unsafe { mem_alloc(layout) } as usize; + if ptr > u32::MAX as usize { + // SAFETY: `ptr`/`layout` are exactly the allocation just returned. + unsafe { mem_dealloc(ptr as *mut u8, layout) }; + return 0; + } + ptr as u32 } /// Free a buffer previously returned by [`host_alloc`] (or packed into a -/// `-> u64` ABI return). No-op for null pointers or zero length. +/// `-> u64` ABI return). No-op for null pointers, zero length, or an +/// oversized length that could not have been allocated. pub fn host_dealloc(ptr: u32, len: u32) { if ptr == 0 || len == 0 { return; } - let layout = Layout::from_size_align(len as usize, 1).expect("valid layout"); + let Ok(layout) = Layout::from_size_align(len as usize, 1) else { + return; + }; // SAFETY: `ptr`/`len` must describe a buffer from `host_alloc`. unsafe { mem_dealloc(ptr as *mut u8, layout) } } @@ -34,12 +51,14 @@ pub fn host_dealloc(ptr: u32, len: u32) { /// (via `alloc`) that lives for the duration of the returned borrow. #[must_use] pub unsafe fn read_input<'a>(ptr: u32, len: u32) -> &'a [u8] { - unsafe { - if len == 0 { - return &[]; - } - std::slice::from_raw_parts(ptr as *const u8, len as usize) + // A 32-bit ABI pointer can only address real memory on a 32-bit target + // (wasm32). On a 64-bit host the value is a truncated address, so never + // dereference it — return empty for anything but the len==0 case. + if len == 0 || !cfg!(target_pointer_width = "32") { + return &[]; } + // SAFETY: on wasm32 `ptr`/`len` describe a host-written allocation. + unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize) } } /// Leak `bytes` into linear memory and return the packed @@ -53,7 +72,13 @@ pub unsafe fn read_input<'a>(ptr: u32, len: u32) -> &'a [u8] { pub fn write_output(bytes: Vec) -> u64 { let mut boxed = bytes.into_boxed_slice(); let out_len = boxed.len() as u32; - let out_ptr = boxed.as_mut_ptr() as usize as u32; + let out_ptr = boxed.as_mut_ptr() as usize; + if out_ptr > u32::MAX as usize { + // 64-bit host: the address can't be represented in the 32-bit ABI. Drop + // the buffer (no leak) and return a null pointer with the exact length. + // On wasm32 every address fits, so this branch is inert. + return u64::from(out_len); + } std::mem::forget(boxed); - ((out_ptr as u64) << 32) | (out_len as u64) + ((out_ptr as u64) << 32) | u64::from(out_len) } diff --git a/crates/dpp-plugin-sdk/src/tests.rs b/crates/dpp-plugin-sdk/src/tests.rs index 0e12a8a..e167fdd 100644 --- a/crates/dpp-plugin-sdk/src/tests.rs +++ b/crates/dpp-plugin-sdk/src/tests.rs @@ -138,9 +138,12 @@ fn generate_passport_error_when_input_parses_but_is_rejected() { assert!(json.get("ok").is_none()); } -// Note: the `write_output`/`read_input` packing uses 32-bit pointers and is -// only valid on `wasm32` (host pointers are 64-bit and would truncate). The -// host-testable surface is the pure `*_bytes` glue exercised above. +// Note: the `write_output`/`read_input`/`host_alloc` pointer packing is a +// 32-bit ABI. On a 64-bit host the raw functions guard against truncation — +// `host_alloc`/`write_output` return null instead of a truncated pointer and +// `read_input` never dereferences one — so the macro exports below are memory- +// safe to drive on the host. The primary host-testable surface remains the pure +// `*_bytes` glue exercised above. // ── `export_plugin!` macro expansion (host-target coverage) ────────────── // @@ -163,14 +166,34 @@ fn out_len(packed: u64) -> usize { fn macro_alloc_dealloc_are_callable() { // Zero-length alloc returns a null pointer without allocating. assert_eq!(alloc(0), 0); - // Non-zero alloc returns a (truncated-on-host) pointer; the allocation - // is intentionally leaked — the truncated u32 cannot be safely freed on - // a 64-bit host, and dealloc's no-op path is covered below. + // Non-zero alloc: on a 64-bit host the address can't fit the 32-bit ABI, so + // host_alloc frees it and returns null rather than a truncated pointer. let _ = alloc(8); // dealloc's null/zero guard is the only branch safe to drive on host. dealloc(0, 0); } +#[test] +#[cfg(not(target_pointer_width = "32"))] +fn read_input_never_dereferences_truncated_pointer_on_host() { + // On a 64-bit host a 32-bit ABI pointer is a truncated address; read_input + // must return an empty slice rather than dereferencing it, so a plugin's + // native test suite cannot trigger memory unsafety through the raw ABI. + let bytes = unsafe { crate::abi::read_input(0xDEAD_BEEF, 16) }; + assert!( + bytes.is_empty(), + "must not deref a truncated pointer on host" + ); +} + +#[test] +#[cfg(not(target_pointer_width = "32"))] +fn host_alloc_does_not_hand_out_truncated_pointer_on_host() { + // A real 64-bit heap address cannot be represented in u32, so host_alloc + // returns null instead of a truncated (un-freeable) pointer. + assert_eq!(crate::abi::host_alloc(8), 0); +} + #[test] fn macro_metadata_and_describe_pack_glue_output() { assert_eq!(out_len(metadata()), metadata_bytes(&DummyPlugin).len()); diff --git a/crates/dpp-plugin-sdk/src/validate.rs b/crates/dpp-plugin-sdk/src/validate.rs index 960deaf..5246551 100644 --- a/crates/dpp-plugin-sdk/src/validate.rs +++ b/crates/dpp-plugin-sdk/src/validate.rs @@ -218,6 +218,39 @@ impl<'a> Validator<'a> { self } + /// If present (and non-null), the value must be a finite number in `[min, max]`. + /// + /// For a field that is optional (its absence is meaningful) but must be + /// bounded when supplied — e.g. a 0–10 repairability score that gates a + /// verdict only when present. + pub fn optional_range(&mut self, key: &str, min: f64, max: f64) -> &mut Self { + let err = match present(self.input, key) { + None => None, + Some(v) => match v.as_f64().filter(|n| n.is_finite()) { + Some(n) if (min..=max).contains(&n) => None, + _ => Some(( + "out_of_range", + format!("{key} must be a number in {min}..={max}"), + )), + }, + }; + self.push_opt(key, err); + self + } + + /// If present (and non-null), the value must be a finite number ≥ 0. + pub fn optional_non_negative(&mut self, key: &str) -> &mut Self { + let err = match present(self.input, key) { + None => None, + Some(v) => match v.as_f64().filter(|n| n.is_finite()) { + Some(n) if n >= 0.0 => None, + _ => Some(("out_of_range", format!("{key} must be a finite number ≥ 0"))), + }, + }; + self.push_opt(key, err); + self + } + /// Finish validation, returning every collected error at once. pub fn finish(&mut self) -> Result<(), PluginError> { if self.errors.is_empty() { @@ -390,6 +423,55 @@ mod tests { assert!(Validator::new(&bad).optional_pct("x").finish().is_err()); } + #[test] + fn optional_range_absent_ok_present_bounded() { + // Absent → ok (the field's absence is meaningful). + assert!( + Validator::new(&json!({})) + .optional_range("s", 0.0, 10.0) + .finish() + .is_ok() + ); + // In range → ok. + assert!( + Validator::new(&json!({ "s": 6.0 })) + .optional_range("s", 0.0, 10.0) + .finish() + .is_ok() + ); + // Out of range → err (the fail-open case the plugins guard against). + for bad in [json!({ "s": 999999.0 }), json!({ "s": -1.0 })] { + assert!( + Validator::new(&bad) + .optional_range("s", 0.0, 10.0) + .finish() + .is_err() + ); + } + } + + #[test] + fn optional_non_negative_absent_ok_negative_fails() { + assert!( + Validator::new(&json!({})) + .optional_non_negative("c") + .finish() + .is_ok() + ); + assert!( + Validator::new(&json!({ "c": 0.0 })) + .optional_non_negative("c") + .finish() + .is_ok() + ); + assert!( + Validator::new(&json!({ "c": -999.0 })) + .optional_non_negative("c") + .finish() + .is_err() + ); + } + #[test] fn readers_extract_values() { let input = json!({ "n": 3.5, "s": "hi", "bad": "x" }); diff --git a/crates/dpp-plugin-traits/Cargo.toml b/crates/dpp-plugin-traits/Cargo.toml index 2eb11dd..35e6e55 100644 --- a/crates/dpp-plugin-traits/Cargo.toml +++ b/crates/dpp-plugin-traits/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "dpp-plugin-traits" -description = "Host/guest contract for Odal Node Wasm sector plugins — no_std compatible" +description = "Host/guest contract for Odal Node Wasm sector plugins" keywords = ["dpp", "wasm", "plugin", "sector", "compliance"] -categories = ["wasm", "no-std::no-alloc"] +categories = ["wasm"] version.workspace = true edition.workspace = true authors.workspace = true diff --git a/crates/dpp-plugin-traits/src/lib.rs b/crates/dpp-plugin-traits/src/lib.rs index ae5b87c..63f97af 100644 --- a/crates/dpp-plugin-traits/src/lib.rs +++ b/crates/dpp-plugin-traits/src/lib.rs @@ -4,9 +4,10 @@ //! as `extern "C"` symbols. The host invokes them through the wasmtime //! component model or directly via the low-level ABI defined below. //! -//! The interface is intentionally `no_std`-friendly: no heap allocations -//! are required from the host's perspective. Data is passed as JSON strings -//! over a shared-memory slice. +//! Data crosses the host/guest boundary as JSON strings over a shared-memory +//! slice, so the low-level ABI itself is just integer pointer/length pairs. +//! (The crate uses `std` types — `String`, `Vec`, `HashMap` — so it is not +//! `no_std`.) //! //! ## Versioning //! diff --git a/crates/dpp-plugin-traits/src/result.rs b/crates/dpp-plugin-traits/src/result.rs index 5936540..19f4b8b 100644 --- a/crates/dpp-plugin-traits/src/result.rs +++ b/crates/dpp-plugin-traits/src/result.rs @@ -63,6 +63,28 @@ impl PluginFinding { } } +/// Serialise a metric map, failing on any non-finite value instead of letting +/// `serde_json` silently emit `null` for it. +fn serialize_finite_metrics( + metrics: &std::collections::HashMap, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + use serde::ser::{Error, SerializeMap}; + let mut map = serializer.serialize_map(Some(metrics.len()))?; + for (key, value) in metrics { + if !value.is_finite() { + return Err(S::Error::custom(format!( + "metric '{key}' is not finite ({value})" + ))); + } + map.serialize_entry(key, value)?; + } + map.end() +} + /// Compliance result returned by the plugin. /// /// `metrics` is a sector-extensible map of named numeric values. Use the @@ -84,7 +106,12 @@ pub struct PluginResult { /// Typed compliance determination. pub compliance_status: PluginComplianceStatus, /// Sector-extensible keyed metric map (all values finite f64). - #[serde(default)] + /// + /// Serialisation **fails** on a non-finite value (`NaN`/`Infinity`) rather + /// than silently coercing it to JSON `null`, so a metric inserted directly + /// into this `pub` field (bypassing the `with_metric` guard) cannot slip + /// through the ABI envelope as a spurious success. + #[serde(default, serialize_with = "serialize_finite_metrics")] pub metrics: std::collections::HashMap, /// Non-numeric sector-specific data (free-form; stored verbatim in extra). #[serde(skip_serializing_if = "Option::is_none")] @@ -185,8 +212,18 @@ pub enum AbiResult { impl AbiResult { /// Build a successful response by serialising `value`. + /// + /// If serialisation fails — e.g. a plugin inserted a non-finite `f64` into + /// `PluginResult.metrics`, bypassing the `with_metric` finite-value guard — + /// this returns the [`AbiResult::Error`] variant rather than a spurious + /// `Ok(null)`, so the failure is not silently swallowed. pub fn ok(value: &T) -> Self { - Self::Ok(serde_json::to_value(value).unwrap_or(serde_json::Value::Null)) + match serde_json::to_value(value) { + Ok(v) => Self::Ok(v), + Err(e) => Self::Error(PluginError::Internal(format!( + "failed to serialise plugin result: {e}" + ))), + } } /// Returns `true` if this is the success variant. diff --git a/crates/dpp-plugin-traits/src/tests.rs b/crates/dpp-plugin-traits/src/tests.rs index 3c28c23..21f5477 100644 --- a/crates/dpp-plugin-traits/src/tests.rs +++ b/crates/dpp-plugin-traits/src/tests.rs @@ -217,6 +217,46 @@ fn abi_result_ok_round_trip() { } } +#[test] +fn abi_result_ok_rejects_non_finite_metric() { + // A plugin can insert a non-finite metric directly into the pub `metrics` + // field, bypassing the `with_metric` finite guard. Serialisation must fail, + // and `AbiResult::ok` must surface that as an Error — not a spurious Ok + // carrying a silently-nulled metric. + let mut result = PluginResult::new(PluginComplianceStatus::Compliant); + result + .metrics + .insert(METRIC_CO2E_SCORE.to_owned(), f64::INFINITY); + let envelope = AbiResult::ok(&result); + assert!( + !envelope.is_ok(), + "a non-finite metric must surface as an error, not Ok" + ); +} + +#[test] +fn non_semver_range_bound_does_not_lexicographically_match() { + // A range with an unparseable bound must not fall back to string comparison + // (where "1.9.0" > "1.10.0"); the range simply can't match, so the check + // fails closed rather than returning a misleading answer. + let caps = PluginCapabilities { + abi_version: AbiVersion::current(), + supported_schemas: vec![SchemaVersionRange { + min_version: "1.0".into(), // not valid semver + max_version: "1.10.0".into(), + }], + capabilities: vec![], + min_host_version: None, + max_fuel: None, + max_memory_bytes: None, + }; + let result = check_compatibility(&caps, Some("1.9.0"), &[]); + assert!(matches!( + result, + CompatibilityStatus::SchemaUnsupported { .. } + )); +} + #[test] fn abi_result_error_round_trip() { let envelope = AbiResult::Error(PluginError::ValidationErrors(vec![PluginFieldError { diff --git a/crates/dpp-plugin-traits/src/version.rs b/crates/dpp-plugin-traits/src/version.rs index d949bb3..38def88 100644 --- a/crates/dpp-plugin-traits/src/version.rs +++ b/crates/dpp-plugin-traits/src/version.rs @@ -117,18 +117,20 @@ pub fn check_compatibility( } } - // 3. Schema version check (semantic via semver crate; falls back to lexicographic) + // 3. Schema version check — strictly semantic (semver). A version string + // that isn't valid semver cannot be compared as a version; treat such a + // range as non-matching rather than falling back to a lexicographic string + // comparison (where e.g. "1.9.0" > "1.10.0" gives the wrong answer). if let Some(requested) = requested_schema_version { let req = semver::Version::parse(requested).ok(); let supported = capabilities.supported_schemas.iter().any(|range| { - let lo = semver::Version::parse(&range.min_version).ok(); - let hi = semver::Version::parse(&range.max_version).ok(); - match (req.as_ref(), lo, hi) { - (Some(r), Some(l), Some(h)) => r >= &l && r <= &h, - _ => { - requested >= range.min_version.as_str() - && requested <= range.max_version.as_str() - } + match ( + req.as_ref(), + semver::Version::parse(&range.min_version), + semver::Version::parse(&range.max_version), + ) { + (Some(r), Ok(l), Ok(h)) => r >= &l && r <= &h, + _ => false, } }); if !supported { diff --git a/crates/dpp-registry/Cargo.toml b/crates/dpp-registry/Cargo.toml index e7884d1..cdc1a09 100644 --- a/crates/dpp-registry/Cargo.toml +++ b/crates/dpp-registry/Cargo.toml @@ -20,4 +20,4 @@ serde_json = { workspace = true } thiserror = { workspace = true } uuid = { version = "1", features = ["v4", "serde", "js"] } chrono = { workspace = true } -getrandom = { version = "0.3", features = ["wasm_js"] } +getrandom = { version = "0.4", features = ["wasm_js"] } diff --git a/crates/dpp-registry/src/registry/identifiers.rs b/crates/dpp-registry/src/registry/identifiers.rs index 9eeb915..d3586fd 100644 --- a/crates/dpp-registry/src/registry/identifiers.rs +++ b/crates/dpp-registry/src/registry/identifiers.rs @@ -11,7 +11,11 @@ use super::error::RegistryValidationError; fn validate_country_code(code: &str) -> Result<(), RegistryValidationError> { if code.is_empty() { - return Ok(()); // unknown/not-yet-set is acceptable pre-go-live + // `country` is a non-optional Annex III field; an empty value is a + // missing mandatory identifier, not an acceptable "unknown". + return Err(RegistryValidationError::MissingRequiredField( + "country".into(), + )); } if dpp_rules::country_code_valid(code) { Ok(()) @@ -72,6 +76,25 @@ pub struct ProductItemIdentifier { pub batch_id: Option, } +impl ProductItemIdentifier { + /// Validate the item identifier: `scheme` and `value` must both be + /// non-empty. Scheme-specific structural checks (SGTIN layout, etc.) are + /// deferred until the EU fixes item-identifier formats. + pub fn validate(&self) -> Result<(), RegistryValidationError> { + if self.scheme.is_empty() { + return Err(RegistryValidationError::MissingRequiredField( + "itemId.scheme".into(), + )); + } + if self.value.is_empty() { + return Err(RegistryValidationError::MissingRequiredField( + "itemId.value".into(), + )); + } + Ok(()) + } +} + /// Facility identifier — identifies the manufacturing or assembly facility. /// /// Used for market surveillance to trace products back to their physical @@ -137,9 +160,14 @@ pub struct OperatorIdentifier { impl OperatorIdentifier { /// Validate the operator identifier. /// - /// Checks the country code first, then applies a per-scheme structural or - /// checksum check (see `validate_operator_scheme`). + /// Checks that `name` is present, then the country code, then applies a + /// per-scheme structural or checksum check (see `validate_operator_scheme`). pub fn validate(&self) -> Result<(), RegistryValidationError> { + if self.name.is_empty() { + return Err(RegistryValidationError::MissingRequiredField( + "operatorId.name".into(), + )); + } validate_country_code(&self.country)?; validate_operator_scheme(&self.scheme, &self.value) } diff --git a/crates/dpp-registry/src/registry/payload.rs b/crates/dpp-registry/src/registry/payload.rs index 89635d2..32c15ab 100644 --- a/crates/dpp-registry/src/registry/payload.rs +++ b/crates/dpp-registry/src/registry/payload.rs @@ -45,12 +45,17 @@ impl RegistrationPayload { /// (GTIN checksum, invalid country codes) before a network round-trip. pub fn validate(&self) -> Result<(), RegistryValidationError> { self.product_id.validate()?; + self.item_id.validate()?; self.facility_id.validate()?; self.operator_id.validate()?; - if self.digital_link_url.is_empty() { - return Err(RegistryValidationError::MissingRequiredField( - "digitalLinkUrl".into(), - )); + for (name, value) in [ + ("sector", &self.sector), + ("schemaVersion", &self.schema_version), + ("digitalLinkUrl", &self.digital_link_url), + ] { + if value.is_empty() { + return Err(RegistryValidationError::MissingRequiredField(name.into())); + } } Ok(()) } diff --git a/crates/dpp-registry/src/registry/tests.rs b/crates/dpp-registry/src/registry/tests.rs index 90b5168..895aa26 100644 --- a/crates/dpp-registry/src/registry/tests.rs +++ b/crates/dpp-registry/src/registry/tests.rs @@ -214,7 +214,9 @@ fn valid_iso_country_passes() { } #[test] -fn empty_country_passes_as_unknown() { +fn empty_country_rejected() { + // `country` is a mandatory Annex III field — an empty value is a missing + // required identifier, not an acceptable "unknown". let fac = FacilityIdentifier { scheme: "national".into(), value: "FAC-001".into(), @@ -222,7 +224,10 @@ fn empty_country_passes_as_unknown() { country: String::new(), address: None, }; - assert!(fac.validate().is_ok()); + assert!(matches!( + fac.validate(), + Err(RegistryValidationError::MissingRequiredField(_)) + )); } #[test] @@ -360,6 +365,52 @@ fn payload_with_invalid_gtin_fails() { )); } +#[test] +fn empty_item_id_rejected() { + let mut payload = sample_payload(); + payload.item_id = ProductItemIdentifier { + scheme: String::new(), + value: String::new(), + batch_id: None, + }; + assert!(matches!( + payload.validate(), + Err(RegistryValidationError::MissingRequiredField(_)) + )); +} + +#[test] +fn empty_sector_or_schema_version_rejected() { + let mut payload = sample_payload(); + payload.sector = String::new(); + assert!(matches!( + payload.validate(), + Err(RegistryValidationError::MissingRequiredField(_)) + )); + + let mut payload = sample_payload(); + payload.schema_version = String::new(); + assert!(matches!( + payload.validate(), + Err(RegistryValidationError::MissingRequiredField(_)) + )); +} + +#[test] +fn empty_operator_name_rejected() { + let op = OperatorIdentifier { + scheme: "vat".into(), + value: "DE123456789".into(), + name: String::new(), + country: "DE".into(), + did: None, + }; + assert!(matches!( + op.validate(), + Err(RegistryValidationError::MissingRequiredField(_)) + )); +} + #[test] fn validation_error_display_messages() { let gtin = RegistryValidationError::InvalidGtin { diff --git a/crates/dpp-rules/src/bundle/tests.rs b/crates/dpp-rules/src/bundle/tests.rs index a2e0908..d911bb2 100644 --- a/crates/dpp-rules/src/bundle/tests.rs +++ b/crates/dpp-rules/src/bundle/tests.rs @@ -33,7 +33,7 @@ fn manifest(version: &str, content: &serde_json::Value) -> RulesetManifest { effective_date: chrono::Utc::now(), act_citations: vec!["ESPR Art. 25".into()], schema_versions: BTreeMap::from([("textile".to_owned(), "2.0.0".to_owned())]), - content_sha256: content_hash(content), + content_sha256: content_hash(content).expect("finite test content hashes"), } } @@ -123,5 +123,33 @@ fn malformed_jws_payload_not_json_is_refused() { fn content_hash_is_stable_for_key_order() { let a = serde_json::json!({ "a": 1, "b": 2 }); let b = serde_json::json!({ "b": 2, "a": 1 }); - assert_eq!(content_hash(&a), content_hash(&b)); + assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap()); +} + +#[test] +fn content_hash_errors_on_non_finite_float() { + // serde_json parses a huge exponent to f64::INFINITY; JCS (RFC 8785) + // rejects non-finite floats. content_hash must return Err, not panic. + let content: serde_json::Value = serde_json::from_str(r#"{ "x": 1e400 }"#).unwrap(); + assert!(content["x"].as_f64().unwrap().is_infinite()); + assert!(matches!( + content_hash(&content), + Err(RulesetError::Malformed(_)) + )); +} + +#[test] +fn verify_bundle_errors_on_non_finite_content() { + // A well-signed bundle whose unauthenticated content holds a non-finite + // float must fail closed on the integrity step, not panic. + let content: serde_json::Value = serde_json::from_str(r#"{ "threshold": 1e400 }"#).unwrap(); + let m = manifest("1.0.0", &serde_json::json!({ "threshold": 0 })); + let bundle = SignedBundle { + manifest_jws: fake_jws(&m), + content, + }; + assert!(matches!( + verify_bundle(&bundle, "pubkey", &AlwaysOk), + Err(RulesetError::Malformed(_)) + )); } diff --git a/crates/dpp-rules/src/bundle/verify.rs b/crates/dpp-rules/src/bundle/verify.rs index 808af53..4cb7039 100644 --- a/crates/dpp-rules/src/bundle/verify.rs +++ b/crates/dpp-rules/src/bundle/verify.rs @@ -29,10 +29,15 @@ pub trait JwsVerify { /// /// Exposed so a signer builds the exact same `content_sha256` a verifier /// will later check against — one hash function, two call sites. -#[must_use] -pub fn content_hash(content: &serde_json::Value) -> String { - let bytes = serde_jcs::to_vec(content).expect("JCS canonicalisation is infallible"); - hex::encode(Sha256::digest(&bytes)) +/// +/// # Errors +/// [`RulesetError::Malformed`] if `content` cannot be JCS-canonicalised — RFC +/// 8785 rejects non-finite floats (`NaN`/`Infinity`), which can appear in a +/// bundle's unauthenticated `content` deserialized straight off the wire. +pub fn content_hash(content: &serde_json::Value) -> Result { + let bytes = serde_jcs::to_vec(content) + .map_err(|e| RulesetError::Malformed(format!("JCS canonicalisation failed: {e}")))?; + Ok(hex::encode(Sha256::digest(&bytes))) } /// Verify a bundle against the pinned publisher public key (base64url). Both @@ -53,7 +58,7 @@ pub fn verify_bundle( // (2) The manifest is now trusted — extract it from the JWS payload. let manifest: RulesetManifest = decode_jws_payload(&bundle.manifest_jws)?; // (3) Integrity: content must hash to what the signed manifest commits to. - if content_hash(&bundle.content) != manifest.content_sha256 { + if content_hash(&bundle.content)? != manifest.content_sha256 { return Err(RulesetError::ContentHashMismatch); } Ok(VerifiedRuleset { diff --git a/crates/dpp-rules/src/lib.rs b/crates/dpp-rules/src/lib.rs index ccf6452..b7b21ef 100644 --- a/crates/dpp-rules/src/lib.rs +++ b/crates/dpp-rules/src/lib.rs @@ -37,6 +37,9 @@ pub mod batteries; pub mod electronics; pub mod textiles; +// Plausibility lints — non-binding findings, never a compliance gate. +pub mod lint; + // Placeholder sectors — rules to be implemented in a later phase. pub mod construction; pub mod metals; diff --git a/crates/dpp-rules/src/lint/battery.rs b/crates/dpp-rules/src/lint/battery.rs new file mode 100644 index 0000000..c8db84b --- /dev/null +++ b/crates/dpp-rules/src/lint/battery.rs @@ -0,0 +1,311 @@ +//! Battery plausibility lints — physically-grounded consistency checks that +//! EU Battery Regulation 2023/1542 does not itself require, but that flag +//! likely data-entry mistakes: energy/capacity arithmetic, unit-conversion +//! consistency, material-composition sums, and date/range plausibility. + +use alloc::{format, string::String, vec::Vec}; + +use super::{LintFinding, LintSeverity}; + +/// Borrowing view over the battery fields these lints inspect. +#[derive(Debug, Clone, Copy)] +pub struct BatteryLintInput<'a> { + pub nominal_voltage_v: f64, + pub nominal_capacity_ah: f64, + pub rated_energy_wh: Option, + pub rated_capacity_kwh: Option, + pub operating_temp_min_c: Option, + pub operating_temp_max_c: Option, + /// Unix seconds. `None` when the passport carries no manufacturing date. + pub manufacturing_date_unix: Option, + /// Unix seconds "now" — this crate has no clock, so the caller supplies it. + pub as_of_unix: i64, + pub cathode_material_pct: &'a [f64], + pub anode_material_pct: &'a [f64], + pub electrolyte_material_pct: &'a [f64], +} + +const ENERGY_CAPACITY_TOLERANCE_PCT: f64 = 20.0; + +/// Physics check: energy (Wh) = voltage (V) × capacity (Ah). A declared +/// `ratedEnergyWh` more than `ENERGY_CAPACITY_TOLERANCE_PCT` away from the +/// V×Ah product is more likely a data-entry error than a real spec — though a +/// generous tolerance is kept since manufacturers sometimes rate energy at +/// average (not nominal) discharge voltage, hence `Notice` not `Warning`. +#[must_use] +pub fn energy_capacity_mismatch(input: &BatteryLintInput<'_>) -> Option { + let declared = input.rated_energy_wh?; + let computed = input.nominal_voltage_v * input.nominal_capacity_ah; + if !declared.is_finite() || !computed.is_finite() || computed <= 0.0 { + return None; + } + let deviation_pct = ((declared - computed).abs() / computed) * 100.0; + if deviation_pct <= ENERGY_CAPACITY_TOLERANCE_PCT { + return None; + } + Some(LintFinding { + code: "battery.energy_capacity_mismatch", + field: "ratedEnergyWh", + severity: LintSeverity::Notice, + message: format!( + "ratedEnergyWh ({declared:.1} Wh) differs from nominalVoltageV × nominalCapacityAh \ + ({computed:.1} Wh) by {deviation_pct:.0}% — intended?" + ), + }) +} + +const CAPACITY_UNIT_TOLERANCE_PCT: f64 = 5.0; + +/// Unit-conversion check: `ratedCapacityKwh × 1000` and `ratedEnergyWh` +/// describe the same physical quantity in different units and should match +/// closely — unlike the voltage×capacity estimate above, there is no +/// legitimate reason for these two to diverge by more than rounding. +#[must_use] +pub fn rated_capacity_kwh_wh_mismatch(input: &BatteryLintInput<'_>) -> Option { + let kwh = input.rated_capacity_kwh?; + let wh = input.rated_energy_wh?; + if !kwh.is_finite() || !wh.is_finite() { + return None; + } + let computed_wh = kwh * 1000.0; + if computed_wh <= 0.0 { + return None; + } + let deviation_pct = ((wh - computed_wh).abs() / computed_wh) * 100.0; + if deviation_pct <= CAPACITY_UNIT_TOLERANCE_PCT { + return None; + } + Some(LintFinding { + code: "battery.rated_capacity_kwh_wh_mismatch", + field: "ratedCapacityKwh", + severity: LintSeverity::Warning, + message: format!( + "ratedCapacityKwh ({kwh:.3} kWh = {computed_wh:.1} Wh) does not match ratedEnergyWh \ + ({wh:.1} Wh) — intended?" + ), + }) +} + +const MATERIAL_SUM_TOLERANCE_PCT: f64 = 2.0; + +fn material_sum_finding(field: &'static str, pcts: &[f64]) -> Option { + if pcts.is_empty() { + return None; + } + let total: f64 = pcts.iter().copied().sum(); + if !total.is_finite() { + return None; + } + if (total - 100.0).abs() <= MATERIAL_SUM_TOLERANCE_PCT { + return None; + } + Some(LintFinding { + code: "battery.material_composition_sum", + field, + severity: LintSeverity::Warning, + message: format!( + "{field} weightPct entries sum to {total:.1}%, expected ~100% — intended?" + ), + }) +} + +/// Sum-consistency check: `cathodeMaterial`, `anodeMaterial`, and +/// `electrolyteMaterial` are each declared as a `weightPct` breakdown of that +/// component — none of the three currently have a hard schema or cross-field +/// gate requiring them to sum to 100%. Fires independently per list, so 0–3 +/// findings can result from one call. +#[must_use] +pub fn material_composition_sums(input: &BatteryLintInput<'_>) -> Vec { + [ + ("cathodeMaterial", input.cathode_material_pct), + ("anodeMaterial", input.anode_material_pct), + ("electrolyteMaterial", input.electrolyte_material_pct), + ] + .into_iter() + .filter_map(|(field, pcts)| material_sum_finding(field, pcts)) + .collect() +} + +/// Cross-field ordering: a declared manufacturing date after "now" cannot be +/// correct — the battery hasn't been made yet. +#[must_use] +pub fn manufacturing_date_in_future(input: &BatteryLintInput<'_>) -> Option { + let mfg = input.manufacturing_date_unix?; + if mfg <= input.as_of_unix { + return None; + } + Some(LintFinding { + code: "battery.manufacturing_date_in_future", + field: "manufacturingDate", + severity: LintSeverity::Warning, + message: String::from("manufacturingDate is in the future — intended?"), + }) +} + +const OPERATING_TEMP_MIN_PLAUSIBLE_C: f64 = -60.0; +const OPERATING_TEMP_MAX_PLAUSIBLE_C: f64 = 150.0; + +/// Range plausibility: no commercial battery chemistry operates outside +/// roughly -60°C to 150°C. A declared bound beyond that is far more likely a +/// unit slip (e.g. Fahrenheit entered as Celsius) than a real spec. Distinct +/// from [`crate::batteries::chemistry::validate_operating_temp_range`], which +/// checks `min < max` — this checks each bound against a plausible envelope. +#[must_use] +pub fn operating_temp_absurd_range(input: &BatteryLintInput<'_>) -> Vec { + let mut out = Vec::new(); + if let Some(min) = input.operating_temp_min_c + && min.is_finite() + && min < OPERATING_TEMP_MIN_PLAUSIBLE_C + { + out.push(LintFinding { + code: "battery.operating_temp_range_implausible", + field: "operatingTempMinC", + severity: LintSeverity::Notice, + message: format!( + "operatingTempMinC ({min}°C) is outside the plausible range for any known battery \ + chemistry — intended?" + ), + }); + } + if let Some(max) = input.operating_temp_max_c + && max.is_finite() + && max > OPERATING_TEMP_MAX_PLAUSIBLE_C + { + out.push(LintFinding { + code: "battery.operating_temp_range_implausible", + field: "operatingTempMaxC", + severity: LintSeverity::Notice, + message: format!( + "operatingTempMaxC ({max}°C) is outside the plausible range for any known battery \ + chemistry — intended?" + ), + }); + } + out +} + +/// Run every battery plausibility lint and collect the findings. +#[must_use] +pub fn lint_battery(input: &BatteryLintInput<'_>) -> Vec { + let mut out = Vec::new(); + out.extend(energy_capacity_mismatch(input)); + out.extend(rated_capacity_kwh_wh_mismatch(input)); + out.extend(material_composition_sums(input)); + out.extend(manufacturing_date_in_future(input)); + out.extend(operating_temp_absurd_range(input)); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_input() -> BatteryLintInput<'static> { + BatteryLintInput { + nominal_voltage_v: 3.7, + nominal_capacity_ah: 10.0, + rated_energy_wh: Some(37.0), + rated_capacity_kwh: Some(0.037), + operating_temp_min_c: Some(-20.0), + operating_temp_max_c: Some(60.0), + manufacturing_date_unix: Some(1_000), + as_of_unix: 2_000, + cathode_material_pct: &[], + anode_material_pct: &[], + electrolyte_material_pct: &[], + } + } + + // ── energy_capacity_mismatch ──────────────────────────────────────────── + + #[test] + fn energy_capacity_within_tolerance_passes() { + assert!(energy_capacity_mismatch(&base_input()).is_none()); + } + + #[test] + fn energy_capacity_far_off_triggers() { + let mut input = base_input(); + input.rated_energy_wh = Some(200.0); // 37.0 expected, way off + let finding = energy_capacity_mismatch(&input).unwrap(); + assert_eq!(finding.code, "battery.energy_capacity_mismatch"); + assert_eq!(finding.severity, LintSeverity::Notice); + } + + // ── rated_capacity_kwh_wh_mismatch ────────────────────────────────────── + + #[test] + fn capacity_unit_consistent_passes() { + assert!(rated_capacity_kwh_wh_mismatch(&base_input()).is_none()); + } + + #[test] + fn capacity_unit_mismatch_triggers() { + let mut input = base_input(); + input.rated_capacity_kwh = Some(1.0); // 1000 Wh expected, declared 37 Wh + let finding = rated_capacity_kwh_wh_mismatch(&input).unwrap(); + assert_eq!(finding.code, "battery.rated_capacity_kwh_wh_mismatch"); + assert_eq!(finding.severity, LintSeverity::Warning); + } + + // ── material_composition_sums ─────────────────────────────────────────── + + #[test] + fn material_sums_near_100_pass() { + let mut input = base_input(); + input.cathode_material_pct = &[60.0, 40.0]; + assert!(material_composition_sums(&input).is_empty()); + } + + #[test] + fn material_sum_off_triggers() { + let mut input = base_input(); + input.cathode_material_pct = &[60.0, 20.0]; // sums to 80 + let findings = material_composition_sums(&input); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].field, "cathodeMaterial"); + } + + #[test] + fn empty_material_lists_never_trigger() { + assert!(material_composition_sums(&base_input()).is_empty()); + } + + // ── manufacturing_date_in_future ──────────────────────────────────────── + + #[test] + fn manufacturing_date_in_past_passes() { + assert!(manufacturing_date_in_future(&base_input()).is_none()); + } + + #[test] + fn manufacturing_date_in_future_triggers() { + let mut input = base_input(); + input.manufacturing_date_unix = Some(3_000); // as_of is 2_000 + let finding = manufacturing_date_in_future(&input).unwrap(); + assert_eq!(finding.code, "battery.manufacturing_date_in_future"); + } + + // ── operating_temp_absurd_range ───────────────────────────────────────── + + #[test] + fn plausible_temp_range_passes() { + assert!(operating_temp_absurd_range(&base_input()).is_empty()); + } + + #[test] + fn absurd_temp_range_triggers_both_bounds() { + let mut input = base_input(); + input.operating_temp_min_c = Some(-200.0); + input.operating_temp_max_c = Some(500.0); + let findings = operating_temp_absurd_range(&input); + assert_eq!(findings.len(), 2); + } + + // ── lint_battery aggregator ───────────────────────────────────────────── + + #[test] + fn clean_input_produces_no_findings() { + assert!(lint_battery(&base_input()).is_empty()); + } +} diff --git a/crates/dpp-rules/src/lint/mod.rs b/crates/dpp-rules/src/lint/mod.rs new file mode 100644 index 0000000..915e0b9 --- /dev/null +++ b/crates/dpp-rules/src/lint/mod.rs @@ -0,0 +1,23 @@ +//! Passport plausibility lints — non-binding findings that flag *implausible* +//! data (arithmetic that doesn't add up, values outside physically plausible +//! ranges, or fields whose declared values are inconsistent with each other), +//! as distinct from the *malformed* data JSON Schema catches or the +//! *non-compliant* data a [`crate::batteries`]/[`crate::textiles`]-style rule +//! catches. A lint finding never blocks publish — see [`LintSeverity`]. +//! +//! Each lint cites the physics or arithmetic behind it, the way the sector +//! rule modules cite the regulatory article behind a binding rule. Findings +//! are phrased as questions ("intended?"), never verdicts. + +mod types; + +pub mod battery; +pub mod textile; +pub mod unsold_goods; + +pub use types::{LintFinding, LintSeverity}; + +/// Version of this crate's lint pack. Bump whenever a lint is added, removed, +/// or its trigger condition changes — callers surface this alongside +/// findings so a consumer can tell which pack produced them. +pub const LINT_PACK_VERSION: &str = "1.0.0"; diff --git a/crates/dpp-rules/src/lint/textile.rs b/crates/dpp-rules/src/lint/textile.rs new file mode 100644 index 0000000..ba92ca4 --- /dev/null +++ b/crates/dpp-rules/src/lint/textile.rs @@ -0,0 +1,287 @@ +//! Textile plausibility lints — consistency checks the EU textile DPP rules +//! do not themselves require, but that flag likely mistakes or claims made +//! without their usual supporting field. + +use alloc::{format, vec::Vec}; + +use super::{LintFinding, LintSeverity}; + +/// Borrowing view over the textile fields these lints inspect. +#[derive(Debug, Clone, Copy)] +pub struct TextileLintInput<'a> { + pub durability_score: Option, + pub expected_wash_cycles: Option, + pub repair_count: Option, + pub repair_history_url: Option<&'a str>, + pub prior_use_cycles: Option, + pub reuse_condition: Option<&'a str>, + pub repair_score: Option, + pub disassembly_instructions: Option<&'a str>, + pub spare_parts_available: Option, + pub microplastic_shedding_mg_per_wash: Option, + pub fibres: &'a [&'a str], +} + +const HIGH_DURABILITY_THRESHOLD: f64 = 8.0; +const LOW_WASH_CYCLES_THRESHOLD: u32 = 10; + +/// Cross-field plausibility: a garment declared highly durable (≥8/10) that +/// is also expected to degrade within under 10 wash cycles is self-contradictory. +#[must_use] +pub fn durability_wash_cycles_mismatch(input: &TextileLintInput<'_>) -> Option { + let durability = input.durability_score?; + let cycles = input.expected_wash_cycles?; + if durability < HIGH_DURABILITY_THRESHOLD || cycles >= LOW_WASH_CYCLES_THRESHOLD { + return None; + } + Some(LintFinding { + code: "textile.durability_wash_cycles_mismatch", + field: "expectedWashCycles", + severity: LintSeverity::Notice, + message: format!( + "durabilityScore ({durability:.1}/10) is high but expectedWashCycles ({cycles}) is low — intended?" + ), + }) +} + +/// Claim-without-evidence check: a positive `repairCount` with no +/// `repairHistoryUrl` asserts repairs happened with nothing to point to. +#[must_use] +pub fn repair_count_without_history(input: &TextileLintInput<'_>) -> Option { + let count = input.repair_count?; + if count == 0 || input.repair_history_url.is_some() { + return None; + } + Some(LintFinding { + code: "textile.repair_count_without_history", + field: "repairCount", + severity: LintSeverity::Notice, + message: format!("repairCount is {count} but repairHistoryUrl is absent — intended?"), + }) +} + +/// Claim-without-evidence check: a positive `priorUseCycles` (this is not a +/// new item) with no `reuseCondition` grade omits the condition a buyer of a +/// used item would expect. +#[must_use] +pub fn prior_use_without_reuse_condition(input: &TextileLintInput<'_>) -> Option { + let cycles = input.prior_use_cycles?; + if cycles == 0 || input.reuse_condition.is_some() { + return None; + } + Some(LintFinding { + code: "textile.prior_use_without_reuse_condition", + field: "priorUseCycles", + severity: LintSeverity::Notice, + message: format!("priorUseCycles is {cycles} but reuseCondition is absent — intended?"), + }) +} + +const HIGH_REPAIR_SCORE_THRESHOLD: f64 = 8.0; + +/// Claim-without-evidence check: a high `repairScore` (≥8/10) with neither +/// disassembly instructions nor confirmed spare-parts availability asserts +/// repairability with nothing backing it. +#[must_use] +pub fn repair_score_high_without_support(input: &TextileLintInput<'_>) -> Option { + let score = input.repair_score?; + if score < HIGH_REPAIR_SCORE_THRESHOLD + || input.disassembly_instructions.is_some() + || input.spare_parts_available == Some(true) + { + return None; + } + Some(LintFinding { + code: "textile.repair_score_high_without_support", + field: "repairScore", + severity: LintSeverity::Notice, + message: format!( + "repairScore ({score:.1}/10) is high but neither disassemblyInstructions nor \ + sparePartsAvailable is declared — intended?" + ), + }) +} + +const KNOWN_NATURAL_FIBRES: &[&str] = &[ + "cotton", "wool", "silk", "linen", "hemp", "jute", "cashmere", "alpaca", "mohair", "flax", + "ramie", "angora", +]; + +fn is_known_natural(fibre: &str) -> bool { + let normalized = fibre.trim(); + KNOWN_NATURAL_FIBRES + .iter() + .any(|f| normalized.eq_ignore_ascii_case(f)) +} + +/// Physics check: microplastic fibre shedding (ISO/DIS 4484) is a +/// synthetic-polymer phenomenon — natural fibres like cotton or wool do not +/// shed microplastics on washing. Fires only when *every* declared fibre is +/// recognised as natural, to avoid false positives on unrecognised or blended +/// synthetic names. +#[must_use] +pub fn microplastic_shedding_without_synthetic_fibre( + input: &TextileLintInput<'_>, +) -> Option { + let shedding = input.microplastic_shedding_mg_per_wash?; + if !shedding.is_finite() || shedding <= 0.0 { + return None; + } + if input.fibres.is_empty() || !input.fibres.iter().all(|f| is_known_natural(f)) { + return None; + } + Some(LintFinding { + code: "textile.microplastic_shedding_without_synthetic_fibre", + field: "microplasticSheddingMgPerWash", + severity: LintSeverity::Notice, + message: format!( + "microplasticSheddingMgPerWash ({shedding:.2} mg) is declared but every fibre in \ + fibreComposition is a natural fibre — intended?" + ), + }) +} + +/// Run every textile plausibility lint and collect the findings. +#[must_use] +pub fn lint_textile(input: &TextileLintInput<'_>) -> Vec { + let mut out = Vec::new(); + out.extend(durability_wash_cycles_mismatch(input)); + out.extend(repair_count_without_history(input)); + out.extend(prior_use_without_reuse_condition(input)); + out.extend(repair_score_high_without_support(input)); + out.extend(microplastic_shedding_without_synthetic_fibre(input)); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_input() -> TextileLintInput<'static> { + TextileLintInput { + durability_score: Some(5.0), + expected_wash_cycles: Some(50), + repair_count: None, + repair_history_url: None, + prior_use_cycles: None, + reuse_condition: None, + repair_score: Some(5.0), + disassembly_instructions: None, + spare_parts_available: None, + microplastic_shedding_mg_per_wash: None, + fibres: &["cotton", "polyester"], + } + } + + // ── durability_wash_cycles_mismatch ───────────────────────────────────── + + #[test] + fn durability_wash_cycles_consistent_passes() { + assert!(durability_wash_cycles_mismatch(&base_input()).is_none()); + } + + #[test] + fn high_durability_low_cycles_triggers() { + let mut input = base_input(); + input.durability_score = Some(9.0); + input.expected_wash_cycles = Some(5); + let finding = durability_wash_cycles_mismatch(&input).unwrap(); + assert_eq!(finding.code, "textile.durability_wash_cycles_mismatch"); + } + + // ── repair_count_without_history ──────────────────────────────────────── + + #[test] + fn no_repairs_passes() { + assert!(repair_count_without_history(&base_input()).is_none()); + } + + #[test] + fn repairs_without_url_triggers() { + let mut input = base_input(); + input.repair_count = Some(3); + let finding = repair_count_without_history(&input).unwrap(); + assert_eq!(finding.code, "textile.repair_count_without_history"); + } + + #[test] + fn repairs_with_url_passes() { + let mut input = base_input(); + input.repair_count = Some(3); + input.repair_history_url = Some("https://example.com/repairs/123"); + assert!(repair_count_without_history(&input).is_none()); + } + + // ── prior_use_without_reuse_condition ─────────────────────────────────── + + #[test] + fn new_item_passes() { + assert!(prior_use_without_reuse_condition(&base_input()).is_none()); + } + + #[test] + fn prior_use_without_condition_triggers() { + let mut input = base_input(); + input.prior_use_cycles = Some(2); + let finding = prior_use_without_reuse_condition(&input).unwrap(); + assert_eq!(finding.code, "textile.prior_use_without_reuse_condition"); + } + + // ── repair_score_high_without_support ─────────────────────────────────── + + #[test] + fn moderate_repair_score_passes() { + assert!(repair_score_high_without_support(&base_input()).is_none()); + } + + #[test] + fn high_repair_score_without_support_triggers() { + let mut input = base_input(); + input.repair_score = Some(9.0); + let finding = repair_score_high_without_support(&input).unwrap(); + assert_eq!(finding.code, "textile.repair_score_high_without_support"); + } + + #[test] + fn high_repair_score_with_spare_parts_passes() { + let mut input = base_input(); + input.repair_score = Some(9.0); + input.spare_parts_available = Some(true); + assert!(repair_score_high_without_support(&input).is_none()); + } + + // ── microplastic_shedding_without_synthetic_fibre ─────────────────────── + + #[test] + fn shedding_with_synthetic_fibre_passes() { + let mut input = base_input(); + input.microplastic_shedding_mg_per_wash = Some(12.0); // fibres include polyester + assert!(microplastic_shedding_without_synthetic_fibre(&input).is_none()); + } + + #[test] + fn shedding_with_only_natural_fibres_triggers() { + let mut input = base_input(); + input.fibres = &["cotton", "wool"]; + input.microplastic_shedding_mg_per_wash = Some(12.0); + let finding = microplastic_shedding_without_synthetic_fibre(&input).unwrap(); + assert_eq!( + finding.code, + "textile.microplastic_shedding_without_synthetic_fibre" + ); + } + + #[test] + fn no_shedding_declared_passes() { + let mut input = base_input(); + input.fibres = &["cotton"]; + assert!(microplastic_shedding_without_synthetic_fibre(&input).is_none()); + } + + // ── lint_textile aggregator ───────────────────────────────────────────── + + #[test] + fn clean_input_produces_no_findings() { + assert!(lint_textile(&base_input()).is_empty()); + } +} diff --git a/crates/dpp-rules/src/lint/types.rs b/crates/dpp-rules/src/lint/types.rs new file mode 100644 index 0000000..46cd50a --- /dev/null +++ b/crates/dpp-rules/src/lint/types.rs @@ -0,0 +1,28 @@ +//! Shared lint finding types. + +use alloc::string::String; + +/// How strongly a lint finding should be read. Never blocking either way — +/// the distinction is tone, not gating (ADR-002: informative only). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LintSeverity { + /// A data point that is very likely a mistake (e.g. a unit-conversion + /// mismatch between two fields describing the same physical quantity). + Warning, + /// A softer signal — plausible but worth a second look (e.g. a claim + /// with no supporting field, or a value near a physically wide bound). + Notice, +} + +/// A single plausibility finding. Phrased as a question, never a verdict — +/// callers should render `message` as-is. +#[derive(Debug, Clone, PartialEq)] +pub struct LintFinding { + /// Stable machine-readable code, e.g. `"battery.energy_capacity_mismatch"`. + pub code: &'static str, + /// camelCase field locator this finding is primarily about. + pub field: &'static str, + pub severity: LintSeverity, + /// Human-readable finding, phrased as a question. + pub message: String, +} diff --git a/crates/dpp-rules/src/lint/unsold_goods.rs b/crates/dpp-rules/src/lint/unsold_goods.rs new file mode 100644 index 0000000..5a325b4 --- /dev/null +++ b/crates/dpp-rules/src/lint/unsold_goods.rs @@ -0,0 +1,354 @@ +//! Unsold-goods plausibility lints (EU ESPR Article 25 destruction ban +//! reports) — consistency checks the schema does not itself require. + +use alloc::{format, vec::Vec}; + +use super::{LintFinding, LintSeverity}; + +/// Borrowing view over the unsold-goods report fields these lints inspect. +#[derive(Debug, Clone, Copy)] +pub struct UnsoldGoodsLintInput<'a> { + pub reporting_period: &'a str, + pub volume_kg: f64, + /// Serde code, e.g. `"donation"`, `"exempt_destruction"`. + pub destination: &'a str, + pub operator_name: Option<&'a str>, + pub destruction_justification: Option<&'a str>, + /// Current UTC year — this crate has no clock, so the caller supplies it. + pub as_of_year: u32, + /// Current UTC month (1–12). + pub as_of_month: u32, +} + +struct ParsedPeriod { + year: u32, + /// `None` for a bare year; otherwise the last month the period covers + /// (quarter end for `YYYY-QN`, the month itself for `YYYY-MM`). + end_month: Option, +} + +fn parse_reporting_period(s: &str) -> Option { + let s = s.trim(); + let is_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit()); + + if s.len() == 4 && is_digits(s) { + return Some(ParsedPeriod { + year: s.parse().ok()?, + end_month: None, + }); + } + if let Some((y, q)) = s.split_once("-Q").or_else(|| s.split_once("-q")) { + if y.len() == 4 && is_digits(y) && q.len() == 1 && is_digits(q) { + let quarter: u32 = q.parse().ok()?; + if (1..=4).contains(&quarter) { + return Some(ParsedPeriod { + year: y.parse().ok()?, + end_month: Some(quarter * 3), + }); + } + } + return None; + } + if let Some((y, m)) = s.split_once('-') { + if y.len() == 4 && is_digits(y) && m.len() == 2 && is_digits(m) { + let month: u32 = m.parse().ok()?; + if (1..=12).contains(&month) { + return Some(ParsedPeriod { + year: y.parse().ok()?, + end_month: Some(month), + }); + } + } + return None; + } + None +} + +/// Format plausibility: `reportingPeriod` is free text in the schema, but a +/// value that matches none of the conventional forms (`YYYY`, `YYYY-QN`, +/// `YYYY-MM`) used elsewhere in this report is likely a typo. +#[must_use] +pub fn reporting_period_format_implausible( + input: &UnsoldGoodsLintInput<'_>, +) -> Option { + if parse_reporting_period(input.reporting_period).is_some() { + return None; + } + Some(LintFinding { + code: "unsold_goods.reporting_period_format_implausible", + field: "reportingPeriod", + severity: LintSeverity::Notice, + message: format!( + "reportingPeriod '{}' does not match a recognised YYYY, YYYY-QN, or YYYY-MM format — intended?", + input.reporting_period + ), + }) +} + +/// Cross-field ordering: a disposal cannot be reported for a period that +/// hasn't happened yet. Only fires when the period parses (see +/// [`reporting_period_format_implausible`] for the unparsable case). +#[must_use] +pub fn reporting_period_in_future(input: &UnsoldGoodsLintInput<'_>) -> Option { + let period = parse_reporting_period(input.reporting_period)?; + let is_future = match period.end_month { + Some(month) => { + period.year > input.as_of_year + || (period.year == input.as_of_year && month > input.as_of_month) + } + None => period.year > input.as_of_year, + }; + if !is_future { + return None; + } + Some(LintFinding { + code: "unsold_goods.reporting_period_in_future", + field: "reportingPeriod", + severity: LintSeverity::Warning, + message: format!( + "reportingPeriod '{}' is in the future — intended?", + input.reporting_period + ), + }) +} + +const VOLUME_KG_IMPLAUSIBLE_THRESHOLD: f64 = 1_000_000.0; + +/// Range plausibility: 1,000 tonnes of unsold goods in a single report is far +/// beyond a typical reporting-period volume — worth a second look as a +/// possible unit slip (e.g. grams entered as kilograms). +#[must_use] +pub fn volume_kg_implausibly_large(input: &UnsoldGoodsLintInput<'_>) -> Option { + if !input.volume_kg.is_finite() || input.volume_kg <= VOLUME_KG_IMPLAUSIBLE_THRESHOLD { + return None; + } + Some(LintFinding { + code: "unsold_goods.volume_kg_implausibly_large", + field: "volumeKg", + severity: LintSeverity::Notice, + message: format!( + "volumeKg ({}) exceeds 1,000,000 kg for a single report — intended, or a unit slip \ + (e.g. grams entered as kilograms)?", + input.volume_kg + ), + }) +} + +const THIRD_PARTY_DESTINATIONS: &[&str] = &[ + "donation", + "recycling", + "supplier_return", + "exempt_destruction", +]; + +/// Claim-without-evidence check: the schema's own field description calls +/// `operatorName` "required for audit trail" for third-party destinations +/// (everything except a same-operator repurposing), yet the field itself is +/// optional. +#[must_use] +pub fn operator_name_missing_for_third_party_destination( + input: &UnsoldGoodsLintInput<'_>, +) -> Option { + let is_third_party = THIRD_PARTY_DESTINATIONS + .iter() + .any(|d| input.destination.eq_ignore_ascii_case(d)); + if !is_third_party || input.operator_name.is_some() { + return None; + } + Some(LintFinding { + code: "unsold_goods.operator_name_missing_for_third_party_destination", + field: "operatorName", + severity: LintSeverity::Notice, + message: format!( + "destination '{}' involves a third party but operatorName is absent — intended?", + input.destination + ), + }) +} + +/// Structural plausibility: `destructionJustification` only has defined +/// meaning when `destination` is `exempt_destruction` (the schema's own +/// conditional-required rule). A populated value alongside any other +/// destination is a stray field, not a schema violation. +#[must_use] +pub fn destruction_justification_without_exempt_destination( + input: &UnsoldGoodsLintInput<'_>, +) -> Option { + if input.destruction_justification.is_none() + || input.destination.eq_ignore_ascii_case("exempt_destruction") + { + return None; + } + Some(LintFinding { + code: "unsold_goods.destruction_justification_without_exempt_destination", + field: "destructionJustification", + severity: LintSeverity::Notice, + message: format!( + "destructionJustification is populated but destination is '{}', not exempt_destruction — intended?", + input.destination + ), + }) +} + +/// Run every unsold-goods plausibility lint and collect the findings. +#[must_use] +pub fn lint_unsold_goods(input: &UnsoldGoodsLintInput<'_>) -> Vec { + let mut out = Vec::new(); + out.extend(reporting_period_format_implausible(input)); + out.extend(reporting_period_in_future(input)); + out.extend(volume_kg_implausibly_large(input)); + out.extend(operator_name_missing_for_third_party_destination(input)); + out.extend(destruction_justification_without_exempt_destination(input)); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_input() -> UnsoldGoodsLintInput<'static> { + UnsoldGoodsLintInput { + reporting_period: "2026-Q2", + volume_kg: 500.0, + destination: "donation", + operator_name: Some("Caritas Skopje"), + destruction_justification: None, + as_of_year: 2026, + as_of_month: 7, + } + } + + // ── parse_reporting_period (via the two lints that use it) ───────────── + + #[test] + fn recognised_formats_all_parse() { + for s in ["2026", "2026-Q2", "2026-07"] { + let mut input = base_input(); + input.reporting_period = s; + assert!(reporting_period_format_implausible(&input).is_none(), "{s}"); + } + } + + #[test] + fn garbage_format_triggers() { + let mut input = base_input(); + input.reporting_period = "asdf"; + let finding = reporting_period_format_implausible(&input).unwrap(); + assert_eq!( + finding.code, + "unsold_goods.reporting_period_format_implausible" + ); + } + + #[test] + fn out_of_range_quarter_triggers_format_lint() { + let mut input = base_input(); + input.reporting_period = "2026-Q9"; + assert!(reporting_period_format_implausible(&input).is_some()); + } + + // ── reporting_period_in_future ────────────────────────────────────────── + + #[test] + fn past_period_passes() { + assert!(reporting_period_in_future(&base_input()).is_none()); + } + + #[test] + fn future_quarter_triggers() { + let mut input = base_input(); + input.reporting_period = "2027-Q1"; + let finding = reporting_period_in_future(&input).unwrap(); + assert_eq!(finding.code, "unsold_goods.reporting_period_in_future"); + } + + #[test] + fn same_year_later_month_triggers() { + let mut input = base_input(); + input.reporting_period = "2026-12"; + input.as_of_month = 7; + assert!(reporting_period_in_future(&input).is_some()); + } + + #[test] + fn unparsable_period_does_not_trigger_future_lint() { + let mut input = base_input(); + input.reporting_period = "asdf"; + assert!(reporting_period_in_future(&input).is_none()); + } + + // ── volume_kg_implausibly_large ───────────────────────────────────────── + + #[test] + fn ordinary_volume_passes() { + assert!(volume_kg_implausibly_large(&base_input()).is_none()); + } + + #[test] + fn huge_volume_triggers() { + let mut input = base_input(); + input.volume_kg = 5_000_000.0; + let finding = volume_kg_implausibly_large(&input).unwrap(); + assert_eq!(finding.code, "unsold_goods.volume_kg_implausibly_large"); + } + + // ── operator_name_missing_for_third_party_destination ─────────────────── + + #[test] + fn third_party_with_operator_name_passes() { + assert!(operator_name_missing_for_third_party_destination(&base_input()).is_none()); + } + + #[test] + fn third_party_without_operator_name_triggers() { + let mut input = base_input(); + input.operator_name = None; + let finding = operator_name_missing_for_third_party_destination(&input).unwrap(); + assert_eq!( + finding.code, + "unsold_goods.operator_name_missing_for_third_party_destination" + ); + } + + #[test] + fn repurposing_destination_never_triggers() { + let mut input = base_input(); + input.destination = "repurposing"; + input.operator_name = None; + assert!(operator_name_missing_for_third_party_destination(&input).is_none()); + } + + // ── destruction_justification_without_exempt_destination ──────────────── + + #[test] + fn no_justification_passes() { + assert!(destruction_justification_without_exempt_destination(&base_input()).is_none()); + } + + #[test] + fn justification_on_exempt_destination_passes() { + let mut input = base_input(); + input.destination = "exempt_destruction"; + input.destruction_justification = + Some("Contaminated batch, health authority order 2026-119"); + assert!(destruction_justification_without_exempt_destination(&input).is_none()); + } + + #[test] + fn justification_on_other_destination_triggers() { + let mut input = base_input(); + input.destruction_justification = Some("stray text"); + let finding = destruction_justification_without_exempt_destination(&input).unwrap(); + assert_eq!( + finding.code, + "unsold_goods.destruction_justification_without_exempt_destination" + ); + } + + // ── lint_unsold_goods aggregator ──────────────────────────────────────── + + #[test] + fn clean_input_produces_no_findings() { + assert!(lint_unsold_goods(&base_input()).is_empty()); + } +} diff --git a/crates/dpp-tests/Cargo.toml b/crates/dpp-tests/Cargo.toml index dd23bad..2abddd6 100644 --- a/crates/dpp-tests/Cargo.toml +++ b/crates/dpp-tests/Cargo.toml @@ -19,7 +19,6 @@ path = "src/lib.rs" [dev-dependencies] dpp-domain = { workspace = true } dpp-crypto = { workspace = true } -dpp-evidence = { workspace = true } dpp-digital-link = { workspace = true } serde_json = { workspace = true } chrono = { workspace = true } diff --git a/crates/dpp-tests/tests/adversarial_security.rs b/crates/dpp-tests/tests/adversarial_security.rs index 183fa55..1661628 100644 --- a/crates/dpp-tests/tests/adversarial_security.rs +++ b/crates/dpp-tests/tests/adversarial_security.rs @@ -485,7 +485,9 @@ fn passport_validate_catches_bad_fibre_sum() { co2e_per_unit: None, repairability_score: None, compliance_result: None, + lint_result: None, sector_data: Some(SectorData::Textile(TextileData { + gtin: "09506000134352".into(), // sum = 50%, should be ~100% fibre_composition: vec![FibreEntry { fibre: "cotton".into(), diff --git a/crates/dpp-tests/tests/all_sectors_aas.rs b/crates/dpp-tests/tests/all_sectors_aas.rs index bf66a6b..2ff4839 100644 --- a/crates/dpp-tests/tests/all_sectors_aas.rs +++ b/crates/dpp-tests/tests/all_sectors_aas.rs @@ -50,6 +50,7 @@ fn base(sector: Sector, sector_data: SectorData, schema_version: &str) -> Passpo co2e_per_unit: Some(CarbonFootprint::from_kg(12.0)), repairability_score: Some(RepairabilityScore::from_scalar(6.0)), compliance_result: None, + lint_result: None, sector_data: Some(sector_data), status: PassportStatus::Draft, qr_code_url: None, @@ -111,6 +112,7 @@ fn electronics_data() -> ElectronicsData { fn textile_data() -> TextileData { TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![FibreEntry { fibre: "cotton".into(), pct: 100.0, diff --git a/crates/dpp-tests/tests/battery_end_to_end.rs b/crates/dpp-tests/tests/battery_end_to_end.rs index 18bd0ed..fda77f7 100644 --- a/crates/dpp-tests/tests/battery_end_to_end.rs +++ b/crates/dpp-tests/tests/battery_end_to_end.rs @@ -63,6 +63,7 @@ fn make_battery_passport() -> Passport { co2e_per_unit: Some(CarbonFootprint::from_kg(73.2)), repairability_score: Some(RepairabilityScore::from_scalar(4.5)), compliance_result: None, + lint_result: None, sector_data: Some(SectorData::Battery(BatteryData { gtin: Gtin::parse(VALID_GTIN).unwrap(), battery_chemistry: BatteryChemistry::Lfp, diff --git a/crates/dpp-tests/tests/jws_cross_verification.rs b/crates/dpp-tests/tests/jws_cross_verification.rs deleted file mode 100644 index d34c259..0000000 --- a/crates/dpp-tests/tests/jws_cross_verification.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! Golden cross-tests between `dpp-crypto`'s JWS signer/verifier and -//! `dpp-evidence`'s vendored copy. -//! -//! `dpp-evidence` cannot depend on `dpp-crypto` directly (it breaks the -//! wasm32-unknown-unknown build — see `dpp-evidence/src/jws/mod.rs`'s doc -//! comment), so its verify path is a maintained duplicate. These tests are -//! the drift guard: sign with the real signer, verify with both -//! implementations, and assert they agree on every tamper class. - -use dpp_crypto::identity::did_builder::build_did_document; -use dpp_crypto::jws::{self as core_jws}; -use dpp_crypto::keystore::KeyStore; - -fn open_store() -> KeyStore { - let path = std::env::temp_dir().join(format!("jws-cross-test-{}.json", uuid::Uuid::now_v7())); - KeyStore::open(&path, "test-pass").expect("open keystore") -} - -// ── 1. Round-trip: sign with dpp-crypto, verify with dpp-evidence ───────── - -#[test] -fn round_trip_signs_with_core_verifies_with_evidence() { - let store = open_store(); - store.generate_key("root").expect("generate key"); - let payload = serde_json::json!({"passportId": "p1", "status": "active"}); - - let jws = core_jws::sign(&store, "root", &payload).expect("sign"); - let did_doc = build_did_document(&store, "cross-test.example.com", "root").expect("did doc"); - let key_b64 = dpp_evidence::jws::extract_primary_public_key(&did_doc) - .expect("dpp-evidence must find the primary key in a dpp-crypto-built DID document"); - - assert!(dpp_evidence::jws::verify_jws(&jws, &key_b64).unwrap()); - assert!(dpp_evidence::jws::verify_jws_content(&jws, &key_b64, &payload).unwrap()); - - // And the reverse direction, for good measure: dpp-crypto's own verifier - // over the same JWS/key. - assert!(core_jws::verify_jws(&jws, &key_b64).unwrap()); -} - -// ── 2. Tamper matrix — both implementations must reject every class ─────── - -#[test] -fn tamper_matrix_both_implementations_reject_every_class() { - let store = open_store(); - store.generate_key("root").expect("generate key"); - let payload = serde_json::json!({"a": 1, "b": "two"}); - let jws = core_jws::sign(&store, "root", &payload).expect("sign"); - let did_doc = build_did_document(&store, "cross-test.example.com", "root").expect("did doc"); - let key_b64 = dpp_evidence::jws::extract_primary_public_key(&did_doc).unwrap(); - - let parts: Vec<&str> = jws.splitn(3, '.').collect(); - assert_eq!(parts.len(), 3); - - // Flip the first character of a base64url segment to another valid - // base64url character — same length, so it stays decodable (unlike - // appending a character, which can break the base64 padding/length - // invariant and turn "tampered" into "malformed input" instead). - fn flip_first_char(segment: &str) -> String { - let replacement = if segment.starts_with('A') { 'B' } else { 'A' }; - format!("{replacement}{}", &segment[1..]) - } - - // Flipped payload byte. - let tampered_payload = format!("{}.{}.{}", parts[0], flip_first_char(parts[1]), parts[2]); - assert!(!core_jws::verify_jws(&tampered_payload, &key_b64).unwrap()); - assert!(!dpp_evidence::jws::verify_jws(&tampered_payload, &key_b64).unwrap()); - - // Flipped signature byte. - let tampered_sig = format!("{}.{}.{}", parts[0], parts[1], flip_first_char(parts[2])); - assert!(!core_jws::verify_jws(&tampered_sig, &key_b64).unwrap()); - assert!(!dpp_evidence::jws::verify_jws(&tampered_sig, &key_b64).unwrap()); - - // `alg` swapped to `none`. - let b64 = base64::Engine::encode( - &base64::engine::general_purpose::URL_SAFE_NO_PAD, - serde_json::to_vec(&serde_json::json!({"alg": "none"})).unwrap(), - ); - let alg_none = format!("{b64}.{}.{}", parts[1], parts[2]); - assert!(!core_jws::verify_jws(&alg_none, &key_b64).unwrap()); - assert!(!dpp_evidence::jws::verify_jws(&alg_none, &key_b64).unwrap()); - - // `alg` swapped to an unrelated algorithm name. - let b64 = base64::Engine::encode( - &base64::engine::general_purpose::URL_SAFE_NO_PAD, - serde_json::to_vec(&serde_json::json!({"alg": "HS256"})).unwrap(), - ); - let alg_other = format!("{b64}.{}.{}", parts[1], parts[2]); - assert!(!core_jws::verify_jws(&alg_other, &key_b64).unwrap()); - assert!(!dpp_evidence::jws::verify_jws(&alg_other, &key_b64).unwrap()); - - // Sanity: the untampered original still verifies under both. - assert!(core_jws::verify_jws(&jws, &key_b64).unwrap()); - assert!(dpp_evidence::jws::verify_jws(&jws, &key_b64).unwrap()); -} - -// ── 3. Key-resolution parity across rotation ─────────────────────────────── - -#[test] -fn dpp_evidence_resolves_a_rotation_archived_key_the_same_way_as_dpp_crypto() { - let store = open_store(); - store.generate_key("root").expect("generate key"); - let payload = serde_json::json!({"before": "rotation"}); - let old_jws = core_jws::sign(&store, "root", &payload).expect("sign with pre-rotation key"); - - // Hygiene rotation: the old key is archived but stays valid (not - // revoked) — `jws::verify(store, key_id, ..)` is not rotation-aware (it - // just loads whatever is *currently* stored at `key_id`), so the - // rotation-aware path both `LocalIdentityService::verify_signature` and - // the resolver/CLI actually use is: build the DID document (which lists - // archived, non-revoked keys too), extract the JWS's `kid`, and find the - // matching key by fingerprint. Reproduced inline here with dpp-crypto's - // own public functions so this is a true apples-to-apples comparison. - store.rotate_key("root").expect("rotate"); - let did_doc_after = build_did_document(&store, "cross-test.example.com", "root") - .expect("did doc after rotation"); - - let kid = core_jws::extract_kid_from_jws(&old_jws).expect("jws carries a kid"); - let core_key_b64 = core_jws::extract_key_by_fingerprint(&did_doc_after, &kid) - .or_else(|| core_jws::extract_primary_public_key(&did_doc_after)) - .expect("dpp-crypto must find the archived key by fingerprint"); - assert!(core_jws::verify_jws(&old_jws, &core_key_b64).expect("core verify")); - - // dpp-evidence must resolve the same archived key the same way, using - // only the published DID document — no store access. - let evidence_kid = - dpp_evidence::jws::extract_kid_from_jws(&old_jws).expect("jws carries a kid"); - assert_eq!( - evidence_kid, kid, - "both implementations must extract the same kid" - ); - let evidence_key_b64 = - dpp_evidence::jws::extract_key_by_fingerprint(&did_doc_after, &evidence_kid) - .expect("dpp-evidence must find the archived key by fingerprint"); - assert_eq!( - evidence_key_b64, core_key_b64, - "both implementations must resolve the same key material" - ); - assert!(dpp_evidence::jws::verify_jws_content(&old_jws, &evidence_key_b64, &payload).unwrap()); -} - -// ── 4. Same signature verifies under both implementations ───────────────── -// -// `KeyStore::generate_key` always uses `OsRng` (no seeded-key constructor is -// exposed publicly), so a literal committed golden JWS string isn't -// reachable through the public API. This instead pins the property that -// actually matters: for the *same* signature, the two independently -// maintained verifiers must agree — if either implementation's algorithm -// silently drifts (canonicalisation, key encoding, signing-input assembly), -// this test is what catches it. - -#[test] -fn same_signature_verifies_identically_under_both_implementations() { - let store = open_store(); - store.generate_key("root").expect("generate key"); - let payload = serde_json::json!({ - "id": "01HXYZGOLDEN0000000000001", - "productName": "Golden Vector Widget", - "status": "active", - "materials": ["lithium", "aluminium"], - }); - let jws = core_jws::sign(&store, "root", &payload).expect("sign"); - let did_doc = build_did_document(&store, "cross-test.example.com", "root").expect("did doc"); - let key_b64 = dpp_evidence::jws::extract_primary_public_key(&did_doc).unwrap(); - - let core_result = core_jws::verify_jws(&jws, &key_b64).expect("core verify"); - let evidence_result = dpp_evidence::jws::verify_jws(&jws, &key_b64).expect("evidence verify"); - assert!( - core_result && evidence_result, - "both implementations must agree the golden signature verifies" - ); - - let evidence_content_result = dpp_evidence::jws::verify_jws_content(&jws, &key_b64, &payload) - .expect("evidence content verify"); - assert!(evidence_content_result, "content-binding must also hold"); -} diff --git a/crates/dpp-tests/tests/mod_rs_is_pure_index.rs b/crates/dpp-tests/tests/mod_rs_is_pure_index.rs index 6cb41df..d0d1cc0 100644 --- a/crates/dpp-tests/tests/mod_rs_is_pure_index.rs +++ b/crates/dpp-tests/tests/mod_rs_is_pure_index.rs @@ -18,7 +18,6 @@ const PUBLISHED_CRATES: &[&str] = &[ "dpp-registry", "dpp-rules", "dpp-calc", - "dpp-evidence", ]; fn workspace_root() -> PathBuf { diff --git a/crates/dpp-tests/tests/textile_end_to_end.rs b/crates/dpp-tests/tests/textile_end_to_end.rs index 40dfec5..1a1e716 100644 --- a/crates/dpp-tests/tests/textile_end_to_end.rs +++ b/crates/dpp-tests/tests/textile_end_to_end.rs @@ -55,7 +55,9 @@ fn make_textile_passport() -> Passport { co2e_per_unit: Some(CarbonFootprint::from_kg(8.5)), repairability_score: Some(RepairabilityScore::from_scalar(6.5)), compliance_result: None, + lint_result: None, sector_data: Some(SectorData::Textile(TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![ FibreEntry { fibre: "cotton".into(), diff --git a/docs/README.md b/docs/README.md index c691896..0063d0b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,6 @@ This folder documents **the standard, not the product**: what a Digital Product | "How is the library structured, and why hexagonal?" | [architecture/ARCHITECTURE.md](architecture/ARCHITECTURE.md) · [architecture/DESIGN-PATTERNS.md](architecture/DESIGN-PATTERNS.md) | | "How do identity, signing, and verifiable credentials work?" | [architecture/IDENTITY.md](architecture/IDENTITY.md) | | "How do sector plugins run safely?" | [architecture/PLUGIN-HOST.md](architecture/PLUGIN-HOST.md) | -| "Can a third party verify a passport without trusting anyone?" | [../crates/dpp-evidence/spec/dossier-v1.md](../crates/dpp-evidence/spec/dossier-v1.md) — the evidence-dossier wire format and its offline checks | | "Where does code meet regulation, formally?" | [regulatory/CONFORMITY.md](regulatory/CONFORMITY.md) — written for assessment bodies | | "How are releases, versions, and contributions governed?" | [governance/](governance/) — VERSIONING, RELEASE, CONTRIBUTING, CHANGELOG | diff --git a/plugins/sector-aluminium/src/lib.rs b/plugins/sector-aluminium/src/lib.rs index 949048b..bf5eaf0 100644 --- a/plugins/sector-aluminium/src/lib.rs +++ b/plugins/sector-aluminium/src/lib.rs @@ -5,12 +5,12 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - AbiVersion, DppSectorPlugin, PluginCapabilities, PluginCapability, PluginComplianceStatus, - PluginError, PluginInput, PluginMeta, PluginResult, SchemaVersionRange, - METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + AbiVersion, DppSectorPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + PluginCapabilities, PluginCapability, PluginComplianceStatus, PluginError, PluginInput, + PluginMeta, PluginResult, SchemaVersionRange, }; -use dpp_plugin_sdk::validate::{num, str_of, Validator}; -use serde_json::{json, Value}; +use dpp_plugin_sdk::validate::{Validator, num, str_of}; +use serde_json::{Value, json}; #[derive(Default)] struct AluminiumPlugin; @@ -50,7 +50,10 @@ impl DppSectorPlugin for AluminiumPlugin { Validator::new(input) .require_gtin("gtin") .require_str("alloyGrade") - .require_str("productionRoute") + .require_enum( + "productionRoute", + &["primary", "secondary-recycled", "mixed"], + ) .require_non_negative("co2ePerTonneKg") .require_pct("recycledContentPct") .require_country("countryOfProduction") @@ -66,7 +69,9 @@ impl DppSectorPlugin for AluminiumPlugin { "primary" => 10_000.0, "secondary-recycled" => 1_000.0, "mixed" => 5_000.0, - _ => 12_000.0, // conservative default + // Unreachable after validate_input rejects unknown routes; fail + // closed on the strictest threshold rather than the most permissive. + _ => 1_000.0, }; let status = if co2e_kg.is_some_and(|v| v <= threshold_kg) { PluginComplianceStatus::Compliant @@ -137,4 +142,18 @@ mod tests { d["recycledContentPct"] = json!(120.0); assert!(AluminiumPlugin.validate_input(&d).is_err()); } + + #[test] + fn unrecognized_production_route_fails_validation() { + // Wrong case / unlisted route must be rejected, not fall through to the + // most-permissive threshold. + for route in ["Primary", "electric_arc", "unknown"] { + let mut d = valid(); + d["productionRoute"] = json!(route); + assert!( + AluminiumPlugin.validate_input(&d).is_err(), + "route {route:?} should fail validation" + ); + } + } } diff --git a/plugins/sector-battery/src/lib.rs b/plugins/sector-battery/src/lib.rs index 1cf2d73..7ed2ab5 100644 --- a/plugins/sector-battery/src/lib.rs +++ b/plugins/sector-battery/src/lib.rs @@ -16,15 +16,16 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::rules::batteries::recycled_content::{ - annex_x_shortfalls_2031, chemistry_regulated_metals, RecycledContentInput, + RecycledContentInput, annex_x_shortfalls_2031, chemistry_regulated_metals, + recycled_content_chemistry_conflicts, }; use dpp_plugin_sdk::traits::{ - AbiVersion, DppSectorPlugin, PluginCapabilities, PluginCapability, PluginComplianceStatus, - PluginError, PluginFinding, PluginInput, PluginMeta, PluginResult, SchemaVersionRange, - METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + AbiVersion, DppSectorPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + PluginCapabilities, PluginCapability, PluginComplianceStatus, PluginError, PluginFinding, + PluginInput, PluginMeta, PluginResult, SchemaVersionRange, }; -use dpp_plugin_sdk::validate::{num, Validator}; -use serde_json::{json, Value}; +use dpp_plugin_sdk::validate::{Validator, num}; +use serde_json::{Value, json}; #[derive(Default)] struct BatteryPlugin; @@ -106,12 +107,6 @@ impl DppSectorPlugin for BatteryPlugin { Some(declared.iter().sum::() / declared.len() as f64) }; - // Recycled-content target check (EU 2023/1542 Art. 8 + Annex X). The - // Phase-1 minima are not binding until 18 Aug 2031, so shortfalls are - // surfaced as **advisory warnings**, never blocking violations. We scope - // the check to the metals the declared chemistry actually contains (an - // LFP cell has no cobalt/nickel) and skip portable batteries, which are - // out of Annex X scope. let chemistry = input .get("batteryChemistry") .and_then(Value::as_str) @@ -121,9 +116,51 @@ impl DppSectorPlugin for BatteryPlugin { .and_then(Value::as_str) .unwrap_or(""); let regulated = chemistry_regulated_metals(chemistry); + // Energy capacity for the Phase-1 ">2 kWh" scoping threshold: the rated + // value if given, else nominal V × Ah (both required, so always known). + let capacity_kwh = num(input, "ratedCapacityKwh").or_else(|| { + match ( + num(input, "nominalVoltageV"), + num(input, "nominalCapacityAh"), + ) { + (Some(v), Some(ah)) => Some(v * ah / 1000.0), + _ => None, + } + }); let mut warnings: Vec = Vec::new(); - if !battery_type.eq_ignore_ascii_case("portable") { + + // Data integrity: recycled content declared for a metal the chemistry + // does not contain (e.g. cobalt on an LFP cell) is a contradiction — + // surface it as an advisory rather than folding it into the mean silently. + for metal in recycled_content_chemistry_conflicts(chemistry, cobalt, lithium, nickel, lead) + { + let field = match metal { + "cobalt" => "/recycledContentCobaltPct", + "lithium" => "/recycledContentLithiumPct", + "nickel" => "/recycledContentNickelPct", + "lead" => "/recycledContentLeadPct", + _ => "", + }; + warnings.push(PluginFinding::new( + format!("battery.recycled_content.{metal}_not_in_chemistry"), + field, + format!( + "Declared {metal} recycled content, but a {chemistry} battery contains \ + no {metal} — check the declaration." + ), + )); + } + + // Recycled-content target check (EU 2023/1542 Art. 8 + Annex X). Phase-1 + // (from 18 Aug 2031) covers EV, SLI, and industrial batteries > 2 kWh; + // portable and LMT batteries are out of Phase-1 scope (LMT joins Phase 2 + // in 2036). Applying the Phase-1 minima to an out-of-scope battery would + // surface a factually wrong "below minimum" advisory. The minima are not + // binding yet, so in-scope shortfalls are advisory warnings, never + // blocking violations, and are scoped to the metals the declared + // chemistry actually contains (an LFP cell has no cobalt/nickel). + if phase1_in_scope(battery_type, capacity_kwh) { let scoped = RecycledContentInput { cobalt_pct: if regulated.cobalt { cobalt } else { None }, lithium_pct: if regulated.lithium { lithium } else { None }, @@ -190,7 +227,9 @@ impl DppSectorPlugin for BatteryPlugin { "carbonFootprintClass": input.get("carbonFootprintClass"), "batteryType": input.get("batteryType"), })); - Ok(warnings.into_iter().fold(result, PluginResult::with_warning)) + Ok(warnings + .into_iter() + .fold(result, PluginResult::with_warning)) } fn generate_passport(&self, input: &PluginInput) -> Result { @@ -201,6 +240,19 @@ impl DppSectorPlugin for BatteryPlugin { } } +/// Whether a battery is within EU 2023/1542 Annex X **Phase-1** scope (from +/// 18 Aug 2031): EV, SLI, and industrial batteries with capacity > 2 kWh. +/// Portable and LMT (light means of transport) batteries are excluded — LMT +/// joins only in Phase 2 (2036). An unknown/absent `battery_type` is treated as +/// in-scope, so a mislabelled in-scope battery is not silently skipped. +fn phase1_in_scope(battery_type: &str, capacity_kwh: Option) -> bool { + match battery_type.to_ascii_lowercase().as_str() { + "portable" | "lmt" => false, + "industrial" => capacity_kwh.is_some_and(|k| k > 2.0), + _ => true, + } +} + export_plugin!(BatteryPlugin); // ─── Tests (host target) ────────────────────────────────────────────────────── @@ -247,9 +299,10 @@ mod tests { let err = BatteryPlugin.validate_input(&data).unwrap_err(); match err { PluginError::ValidationErrors(errs) => { - assert!(errs - .iter() - .any(|e| e.field == "/gtin" && e.code == "missing")); + assert!( + errs.iter() + .any(|e| e.field == "/gtin" && e.code == "missing") + ); } other => panic!("expected ValidationErrors, got {other:?}"), } @@ -282,7 +335,10 @@ mod tests { fn metrics_surface_all_declared_metals() { let result = BatteryPlugin.calculate_metrics(&valid_battery()).unwrap(); assert_eq!(result.co2e_score(), Some(85.4)); - assert_eq!(result.compliance_status, PluginComplianceStatus::NotAssessed); + assert_eq!( + result.compliance_status, + PluginComplianceStatus::NotAssessed + ); // mean of cobalt(16) + lithium(6) = 11 assert_eq!(result.recycled_content_pct(), Some(11.0)); let extra = result.extra.unwrap(); @@ -319,7 +375,10 @@ mod tests { "recycledContentLithiumPct": 6.0 // >= 6 ok }); let result = BatteryPlugin.calculate_metrics(&data).unwrap(); - assert_eq!(result.compliance_status, PluginComplianceStatus::NotAssessed); + assert_eq!( + result.compliance_status, + PluginComplianceStatus::NotAssessed + ); // Not-in-force threshold ⇒ advisory only, never blocking. assert!(result.violations.is_empty()); assert!( @@ -409,4 +468,102 @@ mod tests { let result = BatteryPlugin.calculate_metrics(&data).unwrap(); assert!(result.warnings.is_empty(), "got: {:?}", result.warnings); } + + #[test] + fn lmt_battery_below_target_gets_no_phase1_advisory() { + // LMT (e-bike/e-scooter) batteries are out of Phase-1 scope (Phase 2 + // only, 2036), so a below-2031-target declaration must not be flagged. + let data = json!({ + "gtin": "12345678901231", + "batteryChemistry": "NMC", + "batteryType": "lmt", + "nominalVoltageV": 36.0, + "nominalCapacityAh": 10.0, + "expectedLifetimeCycles": 800, + "co2ePerUnitKg": 12.0, + "recycledContentCobaltPct": 5.0 // < 16, but LMT is out of scope + }); + let result = BatteryPlugin.calculate_metrics(&data).unwrap(); + assert!( + !result + .warnings + .iter() + .any(|w| w.code.contains("below_2031")), + "LMT must not get a Phase-1 shortfall advisory; got: {:?}", + result.warnings + ); + } + + #[test] + fn small_industrial_below_target_gets_no_phase1_advisory() { + // Industrial batteries ≤ 2 kWh are out of Phase-1 scope. + let data = json!({ + "gtin": "12345678901231", + "batteryChemistry": "NMC", + "batteryType": "industrial", + "nominalVoltageV": 12.0, + "nominalCapacityAh": 100.0, // 1.2 kWh ≤ 2 kWh + "expectedLifetimeCycles": 1000, + "co2ePerUnitKg": 20.0, + "recycledContentCobaltPct": 5.0 // < 16, but ≤ 2 kWh is out of scope + }); + let result = BatteryPlugin.calculate_metrics(&data).unwrap(); + assert!( + !result + .warnings + .iter() + .any(|w| w.code.contains("below_2031")), + "≤2 kWh industrial must not get a Phase-1 shortfall advisory; got: {:?}", + result.warnings + ); + } + + #[test] + fn large_industrial_below_target_gets_phase1_advisory() { + // > 2 kWh industrial IS in Phase-1 scope. + let data = json!({ + "gtin": "12345678901231", + "batteryChemistry": "NMC", + "batteryType": "industrial", + "nominalVoltageV": 48.0, + "nominalCapacityAh": 100.0, // 4.8 kWh > 2 kWh + "expectedLifetimeCycles": 3000, + "co2ePerUnitKg": 85.4, + "recycledContentCobaltPct": 5.0 // < 16 + }); + let result = BatteryPlugin.calculate_metrics(&data).unwrap(); + assert!( + result + .warnings + .iter() + .any(|w| w.code.contains("cobalt_below_2031")), + "in-scope industrial shortfall must be flagged; got: {:?}", + result.warnings + ); + } + + #[test] + fn cobalt_declared_on_lfp_emits_chemistry_conflict_advisory() { + // LFP contains no cobalt; a positive cobalt declaration is a data + // contradiction that must be surfaced, not silently accepted. + let data = json!({ + "gtin": "12345678901231", + "batteryChemistry": "LFP", + "nominalVoltageV": 48.0, + "nominalCapacityAh": 100.0, + "expectedLifetimeCycles": 3000, + "co2ePerUnitKg": 45.2, + "recycledContentCobaltPct": 8.0, + "recycledContentLithiumPct": 12.0 + }); + let result = BatteryPlugin.calculate_metrics(&data).unwrap(); + assert!( + result + .warnings + .iter() + .any(|w| w.code == "battery.recycled_content.cobalt_not_in_chemistry"), + "cobalt-on-LFP must emit a chemistry-conflict advisory; got: {:?}", + result.warnings + ); + } } diff --git a/plugins/sector-detergent/src/lib.rs b/plugins/sector-detergent/src/lib.rs index d8194ba..5bbe648 100644 --- a/plugins/sector-detergent/src/lib.rs +++ b/plugins/sector-detergent/src/lib.rs @@ -6,11 +6,11 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - AbiVersion, DppSectorPlugin, PluginCapabilities, PluginCapability, PluginComplianceStatus, - PluginError, PluginInput, PluginMeta, PluginResult, SchemaVersionRange, - METRIC_CO2E_SCORE, + AbiVersion, DppSectorPlugin, METRIC_CO2E_SCORE, PluginCapabilities, PluginCapability, + PluginComplianceStatus, PluginError, PluginFieldError, PluginInput, PluginMeta, PluginResult, + SchemaVersionRange, }; -use dpp_plugin_sdk::validate::{num, Validator}; +use dpp_plugin_sdk::validate::{Validator, num}; use serde_json::Value; #[derive(Default)] @@ -54,7 +54,31 @@ impl DppSectorPlugin for DetergentPlugin { .require_str("format") .require_non_empty_array("surfactants") .require_country("countryOfManufacture") - .finish() + .optional_non_negative("co2ePerUnitKg") + .finish()?; + + // Each surfactant must declare biodegradability as a boolean. The + // sector's one hard check ("all surfactants readily biodegradable") is + // only meaningful if the field is actually present — an omitted, null, + // or non-boolean value must be rejected, not silently pass as + // NotAssessed. + let mut errors = Vec::new(); + if let Some(arr) = input.get("surfactants").and_then(Value::as_array) { + for (i, s) in arr.iter().enumerate() { + if s.get("biodegradable").and_then(Value::as_bool).is_none() { + errors.push(PluginFieldError { + field: format!("/surfactants/{i}/biodegradable"), + code: "missing".into(), + message: "each surfactant must declare biodegradable as a boolean".into(), + }); + } + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(PluginError::ValidationErrors(errors)) + } } fn calculate_metrics(&self, input: &PluginInput) -> Result { @@ -71,8 +95,7 @@ impl DppSectorPlugin for DetergentPlugin { } else { PluginComplianceStatus::NotAssessed }; - Ok(PluginResult::new(status) - .maybe_metric(METRIC_CO2E_SCORE, num(input, "co2ePerUnitKg"))) + Ok(PluginResult::new(status).maybe_metric(METRIC_CO2E_SCORE, num(input, "co2ePerUnitKg"))) } fn generate_passport(&self, input: &PluginInput) -> Result { @@ -131,4 +154,19 @@ mod tests { d["surfactants"] = json!([]); assert!(DetergentPlugin.validate_input(&d).is_err()); } + + #[test] + fn undeclared_biodegradable_fails_validation() { + // biodegradable omitted must be rejected, not silently NotAssessed. + let mut d = valid(); + d["surfactants"] = json!([{ "name": "LAS", "concentrationBand": "5-15%" }]); + assert!(DetergentPlugin.validate_input(&d).is_err()); + } + + #[test] + fn negative_co2e_fails_validation() { + let mut d = valid(); + d["co2ePerUnitKg"] = json!(-999.0); + assert!(DetergentPlugin.validate_input(&d).is_err()); + } } diff --git a/plugins/sector-electronics/src/lib.rs b/plugins/sector-electronics/src/lib.rs index 67c58e3..4cf092b 100644 --- a/plugins/sector-electronics/src/lib.rs +++ b/plugins/sector-electronics/src/lib.rs @@ -11,11 +11,11 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - AbiVersion, DppSectorPlugin, PluginCapabilities, PluginCapability, PluginComplianceStatus, + AbiVersion, DppSectorPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + METRIC_REPAIRABILITY_INDEX, PluginCapabilities, PluginCapability, PluginComplianceStatus, PluginError, PluginInput, PluginMeta, PluginResult, SchemaVersionRange, - METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, METRIC_REPAIRABILITY_INDEX, }; -use dpp_plugin_sdk::validate::{num, str_of, Validator}; +use dpp_plugin_sdk::validate::{Validator, num, str_of}; use serde_json::Value; #[derive(Default)] @@ -62,6 +62,9 @@ impl DppSectorPlugin for ElectronicsPlugin { ) .require_non_negative("co2ePerUnitKg") .optional_pct("recycledContentPct") + // Optional, but when present it gates the verdict (pass ≥ 6.0), so it + // must be a real 0–10 score, not an unbounded value. + .optional_range("repairabilityScore", 0.0, 10.0) .finish() } @@ -156,4 +159,12 @@ mod tests { d["energyEfficiencyClass"] = json!("Z"); assert!(ElectronicsPlugin.validate_input(&d).is_err()); } + + #[test] + fn out_of_range_repairability_fails_validation() { + // An unbounded score must not sail through and force a Compliant verdict. + let mut d = base(); + d["repairabilityScore"] = json!(999999.0); + assert!(ElectronicsPlugin.validate_input(&d).is_err()); + } } diff --git a/plugins/sector-furniture/src/lib.rs b/plugins/sector-furniture/src/lib.rs index 1538603..1ce2451 100644 --- a/plugins/sector-furniture/src/lib.rs +++ b/plugins/sector-furniture/src/lib.rs @@ -6,11 +6,11 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - AbiVersion, DppSectorPlugin, PluginCapabilities, PluginCapability, PluginComplianceStatus, + AbiVersion, DppSectorPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + METRIC_REPAIRABILITY_INDEX, PluginCapabilities, PluginCapability, PluginComplianceStatus, PluginError, PluginInput, PluginMeta, PluginResult, SchemaVersionRange, - METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, METRIC_REPAIRABILITY_INDEX, }; -use dpp_plugin_sdk::validate::{num, Validator}; +use dpp_plugin_sdk::validate::{Validator, num}; use serde_json::Value; #[derive(Default)] @@ -54,6 +54,8 @@ impl DppSectorPlugin for FurniturePlugin { .require_str("primaryMaterial") .require_country("countryOfManufacture") .optional_pct("recycledContentPct") + .optional_non_negative("co2ePerUnitKg") + .optional_range("repairabilityScore", 0.0, 10.0) .finish() } @@ -62,7 +64,10 @@ impl DppSectorPlugin for FurniturePlugin { Ok(PluginResult::new(PluginComplianceStatus::NotAssessed) .maybe_metric(METRIC_CO2E_SCORE, num(input, "co2ePerUnitKg")) .maybe_metric(METRIC_REPAIRABILITY_INDEX, num(input, "repairabilityScore")) - .maybe_metric(METRIC_RECYCLED_CONTENT_PCT, num(input, "recycledContentPct"))) + .maybe_metric( + METRIC_RECYCLED_CONTENT_PCT, + num(input, "recycledContentPct"), + )) } fn generate_passport(&self, input: &PluginInput) -> Result { @@ -103,4 +108,15 @@ mod tests { d["countryOfManufacture"] = json!("Sweden"); assert!(FurniturePlugin.validate_input(&d).is_err()); } + + #[test] + fn negative_metrics_are_rejected() { + let mut d = valid(); + d["co2ePerUnitKg"] = json!(-999.0); + assert!(FurniturePlugin.validate_input(&d).is_err()); + + let mut d = valid(); + d["repairabilityScore"] = json!(-1.0); + assert!(FurniturePlugin.validate_input(&d).is_err()); + } } diff --git a/plugins/sector-steel/src/lib.rs b/plugins/sector-steel/src/lib.rs index 18bf243..dd644e2 100644 --- a/plugins/sector-steel/src/lib.rs +++ b/plugins/sector-steel/src/lib.rs @@ -8,12 +8,12 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - AbiVersion, DppSectorPlugin, PluginCapabilities, PluginCapability, PluginComplianceStatus, - PluginError, PluginInput, PluginMeta, PluginResult, SchemaVersionRange, - METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + AbiVersion, DppSectorPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + PluginCapabilities, PluginCapability, PluginComplianceStatus, PluginError, PluginInput, + PluginMeta, PluginResult, SchemaVersionRange, }; -use dpp_plugin_sdk::validate::{num, str_of, Validator}; -use serde_json::{json, Value}; +use dpp_plugin_sdk::validate::{Validator, num, str_of}; +use serde_json::{Value, json}; #[derive(Default)] struct SteelPlugin; @@ -55,7 +55,10 @@ impl DppSectorPlugin for SteelPlugin { .require_non_negative("co2ePerTonneSteel") .require_pct("recycledScrapContentPct") .require_str("productCategory") - .require_str("productionRoute") + .require_enum( + "productionRoute", + &["blast-furnace", "electric-arc", "direct-reduction"], + ) .require_country("countryOfProduction") .finish() } @@ -69,7 +72,9 @@ impl DppSectorPlugin for SteelPlugin { "blast-furnace" => 2.1, "electric-arc" => 0.4, "direct-reduction" => 1.0, - _ => 2.5, // conservative default for unknown routes + // Unreachable after validate_input rejects unknown routes; fail + // closed on the strictest threshold rather than the most permissive. + _ => 0.4, }; let status = if co2e.is_some_and(|v| v <= threshold) { PluginComplianceStatus::Compliant @@ -133,4 +138,18 @@ mod tests { d.as_object_mut().unwrap().remove("countryOfProduction"); assert!(SteelPlugin.validate_input(&d).is_err()); } + + #[test] + fn unrecognized_production_route_fails_validation() { + // A wrong-case or unlisted route must be rejected, not scored against + // the most-permissive wildcard threshold. + for route in ["Electric-Arc", "electric_arc", "unknown"] { + let mut d = valid(); + d["productionRoute"] = json!(route); + assert!( + SteelPlugin.validate_input(&d).is_err(), + "route {route:?} should fail validation" + ); + } + } } diff --git a/plugins/sector-textile/src/fibre_composition.rs b/plugins/sector-textile/src/fibre_composition.rs index 2f0b391..08541eb 100644 --- a/plugins/sector-textile/src/fibre_composition.rs +++ b/plugins/sector-textile/src/fibre_composition.rs @@ -1,12 +1,15 @@ //! Textile fibre-composition compliance — fibre percentages must sum to ~100% //! (± 2.0 tolerance), and carbon/recycled/repair metrics pass through. -use dpp_plugin_sdk::traits::{PluginComplianceStatus, PluginResult, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, METRIC_REPAIRABILITY_INDEX}; +use dpp_plugin_sdk::traits::{ + METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, METRIC_REPAIRABILITY_INDEX, + PluginComplianceStatus, PluginResult, +}; use dpp_plugin_sdk::validate::num; use serde_json::Value; pub fn calculate(input: &Value) -> PluginResult { - let status = if fibre_sum_ok(input) { + let status = if fibre_composition_ok(input) { PluginComplianceStatus::Compliant } else { PluginComplianceStatus::NonCompliant @@ -14,19 +17,33 @@ pub fn calculate(input: &Value) -> PluginResult { PluginResult::new(status) .maybe_metric(METRIC_CO2E_SCORE, num(input, "carbonFootprintKgCo2e")) .maybe_metric(METRIC_REPAIRABILITY_INDEX, num(input, "repairScore")) - .maybe_metric(METRIC_RECYCLED_CONTENT_PCT, num(input, "recycledContentPct")) + .maybe_metric( + METRIC_RECYCLED_CONTENT_PCT, + num(input, "recycledContentPct"), + ) } -fn fibre_sum_ok(input: &Value) -> bool { - // The fibre-sum rule lives once in `dpp-rules` (shared with dpp-domain). - match input.get("fibreComposition").and_then(Value::as_array) { - Some(fibres) if !fibres.is_empty() => { - let pcts: Vec = fibres - .iter() - .filter_map(|f| f.get("pct").and_then(Value::as_f64)) - .collect(); - dpp_plugin_sdk::rules::fibre_sum_ok(&pcts) - } - _ => false, +/// Full fibre-composition validity, delegated to the shared `dpp-rules` +/// validator (each `pct` finite and in `[0, 100]`, any `countryOfOrigin` a valid +/// ISO code, and the percentages summing to ~100%). Reimplementing only the sum +/// check here would let out-of-range percentages that happen to sum to 100 — +/// and entries missing a `pct` entirely — pass as compliant. +fn fibre_composition_ok(input: &Value) -> bool { + let Some(fibres) = input.get("fibreComposition").and_then(Value::as_array) else { + return false; + }; + let mut inputs = Vec::with_capacity(fibres.len()); + for f in fibres { + // A missing/non-numeric pct is an incomplete declaration — fail rather + // than silently dropping the entry. + let Some(pct) = f.get("pct").and_then(Value::as_f64) else { + return false; + }; + inputs.push(dpp_plugin_sdk::rules::FibreInput { + fibre: f.get("fibre").and_then(Value::as_str).unwrap_or(""), + pct, + country_of_origin: f.get("countryOfOrigin").and_then(Value::as_str), + }); } + dpp_plugin_sdk::rules::validate_fibre_composition(&inputs).is_ok() } diff --git a/plugins/sector-textile/src/lib.rs b/plugins/sector-textile/src/lib.rs index 3b20178..767b0f1 100644 --- a/plugins/sector-textile/src/lib.rs +++ b/plugins/sector-textile/src/lib.rs @@ -14,10 +14,10 @@ mod unsold_goods; use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - AbiVersion, DppSectorPlugin, PluginCapabilities, PluginCapability, PluginComplianceStatus, - PluginError, PluginInput, PluginMeta, PluginResult, SchemaVersionRange, + AbiVersion, DppSectorPlugin, PluginCapabilities, PluginCapability, PluginError, PluginInput, + PluginMeta, PluginResult, SchemaVersionRange, }; -use dpp_plugin_sdk::validate::{str_of, Validator}; +use dpp_plugin_sdk::validate::{Validator, str_of}; use serde_json::Value; #[derive(Default)] @@ -80,6 +80,9 @@ impl DppSectorPlugin for TextilePlugin { .require_country("countryOfManufacturing") .require_str("careInstructions") .require_str("chemicalComplianceStandard") + .optional_pct("recycledContentPct") + .optional_non_negative("carbonFootprintKgCo2e") + .optional_range("repairScore", 0.0, 10.0) .finish() } } @@ -104,6 +107,7 @@ export_plugin!(TextilePlugin); #[cfg(test)] mod tests { use super::*; + use dpp_plugin_sdk::traits::PluginComplianceStatus; use serde_json::json; fn textile() -> Value { @@ -181,4 +185,59 @@ mod tests { PluginComplianceStatus::NonCompliant ); } + + #[test] + fn out_of_range_fibre_pcts_are_non_compliant() { + // Sums to 100 but neither percentage is physically valid. + let mut d = textile(); + d["fibreComposition"] = json!([ + { "fibre": "cotton", "pct": 150.0 }, + { "fibre": "wool", "pct": -50.0 } + ]); + assert_eq!( + TextilePlugin + .calculate_metrics(&d) + .unwrap() + .compliance_status, + PluginComplianceStatus::NonCompliant + ); + } + + #[test] + fn fibre_entry_missing_pct_is_non_compliant() { + // One entry has no pct — an incomplete declaration, not a 100% cotton. + let mut d = textile(); + d["fibreComposition"] = json!([ + { "fibre": "cotton" }, + { "fibre": "wool", "pct": 100.0 } + ]); + assert_eq!( + TextilePlugin + .calculate_metrics(&d) + .unwrap() + .compliance_status, + PluginComplianceStatus::NonCompliant + ); + } + + #[test] + fn negative_carbon_footprint_fails_validation() { + let mut d = textile(); + d["carbonFootprintKgCo2e"] = json!(-10.0); + assert!(TextilePlugin.validate_input(&d).is_err()); + } + + #[test] + fn whitespace_only_justification_is_non_compliant() { + let mut d = unsold(); + d["destination"] = json!("exempt_destruction"); + d["destructionJustification"] = json!(" "); // 10 spaces + assert_eq!( + TextilePlugin + .calculate_metrics(&d) + .unwrap() + .compliance_status, + PluginComplianceStatus::NonCompliant + ); + } } diff --git a/plugins/sector-textile/src/unsold_goods.rs b/plugins/sector-textile/src/unsold_goods.rs index 13f32be..8a262cc 100644 --- a/plugins/sector-textile/src/unsold_goods.rs +++ b/plugins/sector-textile/src/unsold_goods.rs @@ -7,7 +7,7 @@ use dpp_plugin_sdk::traits::{PluginComplianceStatus, PluginResult}; use dpp_plugin_sdk::validate::{num, str_of}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; pub fn calculate(input: &Value) -> PluginResult { let destination = str_of(input, "destination").unwrap_or(""); @@ -16,19 +16,28 @@ pub fn calculate(input: &Value) -> PluginResult { let (status, detail): (PluginComplianceStatus, &str) = match destination { "exempt_destruction" => { let justification = str_of(input, "destructionJustification").unwrap_or(""); - if justification.len() < 10 { + // Count trimmed characters — a run of whitespace is not a + // substantive justification. + if justification.trim().chars().count() < 10 { ( PluginComplianceStatus::NonCompliant, "exempt_destruction requires destructionJustification of at least 10 characters", ) } else { - (PluginComplianceStatus::Compliant, "exempt destruction with valid justification") + ( + PluginComplianceStatus::Compliant, + "exempt destruction with valid justification", + ) } } - "donation" | "recycling" | "repurposing" | "supplier_return" => { - (PluginComplianceStatus::Compliant, "approved disposal destination") - } - "" => (PluginComplianceStatus::NonCompliant, "missing destination field"), + "donation" | "recycling" | "repurposing" | "supplier_return" => ( + PluginComplianceStatus::Compliant, + "approved disposal destination", + ), + "" => ( + PluginComplianceStatus::NonCompliant, + "missing destination field", + ), _ => (PluginComplianceStatus::NonCompliant, "unknown destination"), }; diff --git a/plugins/sector-tyre/src/lib.rs b/plugins/sector-tyre/src/lib.rs index 8e88fc5..60dca7c 100644 --- a/plugins/sector-tyre/src/lib.rs +++ b/plugins/sector-tyre/src/lib.rs @@ -7,11 +7,11 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - AbiVersion, DppSectorPlugin, PluginCapabilities, PluginCapability, PluginComplianceStatus, - PluginError, PluginInput, PluginMeta, PluginResult, SchemaVersionRange, - METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + AbiVersion, DppSectorPlugin, METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, + PluginCapabilities, PluginCapability, PluginComplianceStatus, PluginError, PluginInput, + PluginMeta, PluginResult, SchemaVersionRange, }; -use dpp_plugin_sdk::validate::{num, Validator}; +use dpp_plugin_sdk::validate::{Validator, num}; use serde_json::Value; #[derive(Default)] @@ -51,11 +51,12 @@ impl DppSectorPlugin for TyrePlugin { fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { Validator::new(input) .require_gtin("gtin") - .require_str("tyreClass") + .require_enum("tyreClass", &["C1", "C2", "C3"]) .require_enum("fuelEfficiencyClass", &["A", "B", "C", "D", "E"]) .require_enum("wetGripClass", &["A", "B", "C", "D", "E"]) .require_non_negative("externalRollingNoiseDb") .optional_pct("recycledRubberPct") + .optional_non_negative("co2ePerTyreKg") .finish() } @@ -104,4 +105,18 @@ mod tests { d["wetGripClass"] = json!("F"); // 2020/740 is A–E only assert!(TyrePlugin.validate_input(&d).is_err()); } + + #[test] + fn invalid_tyre_class_is_rejected() { + let mut d = valid(); + d["tyreClass"] = json!("garbage"); // must be C1/C2/C3 + assert!(TyrePlugin.validate_input(&d).is_err()); + } + + #[test] + fn negative_co2e_per_tyre_is_rejected() { + let mut d = valid(); + d["co2ePerTyreKg"] = json!(-50.0); + assert!(TyrePlugin.validate_input(&d).is_err()); + } }