Skip to main content

gpui_updater_core/
release.rs

1//! Release metadata shared across update sources.
2
3use std::time::SystemTime;
4
5use semver::Version;
6
7use crate::error::{Error, Result};
8
9/// Format identifier of the deltas produced by the `delta` feature's
10/// `delta::create`: per-file bsdiff patches against the installed tree.
11pub const DELTA_FORMAT_FILES_BSDIFF_V1: &str = "files-bsdiff-v1";
12
13/// A downloadable file attached to a release.
14///
15/// Marked `#[non_exhaustive]`: build one with [`Asset::new`] so new fields can
16/// be added without breaking custom [`UpdateSource`](crate::UpdateSource)
17/// implementations.
18#[derive(Debug, Clone)]
19#[non_exhaustive]
20pub struct Asset {
21    /// File name as published (e.g. `OpenLogi-v0.2.0-macos.dmg`).
22    pub name: String,
23    /// Direct download URL.
24    pub url: String,
25    /// Size in bytes (`0` if the source did not report it).
26    pub size: u64,
27}
28
29impl Asset {
30    /// Describe a downloadable file. Pass `0` for `size` when it is unknown.
31    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/// The latest applicable release resolved for the running platform.
41///
42/// Marked `#[non_exhaustive]`: build one with [`Release::new`] and the `with_*`
43/// methods. Fields stay public for reading and adjusting.
44#[derive(Debug, Clone)]
45#[non_exhaustive]
46pub struct Release {
47    /// Parsed semantic version (the tag with any leading `v` stripped).
48    pub version: Version,
49    /// Human-readable release notes / changelog, when provided.
50    pub notes: Option<String>,
51    /// The platform artifact to download and install.
52    pub asset: Asset,
53    /// Inline detached minisign signature for `asset`, when published.
54    pub signature: Option<String>,
55    /// URL of a detached minisign signature for `asset`, when published.
56    pub signature_url: Option<String>,
57    /// Expected lowercase hex SHA-256 of `asset`, when the source could resolve
58    /// it (e.g. from a `SHA256SUMS` file).
59    pub sha256: Option<String>,
60    /// Base64 Ed25519 signature over the whole `asset` file, in the format of a
61    /// Sparkle appcast's `sparkle:edSignature` (what Sparkle's `sign_update`
62    /// prints). Verified against
63    /// [`EngineConfig::ed25519_public_key`](crate::EngineConfig::ed25519_public_key).
64    pub ed25519_signature: Option<String>,
65    /// Binary deltas that rebuild this release from older installed versions.
66    /// Empty when the source publishes none. Used only with the `delta` feature;
67    /// the full `asset` remains the fallback.
68    pub deltas: Vec<DeltaAsset>,
69    /// When the release was published, when the source says so. For display
70    /// only; nothing in the engine depends on it.
71    pub published_at: Option<SystemTime>,
72    /// A web page about the release, such as its GitHub release page, for a
73    /// "View release" link. The built-in sources keep only `http(s)` URLs.
74    pub release_url: Option<String>,
75}
76
77impl Release {
78    /// A release of `version` that ships `asset`, with no notes, checksum, or
79    /// signatures yet.
80    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    /// Attach release notes (or a link to them).
96    #[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    /// Attach the expected hex SHA-256 of the asset (stored lowercase).
103    #[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    /// Attach an inline minisign signature (the contents of a `.minisig`).
110    #[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    /// Attach the URL of a detached minisign signature.
117    #[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    /// Attach a base64 Ed25519 signature in Sparkle's `sparkle:edSignature`
124    /// format.
125    #[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    /// Attach the binary deltas that rebuild this release from older
132    /// versions.
133    #[must_use]
134    pub fn with_deltas(mut self, deltas: Vec<DeltaAsset>) -> Self {
135        self.deltas = deltas;
136        self
137    }
138
139    /// Attach the time the release was published.
140    #[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    /// Attach the URL of a web page about the release. Pass an `http(s)` URL:
147    /// apps typically open it in a browser.
148    #[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
155/// `url`, trimmed, if it is an `http://` or `https://` URL a browser can open.
156/// Anything else — blank, `file:`, `javascript:` — is dropped, because apps
157/// hand this URL to the system browser.
158pub(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/// A binary delta that upgrades one specific installed version to another.
169///
170/// Most deltas end at the release they are attached to. Sources that also list
171/// deltas of earlier releases let the engine chain several of them when no
172/// delta starts at the installed version.
173///
174/// Marked `#[non_exhaustive]`: build one with [`DeltaAsset::new`] and set the
175/// public fields afterwards.
176#[derive(Debug, Clone)]
177#[non_exhaustive]
178pub struct DeltaAsset {
179    /// The installed version this delta applies to.
180    pub from: Version,
181    /// The version this delta produces.
182    pub to: Version,
183    /// Delta format, e.g. [`DELTA_FORMAT_FILES_BSDIFF_V1`]. Deltas in unknown
184    /// formats are ignored.
185    pub format: String,
186    /// The delta file to download.
187    pub asset: Asset,
188    /// Expected lowercase hex SHA-256 of the delta file, when published.
189    pub sha256: Option<String>,
190    /// URL of a `SHA256SUMS`-style file listing the delta's checksum under
191    /// `asset.name`, fetched only if this delta is actually used. Ignored when
192    /// `sha256` is set.
193    pub checksums_url: Option<String>,
194    /// Inline detached minisign signature of the delta file, when published.
195    pub signature: Option<String>,
196    /// URL of a detached minisign signature of the delta file, when published.
197    pub signature_url: Option<String>,
198    /// Base64 Ed25519 signature of the delta file in Sparkle's
199    /// `sparkle:edSignature` format, when published.
200    pub ed25519_signature: Option<String>,
201}
202
203impl DeltaAsset {
204    /// A delta in the [`DELTA_FORMAT_FILES_BSDIFF_V1`] format that upgrades
205    /// `from` to `to`, with no checksum or signatures yet.
206    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
221/// Parse a release tag such as `v1.2.3` or `1.2.3` into a [`Version`].
222///
223/// # Errors
224/// Returns [`Error::Parse`] if the tag is not valid semver.
225pub 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
231/// Parse a version the way feeds that are not strictly semver publish it.
232///
233/// Accepts everything [`parse_tag`] accepts, plus purely numeric versions with
234/// one or two components, which are padded with zeros (`2` → `2.0.0`,
235/// `1.4` → `1.4.0`). Sparkle feeds commonly publish such versions. Anything
236/// else (four components, words, empty strings) is rejected rather than
237/// guessed at, so a malformed feed never compares as an upgrade.
238///
239/// # Errors
240/// Returns [`Error::Parse`] if the string is neither semver nor a 1–3
241/// component numeric version.
242pub 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}