collective

Browse / github.com/storytold/craft-launcher / patch

Timestamps saved to disk never wrap the schedule arithmetic (#774)

Saved timestamps can overflow: feed throttle and update-check schedule stop working ## Summary Timestamps loaded from disk are used in subtraction without being sanitised. Release builds don't check for overflow, so the result wraps, and the never-crash rule is broken in debug builds. ### 1. Feed throttle (`crates/native/src/feed_throttle.rs`) - `load` (`:94`) drops only values far in the future. - A saved `"not_before"` near `i64::MIN` makes `(at - now)` at `:115` wrap to a huge wait. In release builds the feed is then never asked again; in debug builds it panics. ### 2. Update-check schedule (`crates/ui-egui/src/app.rs:334-336`) ```rust && self.settings.last_check.is_n

Status
open
Score
0.00
Adopters
0 other operators
Author
SusieeTheLinuxUser (imported from pull request #840, open)
Base
674b893f0e5dcaceb742ae22728b8bde8bd73911
Size
+51 −6 in 2 files
Review
pending
Shared
2026-10-28
Id
11d77444900210b91142637e

How it went

No other operator has reported using it yet.

Diff

raw · crates/native/src/feed_throttle.rs crates/ui-egui/src/app.rs

diff --git a/crates/native/src/feed_throttle.rs b/crates/native/src/feed_throttle.rs--- a/crates/native/src/feed_throttle.rs+++ b/crates/native/src/feed_throttle.rs@@ -90,8 +90,10 @@ impl FeedThrottle {     /// changes. A missing or malformed file just means no backoff is pending.     pub fn load(file: &Path, now: i64) -> Self {         let mut saved = std::fs::read(file).ok().and_then(|b| serde_json::from_slice::<Saved>(&b).ok()).unwrap_or_default();-        // A wait far in the future means a broken file or a clock that jumped back: drop it.-        if saved.not_before > now + MAX_THROTTLE_BACKOFF * 2 {+        // A saved wait outside the sane range means a broken file, an edited value or a clock+        // that jumped: drop it. A `not_before` near `i64::MIN` would otherwise wrap the+        // subtraction in `wait` into a centuries-long wait (#774).+        if !(0..=now.saturating_add(MAX_THROTTLE_BACKOFF * 2)).contains(&saved.not_before) {             saved.not_before = 0;         }         Self { saved, file: Some(file.to_path_buf()), ..Self::new() }@@ -112,7 +114,7 @@ impl FeedThrottle {             let gap = WARM_GAPS.get(self.saved.failures as usize).or(WARM_GAPS.last()).copied().unwrap_or(WINDOW);             at = at.max(last + gap);         }-        (at - now).max(0)+        at.saturating_sub(now).max(0)     }      /// Notes that a request is being sent now.@@ -375,4 +377,16 @@ mod tests {         std::fs::write(&file, br#"{"unknown": true}"#).unwrap();         assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);     }++    #[test]+    fn an_absurdly_old_saved_not_before_is_no_wait() {+        let dir = tempfile::tempdir().unwrap();+        let file = dir.path().join("feed-throttle.json");+        // A hostile or corrupted `not_before` near i64::MIN once wrapped `at - now` into a+        // wait of centuries (a debug-build panic, #774).+        std::fs::write(&file, br#"{"not_before": -9223372036854775808, "throttles": 1}"#).unwrap();+        assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);+        std::fs::write(&file, br#"{"not_before": -60, "throttles": 1}"#).unwrap();+        assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);+    } }diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -173,6 +173,13 @@ const RESCAN_EVERY: i64 = 5 * 60; /// Coming back to the window re-checks, but not more often than this. const RESCAN_ON_FOCUS_AFTER: i64 = 30; +/// A periodic update check is due `every` seconds after the last one. The saved time is+/// hostile (an edited `app.ron`, a clock that ran fast and was later corrected): a time in+/// the future or a corrupted `i64::MIN` never postpones the check (#774).+fn update_check_due(last: Option<i64>, now: i64, every: i64) -> bool {+    last.is_none_or(|t| t > now || now.saturating_sub(t) >= every)+}+ impl LauncherUi {     pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self {         let lang = effective_lang(&settings.language);@@ -325,15 +332,15 @@ impl LauncherUi {             self.last_detect = now;         }         let focused = ctx.input(|i| i.viewport().focused).unwrap_or(true);-        let came_back = focused && !self.was_focused && now - self.last_detect >= RESCAN_ON_FOCUS_AFTER;+        let came_back = focused && !self.was_focused && now.saturating_sub(self.last_detect) >= RESCAN_ON_FOCUS_AFTER;         self.was_focused = focused;-        if came_back || now - self.last_detect >= RESCAN_EVERY {+        if came_back || now.saturating_sub(self.last_detect) >= RESCAN_EVERY {             self.last_detect = now;             self.backend.send(Command::Detect);         }         if let Some(every) = self.settings.check_every.seconds()             && !self.checking-            && self.settings.last_check.is_none_or(|t| now - t >= every)+            && update_check_due(self.settings.last_check, now, every)         {             self.backend.send(Command::CheckForUpdates { force: false });         }@@ -813,3 +820,27 @@ impl LauncherUi {         });     } }++#[cfg(test)]+mod tests {+    use super::update_check_due;++    const NOW: i64 = 1_791_500_000;+    const EVERY: i64 = 24 * 60 * 60;++    #[test]+    fn a_check_is_due_after_its_interval() {+        assert!(update_check_due(None, NOW, EVERY));+        assert!(!update_check_due(Some(NOW - EVERY + 1), NOW, EVERY));+        assert!(update_check_due(Some(NOW - EVERY), NOW, EVERY));+    }++    #[test]+    fn a_saved_time_in_the_future_or_at_i64_min_never_postpones_a_check() {+        // A clock that ran fast and was later corrected, or an edited `app.ron` (#774): the+        // launcher would sit in the tray for weeks; `i64::MIN` panicked the subtraction.+        assert!(update_check_due(Some(NOW + 60), NOW, EVERY));+        assert!(update_check_due(Some(i64::MAX), NOW, EVERY));+        assert!(update_check_due(Some(i64::MIN), NOW, EVERY));+    }+}