use std::time::SystemTime;
use semver::Version;
use crate::error::{Error, Result};
pub const DELTA_FORMAT_FILES_BSDIFF_V1: &str = "files-bsdiff-v1";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Asset {
pub name: String,
pub url: String,
pub size: u64,
}
impl Asset {
pub fn new(name: impl Into<String>, url: impl Into<String>, size: u64) -> Self {
Self {
name: name.into(),
url: url.into(),
size,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Release {
pub version: Version,
pub notes: Option<String>,
pub asset: Asset,
pub signature: Option<String>,
pub signature_url: Option<String>,
pub sha256: Option<String>,
pub ed25519_signature: Option<String>,
pub deltas: Vec<DeltaAsset>,
pub published_at: Option<SystemTime>,
pub release_url: Option<String>,
}
impl Release {
pub fn new(version: Version, asset: Asset) -> Self {
Self {
version,
notes: None,
asset,
signature: None,
signature_url: None,
sha256: None,
ed25519_signature: None,
deltas: Vec::new(),
published_at: None,
release_url: None,
}
}
#[must_use]
pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
self.notes = Some(notes.into());
self
}
#[must_use]
pub fn with_sha256(mut self, sha256: impl Into<String>) -> Self {
self.sha256 = Some(sha256.into().trim().to_ascii_lowercase());
self
}
#[must_use]
pub fn with_minisign_signature(mut self, signature: impl Into<String>) -> Self {
self.signature = Some(signature.into());
self
}
#[must_use]
pub fn with_minisign_signature_url(mut self, url: impl Into<String>) -> Self {
self.signature_url = Some(url.into());
self
}
#[must_use]
pub fn with_ed25519_signature(mut self, signature: impl Into<String>) -> Self {
self.ed25519_signature = Some(signature.into());
self
}
#[must_use]
pub fn with_deltas(mut self, deltas: Vec<DeltaAsset>) -> Self {
self.deltas = deltas;
self
}
#[must_use]
pub fn with_published_at(mut self, published_at: SystemTime) -> Self {
self.published_at = Some(published_at);
self
}
#[must_use]
pub fn with_release_url(mut self, url: impl Into<String>) -> Self {
self.release_url = Some(url.into());
self
}
}
pub(crate) fn web_page_url(url: Option<&str>) -> Option<String> {
let url = url?.trim();
let scheme = url.split_once("://").map(|(scheme, _)| scheme)?;
let web = scheme.eq_ignore_ascii_case("https") || scheme.eq_ignore_ascii_case("http");
if !web {
tracing::debug!(url, "ignoring a release page that is not an http(s) URL");
}
web.then(|| url.to_string())
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DeltaAsset {
pub from: Version,
pub to: Version,
pub format: String,
pub asset: Asset,
pub sha256: Option<String>,
pub checksums_url: Option<String>,
pub signature: Option<String>,
pub signature_url: Option<String>,
pub ed25519_signature: Option<String>,
}
impl DeltaAsset {
pub fn new(from: Version, to: Version, asset: Asset) -> Self {
Self {
from,
to,
format: DELTA_FORMAT_FILES_BSDIFF_V1.to_string(),
asset,
sha256: None,
checksums_url: None,
signature: None,
signature_url: None,
ed25519_signature: None,
}
}
}
pub fn parse_tag(tag: &str) -> Result<Version> {
let trimmed = tag.trim();
let stripped = trimmed.strip_prefix('v').unwrap_or(trimmed);
Version::parse(stripped).map_err(|e| Error::Parse(format!("tag `{tag}`: {e}")))
}
pub fn parse_version_lenient(version: &str) -> Result<Version> {
if let Ok(parsed) = parse_tag(version) {
return Ok(parsed);
}
let trimmed = version.trim();
let stripped = trimmed.strip_prefix('v').unwrap_or(trimmed);
let parts: Vec<&str> = stripped.split('.').collect();
let numeric = !parts.is_empty()
&& parts.len() <= 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
if !numeric {
return Err(Error::Parse(format!("version `{version}` is not semver")));
}
let mut padded = parts.join(".");
for _ in parts.len()..3 {
padded.push_str(".0");
}
Version::parse(&padded).map_err(|e| Error::Parse(format!("version `{version}`: {e}")))
}
#[cfg(test)]
mod tests {
use super::{Asset, Release, parse_tag, parse_version_lenient, web_page_url};
use semver::Version;
#[test]
fn keeps_only_web_page_urls() {
assert_eq!(
web_page_url(Some(" https://a.example/x ")).as_deref(),
Some("https://a.example/x")
);
assert_eq!(
web_page_url(Some("HTTP://a.example")).as_deref(),
Some("HTTP://a.example")
);
for bad in [
None,
Some(""),
Some("javascript:alert(1)"),
Some("file:///etc/passwd"),
Some("ftp://a.example/x"),
Some("/releases/1.2.0"),
] {
assert_eq!(web_page_url(bad), None, "{bad:?}");
}
}
#[test]
fn parses_v_prefixed_and_bare_tags() {
assert_eq!(parse_tag("v1.2.3").unwrap().to_string(), "1.2.3");
assert_eq!(parse_tag(" 1.2.3 ").unwrap().to_string(), "1.2.3");
assert_eq!(parse_tag("v0.1.0-rc.1").unwrap().to_string(), "0.1.0-rc.1");
}
#[test]
fn rejects_non_semver_tags() {
assert!(parse_tag("latest").is_err());
assert!(parse_tag("v1.2").is_err());
}
#[test]
fn lenient_parse_pads_short_numeric_versions() {
assert_eq!(parse_version_lenient("2").unwrap(), Version::new(2, 0, 0));
assert_eq!(
parse_version_lenient("v1.4").unwrap(),
Version::new(1, 4, 0)
);
assert_eq!(
parse_version_lenient(" 1.2.3 ").unwrap(),
Version::new(1, 2, 3)
);
assert_eq!(
parse_version_lenient("0.3.0-beta.2").unwrap().to_string(),
"0.3.0-beta.2"
);
}
#[test]
fn lenient_parse_rejects_what_it_cannot_order() {
for bad in ["", "1.2.3.4", "1..2", "build 42", "1.2b", "latest"] {
assert!(parse_version_lenient(bad).is_err(), "{bad:?} should fail");
}
}
#[test]
fn builder_sets_optional_fields() {
let release = Release::new(
Version::new(1, 0, 0),
Asset::new("app.dmg", "https://example.test/app.dmg", 3),
)
.with_notes("notes")
.with_sha256(" ABC ")
.with_ed25519_signature("sig");
assert_eq!(release.notes.as_deref(), Some("notes"));
assert_eq!(release.sha256.as_deref(), Some("abc"));
assert_eq!(release.ed25519_signature.as_deref(), Some("sig"));
assert!(release.signature.is_none());
assert_eq!(release.asset.size, 3);
}
}