Files
wordcraft/crates/docbin/src/metadata.rs
T
Dylan M. Taylor 7f38f41d8c Use chrono instead of hand-rolled UTC conversion (#3427)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / days_from_civil copies and the
Excel serial-date, FILETIME and logging/PDF date conversions with chrono as
a workspace dependency (default-features = false, std only, so wasm is
unaffected). Output formats are unchanged and pinned by the existing (and one
new) test. Excel's 1900 leap-year bug and the 1900/1904 systems keep their
explicit special cases; a small proleptic-Gregorian fallback covers years
outside chrono's NaiveDate range so no input changes behaviour or panics.
AGENTS.md now says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic or reshape date output for clocks outside chrono's range

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. logging::timestamp now uses the fallible
DateTime::from_timestamp and reads such a clock as the epoch, as the old
saturating code did.

iso_from_unix_secs fell back to the Unix epoch for timestamps chrono
rejects; it now keeps the proleptic Gregorian date civil_from_days gives,
restoring the pre-chrono result for supported out-of-range u64 inputs.

Years past 9999 are written with {:04} rather than %Y (which prepends a
'+') in logging::timestamp, iso_from_unix_secs, docbin::filetime_iso and
odt::now_iso, so byte-for-byte output is preserved.

filetime_iso gained a docstring; AGENTS.md's chrono bullet now notes the
narrow out-of-range fallback. Tests cover each boundary.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies

The PR kept hand-rolled Hinnant `civil_fallback`/`days_from_civil_fallback`
copies for dates outside chrono's ±262143-year range, and AGENTS.md carved a
"narrow fallback" exception for them. That left calendar arithmetic outside
chrono after all.

Replace every copy with the Gregorian 400-year cycle (146_097 days): chrono
converts the day/year within one cycle and whole cycles are added back via i128
(so i64::MIN/MAX can't overflow). This keeps chrono as the only calendar logic
and reproduces the pre-chrono proleptic Gregorian result for every value:

- engine cmd/mod.rs `civil_from_days` (reached by the system clock and by
  `iso_from_unix_secs` for a u64 seconds count past chrono's range).
- engine cmd/controls.rs `days_from_civil` and `days_in_month` (`days_in_month`
  now uses `rem_euclid(400)` since month lengths repeat every 400 years).
- formats sheet.rs `civil` and docx chart.rs `civil` (Excel serials).

Outputs are unchanged: verified the cycle result equals the old formula over a
wide range and at the extremes with a scratch chrono program. New tests pin
accepted inputs past chrono's range on both sides of the epoch, through the
public/lib entry points and the private converters, with values from the old
formula. Removed the AGENTS.md "narrow fallback" exception.

pdf/lib.rs `civil` is left as-is: its only caller is the live system clock and
its year is clamped to 0..9999 by the PDF DateTime, so the epoch fallback past
year 262143 is the accepted live-clock behavior, not a hand-rolled copy.

Tested: cargo test -p wordcraft-engine -p wordcraft-formats -p wordcraft-docx;
fmt --check; clippy --all-targets -D warnings.

* logging/docbin/odt/pdf: keep extended-year dates, no epoch fallback

FredApps (CHANGES_REQUESTED) showed `DateTime::from_timestamp(..).unwrap_or_default()`
changes output: a timestamp the old code converted past chrono's year 262143 range now
collapsed to 1970-01-01 instead of its real proleptic-Gregorian date. The same regression
exists wherever the PR swapped a hand-rolled converter for the epoch fallback. Fix every
such site to keep chrono as the only calendar logic via the 400-year Gregorian cycle
(146_097 days): chrono converts the day within one cycle, whole cycles carry in the year
(i128, saturated into i64). Verified the cycle result equals the old formula across a wide
range and at the extremes.

- apps/wordcraft/src/logging.rs `timestamp`: added `civil_from_days(u64)` (cycle), kept the
  pre-1970 epoch clamp (`duration_since`) and the milliseconds/time-of-day format exactly.
  Pins: UNIX_EPOCH + 10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z (FredApps' example,
  was 1970-...); the checked_add u64::MAX/2 s fixture now asserts 292277026596-12-04T15:30:07.000Z
  instead of the epoch.
- crates/docbin/src/metadata.rs `filetime_iso`: restored a cycle-based `civil(i64)` instead
  of `from_timestamp(..)?` (which dropped the field past chrono's range). A u64 FILETIME caps
  at ~1.83e12 unix s (year 60056), inside chrono's range, so no reachable value changes; pinned
  the largest FILETIME (u64::MAX) -> 60056-05-28T05:36:10Z to lock the extended-year path.
- crates/formats/src/odt.rs `now_iso`: reuse the crate's existing cycle converter
  `sheet::civil` (made pub(crate)) rather than the epoch fallback.
- crates/pdf/src/lib.rs `civil`: cycle converter; year returned unclamped and the caller's
  existing PDF DateTime 0..=9999 year clamp is kept exactly, so month/day/time of a far-future
  clock survive. Pins: civil(10_000_000_000_000)=(318857,5,20,17,46,40),
  civil(-10_000_000_000_000)=(-314918,8,13,6,13,20), civil(253_402_300_800)=(10000,1,1,0,0,0).

engine `cmd/mod.rs::iso_from_unix_secs` already uses the cycle fallback (last round); unchanged.

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-21 19:36:41 -04:00

194 lines
8.1 KiB
Rust

//! Optional document text properties from [MS-OLEPS] §2.21 and §2.25.1.
//! Broken metadata must not prevent opening the document or allocate from unchecked sizes.
use std::io::{Read, Seek};
use wordcraft_doc::CoreProps;
const MAX_STREAM: u64 = 2 << 20;
const MAX_STRING: usize = 64 << 10;
const SUMMARY_FMTID: &[u8] = &[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0x00, 0x2b, 0x27, 0xb3, 0xd9];
pub(crate) fn read<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>) -> Option<CoreProps> {
let stream = comp.open_stream("/\u{5}SummaryInformation").ok()?;
if stream.len() > MAX_STREAM {
return None;
}
let mut bytes = Vec::new();
stream.take(MAX_STREAM + 1).read_to_end(&mut bytes).ok()?;
if bytes.len() as u64 > MAX_STREAM {
return None;
}
parse(&bytes)
}
fn u32_at(bytes: &[u8], offset: usize) -> Option<u32> {
Some(u32::from_le_bytes(bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?))
}
fn parse(bytes: &[u8]) -> Option<CoreProps> {
if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? < 1 {
return None;
}
if u16::from_le_bytes(bytes.get(2..4)?.try_into().ok()?) > 1 {
return None;
}
let offset = u32_at(bytes, 44)? as usize;
if offset < 48 || !offset.is_multiple_of(4) {
return None;
}
let size = u32_at(bytes, offset)? as usize;
let set = bytes.get(offset..offset.checked_add(size)?)?;
let count = u32_at(set, 4)? as usize;
if count > 4096 {
return None;
}
let table_end = 8 + count * 8;
let table = set.get(8..table_end)?;
// Offsets are relative to the property set. Bound each value by the next property so a
// forged string length cannot read another property's bytes as part of the title.
let property = |id: u32| -> Option<&[u8]> {
let entry = table.as_chunks::<8>().0.iter().find(|entry| u32_at(entry.as_slice(), 0) == Some(id))?.as_slice();
let start = u32_at(entry, 4)? as usize;
if start < table_end || !start.is_multiple_of(4) {
return None;
}
let end = table
.as_chunks::<8>()
.0
.iter()
.filter_map(|e| u32_at(e, 4))
.map(|n| n as usize)
.filter(|n| *n > start)
.min()
.unwrap_or(set.len())
.min(set.len());
set.get(start..end)
};
let codepage = property(1)
.and_then(|cp| {
let vt = u32_at(cp, 0)?;
if vt == 2 || vt == 3 { Some(u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?)) } else { None }
})
.unwrap_or(1252);
let get = |id| property(id).and_then(|value| text(value, codepage)).unwrap_or_default();
let get_filetime = |id| property(id).and_then(filetime_iso).unwrap_or_default();
Some(CoreProps {
title: get(2),
subject: get(3),
creator: get(4),
keywords: get(5),
description: get(6),
last_modified_by: get(8),
revision: get(9).parse().unwrap_or_default(),
created: get_filetime(12),
modified: get_filetime(13),
..Default::default()
})
}
/// A `VT_FILETIME` property value as an ISO-8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`), or `None`
/// if it is not a FILETIME, is zero, or predates the 1601 FILETIME epoch.
fn filetime_iso(value: &[u8]) -> Option<String> {
let kind = u32_at(value, 0)?;
if kind != 64 {
return None;
}
let ft_low = u32_at(value, 4)? as u64;
let ft_high = u32_at(value, 8)? as u64;
let ft = (ft_high << 32) | ft_low;
if ft == 0 {
return None;
}
const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000;
let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?;
let unix_secs = i64::try_from(unix_100ns / 10_000_000).ok()?;
let (days, sod) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400));
let (h, mi, s) = (sod / 3600, sod / 60 % 60, sod % 60);
let (y, m, d) = civil(days);
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"))
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// A FILETIME can land past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter
/// still produced a date; dropping to `None` there would change the output. The Gregorian calendar
/// repeats every 400 years (146_097 days), so chrono converts the day within one cycle and whole
/// cycles go back into the year (an `i128` so the extremes can't overflow), keeping chrono as the
/// only calendar logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
fn civil(days: i64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
}
fn text(value: &[u8], codepage: u16) -> Option<String> {
let kind = u32_at(value, 0)?;
let count = u32_at(value, 4)? as usize;
let size = match kind {
30 => count, // CodePageString length is in bytes, even for code page 1200.
31 => count.checked_mul(2)?, // UnicodeString length is in UTF-16 units.
_ => return None,
};
if size > MAX_STRING {
return None;
}
let bytes = value.get(8..8usize.checked_add(size)?)?;
if kind == 31 || codepage == 1200 {
if !bytes.len().is_multiple_of(2) {
return None;
}
let units = bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).take_while(|u| *u != 0);
Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect())
} else {
let bytes = bytes.split(|b| *b == 0).next().unwrap_or_default();
if codepage == 65001 {
Some(String::from_utf8_lossy(bytes).into_owned())
} else if wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&codepage) {
Some(bytes.iter().map(|b| wordcraft_doc::encoding::windows_byte(codepage, *b)).collect())
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::filetime_iso;
/// A `FILETIME` property (VT_FILETIME = 64) as `YYYY-MM-DDTHH:MM:SSZ`. The values are pinned
/// to what the old hand-rolled `civil` produced.
fn value(ft: u64) -> Vec<u8> {
let mut v = 64u32.to_le_bytes().to_vec();
v.extend_from_slice(&((ft & 0xffff_ffff) as u32).to_le_bytes());
v.extend_from_slice(&((ft >> 32) as u32).to_le_bytes());
v
}
#[test]
fn filetime_to_iso() {
// 2020-01-01T00:00:00Z and the same day at 13:45:09.
assert_eq!(filetime_iso(&value(132_223_104_000_000_000)).as_deref(), Some("2020-01-01T00:00:00Z"));
assert_eq!(filetime_iso(&value(132_223_599_090_000_000)).as_deref(), Some("2020-01-01T13:45:09Z"));
// Year 10000 keeps the plain zero-padded year (no leading `+` that chrono's `%Y` would add).
assert_eq!(filetime_iso(&value(2_650_467_744_000_000_000)).as_deref(), Some("10000-01-01T00:00:00Z"));
// The largest FILETIME (u64::MAX) lands in year 60056: past year 9999 (so the plain year
// matters) and the old `civil` converted it without ever dropping to the epoch. chrono's
// own range (year 262143) isn't reachable here — a u64 FILETIME caps at unix seconds
// ~1.83e12 — but the 400-year cycle keeps the result exact regardless.
assert_eq!(filetime_iso(&value(u64::MAX)).as_deref(), Some("60056-05-28T05:36:10Z"));
// Zero, a non-FILETIME kind and times before the 1601 epoch are rejected, not a panic.
assert_eq!(filetime_iso(&value(0)), None);
assert_eq!(filetime_iso(&[2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), None);
assert_eq!(filetime_iso(&value(1)), None);
}
}