Skip to main content

strand_version_check/
lib.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Client for the strand-braid version-check service.
5//!
6//! Both Strand Camera and Braid periodically ask
7//! `https://version-check.strawlab.org/<product>` whether a newer release is
8//! available. This crate centralizes that network call so each application only
9//! has to decide how to surface the result. The wire format is:
10//!
11//! ```json
12//! { "available": "1.0.0-rc.3", "message": "...", "url": "https://..." }
13//! ```
14//!
15//! All three fields are required; a response missing any of them fails to parse
16//! and is treated as "no update available".
17
18use std::time::Duration;
19
20use bytes::Bytes;
21use http_body_util::{combinators::BoxBody, BodyExt, Full};
22use hyper_rustls::HttpsConnector;
23use hyper_util::{
24    client::legacy::{connect::HttpConnector, Client},
25    rt::TokioExecutor,
26};
27use serde::Deserialize;
28use tracing::warn;
29
30type Body = BoxBody<Bytes, std::convert::Infallible>;
31
32fn empty_body() -> Body {
33    Full::new(Bytes::new())
34        .map_err(|never| match never {})
35        .boxed()
36}
37
38/// A newer available version, as reported by the version-check server.
39#[derive(Debug, Clone, PartialEq)]
40pub struct AvailableVersion {
41    /// The newest available version.
42    pub version: semver::Version,
43    /// Human-readable message from the server.
44    pub message: String,
45    /// URL with release notes / downloads.
46    pub url: String,
47}
48
49/// Reusable client for the version-check service.
50///
51/// Construct once and reuse for every check: it holds a connection pool, so
52/// rebuilding it each time would be wasteful.
53#[derive(Clone)]
54pub struct VersionChecker {
55    client: Client<HttpsConnector<HttpConnector>, Body>,
56}
57
58impl Default for VersionChecker {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl VersionChecker {
65    /// Build a version checker with its own HTTPS client.
66    pub fn new() -> Self {
67        let https = hyper_rustls::HttpsConnectorBuilder::new()
68            .with_webpki_roots()
69            .https_only()
70            .enable_http1()
71            .build();
72        let client = Client::builder(TokioExecutor::new()).build::<_, Body>(https);
73        Self { client }
74    }
75
76    /// Ask the server which version is available for `product` (e.g. `"braid"`
77    /// or `"strand-cam"`), identifying ourselves with `user_agent`.
78    ///
79    /// Returns `Some` only on a successful, parseable response. Network errors,
80    /// timeouts, non-200 responses, and parse errors are logged and yield
81    /// `None`, so a failing check never disrupts the caller.
82    pub async fn fetch(&self, product: &str, user_agent: &str) -> Option<AvailableVersion> {
83        #[derive(Debug, Deserialize)]
84        struct VersionResponse {
85            available: semver::Version,
86            message: String,
87            url: String,
88        }
89
90        let url = format!("https://version-check.strawlab.org/{product}");
91        let uri: hyper::Uri = match url.parse() {
92            Ok(uri) => uri,
93            Err(e) => {
94                warn!("invalid version-check URL {url}: {e}");
95                return None;
96            }
97        };
98
99        let req = hyper::Request::builder()
100            .uri(&uri)
101            .header(hyper::header::USER_AGENT, user_agent)
102            .body(empty_body())
103            .unwrap();
104
105        // Bound the request so a hung connection cannot wedge the checker.
106        let res =
107            match tokio::time::timeout(Duration::from_secs(30), self.client.request(req)).await {
108                Ok(Ok(res)) => res,
109                Ok(Err(e)) => {
110                    warn!("version check request to {url} failed: {e}");
111                    return None;
112                }
113                Err(_elapsed) => {
114                    warn!("version check request to {url} timed out");
115                    return None;
116                }
117            };
118
119        if res.status() != hyper::StatusCode::OK {
120            return None;
121        }
122
123        let data = match res.into_body().collect().await {
124            Ok(collected) => collected.to_bytes(),
125            Err(e) => {
126                warn!("could not read version response from {url}: {e}");
127                return None;
128            }
129        };
130
131        match serde_json::from_slice::<VersionResponse>(&data) {
132            Ok(v) => Some(AvailableVersion {
133                version: v.available,
134                message: v.message,
135                url: v.url,
136            }),
137            Err(e) => {
138                warn!("could not parse version response JSON from {url}: {e}");
139                None
140            }
141        }
142    }
143}