Skip to main content

structured_pkix/crl/
scope.rs

1//! CRL issuing-distribution-point grammar shares certificate DP name parsing.
2
3use crate::access::{self, DistributionPoint, PointName};
4use crate::error::{Error, ErrorCode, Located, Result};
5use crate::extension::{self, Extension};
6use crate::general_name::GeneralName;
7use crate::name::{self, Name, RelativeName};
8use crate::{Certificate, Version, oid};
9use structured_asn1::BitString;
10use structured_asn1::memory::Memory;
11
12/// The eight revocation reasons in RFC 5280 §6.3.2, excluding unspecified
13/// and removeFromCRL. `ReasonFlags` unused(0) never contributes coverage.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct Reasons(u16);
16
17impl Reasons {
18    /// Every reason required by the standard CRL-processing algorithm.
19    pub const ALL: Self = Self(0x1fe);
20    /// No authenticated reason coverage.
21    pub const NONE: Self = Self(0);
22    /// Union of already established coverage facts.
23    #[must_use]
24    pub const fn union(self, other: Self) -> Self {
25        Self(self.0 | other.0)
26    }
27    /// Whether the coverage includes all eight reasons.
28    #[must_use]
29    pub const fn is_complete(self) -> bool {
30        self.0 == Self::ALL.0
31    }
32    /// Whether coverage is empty.
33    #[must_use]
34    pub const fn is_empty(self) -> bool {
35        self.0 == 0
36    }
37    /// Original `ReasonFlags` positions (1 through 8), without unused(0).
38    #[must_use]
39    pub const fn bits(self) -> u16 {
40        self.0
41    }
42}
43
44/// Geometric issuer/name/category scope mismatch, not a signature verdict.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ScopeFailure {
47    /// Candidate CRL does not name the applicable issuer.
48    Issuer,
49    /// A DP naming a separate CRL issuer requires an indirect CRL.
50    IndirectRequired,
51    /// Expanded distribution-point names do not match.
52    DistributionName,
53    /// Category does not cover this public-key certificate.
54    CertificateKind,
55    /// A user-only/CA-only IDP cannot cover a v1/v2 certificate.
56    CertificateVersion,
57    /// The reason intersection covers none of the legal reasons.
58    NoReasons,
59}
60
61/// Applicable scope says nothing about freshness or unknown critical demands.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ScopeOutcome {
64    /// Reason coverage for which this CRL's issuer and DP scope match.
65    Applicable(Reasons),
66    /// This source does not apply under the attempted distribution scope.
67    NotApplicable(ScopeFailure),
68}
69
70impl super::Authenticated<'_, '_> {
71    /// Derive applicability from the target's own signed DP extension. Relative
72    /// names are compared as borrowed composed RDN views, never encoded again.
73    /// If no explicit DP applies, also consider the RFC 5280 §6.3.3 issuer-name
74    /// fallback over supplied CRLs; this performs no fetching or discovery.
75    pub fn scope(&self, target: &Certificate<'_>, execution: &Memory) -> Result<ScopeOutcome> {
76        const FIELD: &str = "certificateList.scope";
77        let crl = self.crl();
78        execution
79            .charge_work(1)
80            .map_err(|reason| Error::resource(reason, FIELD, crl.span().start))?;
81        let idp = crl
82            .tbs()
83            .extensions()
84            .get(oid::ISSUING_DISTRIBUTION_POINT, execution)?
85            .map(|extension| IssuingDistributionPoint::read(extension, execution))
86            .transpose()?;
87        if let Some(idp) = &idp {
88            match idp.kind {
89                CertificateKind::Attribute => {
90                    return Ok(ScopeOutcome::NotApplicable(ScopeFailure::CertificateKind));
91                }
92                CertificateKind::EndEntity | CertificateKind::Authority => {
93                    // RFC5280 §5.2.5 excludes v1/v2 for BOTH user-only and
94                    // CA-only CRLs. The cA tests in §6.3.3(b)(2)(ii–iii) are
95                    // additional conditions, not an exception to that scope.
96                    // <https://www.rfc-editor.org/rfc/rfc5280#section-5.2.5>
97                    if target.tbs().version() != Version::V3 {
98                        return Ok(ScopeOutcome::NotApplicable(
99                            ScopeFailure::CertificateVersion,
100                        ));
101                    }
102                    let ca = target
103                        .tbs()
104                        .extensions()
105                        .basic_constraints(execution)?
106                        .is_some_and(crate::constraints::BasicConstraints::is_authority);
107                    if ca != (idp.kind == CertificateKind::Authority) {
108                        return Ok(ScopeOutcome::NotApplicable(ScopeFailure::CertificateKind));
109                    }
110                }
111                CertificateKind::All => (),
112            }
113        }
114        if let Some(outcome) = specified_scope(crl.tbs().issuer(), idp.as_ref(), target, execution)?
115        {
116            return Ok(outcome);
117        }
118        // RFC 5280 §6.3.3 final paragraph: synthetic DP names comprise the
119        // target issuer DN and issuerAltName, with no cRLIssuer or reasons.
120        // https://www.rfc-editor.org/rfc/rfc5280#section-6.3.3
121        if !crl
122            .tbs()
123            .issuer()
124            .matches(target.tbs().issuer(), execution)?
125        {
126            return Ok(ScopeOutcome::NotApplicable(ScopeFailure::Issuer));
127        }
128        if let Some(idp) = &idp
129            && idp.name.is_some()
130        {
131            let mut matched = point_name_matches_directory(
132                idp,
133                crl.tbs().issuer(),
134                target.tbs().issuer(),
135                execution,
136            )?;
137            if !matched && let Some(names) = target.tbs().extensions().issuer_alt_name(execution)? {
138                matched =
139                    point_name_matches_names::<false>(idp, crl.tbs().issuer(), &names, execution)?;
140            }
141            if !matched {
142                return Ok(ScopeOutcome::NotApplicable(ScopeFailure::DistributionName));
143            }
144        }
145        Ok(ScopeOutcome::Applicable(reason_mask(
146            idp.as_ref().and_then(|idp| idp.reasons),
147            execution,
148            crl.span().start,
149        )?))
150    }
151}
152
153fn specified_scope(
154    issuer: &Name<'_>,
155    idp: Option<&IssuingDistributionPoint<'_>>,
156    target: &Certificate<'_>,
157    execution: &Memory,
158) -> Result<Option<ScopeOutcome>> {
159    let mut coverage = Reasons::NONE;
160    let mut failure = None;
161    if let Some(points) = target
162        .tbs()
163        .extensions()
164        .crl_distribution_points(execution)?
165    {
166        for point in points.points() {
167            execution.charge_work(1).map_err(|reason| {
168                Error::resource(reason, "certificateList.scope", target.span().start)
169            })?;
170            match point_scope(issuer, idp, target, point, execution)? {
171                ScopeOutcome::Applicable(reasons) => coverage = coverage.union(reasons),
172                ScopeOutcome::NotApplicable(ScopeFailure::NoReasons) => {
173                    failure = Some(ScopeFailure::NoReasons);
174                }
175                ScopeOutcome::NotApplicable(ScopeFailure::DistributionName)
176                    if failure.is_none()
177                        && idp.is_some_and(|idp| idp.name.is_some())
178                        && (!point.full_name().is_empty()
179                            || point.name_relative_to_issuer().is_some()) =>
180                {
181                    // Both fields are present: §5.2.5 requires an identical
182                    // selected name. The §6.3.3 synthetic fallback cannot
183                    // discard this explicit field or its reason restriction.
184                    failure = Some(ScopeFailure::DistributionName);
185                }
186                ScopeOutcome::NotApplicable(_) => (),
187            }
188        }
189    }
190    if !coverage.is_empty() {
191        return Ok(Some(ScopeOutcome::Applicable(coverage)));
192    }
193    Ok(failure.map(ScopeOutcome::NotApplicable))
194}
195
196fn reason_mask(flags: Option<BitString<'_>>, execution: &Memory, at: u64) -> Result<Reasons> {
197    execution
198        .charge_work(9)
199        .map_err(|reason| Error::resource(reason, "certificateList.scope", at))?;
200    let Some(flags) = flags else {
201        return Ok(Reasons::ALL);
202    };
203    let mut mask = 0;
204    for bit in 1..=8 {
205        if flags.bit(bit) {
206            mask |= 1 << bit;
207        }
208    }
209    Ok(Reasons(mask))
210}
211
212fn point_scope(
213    issuer: &Name<'_>,
214    idp: Option<&IssuingDistributionPoint<'_>>,
215    target: &Certificate<'_>,
216    point: &DistributionPoint<'_>,
217    execution: &Memory,
218) -> Result<ScopeOutcome> {
219    if !point.issuer().is_empty() {
220        if !idp.is_some_and(IssuingDistributionPoint::is_indirect) {
221            return Ok(ScopeOutcome::NotApplicable(ScopeFailure::IndirectRequired));
222        }
223        let mut matches = false;
224        for name in point.issuer() {
225            execution.charge_work(1).map_err(|reason| {
226                Error::resource(reason, "certificateList.scope", target.span().start)
227            })?;
228            if let GeneralName::Directory(name) = name
229                && issuer_encoding_matches(issuer, name, execution)?
230            {
231                matches = true;
232                break;
233            }
234        }
235        if !matches {
236            return Ok(ScopeOutcome::NotApplicable(ScopeFailure::Issuer));
237        }
238    } else if !issuer.matches(target.tbs().issuer(), execution)? {
239        return Ok(ScopeOutcome::NotApplicable(ScopeFailure::Issuer));
240    }
241    if let Some(idp) = idp
242        && idp.name.is_some()
243    {
244        let matched = if let Some(relative) = point.name_relative_to_issuer() {
245            let base = if point.issuer().is_empty() {
246                target.tbs().issuer()
247            } else {
248                match &point.issuer()[0] {
249                    GeneralName::Directory(name) => name,
250                    _ => unreachable!("DP reader requires directory issuers"),
251                }
252            };
253            point_name_matches_composed::<true>(idp, issuer, base, Some(relative), execution)?
254        } else if !point.full_name().is_empty() {
255            // RFC5280 5.2.5 requires identical selected name encoding when
256            // both DP fields are present; semantic name equality is used only
257            // for issuer-only/synthetic fields in 6.3.3(b)(2)(i).
258            // <https://www.rfc-editor.org/rfc/rfc5280#section-5.2.5>
259            point_name_matches_names::<true>(idp, issuer, point.full_name(), execution)?
260        } else {
261            point_name_matches_names::<false>(idp, issuer, point.issuer(), execution)?
262        };
263        if !matched {
264            return Ok(ScopeOutcome::NotApplicable(ScopeFailure::DistributionName));
265        }
266    }
267    let left = reason_mask(
268        idp.and_then(|idp| idp.reasons),
269        execution,
270        target.span().start,
271    )?;
272    let right = reason_mask(point.reasons(), execution, target.span().start)?;
273    let reasons = Reasons(left.0 & right.0);
274    if reasons.is_empty() {
275        return Ok(ScopeOutcome::NotApplicable(ScopeFailure::NoReasons));
276    }
277    Ok(ScopeOutcome::Applicable(reasons))
278}
279
280fn issuer_encoding_matches(
281    issuer: &Name<'_>,
282    named: &Name<'_>,
283    execution: &Memory,
284) -> Result<bool> {
285    // RFC5280 §4.2.1.13 requires exactly the same encoding for explicit
286    // cRLIssuer; ordinary DN equality remains applicable to the default issuer.
287    // <https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.13>
288    let left = issuer.as_bytes();
289    let right = named.as_bytes();
290    let work = u64::try_from(left.len().max(right.len()))
291        .ok()
292        .and_then(|n| n.checked_mul(2))
293        .and_then(|n| n.checked_add(1))
294        .ok_or_else(|| named.resource(structured_asn1::memory::MemoryError::WorkExceeded))?;
295    execution
296        .charge_work(work)
297        .map_err(|reason| named.resource(reason))?;
298    Ok(left == right)
299}
300
301fn point_name_matches_directory(
302    idp: &IssuingDistributionPoint<'_>,
303    issuer: &Name<'_>,
304    directory: &Name<'_>,
305    execution: &Memory,
306) -> Result<bool> {
307    point_name_matches_composed::<false>(idp, issuer, directory, None, execution)
308}
309
310fn point_name_matches_composed<const ENCODED: bool>(
311    idp: &IssuingDistributionPoint<'_>,
312    issuer: &Name<'_>,
313    base: &Name<'_>,
314    relative: Option<&RelativeName<'_>>,
315    execution: &Memory,
316) -> Result<bool> {
317    match &idp.name {
318        Some(PointName::Relative(suffix)) => {
319            if ENCODED && relative.is_none() {
320                return Ok(false);
321            }
322            name::matches_composed::<ENCODED>(issuer, Some(suffix), base, relative, execution)
323        }
324        Some(PointName::Full(names)) => {
325            if ENCODED && relative.is_some() {
326                return Ok(false);
327            }
328            for name in names {
329                execution
330                    .charge_work(1)
331                    .map_err(|reason| base.resource(reason))?;
332                if let GeneralName::Directory(directory) = name
333                    && name::matches_composed::<ENCODED>(
334                        directory, None, base, relative, execution,
335                    )?
336                {
337                    return Ok(true);
338                }
339            }
340            Ok(false)
341        }
342        None => Ok(true),
343    }
344}
345
346fn point_name_matches_names<const ENCODED: bool>(
347    idp: &IssuingDistributionPoint<'_>,
348    issuer: &Name<'_>,
349    names: &[GeneralName<'_>],
350    execution: &Memory,
351) -> Result<bool> {
352    for right in names {
353        execution
354            .charge_work(1)
355            .map_err(|reason| issuer.resource(reason))?;
356        match &idp.name {
357            Some(PointName::Relative(relative)) => {
358                if ENCODED {
359                    return Ok(false);
360                }
361                if let GeneralName::Directory(directory) = right
362                    && name::matches_composed::<ENCODED>(
363                        issuer,
364                        Some(relative),
365                        directory,
366                        None,
367                        execution,
368                    )?
369                {
370                    return Ok(true);
371                }
372            }
373            Some(PointName::Full(left)) => {
374                for left in left {
375                    execution
376                        .charge_work(1)
377                        .map_err(|reason| issuer.resource(reason))?;
378                    if names_match::<ENCODED>(left, right, issuer, execution)? {
379                        return Ok(true);
380                    }
381                }
382            }
383            None => return Ok(true),
384        }
385    }
386    Ok(false)
387}
388
389fn names_match<const ENCODED: bool>(
390    left: &GeneralName<'_>,
391    right: &GeneralName<'_>,
392    source: &Name<'_>,
393    execution: &Memory,
394) -> Result<bool> {
395    let (left, right) = match (left, right) {
396        (GeneralName::Directory(left), GeneralName::Directory(right)) => {
397            return name::matches_composed::<ENCODED>(left, None, right, None, execution);
398        }
399        (GeneralName::Uri(left), GeneralName::Uri(right)) if !ENCODED => {
400            return super::uri::matches(left, right, execution, source.offset());
401        }
402        (GeneralName::Dns(left), GeneralName::Dns(right)) if !ENCODED => {
403            admit_text(left, right, source, execution)?;
404            // RFC5280 §7.2 requires whole-DNS-name ASCII case-insensitive
405            // exact comparison; no trailing-dot or suffix identity is added.
406            // https://www.rfc-editor.org/rfc/rfc5280#section-7.2
407            return Ok(left.eq_ignore_ascii_case(right));
408        }
409        (GeneralName::Email(left), GeneralName::Email(right)) if !ENCODED => {
410            admit_text(left, right, source, execution)?;
411            // RFC5280 §7.5: local-part exact, host ASCII case-insensitive.
412            // Quotes can contain @; the separator is outside quoted local-part.
413            // https://www.rfc-editor.org/rfc/rfc5280#section-7.5
414            let (Some((left_local, left_host)), Some((right_local, right_host))) =
415                (mail_parts(left), mail_parts(right))
416            else {
417                return Ok(false);
418            };
419            return Ok(left_local == right_local && left_host.eq_ignore_ascii_case(right_host));
420        }
421        (GeneralName::Uri(left), GeneralName::Uri(right))
422        | (GeneralName::Dns(left), GeneralName::Dns(right))
423        | (GeneralName::Email(left), GeneralName::Email(right)) => {
424            (left.as_bytes(), right.as_bytes())
425        }
426        (GeneralName::X400(left), GeneralName::X400(right))
427        | (GeneralName::EdiParty(left), GeneralName::EdiParty(right))
428        | (GeneralName::IpAddress(left), GeneralName::IpAddress(right)) => (*left, *right),
429        (GeneralName::RegisteredId(left), GeneralName::RegisteredId(right)) => {
430            (left.as_bytes(), right.as_bytes())
431        }
432        (
433            GeneralName::Other {
434                type_id: left_kind,
435                value: left,
436            },
437            GeneralName::Other {
438                type_id: right_kind,
439                value: right,
440            },
441        ) => {
442            let work = (left_kind.as_bytes().len().max(right_kind.as_bytes().len()) as u64)
443                .checked_mul(2)
444                .ok_or_else(|| {
445                    source.resource(structured_asn1::memory::MemoryError::WorkExceeded)
446                })?;
447            execution
448                .charge_work(work)
449                .map_err(|reason| source.resource(reason))?;
450            if left_kind != right_kind {
451                return Ok(false);
452            }
453            (*left, *right)
454        }
455        _ => return Ok(false),
456    };
457    let work = (left.len().max(right.len()) as u64)
458        .checked_mul(2)
459        .and_then(|n| n.checked_add(1))
460        .ok_or_else(|| source.resource(structured_asn1::memory::MemoryError::WorkExceeded))?;
461    execution
462        .charge_work(work)
463        .map_err(|reason| source.resource(reason))?;
464    Ok(left == right)
465}
466
467fn admit_text(left: &str, right: &str, source: &Name<'_>, execution: &Memory) -> Result<()> {
468    let work = u64::try_from(left.len().max(right.len()))
469        .ok()
470        .and_then(|length| length.checked_mul(4))
471        .and_then(|work| work.checked_add(1))
472        .ok_or_else(|| source.resource(structured_asn1::memory::MemoryError::WorkExceeded))?;
473    execution
474        .charge_work(work)
475        .map_err(|reason| source.resource(reason))
476}
477
478fn mail_parts(text: &str) -> Option<(&str, &str)> {
479    let (mut quoted, mut escaped) = (false, false);
480    for (index, byte) in text.bytes().enumerate() {
481        if escaped {
482            escaped = false;
483            continue;
484        }
485        if quoted && byte == b'\\' {
486            escaped = true;
487        } else if byte == b'"' {
488            quoted = !quoted;
489        } else if !quoted && byte == b'@' {
490            return Some((&text[..index], &text[index + 1..]));
491        }
492    }
493    None
494}
495
496/// Which certificate category an issuing distribution point covers.
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498pub enum CertificateKind {
499    /// No category restriction was encoded.
500    All,
501    /// End entity public-key certificates only.
502    EndEntity,
503    /// Certification authority certificates only.
504    Authority,
505    /// Attribute certificates only, preserved separately from public-key scope.
506    Attribute,
507}
508
509/// Parsed CRL scope, without claiming applicability to a target certificate.
510#[derive(Debug)]
511pub struct IssuingDistributionPoint<'a> {
512    name: Option<PointName<'a>>,
513    kind: CertificateKind,
514    reasons: Option<BitString<'a>>,
515    indirect: bool,
516}
517
518impl<'a> IssuingDistributionPoint<'a> {
519    /// Full distribution-point names, if that alternative was written.
520    #[must_use]
521    pub fn full_name(&self) -> &[GeneralName<'a>] {
522        match &self.name {
523            Some(PointName::Full(names)) => names,
524            _ => &[],
525        }
526    }
527
528    /// Relative RDN, if that alternative was written.
529    #[must_use]
530    pub fn name_relative_to_issuer(&self) -> Option<&RelativeName<'a>> {
531        match &self.name {
532            Some(PointName::Relative(name)) => Some(name),
533            _ => None,
534        }
535    }
536
537    /// Category restriction; it does not classify any supplied certificate.
538    #[must_use]
539    pub fn certificate_kind(&self) -> CertificateKind {
540        self.kind
541    }
542
543    /// Encoded `ReasonFlags`. Absent means all reasons, not an empty mask.
544    #[must_use]
545    pub fn reasons(&self) -> Option<BitString<'a>> {
546        self.reasons
547    }
548
549    /// Whether entries may belong to issuers other than this CRL's issuer.
550    #[must_use]
551    pub fn is_indirect(&self) -> bool {
552        self.indirect
553    }
554
555    /// Decode through the extension's original parser with current execution.
556    pub fn read(extension: &Extension<'a>, execution: &Memory) -> Result<Self> {
557        const FIELD: &str = "extensions.issuingDistributionPoint";
558        let fail = |code, at| Error::new(code, FIELD, at);
559        let mut reader = extension::sequence(extension, FIELD, execution)?;
560        let mut result = Self {
561            name: None,
562            kind: CertificateKind::All,
563            reasons: None,
564            indirect: false,
565        };
566        let mut last = None;
567        while let Some(element) = reader.next_element().field(FIELD)? {
568            let (number, constructed) = extension::context_of(element.tag())
569                .ok_or_else(|| fail(ErrorCode::UnexpectedField, element.span().start))?;
570            if last.is_some_and(|previous| number <= previous) {
571                return Err(fail(ErrorCode::UnexpectedField, element.span().start));
572            }
573            last = Some(number);
574            match (number, constructed) {
575                (0, true) => {
576                    result.name = Some(access::read_distribution_name(&reader, &element, FIELD)?);
577                }
578                (3, false) => {
579                    let reasons = reader.bit_string(&element).field(FIELD)?;
580                    reader.charge(9).field(FIELD)?;
581                    // RFC 5280 §5.2.5 references ReasonFlags of §4.2.1.13;
582                    // X.690 §11.2.2 trims a DER named bit list to its final one.
583                    // https://www.rfc-editor.org/rfc/rfc5280#section-5.2.5
584                    if reasons.len() > 9
585                        || !(1..=8).any(|bit| reasons.bit(bit))
586                        || !reasons.bit(reasons.len() - 1)
587                    {
588                        return Err(fail(ErrorCode::MalformedValue, element.span().start));
589                    }
590                    result.reasons = Some(reasons);
591                }
592                (1 | 2 | 4 | 5, false) => {
593                    // These BOOLEANs default to FALSE and DER omits defaults.
594                    if !reader.boolean(&element).field(FIELD)? {
595                        return Err(fail(ErrorCode::MalformedValue, element.span().start));
596                    }
597                    if number == 4 {
598                        result.indirect = true;
599                        continue;
600                    }
601                    if result.kind != CertificateKind::All {
602                        return Err(fail(ErrorCode::MalformedValue, element.span().start));
603                    }
604                    result.kind = match number {
605                        1 => CertificateKind::EndEntity,
606                        2 => CertificateKind::Authority,
607                        _ => CertificateKind::Attribute,
608                    };
609                }
610                _ => return Err(fail(ErrorCode::UnexpectedField, element.span().start)),
611            }
612        }
613        // RFC 5280 §5.2.5 disallows an entirely empty IDP. Attribute scope is
614        // kept as its actual category, never treated as public-key coverage.
615        if result.name.is_none()
616            && result.kind == CertificateKind::All
617            && result.reasons.is_none()
618            && !result.indirect
619        {
620            return Err(fail(ErrorCode::EmptySequence, extension.span().start));
621        }
622        Ok(result)
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use crate::general_name::GeneralName::{Dns, Email};
629    #[test]
630    fn dns_and_mailbox_scope_follow_their_separate_case_rules() {
631        // RFC5280 §§7.2/7.5 require DNS and mail host case-insensitive
632        // identity, while the mailbox local-part remains an exact match.
633        let bytes = crate::fixtures::CertificateParts::default().build();
634        let parser = structured_asn1::Parser::der(crate::fixtures::memory());
635        let source = crate::Certificate::read(&parser, &bytes, 100).unwrap();
636        let execution = crate::fixtures::memory();
637        for (left, right, expected) in [
638            (Dns("EXAMPLE.com"), Dns("example.COM"), true),
639            (Email("user@EXAMPLE.com"), Email("user@example.COM"), true),
640            (Email("User@example.com"), Email("user@example.com"), false),
641            (
642                Email("\"a@b\"@EXAMPLE.com"),
643                Email("\"a@b\"@example.com"),
644                true,
645            ),
646        ] {
647            assert_eq!(
648                super::names_match::<false>(&left, &right, source.tbs().issuer(), &execution)
649                    .unwrap(),
650                expected
651            );
652        }
653    }
654
655    #[test]
656    fn uri_scope_compares_normalized_identity_without_folding_path_or_query() {
657        // RFC5280 §7.4 requires scheme/host case, percent, path-segment
658        // and recognized scheme normalization before exact comparison.
659        let bytes = crate::fixtures::CertificateParts::default().build();
660        let parser = structured_asn1::Parser::der(crate::fixtures::memory());
661        let source = crate::Certificate::read(&parser, &bytes, 100).unwrap();
662        let execution = crate::fixtures::memory();
663        for (left, right, expected) in [
664            (
665                "HTTP://Example.com:080/a/../%7e",
666                "http://example.com/~",
667                true,
668            ),
669            ("https://example.com:443", "https://example.com/", true),
670            (
671                "ldap://example.com/",
672                "ldap://EXAMPLE.com:389/??BASE?(objectClass=*)",
673                true,
674            ),
675            ("ftp://example.com:21", "ftp://example.com/", true),
676            (
677                "http://example.com/A?q=A",
678                "http://example.com/a?q=A",
679                false,
680            ),
681            (
682                "http://example.com/a?q=A",
683                "http://example.com/a?q=a",
684                false,
685            ),
686            ("http://example.com/%2f", "http://example.com//", false),
687            ("http://example.com/?", "http://example.com/", false),
688            (
689                "ftp://example.com/a;type=a",
690                "ftp://example.com/a;type=i",
691                false,
692            ),
693        ] {
694            assert_eq!(
695                super::names_match::<false>(
696                    &crate::general_name::GeneralName::Uri(left),
697                    &crate::general_name::GeneralName::Uri(right),
698                    source.tbs().issuer(),
699                    &execution
700                )
701                .unwrap(),
702                expected,
703                "{left} versus {right}"
704            );
705        }
706    }
707}