Skip to main content

structured_pkix/
time.rs

1//! The two ways a certificate writes a moment, read into one.
2//!
3//! Both preserve UTC labels, including an inserted second. The form is kept because it is
4//! part of what the certificate says: the profile requires the two-digit form
5//! through 2049 and the four-digit form afterwards, and a report that has to
6//! explain a certificate cannot explain what it did not keep.
7//!
8//! Only the fixed-width form with seconds and `Z` is accepted. The wider
9//! encodings the base standard allows, an offset from local time, minutes
10//! without seconds, a fractional part, all let one moment be written several
11//! ways, and here they would let two documents disagree about when a
12//! certificate expired while both claiming to be the same certificate.
13
14use crate::error::{Error, ErrorCode, Result};
15use crate::temporal::Instant;
16use core::fmt;
17use structured_asn1::{Element, Tag, universal};
18
19/// Which form a time was written in.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Form {
22    /// Two digits of year, which the profile requires through 2049.
23    Utc,
24    /// Four digits of year, which the profile requires from 2050.
25    Generalized,
26}
27
28/// A moment, and how the certificate wrote it.
29///
30/// Two times are equal when they name the same moment. The form is what the
31/// certificate used to say it, not part of which moment it is.
32#[derive(Debug, Clone, Copy)]
33pub struct Time {
34    instant: Instant,
35    form: Form,
36}
37
38/// The moment `99991231235959Z` names, which the profile uses to say that a
39/// certificate has no well defined expiry rather than that it expires then.
40pub const NO_WELL_DEFINED_EXPIRY: i64 = 253_402_300_799;
41
42impl Time {
43    /// POSIX label, negative before the epoch. For an inserted second this
44    /// is the preceding ordinary second's label. Use [`Self::instant`] for
45    /// comparisons and [`crate::temporal::LeapTable`] for elapsed arithmetic.
46    #[must_use]
47    pub fn seconds(self) -> i64 {
48        self.instant.seconds()
49    }
50
51    /// The lossless UTC label; parsing does not authenticate its occurrence.
52    #[must_use]
53    pub fn instant(self) -> Instant {
54        self.instant
55    }
56
57    /// Whether the encoded time labels an inserted second.
58    #[must_use]
59    pub fn is_leap_second(self) -> bool {
60        self.instant.is_leap_second()
61    }
62
63    /// Which form the certificate wrote this in.
64    #[must_use]
65    pub fn form(self) -> Form {
66        self.form
67    }
68
69    /// Whether this is the moment the profile uses to mean that no expiry was
70    /// defined. What that means for a verdict is a policy question, and this
71    /// only reports that the certificate said it.
72    #[must_use]
73    pub fn is_no_well_defined_expiry(self) -> bool {
74        self.seconds() == NO_WELL_DEFINED_EXPIRY && !self.is_leap_second()
75    }
76}
77
78impl PartialEq for Time {
79    fn eq(&self, other: &Self) -> bool {
80        self.instant == other.instant
81    }
82}
83
84impl Eq for Time {}
85
86impl PartialOrd for Time {
87    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
88        Some(self.cmp(other))
89    }
90}
91
92impl Ord for Time {
93    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
94        self.instant.cmp(&other.instant)
95    }
96}
97
98impl fmt::Display for Time {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        let (year, month, day, hour, minute, ordinary) = civil_from_seconds(self.seconds());
101        let second = if self.is_leap_second() { 60 } else { ordinary };
102        write!(
103            f,
104            "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
105        )
106    }
107}
108
109/// Reads an element that a schema says is a time.
110///
111/// The tag chooses the form, so a field declared as a choice between the two is
112/// read by handing whichever element was found to this.
113pub(crate) fn read(element: &Element<'_>, field: &'static str) -> Result<Time> {
114    read_as(element, field, Fraction::Absent)
115}
116
117/// Whether a fractional-seconds element may close the value.
118#[derive(Clone, Copy, PartialEq, Eq)]
119pub(crate) enum Fraction {
120    /// The profile writes a certificate's validity dates without one
121    /// (RFC 5280 4.1.2.5.2), and this reader answers with one moment, which
122    /// a fraction would not change.
123    Absent,
124    /// The encoding writes one where there is one to write, with no trailing
125    /// zeros and none at all where it stands for zero (X.690 11.7.3). A
126    /// value preserved inside somebody else's payload is written under that
127    /// rule and not under the profile's narrower one.
128    Written,
129}
130
131/// Reads a time under whichever of the two rules the caller is asking about.
132pub(crate) fn read_as(
133    element: &Element<'_>,
134    field: &'static str,
135    fraction: Fraction,
136) -> Result<Time> {
137    let fail = |code| Error::new(code, field, element.span().start);
138
139    let form = if element.tag() == Tag::primitive(universal::UTC_TIME) {
140        Form::Utc
141    } else if element.tag() == Tag::primitive(universal::GENERALIZED_TIME) {
142        Form::Generalized
143    } else {
144        return Err(fail(ErrorCode::Encoding(
145            structured_asn1::ErrorCode::UnexpectedTag,
146        )));
147    };
148
149    let contents = element.contents();
150    let (year, rest) = match form {
151        Form::Utc => {
152            let (digits, rest) =
153                split(contents, 2).ok_or_else(|| fail(ErrorCode::MalformedTime))?;
154            let two = number(digits).ok_or_else(|| fail(ErrorCode::MalformedTime))?;
155            // The profile's pivot: a two-digit year is read into the window
156            // 1950 to 2049, so a certificate written in 1998 does not become
157            // one written in 2098.
158            (if two >= 50 { 1900 + two } else { 2000 + two }, rest)
159        }
160        Form::Generalized => {
161            // Any year, including one the profile tells issuers to write as a
162            // UTCTime instead. That rule binds the issuer: "CAs conforming to
163            // this profile MUST always encode certificate validity dates
164            // through the year 2049 as UTCTime". The sentence after it binds
165            // this reader the other way: "Conforming applications MUST be
166            // able to process validity dates that are encoded in either
167            // UTCTime or GeneralizedTime" (RFC 5280 4.1.2.5). A date written
168            // in the wrong form still names one moment, unambiguously, and
169            // refusing to read it would leave a person holding a document
170            // this product declined to explain over its issuer's spelling.
171            let (digits, rest) =
172                split(contents, 4).ok_or_else(|| fail(ErrorCode::MalformedTime))?;
173            let four = number(digits).ok_or_else(|| fail(ErrorCode::MalformedTime))?;
174            (four, rest)
175        }
176    };
177
178    let ([month, day, hour, minute, second], tail) =
179        fields_of(rest).ok_or_else(|| fail(ErrorCode::MalformedTime))?;
180
181    // A fraction closes the value where one is written at all: only the wider
182    // form has one, and only where the caller is reading under the encoding's
183    // rule rather than the profile's.
184    let closed = match tail.split_first() {
185        Some((b'.', digits)) if fraction == Fraction::Written && form == Form::Generalized => {
186            // At least one digit, no trailing zero, and the Z after them.
187            matches!(digits.split_last(), Some((b'Z', fraction)) if !fraction.is_empty()
188                && fraction.iter().all(u8::is_ascii_digit)
189                && fraction.last() != Some(&b'0'))
190        }
191        _ => tail == b"Z",
192    };
193    if !closed {
194        return Err(fail(ErrorCode::MalformedTime));
195    }
196
197    if form == Form::Generalized {
198        // X.680 (2021) §46.3 incorporates ISO 8601 time-of-day notation,
199        // including inserted-second labels. X.690 §11.7 restricts spelling,
200        // not UTC occurrence. After the spelling checks above, use the same
201        // calendar/label semantics as the temporal reader; a LeapTable alone
202        // can establish occurrence. UTCTime has its own 00..59 grammar below.
203        // https://www.itu.int/rec/T-REC-X.680-202102-I/en
204        // https://www.itu.int/rec/T-REC-X.690-202102-I/en
205        let instant = crate::temporal::generalized(element, field)?.start();
206        return Ok(Time { instant, form });
207    }
208
209    if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
210        return Err(fail(ErrorCode::TimeOutOfRange));
211    }
212    // X.680 (2021) §47.3(b)(2): "ss is seconds (00 to 59)" for UTCTime.
213    // https://www.itu.int/rec/T-REC-X.680-202102-I/en
214    if hour > 23 || minute > 59 || second > 59 {
215        return Err(fail(ErrorCode::TimeOutOfRange));
216    }
217
218    // Every field is bounded by the checks above and the year by its digit
219    // count, so the seconds count is at most the year 9999 in seconds and
220    // nothing here can overflow.
221    let days = days_from_civil(i64::from(year), month, day);
222    let seconds =
223        days * 86_400 + i64::from(hour) * 3_600 + i64::from(minute) * 60 + i64::from(second);
224
225    let instant = Instant::new(seconds, 0).ok_or_else(|| fail(ErrorCode::TimeOutOfRange))?;
226    Ok(Time { instant, form })
227}
228
229/// Splits off `count` bytes, which must all be digits.
230pub(super) fn split(contents: &[u8], count: usize) -> Option<(&[u8], &[u8])> {
231    let (digits, rest) = contents.split_at_checked(count)?;
232    digits
233        .iter()
234        .all(u8::is_ascii_digit)
235        .then_some((digits, rest))
236}
237
238/// Reads a run of digits already known to be digits.
239pub(super) fn number(digits: &[u8]) -> Option<u32> {
240    digits.iter().try_fold(0u32, |value, digit| {
241        value
242            .checked_mul(10)
243            .and_then(|value| value.checked_add(u32::from(digit - b'0')))
244    })
245}
246
247/// Where a year falls in the four-hundred-year cycle the calendar repeats
248/// on, which is what every question about its length answers to.
249///
250/// The Gregorian calendar comes back to the same shape every four hundred
251/// years: the leap rule is stated in fours, hundreds and four hundreds, and
252/// four hundred years hold a whole number of weeks. So a year of any width
253/// is answered by its last four digits, which is what makes a year too wide
254/// for a counter no less readable than a short one. The sign turns the count
255/// the other way round the cycle.
256fn in_cycle(digits: &str, negative: bool) -> u32 {
257    let counted = digits.as_bytes().iter().fold(0u32, |cycle, digit| {
258        (cycle * 10 + u32::from(digit - b'0')) % 400
259    });
260    if negative {
261        (400 - counted) % 400
262    } else {
263        counted
264    }
265}
266
267/// How many weeks a year is written in: fifty-two, and fifty-three where the
268/// year is the long one.
269///
270/// A year is long where it begins on a Thursday, and where it is a leap year
271/// beginning on a Wednesday (ISO 8601 4.1.4, which the week components of
272/// X.680 Table 7 are written in). Both are said by the day the first of
273/// January falls on, which is what the count below is: the days from the
274/// start of the calendar to that year, over seven.
275fn weeks_in_year(year: u32) -> u32 {
276    let opens = |year: u32| (year + year / 4 - year / 100 + year / 400) % 7;
277    // The second reading needs the year before, which the first year of the
278    // calendar does not have; it opens the calendar rather than continuing
279    // one, so only the first reading applies to it.
280    let long = opens(year) == 4 || year.checked_sub(1).is_some_and(|before| opens(before) == 3);
281    if long { 53 } else { 52 }
282}
283
284fn is_leap(year: u32) -> bool {
285    year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
286}
287
288pub(super) fn days_in_month(year: u32, month: u32) -> u32 {
289    match month {
290        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
291        4 | 6 | 9 | 11 => 30,
292        2 if is_leap(year) => 29,
293        2 => 28,
294        _ => 0,
295    }
296}
297
298/// Days from the epoch to a date, by the shift-the-year-to-March method: with
299/// March first, the four-year, hundred-year and four-hundred-year corrections
300/// all land at the end of the year and the leap day needs no special case.
301///
302/// Every value is bounded by the calendar checks above, so ordinary arithmetic
303/// cannot overflow here.
304pub(super) fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
305    let month = i64::from(month);
306    let year = if month <= 2 { year - 1 } else { year };
307    let era = if year >= 0 { year } else { year - 399 } / 400;
308    let year_of_era = year - era * 400;
309    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
310    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
311    // 719468 is the number of days from the epoch back to the start of the era
312    // this counts from.
313    era * 146_097 + day_of_era - 719_468
314}
315
316/// The inverse, for rendering. Bounded the same way.
317pub(super) fn civil_from_seconds(seconds: i64) -> (i64, i64, i64, i64, i64, i64) {
318    let days = seconds.div_euclid(86_400);
319    let within = seconds.rem_euclid(86_400);
320
321    let shifted = days + 719_468;
322    let era = if shifted >= 0 {
323        shifted
324    } else {
325        shifted - 146_096
326    } / 146_097;
327    let day_of_era = shifted - era * 146_097;
328    let year_of_era =
329        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
330    let year = year_of_era + era * 400;
331    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
332    let shifted_month = (5 * day_of_year + 2) / 153;
333    let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
334    let month = if shifted_month < 10 {
335        shifted_month + 3
336    } else {
337        shifted_month - 9
338    };
339
340    (
341        if month <= 2 { year + 1 } else { year },
342        month,
343        day,
344        within / 3_600,
345        within / 60 % 60,
346        within % 60,
347    )
348}
349
350/// Which of the types defined in X.680 38.4 a value claims to be, for the
351/// four whose notation is settled enough to hold it to.
352#[derive(Clone, Copy, PartialEq, Eq)]
353pub(crate) enum Defined {
354    /// `DATE`, a year, a month and a day.
355    Date,
356    /// `TIME-OF-DAY`, an hour, a minute and a second.
357    TimeOfDay,
358    /// `DATE-TIME`, the two of them.
359    DateTime,
360    /// `DURATION`, a length of time rather than a moment.
361    Duration,
362    /// `TIME` itself, whose notation is the whole of the interchange format
363    /// and is read here only as the characters it is written from.
364    Any,
365}
366
367/// Whether the contents of one of the newer time types are the notation that
368/// type writes.
369///
370/// The contents are the value notation in UTF-8, with the characters X.690
371/// 8.26 names removed: the hyphens of a date, the colons of a time, the T
372/// between them, the P before a duration. What is left is what this reads.
373/// A value that is not the notation is not a value of the type, and one
374/// preserved for somebody else must not claim a type it does not have.
375pub(crate) fn defined_fits(kind: Defined, contents: &[u8]) -> bool {
376    match kind {
377        Defined::Date => date_fits(contents),
378        Defined::TimeOfDay => time_of_day_fits(contents),
379        Defined::DateTime => contents
380            .split_at_checked(8)
381            .is_some_and(|(date, time)| date_fits(date) && time_of_day_fits(time)),
382        Defined::Duration => duration_fits(contents),
383        Defined::Any => core::str::from_utf8(contents).is_ok_and(any_fits),
384    }
385}
386
387/// The unrestricted type, whose notation is the whole of what the property
388/// settings can choose: a point in time, a duration, an interval of the two,
389/// and any of those repeated (X.680 Table 7).
390///
391/// Written in the extended formats, which keep their separators, and X.690
392/// 8.26.1.2 takes only the quotation marks off, so what arrives here is the
393/// notation as the table writes it.
394fn any_fits(text: &str) -> bool {
395    // Recurrence opens the value: R, how many times if it is not unlimited,
396    // and then the interval it repeats.
397    let interval = match text.strip_prefix('R') {
398        Some(after) => {
399            let count = after.bytes().take_while(u8::is_ascii_digit).count();
400            let (repeats, tail) = after.split_at(count);
401            if repeats.starts_with('0') && repeats.len() > 1 {
402                return false;
403            }
404            let Some(rest) = tail.strip_prefix('/') else {
405                return false;
406            };
407            rest
408        }
409        None => text,
410    };
411    match interval.split_once('/') {
412        // An interval is a start point and an end point, a start point and a
413        // duration, or a duration and an end point (Table 7, "Time
414        // interval"). Two durations name no interval: nothing says when.
415        Some((start, end)) => match (part_fits(start), part_fits(end)) {
416            (Some(starts), Some(ends)) => !(starts == Part::Duration && ends == Part::Duration),
417            _ => false,
418        },
419        None => part_fits(interval).is_some(),
420    }
421}
422
423/// Which of the two an end of an interval, or a value standing alone, is.
424#[derive(Clone, Copy, PartialEq, Eq)]
425enum Part {
426    Point,
427    Duration,
428}
429
430/// Whether one end of a value is a point in time or a length of one.
431fn part_fits(text: &str) -> Option<Part> {
432    if let Some(duration) = text.strip_prefix('P') {
433        return duration_fits(duration.as_bytes()).then_some(Part::Duration);
434    }
435    point_fits(text).then_some(Part::Point)
436}
437
438/// Whether text is a point in time: a date, a time of day, or both with the
439/// T between them, and the zone the value is read in if it carries one.
440fn point_fits(text: &str) -> bool {
441    match text.split_once('T') {
442        Some((date, time)) => date_part_fits(date) && zoned_time_fits(time),
443        // The hours component of a time standing alone is written without
444        // the T (Table 7, "Hours component" with Basic=Time), so what is
445        // left is one or the other.
446        None => date_part_fits(text) || zoned_time_fits(text),
447    }
448}
449
450/// Whether text is a time of day and, where it carries one, the zone it is
451/// read in: the UTC designator, or the difference from it (Table 7, the last
452/// two rows). The zone follows the time and nothing else, so it is looked
453/// for here rather than at the end of the whole point.
454fn zoned_time_fits(text: &str) -> bool {
455    let stated = match text.strip_suffix('Z') {
456        Some(rest) => rest,
457        None => match text.rfind(['+', '-']) {
458            Some(at) if difference_fits(&text[at..]) => &text[..at],
459            _ => text,
460        },
461    };
462    time_part_fits(stated)
463}
464
465/// Whether text is the difference from UTC: a sign, hours, and minutes where
466/// the difference is not a whole number of hours.
467fn difference_fits(text: &str) -> bool {
468    let Some(rest) = text.strip_prefix(['+', '-']) else {
469        return false;
470    };
471    match rest.split_once(':') {
472        Some((hours, minutes)) => {
473            two_digits(hours).is_some_and(|hours| hours <= 23)
474                && two_digits(minutes).is_some_and(|minutes| minutes <= 59)
475        }
476        None => two_digits(rest).is_some_and(|hours| hours <= 23),
477    }
478}
479
480/// Whether text is a date in the extended format: a year, and then a month
481/// and a day, a week and a day of it, or a day of the year (Table 7, the
482/// year, month, week and day components).
483fn date_part_fits(text: &str) -> bool {
484    let signed = text.starts_with(['+', '-']);
485    let rest = if signed { &text[1..] } else { text };
486    let mut parts = rest.split('-');
487    let Some(year) = parts.next() else {
488        return false;
489    };
490    // Four digits, or more where the settings ask for a wider year.
491    if year.len() < 4 || !year.bytes().all(|byte| byte.is_ascii_digit()) {
492        return false;
493    }
494    let Some(next) = parts.next() else {
495        return parts.next().is_none();
496    };
497    // How far the days and the weeks of this year run is the year's to say,
498    // and a year of any width says it: the calendar repeats every four
499    // hundred years, so where in that cycle the year falls is the whole of
500    // the answer.
501    let counted = in_cycle(year, signed);
502    if let Some(week) = next.strip_prefix('W') {
503        // A week, and then which day of it, one digit through seven.
504        let last = weeks_in_year(counted);
505        let weeks = two_digits(week).is_some_and(|week| (1..=last).contains(&week));
506        return weeks
507            && match parts.next() {
508                Some(day) => {
509                    day.len() == 1
510                        && day.bytes().all(|byte| (b'1'..=b'7').contains(&byte))
511                        && parts.next().is_none()
512                }
513                None => true,
514            };
515    }
516    if next.len() == 3 {
517        // The day of the year, which stands in for the month and the day,
518        // and runs to the length of that year.
519        let last = if is_leap(counted) { 366 } else { 365 };
520        return next.bytes().all(|byte| byte.is_ascii_digit())
521            && number(next.as_bytes()).is_some_and(|day| (1..=last).contains(&day))
522            && parts.next().is_none();
523    }
524    let Some(month) = two_digits(next).filter(|month| (1..=12).contains(month)) else {
525        return false;
526    };
527    match parts.next() {
528        Some(day) => {
529            let Some(day) = two_digits(day) else {
530                return false;
531            };
532            // The calendar decides the last day of the month, and the year
533            // decides February.
534            let last = days_in_month(counted, month);
535            day >= 1 && day <= last && parts.next().is_none()
536        }
537        None => true,
538    }
539}
540
541/// Whether text is a time of day in the extended format: hours, and then
542/// minutes and seconds, the last component present carrying a fraction where
543/// the value is written to that accuracy (Table 7).
544fn time_part_fits(text: &str) -> bool {
545    let mut parts = text.split(':');
546    let bounds = [23u32, 59, 60];
547    let mut carried = None;
548    let mut counted = 0usize;
549    let mut ending = false;
550    for (piece, most) in parts.by_ref().zip(bounds) {
551        let (digits, fraction) = match piece.split_once(['.', ',']) {
552            Some((digits, fraction)) => (digits, Some(fraction)),
553            None => (piece, None),
554        };
555        let Some(value) = two_digits(digits) else {
556            return false;
557        };
558        // A fraction says how accurately the value is written, so the
559        // component carrying one is the last (ISO 8601, 4.2.2.4).
560        if let Some(fraction) = fraction {
561            if fraction.is_empty() || !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
562                return false;
563            }
564            carried = Some(counted);
565        }
566        counted += 1;
567        if carried.is_some_and(|at| at + 1 != counted) {
568            return false;
569        }
570        // Midnight at the end of a day is the twenty-fourth hour, which is
571        // the one value past the bounds (Table 7, "Hours component"). It
572        // names one instant rather than an hour a value can sit inside, so
573        // what follows it is zero and nothing carries a fraction: a
574        // fraction of that hour, or of a component after it, is a moment
575        // past the end of the day.
576        if ending && fraction.is_some() {
577            return false;
578        }
579        if counted == 1 && value == 24 {
580            ending = true;
581            if fraction.is_some() {
582                return false;
583            }
584            continue;
585        }
586        if value > most || (ending && value != 0) {
587            return false;
588        }
589    }
590    counted > 0 && parts.next().is_none()
591}
592
593/// Two digits read as a number, which is how every component but the year
594/// and the day of the year is written.
595fn two_digits(text: &str) -> Option<u32> {
596    (text.len() == 2 && text.bytes().all(|byte| byte.is_ascii_digit()))
597        .then(|| number(text.as_bytes()))?
598}
599
600/// Eight digits naming a day of the calendar (`Date=YMD Year=Basic`).
601fn date_fits(contents: &[u8]) -> bool {
602    let Some((year, rest)) = split(contents, 4) else {
603        return false;
604    };
605    let Some(([month, day], [])) = fields_of(rest) else {
606        return false;
607    };
608    let Some(year) = number(year) else {
609        return false;
610    };
611    (1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month)
612}
613
614/// Six digits naming a time of day (`Time=HMS`).
615fn time_of_day_fits(contents: &[u8]) -> bool {
616    let Some(([hour, minute, second], [])) = fields_of(contents) else {
617        return false;
618    };
619    // Midnight is written at either end of the day, so the hour reaches
620    // twenty-four where the rest of the value is zero (X.680 38.4.2), and
621    // the second reaches sixty because the interchange format writes a leap
622    // second that way.
623    let midnight = hour == 24 && minute == 0 && second == 0;
624    (hour <= 23 || midnight) && minute <= 59 && second <= 60
625}
626
627/// A duration, as X.690 8.26.5 leaves it: the notation without its leading P,
628/// so a count and its designator for each component that is present, largest
629/// first, with the time components after a T.
630fn duration_fits(contents: &[u8]) -> bool {
631    let Ok(text) = core::str::from_utf8(contents) else {
632        return false;
633    };
634    // The week form stands alone: it is one of the three alternatives and
635    // does not mix with the others (X.680 38.3.6.1).
636    if text.ends_with('W') {
637        return components_fit(text, b"W").is_some_and(|weeks| weeks.any);
638    }
639    let (date, time) = match text.split_once('T') {
640        Some((date, time)) => (date, Some(time)),
641        None => (text, None),
642    };
643    // A T with nothing after it names no component.
644    if time.is_some_and(str::is_empty) {
645        return false;
646    }
647    let Some(dated) = components_fit(date, b"YMD") else {
648        return false;
649    };
650    // A fraction ends the duration, so a date part that carries one may not
651    // be followed by a time part either (X.680 38.3.6.13).
652    if dated.fractional && time.is_some() {
653        return false;
654    }
655    let Some(timed) = time.map_or(Some(Components::NONE), |time| components_fit(time, b"HMS"))
656    else {
657        return false;
658    };
659    // Something has to be there: a duration of no components is no length of
660    // time.
661    dated.any || timed.any
662}
663
664/// What a run of designations turned out to be.
665#[derive(Clone, Copy)]
666struct Components {
667    /// Whether there were any at all, which is a form the caller decides
668    /// about rather than a malformation on its own.
669    any: bool,
670    /// Whether the last one carried a fraction, which closes the duration.
671    fractional: bool,
672}
673
674impl Components {
675    const NONE: Self = Self {
676        any: false,
677        fractional: false,
678    };
679}
680
681/// Whether a run is designations in the order given, each designator used at
682/// most once: a count, an optional fraction, and the letter naming the
683/// component (X.680 38.3.6.2 to 38.3.6.13). `None` says the run is not
684/// designations at all.
685fn components_fit(text: &str, order: &[u8]) -> Option<Components> {
686    let mut rest = text.as_bytes();
687    let mut left = order;
688    let mut seen = Components::NONE;
689    while !rest.is_empty() {
690        // The count opens with a zero only where it is the single digit zero
691        // (38.3.6.11), so the accuracy a value is written to is written one
692        // way.
693        let count = rest.iter().take_while(|byte| byte.is_ascii_digit()).count();
694        if count == 0 || (rest[0] == b'0' && count > 1) {
695            return None;
696        }
697        let after = &rest[count..];
698        let (after, fractional) = match after.split_first() {
699            Some((b'.' | b',', tail)) => {
700                let digits = tail.iter().take_while(|byte| byte.is_ascii_digit()).count();
701                if digits == 0 {
702                    return None;
703                }
704                (&tail[digits..], true)
705            }
706            _ => (after, false),
707        };
708        let (designator, tail) = after.split_first()?;
709        let at = left.iter().position(|kind| kind == designator)?;
710        left = &left[at + 1..];
711        // "If a designation contains a fractional part, there shall be no
712        // following designation" (38.3.6.13): the fraction says how
713        // accurately the duration is written, and a component after it
714        // would say something more accurate again.
715        if seen.fractional {
716            return None;
717        }
718        seen = Components {
719            any: true,
720            fractional,
721        };
722        rest = tail;
723    }
724    Some(seen)
725}
726
727/// The two-digit fields at the front of a value, and whatever follows them
728/// for the caller to hold to its own rule.
729pub(super) fn fields_of<const N: usize>(contents: &[u8]) -> Option<([u32; N], &[u8])> {
730    let mut values = [0u32; N];
731    let mut rest = contents;
732    for value in &mut values {
733        let (digits, tail) = split(rest, 2)?;
734        *value = number(digits)?;
735        rest = tail;
736    }
737    Some((values, rest))
738}
739
740#[cfg(test)]
741mod tests;