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
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)