Skip to main content

sherlock_nsf_parser/
time.rs

1//! Notes `TIMEDATE` (8-byte timestamp) parsing.
2//!
3//! TIMEDATE is the universal timestamp format used everywhere in NSF
4//! databases (note creation/modification times, the DBID, replica IDs,
5//! TYPE_TIME items, etc). It is two little-endian u32 "Innards":
6//!
7//! ```text
8//! Innards[0] = number of 1/100ths of a second since midnight UTC
9//! Innards[1] = high byte: timezone/DST flags
10//!              low 24 bits: astronomical Julian Day Number (proleptic
11//!              Julian calendar; epoch = Jan 1, 4713 BC at noon UTC)
12//! ```
13//!
14//! Worked example from the canonical Lotus C API doc:
15//!
16//! ```text
17//! 2:49:04 P.M. Eastern Standard Time, December 10, 1996
18//! Innards[0] = 0x006CDCC0  (= 19:49:04 GMT in 1/100s since midnight)
19//! Innards[1] = 0x852563FC  (= DST observed, +5 east, JDN 2,450,428)
20//! ```
21//!
22//! High-byte layout of Innards[1]:
23//!
24//! ```text
25//! bit 31: DST observed flag
26//! bit 30: 1 = east of GMT, 0 = west of GMT
27//! bits 29-28: quarter-hours offset (0-3)
28//! bits 27-24: whole-hours offset (0-15)
29//! ```
30//!
31//! Notes UNID + Database ID + Replica ID all reuse the same 8-byte
32//! TIMEDATE layout for their identifiers (the value is treated as an
33//! opaque identifier rather than a clock reading, but the format is
34//! identical and the bytes parse cleanly into a date).
35
36use crate::error::NsfError;
37
38/// 8 raw bytes of a Notes TIMEDATE, exactly as they appear on disk. The
39/// two u32 "Innards" are stored little-endian.
40///
41/// Kept as opaque bytes rather than decoded fields so the value can serve
42/// double duty as an identifier (DBID, UNID-fragment, ReplicaID) and as
43/// a clock reading without re-parsing.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
45pub struct Timedate {
46    /// Innards[0]. 1/100ths of a second since midnight UTC for clock
47    /// uses; arbitrary bytes for identifier uses.
48    pub innards0: u32,
49    /// Innards[1]. High byte = timezone/DST flags; low 24 bits =
50    /// Julian Day Number for clock uses.
51    pub innards1: u32,
52}
53
54impl Timedate {
55    /// Parse from an 8-byte slice. Returns an error if the slice is
56    /// shorter than 8 bytes.
57    pub fn from_bytes(bytes: &[u8]) -> Result<Self, NsfError> {
58        if bytes.len() < 8 {
59            return Err(NsfError::TooShort {
60                actual: bytes.len(),
61                required: 8,
62            });
63        }
64        let innards0 = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
65        let innards1 = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
66        Ok(Self { innards0, innards1 })
67    }
68
69    /// Treat the TIMEDATE as a clock reading and decode it. Returns
70    /// `None` when the JDN is implausible (zero or far-future) which
71    /// usually means the TIMEDATE is actually being used as an opaque
72    /// identifier (DBID, ReplicaID).
73    pub fn as_clock(&self) -> Option<DecodedTimedate> {
74        let raw_julian = self.innards1 & 0x00FF_FFFF;
75        // JDN range sanity: Notes 1.0 shipped in 1989 (JDN ~2,447,500).
76        // Treat anything before the 1900s or after 2200 as identifier
77        // bytes, not a real date.
78        if !(2_400_000..=2_550_000).contains(&raw_julian) {
79            return None;
80        }
81        let centiseconds = self.innards0;
82        if centiseconds >= 8_640_000 {
83            // > 24 hours of centiseconds, garbage.
84            return None;
85        }
86        let dst = (self.innards1 & 0x8000_0000) != 0;
87        let east = (self.innards1 & 0x4000_0000) != 0;
88        let quarter_hours = ((self.innards1 >> 28) & 0x3) as i32;
89        let hours = ((self.innards1 >> 24) & 0xF) as i32;
90        let offset_minutes_abs = hours * 60 + quarter_hours * 15;
91        let tz_offset_minutes = if east { offset_minutes_abs } else { -offset_minutes_abs };
92
93        // JDN 2440588 = 1970-01-01.
94        let days_since_unix_epoch = (raw_julian as i64) - 2_440_588;
95        let seconds_into_day = (centiseconds / 100) as i64;
96        let unix_seconds_utc = days_since_unix_epoch * 86_400 + seconds_into_day;
97        let centi_remainder = (centiseconds % 100) as u32;
98
99        Some(DecodedTimedate {
100            unix_seconds_utc,
101            centiseconds: centi_remainder,
102            tz_offset_minutes,
103            dst,
104            julian_day_number: raw_julian,
105        })
106    }
107
108    /// Render as an opaque 16-hex-character identifier. Used to display
109    /// DBIDs and ReplicaIDs in the operator UI.
110    ///
111    /// Order is the on-disk byte order (Innards[0] little-endian bytes
112    /// first, then Innards[1] little-endian bytes). This matches what
113    /// the Notes client shows in File / Database / Properties.
114    pub fn as_hex_id(&self) -> String {
115        let b0 = self.innards0.to_le_bytes();
116        let b1 = self.innards1.to_le_bytes();
117        format!(
118            "{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}",
119            b0[0], b0[1], b0[2], b0[3], b1[0], b1[1], b1[2], b1[3]
120        )
121    }
122}
123
124/// Decoded clock representation of a TIMEDATE. Returned by
125/// [`Timedate::as_clock`]. The TIMEDATE bytes themselves are preserved
126/// on the parent [`Timedate`] in case downstream code needs the raw
127/// identifier-style view.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub struct DecodedTimedate {
130    /// Unix seconds (UTC) corresponding to the date+time portion.
131    /// Centiseconds-precision remainder is in `centiseconds`.
132    pub unix_seconds_utc: i64,
133    /// 0-99 centiseconds (1/100ths of a second) on top of
134    /// `unix_seconds_utc`.
135    pub centiseconds: u32,
136    /// Originating timezone offset from UTC, in minutes. Positive =
137    /// east of GMT, negative = west. Preserve this for chain-of-custody:
138    /// "this email was sent from CET" is forensically distinct from
139    /// "this email was sent at 14:49 UTC".
140    pub tz_offset_minutes: i32,
141    /// DST-observed flag from the original TIMEDATE. Notes stores DST
142    /// status as a flag bit, not as a derived value; preserve it.
143    pub dst: bool,
144    /// Astronomical Julian Day Number (proleptic Julian calendar). JDN
145    /// 2,440,588 == 1970-01-01.
146    pub julian_day_number: u32,
147}
148
149impl DecodedTimedate {
150    /// ISO-8601 representation in the originating timezone, e.g.
151    /// `1996-12-10T14:49:04.00-05:00`. Centiseconds-precision.
152    pub fn to_iso_8601(&self) -> String {
153        // Adjust UTC seconds into the local timezone for display.
154        let local_seconds = self.unix_seconds_utc + (self.tz_offset_minutes as i64) * 60;
155        let (year, month, day) = civil_from_unix_day(local_seconds.div_euclid(86_400));
156        let day_seconds = local_seconds.rem_euclid(86_400) as u32;
157        let hour = day_seconds / 3600;
158        let minute = (day_seconds % 3600) / 60;
159        let second = day_seconds % 60;
160        let tz_sign = if self.tz_offset_minutes >= 0 { '+' } else { '-' };
161        let tz_abs = self.tz_offset_minutes.unsigned_abs();
162        let tz_h = tz_abs / 60;
163        let tz_m = tz_abs % 60;
164        format!(
165            "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{:02}{tz_sign}{tz_h:02}:{tz_m:02}",
166            self.centiseconds
167        )
168    }
169}
170
171/// Convert a count of days since 1970-01-01 to (year, month, day) using
172/// the proleptic Gregorian calendar. Howard Hinnant's algorithm. Kept
173/// inline to avoid pulling chrono into this dependency-free crate.
174fn civil_from_unix_day(z: i64) -> (i32, u32, u32) {
175    let z = z + 719_468; // shift epoch so March is the first month
176    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
177    let doe = (z - era * 146_097) as u32;
178    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
179    let y = (yoe as i32) + (era as i32) * 400;
180    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
181    let mp = (5 * doy + 2) / 153;
182    let d = doy - (153 * mp + 2) / 5 + 1;
183    let m = if mp < 10 { mp + 3 } else { mp - 9 };
184    let y = if m <= 2 { y + 1 } else { y };
185    (y, m, d)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    /// Canonical Lotus C API example: Dec 10 1996 14:49:04 EST (-5).
193    /// Innards[0] = 0x006CDCC0, Innards[1] = 0x852563FC.
194    /// We just need byte-for-byte parsing and round-trip; the date math
195    /// is verified by the round-trip check on a few well-known epochs.
196    #[test]
197    fn parses_canonical_lotus_example_bytes() {
198        let bytes = [0xC0, 0xDC, 0x6C, 0x00, 0xFC, 0x63, 0x25, 0x85];
199        let td = Timedate::from_bytes(&bytes).unwrap();
200        assert_eq!(td.innards0, 0x006C_DCC0);
201        assert_eq!(td.innards1, 0x8525_63FC);
202        let clock = td.as_clock().unwrap();
203        assert!(clock.dst, "DST flag should be set");
204        assert!(!matches!(clock.tz_offset_minutes, 0), "offset is non-zero");
205        // EST is -5 -> -300 minutes from GMT.
206        assert_eq!(clock.tz_offset_minutes, -300);
207        assert_eq!(clock.julian_day_number, 2_450_428);
208    }
209
210    #[test]
211    fn round_trips_unix_epoch_day() {
212        // JDN 2440588 = 1970-01-01 by definition.
213        let mut innards1 = 2_440_588u32;
214        innards1 |= 0x4000_0000; // east of GMT (offset zero either way)
215        let td = Timedate {
216            innards0: 0,
217            innards1,
218        };
219        let clock = td.as_clock().unwrap();
220        assert_eq!(clock.unix_seconds_utc, 0);
221        let iso = clock.to_iso_8601();
222        assert!(iso.starts_with("1970-01-01"), "got {iso}");
223    }
224
225    #[test]
226    fn renders_iso_8601() {
227        let bytes = [0xC0, 0xDC, 0x6C, 0x00, 0xFC, 0x63, 0x25, 0x85];
228        let td = Timedate::from_bytes(&bytes).unwrap();
229        let clock = td.as_clock().unwrap();
230        let iso = clock.to_iso_8601();
231        // 1996-12-10 14:49:04.00 in EST (offset -05:00).
232        assert!(iso.starts_with("1996-12-10T14:49:04"), "got {iso}");
233        assert!(iso.ends_with("-05:00"), "got {iso}");
234    }
235
236    #[test]
237    fn rejects_short_buffer() {
238        let bytes = [0x00; 4];
239        let err = Timedate::from_bytes(&bytes).unwrap_err();
240        assert!(matches!(err, NsfError::TooShort { .. }));
241    }
242
243    #[test]
244    fn identifier_uses_render_as_hex() {
245        let td = Timedate {
246            innards0: 0xDEAD_BEEF,
247            innards1: 0xCAFE_BABE,
248        };
249        // LE byte order: 0xEF 0xBE 0xAD 0xDE | 0xBE 0xBA 0xFE 0xCA.
250        assert_eq!(td.as_hex_id(), "EFBEADDEBEBAFECA");
251    }
252
253    #[test]
254    fn implausible_jdn_returns_none_for_clock() {
255        let td = Timedate {
256            innards0: 0,
257            innards1: 0, // JDN = 0 (way before Notes existed)
258        };
259        assert!(td.as_clock().is_none());
260        // But hex_id still works because identifier-uses do not care.
261        assert_eq!(td.as_hex_id(), "0000000000000000");
262    }
263}