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:   FSL-1.1-ALv2
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) => log_version_response(&config, &resp),
181                Err(e) => {
182                    tracing::warn!(error = %e, "version check failed (non-fatal)");
183                }
184            }
185        });
186    }
187}
188
189// ============================================================================
190// Request / response types
191// ============================================================================
192
193/// Payload sent to the version check API.
194///
195/// Intentionally minimal. Pre-2.7.5 the payload also included:
196///   - `instance_id`: a persistent UUID disk-stored in `~/.cache/hyperi/`.
197///     Effectively a tracking cookie that survived restarts. Dropped --
198///     too aggressive for an OSS library's default behaviour. Operators
199///     who want a stable identifier can set one themselves via
200///     `VersionCheckConfig` (not currently exposed; can be added if a
201///     real need emerges).
202///   - `deployment`: free-form string from the operator's config
203///     (`"production-east"`, etc.). Operators sometimes embed sensitive
204///     names; dropping by default. Field stays on `VersionCheckConfig`
205///     for forward-compat but is no longer sent.
206///
207/// Kept: `product`, `current_version`, `os` (family -- Linux/Darwin/
208/// Windows), `arch` (x86_64/aarch64). Enough signal for "which versions
209/// are running on which platforms"; zero personal data.
210#[derive(Debug, Serialize)]
211struct CheckPayload {
212    product: String,
213    current_version: String,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    os: Option<String>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    arch: Option<String>,
218}
219
220/// Response from the version check API.
221#[derive(Debug, Deserialize)]
222pub struct VersionCheckResponse {
223    /// Latest available version (e.g., "1.9.0").
224    pub latest_version: Option<String>,
225    /// Whether an update is available.
226    pub update_available: bool,
227    /// URL to the release page.
228    pub release_url: Option<String>,
229    /// When the latest version was published (ISO 8601).
230    pub published_at: Option<String>,
231    /// Optional message from the server.
232    pub message: Option<String>,
233}
234
235// ============================================================================
236// Internal helpers
237// ============================================================================
238
239/// Environment-variable opt-out check. Returns true if the user has
240/// disabled telemetry via `HYPERI_TELEMETRY=off|0|false|no`.
241fn telemetry_opted_out() -> bool {
242    std::env::var("HYPERI_TELEMETRY").is_ok_and(|v| {
243        let l = v.to_ascii_lowercase();
244        matches!(l.as_str(), "off" | "0" | "false" | "no" | "disabled")
245    })
246}
247
248/// Once-per-process announcement of the telemetry call. The first
249/// time we make a version check, log loudly what gets sent and how
250/// to opt out. Subsequent calls stay quiet (this is `info!`, not
251/// `warn!`, so log level filtering still applies).
252fn announce_once(config: &VersionCheckConfig) {
253    static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
254    ANNOUNCED.get_or_init(|| {
255        tracing::info!(
256            endpoint = %config.api_url,
257            "version check telemetry: sending anonymous {{product, current_version, os, arch}} to endpoint; \
258             set HYPERI_TELEMETRY=off to disable"
259        );
260    });
261}
262
263/// Perform the HTTP version check.
264async fn do_version_check(
265    config: &VersionCheckConfig,
266) -> Result<VersionCheckResponse, VersionCheckError> {
267    if telemetry_opted_out() {
268        return Err(VersionCheckError::Http(
269            "telemetry opted out via HYPERI_TELEMETRY env var".into(),
270        ));
271    }
272
273    announce_once(config);
274
275    let payload = CheckPayload {
276        product: config.product.clone(),
277        current_version: config.current_version.clone(),
278        os: Some(std::env::consts::OS.into()),
279        arch: Some(std::env::consts::ARCH.into()),
280    };
281
282    let client = reqwest::Client::builder()
283        .timeout(config.timeout)
284        .build()
285        .map_err(|e| VersionCheckError::Http(e.to_string()))?;
286
287    let resp = client
288        .post(&config.api_url)
289        .json(&payload)
290        .send()
291        .await
292        .map_err(|e| VersionCheckError::Http(e.to_string()))?;
293
294    if !resp.status().is_success() {
295        return Err(VersionCheckError::Http(format!("HTTP {}", resp.status())));
296    }
297
298    resp.json::<VersionCheckResponse>()
299        .await
300        .map_err(|e| VersionCheckError::Parse(e.to_string()))
301}
302
303/// Log the version check response at the appropriate level.
304fn log_version_response(config: &VersionCheckConfig, resp: &VersionCheckResponse) {
305    if resp.update_available {
306        if let Some(ref latest) = resp.latest_version {
307            let age = resp
308                .published_at
309                .as_deref()
310                .and_then(format_age)
311                .unwrap_or_default();
312
313            tracing::info!(
314                product = %config.product,
315                current = %config.current_version,
316                latest = %latest,
317                age = %age,
318                url = resp.release_url.as_deref().unwrap_or(""),
319                "new version available"
320            );
321        }
322    } else {
323        tracing::debug!(
324            product = %config.product,
325            version = %config.current_version,
326            "running latest version"
327        );
328    }
329
330    if let Some(ref msg) = resp.message
331        && !msg.is_empty()
332    {
333        tracing::info!(product = %config.product, "{msg}");
334    }
335}
336
337/// Format an ISO 8601 timestamp into a human-readable age string.
338///
339/// Returns `None` if the timestamp cannot be parsed.
340fn format_age(published_at: &str) -> Option<String> {
341    // Parse ISO 8601 with timezone (e.g., "2026-01-15T10:00:00Z")
342    // Try with timezone first, then without
343    let published = published_at
344        .parse::<chrono::DateTime<chrono::Utc>>()
345        .or_else(|_| {
346            chrono::NaiveDateTime::parse_from_str(published_at, "%Y-%m-%dT%H:%M:%S")
347                .map(|dt| dt.and_utc())
348        })
349        .ok()?;
350
351    let now = chrono::Utc::now();
352    let duration = now.signed_duration_since(published);
353
354    let days = duration.num_days();
355    if days < 0 {
356        return Some("just released".into());
357    }
358    if days == 0 {
359        return Some("released today".into());
360    }
361    if days == 1 {
362        return Some("released 1 day ago".into());
363    }
364    if days < 30 {
365        return Some(format!("released {days} days ago"));
366    }
367    let months = days / 30;
368    if months == 1 {
369        return Some("released 1 month ago".into());
370    }
371    if months < 12 {
372        return Some(format!("released {months} months ago"));
373    }
374    let years = months / 12;
375    let remaining_months = months % 12;
376    if remaining_months == 0 {
377        Some(format!("released {years}y ago"))
378    } else {
379        Some(format!("released {years}y {remaining_months}m ago"))
380    }
381}
382
383// Persistent-instance-id helpers removed in 2.7.5.
384//
385// Previously this file maintained a UUID at
386// `~/.cache/hyperi/instance_id` and included it in every telemetry
387// payload. The persistent identifier survived restarts, IP changes,
388// and process recycles -- functionally indistinguishable from a
389// long-lived tracking cookie. For an open-source library the friction
390// it created for SOC2 / regulated consumers far exceeded the fleet-
391// uniqueness signal value. Removed in the pre-GA hardening pass.
392
393/// Errors during version check (internal, never exposed to caller).
394#[derive(Debug)]
395enum VersionCheckError {
396    Http(String),
397    Parse(String),
398}
399
400impl std::fmt::Display for VersionCheckError {
401    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402        match self {
403            Self::Http(e) => write!(f, "http: {e}"),
404            Self::Parse(e) => write!(f, "parse: {e}"),
405        }
406    }
407}
408
409// ============================================================================
410// Tests
411// ============================================================================
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn test_default_config() {
419        let config = VersionCheckConfig::default();
420        assert_eq!(config.api_url, DEFAULT_API_URL);
421        assert_eq!(config.timeout, Duration::from_secs(5));
422        assert!(!config.disabled);
423        assert!(config.product.is_empty());
424    }
425
426    #[test]
427    fn telemetry_opt_out_recognises_common_values() {
428        // `temp_env::with_var` scopes env mutation to the closure and
429        // restores the previous value on drop -- required because the
430        // crate has `#![deny(unsafe_code)]` and edition 2024 forbids
431        // direct `std::env::set_var` without `unsafe { }`.
432        for v in ["off", "Off", "OFF", "0", "false", "False", "no", "disabled"] {
433            temp_env::with_var("HYPERI_TELEMETRY", Some(v), || {
434                assert!(telemetry_opted_out(), "value `{v}` should opt out");
435            });
436        }
437        for v in ["on", "1", "true", ""] {
438            temp_env::with_var("HYPERI_TELEMETRY", Some(v), || {
439                assert!(!telemetry_opted_out(), "value `{v}` should NOT opt out");
440            });
441        }
442        temp_env::with_var_unset("HYPERI_TELEMETRY", || {
443            assert!(!telemetry_opted_out(), "absent var should NOT opt out");
444        });
445    }
446
447    #[test]
448    fn check_payload_omits_dropped_fields() {
449        // The payload struct itself no longer has instance_id or deployment
450        // -- this test enforces that by serialising and checking the JSON
451        // shape. A future change that re-adds them will fail here.
452        let payload = CheckPayload {
453            product: "dfe-loader".into(),
454            current_version: "1.0.0".into(),
455            os: Some("linux".into()),
456            arch: Some("x86_64".into()),
457        };
458        let json = serde_json::to_string(&payload).unwrap();
459        assert!(!json.contains("instance_id"));
460        assert!(!json.contains("deployment"));
461        assert!(json.contains("product"));
462        assert!(json.contains("current_version"));
463        assert!(json.contains("\"os\":\"linux\""));
464        assert!(json.contains("\"arch\":\"x86_64\""));
465    }
466
467    #[test]
468    fn test_check_payload_serialization() {
469        let payload = CheckPayload {
470            product: "dfe-loader".into(),
471            current_version: "1.8.0".into(),
472            os: Some("linux".into()),
473            arch: Some("x86_64".into()),
474        };
475
476        let json = serde_json::to_value(&payload).unwrap();
477        assert_eq!(json["product"], "dfe-loader");
478        assert_eq!(json["current_version"], "1.8.0");
479        assert_eq!(json["os"], "linux");
480        assert_eq!(json["arch"], "x86_64");
481        // Dropped fields must not reappear.
482        assert!(json.get("instance_id").is_none());
483        assert!(json.get("deployment").is_none());
484    }
485
486    #[test]
487    fn test_response_deserialization() {
488        let json = r#"{
489            "latest_version": "1.9.0",
490            "update_available": true,
491            "release_url": "https://github.com/hyperi-io/dfe-loader/releases/tag/v1.9.0",
492            "published_at": "2026-02-15T10:00:00Z",
493            "message": null
494        }"#;
495
496        let resp: VersionCheckResponse = serde_json::from_str(json).unwrap();
497        assert!(resp.update_available);
498        assert_eq!(resp.latest_version.as_deref(), Some("1.9.0"));
499        assert_eq!(resp.published_at.as_deref(), Some("2026-02-15T10:00:00Z"));
500        assert!(resp.message.is_none());
501    }
502
503    #[test]
504    fn test_response_no_update() {
505        let json = r#"{
506            "latest_version": "1.8.0",
507            "update_available": false,
508            "release_url": null,
509            "published_at": null,
510            "message": null
511        }"#;
512
513        let resp: VersionCheckResponse = serde_json::from_str(json).unwrap();
514        assert!(!resp.update_available);
515    }
516
517    #[test]
518    fn test_format_age_today() {
519        let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
520        let age = format_age(&now).unwrap();
521        assert_eq!(age, "released today");
522    }
523
524    #[test]
525    fn test_format_age_days() {
526        let ten_days_ago = (chrono::Utc::now() - chrono::Duration::days(10))
527            .format("%Y-%m-%dT%H:%M:%SZ")
528            .to_string();
529        let age = format_age(&ten_days_ago).unwrap();
530        assert_eq!(age, "released 10 days ago");
531    }
532
533    #[test]
534    fn test_format_age_months() {
535        let three_months_ago = (chrono::Utc::now() - chrono::Duration::days(90))
536            .format("%Y-%m-%dT%H:%M:%SZ")
537            .to_string();
538        let age = format_age(&three_months_ago).unwrap();
539        assert_eq!(age, "released 3 months ago");
540    }
541
542    #[test]
543    fn test_format_age_invalid() {
544        assert!(format_age("not-a-date").is_none());
545    }
546
547    #[test]
548    fn test_disabled_does_not_spawn() {
549        let checker = VersionCheck::new(VersionCheckConfig {
550            disabled: true,
551            ..Default::default()
552        });
553        // Should return immediately without panic (no tokio runtime needed)
554        checker.check_on_startup();
555    }
556
557    #[test]
558    fn test_empty_product_does_not_spawn() {
559        let checker = VersionCheck::new(VersionCheckConfig::default());
560        // Should return immediately without panic
561        checker.check_on_startup();
562    }
563}