Skip to main content

codewhale_release/
check.rs

1//! Throttling and suppression for background "is there a newer release?"
2//! checks.
3//!
4//! Two rules govern when we are allowed to ask GitHub:
5//!
6//! 1. **Not in CI, and not when the user said no.** A build agent has no one
7//!    to tell, and an outbound request from a sandboxed runner is at best
8//!    noise and at worst a firewall alert.
9//! 2. **At most once per interval.** The answer changes on release cadence,
10//!    not on launch cadence, so it is cached on disk and reused.
11//!
12//! The cache stores the *answer* (the latest tag), not merely a "checked
13//! recently" flag. That distinction matters: a stale-binary user who relaunches
14//! ten times in an hour should still see the notice every time, while the
15//! network is touched at most once. Caching only a timestamp — and returning
16//! "no update" on a cache hit — would hide the notice for the whole interval,
17//! which is the opposite of what the feature is for.
18
19use std::path::{Path, PathBuf};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use anyhow::{Context, Result};
23use serde::{Deserialize, Serialize};
24
25/// Filename of the on-disk update-check cache, relative to the CodeWhale home
26/// directory (`~/.codewhale/update-check.json` by default).
27pub const UPDATE_CHECK_CACHE_FILE: &str = "update-check.json";
28
29/// Default hours between network update checks.
30pub const DEFAULT_CHECK_INTERVAL_HOURS: u64 = 1;
31
32/// Explicit opt-out, and the `update-notifier` convention many CLIs honour.
33const OPT_OUT_ENV: &[&str] = &["CODEWHALE_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"];
34
35/// Environment variables whose presence means "this is an automated build".
36const CI_ENV: &[&str] = &[
37    "CI",
38    "CONTINUOUS_INTEGRATION",
39    "GITHUB_ACTIONS",
40    "GITLAB_CI",
41    "BUILDKITE",
42    "CIRCLECI",
43    "JENKINS_URL",
44    "TEAMCITY_VERSION",
45    "TF_BUILD",
46];
47
48/// Why an update check was skipped without contacting the network.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SuppressionReason {
51    /// The user (or a wrapper script) set an opt-out variable.
52    OptOut(&'static str),
53    /// A CI marker variable is set.
54    ContinuousIntegration(&'static str),
55}
56
57impl SuppressionReason {
58    /// The environment variable responsible, for logs and `doctor` output.
59    #[must_use]
60    pub fn variable(self) -> &'static str {
61        match self {
62            Self::OptOut(var) | Self::ContinuousIntegration(var) => var,
63        }
64    }
65}
66
67/// Returns the reason update checks are suppressed in this process, if any.
68///
69/// A variable set to an explicitly falsey value (`""`, `"0"`, `"false"`) does
70/// not count as set — some shells and CI images export `CI=false`, and taking
71/// that as "we are in CI" would disable the check for ordinary users.
72#[must_use]
73pub fn suppression_reason() -> Option<SuppressionReason> {
74    for var in OPT_OUT_ENV {
75        if env_flag_is_truthy(var) {
76            return Some(SuppressionReason::OptOut(var));
77        }
78    }
79    for var in CI_ENV {
80        if env_flag_is_truthy(var) {
81            return Some(SuppressionReason::ContinuousIntegration(var));
82        }
83    }
84    None
85}
86
87fn env_flag_is_truthy(var: &str) -> bool {
88    match std::env::var(var) {
89        Ok(value) => flag_value_is_truthy(&value),
90        Err(_) => false,
91    }
92}
93
94fn flag_value_is_truthy(value: &str) -> bool {
95    !matches!(
96        value.trim().to_ascii_lowercase().as_str(),
97        "" | "0" | "false" | "no" | "off"
98    )
99}
100
101/// Cached result of the last network update check.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct UpdateCheckCache {
104    /// Unix seconds at which the network was last queried.
105    pub checked_at_unix: u64,
106    /// The latest release tag seen, or `None` when the check completed but
107    /// produced no usable tag (e.g. the release had no publishable assets).
108    #[serde(default)]
109    pub latest_tag: Option<String>,
110}
111
112impl UpdateCheckCache {
113    /// Record a check that just completed.
114    #[must_use]
115    pub fn now(latest_tag: Option<String>) -> Self {
116        Self {
117            checked_at_unix: now_unix(),
118            latest_tag,
119        }
120    }
121
122    /// Read the cache, returning `None` for "absent, unreadable, or corrupt".
123    ///
124    /// A damaged cache is indistinguishable from no cache for our purposes:
125    /// both mean "we do not know, go ask". Nothing here is worth surfacing an
126    /// error for.
127    #[must_use]
128    pub fn load(path: &Path) -> Option<Self> {
129        let raw = std::fs::read_to_string(path).ok()?;
130        serde_json::from_str(&raw).ok()
131    }
132
133    /// True when this entry is young enough to reuse instead of re-fetching.
134    ///
135    /// A timestamp in the future is treated as stale rather than
136    /// infinitely-fresh, so a clock that jumped forward and back cannot wedge
137    /// the check off permanently.
138    #[must_use]
139    pub fn is_fresh(&self, now_unix: u64, interval_hours: u64) -> bool {
140        if self.checked_at_unix > now_unix {
141            return false;
142        }
143        let age = now_unix - self.checked_at_unix;
144        age < interval_hours.saturating_mul(3600)
145    }
146
147    /// Write the cache atomically (temp file, then rename), creating the
148    /// parent directory if needed.
149    pub fn store(&self, path: &Path) -> Result<()> {
150        if let Some(dir) = path.parent() {
151            std::fs::create_dir_all(dir)
152                .with_context(|| format!("failed to create {}", dir.display()))?;
153        }
154        let tmp = path.with_extension("json.tmp");
155        let body = serde_json::to_vec_pretty(self).context("failed to serialize update cache")?;
156        std::fs::write(&tmp, body).with_context(|| format!("failed to write {}", tmp.display()))?;
157        std::fs::rename(&tmp, path)
158            .with_context(|| format!("failed to install {}", path.display()))?;
159        Ok(())
160    }
161}
162
163/// Resolve the cache path inside a CodeWhale home directory.
164#[must_use]
165pub fn cache_path_in(codewhale_home: &Path) -> PathBuf {
166    codewhale_home.join(UPDATE_CHECK_CACHE_FILE)
167}
168
169/// Current Unix time in seconds, saturating at 0 for pre-epoch clocks.
170#[must_use]
171pub fn now_unix() -> u64 {
172    SystemTime::now()
173        .duration_since(UNIX_EPOCH)
174        .map(|d| d.as_secs())
175        .unwrap_or(0)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn cache_is_fresh_inside_the_interval_and_stale_outside_it() {
184        let entry = UpdateCheckCache {
185            checked_at_unix: 1_000_000,
186            latest_tag: Some("v0.9.5".to_string()),
187        };
188        // One second before the one-hour boundary: still fresh.
189        assert!(entry.is_fresh(1_000_000 + 3599, 1));
190        // One second beyond the boundary: stale.
191        assert!(!entry.is_fresh(1_000_000 + 3601, 1));
192        // Exactly at the boundary counts as stale, so the interval is a true
193        // upper bound on cache age.
194        assert!(!entry.is_fresh(1_000_000 + 3600, 1));
195    }
196
197    #[test]
198    fn a_zero_interval_always_refetches() {
199        let entry = UpdateCheckCache {
200            checked_at_unix: 1_000_000,
201            latest_tag: None,
202        };
203        assert!(!entry.is_fresh(1_000_000, 0));
204    }
205
206    #[test]
207    fn a_future_timestamp_is_stale_not_permanently_fresh() {
208        let entry = UpdateCheckCache {
209            checked_at_unix: 2_000_000,
210            latest_tag: Some("v9.9.9".to_string()),
211        };
212        assert!(!entry.is_fresh(1_000_000, 24));
213    }
214
215    #[test]
216    fn store_then_load_round_trips_and_survives_an_existing_file() {
217        let dir = tempfile::tempdir().expect("tempdir");
218        let path = cache_path_in(dir.path());
219        assert_eq!(path.file_name().unwrap(), UPDATE_CHECK_CACHE_FILE);
220
221        let first = UpdateCheckCache {
222            checked_at_unix: 42,
223            latest_tag: Some("v0.9.5".to_string()),
224        };
225        first.store(&path).expect("store");
226        assert_eq!(UpdateCheckCache::load(&path), Some(first));
227
228        // Overwriting in place must not leave the temp file behind.
229        let second = UpdateCheckCache {
230            checked_at_unix: 99,
231            latest_tag: None,
232        };
233        second.store(&path).expect("overwrite");
234        assert_eq!(UpdateCheckCache::load(&path), Some(second));
235        assert!(!path.with_extension("json.tmp").exists());
236    }
237
238    #[test]
239    fn store_creates_a_missing_home_directory() {
240        let dir = tempfile::tempdir().expect("tempdir");
241        let path = cache_path_in(&dir.path().join("nested").join("home"));
242        UpdateCheckCache::now(Some("v1.0.0".to_string()))
243            .store(&path)
244            .expect("store into a fresh directory");
245        assert!(path.exists());
246    }
247
248    #[test]
249    fn a_corrupt_or_absent_cache_reads_as_none() {
250        let dir = tempfile::tempdir().expect("tempdir");
251        let path = cache_path_in(dir.path());
252        assert_eq!(UpdateCheckCache::load(&path), None);
253        std::fs::write(&path, b"{ not json").expect("write junk");
254        assert_eq!(UpdateCheckCache::load(&path), None);
255    }
256
257    #[test]
258    fn falsey_flag_values_do_not_count_as_set() {
259        for value in ["", "0", "false", "FALSE", " no ", "off"] {
260            assert!(
261                !flag_value_is_truthy(value),
262                "{value:?} should not read as set"
263            );
264        }
265        for value in ["1", "true", "yes", "azure-pipelines"] {
266            assert!(flag_value_is_truthy(value), "{value:?} should read as set");
267        }
268    }
269
270    #[test]
271    fn suppression_reason_names_the_responsible_variable() {
272        assert_eq!(
273            SuppressionReason::OptOut("CODEWHALE_NO_UPDATE_CHECK").variable(),
274            "CODEWHALE_NO_UPDATE_CHECK"
275        );
276        assert_eq!(
277            SuppressionReason::ContinuousIntegration("CI").variable(),
278            "CI"
279        );
280    }
281}