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