Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 14 additions & 67 deletions crates/dpp-domain/src/identifier/product_identifier/scheme.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Self, ProductIdentifierError> {
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 {
Expand All @@ -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<Self, ProductIdentifierError> {
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(),
})
Expand Down Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions crates/dpp-plugin-sdk/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Loading
Loading