| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | //! Snowbound updating itself. Every published build keeps its own folder under `BASE`, with a |
| 2 | | //! `build.json` the release key signs; `latest.json` names each platform's newest build. A |
| 3 | | //! thread checks on launch and daily, downloads a newer build for this platform, verifies |
| 4 | | //! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits. |
| 2 | //! `build.json` the release key signs; `latest.json` names each platform's newest build, and |
| 3 | //! `history.json` every build. A thread checks on launch and daily, downloads a newer build |
| 4 | //! for this platform, verifies it, and unpacks it beside the install. Restart to Update |
| 5 | //! swaps it in once the app quits. |
| 5 | 6 | //! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload, |
| 6 | 7 | //! so nothing is fetched or installed there. |
| 7 | 8 | #![cfg_attr(target_arch = "wasm32", allow(dead_code))] |
| ... | ... | @@ -12,7 +13,7 @@ use crate::{EventLoopProxy, State, UserEvent, platform}; |
| 12 | 13 | #[cfg(not(target_arch = "wasm32"))] |
| 13 | 14 | use ring::signature::{ED25519, UnparsedPublicKey}; |
| 14 | 15 | use serde::Deserialize; |
| 15 | | use std::collections::HashMap; |
| 16 | use std::collections::{BTreeSet, HashMap, HashSet}; |
| 16 | 17 | #[cfg(not(target_os = "linux"))] |
| 17 | 18 | use std::env::current_exe as executable; |
| 18 | 19 | use std::ffi::OsString; |
| ... | ... | @@ -36,6 +37,9 @@ pub const FINISH: &str = "--finish-update"; |
| 36 | 37 | |
| 37 | 38 | const DAY: Duration = Duration::from_secs(24 * 60 * 60); |
| 38 | 39 | |
| 40 | /// How many builds' changes a check reads at most, the newest's included. |
| 41 | const CHAIN: usize = 20; |
| 42 | |
| 39 | 43 | /// This build's version as `tools/release.py` stamped it; development builds have none. |
| 40 | 44 | fn running() -> Option<Version> { |
| 41 | 45 | Version::parse(option_env!("SNOWBOUND_BUILD")?) |
| ... | ... | @@ -130,12 +134,21 @@ pub fn describe_running() -> String { |
| 130 | 134 | struct Build { |
| 131 | 135 | version: String, |
| 132 | 136 | archives: HashMap<String, Archive>, |
| 133 | | /// Every change since the first published build, oldest first; builds before these |
| 134 | | /// were listed have none. |
| 137 | /// The changes since the build published before it, oldest first. Builds published before |
| 138 | /// `history.json` list every change since the first published build, and earlier ones none. |
| 135 | 139 | #[serde(default)] |
| 136 | 140 | changes: Vec<Change>, |
| 137 | 141 | } |
| 138 | 142 | |
| 143 | /// What an update brings, features first. |
| 144 | #[derive(Clone, Debug, Default, PartialEq)] |
| 145 | pub struct Changes { |
| 146 | pub list: Vec<Change>, |
| 147 | /// Some builds' changes went unread: past `CHAIN`, unverified, or with no `history.json` |
| 148 | /// to name them. |
| 149 | pub more: bool, |
| 150 | } |
| 151 | |
| 139 | 152 | /// One feature, bug fix or other change a build brings, as `tools/release.py` lists them. |
| 140 | 153 | #[derive(Clone, Debug, Deserialize, PartialEq)] |
| 141 | 154 | pub struct Change { |
| ... | ... | @@ -165,29 +178,39 @@ impl Kind { |
| 165 | 178 | } |
| 166 | 179 | } |
| 167 | 180 | |
| 168 | | /// What `changes` amount to, as "3 features, 5 bug fixes, and 2 other changes"; none when |
| 169 | | /// there are none. |
| 170 | | pub fn summary(changes: &[Change]) -> Option<String> { |
| 171 | | let parts: Vec<String> = [ |
| 181 | /// What `changes` amount to, as "3 features, 5 bug fixes, and 2 other changes", or "1 bug |
| 182 | /// fix and more" where some went unread; none when there are none. |
| 183 | pub fn summary(changes: &Changes) -> Option<String> { |
| 184 | if changes.list.is_empty() { |
| 185 | return None; |
| 186 | } |
| 187 | let mut parts: Vec<String> = [ |
| 172 | 188 | (Kind::Feature, "feature", "features"), |
| 173 | 189 | (Kind::Fix, "bug fix", "bug fixes"), |
| 174 | 190 | (Kind::Other, "other change", "other changes"), |
| 175 | 191 | ] |
| 176 | 192 | .into_iter() |
| 177 | 193 | .filter_map(|(kind, one, many)| { |
| 178 | | match changes.iter().filter(|change| change.kind == kind).count() { |
| 194 | match changes |
| 195 | .list |
| 196 | .iter() |
| 197 | .filter(|change| change.kind == kind) |
| 198 | .count() |
| 199 | { |
| 179 | 200 | 0 => None, |
| 180 | 201 | 1 => Some(format!("1 {one}")), |
| 181 | 202 | count => Some(format!("{count} {many}")), |
| 182 | 203 | } |
| 183 | 204 | }) |
| 184 | 205 | .collect(); |
| 185 | | match parts.as_slice() { |
| 186 | | [] => None, |
| 187 | | [one] => Some(one.clone()), |
| 188 | | [first, second] => Some(format!("{first} and {second}")), |
| 189 | | [rest @ .., last] => Some(format!("{}, and {last}", rest.join(", "))), |
| 206 | if changes.more { |
| 207 | parts.push("more".to_owned()); |
| 190 | 208 | } |
| 209 | Some(match parts.as_slice() { |
| 210 | [first, second] => format!("{first} and {second}"), |
| 211 | [rest @ .., last] if !rest.is_empty() => format!("{}, and {last}", rest.join(", ")), |
| 212 | _ => parts.concat(), |
| 213 | }) |
| 191 | 214 | } |
| 192 | 215 | |
| 193 | 216 | /// What the signed `build.json` says of an archive. The `signature` it also gives, the release |
| ... | ... | @@ -255,36 +278,69 @@ fn newer( |
| 255 | 278 | .then_some(version)) |
| 256 | 279 | } |
| 257 | 280 | |
| 258 | | /// `platform`'s archive in the build `build.json` describes, once its signature holds and |
| 259 | | /// it describes `version`, and its changes since `running`, features first. |
| 260 | | fn archive( |
| 281 | /// The build `build.json` describes, once its signature holds and it describes `version`. |
| 282 | fn verified( |
| 261 | 283 | key: &[u8], |
| 262 | 284 | build: &[u8], |
| 263 | 285 | signature: &[u8], |
| 264 | 286 | version: &Version, |
| 265 | | platform: &str, |
| 266 | | running: Option<&Version>, |
| 267 | | ) -> Result<(Archive, Vec<Change>), String> { |
| 287 | ) -> Result<Build, String> { |
| 268 | 288 | verify(key, build, &String::from_utf8_lossy(signature))?; |
| 269 | | let mut build: Build = |
| 289 | let build: Build = |
| 270 | 290 | serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?; |
| 271 | 291 | if Version::parse(&build.version).as_ref() != Some(version) { |
| 272 | 292 | return Err(format!("build.json describes {}", build.version)); |
| 273 | 293 | } |
| 274 | | let archive = build |
| 275 | | .archives |
| 276 | | .remove(platform) |
| 277 | | .ok_or_else(|| format!("{} has no {platform} archive", version.name()))?; |
| 278 | | let mut changes: Vec<Change> = build |
| 279 | | .changes |
| 280 | | .into_iter() |
| 281 | | .filter(|change| { |
| 282 | | Version::parse(&change.version) |
| 283 | | .is_some_and(|made| running.is_none_or(|running| made > *running)) |
| 284 | | }) |
| 294 | Ok(build) |
| 295 | } |
| 296 | |
| 297 | /// What updating from `running` to `newest`, whose verified `build` is given, brings: the |
| 298 | /// changes of each build `history` names between them, read in parallel, and `build`'s. |
| 299 | /// Skipped releases count whatever platforms they built. A commit's changes count from the |
| 300 | /// newest build listing them, as builds published before `history.json` list every change |
| 301 | /// since the first. `history` comes unsigned and only says which builds to read. |
| 302 | fn changes( |
| 303 | newest: &Version, |
| 304 | build: Build, |
| 305 | history: Option<BTreeSet<Version>>, |
| 306 | running: Option<&Version>, |
| 307 | read: &(dyn Fn(&Version) -> Result<Build, &'static str> + Sync), |
| 308 | ) -> Changes { |
| 309 | let mut more = history.is_none(); |
| 310 | let mut known = history.unwrap_or_default(); |
| 311 | known.insert(newest.clone()); |
| 312 | let mut older: Vec<&Version> = known |
| 313 | .range(..newest) |
| 314 | .rev() |
| 315 | .take_while(|version| running.is_none_or(|running| *version > running)) |
| 285 | 316 | .collect(); |
| 286 | | changes.sort_by_key(|change| change.kind); |
| 287 | | Ok((archive, changes)) |
| 317 | more |= older.len() >= CHAIN; |
| 318 | older.truncate(CHAIN - 1); |
| 319 | let builds: Vec<Option<Build>> = std::thread::scope(|scope| { |
| 320 | let threads: Vec<_> = (older.iter()) |
| 321 | .map(|&version| scope.spawn(move || read(version).ok())) |
| 322 | .collect(); |
| 323 | (threads.into_iter()) |
| 324 | .map(|thread| thread.join().unwrap()) |
| 325 | .collect() |
| 326 | }); |
| 327 | more |= builds.iter().any(Option::is_none); |
| 328 | let mut listed = HashSet::new(); |
| 329 | let mut lists = Vec::new(); |
| 330 | for build in std::iter::once(build).chain(builds.into_iter().flatten()) { |
| 331 | let own: Vec<Change> = (build.changes.into_iter()) |
| 332 | .filter(|change| { |
| 333 | !listed.contains(&change.version) |
| 334 | && Version::parse(&change.version) |
| 335 | .is_some_and(|made| running.is_none_or(|running| made > *running)) |
| 336 | }) |
| 337 | .collect(); |
| 338 | listed.extend(own.iter().map(|change| change.version.clone())); |
| 339 | lists.push(own); |
| 340 | } |
| 341 | let mut list: Vec<Change> = lists.into_iter().rev().flatten().collect(); |
| 342 | list.sort_by_key(|change| change.kind); |
| 343 | Changes { list, more } |
| 288 | 344 | } |
| 289 | 345 | |
| 290 | 346 | fn check_archive(archive: &Archive, bytes: &[u8]) -> Result<(), String> { |
| ... | ... | @@ -404,9 +460,9 @@ pub enum Status { |
| 404 | 460 | Downloading(Version), |
| 405 | 461 | /// Verified and unpacked beside the install, waiting for Restart to Update, with what it |
| 406 | 462 | /// changes. |
| 407 | | Ready(Version, PathBuf, Vec<Change>), |
| 463 | Ready(Version, PathBuf, Changes), |
| 408 | 464 | /// Newer than this build, which can't install it itself: its folder has the download. |
| 409 | | Available(Version, Vec<Change>), |
| 465 | Available(Version, Changes), |
| 410 | 466 | Failed(&'static str), |
| 411 | 467 | } |
| 412 | 468 | |
| ... | ... | @@ -416,7 +472,7 @@ const UNVERIFIED: &str = |
| 416 | 472 | "The update didn’t match Snowbound’s release signature, so it wasn’t installed."; |
| 417 | 473 | |
| 418 | 474 | /// Fetches a path under `BASE`, refusing a body over the limit in bytes. |
| 419 | | type Fetch<'a> = dyn Fn(&str, u64) -> Result<Vec<u8>, String> + 'a; |
| 475 | type Fetch<'a> = dyn Fn(&str, u64) -> Result<Vec<u8>, String> + Sync + 'a; |
| 420 | 476 | |
| 421 | 477 | /// Looks for a build newer than `running` and stages it beside `install` if there is one. |
| 422 | 478 | fn check( |
| ... | ... | @@ -446,10 +502,28 @@ fn check( |
| 446 | 502 | else { |
| 447 | 503 | return Ok(Status::UpToDate); |
| 448 | 504 | }; |
| 449 | | let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?; |
| 450 | | let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?; |
| 451 | | let (archive, changes) = |
| 452 | | archive(key, &build, &signature, &version, &platform, since).map_err(unverified)?; |
| 505 | let read = |version: &Version| { |
| 506 | let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?; |
| 507 | let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?; |
| 508 | verified(key, &build, &signature, version).map_err(unverified) |
| 509 | }; |
| 510 | let mut build = read(&version)?; |
| 511 | let archive = build |
| 512 | .archives |
| 513 | .remove(&platform) |
| 514 | .ok_or_else(|| unverified(format!("{} has no {platform} archive", version.name())))?; |
| 515 | let history = fetch("history.json", 1 << 20).ok().and_then(|bytes| { |
| 516 | let names = serde_json::from_slice::<Vec<String>>(&bytes); |
| 517 | let names = names.map_err(|error| eprintln!("history.json: {error}")); |
| 518 | Some( |
| 519 | names |
| 520 | .ok()? |
| 521 | .iter() |
| 522 | .filter_map(|name| Version::parse(name)) |
| 523 | .collect(), |
| 524 | ) |
| 525 | }); |
| 526 | let changes = changes(&version, build, history, since, &read); |
| 453 | 527 | let Some(folder) = install.and_then(staging) else { |
| 454 | 528 | return Ok(Status::Available(version, changes)); |
| 455 | 529 | }; |
| ... | ... | @@ -689,22 +763,24 @@ impl State { |
| 689 | 763 | |
| 690 | 764 | /// `lead`, then what `changes` amount to and the first dozen of their titles under their |
| 691 | 765 | /// kinds, as a dialog's detail. |
| 692 | | fn described(lead: String, changes: &[Change]) -> String { |
| 766 | fn described(lead: String, changes: &Changes) -> String { |
| 693 | 767 | const LISTED: usize = 12; |
| 694 | 768 | let Some(summary) = summary(changes) else { |
| 695 | 769 | return lead; |
| 696 | 770 | }; |
| 697 | 771 | let mut detail = format!("{lead} It brings {summary}.\n"); |
| 698 | 772 | let mut kind = None; |
| 699 | | for change in changes.iter().take(LISTED) { |
| 773 | for change in changes.list.iter().take(LISTED) { |
| 700 | 774 | if kind != Some(change.kind) { |
| 701 | 775 | kind = Some(change.kind); |
| 702 | 776 | detail += &format!("\n{}\n", change.kind.heading()); |
| 703 | 777 | } |
| 704 | 778 | detail += &format!("• {}\n", change.title); |
| 705 | 779 | } |
| 706 | | if changes.len() > LISTED { |
| 707 | | detail += &format!("and {} more\n", changes.len() - LISTED); |
| 780 | if changes.more { |
| 781 | detail += "and more\n"; |
| 782 | } else if changes.list.len() > LISTED { |
| 783 | detail += &format!("and {} more\n", changes.list.len() - LISTED); |
| 708 | 784 | } |
| 709 | 785 | detail.trim_end().to_owned() |
| 710 | 786 | } |
| ... | ... | @@ -862,8 +938,9 @@ mod tests { |
| 862 | 938 | assert!(newer(b"<html>", "macos-aarch64", None).is_err()); |
| 863 | 939 | } |
| 864 | 940 | |
| 865 | | /// What each build `publish` describes lists: r9 and r10 published, r7 and r8 skipped. |
| 866 | | fn changes() -> serde_json::Value { |
| 941 | /// What a build published before `history.json` lists, every change since the first |
| 942 | /// published build: r9 and r10 published, r7 and r8 skipped. |
| 943 | fn every_change() -> serde_json::Value { |
| 867 | 944 | serde_json::json!([ |
| 868 | 945 | {"version": "2026-09-29-r7", "kind": "fix", "title": "Old fix"}, |
| 869 | 946 | {"version": "2026-09-29-r8", "kind": "fix", "title": "Pasted pictures keep their size"}, |
| ... | ... | @@ -874,10 +951,11 @@ mod tests { |
| 874 | 951 | ]) |
| 875 | 952 | } |
| 876 | 953 | |
| 877 | | /// A build.json for `archive`'s bytes under `platform`, with its signature. |
| 954 | /// A build.json listing `changes` and `archive`'s bytes under `platform`, with its signature. |
| 878 | 955 | fn publish( |
| 879 | 956 | pair: &Ed25519KeyPair, |
| 880 | 957 | name: &str, |
| 958 | changes: serde_json::Value, |
| 881 | 959 | platform: &str, |
| 882 | 960 | file: &str, |
| 883 | 961 | bytes: &[u8], |
| ... | ... | @@ -886,7 +964,7 @@ mod tests { |
| 886 | 964 | let build = serde_json::to_vec_pretty(&serde_json::json!({ |
| 887 | 965 | "version": name, |
| 888 | 966 | "commit": "0123456789abcdef", |
| 889 | | "changes": changes(), |
| 967 | "changes": changes, |
| 890 | 968 | "archives": {platform: { |
| 891 | 969 | "file": file, |
| 892 | 970 | "size": bytes.len(), |
| ... | ... | @@ -905,39 +983,26 @@ mod tests { |
| 905 | 983 | let key = pair.public_key().as_ref(); |
| 906 | 984 | let tenth = version("2026-09-29-r10"); |
| 907 | 985 | let bytes = b"an archive".to_vec(); |
| 908 | | let (build, signature) = |
| 909 | | publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes); |
| 910 | | let (found, _) = archive(key, &build, &signature, &tenth, "linux-x86_64", None).unwrap(); |
| 986 | let (build, signature) = publish( |
| 987 | &pair, |
| 988 | "2026-09-29-r10", |
| 989 | every_change(), |
| 990 | "linux-x86_64", |
| 991 | "a.tar.gz", |
| 992 | &bytes, |
| 993 | ); |
| 994 | let found = |
| 995 | verified(key, &build, &signature, &tenth).unwrap().archives["linux-x86_64"].clone(); |
| 911 | 996 | check_archive(&found, &bytes).unwrap(); |
| 912 | 997 | |
| 913 | 998 | let mut tampered = build.clone(); |
| 914 | 999 | let at = tampered.iter().position(|&byte| byte == b'a').unwrap(); |
| 915 | 1000 | tampered[at] = b'b'; |
| 916 | | assert!(archive(key, &tampered, &signature, &tenth, "linux-x86_64", None).is_err()); |
| 917 | | assert!( |
| 918 | | archive( |
| 919 | | generate().public_key().as_ref(), |
| 920 | | &build, |
| 921 | | &signature, |
| 922 | | &tenth, |
| 923 | | "linux-x86_64", |
| 924 | | None |
| 925 | | ) |
| 926 | | .is_err() |
| 927 | | ); |
| 928 | | assert!(archive(key, &build, b"zz", &tenth, "linux-x86_64", None).is_err()); |
| 929 | | assert!( |
| 930 | | archive( |
| 931 | | key, |
| 932 | | &build, |
| 933 | | &signature, |
| 934 | | &version("2026-09-29-r11"), |
| 935 | | "linux-x86_64", |
| 936 | | None |
| 937 | | ) |
| 938 | | .is_err() |
| 939 | | ); |
| 940 | | assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err()); |
| 1001 | assert!(verified(key, &tampered, &signature, &tenth).is_err()); |
| 1002 | let other = generate(); |
| 1003 | assert!(verified(other.public_key().as_ref(), &build, &signature, &tenth).is_err()); |
| 1004 | assert!(verified(key, &build, b"zz", &tenth).is_err()); |
| 1005 | assert!(verified(key, &build, &signature, &version("2026-09-29-r11")).is_err()); |
| 941 | 1006 | |
| 942 | 1007 | assert!( |
| 943 | 1008 | check_archive(&found, b"an archivf") |
| ... | ... | @@ -964,83 +1029,263 @@ mod tests { |
| 964 | 1029 | let fix = || change(Kind::Fix, "A fix"); |
| 965 | 1030 | let feature = || change(Kind::Feature, "A feature"); |
| 966 | 1031 | let other = || change(Kind::Other, "Another change"); |
| 967 | | assert_eq!(summary(&[]), None); |
| 968 | | assert_eq!(summary(&[fix()]).unwrap(), "1 bug fix"); |
| 969 | | assert_eq!(summary(&[other(), other()]).unwrap(), "2 other changes"); |
| 1032 | let all = |list: Vec<Change>| Changes { list, more: false }; |
| 1033 | let some = |list: Vec<Change>| Changes { list, more: true }; |
| 1034 | assert_eq!(summary(&all(vec![])), None); |
| 1035 | assert_eq!(summary(&some(vec![])), None); |
| 1036 | assert_eq!(summary(&all(vec![fix()])).unwrap(), "1 bug fix"); |
| 1037 | assert_eq!(summary(&some(vec![fix()])).unwrap(), "1 bug fix and more"); |
| 1038 | assert_eq!( |
| 1039 | summary(&all(vec![other(), other()])).unwrap(), |
| 1040 | "2 other changes" |
| 1041 | ); |
| 970 | 1042 | assert_eq!( |
| 971 | | summary(&[fix(), feature(), fix()]).unwrap(), |
| 1043 | summary(&all(vec![fix(), feature(), fix()])).unwrap(), |
| 972 | 1044 | "1 feature and 2 bug fixes" |
| 973 | 1045 | ); |
| 974 | | let mut all = vec![feature(), feature(), feature(), other(), other()]; |
| 975 | | all.extend(std::iter::repeat_with(fix).take(5)); |
| 976 | 1046 | assert_eq!( |
| 977 | | summary(&all).unwrap(), |
| 1047 | summary(&some(vec![fix(), feature(), fix()])).unwrap(), |
| 1048 | "1 feature, 2 bug fixes, and more" |
| 1049 | ); |
| 1050 | let mut many = vec![feature(), feature(), feature(), other(), other()]; |
| 1051 | many.extend(std::iter::repeat_with(fix).take(5)); |
| 1052 | assert_eq!( |
| 1053 | summary(&all(many)).unwrap(), |
| 978 | 1054 | "3 features, 5 bug fixes, and 2 other changes" |
| 979 | 1055 | ); |
| 980 | 1056 | assert_eq!( |
| 981 | | described("Ready.".to_owned(), &[feature(), fix(), fix()]), |
| 1057 | described("Ready.".to_owned(), &all(vec![feature(), fix(), fix()])), |
| 982 | 1058 | "Ready. It brings 1 feature and 2 bug fixes.\n\nFeatures\n• A feature\n\nBug fixes\n• A fix\n• A fix" |
| 983 | 1059 | ); |
| 984 | | assert_eq!(described("Ready.".to_owned(), &[]), "Ready."); |
| 1060 | assert_eq!( |
| 1061 | described("Ready.".to_owned(), &some(vec![fix()])), |
| 1062 | "Ready. It brings 1 bug fix and more.\n\nBug fixes\n• A fix\nand more" |
| 1063 | ); |
| 1064 | assert_eq!(described("Ready.".to_owned(), &all(vec![])), "Ready."); |
| 1065 | } |
| 1066 | |
| 1067 | /// A published folder by path: `builds` oldest first, each with its `changes`, only the |
| 1068 | /// newest built for this platform, and `history.json` naming them all. |
| 1069 | fn shelf( |
| 1070 | pair: &Ed25519KeyPair, |
| 1071 | builds: &[(&str, serde_json::Value)], |
| 1072 | ) -> HashMap<String, Vec<u8>> { |
| 1073 | let mut files = HashMap::new(); |
| 1074 | let names: Vec<&str> = builds.iter().map(|(name, _)| *name).collect(); |
| 1075 | for (name, changes) in builds { |
| 1076 | let platform = if Some(name) == names.last() { |
| 1077 | platform() |
| 1078 | } else { |
| 1079 | "beos-x86".to_owned() |
| 1080 | }; |
| 1081 | let (build, signature) = publish(pair, name, changes.clone(), &platform, "a", b"a"); |
| 1082 | let folder = version(name).folder(); |
| 1083 | files.insert(format!("{folder}build.json"), build); |
| 1084 | files.insert(format!("{folder}build.json.sig"), signature); |
| 1085 | } |
| 1086 | let latest = serde_json::json!({ platform(): names.last() }); |
| 1087 | files.insert("latest.json".into(), serde_json::to_vec(&latest).unwrap()); |
| 1088 | files.insert("history.json".into(), serde_json::to_vec(&names).unwrap()); |
| 1089 | files |
| 1090 | } |
| 1091 | |
| 1092 | /// One fix per (version, title). |
| 1093 | fn fixes(made: &[(&str, &str)]) -> serde_json::Value { |
| 1094 | (made.iter()) |
| 1095 | .map( |
| 1096 | |(made, title)| serde_json::json!({"version": made, "kind": "fix", "title": title}), |
| 1097 | ) |
| 1098 | .collect() |
| 1099 | } |
| 1100 | |
| 1101 | /// What a check from `running` against `files` finds the update brings. |
| 1102 | fn listed(files: &HashMap<String, Vec<u8>>, key: &[u8], running: &str) -> Changes { |
| 1103 | let fetch = |path: &str, _| files.get(path).cloned().ok_or_else(|| "404".to_owned()); |
| 1104 | let status = check(&fetch, key, Some(&version(running)), None, &|_| {}); |
| 1105 | let Status::Available(_, changes) = status else { |
| 1106 | panic!("{status:?}"); |
| 1107 | }; |
| 1108 | changes |
| 1109 | } |
| 1110 | |
| 1111 | fn titles(changes: &Changes) -> Vec<&str> { |
| 1112 | changes |
| 1113 | .list |
| 1114 | .iter() |
| 1115 | .map(|change| change.title.as_str()) |
| 1116 | .collect() |
| 985 | 1117 | } |
| 986 | 1118 | |
| 987 | | /// A build lists what every build since the first brought; a check sums those newer than |
| 988 | | /// the running build, skipped releases included, features first, and an unknown kind is |
| 989 | | /// another change. |
| 1119 | /// An update sums the builds `history.json` names after the running one, whichever |
| 1120 | /// platforms they built, a commit's changes counting once from the newest build listing |
| 1121 | /// them; features first. |
| 990 | 1122 | #[test] |
| 991 | | fn changes_sum_across_skipped_releases() { |
| 1123 | fn changes_sum_the_builds_since_the_running_one() { |
| 992 | 1124 | let pair = generate(); |
| 993 | 1125 | let key = pair.public_key().as_ref(); |
| 994 | | let tenth = version("2026-09-29-r10"); |
| 995 | | let (build, signature) = publish(&pair, "2026-09-29-r10", "linux-x86_64", "a", b"a"); |
| 996 | | let since = |running: Option<&str>| { |
| 997 | | let running = running.map(version); |
| 998 | | let (_, changes) = archive( |
| 999 | | key, |
| 1000 | | &build, |
| 1001 | | &signature, |
| 1002 | | &tenth, |
| 1003 | | "linux-x86_64", |
| 1004 | | running.as_ref(), |
| 1005 | | ) |
| 1006 | | .unwrap(); |
| 1007 | | changes |
| 1008 | | .into_iter() |
| 1009 | | .map(|change| (change.kind, change.title)) |
| 1010 | | .collect::<Vec<_>>() |
| 1011 | | }; |
| 1126 | let files = shelf( |
| 1127 | &pair, |
| 1128 | &[ |
| 1129 | // Published before builds listed changes. |
| 1130 | ("2026-09-29-r7", serde_json::json!([])), |
| 1131 | ( |
| 1132 | "2026-09-29-r8", |
| 1133 | serde_json::json!([ |
| 1134 | {"version": "2026-09-29-r6", "kind": "fix", "title": "Old fix"}, |
| 1135 | {"version": "2026-09-29-r7", "kind": "fix", "title": "Seventh"}, |
| 1136 | {"version": "2026-09-29-r8", "kind": "feature", "title": "Pinch zoom"}, |
| 1137 | {"version": "2026-09-29-r8", "kind": "release", "title": "Stable download names"}, |
| 1138 | ]), |
| 1139 | ), |
| 1140 | ( |
| 1141 | "2026-09-29-r9", |
| 1142 | fixes(&[("2026-09-29-r9", "Pasted pictures keep their size")]), |
| 1143 | ), |
| 1144 | ( |
| 1145 | "2026-09-29-r10", |
| 1146 | serde_json::json!([ |
| 1147 | {"version": "2026-09-29-r10", "kind": "feature", "title": "Styles and themes"}, |
| 1148 | ]), |
| 1149 | ), |
| 1150 | ], |
| 1151 | ); |
| 1152 | let all = listed(&files, key, "2026-09-29-r6"); |
| 1153 | assert_eq!( |
| 1154 | titles(&all), |
| 1155 | [ |
| 1156 | "Pinch zoom", |
| 1157 | "Styles and themes", |
| 1158 | "Seventh", |
| 1159 | "Pasted pictures keep their size", |
| 1160 | "Stable download names", |
| 1161 | ] |
| 1162 | ); |
| 1163 | assert!(!all.more); |
| 1164 | assert_eq!( |
| 1165 | summary(&all).unwrap(), |
| 1166 | "2 features, 2 bug fixes, and 1 other change" |
| 1167 | ); |
| 1168 | assert_eq!( |
| 1169 | titles(&listed(&files, key, "2026-09-29-r8")), |
| 1170 | ["Styles and themes", "Pasted pictures keep their size"] |
| 1171 | ); |
| 1012 | 1172 | assert_eq!( |
| 1013 | | since(Some("2026-09-29-r9")), |
| 1014 | | [(Kind::Feature, "Styles and themes".to_owned())] |
| 1173 | titles(&listed(&files, key, "2026-09-29-r9")), |
| 1174 | ["Styles and themes"] |
| 1015 | 1175 | ); |
| 1176 | |
| 1177 | // Builds published before history.json list every change since the first. |
| 1178 | let (build, signature) = publish( |
| 1179 | &pair, |
| 1180 | "2026-09-29-r10", |
| 1181 | every_change(), |
| 1182 | &platform(), |
| 1183 | "a", |
| 1184 | b"a", |
| 1185 | ); |
| 1186 | let latest = serde_json::json!({ platform(): "2026-09-29-r10" }); |
| 1187 | let old = HashMap::from([ |
| 1188 | ( |
| 1189 | "latest.json".to_owned(), |
| 1190 | serde_json::to_vec(&latest).unwrap(), |
| 1191 | ), |
| 1192 | ("2026-09-29.r10/build.json".to_owned(), build), |
| 1193 | ("2026-09-29.r10/build.json.sig".to_owned(), signature), |
| 1194 | ]); |
| 1195 | let found = listed(&old, key, "2026-09-29-r7"); |
| 1016 | 1196 | assert_eq!( |
| 1017 | | since(Some("2026-09-29-r7")), |
| 1197 | titles(&found), |
| 1018 | 1198 | [ |
| 1019 | | (Kind::Feature, "Pinch zoom".to_owned()), |
| 1020 | | (Kind::Feature, "Styles and themes".to_owned()), |
| 1021 | | (Kind::Fix, "Pasted pictures keep their size".to_owned()), |
| 1022 | | (Kind::Other, "Stable download names".to_owned()), |
| 1199 | "Pinch zoom", |
| 1200 | "Styles and themes", |
| 1201 | "Pasted pictures keep their size", |
| 1202 | "Stable download names", |
| 1023 | 1203 | ] |
| 1024 | 1204 | ); |
| 1025 | | assert_eq!(since(Some("2026-09-29-r10")), []); |
| 1026 | | assert_eq!(since(None).len(), 5); |
| 1027 | | |
| 1028 | | // Builds published before changes were listed still read, as builds listing them do |
| 1029 | | // for clients that predate them. |
| 1030 | | let old = serde_json::json!({"version": "2026-09-29-r10", "archives": {"linux-x86_64": { |
| 1031 | | "file": "a", "size": 1, "sha256": "", "signature": "", |
| 1032 | | }}}); |
| 1033 | | let old = serde_json::to_vec(&old).unwrap(); |
| 1034 | | let signature = hex(pair.sign(&old).as_ref()).into_bytes(); |
| 1035 | | let (_, changes) = archive(key, &old, &signature, &tenth, "linux-x86_64", None).unwrap(); |
| 1036 | | assert_eq!(changes, []); |
| 1037 | | #[derive(Deserialize)] |
| 1038 | | #[allow(dead_code)] |
| 1039 | | struct Earlier { |
| 1040 | | version: String, |
| 1041 | | archives: HashMap<String, Archive>, |
| 1042 | | } |
| 1043 | | assert!(serde_json::from_slice::<Earlier>(&build).is_ok()); |
| 1205 | assert!(found.more); |
| 1206 | } |
| 1207 | |
| 1208 | /// Past `CHAIN` builds, a check stops reading and says there is more. |
| 1209 | #[test] |
| 1210 | fn changes_stop_after_a_chain_of_builds() { |
| 1211 | let pair = generate(); |
| 1212 | let names: Vec<String> = (1..=25) |
| 1213 | .map(|revision| format!("2026-09-01-r{revision}")) |
| 1214 | .collect(); |
| 1215 | let builds: Vec<_> = (names.iter()) |
| 1216 | .map(|name| (name.as_str(), fixes(&[(name, name)]))) |
| 1217 | .collect(); |
| 1218 | let files = shelf(&pair, &builds); |
| 1219 | let key = pair.public_key().as_ref(); |
| 1220 | let capped = listed(&files, key, "2026-08-31-r1"); |
| 1221 | assert_eq!(titles(&capped), names[25 - CHAIN..]); |
| 1222 | assert!(capped.more); |
| 1223 | assert_eq!( |
| 1224 | summary(&capped).unwrap(), |
| 1225 | format!("{CHAIN} bug fixes and more") |
| 1226 | ); |
| 1227 | let whole = listed(&files, key, "2026-09-01-r5"); |
| 1228 | assert_eq!(titles(&whole), names[5..]); |
| 1229 | assert!(!whole.more); |
| 1230 | } |
| 1231 | |
| 1232 | /// A build in the middle that is missing or doesn't verify is skipped: the others still |
| 1233 | /// count, and the check says there is more. So is one `history.json` makes up, and |
| 1234 | /// without `history.json` only the newest build counts. |
| 1235 | #[test] |
| 1236 | fn unverified_or_missing_builds_are_skipped() { |
| 1237 | let pair = generate(); |
| 1238 | let key = pair.public_key().as_ref(); |
| 1239 | let mut files = shelf( |
| 1240 | &pair, |
| 1241 | &[ |
| 1242 | ("2026-09-29-r8", fixes(&[("2026-09-29-r8", "Eighth")])), |
| 1243 | ("2026-09-29-r9", fixes(&[("2026-09-29-r9", "Ninth")])), |
| 1244 | ("2026-09-29-r10", fixes(&[("2026-09-29-r10", "Tenth")])), |
| 1245 | ], |
| 1246 | ); |
| 1247 | let found = |files: &HashMap<String, Vec<u8>>, running| { |
| 1248 | let changes = listed(files, key, running); |
| 1249 | (titles(&changes).join(", "), changes.more) |
| 1250 | }; |
| 1251 | assert_eq!( |
| 1252 | found(&files, "2026-09-29-r7"), |
| 1253 | ("Eighth, Ninth, Tenth".into(), false) |
| 1254 | ); |
| 1255 | |
| 1256 | let forged = fixes(&[("2026-09-29-r9", "Forged")]); |
| 1257 | let (build, signature) = |
| 1258 | publish(&generate(), "2026-09-29-r9", forged, "beos-x86", "a", b"a"); |
| 1259 | let mut unverified = files.clone(); |
| 1260 | unverified.insert("2026-09-29.r9/build.json".into(), build); |
| 1261 | unverified.insert("2026-09-29.r9/build.json.sig".into(), signature); |
| 1262 | assert_eq!( |
| 1263 | found(&unverified, "2026-09-29-r7"), |
| 1264 | ("Eighth, Tenth".into(), true) |
| 1265 | ); |
| 1266 | |
| 1267 | let mut missing = files.clone(); |
| 1268 | missing.remove("2026-09-29.r9/build.json.sig"); |
| 1269 | assert_eq!( |
| 1270 | found(&missing, "2026-09-29-r7"), |
| 1271 | ("Eighth, Tenth".into(), true) |
| 1272 | ); |
| 1273 | |
| 1274 | let made_up = [ |
| 1275 | "2026-09-29-r7", |
| 1276 | "2026-09-29-r8", |
| 1277 | "2026-09-29-r9", |
| 1278 | "soon", |
| 1279 | "2026-09-29-r10", |
| 1280 | ]; |
| 1281 | files.insert("history.json".into(), serde_json::to_vec(&made_up).unwrap()); |
| 1282 | assert_eq!( |
| 1283 | found(&files, "2026-09-29-r6"), |
| 1284 | ("Eighth, Ninth, Tenth".into(), true) |
| 1285 | ); |
| 1286 | |
| 1287 | files.remove("history.json"); |
| 1288 | assert_eq!(found(&files, "2026-09-29-r7"), ("Tenth".into(), true)); |
| 1044 | 1289 | } |
| 1045 | 1290 | |
| 1046 | 1291 | /// An archive as `tools/release.py` packs this platform's, holding `marker`. |
| ... | ... | @@ -1086,9 +1331,16 @@ mod tests { |
| 1086 | 1331 | let build = published.join("2026-09-29.r10"); |
| 1087 | 1332 | std::fs::create_dir_all(&build).unwrap(); |
| 1088 | 1333 | let bytes = pack(&folder, "new"); |
| 1089 | | let (manifest, signature) = |
| 1090 | | publish(&pair, "2026-09-29-r10", &platform, "app.archive", &bytes); |
| 1334 | let (manifest, signature) = publish( |
| 1335 | &pair, |
| 1336 | "2026-09-29-r10", |
| 1337 | every_change(), |
| 1338 | &platform, |
| 1339 | "app.archive", |
| 1340 | &bytes, |
| 1341 | ); |
| 1091 | 1342 | std::fs::write(build.join("app.archive"), &bytes).unwrap(); |
| 1343 | std::fs::write(published.join("history.json"), br#"["2026-09-29-r10"]"#).unwrap(); |
| 1092 | 1344 | std::fs::write(build.join("build.json"), manifest).unwrap(); |
| 1093 | 1345 | std::fs::write(build.join("build.json.sig"), signature).unwrap(); |
| 1094 | 1346 | std::fs::write( |
| ... | ... | @@ -1190,8 +1442,14 @@ mod tests { |
| 1190 | 1442 | let platform = platform(); |
| 1191 | 1443 | let pair = generate(); |
| 1192 | 1444 | let bytes = pack(&folder, "new"); |
| 1193 | | let (manifest, signature) = |
| 1194 | | publish(&pair, "2026-09-29-r10", &platform, "app.archive", &bytes); |
| 1445 | let (manifest, signature) = publish( |
| 1446 | &pair, |
| 1447 | "2026-09-29-r10", |
| 1448 | every_change(), |
| 1449 | &platform, |
| 1450 | "app.archive", |
| 1451 | &bytes, |
| 1452 | ); |
| 1195 | 1453 | let mut served = bytes.clone(); |
| 1196 | 1454 | let last = served.len() - 1; |
| 1197 | 1455 | served[last] ^= 1; |
| ... | ... | @@ -1240,9 +1498,10 @@ mod tests { |
| 1240 | 1498 | Some(&install), |
| 1241 | 1499 | &|_| {}, |
| 1242 | 1500 | ); |
| 1243 | | let Status::Ready(found, staged, _) = status else { |
| 1501 | let Status::Ready(found, staged, changes) = status else { |
| 1244 | 1502 | panic!("{status:?}"); |
| 1245 | 1503 | }; |
| 1504 | assert!(summary(&changes).is_some()); |
| 1246 | 1505 | apply(&staged, &install).unwrap(); |
| 1247 | 1506 | // 10.6's builds are unsigned. |
| 1248 | 1507 | if cfg!(target_os = "macos") && cfg!(feature = "wgpu") { |
| ... | ... | @@ -1252,7 +1511,8 @@ mod tests { |
| 1252 | 1511 | .status(); |
| 1253 | 1512 | assert!(verified.unwrap().success()); |
| 1254 | 1513 | } |
| 1255 | | eprintln!("Installed {found} into {}", install.display()); |
| 1514 | let installed = format!("Installed {found} into {}.", install.display()); |
| 1515 | eprintln!("{}", described(installed, &changes)); |
| 1256 | 1516 | std::fs::remove_dir_all(&folder).unwrap(); |
| 1257 | 1517 | } |
| 1258 | 1518 | } |