Skip to main content

structured_pkix/name/
selector.rs

1//! Strict RFC 4514 lexical identities, independent of input/parser lifetimes.
2
3use super::{Attribute, AttributeValue, Name, Strings, Syntax, prepared, segments};
4use crate::error::{Error, ErrorCode, Located, Result};
5use crate::oid;
6use core::cmp::Ordering;
7use core::ops::Range;
8use structured_asn1::memory::{Memory, OwnedVec, Work};
9use structured_asn1::{Class, Oid, OwnedOid, Parser, ScopedReader as Reader, Tag, universal};
10
11const FIELD: &str = "name.selector";
12
13/// An independently owned RFC 4514 distinguished-name assertion.
14///
15/// Parsing is strict: separator whitespace is not implicitly discarded.
16/// Values retain their decoded assertion octets or complete original BER.
17/// RDN order and unordered membership use the certificate Name's shared
18/// comparison core. Storage remains charged after the parser/account handle
19/// is dropped; each subsequent operation takes its own execution account.
20#[derive(Debug)]
21pub struct Selector {
22    parts: OwnedVec<OwnedVec<Selected>>,
23}
24
25#[derive(Debug)]
26struct Selected {
27    kind: Kind,
28    bytes: OwnedVec<u8>,
29    value: Value,
30    offset: u64,
31}
32
33#[derive(Debug)]
34enum Kind {
35    Known(Oid<'static>),
36    Numeric(OwnedOid),
37}
38
39impl Kind {
40    fn as_oid(&self) -> Oid<'_> {
41        match self {
42            Self::Known(kind) => *kind,
43            Self::Numeric(kind) => kind.as_oid(),
44        }
45    }
46}
47
48#[derive(Debug)]
49enum Value {
50    Assertion,
51    Ber {
52        tag: Tag,
53        contents: Range<usize>,
54        joined: Option<OwnedVec<u8>>,
55    },
56}
57
58impl Selector {
59    /// Parses the grammar of RFC 4514 §3 without compatibility trimming.
60    /// Unknown numeric types are preserved. An unknown descriptor, malformed
61    /// escape, incomplete BER value or syntax violation is a located error;
62    /// unavailable equality semantics remain distinct at comparison time.
63    ///
64    /// ```
65    /// use std::sync::Arc;
66    /// use structured_asn1::memory::{Memory, MemoryAccount};
67    /// use structured_pkix::name::Selector;
68    /// let memory = Memory::new(Arc::new(MemoryAccount::new(4096, 100_000)));
69    /// let left = Selector::parse("CN=A\\,B,O=Example", &memory)?;
70    /// let right = Selector::parse("cn=A\\2CB,o=example", &memory)?;
71    /// assert!(left.matches(&right, &memory)?);
72    /// # Ok::<(), structured_pkix::Error>(())
73    /// ```
74    pub fn parse(text: &str, execution: &Memory) -> Result<Self> {
75        Self::parse_with(text, execution, None)
76    }
77
78    /// Parses original RFC4514 text under the current producing operation.
79    /// Lexical/OID conversion, BER-valued assertions, scratch and retained
80    /// capacity all use that operation's local and aggregate ceilings. Returned
81    /// storage owns its account independently of the borrowed reader.
82    /// Error offsets identify the textual selector, not a physical XML byte.
83    pub fn parse_in(text: &str, parent: &Reader<'_, '_>) -> Result<Self> {
84        Self::parse_with(text, &parent.execution(), Some(parent))
85    }
86
87    fn parse_with<W: Work>(
88        text: &str,
89        execution: &W,
90        parent: Option<&Reader<'_, '_>>,
91    ) -> Result<Self> {
92        // Lexical validation precedes allocation. This admits all fixed
93        // descriptor comparisons and both source passes before scanning.
94        let work = (text.len() as u64)
95            .checked_mul(64)
96            .and_then(|n| n.checked_add(1))
97            .ok_or_else(|| {
98                Error::resource(structured_asn1::memory::MemoryError::WorkExceeded, FIELD, 0)
99            })?;
100        execution
101            .charge_work(work)
102            .map_err(|reason| Error::resource(reason, FIELD, 0))?;
103        scan::<true>(text, |_, _, _, _| Ok(()))?;
104        let parser = Parser::ber(execution.memory().clone());
105        let mut parts = execution.vector();
106        let mut current = execution.vector();
107        scan::<true>(text, |label, value, offset, last| {
108            current
109                .try_push(selected(label, value, offset, &parser, execution, parent)?)
110                .map_err(|reason| Error::resource(reason, FIELD, offset))?;
111            if last {
112                parts
113                    .try_push(core::mem::replace(&mut current, execution.vector()).into_owned())
114                    .map_err(|reason| Error::resource(reason, FIELD, offset))?;
115            }
116            Ok(())
117        })?;
118        execution
119            .charge_work(parts.len() as u64)
120            .map_err(|reason| Error::resource(reason, FIELD, 0))?;
121        parts.reverse(); // RFC 4514 §2.1: textual last RDN is ASN.1 first.
122        Ok(Self {
123            parts: parts.into_owned(),
124        })
125    }
126
127    /// Compares directly with a certificate Name without serializing it.
128    /// Unknown LDAP assertion syntax returns `UnsupportedNameSyntax` when
129    /// semantic preparation is needed; it is never guessed as `UTF8String`.
130    pub fn matches_name<W: Work>(&self, name: &Name<'_>, execution: &W) -> Result<bool> {
131        self.matches_name_with(name, execution, None)
132    }
133
134    /// Compares the assertion to a retained Name under the current reader,
135    /// including deferred constructed-string decoding and normalization.
136    pub fn matches_name_in(&self, name: &Name<'_>, parent: &Reader<'_, '_>) -> Result<bool> {
137        self.matches_name_with(name, &parent.execution(), Some(parent))
138    }
139
140    fn matches_name_with<W: Work>(
141        &self,
142        name: &Name<'_>,
143        execution: &W,
144        parent: Option<&Reader<'_, '_>>,
145    ) -> Result<bool> {
146        if self.parts.len() != name.parts().len() {
147            return Ok(false);
148        }
149        for (selected, part) in self.parts.iter().zip(name.parts()) {
150            execution
151                .charge_work(1)
152                .map_err(|reason| Error::resource(reason, FIELD, 0))?;
153            let fail = |reason| Error::resource(reason, FIELD, 0);
154            let order = prepared::compare_views(
155                selected.iter().map(|item| project(item, execution)),
156                part.attributes()
157                    .iter()
158                    .copied()
159                    .map(|attribute| prepared::Prepared::of_in(attribute, execution, parent)),
160                execution,
161                fail,
162            )?;
163            if order != Ordering::Equal {
164                return Ok(false);
165            }
166        }
167        Ok(true)
168    }
169
170    /// Compares two independently owned selectors under the same Name rules.
171    pub fn matches<W: Work>(&self, other: &Self, execution: &W) -> Result<bool> {
172        if self.parts.len() != other.parts.len() {
173            return Ok(false);
174        }
175        for (left, right) in self.parts.iter().zip(&other.parts) {
176            execution
177                .charge_work(1)
178                .map_err(|reason| Error::resource(reason, FIELD, 0))?;
179            if prepared::compare_views(
180                left.iter().map(|item| project(item, execution)),
181                right.iter().map(|item| project(item, execution)),
182                execution,
183                |reason| Error::resource(reason, FIELD, 0),
184            )? != Ordering::Equal
185            {
186                return Ok(false);
187            }
188        }
189        Ok(true)
190    }
191}
192
193fn selected<W: Work>(
194    label: &str,
195    value: &str,
196    offset: u64,
197    parser: &Parser,
198    execution: &W,
199    parent: Option<&Reader<'_, '_>>,
200) -> Result<Selected> {
201    let kind = match descriptor(label) {
202        Some(kind) => Kind::Known(kind),
203        None => Kind::Numeric(OwnedOid::parse_in(label, execution).map_err(|error| {
204            Error::new(
205                ErrorCode::Encoding(error.code()),
206                FIELD,
207                offset + error.offset(),
208            )
209        })?),
210    };
211    let mut bytes = execution.vector();
212    let value = if let Some(hex) = value.strip_prefix('#') {
213        for pair in hex.as_bytes().as_chunks::<2>().0 {
214            bytes
215                .try_push(
216                    (digit(pair[0]).expect("validated hex") << 4)
217                        | digit(pair[1]).expect("validated hex"),
218                )
219                .map_err(|reason| Error::resource(reason, FIELD, offset))?;
220        }
221        ber(&bytes, kind.as_oid(), parser, execution, parent).map_err(|error| {
222            Error::new(
223                error.code(),
224                FIELD,
225                offset + label.len() as u64 + 2 + 2 * error.offset(),
226            )
227        })?
228    } else {
229        let mut at = 0;
230        let input = value.as_bytes();
231        while at < input.len() {
232            let byte = if input[at] == b'\\' {
233                at += 1;
234                if let Some(high) = digit(input[at]) {
235                    at += 1;
236                    (high << 4) | digit(input[at]).expect("validated escape")
237                } else {
238                    input[at]
239                }
240            } else {
241                input[at]
242            };
243            bytes
244                .try_push(byte)
245                .map_err(|reason| Error::resource(reason, FIELD, offset))?;
246            at += 1;
247        }
248        Value::Assertion
249    };
250    Ok(Selected {
251        kind,
252        bytes: bytes.into_owned(),
253        value,
254        offset,
255    })
256}
257
258fn ber<W: Work>(
259    bytes: &[u8],
260    kind: Oid<'_>,
261    parser: &Parser,
262    execution: &W,
263    parent: Option<&Reader<'_, '_>>,
264) -> Result<Value> {
265    let mut reader = match parent {
266        Some(parent) => parser.read_query_in(parent, bytes, 0, structured_asn1::Encoding::Ber, 0),
267        None => parser.read(bytes, 0),
268    }
269    .field(FIELD)?;
270    let element = reader
271        .next_element()
272        .field(FIELD)?
273        .ok_or_else(|| malformed(0))?;
274    if !reader.is_empty() {
275        return Err(malformed(element.span().end()));
276    }
277    // RFC4514 §2.4's reversible #BER form preserves the original value,
278    // including string payloads Name retained for opaque wire identity.
279    // Structural BER remains checked; textual assertions are validated in project.
280    // https://www.rfc-editor.org/rfc/rfc4514.html#section-2.4
281    super::walk_der(&reader, &element, FIELD, Strings::AsWritten)?;
282    let tag = element.tag();
283    let start = element.as_bytes().len()
284        - element.contents().len()
285        - if element.is_indefinite() { 2 } else { 0 };
286    let contents = start..start + element.contents().len();
287    execution
288        .charge_work(
289            (kind.as_bytes().len() as u64)
290                .checked_mul(40)
291                .ok_or_else(|| {
292                    Error::resource(structured_asn1::memory::MemoryError::WorkExceeded, FIELD, 0)
293                })?,
294        )
295        .map_err(|reason| Error::resource(reason, FIELD, 0))?;
296    let logical_encoding = match tag.number() {
297        Some(number) => super::AttributeValue {
298            tag: Tag::primitive(number),
299            contents: &[],
300            der: &[],
301            offset: 0,
302            depth: 0,
303        }
304        .encoding(),
305        None => super::Encoding::Other,
306    };
307    let joined = if tag.class() == Class::Universal
308        && tag.is_constructed()
309        && tag.number().is_some_and(super::segmented_universal_type)
310        && tag.number() != Some(universal::BIT_STRING)
311        && super::permits(kind, logical_encoding)
312    {
313        let mut joined = execution.vector();
314        segments::walk_in(
315            &reader,
316            &element,
317            FIELD,
318            false,
319            Some(&mut joined),
320            execution,
321        )?;
322        Some(joined.into_owned())
323    } else {
324        None
325    };
326    Ok(Value::Ber {
327        tag,
328        contents,
329        joined,
330    })
331}
332
333fn project<'a, W: Work>(item: &'a Selected, execution: &W) -> Result<prepared::Prepared<'a, W>> {
334    let kind = item.kind.as_oid();
335    let value = match &item.value {
336        Value::Assertion => {
337            execution
338                .charge_work((kind.as_bytes().len() as u64) * 40)
339                .map_err(|reason| Error::resource(reason, FIELD, item.offset))?;
340            let tag = match super::syntax_of(kind) {
341                Some(Syntax::Directory) => universal::UTF8_STRING,
342                Some(Syntax::Printable) => universal::PRINTABLE_STRING,
343                Some(Syntax::Ia5) => universal::IA5_STRING,
344                None => {
345                    return Err(Error::new(
346                        ErrorCode::UnsupportedNameSyntax,
347                        FIELD,
348                        item.offset,
349                    ));
350                }
351            };
352            AttributeValue {
353                tag: Tag::primitive(tag),
354                contents: &item.bytes,
355                der: &[],
356                offset: item.offset,
357                depth: 0,
358            }
359        }
360        Value::Ber {
361            tag,
362            contents,
363            joined,
364        } => AttributeValue {
365            tag: if joined.is_some() {
366                Tag::primitive(tag.number().expect("universal string"))
367            } else {
368                *tag
369            },
370            contents: joined.as_deref().unwrap_or(&item.bytes[contents.clone()]),
371            der: &item.bytes,
372            offset: item.offset,
373            depth: 0,
374        },
375    };
376    let attribute = Attribute { kind, value };
377    if matches!(item.value, Value::Assertion) {
378        let work = (kind.as_bytes().len() as u64)
379            .checked_mul(64)
380            .and_then(|kinds| {
381                (value.contents.len() as u64)
382                    .checked_mul(2)
383                    .and_then(|contents| kinds.checked_add(contents))
384            })
385            .and_then(|work| work.checked_add(1))
386            .ok_or_else(|| {
387                Error::resource(
388                    structured_asn1::memory::MemoryError::WorkExceeded,
389                    FIELD,
390                    item.offset,
391                )
392            })?;
393        execution
394            .charge_work(work)
395            .map_err(|reason| Error::resource(reason, FIELD, item.offset))?;
396        if !super::well_formed(&attribute) {
397            return Err(Error::new(ErrorCode::MalformedValue, FIELD, item.offset));
398        }
399    }
400    let prepared = prepared::Prepared::of(attribute, execution)?;
401    if matches!(item.value, Value::Assertion) && !prepared.is_prepared() {
402        return Err(Error::new(
403            ErrorCode::UnsupportedNameSyntax,
404            FIELD,
405            item.offset,
406        ));
407    }
408    Ok(prepared)
409}
410
411fn malformed(offset: u64) -> Error {
412    Error::new(ErrorCode::MalformedValue, FIELD, offset)
413}
414
415fn digit(byte: u8) -> Option<u8> {
416    match byte {
417        b'0'..=b'9' => Some(byte - b'0'),
418        b'a'..=b'f' => Some(byte - b'a' + 10),
419        b'A'..=b'F' => Some(byte - b'A' + 10),
420        _ => None,
421    }
422}
423
424fn label_fits<const REGISTERED: bool>(label: &str, offset: u64) -> Result<()> {
425    let Some(first) = label.as_bytes().first() else {
426        return Err(malformed(offset));
427    };
428    if first.is_ascii_digit() {
429        let mut count = 0;
430        for component in label.split('.') {
431            if component.is_empty()
432                || !component.bytes().all(|b| b.is_ascii_digit())
433                || (component.len() > 1 && component.starts_with('0'))
434            {
435                return Err(malformed(offset));
436            }
437            count += 1;
438        }
439        if count < 2 {
440            return Err(malformed(offset));
441        }
442    } else if !first.is_ascii_alphabetic()
443        || !label
444            .bytes()
445            .all(|b| b.is_ascii_alphanumeric() || b == b'-')
446    {
447        return Err(malformed(offset));
448    } else if REGISTERED && descriptor(label).is_none() {
449        return Err(Error::new(ErrorCode::UnsupportedNameSyntax, FIELD, offset));
450    }
451    Ok(())
452}
453
454// RFC 4514 §3. Decode escaped octets only after finding delimiters; an
455// escaped comma is part of one value, not a separator to scan a second time.
456fn scan<const REGISTERED: bool>(
457    text: &str,
458    mut visit: impl FnMut(&str, &str, u64, bool) -> Result<()>,
459) -> Result<()> {
460    if text.is_empty() {
461        return Ok(());
462    }
463    let bytes = text.as_bytes();
464    let mut at = 0;
465    loop {
466        let start = at;
467        while at < bytes.len() && bytes[at] != b'=' {
468            at += 1;
469        }
470        if at == bytes.len() {
471            return Err(malformed(start as u64));
472        }
473        let label = &text[start..at];
474        label_fits::<REGISTERED>(label, start as u64)?;
475        at += 1;
476        let value = at;
477        if bytes.get(at) == Some(&b'#') {
478            at += 1;
479            let hex = at;
480            while at < bytes.len() && !matches!(bytes[at], b',' | b'+') {
481                if digit(bytes[at]).is_none() {
482                    return Err(malformed(at as u64));
483                }
484                at += 1;
485            }
486            if at == hex || !(at - hex).is_multiple_of(2) {
487                return Err(malformed(hex as u64));
488            }
489        } else {
490            let mut last_space = false;
491            while at < bytes.len() && !matches!(bytes[at], b',' | b'+') {
492                let byte = bytes[at];
493                if byte == b'\\' {
494                    at += 1;
495                    let Some(&escaped) = bytes.get(at) else {
496                        return Err(malformed(at as u64));
497                    };
498                    if digit(escaped).is_some() {
499                        at += 1;
500                        if bytes.get(at).copied().and_then(digit).is_none() {
501                            return Err(malformed(at as u64));
502                        }
503                    } else if !matches!(
504                        escaped,
505                        b' ' | b'"' | b'#' | b'+' | b',' | b';' | b'<' | b'=' | b'>' | b'\\'
506                    ) {
507                        return Err(malformed(at as u64));
508                    }
509                    last_space = false;
510                } else {
511                    if matches!(byte, 0 | b'"' | b';' | b'<' | b'>')
512                        || (at == value && matches!(byte, b' ' | b'#'))
513                    {
514                        return Err(malformed(at as u64));
515                    }
516                    last_space = byte == b' ';
517                }
518                at += 1;
519            }
520            if last_space {
521                return Err(malformed((at - 1) as u64));
522            }
523        }
524        let last = bytes.get(at) != Some(&b'+');
525        visit(label, &text[value..at], start as u64, last)?;
526        if at == bytes.len() {
527            return Ok(());
528        }
529        at += 1;
530        if at == bytes.len() {
531            return Err(malformed(at as u64));
532        }
533    }
534}
535
536/// RFC4514 lexical grammar only: unknown descriptors do not require local
537/// equality semantics when preserving an unresolved LDAP location. The caller
538/// admits the scan; no storage, schema lookup or identity assertion is made.
539pub(crate) fn syntax_fits(text: &str) -> bool {
540    scan::<false>(text, |_, _, _, _| Ok(())).is_ok()
541}
542
543/// Shared RFC4512 type/matching-rule token grammar, without schema lookup.
544pub(crate) fn attribute_type_syntax_fits(text: &str) -> bool {
545    label_fits::<false>(text, 0).is_ok()
546}
547
548const DESCRIPTORS: &[(&str, Oid<'static>)] = &[
549    ("CN", oid::COMMON_NAME),
550    ("L", oid::LOCALITY),
551    ("ST", oid::STATE_OR_PROVINCE),
552    ("O", oid::ORGANIZATION),
553    ("OU", oid::ORGANIZATIONAL_UNIT),
554    ("C", oid::COUNTRY),
555    ("STREET", oid::STREET_ADDRESS),
556    ("DC", oid::DOMAIN_COMPONENT),
557    ("UID", oid::USER_ID),
558    ("emailAddress", oid::EMAIL_ADDRESS),
559    ("serialNumber", oid::SERIAL_NUMBER),
560    ("surname", oid::SURNAME),
561    ("givenName", oid::GIVEN_NAME),
562    ("initials", oid::INITIALS),
563    ("generationQualifier", oid::GENERATION_QUALIFIER),
564    ("title", oid::TITLE),
565    ("dnQualifier", oid::DN_QUALIFIER),
566    ("pseudonym", oid::PSEUDONYM),
567    ("organizationIdentifier", oid::ORGANIZATION_IDENTIFIER),
568];
569
570/// RFC4514 §2.3 requires every registered descriptor known here, including
571/// RFC4519 names beyond §3's mandatory nine and emailAddress/pseudonym.
572/// organizationIdentifier is an input alias, absent from this IANA registry.
573/// <https://www.iana.org/assignments/ldap-parameters/ldap-parameters.xhtml>
574pub(super) const REGISTERED_DESCRIPTORS: usize = 18;
575
576pub(super) fn registered_name(kind: Oid<'_>) -> Option<&'static str> {
577    DESCRIPTORS[..REGISTERED_DESCRIPTORS]
578        .iter()
579        .find(|(_, oid)| *oid == kind)
580        .map(|(name, _)| *name)
581}
582
583fn descriptor(text: &str) -> Option<Oid<'static>> {
584    if text.eq_ignore_ascii_case("sn") {
585        return Some(oid::SURNAME);
586    }
587    DESCRIPTORS
588        .iter()
589        .find(|(name, _)| name.eq_ignore_ascii_case(text))
590        .map(|(_, oid)| *oid)
591}