Skip to main content

renew/
manifest.rs

1use std::collections::HashMap;
2use std::io::Read;
3
4use minisign_verify::{PublicKey, Signature};
5use serde::Deserialize;
6
7use crate::{MANIFEST_TIMEOUT, MAX_APP_BYTES, MAX_BINARY_BYTES, RenewError};
8
9const MANIFEST_BYTES: u64 = 1024 * 1024;
10const SIGNATURE_BYTES: u64 = 4 * 1024;
11
12#[derive(Debug, Clone, Deserialize)]
13pub(crate) struct Manifest {
14    #[serde(flatten)]
15    latest: Entry,
16    #[serde(default)]
17    releases: Vec<Entry>,
18}
19
20#[derive(Debug, Clone, Deserialize)]
21struct Entry {
22    version: String,
23    platforms: HashMap<String, Binary>,
24}
25
26#[derive(Debug, Clone, PartialEq, Deserialize)]
27pub struct Binary {
28    pub url: String,
29    pub sha256: String,
30    pub size: u64,
31    #[serde(default)]
32    pub app: Option<AppBundle>,
33}
34
35#[derive(Debug, Clone, PartialEq, Deserialize)]
36pub struct AppBundle {
37    pub url: String,
38    pub sha256: String,
39    pub size: u64,
40}
41
42#[derive(Debug, Clone, PartialEq)]
43pub struct Release {
44    pub version: String,
45    pub binary: Binary,
46}
47
48#[derive(Debug, Clone, PartialEq, Default)]
49pub struct Check {
50    pub update: Option<Release>,
51    pub new_major: Option<Release>,
52}
53
54pub fn platform_key() -> &'static str {
55    match (std::env::consts::OS, std::env::consts::ARCH) {
56        ("linux", "x86_64") => "linux-x86_64",
57        ("macos", "aarch64") => "macos-arm64",
58        ("windows", "x86_64") => "windows-x86_64",
59        _ => "",
60    }
61}
62
63impl Manifest {
64    pub(crate) fn verify(
65        raw: &[u8],
66        signature: &[u8],
67        public_key: &PublicKey,
68    ) -> Result<Self, RenewError> {
69        let signature = std::str::from_utf8(signature)
70            .ok()
71            .and_then(|text| Signature::decode(text).ok())
72            .ok_or(RenewError::BadSignature)?;
73        public_key
74            .verify(raw, &signature, false)
75            .map_err(|_| RenewError::BadSignature)?;
76        Self::parse(raw)
77    }
78
79    fn parse(raw: &[u8]) -> Result<Self, RenewError> {
80        serde_json::from_slice(raw).map_err(|_| RenewError::ManifestMalformed)
81    }
82
83    pub(crate) fn check_against(&self, current_version: &str) -> Result<Check, RenewError> {
84        let current = stripped(current_version).ok_or(RenewError::BadVersion)?;
85        let mut entries = Vec::with_capacity(1 + self.releases.len());
86        for entry in std::iter::once(&self.latest).chain(&self.releases) {
87            let version = stripped(&entry.version).ok_or(RenewError::ManifestMalformed)?;
88            entries.push((version, entry));
89        }
90        entries.sort_by(|a, b| b.0.cmp(&a.0));
91        let new_major = entries
92            .iter()
93            .filter(|(version, _)| version.major > current.major)
94            .find_map(|(_, entry)| entry.release().ok());
95        if new_major.is_some() {
96            return Ok(Check {
97                update: new_major.clone(),
98                new_major,
99            });
100        }
101        let update = entries
102            .iter()
103            .find(|(version, _)| version.major == current.major && *version > current)
104            .map(|(_, entry)| entry.release())
105            .transpose()?;
106        Ok(Check {
107            update,
108            new_major: None,
109        })
110    }
111}
112
113fn stripped(version: &str) -> Option<semver::Version> {
114    let mut version: semver::Version = version.parse().ok()?;
115    version.build = semver::BuildMetadata::EMPTY;
116    Some(version)
117}
118
119impl Entry {
120    fn release(&self) -> Result<Release, RenewError> {
121        let binary = self
122            .platforms
123            .get(platform_key())
124            .ok_or(RenewError::NoPlatformBinary)?;
125        sane(&binary.sha256, binary.size, MAX_BINARY_BYTES)?;
126        if let Some(app) = &binary.app {
127            sane(&app.sha256, app.size, MAX_APP_BYTES)?;
128        }
129        Ok(Release {
130            version: self.version.clone(),
131            binary: binary.clone(),
132        })
133    }
134}
135
136fn sane(sha256: &str, size: u64, cap: u64) -> Result<(), RenewError> {
137    if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
138        return Err(RenewError::ManifestMalformed);
139    }
140    if size == 0 || size > cap {
141        return Err(RenewError::VerifyFailed {
142            detail: format!("published size {size} is outside the sane band"),
143        });
144    }
145    Ok(())
146}
147
148pub(crate) fn public_key(base64: &str) -> Result<PublicKey, RenewError> {
149    PublicKey::from_base64(base64.trim()).map_err(|_| RenewError::BadPublicKey)
150}
151
152pub(crate) fn fetch_manifest(url: &str, public_key: &PublicKey) -> Result<Manifest, RenewError> {
153    let raw = fetch_capped(url, MANIFEST_BYTES)?;
154    let signature = fetch_capped(&format!("{url}.minisig"), SIGNATURE_BYTES)?;
155    Manifest::verify(&raw, &signature, public_key)
156}
157
158fn fetch_capped(url: &str, cap: u64) -> Result<Vec<u8>, RenewError> {
159    let response = crate::get(url, MANIFEST_TIMEOUT, RenewError::ManifestUnreachable)?;
160    let mut raw = Vec::new();
161    response
162        .into_body()
163        .into_reader()
164        .take(cap)
165        .read_to_end(&mut raw)
166        .map_err(|_| RenewError::ManifestUnreachable)?;
167    Ok(raw)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn manifest_with(version: &str, key: &str, sha256: &str, size: u64) -> Manifest {
175        let raw = format!(
176            r#"{{"version":"{version}","platforms":{{"{key}":{{"url":"https://example.invalid/work","sha256":"{sha256}","size":{size}}}}}}}"#
177        );
178        Manifest::parse(raw.as_bytes()).unwrap()
179    }
180
181    fn manifest(version: &str, key: &str, size: u64) -> Manifest {
182        manifest_with(version, key, &"ab".repeat(32), size)
183    }
184
185    fn entry(version: &str, key: &str) -> String {
186        format!(
187            r#""version":"{version}","platforms":{{"{key}":{{"url":"https://example.invalid/{version}","sha256":"{sha256}","size":100}}}}"#,
188            sha256 = "ab".repeat(32),
189        )
190    }
191
192    fn lines(latest: &str, releases: &[&str]) -> Manifest {
193        let releases: Vec<String> = releases
194            .iter()
195            .map(|version| format!("{{{}}}", entry(version, platform_key())))
196            .collect();
197        let raw = format!(
198            r#"{{{},"releases":[{}]}}"#,
199            entry(latest, platform_key()),
200            releases.join(",")
201        );
202        Manifest::parse(raw.as_bytes()).unwrap()
203    }
204
205    fn versions(check: &Check) -> (Option<&str>, Option<&str>) {
206        (
207            check
208                .update
209                .as_ref()
210                .map(|release| release.version.as_str()),
211            check
212                .new_major
213                .as_ref()
214                .map(|release| release.version.as_str()),
215        )
216    }
217
218    #[test]
219    fn the_update_crosses_majors_and_new_major_names_the_crossing() {
220        let manifest = lines("2.0.0", &["1.5.0", "1.5.1", "1.4.3", "0.9.9"]);
221        for (running, update, new_major) in [
222            ("1.4.2", Some("2.0.0"), Some("2.0.0")),
223            ("1.5.1", Some("2.0.0"), Some("2.0.0")),
224            ("1.0.0-rc.3", Some("2.0.0"), Some("2.0.0")),
225            ("2.0.0-rc.1", Some("2.0.0"), None),
226            ("2.0.0", None, None),
227            ("0.9.0", Some("2.0.0"), Some("2.0.0")),
228        ] {
229            let check = manifest.check_against(running).unwrap();
230            assert_eq!(versions(&check), (update, new_major), "running {running}");
231        }
232    }
233
234    #[test]
235    fn the_newest_of_several_later_majors_is_the_update_and_its_binary_rides_along() {
236        let check = lines("3.1.0", &["2.4.0", "3.0.2"])
237            .check_against("1.2.0")
238            .unwrap();
239        assert_eq!(versions(&check), (Some("3.1.0"), Some("3.1.0")));
240        assert_eq!(check.update.unwrap().binary.size, 100);
241    }
242
243    #[test]
244    fn a_later_major_without_this_platform_falls_back_to_the_running_majors_update() {
245        let raw = format!(
246            r#"{{{},"releases":[{{{}}}]}}"#,
247            entry("2.0.0", "definitely-not-this-platform"),
248            entry("1.5.1", platform_key()),
249        );
250        let check = Manifest::parse(raw.as_bytes())
251            .unwrap()
252            .check_against("1.4.2")
253            .unwrap();
254        assert_eq!(versions(&check), (Some("1.5.1"), None));
255    }
256
257    #[test]
258    fn platform_key_matches_this_build_target() {
259        let key = platform_key();
260        match (std::env::consts::OS, std::env::consts::ARCH) {
261            ("linux", "x86_64") => assert_eq!(key, "linux-x86_64"),
262            ("macos", "aarch64") => assert_eq!(key, "macos-arm64"),
263            ("windows", "x86_64") => assert_eq!(key, "windows-x86_64"),
264            _ => assert_eq!(key, ""),
265        }
266    }
267
268    #[test]
269    fn the_same_or_an_older_version_is_current() {
270        for published in ["1.1.0", "1.0.9", "0.9.0"] {
271            let check = manifest(published, platform_key(), 100)
272                .check_against("1.1.0")
273                .unwrap();
274            assert_eq!(check, Check::default(), "published {published}");
275        }
276    }
277
278    #[test]
279    fn build_metadata_never_makes_a_version_newer() {
280        for (published, running) in [("1.2.0+rebuild", "1.2.0"), ("1.2.0", "1.2.0+local")] {
281            let check = manifest(published, platform_key(), 100)
282                .check_against(running)
283                .unwrap();
284            assert_eq!(check, Check::default(), "{published} against {running}");
285        }
286    }
287
288    #[test]
289    fn a_prerelease_current_version_still_compares_by_semver() {
290        let check = manifest("1.0.0", platform_key(), 100)
291            .check_against("1.0.0-rc.1")
292            .unwrap();
293        assert!(check.update.is_some());
294    }
295
296    #[test]
297    fn a_newer_version_without_this_platform_is_a_named_refusal() {
298        let outcome = manifest("1.1.0", "definitely-not-this-platform", 100).check_against("1.0.0");
299        assert!(matches!(outcome, Err(RenewError::NoPlatformBinary)));
300    }
301
302    #[test]
303    fn a_zero_or_absurd_published_size_is_refused() {
304        for size in [0, MAX_BINARY_BYTES + 1] {
305            let outcome = manifest("1.1.0", platform_key(), size).check_against("1.0.0");
306            assert!(matches!(outcome, Err(RenewError::VerifyFailed { .. })));
307        }
308    }
309
310    #[test]
311    fn a_sha256_that_is_not_64_hex_digits_is_a_malformed_manifest() {
312        for sha256 in [
313            "a".repeat(63),
314            "a".repeat(65),
315            format!("{}g", "a".repeat(63)),
316        ] {
317            let outcome =
318                manifest_with("1.1.0", platform_key(), &sha256, 100).check_against("1.0.0");
319            assert!(
320                matches!(outcome, Err(RenewError::ManifestMalformed)),
321                "sha256 {sha256}"
322            );
323        }
324    }
325
326    #[test]
327    fn a_platforms_app_bundle_rides_along_and_is_held_to_the_same_checks() {
328        let app = |sha256: &str, size: u64| {
329            let raw = format!(
330                r#"{{"version":"1.1.0","platforms":{{"{key}":{{"url":"https://example.invalid/bin","sha256":"{bin}","size":100,"app":{{"url":"https://example.invalid/App.zip","sha256":"{sha256}","size":{size}}}}}}}}}"#,
331                key = platform_key(),
332                bin = "ab".repeat(32),
333            );
334            Manifest::parse(raw.as_bytes())
335                .unwrap()
336                .check_against("1.0.0")
337        };
338        assert_eq!(
339            app(&"cd".repeat(32), 200)
340                .unwrap()
341                .update
342                .unwrap()
343                .binary
344                .app,
345            Some(AppBundle {
346                url: "https://example.invalid/App.zip".to_string(),
347                sha256: "cd".repeat(32),
348                size: 200,
349            })
350        );
351        assert!(matches!(
352            app(&"c".repeat(63), 200),
353            Err(RenewError::ManifestMalformed)
354        ));
355        assert!(matches!(
356            app(&"cd".repeat(32), MAX_APP_BYTES + 1),
357            Err(RenewError::VerifyFailed { .. })
358        ));
359    }
360
361    #[test]
362    fn garbage_bytes_are_a_malformed_manifest_not_a_panic() {
363        assert!(matches!(
364            Manifest::parse(b"not json"),
365            Err(RenewError::ManifestMalformed)
366        ));
367        assert!(matches!(
368            Manifest::parse(b"{}"),
369            Err(RenewError::ManifestMalformed)
370        ));
371    }
372
373    #[test]
374    fn an_unparseable_current_version_is_a_bad_version_not_a_bad_manifest() {
375        let outcome = manifest("1.1.0", platform_key(), 100).check_against("not-semver");
376        assert!(matches!(outcome, Err(RenewError::BadVersion)));
377    }
378
379    #[test]
380    fn an_unparseable_manifest_version_is_a_malformed_manifest() {
381        let outcome = manifest("not-semver", platform_key(), 100).check_against("1.0.0");
382        assert!(matches!(outcome, Err(RenewError::ManifestMalformed)));
383    }
384}