dpp_rules/common/identifier.rs
1//! EN 18219:2026 clause 5 identifier syntax, for the two tiers that check it,
2//! and the GS1 AI 10 batch and AI 21 serial a passport's data carrier prints.
3//!
4//! 🚨 **One home on purpose.** These predicates lived only in
5//! `dpp_domain::identifier::ProductIdentifier`, and the plugin SDK grew its own
6//! copy when the product-group payloads moved to `productIdentifier`. The copies
7//! disagreed: the SDK accepted `https:///p/1` and `did:web: ` because it tested
8//! a prefix and a non-empty remainder, while the domain tested the authority and
9//! the W3C grammar. A plugin is the *first* thing to see product group data, so
10//! the weaker of the two copies was the one on the outside.
11//!
12//! Kept dependency-free and `no_std` so the Wasm guest SDK can call the same
13//! code the host does, rather than a second reading of the same clause.
14//!
15// LAYOUT-DEVIATION: rule 15 counts users among a bucket's siblings, inside one
16// crate. This module's two users are `dpp-domain` and `dpp-plugin-sdk`, so the
17// count it can see is zero and the sharing it is testing for is real but
18// cross-crate. `dpp-rules` exists precisely to be depended on by both without
19// either depending on the other, so a type shared that way has no in-crate
20// sibling to count and cannot satisfy the rule as written.
21
22/// The DID methods EN 18219 scheme 3 names.
23///
24/// The standard describes scheme 3 as Decentralized Identifiers and names these
25/// three as the admissible methods, `did:web` being the lightweight non-DLT
26/// option. Closed rather than open because an identifier exists to be followed:
27/// a method no reader can resolve identifies nothing, and accepting one would
28/// let a passport be created that is unreachable by design.
29pub const DID_METHODS: [&str; 3] = ["web", "ethr", "ebsi"];
30
31/// Why a candidate scheme 3 value is not an admissible DID.
32///
33/// Three variants rather than a `bool` because the callers report differently:
34/// the domain has an error type per case, and a plugin turns them into one
35/// field message. Neither should have to re-derive which case it hit.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum DidRejection<'a> {
38 /// Not `did:<method>:<method-specific-id>`, or the id breaks the W3C DID
39 /// v1.0 clause 3.1 grammar.
40 Malformed,
41 /// Well-formed, but the method is outside the closed set clause 5 names.
42 /// Carries the method as read, so a caller naming it in an error does not
43 /// have to take the value apart a second time.
44 UnsupportedMethod(&'a str),
45 /// A named method with nothing after it — `did:web:` identifies no one.
46 EmptyMethodId,
47}
48
49/// An absolute `http`/`https` URL with a host, as EN 18219 scheme 2 requires.
50///
51/// 🚨 The authority ends at the first `/`, `?` or `#` — it is not simply
52/// "whatever follows the scheme". `https:///p/1` and `https://?q` each leave a
53/// non-empty remainder and no host whatsoever, so testing that remainder for
54/// emptiness accepted two values nothing can resolve.
55///
56/// This is a shape check, not a conformance claim: scheme 2's format is
57/// specified by EN IEC 61406-1/-2 and those rules are **not** applied here.
58#[must_use]
59pub fn is_absolute_web_url(url: &str) -> bool {
60 let Some(rest) = url
61 .strip_prefix("https://")
62 .or_else(|| url.strip_prefix("http://"))
63 else {
64 return false;
65 };
66 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
67 !authority.is_empty() && !url.contains(char::is_whitespace)
68}
69
70/// A DID under one of the methods [`DID_METHODS`] names.
71///
72/// # Errors
73///
74/// [`DidRejection`], naming which of the three ways the value failed.
75pub fn check_did(did: &str) -> Result<(), DidRejection<'_>> {
76 let rest = did.strip_prefix("did:").ok_or(DidRejection::Malformed)?;
77 let (method, method_id) = rest.split_once(':').ok_or(DidRejection::Malformed)?;
78 if !DID_METHODS.contains(&method) {
79 return Err(DidRejection::UnsupportedMethod(method));
80 }
81 if method_id.is_empty() {
82 return Err(DidRejection::EmptyMethodId);
83 }
84 if !is_method_specific_id(method_id) {
85 return Err(DidRejection::Malformed);
86 }
87 Ok(())
88}
89
90/// The most characters a GS1 AI 21 serial number may carry.
91///
92/// GS1's Barcode Syntax Dictionary specifies AI 21 as `X..20`: one to twenty
93/// characters of CSET 82. That dictionary is vendored by `dpp-digital-link`,
94/// which this crate cannot depend on, so the number is restated here and a
95/// cross-crate test holds it against the dictionary's own entry.
96pub const MAX_GS1_SERIAL_CHARS: usize = 20;
97
98/// The most characters a GS1 AI 10 batch or lot number may carry.
99///
100/// AI 10 is `X..20` in the same dictionary — the same rule as AI 21, restated
101/// under its own name because the two are separate entries that GS1 could
102/// change apart. The same cross-crate test holds it against the dictionary.
103pub const MAX_GS1_LOT_CHARS: usize = 20;
104
105/// Why a candidate AI 10 or AI 21 value cannot be printed.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum Gs1ValueRejection {
108 /// No characters at all. `X..20` has a minimum of one.
109 Empty,
110 /// More than the AI allows — [`MAX_GS1_LOT_CHARS`] or
111 /// [`MAX_GS1_SERIAL_CHARS`] — counted in characters.
112 TooLong {
113 /// How many characters the value has.
114 chars: usize,
115 },
116 /// A character outside CSET 82 — the first one met.
117 OutsideCset82(char),
118}
119
120/// Whether `c` belongs to GS1 CSET 82, the character set of every `X`-typed
121/// Application Identifier component — AI 10 and AI 21 among them.
122///
123/// 🚨 **The table below is ours, and it is not checked against a text.** The
124/// syntax dictionary names the set (`"X": CSET 82`) without enumerating it; the
125/// enumeration is in the GS1 General Specifications, which this repository does
126/// not hold. What checks it instead is GS1's own Barcode Syntax Engine: the
127/// Digital Link oracle corpus carries every printable ASCII character in an
128/// AI 21 value together with this function's verdict, and GS1's engine has to
129/// agree in both directions.
130#[must_use]
131pub const fn is_cset_82(c: char) -> bool {
132 matches!(
133 c,
134 '!' | '"'
135 | '%'
136 | '&'
137 | '\''
138 | '('
139 | ')'
140 | '*'
141 | '+'
142 | ','
143 | '-'
144 | '.'
145 | '/'
146 | '0'..='9'
147 | ':'
148 | ';'
149 | '<'
150 | '='
151 | '>'
152 | '?'
153 | 'A'..='Z'
154 | '_'
155 | 'a'..='z'
156 )
157}
158
159/// A value GS1 admits in AI 21: one to [`MAX_GS1_SERIAL_CHARS`] characters, all
160/// in CSET 82.
161///
162/// # Errors
163///
164/// [`Gs1ValueRejection`], naming the first rule the value breaks.
165pub fn check_gs1_serial(value: &str) -> Result<(), Gs1ValueRejection> {
166 check_cset_82_up_to(value, MAX_GS1_SERIAL_CHARS)
167}
168
169/// A value GS1 admits in AI 10: one to [`MAX_GS1_LOT_CHARS`] characters, all in
170/// CSET 82.
171///
172/// # Errors
173///
174/// [`Gs1ValueRejection`], naming the first rule the value breaks.
175pub fn check_gs1_lot(value: &str) -> Result<(), Gs1ValueRejection> {
176 check_cset_82_up_to(value, MAX_GS1_LOT_CHARS)
177}
178
179/// `X..max`: one to `max` characters, all in CSET 82.
180fn check_cset_82_up_to(value: &str, max: usize) -> Result<(), Gs1ValueRejection> {
181 if value.is_empty() {
182 return Err(Gs1ValueRejection::Empty);
183 }
184 let chars = value.chars().count();
185 if chars > max {
186 return Err(Gs1ValueRejection::TooLong { chars });
187 }
188 match value.chars().find(|c| !is_cset_82(*c)) {
189 Some(c) => Err(Gs1ValueRejection::OutsideCset82(c)),
190 None => Ok(()),
191 }
192}
193
194/// W3C DID v1.0 clause 3.1: `method-specific-id = *( *idchar ":" ) 1*idchar`.
195///
196/// Colon-separated segments, of which only the last must be non-empty. Checked
197/// because the shape is the whole claim the value makes — one carrying a raw
198/// space or a truncated `%` escape is not a DID with a formatting blemish, it is
199/// a string no resolver will accept, pointing at no passport.
200fn is_method_specific_id(id: &str) -> bool {
201 !id.is_empty() && !id.ends_with(':') && id.split(':').all(is_idchars)
202}
203
204/// `idchar = ALPHA / DIGIT / "." / "-" / "_" / pct-encoded`, where
205/// `pct-encoded = "%" HEXDIG HEXDIG`.
206fn is_idchars(segment: &str) -> bool {
207 let mut chars = segment.chars();
208 while let Some(c) = chars.next() {
209 let ok = match c {
210 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => true,
211 '%' => matches!(
212 (chars.next(), chars.next()),
213 (Some(hi), Some(lo)) if hi.is_ascii_hexdigit() && lo.is_ascii_hexdigit()
214 ),
215 _ => false,
216 };
217 if !ok {
218 return false;
219 }
220 }
221 true
222}