返回 CodeWhale
check.rs
根目录 / crates / release / src / 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
19 use std::path::{Path, PathBuf};
20 use std::time::{SystemTime, UNIX_EPOCH};
21
22 use anyhow::{Context, Result};
23 use 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).
27 pub const UPDATE_CHECK_CACHE_FILE: &str = "update-check.json";
28
29 /// Default hours between network update checks.
30 pub const DEFAULT_CHECK_INTERVAL_HOURS: u64 = 24;
31
32 /// Explicit opt-out, and the `update-notifier` convention many CLIs honour.
33 const OPT_OUT_ENV: &[&str] = &["CODEWHALE_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"];
34
35 /// Environment variables whose presence means "this is an automated build".
36 const 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)]
50 pub 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
57 impl 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]
73 pub 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
87 fn 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
94 fn 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)]
103 pub 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
112 impl 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]
165 pub 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]
171 pub 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)]
179 mod 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 // 23h later: still fresh on a 24h interval.
189 assert!(entry.is_fresh(1_000_000 + 23 * 3600, 24));
190 // 25h later: stale.
191 assert!(!entry.is_fresh(1_000_000 + 25 * 3600, 24));
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 + 24 * 3600, 24));
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 }
282
282 lines RUST