Skip to main content

hyperi_rustlib/version_check/
mod.rs

1// Project:   hyperi-rustlib
2// File:      src/version_check/mod.rs
3// Purpose:   Startup version check against HyperI version API
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Startup version check.
10//!
11//! Calls the HyperI version API on startup to check if a newer version is
12//! available. The check is non-blocking, fire-and-forget, and gracefully
13//! handles all failure modes (network errors, timeouts, bad responses).
14//!
15//! # Usage
16//!
17//! ```rust,no_run
18//! use hyperi_rustlib::version_check::{VersionCheck, VersionCheckConfig};
19//!
20//! #[tokio::main]
21//! async fn main() {
22//!     let checker = VersionCheck::new(VersionCheckConfig {
23//!         product: "dfe-loader".into(),
24//!         current_version: env!("CARGO_PKG_VERSION").into(),
25//!         ..Default::default()
26//!     });
27//!
28//!     // Fire-and-forget -- spawns a background task, never blocks startup
29//!     checker.check_on_startup();
30//! }
31//! ```
32
33use std::time::Duration;
34
35use serde::{Deserialize, Serialize};
36
37/// Default version check API endpoint.
38const DEFAULT_API_URL: &str = "https://releases.hyperi.io/api/v1/check";
39
40/// Default HTTP timeout for the version check.
41const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
42
43/// Configuration for the startup version check.
44///
45/// When the `config` feature is enabled, this can be loaded from the config
46/// cascade under the `version_check` key:
47///
48/// ```yaml
49/// version_check:
50///   api_url: "https://releases.hyperi.io/api/v1/check"
51///   timeout_secs: 5
52///   disabled: false
53/// ```
54///
55/// `product` and `current_version` are always set programmatically -- they
56/// come from the binary, not from config files.
57#[derive(Debug, Clone, Deserialize, Serialize)]
58pub struct VersionCheckConfig {
59    /// Product identifier (e.g., "dfe-loader", "dfe-receiver").
60    #[serde(default)]
61    pub product: String,
62    /// Current version of this product (e.g., "1.8.0").
63    #[serde(default)]
64    pub current_version: String,
65    /// Deployment type (e.g., "k8s", "docker", "bare").
66    #[serde(default)]
67    pub deployment: Option<String>,
68    /// API endpoint URL. Defaults to the HyperI version API.
69    #[serde(default = "default_api_url")]
70    pub api_url: String,
71    /// HTTP request timeout in seconds.
72    #[serde(default = "default_timeout", with = "duration_secs")]
73    pub timeout: Duration,
74    /// Disable the version check entirely.
75    #[serde(default)]
76    pub disabled: bool,
77}
78
79fn default_api_url() -> String {
80    DEFAULT_API_URL.into()
81}
82
83fn default_timeout() -> Duration {
84    DEFAULT_TIMEOUT
85}
86
87/// Serde helper to serialise `Duration` as seconds (u64).
88mod duration_secs {
89    use std::time::Duration;
90
91    use serde::{Deserialize, Deserializer, Serializer};
92
93    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
94        s.serialize_u64(d.as_secs())
95    }
96
97    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
98        let secs = u64::deserialize(d)?;
99        Ok(Duration::from_secs(secs))
100    }
101}
102
103impl Default for VersionCheckConfig {
104    fn default() -> Self {
105        Self {
106            product: String::new(),
107            current_version: String::new(),
108            deployment: None,
109            api_url: default_api_url(),
110            timeout: DEFAULT_TIMEOUT,
111            disabled: false,
112        }
113    }
114}
115
116impl VersionCheckConfig {
117    /// Load from the config cascade, then overlay product/version.
118    ///
119    /// Reads the `version_check` key from the cascade for `api_url`,
120    /// `timeout`, and `disabled`. The `product` and `current_version`
121    /// fields are always set from the provided arguments (they come
122    /// from the binary, not from config files).
123    #[must_use]
124    pub fn from_cascade(product: &str, current_version: &str) -> Self {
125        let mut config = Self::cascade_base();
126        config.product = product.into();
127        config.current_version = current_version.into();
128        config
129    }
130
131    /// Load just the cascade portion (api_url, timeout, disabled).
132    fn cascade_base() -> Self {
133        #[cfg(feature = "config")]
134        {
135            if let Some(cfg) = crate::config::try_get()
136                && let Ok(vc) = cfg.unmarshal_key_registered::<Self>("version_check")
137            {
138                return vc;
139            }
140        }
141        Self::default()
142    }
143}
144
145/// Startup version checker.
146///
147/// Call [`VersionCheck::check_on_startup`] during application init to spawn
148/// a background task that checks for newer versions. The check never blocks
149/// the main thread and gracefully handles all errors.
150#[derive(Debug, Clone)]
151pub struct VersionCheck {
152    config: VersionCheckConfig,
153}
154
155impl VersionCheck {
156    /// Create a new version checker with the given configuration.
157    #[must_use]
158    pub fn new(config: VersionCheckConfig) -> Self {
159        Self { config }
160    }
161
162    /// Spawn a background task to check for a newer version.
163    ///
164    /// This method returns immediately. The check runs asynchronously and
165    /// logs the result. Any errors are logged at warn level and swallowed.
166    pub fn check_on_startup(&self) {
167        if self.config.disabled {
168            tracing::debug!("version check disabled");
169            return;
170        }
171
172        if self.config.product.is_empty() || self.config.current_version.is_empty() {
173            tracing::debug!("version check skipped: product or version not set");
174            return;
175        }
176
177        let config = self.config.clone();
178        tokio::spawn(async move {
179            match do_version_check(&config).await {
180                Ok(resp) => {
181                    // New default (metrics audit): bounded-enum `result` label
182                    // (ok/stale/error) -- NEVER the version string (unbounded
183                    // cardinality). NOTE: emitted with a bare name; the spec's
184                    // `{ns}_` prefix needs a MetricsManager handle threaded into
185                    // version_check, deferred to avoid touching the runtime here.
186                    #[cfg(any(feature = "metrics", feature = "otel-metrics"))]
187                    {
188                        let result = if resp.update_available { "stale" } else { "ok" };
189                        metrics::counter!("version_check_total", "result" => result).increment(1);
190                    }
191                    log_version_response(&config, &resp);
192                }
193                Err(e) => {
194                    #[cfg(any(feature = "metrics", feature = "otel-metrics"))]
195                    metrics::counter!("version_check_total", "result" => "error").increment(1);
196                    tracing::warn!(error = %e, "version check failed (non-fatal)");
197                }
198            }
199        });
200    }
201}
202
203// ============================================================================
204// Request / response types
205// ============================================================================
206
207/// Payload sent to the version check API.
208///
209/// Intentionally minimal. Pre-2.7.5 the payload also included:
210///   - `instance_id`: a persistent UUID disk-stored in `~/.cache/hyperi/`.
211///     Effectively a tracking cookie that survived restarts. Dropped --
212///     too aggressive for an OSS library's default behaviour. Operators
213///     who want a stable identifier can set one themselves via
214///     `VersionCheckConfig` (not currently exposed; can be added if a
215///     real need emerges).
216///   - `deployment`: free-form string from the operator's config
217///     (`"production-east"`, etc.). Operators sometimes embed sensitive
218///     names; dropping by default. Field stays on `VersionCheckConfig`
219///     for forward-compat but is no longer sent.
220///
221/// Kept: `product`, `current_version`, `os` (family -- Linux/Darwin/
222/// Windows), `arch` (x86_64/aarch64). Enough signal for "which versions
223/// are running on which platforms"; zero personal data.
224#[derive(Debug, Serialize)]
225struct CheckPayload {
226    product: String,
227    current_version: String,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    os: Option<String>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    arch: Option<String>,
232}
233
234/// Response from the version check API.
235#[derive(Debug, Deserialize)]
236pub struct VersionCheckResponse {
237    /// Latest available version (e.g., "1.9.0").
238    pub latest_version: Option<String>,
239    /// Whether an update is available.
240    pub update_available: bool,
241    /// URL to the release page.
242    pub release_url: Option<String>,
243    /// When the latest version was published (ISO 8601).
244    pub published_at: Option<String>,
245    /// Optional message from the server.
246    pub message: Option<String>,
247}
248
249// ============================================================================
250// Internal helpers
251// ============================================================================
252
253/// Environment-variable opt-out check. Returns true if the user has
254/// disabled telemetry via `HYPERI_TELEMETRY=off|0|false|no`.
255fn telemetry_opted_out() -> bool {
256    std::env::var("HYPERI_TELEMETRY").is_ok_and(|v| {
257        let l = v.to_ascii_lowercase();
258        matches!(l.as_str(), "off" | "0" | "false" | "no" | "disabled")
259    })
260}
261
262/// Once-per-process announcement of the telemetry call. The first
263/// time we make a version check, log loudly what gets sent and how
264/// to opt out. Subsequent calls stay quiet (this is `info!`, not
265/// `warn!`, so log level filtering still applies).
266fn announce_once(config: &VersionCheckConfig) {
267    static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
268    ANNOUNCED.get_or_init(|| {
269        tracing::info!(
270            endpoint = %config.api_url,
271            "version check telemetry: sending anonymous {{product, current_version, os, arch}} to endpoint; \
272             set HYPERI_TELEMETRY=off to disable"
273        );
274    });
275}
276
277/// Perform the HTTP version check.
278async fn do_version_check(
279    config: &VersionCheckConfig,
280) -> Result<VersionCheckResponse, VersionCheckError> {
281    if telemetry_opted_out() {
282        return Err(VersionCheckError::Http(
283            "telemetry opted out via HYPERI_TELEMETRY env var".into(),
284        ));
285    }
286
287    announce_once(config);
288
289    let payload = CheckPayload {
290        product: config.product.clone(),
291        current_version: config.current_version.clone(),
292        os: Some(std::env::consts::OS.into()),
293        arch: Some(std::env::consts::ARCH.into()),
294    };
295
296    let client = reqwest::Client::builder()
297        .timeout(config.timeout)
298        .build()
299        .map_err(|e| VersionCheckError::Http(e.to_string()))?;
300
301    let resp = client
302        .post(&config.api_url)
303        .json(&payload)
304        .send()
305        .await
306        .map_err(|e| VersionCheckError::Http(e.to_string()))?;
307
308    if !resp.status().is_success() {
309        return Err(VersionCheckError::Http(format!("HTTP {}", resp.status())));
310    }
311
312    resp.json::<VersionCheckResponse>()
313        .await
314        .map_err(|e| VersionCheckError::Parse(e.to_string()))
315}
316
317/// Log the version check response at the appropriate level.
318fn log_version_response(config: &VersionCheckConfig, resp: &VersionCheckResponse) {
319    if resp.update_available {
320        if let Some(ref latest) = resp.latest_version {
321            let age = resp
322                .published_at
323                .as_deref()
324                .and_then(format_age)
325                .unwrap_or_default();
326
327            tracing::info!(
328                product = %config.product,
329                current = %config.current_version,
330                latest = %latest,
331                age = %age,
332                url = resp.release_url.as_deref().unwrap_or(""),
333                "new version available"
334            );
335        }
336    } else {
337        tracing::debug!(
338            product = %config.product,
339            version = %config.current_version,
340            "running latest version"
341        );
342    }
343
344    if let Some(ref msg) = resp.message
345        && !msg.is_empty()
346    {
347        tracing::info!(product = %config.product, "{msg}");
348    }
349}
350
351/// Format an ISO 8601 timestamp into a human-readable age string.
352///
353/// Returns `None` if the timestamp cannot be parsed.
354fn format_age(published_at: &str) -> Option<String> {
355    // Parse ISO 8601 with timezone (e.g., "2026-01-15T10:00:00Z")
356    // Try with timezone first, then without
357    let published = published_at
358        .parse::<chrono::DateTime<chrono::Utc>>()
359        .or_else(|_| {
360            chrono::NaiveDateTime::parse_from_str(published_at, "%Y-%m-%dT%H:%M:%S")
361                .map(|dt| dt.and_utc())
362        })
363        .ok()?;
364
365    let now = chrono::Utc::now();
366    let duration = now.signed_duration_since(published);
367
368    let days = duration.num_days();
369    if days < 0 {
370        return Some("just released".into());
371    }
372    if days == 0 {
373        return Some("released today".into());
374    }
375    if days == 1 {
376        return Some("released 1 day ago".into());
377    }
378    if days < 30 {
379        return Some(format!("released {days} days ago"));
380    }
381    let months = days / 30;
382    if months == 1 {
383        return Some("released 1 month ago".into());
384    }
385    if months < 12 {
386        return Some(format!("released {months} months ago"));
387    }
388    let years = months / 12;
389    let remaining_months = months % 12;
390    if remaining_months == 0 {
391        Some(format!("released {years}y ago"))
392    } else {
393        Some(format!("released {years}y {remaining_months}m ago"))
394    }
395}
396
397// Persistent-instance-id helpers removed in 2.7.5 -- the disk-stored
398// UUID was a tracking cookie in all but name (see CheckPayload above).
399// Friction for SOC2 / regulated consumers outweighed the fleet-
400// uniqueness signal.
401
402/// Errors during version check (internal, never exposed to caller).
403#[derive(Debug)]
404enum VersionCheckError {
405    Http(String),
406    Parse(String),
407}
408
409impl std::fmt::Display for VersionCheckError {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        match self {
412            Self::Http(e) => write!(f, "http: {e}"),
413            Self::Parse(e) => write!(f, "parse: {e}"),
414        }
415    }
416}
417
418// ============================================================================
419// Tests
420// ============================================================================
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_default_config() {
428        let config = VersionCheckConfig::default();
429        assert_eq!(config.api_url, DEFAULT_API_URL);
430        assert_eq!(config.timeout, Duration::from_secs(5));
431        assert!(!config.disabled);
432        assert!(config.product.is_empty());
433    }
434
435    #[test]
436    fn telemetry_opt_out_recognises_common_values() {
437        // `temp_env::with_var` scopes env mutation to the closure and
438        // restores the previous value on drop -- required because the
439        // crate has `#![deny(unsafe_code)]` and edition 2024 forbids
440        // direct `std::env::set_var` without `unsafe { }`.
441        for v in ["off", "Off", "OFF", "0", "false", "False", "no", "disabled"] {
442            temp_env::with_var("HYPERI_TELEMETRY", Some(v), || {
443                assert!(telemetry_opted_out(), "value `{v}` should opt out");
444            });
445        }
446        for v in ["on", "1", "true", ""] {
447            temp_env::with_var("HYPERI_TELEMETRY", Some(v), || {
448                assert!(!telemetry_opted_out(), "value `{v}` should NOT opt out");
449            });
450        }
451        temp_env::with_var_unset("HYPERI_TELEMETRY", || {
452            assert!(!telemetry_opted_out(), "absent var should NOT opt out");
453        });
454    }
455
456    #[test]
457    fn check_payload_omits_dropped_fields() {
458        // The payload struct itself no longer has instance_id or deployment
459        // -- this test enforces that by serialising and checking the JSON
460        // shape. A future change that re-adds them will fail here.
461        let payload = CheckPayload {
462            product: "dfe-loader".into(),
463            current_version: "1.0.0".into(),
464            os: Some("linux".into()),
465            arch: Some("x86_64".into()),
466        };
467        let json = serde_json::to_string(&payload).unwrap();
468        assert!(!json.contains("instance_id"));
469        assert!(!json.contains("deployment"));
470        assert!(json.contains("product"));
471        assert!(json.contains("current_version"));
472        assert!(json.contains("\"os\":\"linux\""));
473        assert!(json.contains("\"arch\":\"x86_64\""));
474    }
475
476    #[test]
477    fn test_check_payload_serialization() {
478        let payload = CheckPayload {
479            product: "dfe-loader".into(),
480            current_version: "1.8.0".into(),
481            os: Some("linux".into()),
482            arch: Some("x86_64".into()),
483        };
484
485        let json = serde_json::to_value(&payload).unwrap();
486        assert_eq!(json["product"], "dfe-loader");
487        assert_eq!(json["current_version"], "1.8.0");
488        assert_eq!(json["os"], "linux");
489        assert_eq!(json["arch"], "x86_64");
490        // Dropped fields must not reappear.
491        assert!(json.get("instance_id").is_none());
492        assert!(json.get("deployment").is_none());
493    }
494
495    #[test]
496    fn test_response_deserialization() {
497        let json = r#"{
498            "latest_version": "1.9.0",
499            "update_available": true,
500            "release_url": "https://github.com/hyperi-io/dfe-loader/releases/tag/v1.9.0",
501            "published_at": "2026-02-15T10:00:00Z",
502            "message": null
503        }"#;
504
505        let resp: VersionCheckResponse = serde_json::from_str(json).unwrap();
506        assert!(resp.update_available);
507        assert_eq!(resp.latest_version.as_deref(), Some("1.9.0"));
508        assert_eq!(resp.published_at.as_deref(), Some("2026-02-15T10:00:00Z"));
509        assert!(resp.message.is_none());
510    }
511
512    #[test]
513    fn test_response_no_update() {
514        let json = r#"{
515            "latest_version": "1.8.0",
516            "update_available": false,
517            "release_url": null,
518            "published_at": null,
519            "message": null
520        }"#;
521
522        let resp: VersionCheckResponse = serde_json::from_str(json).unwrap();
523        assert!(!resp.update_available);
524    }
525
526    #[test]
527    fn test_format_age_today() {
528        let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
529        let age = format_age(&now).unwrap();
530        assert_eq!(age, "released today");
531    }
532
533    #[test]
534    fn test_format_age_days() {
535        let ten_days_ago = (chrono::Utc::now() - chrono::Duration::days(10))
536            .format("%Y-%m-%dT%H:%M:%SZ")
537            .to_string();
538        let age = format_age(&ten_days_ago).unwrap();
539        assert_eq!(age, "released 10 days ago");
540    }
541
542    #[test]
543    fn test_format_age_months() {
544        let three_months_ago = (chrono::Utc::now() - chrono::Duration::days(90))
545            .format("%Y-%m-%dT%H:%M:%SZ")
546            .to_string();
547        let age = format_age(&three_months_ago).unwrap();
548        assert_eq!(age, "released 3 months ago");
549    }
550
551    #[test]
552    fn test_format_age_invalid() {
553        assert!(format_age("not-a-date").is_none());
554    }
555
556    #[test]
557    fn test_disabled_does_not_spawn() {
558        let checker = VersionCheck::new(VersionCheckConfig {
559            disabled: true,
560            ..Default::default()
561        });
562        // Should return immediately without panic (no tokio runtime needed)
563        checker.check_on_startup();
564    }
565
566    #[test]
567    fn test_empty_product_does_not_spawn() {
568        let checker = VersionCheck::new(VersionCheckConfig::default());
569        // Should return immediately without panic
570        checker.check_on_startup();
571    }
572}