Skip to main content

disk_core/
times.rs

1//! Per-file Unix timestamps shared across the create / import / export paths,
2//! plus the format-specific encoders every non-Unix driver needs to write those
3//! same times into its own on-disk shape.
4//!
5//! The goal is date-preservation on copy: when rb-cli copies a file from a host
6//! folder, a tar archive, or another disk image into a filesystem, the
7//! destination should record the *source's* mtime, not "now". And a
8//! `tar_export` (or any host-side extract) of that image should carry the same
9//! mtime back out.
10//!
11//! ## The rule
12//!
13//! `create_file` / `create_directory` / `create_symlink` use these times when
14//! `options.unix_times.is_some()`; otherwise they stamp `now`. So:
15//!
16//! - **Genuine new file** (user typed `put`, GUI created a blank file) —
17//!   caller leaves `unix_times = None`, driver stamps `now`.
18//! - **Copy / extract / import** — caller (dir_import / tar_import /
19//!   Commander stage_copy) captures the source's mtime and passes it through;
20//!   the destination inode records it verbatim.
21//!
22//! ## Why per-field Options
23//!
24//! Different sources carry different subsets: a tar Header has only `mtime`,
25//! an HFS entry has creation + modification + backup dates, an EFS inode has
26//! all three (atime/mtime/ctime). Missing fields aren't fabricated — the
27//! driver falls back to `now` (or `mtime` where sensible) for each.
28//!
29//! Values are u64 seconds since UNIX epoch 1970-01-01. Sub-second precision
30//! is intentionally dropped: FAT is 2-second granular, EFS/AFFS/PFS3 are 1-
31//! second, only ext/xfs/jfs carry nanoseconds — and the tar exporter only
32//! writes seconds anyway, so nanos wouldn't round-trip end-to-end.
33//!
34//! ## Format encoders
35//!
36//! Every filesystem below rusty-backup edits carries dates in *some* shape:
37//! DOS packed (FAT / exFAT / Human68k), NTFS FILETIME, Mac epoch (HFS /
38//! HFS+ / MFS), ProDOS packed, UCSD packed, ADFS 40-bit centiseconds, OS-9
39//! Y-M-D bytes, QDOS 1961 epoch. Each encoder in this module turns a Unix
40//! `u64` seconds value into that shape; each decoder is the inverse and
41//! returns `None` on the format's "no date set" sentinel (usually a
42//! literal zero on disk).
43//!
44//! Encoders clamp to the format's earliest representable date rather than
45//! underflowing — a FAT file dated 1975 becomes 1980-01-01, not garbage.
46//! Decoders return `None` for zero and for values that would land before
47//! 1970, so a `modified_unix` never carries a suspicious 1969 / 1970-01-01
48//! for what is on disk actually "no timestamp".
49
50use std::time::{SystemTime, UNIX_EPOCH};
51
52/// A snapshot of a file's Unix timestamps, in seconds since 1970-01-01.
53///
54/// Every field is `Option<u64>` so partial sources (tar has only mtime) don't
55/// have to fabricate the others. When a field is `None` the destination
56/// driver falls back to whatever it would use for a genuinely new file.
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub struct UnixTimes {
59    /// Modification time — the "when the content last changed" one, the
60    /// number `ls -l` and `tar` display. This is the field that matters
61    /// most; both dir_import and tar_import populate it.
62    pub mtime: Option<u64>,
63    /// Access time — often equal to mtime on write. dir_import captures
64    /// the host file's atime; tar has no access-time field so tar_import
65    /// leaves it None (driver falls back to mtime or now).
66    pub atime: Option<u64>,
67    /// Metadata-change time — bumped by chmod / rename on POSIX. Almost
68    /// never available at import time (tar doesn't carry it, hosts don't
69    /// usually expose it portably), so this is usually None → driver uses
70    /// mtime or now.
71    pub ctime: Option<u64>,
72}
73
74impl UnixTimes {
75    /// Snapshot with all three fields set to the same value. What
76    /// dir_import uses when the host has only `st_mtime` to hand.
77    pub fn all(secs: u64) -> Self {
78        Self {
79            mtime: Some(secs),
80            atime: Some(secs),
81            ctime: Some(secs),
82        }
83    }
84
85    /// Snapshot with only mtime set. What tar_import produces (tar has no
86    /// atime/ctime fields).
87    pub fn mtime_only(secs: u64) -> Self {
88        Self {
89            mtime: Some(secs),
90            atime: None,
91            ctime: None,
92        }
93    }
94
95    /// Convenience: mtime if set, else atime, else ctime, else `now`. The
96    /// pattern most drivers want when writing a single-field on-disk time
97    /// (e.g. UFS's `di_mtime` only) — pick the most user-meaningful value
98    /// the caller supplied without overwriting it with `now`.
99    pub fn mtime_or_now(&self) -> u64 {
100        self.mtime.or(self.atime).or(self.ctime).unwrap_or_else(now)
101    }
102
103    /// Convenience: atime if set, else mtime, else ctime, else `now`.
104    pub fn atime_or_now(&self) -> u64 {
105        self.atime.or(self.mtime).or(self.ctime).unwrap_or_else(now)
106    }
107
108    /// Convenience: ctime if set, else mtime, else atime, else `now`.
109    pub fn ctime_or_now(&self) -> u64 {
110        self.ctime.or(self.mtime).or(self.atime).unwrap_or_else(now)
111    }
112
113    /// True when every field is None (equivalent to `Default::default`).
114    pub fn is_empty(&self) -> bool {
115        self.mtime.is_none() && self.atime.is_none() && self.ctime.is_none()
116    }
117}
118
119/// Current wall-clock time as seconds since UNIX epoch. Clamped to 0 on the
120/// (impossible) pre-1970 system clock. Replaces every driver's
121/// `SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()`
122/// dance with one function.
123pub fn now() -> u64 {
124    SystemTime::now()
125        .duration_since(UNIX_EPOCH)
126        .map(|d| d.as_secs())
127        .unwrap_or(0)
128}
129
130/// `now()` clamped into a u32 (used by drivers with 32-bit time fields —
131/// EFS, UFS-v1, XFS-v4, FAT/exFAT date halves). u32 overflows in 2106; we
132/// document but do not guard against it (no user has a 2106-vintage disk).
133pub fn now_u32() -> u32 {
134    now() as u32
135}
136
137/// The `now`-fallback rule the drivers use in one place: return the given
138/// `UnixTimes` if any field is set, otherwise a fresh all-fields `now`.
139///
140/// Not every driver wants this shape — some pick fields à la carte via
141/// [`UnixTimes::mtime_or_now`] — but for the common "stamp all three
142/// identically" case it captures the "preserve if given, else now" rule
143/// so no driver open-codes the branch.
144pub fn resolve_or_now(supplied: Option<UnixTimes>) -> UnixTimes {
145    match supplied {
146        Some(t) if !t.is_empty() => t,
147        _ => UnixTimes::all(now()),
148    }
149}
150
151// ---------------------------------------------------------------------------
152// Civil-date helpers (no chrono dep so the vintage build stays lean)
153// ---------------------------------------------------------------------------
154
155/// Break Unix seconds into (year, month 1..=12, day 1..=31, hour, minute,
156/// second). Uses Howard Hinnant's `civil_from_days` (same one
157/// `format_unix_timestamp` in unix_common::inode uses); safe for any u64
158/// value we can actually store on disk. Deliberately not exported — the
159/// format-specific encoders below are the public API.
160fn ymd_hms(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
161    let sec_of_day = secs % 86400;
162    let hour = (sec_of_day / 3600) as u32;
163    let minute = ((sec_of_day % 3600) / 60) as u32;
164    let second = (sec_of_day % 60) as u32;
165
166    let mut days = (secs / 86400) as i64;
167    days += 719468;
168    let era = if days >= 0 { days } else { days - 146096 } / 146097;
169    let doe = (days - era * 146097) as u32;
170    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
171    let y = yoe as i64 + era * 400;
172    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
173    let mp = (5 * doy + 2) / 153;
174    let d = doy - (153 * mp + 2) / 5 + 1;
175    let m = if mp < 10 { mp + 3 } else { mp - 9 };
176    let year = if m <= 2 { y + 1 } else { y };
177    (year, m, d, hour, minute, second)
178}
179
180/// Inverse of `ymd_hms` — pack (year, month, day, hour, minute, second) back
181/// into Unix seconds. Uses Howard Hinnant's `days_from_civil` companion.
182/// Any (year, month, day) triple the encoders emit round-trips exactly.
183/// Invalid inputs (month 0, day > 31, etc.) still return *something*, but the
184/// decoders always range-check first.
185fn secs_from_ymd_hms(year: i64, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> u64 {
186    let y = if month <= 2 { year - 1 } else { year };
187    let era = if y >= 0 { y } else { y - 399 } / 400;
188    let yoe = (y - era * 400) as u32;
189    let m = month;
190    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day - 1;
191    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
192    let days = era * 146097 + doe as i64 - 719468;
193    (days as u64) * 86400 + hour as u64 * 3600 + minute as u64 * 60 + second as u64
194}
195
196/// True when a Gregorian year is a leap year.
197fn is_leap(year: i64) -> bool {
198    (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0)
199}
200
201/// Days in month for a given Gregorian year.
202fn days_in_month(year: i64, month: u32) -> u32 {
203    match month {
204        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
205        4 | 6 | 9 | 11 => 30,
206        2 => {
207            if is_leap(year) {
208                29
209            } else {
210                28
211            }
212        }
213        _ => 0,
214    }
215}
216
217// ---------------------------------------------------------------------------
218// FAT / exFAT / Human68k — DOS packed date + time (16-bit each)
219// ---------------------------------------------------------------------------
220
221/// Seconds between 1970-01-01 and 1980-01-01 (DOS epoch). 3652 days.
222const DOS_EPOCH_SECS: u64 = 315_532_800;
223
224/// Encode Unix seconds as (fat_date, fat_time) — the two 16-bit words FAT
225/// dirent slots hold. `fat_date` layout: `year_since_1980 (7) | month (4)
226/// | day (5)`; `fat_time` layout: `hour (5) | minute (6) | second/2 (5)`.
227///
228/// Any Unix time before 1980-01-01 clamps to 1980-01-01 00:00:00 (the
229/// earliest representable DOS date) so a pre-1980 mtime doesn't become
230/// year-2107. Anything past 2107-12-31 23:59:58 pins to that moment: the
231/// year field is only 7 bits and there is nowhere else to put a bigger
232/// value.
233pub fn unix_to_dos_datetime(secs: u64) -> (u16, u16) {
234    let secs = secs.max(DOS_EPOCH_SECS);
235    let (year, month, day, hour, minute, second) = ymd_hms(secs);
236    // Past 2107 the whole stamp pins to the last representable moment; a
237    // clamped year with the real month and day produced dates like 2107-02-29.
238    if year > 2107 {
239        return ((127 << 9) | (12 << 5) | 31, (23 << 11) | (59 << 5) | 29);
240    }
241    let year_since_1980 = (year - 1980) as u16;
242    let date = (year_since_1980 << 9) | ((month as u16 & 0x0F) << 5) | (day as u16 & 0x1F);
243    let time =
244        ((hour as u16 & 0x1F) << 11) | ((minute as u16 & 0x3F) << 5) | ((second as u16 / 2) & 0x1F);
245    (date, time)
246}
247
248/// Decode a FAT (date, time) pair back to Unix seconds. Returns `None` for
249/// a zero `date` (the "no date set" sentinel every DOS filesystem tool
250/// uses) or for a date field whose components don't make sense (month 0,
251/// day 0, day > days-in-month, etc). Values are treated as UTC — FAT
252/// stores wall-clock time with no timezone, and every tool we round-trip
253/// against does the same.
254pub fn dos_datetime_to_unix(date: u16, time: u16) -> Option<u64> {
255    if date == 0 {
256        return None;
257    }
258    let day = (date & 0x1F) as u32;
259    let month = ((date >> 5) & 0x0F) as u32;
260    let year = ((date >> 9) & 0x7F) as i64 + 1980;
261    let second = ((time & 0x1F) as u32) * 2;
262    let minute = ((time >> 5) & 0x3F) as u32;
263    let hour = ((time >> 11) & 0x1F) as u32;
264    if month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) {
265        return None;
266    }
267    if hour > 23 || minute > 59 || second > 59 {
268        return None;
269    }
270    Some(secs_from_ymd_hms(year, month, day, hour, minute, second))
271}
272
273/// exFAT packs the two 16-bit DOS words into a single u32 (date in the
274/// upper half, time in the lower). Layer over [`unix_to_dos_datetime`].
275pub fn unix_to_exfat_timestamp(secs: u64) -> u32 {
276    let (date, time) = unix_to_dos_datetime(secs);
277    ((date as u32) << 16) | time as u32
278}
279
280/// Inverse of [`unix_to_exfat_timestamp`].
281pub fn exfat_timestamp_to_unix(ts: u32) -> Option<u64> {
282    dos_datetime_to_unix((ts >> 16) as u16, ts as u16)
283}
284
285// ---------------------------------------------------------------------------
286// NTFS FILETIME — 100-ns intervals since 1601-01-01
287// ---------------------------------------------------------------------------
288
289/// Seconds between the NTFS epoch (1601-01-01) and the Unix epoch (1970-01-01).
290const NTFS_EPOCH_OFFSET_SECS: u64 = 11_644_473_600;
291
292/// Encode Unix seconds as an NTFS FILETIME (100-nanosecond intervals since
293/// 1601-01-01). Sub-second precision is lost, which is fine — every other
294/// source in the import chain (host stat, tar Header, cross-fs copy) is
295/// second-granular anyway.
296pub fn unix_to_filetime(secs: u64) -> u64 {
297    secs.saturating_add(NTFS_EPOCH_OFFSET_SECS)
298        .saturating_mul(10_000_000)
299}
300
301/// Decode an NTFS FILETIME back to Unix seconds. Returns `None` for a
302/// zero FILETIME (NTFS's "unset" sentinel) or for a FILETIME that would
303/// land before 1970 (the tar oracle would render those as 1969 either
304/// way, and no real NTFS volume dates a file before 1970).
305pub fn filetime_to_unix(ft: u64) -> Option<u64> {
306    if ft == 0 {
307        return None;
308    }
309    let total_secs = ft / 10_000_000;
310    if total_secs < NTFS_EPOCH_OFFSET_SECS {
311        return None;
312    }
313    Some(total_secs - NTFS_EPOCH_OFFSET_SECS)
314}
315
316// ---------------------------------------------------------------------------
317// HFS / HFS+ / MFS — Mac epoch (u32 seconds since 1904-01-01)
318// ---------------------------------------------------------------------------
319
320/// Seconds between 1904-01-01 (Mac epoch) and 1970-01-01 (Unix epoch).
321/// Duplicated from hfs_common so the encoder is reachable outside the
322/// HFS/HFS+ modules (MFS uses the same epoch and lives elsewhere).
323const MAC_EPOCH_DELTA: u64 = 2_082_844_800;
324
325/// Seconds this host's local time is ahead of UTC, for the filesystems that
326/// store wall-clock time with no zone (classic HFS, MFS).
327pub fn local_utc_offset_secs() -> i64 {
328    chrono::Local::now().offset().local_minus_utc() as i64
329}
330
331/// Unix seconds to a classic-Mac local-time stamp: the wall clock the Mac shows.
332pub fn unix_to_mac_local(secs: u64) -> u32 {
333    let local = (secs as i64).saturating_add(local_utc_offset_secs()).max(0) as u64;
334    unix_to_mac_epoch(local)
335}
336
337/// A classic-Mac local-time stamp back to Unix seconds (UTC).
338pub fn mac_local_to_unix(mac_secs: u32) -> Option<u64> {
339    mac_epoch_to_unix(mac_secs).map(|local| {
340        (local as i64)
341            .saturating_sub(local_utc_offset_secs())
342            .max(0) as u64
343    })
344}
345
346/// The current wall clock as a classic-Mac local-time stamp.
347pub fn mac_local_now() -> u32 {
348    unix_to_mac_local(now())
349}
350
351/// Encode Unix seconds as a Mac epoch u32, clamping at both ends: a bogus
352/// future mtime on an extracted archive used to wrap into 1904..1970.
353pub fn unix_to_mac_epoch(secs: u64) -> u32 {
354    u32::try_from(secs.saturating_add(MAC_EPOCH_DELTA)).unwrap_or(u32::MAX)
355}
356
357/// Decode a Mac epoch u32 back to Unix seconds. Mirrors
358/// `hfs_common::mac_date_to_unix` — returns `None` for zero (the "no date"
359/// sentinel) and for pre-1970 Mac dates (which would tar out as 1969).
360pub fn mac_epoch_to_unix(mac_secs: u32) -> Option<u64> {
361    if mac_secs == 0 || (mac_secs as u64) < MAC_EPOCH_DELTA {
362        return None;
363    }
364    Some(mac_secs as u64 - MAC_EPOCH_DELTA)
365}
366
367// ---------------------------------------------------------------------------
368// ProDOS — packed date + time (2×u16)
369// ---------------------------------------------------------------------------
370
371/// Encode Unix seconds as a ProDOS (date, time) pair. Date layout: `year
372/// (7) | month (4) | day (5)` where year is `year - 2000` when < 40 else
373/// `year - 1900` (ProDOS's own two-digit convention — years 40..99 mean
374/// 1940..1999, years 0..39 mean 2000..2039). Time layout: `hour (5) |
375/// minute (6)` (no seconds — ProDOS is minute-granular).
376///
377/// Clamps to 1940-01-01 for pre-1940 input (the earliest representable
378/// ProDOS date under its own convention) and to 2039-12-31 for
379/// post-2039 input.
380pub fn unix_to_prodos_datetime(secs: u64) -> (u16, u16) {
381    let (mut year, month, day, hour, minute, _second) = ymd_hms(secs);
382    if year < 1940 {
383        return (
384            ((40u16) << 9) | (1u16 << 5) | 1u16, // 1940-01-01
385            0,
386        );
387    }
388    if year > 2039 {
389        year = 2039;
390    }
391    let year_bits = if year >= 2000 {
392        (year - 2000) as u16
393    } else {
394        (year - 1900) as u16
395    };
396    let date = ((year_bits & 0x7F) << 9) | ((month as u16 & 0x0F) << 5) | (day as u16 & 0x1F);
397    let time = ((hour as u16 & 0x1F) << 8) | (minute as u16 & 0x3F);
398    (date, time)
399}
400
401/// Decode a ProDOS (date, time) pair back to Unix seconds. Returns `None`
402/// for a zero `date` ("no date set" — ProDOS Technical Note #28 formalises
403/// this).
404pub fn prodos_datetime_to_unix(date: u16, time: u16) -> Option<u64> {
405    if date == 0 {
406        return None;
407    }
408    let day = (date & 0x1F) as u32;
409    let month = ((date >> 5) & 0x0F) as u32;
410    let raw_year = ((date >> 9) & 0x7F) as i64;
411    let year = if raw_year < 40 {
412        2000 + raw_year
413    } else {
414        1900 + raw_year
415    };
416    if year < 1970 {
417        return None;
418    }
419    let minute = (time & 0x3F) as u32;
420    let hour = ((time >> 8) & 0x1F) as u32;
421    if month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) {
422        return None;
423    }
424    if hour > 23 || minute > 59 {
425        return None;
426    }
427    Some(secs_from_ymd_hms(year, month, day, hour, minute, 0))
428}
429
430// ---------------------------------------------------------------------------
431// UCSD Pascal — packed date (u16: day | month | year)
432// ---------------------------------------------------------------------------
433
434/// Encode Unix seconds as a UCSD Pascal packed date. Layout (little-endian
435/// as it sits in the dirent, but treated here as a plain u16):
436/// `year (7) | month (4) | day (5)` — years are 2-digit like ProDOS, but
437/// UCSD's convention is different: 0..99 all map to 1900..1999.
438///
439/// Clamps to 1900-01-01 for pre-1900 input and to 1999-12-31 for
440/// post-1999 input. Time-of-day is dropped — UCSD is day-granular.
441pub fn unix_to_ucsd_date(secs: u64) -> u16 {
442    let (year, month, day, _h, _m, _s) = ymd_hms(secs);
443    let year = year.clamp(1900, 1999);
444    let year_bits = (year - 1900) as u16;
445    (day as u16 & 0x1F) | ((month as u16 & 0x0F) << 5) | ((year_bits & 0x7F) << 9)
446}
447
448/// Decode a UCSD Pascal packed date to Unix seconds (00:00:00 of that day).
449/// Returns `None` for zero, and for pre-1970 dates (which would round-trip
450/// through tar as 1969 anyway; the year field's 1900..1999 range means any
451/// UCSD-native date can be pre-1970, so this guard matters).
452pub fn ucsd_date_to_unix(word: u16) -> Option<u64> {
453    if word == 0 {
454        return None;
455    }
456    let day = (word & 0x1F) as u32;
457    let month = ((word >> 5) & 0x0F) as u32;
458    let year = 1900 + ((word >> 9) & 0x7F) as i64;
459    if year < 1970 {
460        return None;
461    }
462    if month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) {
463        return None;
464    }
465    Some(secs_from_ymd_hms(year, month, day, 0, 0, 0))
466}
467
468// ---------------------------------------------------------------------------
469// ADFS RISC OS — 40-bit centiseconds since 1900-01-01, packed into
470// (load_addr low 8 bits, exec_addr all 32 bits) with load_addr high bits
471// `0xFFFtt000` (filetype `tt`, plus the 0xFFF marker)
472// ---------------------------------------------------------------------------
473
474/// Centiseconds between 1900-01-01 and 1970-01-01. 70 years × 365.2425
475/// days × 86400 s × 100 = 220_898_880_000 cs. (Includes 17 leap days:
476/// 70/4 = 17.5, minus 1900 not being a leap year = 17.)
477const ADFS_EPOCH_OFFSET_CS: u64 = 220_898_880_000;
478
479/// Encode Unix seconds as an ADFS timestamped (load_addr, exec_addr)
480/// pair carrying the given RISC OS filetype (12 bits: 0xFFF = Data,
481/// 0xFEB = Obey, etc.). `load_addr = 0xFFF_00000 | ((ft & 0xFFF) <<
482/// 8) | ((cs40 >> 32) as u8)`; `exec_addr = cs40 as u32`. The 0xFFF-in-
483/// high-12-bits pattern is the "this is a datestamp, not a load
484/// address" marker every RISC OS tool checks for.
485pub fn unix_to_adfs_time(secs: u64, filetype: u16) -> (u32, u32) {
486    let cs = secs
487        .saturating_mul(100)
488        .saturating_add(ADFS_EPOCH_OFFSET_CS);
489    let load_addr = 0xFFF0_0000u32 | ((filetype as u32 & 0xFFF) << 8) | ((cs >> 32) as u32 & 0xFF);
490    let exec_addr = cs as u32;
491    (load_addr, exec_addr)
492}
493
494/// Decode an ADFS (load_addr, exec_addr) pair to Unix seconds. Returns
495/// `None` when load_addr's high 12 bits aren't `0xFFF` (which means the
496/// pair is a real load/exec address, not a datestamp) or when the
497/// resulting timestamp would land before 1970.
498pub fn adfs_time_to_unix(load: u32, exec: u32) -> Option<u64> {
499    if load & 0xFFF0_0000 != 0xFFF0_0000 {
500        return None;
501    }
502    let cs = ((load as u64 & 0xFF) << 32) | exec as u64;
503    if cs < ADFS_EPOCH_OFFSET_CS {
504        return None;
505    }
506    Some((cs - ADFS_EPOCH_OFFSET_CS) / 100)
507}
508
509// ---------------------------------------------------------------------------
510// OS-9 — FD.DAT (5-byte last-modified Y-M-D-H-M) + FD.DCR (3-byte
511// creation Y-M-D). Each byte is a plain binary component; year is
512// offset from 1900.
513// ---------------------------------------------------------------------------
514
515/// Encode Unix seconds as HP LIF's 6-byte BCD `YYMMDDhhmmss`; years outside 1970-2069 clamp.
516pub fn unix_to_lif_bcd(secs: u64) -> [u8; 6] {
517    let (year, month, day, hour, minute, second) = ymd_hms(secs);
518    let yy = (year.clamp(1970, 2069) % 100) as u32;
519    let bcd = |v: u32| (((v / 10) << 4) | (v % 10)) as u8;
520    [
521        bcd(yy),
522        bcd(month),
523        bcd(day),
524        bcd(hour),
525        bcd(minute),
526        bcd(second),
527    ]
528}
529
530/// Encode Unix seconds as OS-9 FD.DAT (5 bytes: year-1900, month, day,
531/// hour, minute). OS-9's year is a single unsigned byte — 1900..2155
532/// representable. Pre-1900 clamps to 1900-01-01; post-2155 clamps to
533/// 2155-12-31.
534pub fn unix_to_os9_dat(secs: u64) -> [u8; 5] {
535    let (year, month, day, hour, minute, _s) = ymd_hms(secs);
536    let year = year.clamp(1900, 2155);
537    [
538        (year - 1900) as u8,
539        month as u8,
540        day as u8,
541        hour as u8,
542        minute as u8,
543    ]
544}
545
546/// Encode Unix seconds as OS-9 FD.DCR (3 bytes: year-1900, month, day).
547pub fn unix_to_os9_dcr(secs: u64) -> [u8; 3] {
548    let dat = unix_to_os9_dat(secs);
549    [dat[0], dat[1], dat[2]]
550}
551
552/// Decode an OS-9 FD.DAT to Unix seconds. Returns `None` for all-zero
553/// (unwritten) or nonsensical component values.
554pub fn os9_dat_to_unix(dat: &[u8; 5]) -> Option<u64> {
555    if dat == &[0u8; 5] {
556        return None;
557    }
558    let year = 1900i64 + dat[0] as i64;
559    if year < 1970 {
560        return None;
561    }
562    let month = dat[1] as u32;
563    let day = dat[2] as u32;
564    let hour = dat[3] as u32;
565    let minute = dat[4] as u32;
566    if month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) {
567        return None;
568    }
569    if hour > 23 || minute > 59 {
570        return None;
571    }
572    Some(secs_from_ymd_hms(year, month, day, hour, minute, 0))
573}
574
575/// Decode an OS-9 FD.DCR (creation date only) to Unix seconds. Returns
576/// `None` for all-zero or pre-1970 dates.
577pub fn os9_dcr_to_unix(dcr: &[u8; 3]) -> Option<u64> {
578    if dcr == &[0u8; 3] {
579        return None;
580    }
581    let year = 1900i64 + dcr[0] as i64;
582    if year < 1970 {
583        return None;
584    }
585    let month = dcr[1] as u32;
586    let day = dcr[2] as u32;
587    if month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) {
588        return None;
589    }
590    Some(secs_from_ymd_hms(year, month, day, 0, 0, 0))
591}
592
593// ---------------------------------------------------------------------------
594// QDOS (Sinclair QL) — u32 seconds since 1961-01-01
595// ---------------------------------------------------------------------------
596
597/// Seconds between 1961-01-01 and 1970-01-01 (Unix epoch). 9 years:
598/// (365 * 9) + 2 leap days (1964, 1968) = 3287 days × 86400 = 283_996_800 s.
599const QDOS_EPOCH_OFFSET_SECS: u64 = 283_996_800;
600
601/// Encode Unix seconds as a QDOS timestamp (u32 seconds since 1961-01-01,
602/// big-endian on disk — but the encoding is byte-order-agnostic here).
603/// Pre-1961 clamps to 0 (1961-01-01); post-2097-02-06 truncates by u32
604/// wraparound. No QDOS tool guards against either.
605pub fn unix_to_qdos_date(secs: u64) -> u32 {
606    (secs.saturating_add(QDOS_EPOCH_OFFSET_SECS)) as u32
607}
608
609/// Decode a QDOS timestamp to Unix seconds. Returns `None` for zero (the
610/// "no date set" sentinel) or for QDOS values that would land before 1970.
611pub fn qdos_date_to_unix(ts: u32) -> Option<u64> {
612    if ts == 0 || (ts as u64) < QDOS_EPOCH_OFFSET_SECS {
613        return None;
614    }
615    Some(ts as u64 - QDOS_EPOCH_OFFSET_SECS)
616}
617
618// ---------------------------------------------------------------------------
619// Tests
620// ---------------------------------------------------------------------------
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    /// 2020-06-15 12:34:56 UTC — a mid-range date every encoder can hold.
627    const T_2020: u64 = 1_592_224_496;
628
629    /// D19: 2108-02-29 12:00 used to encode as year 127 (2107) with the leap
630    /// day kept, a date that does not exist and that decoders reject.
631    #[test]
632    fn dos_datetime_past_2107_pins_to_the_last_moment() {
633        let leap_2108 = secs_from_ymd_hms(2108, 2, 29, 12, 0, 0);
634        let (d, t) = unix_to_dos_datetime(leap_2108);
635        assert_eq!(
636            dos_datetime_to_unix(d, t),
637            Some(secs_from_ymd_hms(2107, 12, 31, 23, 59, 58))
638        );
639        let (d, t) = unix_to_dos_datetime(u64::MAX / 4);
640        assert!(dos_datetime_to_unix(d, t).is_some());
641    }
642
643    #[test]
644    fn dos_datetime_round_trips_and_clamps() {
645        let (d, t) = unix_to_dos_datetime(T_2020);
646        // 2020 = 1980 + 40, month=6, day=15, hour=12, minute=34, second=56 (56/2=28)
647        assert_eq!(d, (40 << 9) | (6 << 5) | 15);
648        assert_eq!(t, (12 << 11) | (34 << 5) | 28);
649        // Round-trip: 2-second granularity means we get 56 back (not 57).
650        assert_eq!(dos_datetime_to_unix(d, t), Some(T_2020));
651
652        // Zero -> None (the "no date" sentinel).
653        assert_eq!(dos_datetime_to_unix(0, 0), None);
654        // Pre-DOS-epoch clamps to 1980-01-01 00:00:00.
655        assert_eq!(unix_to_dos_datetime(0), ((1 << 5) | 1, 0));
656        // Nonsense date (month 0) -> None. date=1 encodes day=1, month=0, year=1980.
657        assert_eq!(dos_datetime_to_unix(0x0001, 0), None);
658    }
659
660    #[test]
661    fn exfat_timestamp_wraps_dos_helpers() {
662        let ts = unix_to_exfat_timestamp(T_2020);
663        assert_eq!(exfat_timestamp_to_unix(ts), Some(T_2020));
664        assert_eq!(exfat_timestamp_to_unix(0), None);
665    }
666
667    #[test]
668    fn filetime_round_trips_and_rejects_zero() {
669        let ft = unix_to_filetime(T_2020);
670        assert_eq!(filetime_to_unix(ft), Some(T_2020));
671        assert_eq!(filetime_to_unix(0), None);
672        // A FILETIME whose value would decode to pre-1970 is refused.
673        assert_eq!(filetime_to_unix(1), None);
674    }
675
676    #[test]
677    fn mac_epoch_round_trips() {
678        let m = unix_to_mac_epoch(T_2020);
679        assert_eq!(mac_epoch_to_unix(m), Some(T_2020));
680        assert_eq!(mac_epoch_to_unix(0), None);
681        // A Mac-epoch value that would decode before 1970 is refused.
682        assert_eq!(mac_epoch_to_unix(100), None);
683    }
684
685    #[test]
686    fn prodos_datetime_round_trips() {
687        // ProDOS drops seconds; use a time on a minute boundary for round-trip.
688        let t = 1_592_224_440; // 2020-06-15 12:34:00 UTC
689        let (d, t_word) = unix_to_prodos_datetime(t);
690        assert_eq!(prodos_datetime_to_unix(d, t_word), Some(t));
691        assert_eq!(prodos_datetime_to_unix(0, 0), None);
692    }
693
694    #[test]
695    fn prodos_year_convention_bounds() {
696        // Year 20 (0..39) -> 2020.
697        let (d, _) = unix_to_prodos_datetime(1_592_224_440);
698        assert_eq!((d >> 9) & 0x7F, 20);
699        // Year 90 (40..99) -> 1990. 1990-06-15 12:34:00 UTC.
700        let t_1990 = 645_453_240;
701        let (d90, t90) = unix_to_prodos_datetime(t_1990);
702        assert_eq!((d90 >> 9) & 0x7F, 90);
703        assert_eq!(prodos_datetime_to_unix(d90, t90), Some(t_1990));
704    }
705
706    #[test]
707    fn ucsd_date_round_trips_day_granular() {
708        let t = 1_592_179_200; // 2020-06-15 00:00:00 UTC — but UCSD only holds 1900..1999.
709                               // For UCSD, use 1990.
710        let t_1990 = 645_408_000; // 1990-06-15 00:00:00
711        let w = unix_to_ucsd_date(t_1990);
712        assert_eq!(ucsd_date_to_unix(w), Some(t_1990));
713        assert_eq!(ucsd_date_to_unix(0), None);
714        // Silence the 2020 unused-warning.
715        let _ = t;
716    }
717
718    #[test]
719    fn adfs_time_round_trips_with_filetype() {
720        let (load, exec) = unix_to_adfs_time(T_2020, 0xFFF);
721        // High 12 bits must be 0xFFF (the datestamp marker).
722        assert_eq!(load & 0xFFF0_0000, 0xFFF0_0000);
723        // Filetype 0xFFF stored in bits 8..20.
724        assert_eq!((load >> 8) & 0xFFF, 0xFFF);
725        assert_eq!(adfs_time_to_unix(load, exec), Some(T_2020));
726        // A non-datestamp load address (high bits != 0xFFF) -> None.
727        assert_eq!(adfs_time_to_unix(0x0000_8000, 0), None);
728    }
729
730    #[test]
731    fn os9_dat_and_dcr_round_trip() {
732        // OS-9 is minute-granular; use a boundary time.
733        let t = 1_592_224_440; // 2020-06-15 12:34:00
734        let dat = unix_to_os9_dat(t);
735        assert_eq!(dat, [120, 6, 15, 12, 34]);
736        assert_eq!(os9_dat_to_unix(&dat), Some(t));
737
738        let dcr = unix_to_os9_dcr(t);
739        assert_eq!(dcr, [120, 6, 15]);
740        // Creation-date decode is 00:00 of the day.
741        assert_eq!(os9_dcr_to_unix(&dcr), Some(1_592_179_200));
742
743        assert_eq!(os9_dat_to_unix(&[0; 5]), None);
744        assert_eq!(os9_dcr_to_unix(&[0; 3]), None);
745    }
746
747    #[test]
748    fn qdos_date_round_trips() {
749        let q = unix_to_qdos_date(T_2020);
750        assert_eq!(qdos_date_to_unix(q), Some(T_2020));
751        assert_eq!(qdos_date_to_unix(0), None);
752    }
753
754    /// Every non-DOS encoder is expected to preserve the exact input we
755    /// give it. This is the tar-round-trip guarantee: a source mtime that
756    /// went in must come back out on the other side, and any format-
757    /// granularity loss is documented.
758    #[test]
759    fn encoders_preserve_mid_range_values_verbatim() {
760        // NTFS: nanosecond precision, so a whole-second input round-trips
761        // exactly.
762        assert_eq!(filetime_to_unix(unix_to_filetime(T_2020)), Some(T_2020));
763        // Mac epoch: second-granular.
764        assert_eq!(mac_epoch_to_unix(unix_to_mac_epoch(T_2020)), Some(T_2020));
765        // QDOS: second-granular.
766        assert_eq!(qdos_date_to_unix(unix_to_qdos_date(T_2020)), Some(T_2020));
767        // ADFS: centisecond-granular but we input whole seconds.
768        let (l, e) = unix_to_adfs_time(T_2020, 0xFFF);
769        assert_eq!(adfs_time_to_unix(l, e), Some(T_2020));
770    }
771}