1use 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";
18pub const DOWNLOAD_PAGE: &str = "https://badterm.com/";
20const TIMEOUT: Duration = Duration::from_secs(20);
21const MAX_RESPONSE: usize = 1 << 20;
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct Release {
27 pub version: String,
28}
29
30pub 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
39pub 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 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 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
93fn 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}