From cb7966556ccc54cd47caaaacced815c8b275b885 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 19:36:11 -0400 Subject: [PATCH 1/8] security: enforce HTTPS on curl downloads (SonarCloud S6506) (#1016) Add --proto '=https' to curl invocations so redirects cannot downgrade to plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions). --- packaging/linux/package.sh | 2 +- packaging/server/install.sh | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 20da2f76..d38dc0bc 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -121,7 +121,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi diff --git a/packaging/server/install.sh b/packaging/server/install.sh index 11c489a3..826c2bb4 100755 --- a/packaging/server/install.sh +++ b/packaging/server/install.sh @@ -147,7 +147,7 @@ FILES="$HERE" # where the units and the Caddyfile come from from_release() { # download and check the release archive; sets NEW_BIN and FILES local version="$VERSION" name dir if [ -z "$version" ]; then - version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1 + version="$(curl --proto '=https' -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1 [ -n "$version" ] || return 1 fi name="figcraft-server-$version-linux-$ARCH" @@ -155,8 +155,8 @@ from_release() { # download and check the release archive; sets NEW_BIN and FILE rm -rf "$dir" mkdir -p "$dir" say "Downloading $name" - curl -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1 - curl -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1 + curl --proto '=https' -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1 + curl --proto '=https' -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1 (cd "$dir" && grep " $name.tar.gz\$" SHA256SUMS.txt | sha256sum -c --quiet -) || die "the download doesn't match SHA256SUMS.txt" tar -C "$dir" -xzf "$dir/$name.tar.gz" NEW_BIN="$dir/$name/figcraft-server" From a86897dc919b8ae7fb0142fe7e0034c10715f7d9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 19:36:23 -0400 Subject: [PATCH 2/8] ci: pin GitHub Actions to full commit SHA (SonarCloud githubactions:S7637) (#1015) Pin third-party actions to full-length commit SHAs (with version comments) instead of mutable tags/branches to prevent supply-chain tampering via retagged releases. Resolves SonarCloud rule githubactions:S7637. --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9700a8e9..3dfa31d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,12 +42,12 @@ jobs: libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ libgl1-mesa-dev libgtk-3-dev - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy, rustfmt # Only main saves caches; PRs restore main's, so PR caches don't evict main's. - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true @@ -75,10 +75,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: wasm32 save-if: ${{ github.ref == 'refs/heads/main' }} From ce8a224c9e7c5bd7725abec202c707903e42cb76 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 17:57:45 +0700 Subject: [PATCH 3/8] README: the app icon above the title --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 63bc739b..ebbf7fa0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@

+

+ FigCraft icon: an engraved chameleon on a mint-green tile +

+

FigCraft

From 0e3876e6d6ce0f81ad71047d681fb62f69ce36f4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 07:52:34 -0400 Subject: [PATCH 4/8] Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the two hand-rolled Howard Hinnant days-from-civil copies with chrono as a workspace dependency: - crates/collab/src/message.rs: utc_date (YYYY-MM-DD HH:MM UTC) - crates/ui-egui/src/home_recents.rs: date (Mon D, YYYY, UTC; keeps the translated month names and trf formatting) Output is unchanged. Neither function had a test, so each gains one pinning the exact strings the old code produced (leap days included). chrono uses only std, so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono. Follow-up to storytold/cadcraft#1785 --- AGENTS.md | 1 + CLAUDE.md | 1 + Cargo.lock | 11 +++++++++++ Cargo.toml | 2 ++ crates/collab/Cargo.toml | 1 + crates/collab/src/message.rs | 31 +++++++++++++++++------------- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/home_recents.rs | 28 +++++++++++++++++---------- 8 files changed, 53 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 708da805..51157976 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,7 @@ code. L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.set_props(ids, props)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit) diff --git a/CLAUDE.md b/CLAUDE.md index 708da805..51157976 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ code. L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.set_props(ids, props)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit) diff --git a/Cargo.lock b/Cargo.lock index 459c8320..d2052d22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -742,6 +742,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1499,6 +1508,7 @@ name = "figcraft-collab" version = "0.9.0" dependencies = [ "base64 0.22.1", + "chrono", "figcraft-color", "figcraft-doc", "figcraft-geom", @@ -1744,6 +1754,7 @@ name = "figcraft-ui-egui" version = "0.9.0" dependencies = [ "base64 0.22.1", + "chrono", "egui", "egui_extras", "figcraft-color", diff --git a/Cargo.toml b/Cargo.toml index 6cc17546..7bc0b082 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,8 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } regex = "1.13" log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" imbl = { version = "7", features = ["serde"] } postcard = { version = "1", default-features = false, features = ["alloc"] } diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 89a79687..ed85201d 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -13,6 +13,7 @@ figcraft-doc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } imbl = { workspace = true } base64 = { workspace = true } postcard = { workspace = true } diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index d035042b..71e69461 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -699,19 +699,8 @@ pub enum ServerMsg { /// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these /// messages). pub fn utc_date(secs: u64) -> String { - let days = i64::try_from(secs / 86_400).unwrap_or(0); - let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); - // Days to a civil date (proleptic Gregorian). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let mo = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(mo <= 2); - format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC") + let t = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default(); + t.format("%Y-%m-%d %H:%M UTC").to_string() } /// The binary form of `msg`: [`WIRE_VERSION`], then postcard. @@ -752,3 +741,19 @@ pub fn decode_json(text: &str, max_bytes: usize) -> Result< } serde_json::from_str(text).map_err(|e| Error::Decode(e.to_string())) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `utc_date` keeps the exact `YYYY-MM-DD HH:MM UTC` output the hand-rolled civil-from-days + /// code produced (leap days and all). + #[test] + fn utc_date_formats_seconds_since_1970() { + assert_eq!(utc_date(0), "1970-01-01 00:00 UTC"); + assert_eq!(utc_date(1_000_000), "1970-01-12 13:46 UTC"); + assert_eq!(utc_date(951_782_400), "2000-02-29 00:00 UTC"); + assert_eq!(utc_date(1_709_164_800), "2024-02-29 00:00 UTC"); + assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC"); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 65a3592d..09cd2226 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -22,6 +22,7 @@ egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } base64 = { workspace = true } image = { workspace = true } egui_extras = { workspace = true } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 81d68240..dcfd1aa2 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::path::Path; +use chrono::Datelike; use egui::{Align2, Color32, CornerRadius, Id, Key, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_engine::Prefs; use figcraft_format::thumbnail::{FileKind, Preview}; @@ -202,16 +203,8 @@ const MONTHS: [&str; 12] = [ /// `secs` since 1970 as a date in UTC: "Mar 4, 2026". fn date(secs: u64) -> String { - // Days to a civil date (Howard Hinnant's days_from_civil, inverted). - let z = i64::try_from(secs / 86_400).unwrap_or(0) + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = yoe + era * 400 + i64::from(month <= 2); + let dt = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default().naive_utc(); + let (year, month, day) = (i64::from(dt.year()), i64::from(dt.month()), i64::from(dt.day())); let name = usize::try_from(month - 1).ok().and_then(|m| MONTHS.get(m)).map_or("", |m| tr(m)); trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)]) } @@ -965,3 +958,18 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 arrows(ui, recents, files, 0, 1); action } + +#[cfg(test)] +mod tests { + use super::*; + + /// `date` keeps the exact "Mon D, YYYY" (UTC) output the hand-rolled days-from-civil code + /// produced. With no catalog loaded the month and format strings pass through in English. + #[test] + fn date_formats_seconds_since_1970_in_utc() { + assert_eq!(date(0), "Jan 1, 1970"); + assert_eq!(date(951_782_400), "Feb 29, 2000"); + assert_eq!(date(1_709_164_800), "Feb 29, 2024"); + assert_eq!(date(1_741_046_400), "Mar 4, 2025"); + } +} From 630e0dd4034e41058863696a3579ecfe8abf37ae Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 09:11:43 -0400 Subject: [PATCH 5/8] Keep 5-digit years unprefixed in utc_date chrono's %Y writes a '+' before years past 9999, whereas the old hand-rolled {y:04} did not. utc_date now writes the year with {:04} to keep the exact old output. from_timestamp already reads a clock past chrono's range as the epoch rather than panicking. Tests cover the 5-digit-year and out-of-range cases. --- crates/collab/src/message.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 71e69461..fb14924a 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -697,10 +697,12 @@ pub enum ServerMsg { } /// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these -/// messages). +/// messages). A time past the last date chrono can represent (year 262143) reads as the epoch. pub fn utc_date(secs: u64) -> String { + use chrono::Datelike; let t = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default(); - t.format("%Y-%m-%d %H:%M UTC").to_string() + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old `{y:04}` did not). + format!("{:04}-{}", t.year(), t.format("%m-%d %H:%M UTC")) } /// The binary form of `msg`: [`WIRE_VERSION`], then postcard. @@ -755,5 +757,9 @@ mod tests { assert_eq!(utc_date(951_782_400), "2000-02-29 00:00 UTC"); assert_eq!(utc_date(1_709_164_800), "2024-02-29 00:00 UTC"); assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC"); + // A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did. + assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC"); + // Past chrono's range reads as the epoch instead of panicking. + assert_eq!(utc_date(u64::MAX), "1970-01-01 00:00 UTC"); } } From e3169e5a61401d4981ddbd43c10a5cc9a32c2ace Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 17:18:14 -0400 Subject: [PATCH 6/8] collab/ui-egui: keep old UTC output past chrono's year range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `utc_date` (collab) and `date` (ui-egui) took `u64` seconds, which the old hand-rolled civil-from-days code converted for the whole `u64` range. The chrono rewrite used `from_timestamp(..).unwrap_or_default()`, so any instant past chrono's own year limit (262143) silently became the epoch — changing existing output. `u64::MAX` seconds, for example, used to read `584554051223-11-09 07:00 UTC` / `Nov 9, 584554051223`, not 1970. Fix: add a `utc_parts(days, sod, nanos)` helper per crate that moves the instant into chrono's range by whole 400-year Gregorian cycles (146_097 days), lets chrono do the month/day/time, and restores the real year afterwards. chrono stays the only calendar logic; the cycle count just shifts the year, and every `u64` day count fits in `i64` so the conversion is always exact (no epoch fallback). Split seconds with `u64` arithmetic first to avoid the `i64` seconds overflow near `u64::MAX`, matching the old code. Tests now pin the old output instead of the epoch: - collab utc_date(u64::MAX) == "584554051223-11-09 07:00 UTC" (and utc_date(253_402_300_800) == "10000-01-01 00:00 UTC", already pinned) - ui-egui date(253_402_300_800) == "Jan 1, 10000" date(u64::MAX) == "Nov 9, 584554051223" --- crates/collab/src/message.rs | 43 ++++++++++++++++++++++++------ crates/ui-egui/src/home_recents.rs | 37 ++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index fb14924a..4fda776a 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -696,13 +696,38 @@ pub enum ServerMsg { }, } -/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these -/// messages). A time past the last date chrono can represent (year 262143) reads as the epoch. -pub fn utc_date(secs: u64) -> String { +/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock +/// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400 +/// years (146_097 days), so month, day and time of day come from chrono and only the year is +/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar +/// math: chrono still does the date, the cycle count just shifts the year. +fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) { use chrono::Datelike; - let t = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default(); - // `{:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old `{y:04}` did not). - format!("{:04}-{}", t.year(), t.format("%m-%d %H:%M UTC")) + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999)); + match (date, time) { + (Some(d), Some(t)) => { + 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.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + +/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these +/// messages). Years past chrono's own range (262143) keep converting: the instant is moved into +/// range by whole 400-year cycles and the real year restored, so every `u64` reads as the date the +/// old hand-rolled conversion produced (e.g. `u64::MAX` → `584554051223-11-09 07:00 UTC`). +pub fn utc_date(secs: u64) -> String { + // The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), and the + // seconds-of-day is < 86_400, so the conversion is always exact (no epoch fallback). Splitting + // with `u64` arithmetic (as the old code did) avoids the `i64` seconds overflow near `u64::MAX`. + let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32); + let (y, t) = utc_parts(days, sod, 0); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old code did not). + format!("{y:04}-{}", t.format("%m-%d %H:%M UTC")) } /// The binary form of `msg`: [`WIRE_VERSION`], then postcard. @@ -759,7 +784,9 @@ mod tests { assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC"); // A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did. assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC"); - // Past chrono's range reads as the epoch instead of panicking. - assert_eq!(utc_date(u64::MAX), "1970-01-01 00:00 UTC"); + // Past chrono's own year range (262143) the instant is moved into range by whole 400-year + // cycles and the real year restored, so it keeps the old hand-rolled output instead of + // falling back to the epoch. + assert_eq!(utc_date(u64::MAX), "584554051223-11-09 07:00 UTC"); } } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index dcfd1aa2..ca50d78f 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -201,10 +201,35 @@ const MONTHS: [&str; 12] = [ tr_noop("Dec"), ]; -/// `secs` since 1970 as a date in UTC: "Mar 4, 2026". +/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock +/// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400 +/// years (146_097 days), so month, day and time of day come from chrono and only the year is +/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar +/// math: chrono still does the date, the cycle count just shifts the year. +fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) { + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999)); + match (date, time) { + (Some(d), Some(t)) => { + 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.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + +/// `secs` since 1970 as a date in UTC: "Mar 4, 2026". Years past chrono's own range (262143) keep +/// converting: the instant is moved into range by whole 400-year cycles and the real year restored, +/// so every `u64` reads as the date the old hand-rolled conversion produced. fn date(secs: u64) -> String { - let dt = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default().naive_utc(); - let (year, month, day) = (i64::from(dt.year()), i64::from(dt.month()), i64::from(dt.day())); + // The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), so the + // conversion is always exact (no epoch fallback). Splitting with `u64` arithmetic (as the old + // code did) avoids the `i64` seconds overflow near `u64::MAX`. + let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32); + let (year, dt) = utc_parts(days, sod, 0); + let (month, day) = (i64::from(dt.month()), i64::from(dt.day())); let name = usize::try_from(month - 1).ok().and_then(|m| MONTHS.get(m)).map_or("", |m| tr(m)); trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)]) } @@ -971,5 +996,11 @@ mod tests { assert_eq!(date(951_782_400), "Feb 29, 2000"); assert_eq!(date(1_709_164_800), "Feb 29, 2024"); assert_eq!(date(1_741_046_400), "Mar 4, 2025"); + // A year past chrono's own `NaiveDate` range (262143): the instant is moved into range by + // whole 400-year cycles and the real year restored. + assert_eq!(date(253_402_300_800), "Jan 1, 10000"); + // Past chrono's year range, `u64::MAX` keeps the old hand-rolled output instead of falling + // back to the epoch. + assert_eq!(date(u64::MAX), "Nov 9, 584554051223"); } } From a0e937918c0f3923570a68933924ba44f0af6fef Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 18:29:17 -0400 Subject: [PATCH 7/8] 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. --- AGENTS.md | 2 +- CLAUDE.md | 2 +- Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 51157976..80c55db5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ code. L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.set_props(ids, props)`. Colours come from `theme::Tokens`. -- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month, day and time, and add the cycles back to the year. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit) diff --git a/CLAUDE.md b/CLAUDE.md index 51157976..80c55db5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ code. L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.set_props(ids, props)`. Colours come from `theme::Tokens`. -- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month, day and time, and add the cycles back to the year. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit) diff --git a/Cargo.toml b/Cargo.toml index 7bc0b082..b6fb2704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } regex = "1.13" log = "0.4" -# All date/time conversion goes through chrono (no hand-rolled calendar code; AGENTS.md). +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" imbl = { version = "7", features = ["serde"] } From 9ec80bf2c2b6ad8eb34c2fec374ccab4a8219e0d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 19:07:03 -0400 Subject: [PATCH 8/8] 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. --- AGENTS.md | 2 +- CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80c55db5..b2384311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ code. L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.set_props(ids, props)`. Colours come from `theme::Tokens`. -- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month, day and time, and add the cycles back to the year. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit) diff --git a/CLAUDE.md b/CLAUDE.md index 80c55db5..b2384311 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ code. L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.set_props(ids, props)`. Colours come from `theme::Tokens`. -- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month, day and time, and add the cycles back to the year. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit)