use std::io::{self, Read, Write};
use std::net::TcpStream;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::paths::write_atomic;
const DAY: i64 = 24 * 60 * 60;
const HOST: &str = "api.github.com";
const LATEST: &str = "/repos/postvak-app/postvak/releases/latest";
const TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Cached {
pub checked_at: i64,
pub latest: Option<String>,
}
#[derive(Deserialize)]
struct Release {
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
tag_name: String,
}
pub fn parse_version(text: &str) -> Option<[u64; 3]> {
let text = text.strip_prefix('v').unwrap_or(text);
let mut parts = text.split('.').map(|part| part.parse::<u64>().ok());
let version = [parts.next()??, parts.next()??, parts.next()??];
parts.next().is_none().then_some(version)
}
pub fn latest_from_json(json: &str) -> Option<String> {
let release: Release = serde_json::from_str(json).ok()?;
if release.draft || release.prerelease {
return None;
}
let [major, minor, patch] = parse_version(&release.tag_name)?;
Some(format!("{major}.{minor}.{patch}"))
}
pub fn newer(latest: &str, current: &str) -> bool {
matches!((parse_version(latest), parse_version(current)), (Some(latest), Some(current)) if latest > current)
}
pub fn release_url(version: &str) -> String {
format!("https://github.com/postvak-app/postvak/releases/tag/v{version}")
}
pub fn due(cached: Option<&Cached>, now: i64) -> bool {
cached.is_none_or(|cached| now - cached.checked_at >= DAY || cached.checked_at > now)
}
pub fn load(path: &Path) -> Option<Cached> {
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
pub fn save(path: &Path, cached: &Cached) -> io::Result<()> {
write_atomic(path, serde_json::to_string(cached)?.as_bytes())
}
pub fn check(
cache: &Path,
now: i64,
current: &str,
fetch: fn() -> anyhow::Result<String>,
) -> Option<String> {
let cached = load(cache);
let latest = if due(cached.as_ref(), now) {
let latest = match fetch() {
Ok(body) => latest_from_json(&body),
Err(e) => {
log::debug!("update check failed: {e:#}");
cached.and_then(|cached| cached.latest)
}
};
let fresh = Cached {
checked_at: now,
latest: latest.clone(),
};
if let Err(e) = save(cache, &fresh) {
log::debug!("could not save the update check: {e}");
}
latest
} else {
cached.and_then(|cached| cached.latest)
};
latest.filter(|latest| newer(latest, current))
}
pub fn fetch() -> anyhow::Result<String> {
let tcp = TcpStream::connect((HOST, 443))?;
tcp.set_read_timeout(Some(TIMEOUT))?;
tcp.set_write_timeout(Some(TIMEOUT))?;
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
let name = rustls::pki_types::ServerName::try_from(HOST)?;
let mut tls =
rustls::StreamOwned::new(rustls::ClientConnection::new(Arc::new(config), name)?, tcp);
let request = format!(
"GET {LATEST} HTTP/1.0\r\nHost: {HOST}\r\nUser-Agent: postvak/{}\r\nAccept: application/vnd.github+json\r\n\r\n",
env!("CARGO_PKG_VERSION")
);
tls.write_all(request.as_bytes())?;
let mut response = Vec::new();
match tls.read_to_end(&mut response) {
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof && !response.is_empty() => {}
Err(e) => return Err(e.into()),
}
Ok(body_of(&String::from_utf8(response)?)?.to_string())
}
pub fn body_of(response: &str) -> anyhow::Result<&str> {
let (head, body) = response
.split_once("\r\n\r\n")
.ok_or_else(|| anyhow::anyhow!("no end of headers"))?;
let status = head.lines().next().unwrap_or_default();
anyhow::ensure!(
status.split(' ').nth(1) == Some("200"),
"GitHub answered {status}"
);
Ok(body)
}
#[cfg(test)]
mod tests {
use super::*;
const DAY_AGO: i64 = 1_790_000_000;
const NOW: i64 = DAY_AGO + DAY;
fn release(tag: &str, draft: bool, prerelease: bool) -> String {
format!(
r#"{{"html_url":"https://github.com/postvak-app/postvak/releases/tag/{tag}","tag_name":"{tag}","draft":{draft},"prerelease":{prerelease},"assets":[]}}"#
)
}
fn release_0_3_0() -> anyhow::Result<String> {
Ok(release("v0.3.0", false, false))
}
fn release_5_0_0() -> anyhow::Result<String> {
Ok(release("v5.0.0", false, false))
}
fn offline() -> anyhow::Result<String> {
Err(anyhow::anyhow!("offline"))
}
fn cache_in(dir: &tempfile::TempDir) -> std::path::PathBuf {
crate::paths::Paths::under(dir.path()).update_check()
}
#[test]
fn parse_version_takes_plain_versions_with_or_without_v() {
assert_eq!(parse_version("v0.3.0"), Some([0, 3, 0]));
assert_eq!(parse_version("1.12.7"), Some([1, 12, 7]));
assert_eq!(parse_version("v0.3.0-rc.1"), None);
assert_eq!(parse_version("v1.2"), None);
assert_eq!(parse_version("v1.2.3.4"), None);
assert_eq!(parse_version(""), None);
assert_eq!(parse_version(" v1.2.3"), None);
}
#[test]
fn newer_compares_numbers_not_strings() {
assert!(newer("0.10.0", "0.9.0"));
assert!(newer("1.0.0", "0.99.99"));
assert!(!newer("0.2.0", "0.2.0"));
assert!(!newer("0.1.9", "0.2.0"));
assert!(!newer("garbage", "0.2.0"));
}
#[test]
fn only_stable_plain_versions_are_announced() {
assert_eq!(
latest_from_json(&release("v0.3.0", false, false)).as_deref(),
Some("0.3.0")
);
assert_eq!(
latest_from_json(&release("0.3.0", false, false)).as_deref(),
Some("0.3.0")
);
assert_eq!(latest_from_json(&release("v0.3.0", true, false)), None);
assert_eq!(latest_from_json(&release("v0.3.0", false, true)), None);
assert_eq!(
latest_from_json(&release("v0.3.0-rc.1", false, false)),
None
);
assert_eq!(
latest_from_json(&release("v1.2.3/../../evil", false, false)),
None
);
assert_eq!(
latest_from_json(&release(
"v123456789012345678901234567890.0.0",
false,
false
)),
None
);
assert_eq!(
latest_from_json(r#"{"message":"API rate limit exceeded"}"#),
None
);
assert_eq!(latest_from_json("<html>"), None);
assert_eq!(
release_url("0.3.0"),
"https://github.com/postvak-app/postvak/releases/tag/v0.3.0"
);
}
#[test]
fn due_when_missing_old_or_in_the_future() {
let cached = |checked_at| Cached {
checked_at,
latest: None,
};
assert!(due(None, NOW));
assert!(!due(Some(&cached(NOW - 60 * 60)), NOW));
assert!(due(Some(&cached(DAY_AGO)), NOW));
assert!(
due(Some(&cached(NOW + 60)), NOW),
"a clock that went back must not block checks"
);
}
#[test]
fn a_fresh_cache_answers_without_fetching() {
let dir = tempfile::tempdir().unwrap();
let cache = cache_in(&dir);
save(
&cache,
&Cached {
checked_at: NOW - 60,
latest: Some("0.3.0".into()),
},
)
.unwrap();
assert_eq!(
check(&cache, NOW, "0.2.0", release_5_0_0).as_deref(),
Some("0.3.0")
);
}
#[test]
fn a_due_check_fetches_and_caches_the_result() {
let dir = tempfile::tempdir().unwrap();
let cache = cache_in(&dir);
assert_eq!(
check(&cache, NOW, "0.2.0", release_0_3_0).as_deref(),
Some("0.3.0")
);
assert_eq!(
load(&cache),
Some(Cached {
checked_at: NOW,
latest: Some("0.3.0".into())
})
);
assert_eq!(
check(&cache, NOW + 60, "0.2.0", release_5_0_0).as_deref(),
Some("0.3.0")
);
assert_eq!(
check(&cache, NOW + DAY, "0.2.0", release_5_0_0).as_deref(),
Some("5.0.0")
);
}
#[test]
fn a_failed_fetch_counts_as_the_check_and_keeps_the_last_version() {
let dir = tempfile::tempdir().unwrap();
let cache = cache_in(&dir);
assert_eq!(check(&cache, NOW, "0.2.0", offline), None);
assert_eq!(
load(&cache),
Some(Cached {
checked_at: NOW,
latest: None
})
);
save(
&cache,
&Cached {
checked_at: DAY_AGO,
latest: Some("0.3.0".into()),
},
)
.unwrap();
assert_eq!(
check(&cache, NOW, "0.2.0", offline).as_deref(),
Some("0.3.0")
);
assert_eq!(
load(&cache),
Some(Cached {
checked_at: NOW,
latest: Some("0.3.0".into())
})
);
}
#[test]
fn a_cached_version_the_user_already_runs_is_not_announced() {
let dir = tempfile::tempdir().unwrap();
let cache = cache_in(&dir);
save(
&cache,
&Cached {
checked_at: NOW - 60,
latest: Some("0.3.0".into()),
},
)
.unwrap();
assert_eq!(check(&cache, NOW, "0.3.0", release_5_0_0), None);
assert_eq!(check(&cache, NOW, "0.4.0", release_5_0_0), None);
}
#[test]
fn a_corrupt_cache_file_means_a_new_check() {
let dir = tempfile::tempdir().unwrap();
let cache = cache_in(&dir);
crate::paths::write_atomic(&cache, b"{not json").unwrap();
assert_eq!(load(&cache), None);
assert_eq!(
check(&cache, NOW, "0.2.0", release_0_3_0).as_deref(),
Some("0.3.0")
);
}
#[test]
fn body_of_takes_a_200_body_and_rejects_other_statuses() {
let ok = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nconnection: close\r\n\r\n{\"tag_name\":\"v0.3.0\"}";
assert_eq!(body_of(ok).unwrap(), "{\"tag_name\":\"v0.3.0\"}");
let limited =
"HTTP/1.1 403 rate limit exceeded\r\n\r\n{\"message\":\"API rate limit exceeded\"}";
assert!(body_of(limited).unwrap_err().to_string().contains("403"));
assert!(body_of("HTTP/1.1 200 OK\r\nContent-Type: application/json").is_err());
assert!(body_of("").is_err());
}
}