mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 04:17:06 -06:00
* 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.
194 lines
8.1 KiB
Rust
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);
|
|
}
|
|
}
|