Skip to main content

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}