diff --git a/CHANGELOG.md b/CHANGELOG.md index 80da11aa..7e45bce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -319,8 +319,65 @@ This file was started retroactively on 2026-07-03 at v0.4.0; entries for level further in, so losing the type would lose the validation without losing a field. +### Fixed + +- **🚨 The Wasm product group plugins still required a `gtin` the schemas had + stopped carrying, and the compliance determination silently stopped being + made.** The EN 18219 identifier work moved every product group's schema from a + bare top-level `gtin` to a `productIdentifier` object, and migrated the Rust + types, the catalog and the stored-data lens with it. **No file under + `plugins/` was touched.** Nine of the ten plugins went on calling + `require_gtin("gtin")`, which reads a flat top-level key, so fed a current + record they answered *"gtin is required"* β€” against data whose GTIN was + present the whole time, one level down inside `productIdentifier`. + + The failure was silent rather than loud, which is the part worth keeping. Both + consumers of a determination discard the error: the publish-time compliance + gate reads `&& let Ok(determination) = …compute(…)`, so an `Err` makes the + whole condition false and **the gate that blocks a passport carrying binding + violations simply does not fire**. Passports published; nothing was evaluated. + + Five gates were in a position to catch it and none did. `plugins/*` are + excluded from the workspace, so `cargo check --workspace` never compiled them + against the new shape. Their own tests passed because their fixtures still + carried `"gtin": "12345678901231"` β€” a test pinned to a shape that no longer + ships, the same defect class as the stale schema literal fixed earlier in this + release. The catalog↔schema parity test compares those two records to each + other and never asks what a plugin requires. Every plugin's declared + `schema_version_range` was stale, but that is dead metadata: the host calls + `check_compatibility(…, None, …)`, and `None` skips the schema check entirely. + + Each plugin now calls `require_product_identifier("productIdentifier")`, and + the ten stale version ranges are bumped to the versions their product groups + actually serve. + ### Added +- **`Validator::require_product_identifier` β€” the EN 18219 clause 5 check that + replaces `require_gtin` for product group data.** Validates the object against + the scheme that issued it: scheme 1 keeps the GTIN check digit test the bare + field used to get, scheme 2 requires an absolute `url`, scheme 3 a `did` under + one of the three methods clause 5 names. + + The three are **alternatives, not a hierarchy**, so the branch is chosen by + the declared scheme rather than every record being asked for a GTIN β€” which is + what made schemes 2 and 3 unusable. An unmapped scheme is refused rather than + skipped: `"passport_id"` is exactly the kind of invented scheme that otherwise + passes unexamined, and it now fails at the plugin tier too. + + `require_gtin` remains, and is still correct for a bare GS1 field β€” the + scheme 1 branch applies the same check-digit test it does. + +- **EN 18219 clause 5 identifier syntax has one home, `dpp-rules`.** The scheme + 2 and 3 rules existed twice β€” in `dpp_domain::identifier::ProductIdentifier` + and, once the payloads moved to `productIdentifier`, again in the plugin SDK. + The copies disagreed: the plugin tier accepted `https:///acme/1` and + `did:web: ` because it tested a prefix and a non-empty remainder, where the + domain tested the authority and the W3C DID grammar. Since a plugin is the + first thing to see product group data, the weaker copy was the one on the + outside. `dpp_rules::common::identifier` now holds `is_absolute_web_url`, + `check_did` and `DID_METHODS`, and both tiers call it. + - **An EN 18219 identifier converts into the registry's product identifier.** Two types shared a name across a crate boundary β€” `dpp_domain::identifier::ProductIdentifier`, the clause 5 scheme enum, and diff --git a/crates/dpp-domain/src/identifier/product_identifier/scheme.rs b/crates/dpp-domain/src/identifier/product_identifier/scheme.rs index 486ddbbe..4fe092f2 100644 --- a/crates/dpp-domain/src/identifier/product_identifier/scheme.rs +++ b/crates/dpp-domain/src/identifier/product_identifier/scheme.rs @@ -1,19 +1,11 @@ //! [`ProductIdentifier`] β€” a unique product identifier under EN 18219 clause 5. +use dpp_rules::common::identifier::{DidRejection, check_did, is_absolute_web_url}; use serde::{Deserialize, Serialize}; use super::super::gtin::Gtin; use super::error::ProductIdentifierError; -/// The DID methods EN 18219 scheme 3 names. -/// -/// The standard describes scheme 3 as Decentralized Identifiers and names these -/// three as the admissible methods, `did:web` being the lightweight non-DLT -/// option. Closed rather than open because an identifier exists to be followed: -/// a method no reader can resolve identifies nothing, and accepting one would -/// let a passport be created that is unreachable by design. -const DID_METHODS: [&str; 3] = ["web", "ethr", "ebsi"]; - /// A unique product identifier, in whichever EN 18219 clause 5 scheme issued it. /// /// βœ… COMPLIANCE-PIN: EN 18219:2026 clause 5.1 β€” a unique product identifier @@ -108,17 +100,9 @@ impl ProductIdentifier { /// [`ProductIdentifierError::NotAWebUrl`] if `url` is not an absolute /// `http`/`https` URL. See the variant's note on what is *not* checked. pub fn identification_link(url: &str) -> Result { - let rest = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://")) - .ok_or_else(|| ProductIdentifierError::NotAWebUrl(url.to_owned()))?; - - // 🚨 The authority ends at the first `/`, `?` or `#` β€” it is not simply - // "whatever follows the scheme". `https:///p/1` and `https://?q` each - // leave a non-empty remainder and no host whatsoever, so checking that - // remainder for emptiness accepted two values nothing can resolve. - let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); - if authority.is_empty() || url.contains(char::is_whitespace) { + // The syntax lives in `dpp_rules` so the plugin SDK checks the same + // thing this does β€” it previously had a weaker copy of its own. + if !is_absolute_web_url(url) { return Err(ProductIdentifierError::NotAWebUrl(url.to_owned())); } Ok(Self::IdentificationLink { @@ -135,23 +119,16 @@ impl ProductIdentifier { /// if the method is not one clause 5 names, and /// [`ProductIdentifierError::EmptyDidMethodId`] if nothing follows it. pub fn did(did: &str) -> Result { - let rest = did - .strip_prefix("did:") - .ok_or_else(|| ProductIdentifierError::NotADid(did.to_owned()))?; - let (method, method_id) = rest - .split_once(':') - .ok_or_else(|| ProductIdentifierError::NotADid(did.to_owned()))?; - if !DID_METHODS.contains(&method) { - return Err(ProductIdentifierError::UnsupportedDidMethod( - method.to_owned(), - )); - } - if method_id.is_empty() { - return Err(ProductIdentifierError::EmptyDidMethodId(did.to_owned())); - } - if !is_method_specific_id(method_id) { - return Err(ProductIdentifierError::NotADid(did.to_owned())); - } + // The grammar lives in `dpp_rules` so the plugin SDK checks the same + // thing this does. Each rejection keeps its own error here: which of + // the three ways a DID is wrong is worth telling the caller. + check_did(did).map_err(|rejection| match rejection { + DidRejection::UnsupportedMethod(method) => { + ProductIdentifierError::UnsupportedDidMethod(method.to_owned()) + } + DidRejection::EmptyMethodId => ProductIdentifierError::EmptyDidMethodId(did.to_owned()), + DidRejection::Malformed => ProductIdentifierError::NotADid(did.to_owned()), + })?; Ok(Self::Did { did: did.to_owned(), }) @@ -184,36 +161,6 @@ impl ProductIdentifier { } } -/// W3C DID v1.0 clause 3.1: `method-specific-id = *( *idchar ":" ) 1*idchar`. -/// -/// Colon-separated segments, of which only the last must be non-empty. Checked -/// because the shape is the whole claim the type makes β€” a value carrying a raw -/// space or a truncated `%` escape is not a DID with a formatting blemish, it is -/// a string no resolver will accept, pointing at no passport. -fn is_method_specific_id(id: &str) -> bool { - !id.is_empty() && !id.ends_with(':') && id.split(':').all(is_idchars) -} - -/// `idchar = ALPHA / DIGIT / "." / "-" / "_" / pct-encoded`, where -/// `pct-encoded = "%" HEXDIG HEXDIG`. -fn is_idchars(segment: &str) -> bool { - let mut chars = segment.chars(); - while let Some(c) = chars.next() { - let ok = match c { - 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => true, - '%' => matches!( - (chars.next(), chars.next()), - (Some(hi), Some(lo)) if hi.is_ascii_hexdigit() && lo.is_ascii_hexdigit() - ), - _ => false, - }; - if !ok { - return false; - } - } - true -} - /// The wire shape a stored identifier is read into, before validation. /// /// 🚨 Deriving `Deserialize` on [`ProductIdentifier`] itself built the two diff --git a/crates/dpp-plugin-sdk/src/tests.rs b/crates/dpp-plugin-sdk/src/tests.rs index 965e6dc5..2e228e28 100644 --- a/crates/dpp-plugin-sdk/src/tests.rs +++ b/crates/dpp-plugin-sdk/src/tests.rs @@ -207,3 +207,152 @@ fn macro_input_exports_pack_error_envelope_for_empty_input() { generate_passport_bytes(&DummyPlugin, &[]).len() ); } + +// ── require_product_identifier ─────────────────────────────────────────────── +// +// The check that replaced `require_gtin` for product group data. Each scheme is +// exercised on its own, because the whole point of clause 5 is that the three +// are alternatives: a check that only ever passes for GS1 is the defect this +// method exists to remove. + +fn pi_errors(identifier: Value) -> Vec<(String, String)> { + let input = json!({ "productIdentifier": identifier }); + match crate::validate::Validator::new(&input) + .require_product_identifier("productIdentifier") + .finish() + { + Ok(()) => vec![], + Err(PluginError::ValidationErrors(errors)) => { + errors.into_iter().map(|e| (e.field, e.code)).collect() + } + Err(other) => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn every_clause_5_scheme_is_accepted() { + for identifier in [ + json!({ "scheme": "gs1", "gtin": "09506000134352" }), + json!({ "scheme": "identificationLink", "url": "https://id.acme.example.com/p/1" }), + json!({ "scheme": "did", "did": "did:web:acme.example.com:p:1" }), + ] { + assert!( + pi_errors(identifier.clone()).is_empty(), + "scheme 2 and 3 carry no GTIN and must still pass: {identifier}" + ); + } +} + +#[test] +fn a_scheme_2_identifier_is_not_asked_for_a_gtin() { + // The regression that mattered: requiring a GTIN unconditionally rejected + // exactly the passports the identifier work introduced. + let errors = pi_errors(json!({ "scheme": "did", "did": "did:web:acme.example.com" })); + assert!( + !errors.iter().any(|(field, _)| field.contains("gtin")), + "a DID-identified product was asked for a GTIN: {errors:?}" + ); +} + +#[test] +fn the_branch_field_is_required_by_its_own_scheme() { + assert_eq!( + pi_errors(json!({ "scheme": "gs1" })), + vec![("/productIdentifier/gtin".to_owned(), "missing".to_owned())] + ); + assert_eq!( + pi_errors(json!({ "scheme": "identificationLink" })), + vec![("/productIdentifier/url".to_owned(), "missing".to_owned())] + ); + assert_eq!( + pi_errors(json!({ "scheme": "did" })), + vec![("/productIdentifier/did".to_owned(), "missing".to_owned())] + ); +} + +#[test] +fn a_malformed_branch_value_is_refused_per_scheme() { + // GS1 keeps the check-digit test the bare `gtin` field used to get. + assert_eq!( + pi_errors(json!({ "scheme": "gs1", "gtin": "09506000134353" })), + vec![("/productIdentifier/gtin".to_owned(), "checksum".to_owned())] + ); + assert_eq!( + pi_errors(json!({ "scheme": "gs1", "gtin": "12-34" })), + vec![("/productIdentifier/gtin".to_owned(), "format".to_owned())] + ); + assert_eq!( + pi_errors(json!({ "scheme": "identificationLink", "url": "acme.example.com" })), + vec![("/productIdentifier/url".to_owned(), "format".to_owned())] + ); + assert_eq!( + pi_errors(json!({ "scheme": "did", "did": "did:key:z6Mk" })), + vec![("/productIdentifier/did".to_owned(), "format".to_owned())] + ); +} + +#[test] +fn a_carrier_shaped_value_with_nothing_to_resolve_is_refused() { + // 🚨 These four passed. The plugin tier tested a prefix and a non-empty + // remainder, so a URL with no authority and a DID with a space in it were + // both accepted here while `dpp_domain::ProductIdentifier` refused them β€” + // and the plugin is the *first* thing to see product group data, so the + // weaker of the two copies was the one on the outside. Both tiers now call + // `dpp_rules::common::identifier`. + for url in [ + "https:///acme/1", + "https://?q=1", + "https://", + "https://ac me.example.com", + ] { + assert_eq!( + pi_errors(json!({ "scheme": "identificationLink", "url": url })), + vec![("/productIdentifier/url".to_owned(), "format".to_owned())], + "{url} has no host to resolve" + ); + } + for did in [ + "did:web: ", + "did:web:", + "did:web:acme:", + "did:web:ac%2zme", + "did:web", + ] { + assert_eq!( + pi_errors(json!({ "scheme": "did", "did": did })), + vec![("/productIdentifier/did".to_owned(), "format".to_owned())], + "{did} is not a resolvable DID" + ); + } +} + +#[test] +fn an_unmapped_scheme_is_refused_rather_than_skipped() { + // 🚨 The `passport_id` case. A scheme nobody has mapped is where an + // invented identifier passes unexamined, so it must fail rather than + // fall through to "no branch to check". + assert_eq!( + pi_errors(json!({ "scheme": "passport_id", "value": "0199...uuid" })), + vec![("/productIdentifier/scheme".to_owned(), "unknown".to_owned())] + ); +} + +#[test] +fn an_absent_or_headless_identifier_is_refused() { + let empty = json!({}); + let missing = match crate::validate::Validator::new(&empty) + .require_product_identifier("productIdentifier") + .finish() + { + Err(PluginError::ValidationErrors(e)) => e, + other => panic!("expected a refusal, got {other:?}"), + }; + assert_eq!(missing[0].field, "/productIdentifier"); + assert_eq!(missing[0].code, "missing"); + + assert_eq!( + pi_errors(json!({ "gtin": "09506000134352" })), + vec![("/productIdentifier/scheme".to_owned(), "missing".to_owned())], + "a GTIN with no scheme states nothing about which scheme issued it" + ); +} diff --git a/crates/dpp-plugin-sdk/src/validate.rs b/crates/dpp-plugin-sdk/src/validate.rs index 91a6fc94..3e5364bb 100644 --- a/crates/dpp-plugin-sdk/src/validate.rs +++ b/crates/dpp-plugin-sdk/src/validate.rs @@ -11,6 +11,7 @@ //! "measured value at or under threshold" classification they compare against. use dpp_plugin_traits::{PluginComplianceStatus, PluginError, PluginFieldError, PluginInput}; +use dpp_rules::common::identifier::{DidRejection, check_did, is_absolute_web_url}; use serde_json::Value; /// A present, non-null value for `key`, or `None` if absent/null. @@ -73,6 +74,19 @@ impl<'a> Validator<'a> { } } + /// Record a failure against an already-built field path. + /// + /// [`push_opt`](Self::push_opt) derives `/{key}` from a single field name, + /// which cannot name a field *inside* an object. A nested identifier + /// reports `/productIdentifier/gtin`, so the caller builds the path. + fn push_at(&mut self, field: String, code: &str, message: String) { + self.errors.push(PluginFieldError { + field, + code: code.to_owned(), + message, + }); + } + /// Require a present, non-empty string. pub fn require_str(&mut self, key: &str) -> &mut Self { let err = match present(self.input, key) { @@ -115,6 +129,122 @@ impl<'a> Validator<'a> { self } + /// Require an EN 18219:2026 clause 5 unique product identifier object. + /// + /// 🚨 **This replaces [`require_gtin`](Self::require_gtin) for product group + /// data.** Product group records used to carry a bare top-level `gtin`, and + /// every plugin required it. They now carry a `productIdentifier` object + /// whose shape depends on the scheme that issued it, and the GTIN β€” when + /// there is one at all β€” lives *inside* it: + /// + /// ```json + /// { "productIdentifier": { "scheme": "gs1", "gtin": "09506000134352" } } + /// ``` + /// + /// A plugin still asking for `gtin` therefore reports *"gtin is required"* + /// against data that identifies itself perfectly well. That is not a + /// hypothetical: it is what every non-textile plugin did once the schemas + /// moved, and because both host call sites discard a plugin error, the + /// compliance determination silently stopped being made rather than failing + /// loudly. + /// + /// # Why the scheme decides which field is checked + /// + /// Clause 5.1 offers three schemes as **alternatives, not a hierarchy**. + /// Only scheme 1 is GS1-keyed, so only scheme 1 has a GTIN; schemes 2 and 3 + /// are self-issuing and carry a URL and a DID respectively. Validating a + /// GTIN unconditionally would reject exactly the passports the identifier + /// work exists to enable, which is the defect this method replaces. + /// + /// An unrecognised scheme is refused rather than skipped: a scheme string + /// nobody has mapped is where an invented identifier passes unexamined. + pub fn require_product_identifier(&mut self, key: &str) -> &mut Self { + let Some(object) = present(self.input, key).and_then(Value::as_object) else { + self.push_opt(key, Some(("missing", format!("{key} is required")))); + return self; + }; + let Some(scheme) = object.get("scheme").and_then(Value::as_str) else { + self.push_at( + format!("/{key}/scheme"), + "missing", + format!("{key}.scheme is required"), + ); + return self; + }; + // Each arm names the one field its scheme is keyed on. The branch field + // is required *because* the scheme was declared, so a missing one is + // reported against the field, not against the scheme that implies it. + let (field, err) = match scheme { + "gs1" => ( + "gtin", + match object.get("gtin").and_then(Value::as_str) { + None => Some(("missing", format!("{key}.gtin is required for scheme gs1"))), + Some(g) if g.len() == 14 && g.bytes().all(|b| b.is_ascii_digit()) => { + if gs1_check_digit_valid(g) { + None + } else { + Some(( + "checksum", + format!("{key}.gtin has an invalid GS1 check digit"), + )) + } + } + Some(_) => Some(("format", format!("{key}.gtin must be 14 digits"))), + }, + ), + "identificationLink" => ( + "url", + match object.get("url").and_then(Value::as_str) { + None => Some(( + "missing", + format!("{key}.url is required for scheme identificationLink"), + )), + // Checked only to be an absolute http(s) URL with a host. + // EN IEC 61406 format rules are not applied, so passing is + // not a conformance claim β€” the schema says the same. + Some(u) if is_absolute_web_url(u) => None, + Some(_) => Some(("format", format!("{key}.url must be an absolute URL"))), + }, + ), + "did" => ( + "did", + match object.get("did").and_then(Value::as_str) { + None => Some(("missing", format!("{key}.did is required for scheme did"))), + // The method set is closed: a DID method no reader can + // resolve identifies nothing. + Some(d) => match check_did(d) { + Ok(()) => None, + Err(DidRejection::UnsupportedMethod(method)) => Some(( + "format", + format!( + "{key}.did method '{method}' is not did:web, did:ethr or did:ebsi" + ), + )), + Err(DidRejection::EmptyMethodId) => Some(( + "format", + format!("{key}.did names a method but no identifier"), + )), + Err(DidRejection::Malformed) => { + Some(("format", format!("{key}.did is not a well-formed W3C DID"))) + } + }, + }, + ), + other => { + self.push_at( + format!("/{key}/scheme"), + "unknown", + format!("{key}.scheme '{other}' is not an EN 18219 clause 5 scheme"), + ); + return self; + } + }; + if let Some((code, message)) = err { + self.push_at(format!("/{key}/{field}"), code, message); + } + self + } + /// Require a recognized ISO 3166-1 alpha-2 country code. pub fn require_country(&mut self, key: &str) -> &mut Self { let err = match present(self.input, key).and_then(Value::as_str) { diff --git a/crates/dpp-rules/src/common/identifier.rs b/crates/dpp-rules/src/common/identifier.rs new file mode 100644 index 00000000..9aec32e7 --- /dev/null +++ b/crates/dpp-rules/src/common/identifier.rs @@ -0,0 +1,117 @@ +//! EN 18219:2026 clause 5 identifier syntax, for the two tiers that check it. +//! +//! 🚨 **One home on purpose.** These predicates lived only in +//! `dpp_domain::identifier::ProductIdentifier`, and the plugin SDK grew its own +//! copy when the product-group payloads moved to `productIdentifier`. The copies +//! disagreed: the SDK accepted `https:///p/1` and `did:web: ` because it tested +//! a prefix and a non-empty remainder, while the domain tested the authority and +//! the W3C grammar. A plugin is the *first* thing to see product group data, so +//! the weaker of the two copies was the one on the outside. +//! +//! Kept dependency-free and `no_std` so the Wasm guest SDK can call the same +//! code the host does, rather than a second reading of the same clause. +//! +// LAYOUT-DEVIATION: rule 15 counts users among a bucket's siblings, inside one +// crate. This module's two users are `dpp-domain` and `dpp-plugin-sdk`, so the +// count it can see is zero and the sharing it is testing for is real but +// cross-crate. `dpp-rules` exists precisely to be depended on by both without +// either depending on the other, so a type shared that way has no in-crate +// sibling to count and cannot satisfy the rule as written. + +/// The DID methods EN 18219 scheme 3 names. +/// +/// The standard describes scheme 3 as Decentralized Identifiers and names these +/// three as the admissible methods, `did:web` being the lightweight non-DLT +/// option. Closed rather than open because an identifier exists to be followed: +/// a method no reader can resolve identifies nothing, and accepting one would +/// let a passport be created that is unreachable by design. +pub const DID_METHODS: [&str; 3] = ["web", "ethr", "ebsi"]; + +/// Why a candidate scheme 3 value is not an admissible DID. +/// +/// Three variants rather than a `bool` because the callers report differently: +/// the domain has an error type per case, and a plugin turns them into one +/// field message. Neither should have to re-derive which case it hit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DidRejection<'a> { + /// Not `did::`, or the id breaks the W3C DID + /// v1.0 clause 3.1 grammar. + Malformed, + /// Well-formed, but the method is outside the closed set clause 5 names. + /// Carries the method as read, so a caller naming it in an error does not + /// have to take the value apart a second time. + UnsupportedMethod(&'a str), + /// A named method with nothing after it β€” `did:web:` identifies no one. + EmptyMethodId, +} + +/// An absolute `http`/`https` URL with a host, as EN 18219 scheme 2 requires. +/// +/// 🚨 The authority ends at the first `/`, `?` or `#` β€” it is not simply +/// "whatever follows the scheme". `https:///p/1` and `https://?q` each leave a +/// non-empty remainder and no host whatsoever, so testing that remainder for +/// emptiness accepted two values nothing can resolve. +/// +/// This is a shape check, not a conformance claim: scheme 2's format is +/// specified by EN IEC 61406-1/-2 and those rules are **not** applied here. +#[must_use] +pub fn is_absolute_web_url(url: &str) -> bool { + let Some(rest) = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + else { + return false; + }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + !authority.is_empty() && !url.contains(char::is_whitespace) +} + +/// A DID under one of the methods [`DID_METHODS`] names. +/// +/// # Errors +/// +/// [`DidRejection`], naming which of the three ways the value failed. +pub fn check_did(did: &str) -> Result<(), DidRejection<'_>> { + let rest = did.strip_prefix("did:").ok_or(DidRejection::Malformed)?; + let (method, method_id) = rest.split_once(':').ok_or(DidRejection::Malformed)?; + if !DID_METHODS.contains(&method) { + return Err(DidRejection::UnsupportedMethod(method)); + } + if method_id.is_empty() { + return Err(DidRejection::EmptyMethodId); + } + if !is_method_specific_id(method_id) { + return Err(DidRejection::Malformed); + } + Ok(()) +} + +/// W3C DID v1.0 clause 3.1: `method-specific-id = *( *idchar ":" ) 1*idchar`. +/// +/// Colon-separated segments, of which only the last must be non-empty. Checked +/// because the shape is the whole claim the value makes β€” one carrying a raw +/// space or a truncated `%` escape is not a DID with a formatting blemish, it is +/// a string no resolver will accept, pointing at no passport. +fn is_method_specific_id(id: &str) -> bool { + !id.is_empty() && !id.ends_with(':') && id.split(':').all(is_idchars) +} + +/// `idchar = ALPHA / DIGIT / "." / "-" / "_" / pct-encoded`, where +/// `pct-encoded = "%" HEXDIG HEXDIG`. +fn is_idchars(segment: &str) -> bool { + let mut chars = segment.chars(); + while let Some(c) = chars.next() { + let ok = match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => true, + '%' => matches!( + (chars.next(), chars.next()), + (Some(hi), Some(lo)) if hi.is_ascii_hexdigit() && lo.is_ascii_hexdigit() + ), + _ => false, + }; + if !ok { + return false; + } + } + true +} diff --git a/crates/dpp-rules/src/common/identifier_tests.rs b/crates/dpp-rules/src/common/identifier_tests.rs new file mode 100644 index 00000000..9504b8bc --- /dev/null +++ b/crates/dpp-rules/src/common/identifier_tests.rs @@ -0,0 +1,49 @@ +//! What [`is_absolute_web_url`](super::identifier::is_absolute_web_url) and +//! [`check_did`](super::identifier::check_did) accept, and the values the +//! plugin tier used to let through before both tiers shared them. + +use super::identifier::*; + +#[test] +fn an_absolute_web_url_needs_a_host() { + assert!(is_absolute_web_url("https://example.com/01/09506000134352")); + assert!(is_absolute_web_url("http://example.com")); + // The two the prefix-and-remainder test let through. + assert!(!is_absolute_web_url("https:///p/1")); + assert!(!is_absolute_web_url("https://?q")); + assert!(!is_absolute_web_url("https://")); + assert!(!is_absolute_web_url("https://exa mple.com")); + assert!(!is_absolute_web_url("ftp://example.com")); + assert!(!is_absolute_web_url("example.com")); +} + +#[test] +fn a_did_needs_a_named_method_and_a_well_formed_id() { + assert_eq!(check_did("did:web:example.com"), Ok(())); + assert_eq!(check_did("did:ethr:0xAbC123"), Ok(())); + assert_eq!(check_did("did:ebsi:z25a23eWUxQQzmAgnD9srpuU"), Ok(())); + // A colon-separated id: only the last segment must be non-empty. + assert_eq!(check_did("did:web:example.com:products:1"), Ok(())); + assert_eq!(check_did("did:web:ex%2Fample"), Ok(())); + + // The one the non-empty-remainder test let through. + assert_eq!(check_did("did:web: "), Err(DidRejection::Malformed)); + assert_eq!(check_did("did:web:"), Err(DidRejection::EmptyMethodId)); + assert_eq!( + check_did("did:key:z6Mk"), + Err(DidRejection::UnsupportedMethod("key")) + ); + assert_eq!(check_did("did:web:a:"), Err(DidRejection::Malformed)); + // `%2a` would be a *valid* escape β€” `a` is a hex digit. The truncated + // one that is not is `%2z`. + assert_eq!( + check_did("did:web:ex%2zample"), + Err(DidRejection::Malformed) + ); + assert_eq!( + check_did("did:web:trailing%2"), + Err(DidRejection::Malformed) + ); + assert_eq!(check_did("did:web"), Err(DidRejection::Malformed)); + assert_eq!(check_did("web:example.com"), Err(DidRejection::Malformed)); +} diff --git a/crates/dpp-rules/src/common/mod.rs b/crates/dpp-rules/src/common/mod.rs index 28720c93..72a2995e 100644 --- a/crates/dpp-rules/src/common/mod.rs +++ b/crates/dpp-rules/src/common/mod.rs @@ -1,7 +1,11 @@ -//! Cross-product group helper rules: country code validation, numeric utilities, unit -//! conversions, and the dependency-free date key used for regulatory phase selection. +//! Cross-product group helper rules: country code validation, unique product +//! identifier syntax, numeric utilities, unit conversions, and the +//! dependency-free date key used for regulatory phase selection. pub mod country; pub mod date; +pub mod identifier; +#[cfg(test)] +mod identifier_tests; pub mod numeric; pub mod units; diff --git a/plugins/product-group-aluminium/src/lib.rs b/plugins/product-group-aluminium/src/lib.rs index 62cc1fcd..f4357483 100644 --- a/plugins/product-group-aluminium/src/lib.rs +++ b/plugins/product-group-aluminium/src/lib.rs @@ -27,13 +27,13 @@ impl DppProductGroupPlugin for AluminiumPlugin { fn schema_version_range(&self) -> SchemaVersionRange { SchemaVersionRange { min_version: "1.0.0".into(), - max_version: "1.1.0".into(), + max_version: "1.2.0".into(), } } fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { Validator::new(input) - .require_gtin("gtin") + .require_product_identifier("productIdentifier") .require_str("alloyGrade") .require_enum( "productionRoute", @@ -84,7 +84,7 @@ mod tests { fn valid() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "alloyGrade": "6xxx", "productionRoute": "secondary-recycled", "co2ePerTonneKg": 600.0, diff --git a/plugins/product-group-battery/src/lib.rs b/plugins/product-group-battery/src/lib.rs index 79ce9fd8..133b38a8 100644 --- a/plugins/product-group-battery/src/lib.rs +++ b/plugins/product-group-battery/src/lib.rs @@ -45,17 +45,17 @@ impl DppProductGroupPlugin for BatteryPlugin { } } - // Battery schema ships as v1.0.0 through v2.5.0 (Annex XIII + Annex VII). + // Battery schema ships as v1.0.0 through v2.7.0 (Annex XIII + Annex VII). fn schema_version_range(&self) -> SchemaVersionRange { SchemaVersionRange { min_version: "1.0.0".into(), - max_version: "2.6.0".into(), + max_version: "2.7.0".into(), } } fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { Validator::new(input) - .require_gtin("gtin") + .require_product_identifier("productIdentifier") .require_str("batteryChemistry") .require_positive("nominalVoltageV") .require_positive("nominalCapacityAh") @@ -453,7 +453,7 @@ mod tests { fn valid_battery() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "LFP", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, @@ -473,7 +473,7 @@ mod tests { fn capabilities_cover_battery_schema_range() { let caps = BatteryPlugin.capabilities(); assert_eq!(caps.abi_version, AbiVersion::current()); - assert_eq!(caps.supported_schemas[0].max_version, "2.6.0"); + assert_eq!(caps.supported_schemas[0].max_version, "2.7.0"); assert!(caps.capabilities.contains(&PluginCapability::Validate)); } @@ -483,15 +483,15 @@ mod tests { } #[test] - fn missing_gtin_fails_with_field_error() { + fn missing_product_identifier_fails_with_field_error() { let mut data = valid_battery(); - data.as_object_mut().unwrap().remove("gtin"); + data.as_object_mut().unwrap().remove("productIdentifier"); let err = BatteryPlugin.validate_input(&data).unwrap_err(); match err { PluginError::ValidationErrors(errs) => { assert!( errs.iter() - .any(|e| e.field == "/gtin" && e.code == "missing") + .any(|e| e.field == "/productIdentifier" && e.code == "missing") ); } other => panic!("expected ValidationErrors, got {other:?}"), @@ -501,11 +501,32 @@ mod tests { #[test] fn malformed_gtin_fails() { let mut data = valid_battery(); - data["gtin"] = json!("12-34"); + data["productIdentifier"]["gtin"] = json!("12-34"); let err = BatteryPlugin.validate_input(&data).unwrap_err(); assert!(matches!(err, PluginError::ValidationErrors(_))); } + /// 🚨 A battery identified without GS1 is still a battery. + /// + /// Scheme 1 is what hid the old defect: while every record carried a bare + /// `gtin`, requiring one always succeeded. Schemes 2 and 3 carry no GTIN + /// at all, so a plugin that asks for one rejects exactly the passports the + /// identifier work introduced. + #[test] + fn a_battery_identified_without_gs1_is_accepted() { + for identifier in [ + json!({"scheme": "identificationLink", "url": "https://id.acme.example.com/b/1"}), + json!({"scheme": "did", "did": "did:web:acme.example.com:b:1"}), + ] { + let mut data = valid_battery(); + data["productIdentifier"] = identifier.clone(); + assert!( + BatteryPlugin.validate_input(&data).is_ok(), + "scheme carrying no GTIN was refused: {identifier}" + ); + } + } + #[test] fn non_positive_voltage_fails() { let mut data = valid_battery(); @@ -553,13 +574,13 @@ mod tests { fn generate_passport_is_passthrough_on_valid() { let data = valid_battery(); let out = BatteryPlugin.generate_passport(data).unwrap(); - assert_eq!(out["gtin"], "12345678901231"); + assert_eq!(out["productIdentifier"]["gtin"], "12345678901231"); } #[test] fn nmc_below_2031_cobalt_emits_advisory_warning_not_violation() { let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "NMC", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, @@ -595,7 +616,7 @@ mod tests { fn lfp_zero_cobalt_does_not_warn() { // LFP contains no cobalt; a defaulted 0.0 must not produce a shortfall. let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "LFP", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, @@ -617,7 +638,7 @@ mod tests { #[test] fn portable_battery_skips_recycled_content_warnings() { let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "NMC", "batteryType": "portable", "nominalVoltageV": 3.6, @@ -637,7 +658,7 @@ mod tests { #[test] fn rated_capacity_unit_error_emits_advisory_warning() { let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "LFP", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, // nominal = 4.8 kWh @@ -660,7 +681,7 @@ mod tests { #[test] fn consistent_rated_capacity_no_warning() { let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "LFP", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, @@ -678,7 +699,7 @@ mod tests { // 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", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "NMC", "batteryType": "lmt", "nominalVoltageV": 36.0, @@ -706,7 +727,7 @@ mod tests { // EU market in 2030, so that minimum never attaches to it. Deriving the // phase from "today" would report it as short from 18 Aug 2031 onwards. let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "NMC", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, @@ -737,7 +758,7 @@ mod tests { #[test] fn missing_market_date_is_reported_rather_than_guessed() { let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "NMC", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, @@ -765,7 +786,7 @@ mod tests { #[test] fn malformed_market_date_is_treated_as_missing() { let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "NMC", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, @@ -788,7 +809,7 @@ mod tests { #[test] fn lmt_placed_after_2036_is_bound_by_phase_two() { let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "NMC", "batteryType": "lmt", "nominalVoltageV": 36.0, @@ -816,7 +837,7 @@ mod tests { fn small_industrial_below_target_gets_no_phase1_advisory() { // Industrial batteries ≀ 2 kWh are out of Phase-1 scope. let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "NMC", "batteryType": "industrial", "nominalVoltageV": 12.0, @@ -840,7 +861,7 @@ mod tests { fn large_industrial_below_target_gets_phase1_advisory() { // > 2 kWh industrial IS in Phase-1 scope. let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "NMC", "batteryType": "industrial", "nominalVoltageV": 48.0, @@ -866,7 +887,7 @@ mod tests { // LFP contains no cobalt; a positive cobalt declaration is a data // contradiction that must be surfaced, not silently accepted. let data = json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "LFP", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, @@ -1010,7 +1031,7 @@ mod art8_declaration_tests { fn shares_without_year() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryChemistry": "NMC", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, @@ -1062,7 +1083,7 @@ mod art8_declaration_tests { /// Without a second-life status this must produce a shortfall. fn ev_short_on_cobalt() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "batteryType": "ev", "batteryChemistry": "NMC", "nominalVoltageV": 400.0, diff --git a/plugins/product-group-construction/src/lib.rs b/plugins/product-group-construction/src/lib.rs index 80e6914e..e4284478 100644 --- a/plugins/product-group-construction/src/lib.rs +++ b/plugins/product-group-construction/src/lib.rs @@ -28,13 +28,13 @@ impl DppProductGroupPlugin for ConstructionPlugin { fn schema_version_range(&self) -> SchemaVersionRange { SchemaVersionRange { min_version: "1.0.0".into(), - max_version: "1.1.0".into(), + max_version: "1.2.0".into(), } } fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { Validator::new(input) - .require_gtin("gtin") + .require_product_identifier("productIdentifier") .require_str("productFamily") .require_country("countryOfOrigin") .require_non_negative("co2ePerFunctionalUnitKg") @@ -68,7 +68,7 @@ mod tests { fn valid() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "productFamily": "cement", "countryOfOrigin": "PL", "co2ePerFunctionalUnitKg": 780.0, diff --git a/plugins/product-group-detergent/src/lib.rs b/plugins/product-group-detergent/src/lib.rs index 4f4b4387..493eb000 100644 --- a/plugins/product-group-detergent/src/lib.rs +++ b/plugins/product-group-detergent/src/lib.rs @@ -28,13 +28,13 @@ impl DppProductGroupPlugin for DetergentPlugin { fn schema_version_range(&self) -> SchemaVersionRange { SchemaVersionRange { min_version: "1.0.0".into(), - max_version: "1.1.0".into(), + max_version: "1.2.0".into(), } } fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { Validator::new(input) - .require_gtin("gtin") + .require_product_identifier("productIdentifier") .require_str("productType") .require_str("format") .require_non_empty_array("surfactants") @@ -98,7 +98,7 @@ mod tests { fn valid() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "productType": "laundry", "format": "liquid", "surfactants": [ diff --git a/plugins/product-group-electronics/src/lib.rs b/plugins/product-group-electronics/src/lib.rs index e55a4bb4..dcf19b93 100644 --- a/plugins/product-group-electronics/src/lib.rs +++ b/plugins/product-group-electronics/src/lib.rs @@ -35,13 +35,13 @@ impl DppProductGroupPlugin for ElectronicsPlugin { fn schema_version_range(&self) -> SchemaVersionRange { SchemaVersionRange { min_version: "1.0.0".into(), - max_version: "1.2.0".into(), + max_version: "1.4.0".into(), } } fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { Validator::new(input) - .require_gtin("gtin") + .require_product_identifier("productIdentifier") .require_enum( "productCategory", &["smartphone", "other-mobile-phone", "cordless-phone", "tablet"], @@ -99,7 +99,7 @@ mod tests { fn base() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "productCategory": "smartphone", "energyEfficiencyClass": "A", "co2ePerUnitKg": 55.0 diff --git a/plugins/product-group-furniture/src/lib.rs b/plugins/product-group-furniture/src/lib.rs index 25a4c465..1abf6ef0 100644 --- a/plugins/product-group-furniture/src/lib.rs +++ b/plugins/product-group-furniture/src/lib.rs @@ -29,13 +29,13 @@ impl DppProductGroupPlugin for FurniturePlugin { fn schema_version_range(&self) -> SchemaVersionRange { SchemaVersionRange { min_version: "1.0.0".into(), - max_version: "1.1.0".into(), + max_version: "1.3.0".into(), } } fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { Validator::new(input) - .require_gtin("gtin") + .require_product_identifier("productIdentifier") .require_str("productType") .require_str("primaryMaterial") .require_country("countryOfOrigin") @@ -71,7 +71,7 @@ mod tests { fn valid() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "productType": "chair", "primaryMaterial": "solid-wood", "countryOfOrigin": "SE", diff --git a/plugins/product-group-steel/src/lib.rs b/plugins/product-group-steel/src/lib.rs index 05ea4d15..6ba4fdd1 100644 --- a/plugins/product-group-steel/src/lib.rs +++ b/plugins/product-group-steel/src/lib.rs @@ -30,13 +30,13 @@ impl DppProductGroupPlugin for SteelPlugin { fn schema_version_range(&self) -> SchemaVersionRange { SchemaVersionRange { min_version: "1.0.0".into(), - max_version: "1.1.0".into(), + max_version: "1.2.0".into(), } } fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { Validator::new(input) - .require_gtin("gtin") + .require_product_identifier("productIdentifier") .require_non_negative("co2ePerTonneSteel") .require_pct("recycledScrapContentPct") .require_str("productCategory") @@ -87,7 +87,7 @@ mod tests { fn valid() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "co2ePerTonneSteel": 0.35, "recycledScrapContentPct": 90.0, "productCategory": "long", diff --git a/plugins/product-group-textile/src/lib.rs b/plugins/product-group-textile/src/lib.rs index a82855a5..e5d9d0c0 100644 --- a/plugins/product-group-textile/src/lib.rs +++ b/plugins/product-group-textile/src/lib.rs @@ -43,7 +43,7 @@ impl DppProductGroupPlugin for TextilePlugin { fn schema_version_range(&self) -> SchemaVersionRange { SchemaVersionRange { min_version: "1.0.0".into(), - max_version: "1.2.0".into(), + max_version: "1.3.0".into(), } } diff --git a/plugins/product-group-toy/src/lib.rs b/plugins/product-group-toy/src/lib.rs index e04d32c6..2ce08e6a 100644 --- a/plugins/product-group-toy/src/lib.rs +++ b/plugins/product-group-toy/src/lib.rs @@ -28,13 +28,13 @@ impl DppProductGroupPlugin for ToyPlugin { fn schema_version_range(&self) -> SchemaVersionRange { SchemaVersionRange { min_version: "1.0.0".into(), - max_version: "1.1.0".into(), + max_version: "1.2.0".into(), } } fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { Validator::new(input) - .require_gtin("gtin") + .require_product_identifier("productIdentifier") .require_str("ageGroup") .require_str("primaryMaterial") .require_bool("ceMarking") @@ -68,7 +68,7 @@ mod tests { fn valid() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "ageGroup": "3-6", "primaryMaterial": "wood", "ceMarking": true, diff --git a/plugins/product-group-tyre/src/lib.rs b/plugins/product-group-tyre/src/lib.rs index 1a54c522..492bc6a9 100644 --- a/plugins/product-group-tyre/src/lib.rs +++ b/plugins/product-group-tyre/src/lib.rs @@ -29,13 +29,13 @@ impl DppProductGroupPlugin for TyrePlugin { fn schema_version_range(&self) -> SchemaVersionRange { SchemaVersionRange { min_version: "1.0.0".into(), - max_version: "1.0.0".into(), + max_version: "1.1.0".into(), } } fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { Validator::new(input) - .require_gtin("gtin") + .require_product_identifier("productIdentifier") .require_enum("tyreClass", &["C1", "C2", "C3"]) .require_enum("fuelEfficiencyClass", &["A", "B", "C", "D", "E"]) .require_enum("wetGripClass", &["A", "B", "C", "D", "E"]) @@ -68,7 +68,7 @@ mod tests { fn valid() -> Value { json!({ - "gtin": "12345678901231", + "productIdentifier": {"scheme": "gs1", "gtin": "12345678901231"}, "tyreClass": "C1", "fuelEfficiencyClass": "B", "wetGripClass": "A",