Skip to main content

structured_xades/validation/
ref_time.rs

1//! Exact XSD1.0 identifier time labels against original whole-second PKIX time.
2
3use super::{ReferenceIdentifier, ReferenceProperty};
4use crate::{
5    properties::{CalendarFact, CalendarValue, CalendarXml},
6    reference::VisitError,
7    xml::{Access, AccessError},
8};
9use structured_asn1::{
10    ScopedReader,
11    memory::{MemoryError, Work},
12};
13use structured_pkix::Time;
14
15/// A complete comparison or an explicit missing interpretation; no UTC default.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ReferenceTime {
18    /// Original time, timezone and all fractional digits identify this label.
19    Matches,
20    /// Completed comparison identifies a different label.
21    Differs,
22    /// No timezone was written; the hint does not establish UTC equality.
23    TimezoneUnstated,
24    /// Original XML is not a readable XSD1.0 dateTime.
25    Malformed,
26    /// The XML owner's complete schema interpretation is unavailable.
27    Unsupported,
28}
29
30/// Original processing refusals remain terminal, independent of label equality.
31#[derive(Debug)]
32pub enum ReferenceTimeError<E, N> {
33    /// Original source/generation/current dispatch failure.
34    Access(AccessError<E, N>),
35    /// XML-owner lexical/joining/resource failure.
36    Xml(E),
37    /// No original reference exists at this caller-selected ordinal.
38    Index {
39        /// Requested catalogue ordinal, not a physical XML location.
40        index: usize,
41    },
42    /// Common comparison admission was refused before scanning/projecting.
43    Resource {
44        /// Original `IssueTime` or `ProducedAt` enclosure.
45        node: N,
46        /// Original refusal.
47        cause: MemoryError,
48    },
49}
50
51/// Compare original CRL `IssueTime` or OCSP `ProducedAt` with its actual PKIX time.
52/// Annex A.1.2 requires the referenced response's production time. XSD1.0
53/// Datatypes §3.2.7 uses 1 BCE=-0001, end-of-day24 and arbitrary fractions;
54/// §§3.2.7.3–4 define timezone conversion. These are label comparisons, not
55/// authenticated occurrence, freshness or historical trust.
56/// <https://www.w3.org/TR/2004/REC-xmlschema-2-20041028/#dateTime>
57/// <https://www.etsi.org/deliver/etsi_en/319100_319199/31913201/01.03.01_60/en_31913201v010301p.pdf>
58pub fn matches_reference_time<X: CalendarXml>(
59    xml: &X,
60    references: &ReferenceProperty<X::Id, X::Document, X::Generation>,
61    index: usize,
62    actual: Time,
63    operation: &ScopedReader<'_, '_>,
64) -> Result<Option<ReferenceTime>, ReferenceTimeError<X::Error, X::Id>> {
65    let work = operation.execution();
66    let access = Access::new(xml, references.snapshot(), &work);
67    access
68        .query(references.node(), |_, _, _| Ok(()))
69        .map_err(ReferenceTimeError::Access)?;
70    let Some(entry) = references.entries().get(index) else {
71        return Err(ReferenceTimeError::Index { index });
72    };
73    let (ReferenceIdentifier::Crl { time: node, .. }
74    | ReferenceIdentifier::Ocsp { time: node, .. }) = entry.identifier()
75    else {
76        return Ok(None);
77    };
78    let compared = access
79        .query(node, |xml, snapshot, work| {
80            Ok(xml.with_calendar(snapshot, node, work, |value| {
81                let answer = match value {
82                    CalendarFact::Malformed => ReferenceTime::Malformed,
83                    CalendarFact::Unsupported => ReferenceTime::Unsupported,
84                    CalendarFact::Value(value) => compare(value, actual, work)
85                        .map_err(|cause| ReferenceTimeError::Resource { node, cause })?,
86                };
87                Ok(Some(answer))
88            }))
89        })
90        .map_err(ReferenceTimeError::Access)?;
91    compared.map_err(|cause| match cause {
92        VisitError::Processing(cause) => ReferenceTimeError::Xml(cause),
93        VisitError::Visitor(cause) => cause,
94    })
95}
96
97fn compare<W: Work>(
98    value: CalendarValue<'_>,
99    actual: Time,
100    work: &W,
101) -> Result<ReferenceTime, MemoryError> {
102    let length = value
103        .year_digits
104        .len()
105        .checked_add(value.fraction_digits.map_or(0, str::len))
106        .and_then(|length| length.checked_add(32))
107        .ok_or(MemoryError::WorkExceeded)?;
108    work.charge_work(u64::try_from(length).map_err(|_| MemoryError::WorkExceeded)?)?;
109    let Some(offset) = value.timezone_minutes else {
110        return Ok(ReferenceTime::TimezoneUnstated);
111    };
112    if !(-840..=840).contains(&offset) {
113        return Ok(ReferenceTime::Malformed);
114    }
115    // A parsed PKIX label and these bounded offsets can reach at most year10000.
116    // Arbitrarily wider valid XSD years differ without machine-width narrowing.
117    if value.year_digits.len() > 5 {
118        return Ok(ReferenceTime::Differs);
119    }
120    let mut year = 0i64;
121    for digit in value.year_digits.bytes() {
122        if !digit.is_ascii_digit() {
123            return Ok(ReferenceTime::Malformed);
124        }
125        year = year * 10 + i64::from(digit - b'0');
126    }
127    let mut nanos = 0u32;
128    let mut digits = 0;
129    if let Some(fraction) = value.fraction_digits {
130        for digit in fraction.bytes() {
131            if !digit.is_ascii_digit() {
132                return Ok(ReferenceTime::Malformed);
133            }
134            if digits < 9 {
135                nanos = nanos * 10 + u32::from(digit - b'0');
136            } else if digit != b'0' {
137                return Ok(ReferenceTime::Differs);
138            }
139            digits += 1;
140        }
141    }
142    while digits < 9 {
143        nanos *= 10;
144        digits += 1;
145    }
146    let written = structured_pkix::time::Xsd10Label {
147        year: if value.negative_year { -year } else { year },
148        month: value.month,
149        day: value.day,
150        hour: value.hour,
151        minute: value.minute,
152        second: value.second,
153        nanos,
154    };
155    // Compare both operands in the SAME XSD1.0 value space. Applying a UTC
156    // Gregorian offset first would inherit astronomical BCE leap rules and
157    // would lose XSD Appendix E's second60 normalization.
158    let Some(written) = written.at_utc(offset, work)? else {
159        return Ok(ReferenceTime::Malformed);
160    };
161    let Some(expected) = actual.xsd10_label(work)?.at_utc(0, work)? else {
162        // Some astronomical BCE dates have no valid XSD1.0 field label. They
163        // cannot match a valid written hint; this does not alter PKIX parsing.
164        return Ok(ReferenceTime::Differs);
165    };
166    Ok(if written == expected {
167        ReferenceTime::Matches
168    } else {
169        ReferenceTime::Differs
170    })
171}