use std::time::Duration;
use anyhow::{Context, bail};
use bytes::{Bytes, BytesMut};
use semver::Version;
pub(crate) const REPO_BASE: &str = "https://github.com/decdn/decdn";
pub(crate) fn releases_base() -> String {
base_from(std::env::var("DECDN_RELEASES_BASE").ok().as_deref())
}
pub(crate) fn base_from(raw: Option<&str>) -> String {
match raw.map(str::trim).filter(|s| !s.is_empty()) {
Some(base) => base.trim_end_matches('/').to_string(),
None => REPO_BASE.to_string(),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ArchiveKind {
TarGz,
Zip,
}
impl ArchiveKind {
pub(crate) const fn binary_name(self) -> &'static str {
match self {
Self::TarGz => "decdn",
Self::Zip => "decdn.exe",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Target {
X86_64LinuxGnu,
Aarch64LinuxGnu,
X86_64Darwin,
Aarch64Darwin,
X86_64WindowsMsvc,
Aarch64WindowsMsvc,
}
impl Target {
pub(crate) const fn current() -> Option<Self> {
if cfg!(all(
target_arch = "x86_64",
target_os = "linux",
target_env = "gnu"
)) {
Some(Self::X86_64LinuxGnu)
} else if cfg!(all(
target_arch = "aarch64",
target_os = "linux",
target_env = "gnu"
)) {
Some(Self::Aarch64LinuxGnu)
} else if cfg!(all(target_arch = "x86_64", target_os = "macos")) {
Some(Self::X86_64Darwin)
} else if cfg!(all(target_arch = "aarch64", target_os = "macos")) {
Some(Self::Aarch64Darwin)
} else if cfg!(all(
target_arch = "x86_64",
target_os = "windows",
target_env = "msvc"
)) {
Some(Self::X86_64WindowsMsvc)
} else if cfg!(all(
target_arch = "aarch64",
target_os = "windows",
target_env = "msvc"
)) {
Some(Self::Aarch64WindowsMsvc)
} else {
None
}
}
pub(crate) const fn triple(self) -> &'static str {
match self {
Self::X86_64LinuxGnu => "x86_64-unknown-linux-gnu",
Self::Aarch64LinuxGnu => "aarch64-unknown-linux-gnu",
Self::X86_64Darwin => "x86_64-apple-darwin",
Self::Aarch64Darwin => "aarch64-apple-darwin",
Self::X86_64WindowsMsvc => "x86_64-pc-windows-msvc",
Self::Aarch64WindowsMsvc => "aarch64-pc-windows-msvc",
}
}
pub(crate) const fn kind(self) -> ArchiveKind {
match self {
Self::X86_64WindowsMsvc | Self::Aarch64WindowsMsvc => ArchiveKind::Zip,
Self::X86_64LinuxGnu
| Self::Aarch64LinuxGnu
| Self::X86_64Darwin
| Self::Aarch64Darwin => ArchiveKind::TarGz,
}
}
pub(crate) fn archive_name(self, version: &Version) -> String {
let ext = match self.kind() {
ArchiveKind::TarGz => "tar.gz",
ArchiveKind::Zip => "zip",
};
format!("decdn-{version}-{}.{ext}", self.triple())
}
}
#[derive(Debug)]
pub(crate) struct Manifest {
entries: Vec<(String, String)>,
}
impl Manifest {
pub(crate) fn parse(text: &str) -> anyhow::Result<Self> {
let mut entries = Vec::new();
for (idx, raw) in text.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim_end_matches('\r');
if line.is_empty() {
continue;
}
let malformed = || format!("SHA256SUMS line {line_no}: not `<sha256> <name>`");
let (digest, rest) = line.split_once(' ').with_context(malformed)?;
if digest.len() != 64
|| !digest
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
{
bail!("SHA256SUMS line {line_no}: not a lowercase sha256 hex digest");
}
let name = rest
.strip_prefix(' ')
.or_else(|| rest.strip_prefix('*'))
.with_context(malformed)?;
if name.is_empty() {
bail!("SHA256SUMS line {line_no}: empty file name");
}
entries.push((digest.to_string(), name.to_string()));
}
Ok(Self { entries })
}
pub(crate) fn digest_for(&self, name: &str) -> Option<&str> {
self.entries
.iter()
.find(|(_, n)| n == name)
.map(|(d, _)| d.as_str())
}
}
pub(crate) fn tag_from_location(location: &str) -> Option<String> {
let url = url::Url::parse(location)
.or_else(|_| url::Url::parse(REPO_BASE)?.join(location))
.ok()?;
let mut segments = url.path().split('/').filter(|s| !s.is_empty());
while let Some(seg) = segments.next() {
if seg == "releases" {
if segments.next() != Some("download") {
return None;
}
let tag = segments.next()?;
segments.next()?; return Some(tag.to_string());
}
}
None
}
pub(crate) fn version_from_tag(tag: &str) -> anyhow::Result<Version> {
let bare = tag
.strip_prefix('v')
.with_context(|| format!("release tag {tag:?} does not start with `v`"))?;
Version::parse(bare).with_context(|| format!("release tag {tag:?} is not a version"))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Upgrade {
Newer,
UpToDate,
Downgrade,
PreRelease,
}
pub(crate) fn decide(running: &Version, latest: &Version) -> Upgrade {
if !latest.pre.is_empty() {
Upgrade::PreRelease
} else if latest > running {
Upgrade::Newer
} else if latest == running {
Upgrade::UpToDate
} else {
Upgrade::Downgrade
}
}
pub(crate) fn running_version() -> anyhow::Result<Version> {
Version::parse(env!("CARGO_PKG_VERSION")).context("this build's version is not semver")
}
pub(crate) struct Clients {
follow: reqwest::Client,
no_redirect: reqwest::Client,
}
impl Clients {
pub(crate) fn build(timeout: Duration) -> anyhow::Result<Self> {
let base = || {
reqwest::Client::builder()
.user_agent(concat!("decdn/", env!("CARGO_PKG_VERSION")))
.connect_timeout(timeout.min(Duration::from_secs(10)))
.timeout(timeout)
};
Ok(Self {
follow: base().build().context("build HTTP client")?,
no_redirect: base()
.redirect(reqwest::redirect::Policy::none())
.build()
.context("build HTTP client")?,
})
}
}
pub(crate) async fn resolve_latest(clients: &Clients, base: &str) -> anyhow::Result<String> {
let url = format!("{base}/releases/latest/download/SHA256SUMS");
let resp = clients
.no_redirect
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_redirection() {
bail!("GET {url}: expected a redirect to the latest release, got {status}");
}
let location = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.with_context(|| format!("GET {url}: {status} without a Location header"))?;
tag_from_location(location)
.with_context(|| format!("GET {url}: redirect {location:?} names no release tag"))
}
pub(crate) async fn fetch_asset(
clients: &Clients,
base: &str,
tag: &str,
name: &str,
max_bytes: usize,
) -> anyhow::Result<Bytes> {
let url = format!("{base}/releases/download/{tag}/{name}");
let mut resp = clients
.follow
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_success() {
bail!("GET {url}: {status}");
}
if let Some(len) = resp.content_length()
&& usize::try_from(len).is_ok_and(|len| len > max_bytes)
{
bail!("GET {url}: declares {len} bytes, larger than the {max_bytes}-byte limit");
}
let mut body = BytesMut::new();
while let Some(chunk) = resp
.chunk()
.await
.with_context(|| format!("GET {url}: read body"))?
{
if body.len().saturating_add(chunk.len()) > max_bytes {
bail!("GET {url}: body is larger than the {max_bytes}-byte limit");
}
body.extend_from_slice(&chunk);
}
Ok(body.freeze())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests;