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}