1use super::{
5 DAY_NAMES, MAX_SECS, MIN_SECS, MONTH_NAMES, SECS_PER_DAY, civil_from_days, days_from_civil,
6 days_in_month, weekday_from_days,
7};
8use crate::{
9 error::{ErrorKind, ParseError, Result, WriteError, err},
10 limits::Limits,
11 scan::control_kind,
12 text::comment_end,
13 write::{Sink, reserve},
14};
15
16const MAX_OFFSET: i16 = 99 * 60 + 59;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38pub struct MailDate {
39 utc: i64,
40 offset: i16,
41 offset_unknown: bool,
42 leap_second: bool,
43}
44
45impl MailDate {
46 pub fn new(utc_secs: i64, offset_minutes: i16) -> Result<Self> {
65 if offset_minutes.unsigned_abs() > MAX_OFFSET.unsigned_abs() {
66 return err(ErrorKind::OutOfRange, 0);
67 }
68 let local = utc_secs.saturating_add(i64::from(offset_minutes) * 60);
69 if !(MIN_SECS..=MAX_SECS).contains(&local) {
70 return err(ErrorKind::OutOfRange, 0);
71 }
72 Ok(Self {
73 utc: utc_secs,
74 offset: offset_minutes,
75 offset_unknown: false,
76 leap_second: false,
77 })
78 }
79
80 #[must_use]
90 pub const fn utc(self) -> i64 {
91 self.utc
92 }
93
94 #[must_use]
104 pub const fn offset_minutes(self) -> i16 {
105 self.offset
106 }
107
108 #[must_use]
122 pub const fn offset_unknown(self) -> bool {
123 self.offset_unknown
124 }
125
126 #[must_use]
139 pub const fn leap_second(self) -> bool {
140 self.leap_second
141 }
142
143 #[must_use]
156 pub fn format(self) -> ([u8; 31], usize) {
157 let local = self.utc + i64::from(self.offset) * 60;
158 let days = local.div_euclid(SECS_PER_DAY);
159 let secs = local.rem_euclid(SECS_PER_DAY);
160 let (year, month, day) = civil_from_days(days);
161 let mut out = [b' '; 31];
162 let wd = usize::from(weekday_from_days(days)).saturating_sub(1);
163 put(
164 &mut out,
165 0,
166 DAY_NAMES
167 .get(wd)
168 .map_or(b"???".as_slice(), |d| d.as_slice()),
169 );
170 put(&mut out, 3, b",");
171 two(&mut out, 5, day);
172 let mon = MONTH_NAMES
173 .get(usize::from(month).saturating_sub(1))
174 .map_or(b"???".as_slice(), |m| m.as_slice());
175 put(&mut out, 8, mon);
176 let y = u32::try_from(year).unwrap_or(0);
178 two(&mut out, 12, (y / 100) as u8);
179 two(&mut out, 14, (y % 100) as u8);
180 two(&mut out, 17, (secs / 3600) as u8);
181 put(&mut out, 19, b":");
182 two(&mut out, 20, (secs / 60 % 60) as u8);
183 put(&mut out, 22, b":");
184 two(&mut out, 23, (secs % 60) as u8);
185 let (sign, abs) = if self.offset < 0 || self.offset_unknown {
186 (b"-", self.offset.unsigned_abs())
187 } else {
188 (b"+", self.offset.unsigned_abs())
189 };
190 put(&mut out, 26, sign);
191 two(&mut out, 27, (abs / 60) as u8);
192 two(&mut out, 29, (abs % 60) as u8);
193 (out, 31)
194 }
195
196 pub fn write_to(self, out: &mut impl Sink) -> core::result::Result<(), WriteError> {
217 let (bytes, len) = self.format();
218 reserve(out, len)?;
219 out.put(bytes.get(..len).unwrap_or(&bytes))?;
220 Ok(())
221 }
222
223 pub fn parse(value: &[u8], obsolete: bool) -> Result<Self> {
250 if let Some(d) = canonical(value) {
254 return Ok(d);
255 }
256 let mut c = Cur {
257 v: value,
258 i: 0,
259 limits: Limits::DEFAULT,
260 };
261 let _ = c.cfws()?;
262 let weekday = if c.peek().is_some_and(|b| b.is_ascii_alphabetic()) {
263 let wd = c.name(&DAY_NAMES)?;
264 let _ = c.cfws()?;
265 if c.peek() != Some(b',') {
266 return Err(c.fail(ErrorKind::InvalidDate));
267 }
268 c.i += 1;
269 let _ = c.cfws()?;
270 Some(wd + 1)
271 } else {
272 None
273 };
274 let (day, n) = c.digits()?;
275 if !(1..=2).contains(&n) {
276 return Err(c.fail_at(ErrorKind::InvalidDate, c.i - n));
277 }
278 c.space()?;
279 let month = c.name(&MONTH_NAMES)? + 1;
280 c.space()?;
281 let at = c.i;
282 let (y, n) = c.digits()?;
283 let year = match n {
284 4.. => y,
285 2 if obsolete => {
286 if y < 50 {
287 2000 + y
288 } else {
289 1900 + y
290 }
291 }
292 3 if obsolete => 1900 + y,
293 _ => return Err(c.fail_at(ErrorKind::InvalidDate, at)),
294 };
295 if !(1..=9999).contains(&year) {
296 return err(ErrorKind::OutOfRange, at);
297 }
298 c.space()?;
299 let (hour, minute, second) = c.time(obsolete)?;
300 c.space()?;
301 let (offset, unknown) = c.zone(obsolete)?;
302 let _ = c.cfws()?;
303 if c.i < value.len() {
304 return Err(c.fail(ErrorKind::TrailingData));
305 }
306 let (year, month, day) = (year as i32, month as u8, day as u8);
307 if day == 0 || day > days_in_month(year, month) {
308 return err(ErrorKind::InvalidDate, 0);
309 }
310 let days = days_from_civil(year, month, day);
311 if weekday.is_some_and(|w| w as u8 != weekday_from_days(days)) {
312 return err(ErrorKind::WeekdayMismatch, 0);
313 }
314 let leap_second = second == 60;
315 let second = if leap_second { 59 } else { second };
316 let local = days * SECS_PER_DAY + hour * 3600 + minute * 60 + second;
317 Ok(Self {
318 utc: local - i64::from(offset) * 60,
319 offset,
320 offset_unknown: unknown,
321 leap_second,
322 })
323 }
324}
325
326fn name3(b: &[u8], names: &[&[u8; 3]]) -> Option<usize> {
328 let [x, y, z] = *b else { return None };
329 if !(x.is_ascii_alphabetic() && y.is_ascii_alphabetic() && z.is_ascii_alphabetic()) {
330 return None;
331 }
332 let key = [x | 0x20, y | 0x20, z | 0x20];
334 names
335 .iter()
336 .position(|n| [n[0] | 0x20, n[1] | 0x20, n[2] | 0x20] == key)
337}
338
339fn pair(b: &[u8]) -> Option<u8> {
341 match *b {
342 [h @ b'0'..=b'9', l @ b'0'..=b'9'] => Some((h - b'0') * 10 + (l - b'0')),
343 _ => None,
344 }
345}
346
347fn canonical(v: &[u8]) -> Option<MailDate> {
350 if v.len() != 31 {
351 return None;
352 }
353 let at = |i: usize| v.get(i).copied();
354 let punct = at(3) == Some(b',')
355 && at(4) == Some(b' ')
356 && at(7) == Some(b' ')
357 && at(11) == Some(b' ')
358 && at(16) == Some(b' ')
359 && at(19) == Some(b':')
360 && at(22) == Some(b':')
361 && at(25) == Some(b' ');
362 if !punct {
363 return None;
364 }
365 let weekday = name3(v.get(0..3)?, &DAY_NAMES)? as u8 + 1;
366 let day = pair(v.get(5..7)?)?;
367 let month = name3(v.get(8..11)?, &MONTH_NAMES)? as u8 + 1;
368 let year = u16::from(pair(v.get(12..14)?)?) * 100 + u16::from(pair(v.get(14..16)?)?);
369 let (hour, minute, second) = (
370 pair(v.get(17..19)?)?,
371 pair(v.get(20..22)?)?,
372 pair(v.get(23..25)?)?,
373 );
374 let sign = at(26)?;
375 let (zh, zm) = (pair(v.get(27..29)?)?, pair(v.get(29..31)?)?);
376 if !matches!(sign, b'+' | b'-')
377 || zm > 59
378 || hour > 23
379 || minute > 59
380 || second > 60
381 || year == 0
382 {
383 return None;
384 }
385 let year = i32::from(year);
386 if day == 0 || day > days_in_month(year, month) {
387 return None;
388 }
389 let days = days_from_civil(year, month, day);
390 if weekday != weekday_from_days(days) {
391 return None;
392 }
393 let minutes = i16::from(zh) * 60 + i16::from(zm);
394 let (offset, unknown) = if sign == b'-' {
395 (-minutes, minutes == 0)
396 } else {
397 (minutes, false)
398 };
399 let leap_second = second == 60;
400 let second = i64::from(if leap_second { 59 } else { second });
401 let local = days * SECS_PER_DAY + i64::from(hour) * 3600 + i64::from(minute) * 60 + second;
402 Some(MailDate {
403 utc: local - i64::from(offset) * 60,
404 offset,
405 offset_unknown: unknown,
406 leap_second,
407 })
408}
409
410fn put(out: &mut [u8], at: usize, bytes: &[u8]) {
411 if let Some(dst) = out.get_mut(at..at + bytes.len()) {
412 dst.copy_from_slice(bytes);
413 }
414}
415
416fn two(out: &mut [u8], at: usize, v: u8) {
417 put(out, at, &[b'0' + v / 10, b'0' + v % 10]);
418}
419
420struct Cur<'a> {
422 v: &'a [u8],
423 i: usize,
424 limits: Limits,
425}
426
427impl Cur<'_> {
428 fn peek(&self) -> Option<u8> {
429 self.v.get(self.i).copied()
430 }
431
432 fn fail_at(&self, kind: ErrorKind, at: usize) -> ParseError {
433 match self.v.get(at) {
434 Some(&b) if crate::scan::is_control(b) => ParseError::new(control_kind(b), at),
435 _ => ParseError::new(kind, at),
436 }
437 }
438
439 fn fail(&self, kind: ErrorKind) -> ParseError {
440 self.fail_at(kind, self.i)
441 }
442
443 fn cfws(&mut self) -> Result<bool> {
446 let start = self.i;
447 loop {
448 match self.peek() {
449 Some(b' ' | b'\t') => self.i += 1,
450 Some(b'\r') => {
451 if self.v.get(self.i + 1) == Some(&b'\n')
452 && matches!(self.v.get(self.i + 2), Some(b' ' | b'\t'))
453 {
454 self.i += 3;
455 } else {
456 return Err(self.fail(ErrorKind::BareCr));
457 }
458 }
459 Some(b'(') => self.i = comment_end(self.v, self.i, &self.limits)?.0,
460 _ => return Ok(self.i > start),
461 }
462 }
463 }
464
465 fn space(&mut self) -> Result<()> {
467 if self.cfws()? {
468 Ok(())
469 } else {
470 Err(self.fail(ErrorKind::InvalidDate))
471 }
472 }
473
474 fn digits(&mut self) -> Result<(u32, usize)> {
476 let start = self.i;
477 let mut v: u32 = 0;
478 while let Some(d) = self.peek().filter(u8::is_ascii_digit) {
479 v = v.saturating_mul(10).saturating_add(u32::from(d - b'0'));
480 self.i += 1;
481 }
482 if self.i == start {
483 return Err(self.fail(ErrorKind::InvalidDate));
484 }
485 Ok((v, self.i - start))
486 }
487
488 fn two(&mut self) -> Result<i64> {
490 let at = self.i;
491 let (v, n) = self.digits()?;
492 if n != 2 {
493 return Err(self.fail_at(ErrorKind::InvalidDate, at));
494 }
495 Ok(i64::from(v))
496 }
497
498 fn name(&mut self, names: &[&[u8; 3]]) -> Result<u32> {
500 let found = self
501 .v
502 .get(self.i..self.i + 3)
503 .and_then(|w| name3(w, names))
504 .filter(|_| !self.v.get(self.i + 3).is_some_and(u8::is_ascii_alphabetic));
505 match found {
506 Some(idx) => {
507 self.i += 3;
508 Ok(idx as u32)
509 }
510 None => Err(self.fail(ErrorKind::InvalidDate)),
511 }
512 }
513
514 fn time(&mut self, obsolete: bool) -> Result<(i64, i64, i64)> {
517 let at = self.i;
518 let hour = self.two()?;
519 let minute = {
520 self.colon(obsolete)?;
521 self.two()?
522 };
523 let mark = self.i;
524 let second = if obsolete {
525 let _ = self.cfws()?;
526 if self.peek() == Some(b':') {
527 self.colon(obsolete)?;
528 self.two()?
529 } else {
530 self.i = mark;
531 0
532 }
533 } else if self.peek() == Some(b':') {
534 self.i += 1;
535 self.two()?
536 } else {
537 0
538 };
539 if hour > 23 || minute > 59 || second > 60 {
540 return err(ErrorKind::InvalidDate, at);
541 }
542 Ok((hour, minute, second))
543 }
544
545 fn colon(&mut self, obsolete: bool) -> Result<()> {
546 if obsolete {
547 let _ = self.cfws()?;
548 }
549 if self.peek() != Some(b':') {
550 return Err(self.fail(ErrorKind::InvalidDate));
551 }
552 self.i += 1;
553 if obsolete {
554 let _ = self.cfws()?;
555 }
556 Ok(())
557 }
558
559 fn zone(&mut self, obsolete: bool) -> Result<(i16, bool)> {
562 let at = self.i;
563 match self.peek() {
564 Some(sign @ (b'+' | b'-')) => {
565 self.i += 1;
566 let (v, n) = self.digits()?;
567 if n != 4 || v % 100 > 59 {
568 return err(ErrorKind::InvalidDate, at);
569 }
570 let minutes = (v / 100 * 60 + v % 100) as i16;
571 if sign == b'-' {
572 Ok((-minutes, minutes == 0))
573 } else {
574 Ok((minutes, false))
575 }
576 }
577 Some(b) if obsolete && b.is_ascii_alphabetic() => {
578 let n = self
579 .v
580 .get(self.i..)
581 .unwrap_or(&[])
582 .iter()
583 .take_while(|b| b.is_ascii_alphabetic())
584 .count();
585 let name = self.v.get(self.i..self.i + n).unwrap_or(&[]);
586 const ZONES: [(&[u8], i16); 10] = [
587 (b"UT", 0),
588 (b"GMT", 0),
589 (b"EST", -300),
590 (b"EDT", -240),
591 (b"CST", -360),
592 (b"CDT", -300),
593 (b"MST", -420),
594 (b"MDT", -360),
595 (b"PST", -480),
596 (b"PDT", -420),
597 ];
598 let found = ZONES.iter().find(|(z, _)| z.eq_ignore_ascii_case(name));
599 self.i += n;
600 match found {
601 Some(&(_, off)) => Ok((off, false)),
602 None if n == 1 && !name.eq_ignore_ascii_case(b"j") => Ok((0, true)),
605 None => err(ErrorKind::InvalidDate, at),
606 }
607 }
608 _ => Err(self.fail(ErrorKind::InvalidDate)),
609 }
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 fn p(v: &[u8]) -> MailDate {
618 MailDate::parse(v, false).unwrap()
619 }
620
621 fn k(v: &[u8], obsolete: bool) -> ErrorKind {
622 MailDate::parse(v, obsolete).unwrap_err().kind()
623 }
624
625 fn text(d: MailDate) -> std::string::String {
626 let (b, n) = d.format();
627 std::string::String::from_utf8(b[..n].to_vec()).unwrap()
628 }
629
630 #[test]
631 fn test_mail_date_edge_cases() {
632 let d = p(b"Wed, 21 Oct 2026 17:38:00 +0200");
633 assert_eq!(d.utc(), p(b"21 Oct 2026 15:38:00 +0000").utc());
634 assert_eq!(
635 text(p(b"21 Oct 2026 17:38 +0200")),
636 "Wed, 21 Oct 2026 17:38:00 +0200"
637 );
638 assert_eq!(
639 text(p(b"1 Jan 2026 00:00:00 +0000")),
640 "Thu, 01 Jan 2026 00:00:00 +0000"
641 );
642 assert_eq!(
643 k(b"Mon, 21 Oct 2026 17:38:00 +0200", false),
644 ErrorKind::WeekdayMismatch
645 );
646 assert_eq!(p(b"21 Oct 2026 17:38 +2400").offset_minutes(), 1440);
647 assert_eq!(k(b"21 Oct 2026 17:38 +0060", false), ErrorKind::InvalidDate);
648 let u = p(b"21 Oct 2026 17:38 -0000");
649 assert!(u.offset_unknown() && u.offset_minutes() == 0);
650 assert_eq!(text(u), "Wed, 21 Oct 2026 17:38:00 -0000");
651 assert!(!p(b"21 Oct 2026 17:38 +0000").offset_unknown());
652 let c = p(b" (a (b)) Wed , 21 (x) oct 2026 17:38:00 (y)\r\n +0200 (z) ");
653 assert_eq!(c.utc(), d.utc());
654 let l = p(b"31 Dec 2016 23:59:60 +0000");
655 assert!(l.leap_second());
656 assert_eq!(l.utc(), p(b"31 Dec 2016 23:59:59 +0000").utc());
657 assert_eq!(k(b"30 Feb 2026 00:00 +0000", false), ErrorKind::InvalidDate);
658 assert_eq!(k(b"21 Oct 2026 24:00 +0000", false), ErrorKind::InvalidDate);
659 assert_eq!(
660 k(b"21 Oct 2026 17:38 +0000 x", false),
661 ErrorKind::TrailingData
662 );
663 assert_eq!(
664 k(b"21 Oct 2026 17:38 +0000 (", false),
665 ErrorKind::UnbalancedComment
666 );
667 assert_eq!(k(b"21 Oct 2026 17:38\r\n+0000", false), ErrorKind::BareCr);
668 assert_eq!(
669 k(b"21 Octo 2026 17:38 +0000", false),
670 ErrorKind::InvalidDate
671 );
672 assert_eq!(k(b"21 Oct 0000 17:38 +0000", false), ErrorKind::OutOfRange);
673 assert_eq!(k(b"21 Oct 10000 17:38 +0000", false), ErrorKind::OutOfRange);
674 assert_eq!(k(b"21Oct 2026 17:38 +0000", false), ErrorKind::InvalidDate);
675 assert_eq!(k(b"", false), ErrorKind::InvalidDate);
676 let deep = b"21 Oct 2026 17:38 +0000 (((((((((x)))))))))";
677 assert_eq!(k(deep, false), ErrorKind::CommentTooDeep);
678 }
679
680 #[test]
681 fn test_mail_date_obsolete_forms() {
682 assert_eq!(k(b"21 Oct 26 17:38 EDT", false), ErrorKind::InvalidDate);
683 let d = MailDate::parse(b"21 Oct 26 17:38 EDT", true).unwrap();
684 assert_eq!(
685 (d.offset_minutes(), text(d).as_str()),
686 (-240, "Wed, 21 Oct 2026 17:38:00 -0400")
687 );
688 assert_eq!(
689 text(MailDate::parse(b"1 Jan 70 00:00 GMT", true).unwrap()),
690 "Thu, 01 Jan 1970 00:00:00 +0000"
691 );
692 assert_eq!(
693 text(MailDate::parse(b"1 Jan 49 00:00 UT", true).unwrap()),
694 "Fri, 01 Jan 2049 00:00:00 +0000"
695 );
696 assert_eq!(
697 text(MailDate::parse(b"1 Jan 100 00:00 PST", true).unwrap()),
698 "Sat, 01 Jan 2000 00:00:00 -0800"
699 );
700 assert!(
701 MailDate::parse(b"1 Jan 2026 00:00 Z", true)
702 .unwrap()
703 .offset_unknown()
704 );
705 assert_eq!(k(b"1 Jan 2026 00:00 J", true), ErrorKind::InvalidDate);
706 assert_eq!(k(b"1 Jan 2026 00:00 XYZ", true), ErrorKind::InvalidDate);
707 assert_eq!(
708 MailDate::parse(b"1 Jan 2026 00 : 00 : 30 +0000", true)
709 .unwrap()
710 .utc()
711 % 60,
712 30
713 );
714 assert_eq!(
715 k(b"1 Jan 2026 00 : 00 +0000", false),
716 ErrorKind::InvalidDate
717 );
718 }
719
720 #[test]
721 fn test_mail_date_new_and_write() {
722 assert_eq!(
723 MailDate::new(0, 6000).unwrap_err().kind(),
724 ErrorKind::OutOfRange
725 );
726 assert_eq!(
727 MailDate::new(0, -6000).unwrap_err().kind(),
728 ErrorKind::OutOfRange
729 );
730 assert!(MailDate::new(0, 5999).is_ok() && MailDate::new(0, -5999).is_ok());
731 assert_eq!(
732 MailDate::new(MIN_SECS, -1).unwrap_err().kind(),
733 ErrorKind::OutOfRange
734 );
735 assert_eq!(
736 MailDate::new(MAX_SECS, 1).unwrap_err().kind(),
737 ErrorKind::OutOfRange
738 );
739 assert_eq!(
740 text(MailDate::new(MIN_SECS, 0).unwrap()),
741 "Mon, 01 Jan 0001 00:00:00 +0000"
742 );
743 let mut buf = [0u8; 30];
744 let mut out = crate::SliceSink::new(&mut buf);
745 assert_eq!(
746 MailDate::new(0, 0).unwrap().write_to(&mut out),
747 Err(WriteError::Full)
748 );
749 assert!(out.is_empty());
750 for secs in [-62_135_596_800i64, -1, 0, 1_792_540_800, 253_402_300_799] {
751 for off in [-5999i16, -300, 0, 330, 5999] {
752 if let Ok(d) = MailDate::new(secs, off) {
753 let (b, n) = d.format();
754 let back = MailDate::parse(&b[..n], false).unwrap();
755 assert_eq!((back.utc(), back.offset_minutes()), (secs, off));
756 }
757 }
758 }
759 }
760}