Skip to main content

imessage_core/
dates.rs

1//! Apple date conversion utilities.
2//!
3//! The iMessage database (chat.db) stores dates as Apple timestamps:
4//!   - Epoch: January 1, 2001 00:00:00 UTC
5//!   - Unit: nanoseconds (on High Sierra+, multiplier = 10^9) stored as i64
6//!   - MULTIPLIER = 10^6 is used for the conversion, treating the raw value
7//!     as microseconds.
8//!
9//! The formula:
10//!   unix_ms = APPLE_EPOCH_MS + (raw_value / MULTIPLIER)
11//!
12//! Where:
13//!   APPLE_EPOCH_MS = 978307200000 (Jan 1, 2001 in Unix ms)
14//!   MULTIPLIER = 1_000_000
15
16/// Unix timestamp (ms) of January 1, 2001 00:00:00 UTC.
17pub const APPLE_EPOCH_MS: i64 = 978_307_200_000;
18
19/// The multiplier used in the iMessage database (High Sierra+).
20/// Raw DB value / MULTIPLIER = milliseconds offset from Apple epoch.
21pub const MULTIPLIER: i64 = 1_000_000;
22
23/// Convert a raw Apple timestamp from chat.db to Unix epoch milliseconds.
24///
25/// Returns `None` if the raw value is 0 or negative (meaning "not set").
26pub fn apple_to_unix_ms(raw: i64) -> Option<i64> {
27    if raw <= 0 {
28        return None;
29    }
30    Some(APPLE_EPOCH_MS + (raw / MULTIPLIER))
31}
32
33/// Convert Unix epoch milliseconds to a raw Apple timestamp for chat.db queries.
34pub fn unix_ms_to_apple(ms: i64) -> i64 {
35    (ms - APPLE_EPOCH_MS) * MULTIPLIER
36}
37
38#[cfg(test)]
39mod tests {
40    use super::*;
41
42    #[test]
43    fn apple_epoch_is_correct() {
44        // Jan 1, 2001 00:00:00 UTC = 978307200 seconds since Unix epoch
45        assert_eq!(APPLE_EPOCH_MS, 978_307_200_000);
46    }
47
48    #[test]
49    fn zero_returns_none() {
50        assert_eq!(apple_to_unix_ms(0), None);
51    }
52
53    #[test]
54    fn negative_returns_none() {
55        assert_eq!(apple_to_unix_ms(-1), None);
56    }
57
58    #[test]
59    fn one_second_after_apple_epoch() {
60        // 1 second = 1000ms. In DB: 1000 * MULTIPLIER = 1_000_000_000
61        let raw = 1000 * MULTIPLIER;
62        let result = apple_to_unix_ms(raw).unwrap();
63        assert_eq!(result, APPLE_EPOCH_MS + 1000);
64    }
65
66    #[test]
67    fn ten_seconds_after_apple_epoch() {
68        let raw = 10_000 * MULTIPLIER;
69        let result = apple_to_unix_ms(raw).unwrap();
70        assert_eq!(result, APPLE_EPOCH_MS + 10_000);
71    }
72
73    #[test]
74    fn roundtrip() {
75        let now_ms: i64 = 1_700_000_000_000; // ~Nov 2023
76        let apple = unix_ms_to_apple(now_ms);
77        let back = apple_to_unix_ms(apple).unwrap();
78        assert_eq!(back, now_ms);
79    }
80
81    #[test]
82    fn real_message_date() {
83        // Live captured from macOS 26.3 Tahoe: dateEdited: 1771445533049 (Unix ms)
84        // This is a Unix ms timestamp returned by the serializer, not a raw DB value.
85        // Verify that converting back and forth preserves it.
86        let unix_ms: i64 = 1_771_445_533_049;
87        let raw = unix_ms_to_apple(unix_ms);
88        assert!(raw > 0);
89        let back = apple_to_unix_ms(raw).unwrap();
90        assert_eq!(back, unix_ms);
91    }
92
93    #[test]
94    fn multiplier_value() {
95        assert_eq!(MULTIPLIER, 1_000_000);
96    }
97
98    #[test]
99    fn sub_millisecond_truncates() {
100        // Raw values not exactly divisible by MULTIPLIER have sub-ms precision.
101        // Integer division truncates (floors) the fractional ms, which matches
102        // JavaScript's Date constructor behavior (Date truncates fractional ms).
103        let raw: i64 = 793_138_336_556_999_936;
104        let result = apple_to_unix_ms(raw).unwrap();
105        // 793138336556999936 / 1000000 = 793138336556 (truncated)
106        assert_eq!(result, APPLE_EPOCH_MS + 793_138_336_556);
107    }
108
109    #[test]
110    fn real_db_values_match_node() {
111        // Verified against live chat.db raw values.
112        let cases: Vec<(i64, i64)> = vec![
113            (793_138_336_352_743_000, 1_771_445_536_352),
114            (793_138_336_557_000_000, 1_771_445_536_557),
115            (793_138_338_791_721_000, 1_771_445_538_791),
116            (793_138_267_580_708_000, 1_771_445_467_580),
117            (793_138_269_954_512_000, 1_771_445_469_954),
118        ];
119        for (raw, expected) in cases {
120            let result = apple_to_unix_ms(raw).unwrap();
121            assert_eq!(result, expected, "raw={raw}");
122        }
123    }
124}