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}