Skip to main content

postproject_core/
identifier_registry.rs

1//! Optional definitions and syntax checks for well-known identifier schemes.
2
3use crate::{Error, ErrorKind, ExternalIdentifier, IdentifierScheme, Result};
4
5/// SMPTE UMID values represented according to SMPTE ST 2029.
6pub const SMPTE_UMID_SCHEME: &str = "urn:smpte:umid";
7/// International Standard Audiovisual Number URNs from RFC 4246.
8pub const ISAN_SCHEME: &str = "urn:isan";
9/// Entertainment Identifier Registry URNs from RFC 7972.
10pub const EIDR_SCHEME: &str = "urn:eidr";
11/// Application-defined identifiers whose value semantics belong to the host.
12pub const POSTPROJECT_APPLICATION_SCHEME: &str = "https://postproject.org/id/application";
13
14/// The strength of local validation supplied for a known scheme.
15#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
16#[non_exhaustive]
17pub enum IdentifierValidationKind {
18    /// Only the generic bounded-text rules apply.
19    Opaque,
20    /// The registry checks published syntax without resolving the identifier.
21    Syntax,
22}
23
24/// Documentation and optional local validation for one identifier scheme.
25#[derive(Clone, Copy, Debug)]
26pub struct IdentifierSchemeDefinition {
27    scheme: &'static str,
28    label: &'static str,
29    reference: &'static str,
30    validation: IdentifierValidationKind,
31    validator: Option<fn(&str) -> bool>,
32}
33
34impl IdentifierSchemeDefinition {
35    /// Returns the exact scheme string stored with an external identifier.
36    #[must_use]
37    pub const fn scheme(self) -> &'static str {
38        self.scheme
39    }
40
41    /// Returns a short human-readable label.
42    #[must_use]
43    pub const fn label(self) -> &'static str {
44        self.label
45    }
46
47    /// Returns the authoritative syntax reference.
48    #[must_use]
49    pub const fn reference(self) -> &'static str {
50        self.reference
51    }
52
53    /// Returns the local validation strength.
54    #[must_use]
55    pub const fn validation(self) -> IdentifierValidationKind {
56        self.validation
57    }
58
59    /// Checks the value without normalization or any registry/network access.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`ErrorKind::InvalidArgument`] when a syntax-checked value does
64    /// not match the published lexical form supported by this definition.
65    pub fn validate_value(self, value: &str) -> Result<()> {
66        if self.validator.is_none_or(|validator| validator(value)) {
67            return Ok(());
68        }
69        Err(Error::new(
70            ErrorKind::InvalidArgument,
71            format!("identifier value does not match {} syntax", self.label),
72        ))
73    }
74}
75
76/// Small built-in registry. Unknown schemes remain valid opaque identifiers.
77pub const IDENTIFIER_SCHEMES: &[IdentifierSchemeDefinition] = &[
78    definition(
79        SMPTE_UMID_SCHEME,
80        "SMPTE UMID",
81        "https://pub.smpte.org/doc/st2029/20090310-pub/st2029-2009.pdf",
82        Some(valid_umid),
83    ),
84    definition(
85        ISAN_SCHEME,
86        "ISAN",
87        "https://www.rfc-editor.org/rfc/rfc4246.html",
88        Some(valid_isan),
89    ),
90    definition(
91        EIDR_SCHEME,
92        "EIDR",
93        "https://www.rfc-editor.org/rfc/rfc7972.html",
94        Some(valid_eidr),
95    ),
96    definition(
97        POSTPROJECT_APPLICATION_SCHEME,
98        "PostProject application identifier",
99        POSTPROJECT_APPLICATION_SCHEME,
100        None,
101    ),
102];
103
104/// Finds a built-in definition by exact scheme spelling.
105#[must_use]
106pub fn identifier_scheme_definition(
107    scheme: &IdentifierScheme,
108) -> Option<&'static IdentifierSchemeDefinition> {
109    IDENTIFIER_SCHEMES
110        .iter()
111        .find(|definition| definition.scheme == scheme.as_str())
112}
113
114/// Applies optional built-in validation and reports whether the scheme is known.
115///
116/// Unknown schemes return `Ok(false)` and remain fully preservable.
117///
118/// # Errors
119///
120/// Returns an error only when a known syntax-checked value is invalid.
121pub fn validate_known_identifier(identifier: &ExternalIdentifier) -> Result<bool> {
122    let Some(definition) = identifier_scheme_definition(identifier.scheme()) else {
123        return Ok(false);
124    };
125    definition.validate_value(identifier.value())?;
126    Ok(true)
127}
128
129const fn definition(
130    scheme: &'static str,
131    label: &'static str,
132    reference: &'static str,
133    validator: Option<fn(&str) -> bool>,
134) -> IdentifierSchemeDefinition {
135    IdentifierSchemeDefinition {
136        scheme,
137        label,
138        reference,
139        validation: if validator.is_some() {
140            IdentifierValidationKind::Syntax
141        } else {
142            IdentifierValidationKind::Opaque
143        },
144        validator,
145    }
146}
147
148fn valid_umid(value: &str) -> bool {
149    if !value.contains('.') {
150        return matches!(value.len(), 64 | 128)
151            && value.bytes().all(|byte| byte.is_ascii_hexdigit());
152    }
153    let mut groups = value.split('.');
154    let count = groups.clone().count();
155    matches!(count, 8 | 16)
156        && groups
157            .all(|group| group.len() == 8 && group.bytes().all(|byte| byte.is_ascii_hexdigit()))
158}
159
160fn valid_isan(value: &str) -> bool {
161    let parts: Vec<_> = value.split('-').collect();
162    if !matches!(parts.len(), 5 | 8)
163        || !parts[..4].iter().all(|part| hex_group(part, 4))
164        || !check_character(parts[4])
165    {
166        return false;
167    }
168    parts.len() == 5
169        || (parts[5..7].iter().all(|part| hex_group(part, 4)) && check_character(parts[7]))
170}
171
172fn valid_eidr(value: &str) -> bool {
173    let Some((prefix, suffix)) = value.split_once(':') else {
174        return false;
175    };
176    if prefix.is_empty()
177        || suffix.is_empty()
178        || !prefix.bytes().all(eidr_character)
179        || !suffix.bytes().all(eidr_character)
180    {
181        return false;
182    }
183    if prefix != "10.5240" {
184        return true;
185    }
186    let parts: Vec<_> = suffix.split('-').collect();
187    parts.len() == 6
188        && parts[..5].iter().all(|part| hex_group(part, 4))
189        && check_character(parts[5])
190}
191
192fn hex_group(value: &str, length: usize) -> bool {
193    value.len() == length && value.bytes().all(|byte| byte.is_ascii_hexdigit())
194}
195
196fn check_character(value: &str) -> bool {
197    value.len() == 1 && value.bytes().all(|byte| byte.is_ascii_alphanumeric())
198}
199
200fn eidr_character(byte: u8) -> bool {
201    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_')
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn identifier(scheme: &str, value: &str) -> ExternalIdentifier {
209        ExternalIdentifier::new(
210            IdentifierScheme::new(scheme).expect("valid scheme"),
211            value,
212            None,
213        )
214        .expect("generically valid identifier")
215    }
216
217    #[test]
218    fn published_examples_pass_local_syntax_checks() {
219        let umid = identifier(
220            SMPTE_UMID_SCHEME,
221            "060a2b34.01010105.01010d20.13000000.d2c9036c.8f195343.ab7014d2.d718bfda",
222        );
223        let isan = identifier(ISAN_SCHEME, "1881-66C7-3420-6541-9-9F3A-0245-U");
224        let eidr = identifier(EIDR_SCHEME, "10.5240:7791-8534-2C23-9030-8610-5");
225        assert_eq!(validate_known_identifier(&umid), Ok(true));
226        assert_eq!(validate_known_identifier(&isan), Ok(true));
227        assert_eq!(validate_known_identifier(&eidr), Ok(true));
228    }
229
230    #[test]
231    fn known_invalid_values_fail_without_changing_unknown_schemes() {
232        assert!(validate_known_identifier(&identifier(EIDR_SCHEME, "not-an-eidr")).is_err());
233        assert_eq!(
234            validate_known_identifier(&identifier("com.example.camera", " exact value ")),
235            Ok(false)
236        );
237    }
238
239    #[test]
240    fn application_identifiers_are_known_and_remain_opaque() {
241        let identifier = identifier(POSTPROJECT_APPLICATION_SCHEME, "editor:scene/42");
242        assert_eq!(validate_known_identifier(&identifier), Ok(true));
243        assert_eq!(
244            identifier_scheme_definition(identifier.scheme())
245                .expect("known application scheme")
246                .reference(),
247            POSTPROJECT_APPLICATION_SCHEME
248        );
249    }
250}