Skip to main content

badterm_core/
update.rs

1//! Checking for a newer BadTerm: one HTTPS request for the newest release
2//! on the project's Forgejo, made only when the user has turned the check
3//! on. It sends nothing but the request itself (with a "BadTerm/x.y.z"
4//! user agent).
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use anyhow::{Context, Result, anyhow, bail};
10use rustls::pki_types::ServerName;
11use rustls::{ClientConfig, RootCertStore};
12use tokio::io::{AsyncReadExt, AsyncWriteExt};
13use tokio::net::TcpStream;
14use tokio_rustls::TlsConnector;
15
16const HOST: &str = "git.badcluster.dev";
17const PATH: &str = "/api/v1/repos/arelas/badterm/releases?limit=1";
18/// Where people download BadTerm.
19pub const DOWNLOAD_PAGE: &str = "https://badterm.com/";
20const TIMEOUT: Duration = Duration::from_secs(20);
21/// More than any release listing needs.
22const MAX_RESPONSE: usize = 1 << 20;
23
24/// The newest release: its version, without the "v".
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct Release {
27    pub version: String,
28}
29
30/// Ask for the newest release. Blocks; run it on its own thread.
31pub fn latest(current: &str) -> Result<Release> {
32    let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
33    let body = rt
34        .block_on(async { tokio::time::timeout(TIMEOUT, get(current)).await })
35        .map_err(|_| anyhow!("timed out"))??;
36    parse(&body)
37}
38
39/// Whether `candidate` ("0.1.8") is a later version than `current`.
40pub fn is_newer(candidate: &str, current: &str) -> bool {
41    match (version(candidate), version(current)) {
42        (Some(a), Some(b)) => a > b,
43        _ => false,
44    }
45}
46
47fn version(v: &str) -> Option<Vec<u64>> {
48    v.trim().trim_start_matches('v').split('.').map(|n| n.parse().ok()).collect()
49}
50
51fn parse(body: &[u8]) -> Result<Release> {
52    let releases: serde_json::Value = serde_json::from_slice(body).context("reading the release list")?;
53    let tag = releases
54        .get(0)
55        .and_then(|r| r.get("tag_name"))
56        .and_then(|t| t.as_str())
57        .ok_or_else(|| anyhow!("no releases listed"))?;
58    Ok(Release { version: tag.trim_start_matches('v').to_string() })
59}
60
61async fn get(current: &str) -> Result<Vec<u8>> {
62    let roots = RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
63    let provider = Arc::new(rustls::crypto::ring::default_provider());
64    let config = ClientConfig::builder_with_provider(provider)
65        .with_safe_default_protocol_versions()?
66        .with_root_certificates(roots)
67        .with_no_client_auth();
68    let tcp = TcpStream::connect((HOST, 443)).await.with_context(|| format!("connecting to {HOST}"))?;
69    let name = ServerName::try_from(HOST)?;
70    let mut tls = TlsConnector::from(Arc::new(config)).connect(name, tcp).await?;
71    // HTTP/1.0: the reply ends when the connection does.
72    let request = format!(
73        "GET {PATH} HTTP/1.0\r\nHost: {HOST}\r\nUser-Agent: BadTerm/{current}\r\nAccept: application/json\r\n\r\n"
74    );
75    tls.write_all(request.as_bytes()).await?;
76    let mut response = Vec::new();
77    let mut buf = [0u8; 8192];
78    loop {
79        match tls.read(&mut buf).await {
80            Ok(0) => break,
81            Ok(n) => response.extend_from_slice(&buf[..n]),
82            // Servers often close without TLS's close_notify.
83            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
84            Err(e) => return Err(e.into()),
85        }
86        if response.len() > MAX_RESPONSE {
87            bail!("reply too long");
88        }
89    }
90    body(&response)
91}
92
93/// The body of an HTTP response, checking for a 200 and undoing chunked
94/// transfer encoding if the server used it anyway.
95fn body(response: &[u8]) -> Result<Vec<u8>> {
96    let split = response.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| anyhow!("no HTTP headers"))?;
97    let head = String::from_utf8_lossy(&response[..split]).to_ascii_lowercase();
98    let status = head.lines().next().unwrap_or("");
99    if status.split_whitespace().nth(1) != Some("200") {
100        bail!("the server answered {status}");
101    }
102    let mut body = &response[split + 4..];
103    if !head.lines().any(|l| l.starts_with("transfer-encoding:") && l.contains("chunked")) {
104        return Ok(body.to_vec());
105    }
106    let mut out = Vec::new();
107    loop {
108        let end = body.windows(2).position(|w| w == b"\r\n").ok_or_else(|| anyhow!("bad chunk"))?;
109        let size_text = String::from_utf8_lossy(&body[..end]);
110        let size = usize::from_str_radix(size_text.split(';').next().unwrap_or("").trim(), 16)?;
111        body = &body[end + 2..];
112        if size == 0 {
113            return Ok(out);
114        }
115        let chunk = body.get(..size).ok_or_else(|| anyhow!("short chunk"))?;
116        out.extend_from_slice(chunk);
117        body = body.get(size + 2..).unwrap_or(&[]);
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn versions() {
127        assert!(is_newer("0.1.8", "0.1.7"));
128        assert!(is_newer("v0.2.0", "0.1.10"));
129        assert!(is_newer("0.1.10", "0.1.9"));
130        assert!(!is_newer("0.1.7", "0.1.7"));
131        assert!(!is_newer("0.1.6", "0.1.7"));
132        assert!(!is_newer("nightly", "0.1.7"));
133    }
134
135    #[test]
136    fn responses() {
137        let json = br#"[{"tag_name":"v0.1.8","name":"v0.1.8"}]"#;
138        let plain = [b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n".as_slice(), json].concat();
139        assert_eq!(parse(&body(&plain).unwrap()).unwrap().version, "0.1.8");
140        let chunked = [
141            b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n".as_slice(),
142            format!("{:x}\r\n", 10).as_bytes(),
143            &json[..10],
144            b"\r\n",
145            format!("{:x}\r\n", json.len() - 10).as_bytes(),
146            &json[10..],
147            b"\r\n0\r\n\r\n",
148        ]
149        .concat();
150        assert_eq!(body(&chunked).unwrap(), json);
151        assert!(body(b"HTTP/1.1 404 Not Found\r\n\r\n").is_err());
152        assert!(parse(b"[]").is_err());
153    }
154}