1use std::time::SystemTime;
4
5use semver::Version;
6
7use crate::error::{Error, Result};
8
9pub const DELTA_FORMAT_FILES_BSDIFF_V1: &str = "files-bsdiff-v1";
12
13#[derive(Debug, Clone)]
19#[non_exhaustive]
20pub struct Asset {
21 pub name: String,
23 pub url: String,
25 pub size: u64,
27}
28
29impl Asset {
30 pub fn new(name: impl Into<String>, url: impl Into<String>, size: u64) -> Self {
32 Self {
33 name: name.into(),
34 url: url.into(),
35 size,
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
45#[non_exhaustive]
46pub struct Release {
47 pub version: Version,
49 pub notes: Option<String>,
51 pub asset: Asset,
53 pub signature: Option<String>,
55 pub signature_url: Option<String>,
57 pub sha256: Option<String>,
60 pub ed25519_signature: Option<String>,
65 pub deltas: Vec<DeltaAsset>,
69 pub published_at: Option<SystemTime>,
72 pub release_url: Option<String>,
75}
76
77impl Release {
78 pub fn new(version: Version, asset: Asset) -> Self {
81 Self {
82 version,
83 notes: None,
84 asset,
85 signature: None,
86 signature_url: None,
87 sha256: None,
88 ed25519_signature: None,
89 deltas: Vec::new(),
90 published_at: None,
91 release_url: None,
92 }
93 }
94
95 #[must_use]
97 pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
98 self.notes = Some(notes.into());
99 self
100 }
101
102 #[must_use]
104 pub fn with_sha256(mut self, sha256: impl Into<String>) -> Self {
105 self.sha256 = Some(sha256.into().trim().to_ascii_lowercase());
106 self
107 }
108
109 #[must_use]
111 pub fn with_minisign_signature(mut self, signature: impl Into<String>) -> Self {
112 self.signature = Some(signature.into());
113 self
114 }
115
116 #[must_use]
118 pub fn with_minisign_signature_url(mut self, url: impl Into<String>) -> Self {
119 self.signature_url = Some(url.into());
120 self
121 }
122
123 #[must_use]
126 pub fn with_ed25519_signature(mut self, signature: impl Into<String>) -> Self {
127 self.ed25519_signature = Some(signature.into());
128 self
129 }
130
131 #[must_use]
134 pub fn with_deltas(mut self, deltas: Vec<DeltaAsset>) -> Self {
135 self.deltas = deltas;
136 self
137 }
138
139 #[must_use]
141 pub fn with_published_at(mut self, published_at: SystemTime) -> Self {
142 self.published_at = Some(published_at);
143 self
144 }
145
146 #[must_use]
149 pub fn with_release_url(mut self, url: impl Into<String>) -> Self {
150 self.release_url = Some(url.into());
151 self
152 }
153}
154
155pub(crate) fn web_page_url(url: Option<&str>) -> Option<String> {
159 let url = url?.trim();
160 let scheme = url.split_once("://").map(|(scheme, _)| scheme)?;
161 let web = scheme.eq_ignore_ascii_case("https") || scheme.eq_ignore_ascii_case("http");
162 if !web {
163 tracing::debug!(url, "ignoring a release page that is not an http(s) URL");
164 }
165 web.then(|| url.to_string())
166}
167
168#[derive(Debug, Clone)]
177#[non_exhaustive]
178pub struct DeltaAsset {
179 pub from: Version,
181 pub to: Version,
183 pub format: String,
186 pub asset: Asset,
188 pub sha256: Option<String>,
190 pub checksums_url: Option<String>,
194 pub signature: Option<String>,
196 pub signature_url: Option<String>,
198 pub ed25519_signature: Option<String>,
201}
202
203impl DeltaAsset {
204 pub fn new(from: Version, to: Version, asset: Asset) -> Self {
207 Self {
208 from,
209 to,
210 format: DELTA_FORMAT_FILES_BSDIFF_V1.to_string(),
211 asset,
212 sha256: None,
213 checksums_url: None,
214 signature: None,
215 signature_url: None,
216 ed25519_signature: None,
217 }
218 }
219}
220
221pub fn parse_tag(tag: &str) -> Result<Version> {
226 let trimmed = tag.trim();
227 let stripped = trimmed.strip_prefix('v').unwrap_or(trimmed);
228 Version::parse(stripped).map_err(|e| Error::Parse(format!("tag `{tag}`: {e}")))
229}
230
231pub fn parse_version_lenient(version: &str) -> Result<Version> {
243 if let Ok(parsed) = parse_tag(version) {
244 return Ok(parsed);
245 }
246 let trimmed = version.trim();
247 let stripped = trimmed.strip_prefix('v').unwrap_or(trimmed);
248 let parts: Vec<&str> = stripped.split('.').collect();
249 let numeric = !parts.is_empty()
250 && parts.len() <= 3
251 && parts
252 .iter()
253 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
254 if !numeric {
255 return Err(Error::Parse(format!("version `{version}` is not semver")));
256 }
257 let mut padded = parts.join(".");
258 for _ in parts.len()..3 {
259 padded.push_str(".0");
260 }
261 Version::parse(&padded).map_err(|e| Error::Parse(format!("version `{version}`: {e}")))
262}
263
264#[cfg(test)]
265mod tests {
266 use super::{Asset, Release, parse_tag, parse_version_lenient, web_page_url};
267 use semver::Version;
268
269 #[test]
270 fn keeps_only_web_page_urls() {
271 assert_eq!(
272 web_page_url(Some(" https://a.example/x ")).as_deref(),
273 Some("https://a.example/x")
274 );
275 assert_eq!(
276 web_page_url(Some("HTTP://a.example")).as_deref(),
277 Some("HTTP://a.example")
278 );
279 for bad in [
280 None,
281 Some(""),
282 Some("javascript:alert(1)"),
283 Some("file:///etc/passwd"),
284 Some("ftp://a.example/x"),
285 Some("/releases/1.2.0"),
286 ] {
287 assert_eq!(web_page_url(bad), None, "{bad:?}");
288 }
289 }
290
291 #[test]
292 fn parses_v_prefixed_and_bare_tags() {
293 assert_eq!(parse_tag("v1.2.3").unwrap().to_string(), "1.2.3");
294 assert_eq!(parse_tag(" 1.2.3 ").unwrap().to_string(), "1.2.3");
295 assert_eq!(parse_tag("v0.1.0-rc.1").unwrap().to_string(), "0.1.0-rc.1");
296 }
297
298 #[test]
299 fn rejects_non_semver_tags() {
300 assert!(parse_tag("latest").is_err());
301 assert!(parse_tag("v1.2").is_err());
302 }
303
304 #[test]
305 fn lenient_parse_pads_short_numeric_versions() {
306 assert_eq!(parse_version_lenient("2").unwrap(), Version::new(2, 0, 0));
307 assert_eq!(
308 parse_version_lenient("v1.4").unwrap(),
309 Version::new(1, 4, 0)
310 );
311 assert_eq!(
312 parse_version_lenient(" 1.2.3 ").unwrap(),
313 Version::new(1, 2, 3)
314 );
315 assert_eq!(
316 parse_version_lenient("0.3.0-beta.2").unwrap().to_string(),
317 "0.3.0-beta.2"
318 );
319 }
320
321 #[test]
322 fn lenient_parse_rejects_what_it_cannot_order() {
323 for bad in ["", "1.2.3.4", "1..2", "build 42", "1.2b", "latest"] {
324 assert!(parse_version_lenient(bad).is_err(), "{bad:?} should fail");
325 }
326 }
327
328 #[test]
329 fn builder_sets_optional_fields() {
330 let release = Release::new(
331 Version::new(1, 0, 0),
332 Asset::new("app.dmg", "https://example.test/app.dmg", 3),
333 )
334 .with_notes("notes")
335 .with_sha256(" ABC ")
336 .with_ed25519_signature("sig");
337 assert_eq!(release.notes.as_deref(), Some("notes"));
338 assert_eq!(release.sha256.as_deref(), Some("abc"));
339 assert_eq!(release.ed25519_signature.as_deref(), Some("sig"));
340 assert!(release.signature.is_none());
341 assert_eq!(release.asset.size, 3);
342 }
343}