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}