Browse / github.com/storytold/craft-launcher / patch
Use craft-update's release parsing instead of a copy (#764)
[Security] Download links with `..` path segments get past the storytold-only check (#90 bypass) ## Summary #90 limited feed-supplied download links to `https://github.com/storytold/<app>/releases/download/`. The check is a plain string prefix. It does not reject dot segments after the prefix, whether written as `../` or percent-encoded as `%2e%2e/`. github.com resolves those segments on the server before routing. A link that passes the prefix check can therefore resolve to a release asset of **another repository**. I checked this on 2026-10-22: such a URL got the same asset redirect as a direct request for the other repository's asset. I have no evidence that the feed or any release i
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- smittie2000 (imported from pull request #930, open)
- Base
7aafad661a11594a70ae949471d481f30d8fe64a- Size
- +94 −1260 in 17 files
- Review
- pending
- Shared
- 2026-10-28
- Id
4861c912d2a47b7cd818a264
How it went
No other operator has reported using it yet.
Diff
raw · AGENTS.md Cargo.lock Cargo.toml crates/core/Cargo.toml crates/core/src/distro.rs crates/core/src/feed.rs crates/core/src/lib.rs crates/core/src/platform.rs crates/core/src/release.rs crates/core/src/version.rs crates/core/tests/fixtures/photocraft-release.json crates/core/tests/fixtures/soundcraft-release.json crates/native/Cargo.toml crates/native/src/http.rs crates/native/src/install.rs crates/native/src/self_update.rs crates/native/src/single_instance.rs
@@ -19,8 +19,9 @@ Standards and learnings shared across the Craft apps live in `../craftrules` (re ```text crates/- core artcraft-launcher-core pure and wasm-safe: the app catalog, versions, GitHub- release parsing, picking the right asset per platform+ core artcraft-launcher-core pure and wasm-safe: the app catalog, the feed, statuses;+ re-exports craft-update's release parsing, versions,+ platforms and asset picking (storytold/craft-libs) native artcraft-launcher-native desktop only: HTTP, detecting installed apps, download, verify and install ui-egui artcraft-launcher-ui-egui the shared egui UI (desktop and web)@@ -109,8 +110,10 @@ only; English where there is none): never bundle a CJK font, they are 10 MB and refresh it from there, never redraw. 3. **Never break wasm.** `cargo xtask wasm` must pass. File-system, process and OS code is `cfg(not(target_arch = "wasm32"))`.-4. **Tests are the gate.** Release parsing and asset picking in `core` get unit tests with real- GitHub release JSON fixtures (small, committed under `crates/core/tests/fixtures/`).+4. **Tests are the gate.** Release parsing and asset picking live in craft-update+ (`storytold/craft-libs`, `crates/update`), shared with every Craft app: fix them there, with+ unit tests on its real GitHub release JSON fixtures, then bump the pin here. Never copy them+ back into `core`. 5. When fetching anything, use a generic `ArtCraft-Launcher/<version>` User-Agent and never put a person's name, email or other personal details in requests. 6. **Time goes through `jiff`** (workspace dependency). Never hand-roll UTC/calendar conversion@@ -118,7 +121,8 @@ only; English where there is none): never bundle a CJK font, they are 10 MB and exception is range extension: past the years jiff can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let jiff compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is- plain arithmetic).+ plain arithmetic). The release parsing re-exported from craft-update dates a release with+ chrono, which is craft-libs' choice; the launcher's own code stays on jiff. ## 3. Before you finish a task @@ -300,8 +300,8 @@ dependencies = [ name = "artcraft-launcher-core" version = "0.8.1" dependencies = [+ "craft-update", "jiff",- "semver", "serde", "serde_json", ]@@ -966,7 +966,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0"-source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"+source = "git+https://github.com/smittie2000/craft-libs?rev=209f9a93c3f4213859c1faf4e67b7126eea0d11c#209f9a93c3f4213859c1faf4e67b7126eea0d11c" dependencies = [ "chrono", "dunce",@@ -30,7 +30,9 @@ semver = { version = "1", features = ["serde"] } log = "0.4" # All date/time conversion goes through jiff (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). jiff = "0.2"-craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }+# Release parsing, versions and platforms are craft-update's (`core` uses them without `native`,+# so they build for wasm32); `native` adds downloads, installs and self-update.+craft-update = { git = "https://github.com/smittie2000/craft-libs", version = "=0.1.0", rev = "209f9a93c3f4213859c1faf4e67b7126eea0d11c", default-features = false } [profile.dev.package."*"] opt-level = 2@@ -1,6 +1,6 @@ [package] name = "artcraft-launcher-core"-description = "ArtCraft Launcher core: the Craft App Suite catalog, versions, release parsing and asset choice (pure, wasm-safe)"+description = "ArtCraft Launcher core: the Craft App Suite catalog and feed, with craft-update's versions, release parsing and asset choice re-exported (pure, wasm-safe)" version.workspace = true edition.workspace = true license.workspace = true@@ -13,6 +13,6 @@ workspace = true [dependencies] serde = { workspace = true } serde_json = { workspace = true }-semver = { workspace = true } # Calendar conversion (jiff); see AGENTS.md. Only civil-date math is used, no time zone. jiff = { workspace = true }+craft-update = { workspace = true }deleted file mode 100644@@ -1,108 +0,0 @@-//! Linux distributions: reading `os-release` and choosing the package formats that suit them.-//!-//! The distro decides the system package format, not which tools happen to be on `PATH`: Fedora-//! can have `apt-get` and `dpkg` installed (for building Debian images), and an `.rpm` system-//! must never be handed to apt.--use crate::platform::PackageKind;--/// The parts of `/etc/os-release` that say which distro family this is.-#[derive(Clone, Debug, Default, PartialEq, Eq)]-pub struct OsRelease {- /// `ID`, lower-cased (`fedora`, `ubuntu`, `opensuse-tumbleweed`, …). Empty when missing.- pub id: String,- /// `ID_LIKE`, lower-cased and split on spaces (`["rhel", "centos", "fedora"]`).- pub id_like: Vec<String>,-}--/// Reads `os-release` text (`KEY=value` lines, values optionally quoted).-pub fn parse_os_release(text: &str) -> OsRelease {- let mut out = OsRelease::default();- for line in text.lines() {- let line = line.trim();- if line.starts_with('#') {- continue;- }- let Some((key, value)) = line.split_once('=') else { continue };- let value = unquote(value.trim()).to_ascii_lowercase();- match key.trim() {- "ID" => out.id = value,- "ID_LIKE" => out.id_like = value.split_whitespace().map(str::to_string).collect(),- _ => {}- }- }- out-}--fn unquote(v: &str) -> &str {- for q in ['"', '\''] {- if let Some(inner) = v.strip_prefix(q).and_then(|v| v.strip_suffix(q)) {- return inner;- }- }- v-}--/// The distro's own package format: `.deb` for the Debian family, `.rpm` for the Fedora, Red Hat-/// and SUSE families, `.pkg.tar.zst` for the Arch Linux family, `None` for anything else-/// (unknown distros), which gets AppImages.-pub fn native_package(os: &OsRelease) -> Option<PackageKind> {- std::iter::once(&os.id).chain(&os.id_like).find_map(|id| family(id))-}--fn family(id: &str) -> Option<PackageKind> {- match id {- "debian" | "ubuntu" => Some(PackageKind::Deb),- "fedora" | "rhel" | "centos" | "suse" | "sles" => Some(PackageKind::Rpm),- _ if id.starts_with("opensuse") => Some(PackageKind::Rpm),- "arch" | "manjaro" | "endeavouros" | "garuda" | "cachyos" | "artix" => Some(PackageKind::ArchPkg),- _ => None,- }-}--#[cfg(test)]-mod tests {- use super::*;-- const FEDORA: &str = r#"NAME="Fedora Linux"-VERSION="40 (Workstation Edition)"-ID=fedora-VERSION_ID=40-PRETTY_NAME="Fedora Linux 40 (Workstation Edition)"-"#;- const UBUNTU: &str = r#"PRETTY_NAME="Ubuntu 24.04.1 LTS"-NAME="Ubuntu"-VERSION_ID="24.04"-ID=ubuntu-ID_LIKE=debian-"#;- const MINT: &str = "NAME=\"Linux Mint\"\nID=linuxmint\nID_LIKE=\"ubuntu debian\"\n";- const ARCH: &str = "NAME=\"Arch Linux\"\nPRETTY_NAME=\"Arch Linux\"\nID=arch\nBUILD_ID=rolling\n";- const TUMBLEWEED: &str = "NAME=\"openSUSE Tumbleweed\"\n# a comment\nID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n";- const ROCKY: &str = "NAME=\"Rocky Linux\"\nID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n";- const UNKNOWN: &str = "NAME=\"Some OS\"\nID=someos\n";-- fn native(text: &str) -> Option<PackageKind> {- native_package(&parse_os_release(text))- }-- #[test]- fn os_release_is_parsed() {- assert_eq!(parse_os_release(FEDORA), OsRelease { id: "fedora".into(), id_like: vec![] });- assert_eq!(parse_os_release(MINT), OsRelease { id: "linuxmint".into(), id_like: vec!["ubuntu".into(), "debian".into()] });- assert_eq!(parse_os_release("ID='opensuse-leap'\n").id, "opensuse-leap");- assert_eq!(parse_os_release(""), OsRelease::default());- }-- #[test]- fn the_distro_family_picks_the_package_format() {- assert_eq!(native(FEDORA), Some(PackageKind::Rpm));- assert_eq!(native(ROCKY), Some(PackageKind::Rpm));- assert_eq!(native(TUMBLEWEED), Some(PackageKind::Rpm));- assert_eq!(native(UBUNTU), Some(PackageKind::Deb));- assert_eq!(native(MINT), Some(PackageKind::Deb));- assert_eq!(native(ARCH), Some(PackageKind::ArchPkg));- assert_eq!(native(UNKNOWN), None);- assert_eq!(native(""), None);- }-}@@ -186,14 +186,17 @@ impl FeedRelease { /// when it doesn't hold up (no tag, or not a version). fn to_release(&self, repo: &str, mirror: Option<&str>) -> Option<Release> { let version = version::parse(&self.tag)?;- let allowed = |url: &str| {- release::download_url_under(url, &release::download_prefix(repo)) || mirror.is_some_and(|m| release::download_url_under_mirror(url, m))+ // A GitHub link must be exactly this release's own `<tag>/<name>` (#764); a loopback mirror+ // keeps its own layout.+ let allowed = |name: &str, url: &str| {+ release::expected_download_url(repo, &self.tag, name).as_deref() == Some(url)+ || mirror.is_some_and(|m| release::download_url_under_mirror(url, m)) }; let releases_page = format!("https://github.com/{repo}/releases/"); let assets = self .assets .iter()- .filter(|(name, _, url, _)| release::valid_file_name(name) && allowed(url))+ .filter(|(name, _, url, _)| release::valid_file_name(name) && allowed(name, url)) .map(|(name, size, url, sha)| Asset { name: name.clone(), size: *size,@@ -575,6 +578,24 @@ mod tests { assert!(r.assets.is_empty(), "the traversing download must be rejected"); } + /// #764: a feed link must be exactly this release's `<tag>/<name>`, so neither a traversal+ /// (plain or percent-encoded) nor an older release of the same app gets through.+ #[test]+ fn a_feed_link_must_be_its_releases_own() {+ const REAL: &str = "https://github.com/storytold/photocraft/releases/download/v0.6.0/photocraft-0.6.0-macos-universal.dmg";+ for link in [+ "https://github.com/storytold/photocraft/releases/download/../../../../other/repo/releases/download/v1/photocraft-0.6.0-macos-universal.dmg",+ "https://github.com/storytold/photocraft/releases/download/%2e%2e/%2E%2E/%2e%2e/%2e%2e/other/repo/releases/download/v1/photocraft-0.6.0-macos-universal.dmg",+ // An older release of the same app, under this release's file name.+ "https://github.com/storytold/photocraft/releases/download/v0.4.0/photocraft-0.6.0-macos-universal.dmg",+ ] {+ let FeedAnswer::Fresh(Some(r)) = Feed::parse(&sample().replace(REAL, link)).unwrap().answer(app("photocraft"), NOW) else { panic!() };+ assert!(r.assets.is_empty(), "{link}");+ }+ let FeedAnswer::Fresh(Some(r)) = Feed::parse(&sample()).unwrap().answer(app("photocraft"), NOW) else { panic!() };+ assert_eq!(r.assets.iter().map(|a| a.url.as_str()).collect::<Vec<_>>(), [REAL]);+ }+ #[test] fn a_local_mirror_is_trusted_only_when_asked_for_and_never_goes_stale() { const MIRROR: &str = "http://127.0.0.1:8787/";@@ -8,14 +8,15 @@ pub mod catalog; pub mod details;-pub mod distro; pub mod feed;-pub mod platform; pub mod protocol;-pub mod release; pub mod status; pub mod time;-pub mod version;++// GitHub release parsing, versions, platforms and asset choice are shared with every Craft app+// through craft-update (built without its `native` feature, so they stay wasm-safe). Fix them+// there: storytold/craft-libs, crates/update.+pub use craft_update::{distro, platform, release, version}; pub use catalog::{CraftApp, SUITE}; pub use platform::{Arch, Os, PackageKind, Platform};deleted file mode 100644@@ -1,423 +0,0 @@-//! Platforms, package formats, and choosing the right release asset.-//!-//! Every Craft App release names its files `<app>-<version>-<os>-<arch>[-portable].<ext>`, for-//! example `photocraft-0.5.0-windows-x64.msi` or `photocraft-0.5.0-linux-aarch64.AppImage`.-//!-//! Labels are English. The UI translates [`PackageKind::label`]; OS and architecture names are-//! shown as they are.--use serde::{Deserialize, Serialize};--use crate::release::{Asset, Release};--#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]-pub enum Os {- Windows,- MacOs,- Linux,- FreeBsd,-}--impl Os {- pub fn label(self) -> &'static str {- match self {- Self::Windows => "Windows",- Self::MacOs => "macOS",- Self::Linux => "Linux",- Self::FreeBsd => "FreeBSD",- }- }-}--#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]-pub enum Arch {- X64,- X86,- Arm64,- Riscv64,-}--impl Arch {- pub fn label(self) -> &'static str {- match self {- Self::X64 => "x64",- Self::X86 => "x86",- Self::Arm64 => "ARM64",- Self::Riscv64 => "RISC-V",- }- }-}--/// A package format a release ships.-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]-pub enum PackageKind {- /// Windows Installer package (installs for all users into Program Files).- Msi,- /// Windows zip with the exe at the top level.- PortableZip,- /// macOS disk image with the `.app` bundle.- Dmg,- AppImage,- Deb,- Rpm,- /// Arch Linux package (`.pkg.tar.zst`).- ArchPkg,- Flatpak,- /// Linux or FreeBSD tarball (`bin/<app>`, `share/…`).- TarGz,-}--impl PackageKind {- pub fn label(self) -> &'static str {- match self {- Self::Msi => "Installer (.msi)",- Self::PortableZip => "Portable (.zip)",- Self::Dmg => "Disk image (.dmg)",- Self::AppImage => "AppImage",- Self::Deb => "Debian package (.deb)",- Self::Rpm => "RPM package (.rpm)",- Self::ArchPkg => "Arch package (.pkg.tar.zst)",- Self::Flatpak => "Flatpak bundle",- Self::TarGz => "Tarball (.tar.gz)",- }- }-- fn suffix(self) -> &'static str {- match self {- Self::Msi => ".msi",- Self::PortableZip => "-portable.zip",- Self::Dmg => ".dmg",- Self::AppImage => ".AppImage",- Self::Deb => ".deb",- Self::Rpm => ".rpm",- Self::ArchPkg => ".pkg.tar.zst",- Self::Flatpak => ".flatpak",- Self::TarGz => ".tar.gz",- }- }-}--/// Where the launcher is running (or, on the web, the visitor's machine).-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]-pub struct Platform {- pub os: Os,- pub arch: Arch,-}--impl Platform {- /// The platform this binary was built for, or `None` for wasm and unsupported targets.- pub fn current() -> Option<Self> {- let os = if cfg!(target_os = "windows") {- Os::Windows- } else if cfg!(target_os = "macos") {- Os::MacOs- } else if cfg!(target_os = "linux") {- Os::Linux- } else if cfg!(target_os = "freebsd") {- Os::FreeBsd- } else {- return None;- };- let arch = if cfg!(target_arch = "x86_64") {- Arch::X64- } else if cfg!(target_arch = "x86") {- Arch::X86- } else if cfg!(target_arch = "aarch64") {- Arch::Arm64- } else if cfg!(target_arch = "riscv64") {- Arch::Riscv64- } else {- return None;- };- Some(Self { os, arch })- }-- pub fn label(self) -> String {- match self.os {- Os::MacOs => "macOS".into(),- os => format!("{} {}", os.label(), self.arch.label()),- }- }-- /// Asset platform tokens to try, best first. Windows on ARM and 64-bit Windows can fall back- /// to emulated builds; macOS prefers universal, then native builds. Apple silicon can fall- /// back to Intel builds, but Intel Macs cannot run ARM builds.- fn tokens(self) -> &'static [&'static str] {- match (self.os, self.arch) {- (Os::Windows, Arch::X64) => &["windows-x64", "windows-x86"],- (Os::Windows, Arch::X86) => &["windows-x86"],- (Os::Windows, Arch::Arm64) => &["windows-arm64", "windows-x64", "windows-x86"],- (Os::Windows, _) => &[],- (Os::MacOs, Arch::X64) => &["macos-universal", "macos-x86_64"],- (Os::MacOs, Arch::Arm64) => &["macos-universal", "macos-aarch64", "macos-arm64", "macos-x86_64"],- (Os::MacOs, _) => &[],- (Os::Linux, Arch::X64) => &["linux-x86_64"],- (Os::Linux, Arch::Arm64) => &["linux-aarch64"],- (Os::Linux, Arch::Riscv64) => &["linux-riscv64"],- (Os::Linux, Arch::X86) => &[],- (Os::FreeBsd, Arch::X64) => &["freebsd-x86_64"],- (Os::FreeBsd, Arch::Arm64) => &["freebsd-aarch64"],- (Os::FreeBsd, _) => &[],- }- }-- /// Package formats the launcher installs on this OS, in order of preference.- pub fn default_kinds(self) -> &'static [PackageKind] {- match self.os {- Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip],- Os::MacOs => &[PackageKind::Dmg],- Os::Linux => &[PackageKind::AppImage, PackageKind::TarGz],- Os::FreeBsd => &[PackageKind::TarGz],- }- }-- /// Every format offered as a manual download on this OS (the web build's download menu).- pub fn download_kinds(self) -> &'static [PackageKind] {- match self.os {- Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip],- Os::MacOs => &[PackageKind::Dmg],- Os::Linux => &[PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz],- Os::FreeBsd => &[PackageKind::TarGz],- }- }-}--/// What an asset name says about itself.-#[derive(Clone, Debug, PartialEq, Eq)]-pub struct AssetName {- pub stem: String,- pub version: String,- pub token: String,- pub kind: PackageKind,-}--/// Reads `<stem>-<version>-<os>-<arch>[-portable].<ext>`; `None` for checksums, zsync files, CLI-/// and web bundles, or anything else.-pub fn parse_asset_name(name: &str, stems: &[&str]) -> Option<AssetName> {- const KINDS: [PackageKind; 9] = [- PackageKind::PortableZip,- PackageKind::TarGz,- PackageKind::Msi,- PackageKind::Dmg,- PackageKind::AppImage,- PackageKind::Deb,- PackageKind::Rpm,- PackageKind::ArchPkg,- PackageKind::Flatpak,- ];- let (kind, base) = KINDS.iter().find_map(|k| name.strip_suffix(k.suffix()).map(|b| (*k, b)))?;- for stem in stems {- let Some(rest) = base.strip_prefix(stem).and_then(|r| r.strip_prefix('-')) else { continue };- // rest = <version>-<os>-<arch>; os and arch never contain '-', versions may.- let mut parts = rest.rsplitn(3, '-');- let (arch, os, version) = (parts.next()?, parts.next()?, parts.next()?);- if crate::version::parse(version).is_none() || !version.starts_with(|c: char| c.is_ascii_digit()) {- continue;- }- return Some(AssetName { stem: (*stem).to_string(), version: version.to_string(), token: format!("{os}-{arch}"), kind });- }- None-}--/// The best asset of `release` for `platform`, trying `kinds` in order and, within a kind, the-/// platform's tokens in order (native before emulated).-pub fn pick_asset<'a>(release: &'a Release, stems: &[&str], platform: Platform, kinds: &[PackageKind]) -> Option<(&'a Asset, PackageKind)> {- let parsed: Vec<(&Asset, AssetName)> = release.assets.iter().filter_map(|a| parse_asset_name(&a.name, stems).map(|n| (a, n))).collect();- for kind in kinds {- for token in platform.tokens() {- if let Some((asset, _)) = parsed.iter().find(|(_, n)| n.kind == *kind && n.token == *token) {- return Some((asset, *kind));- }- }- }- None-}--#[cfg(test)]-mod tests {- use super::*;-- fn release(names: &[&str]) -> Release {- Release {- assets: names.iter().map(|n| Asset { name: (*n).into(), size: 1, url: format!("https://x/{n}"), sha256: None }).collect(),- ..Release::default()- }- }-- const PHOTO: &[&str] = &[- "photocraft-0.5.0-freebsd-x86_64.tar.gz",- "photocraft-0.5.0-linux-aarch64.AppImage",- "photocraft-0.5.0-linux-aarch64.AppImage.zsync",- "photocraft-0.5.0-linux-aarch64.deb",- "photocraft-0.5.0-linux-riscv64.tar.gz",- "photocraft-0.5.0-linux-x86_64.AppImage",- "photocraft-0.5.0-linux-x86_64.tar.gz",- "photocraft-0.5.0-macos-universal.dmg",- "photocraft-0.5.0-windows-x64-portable.zip",- "photocraft-0.5.0-windows-x64.msi",- "photocraft-0.5.0-windows-x86.msi",- "photocraft-cli-0.5.0-macos-universal.zip",- "photocraft-web-0.5.0.zip",- "SHA256SUMS.txt",- ];-- #[test]- fn macos_prefers_the_published_universal_build() {- // PhotoCraft v0.9.0's GitHub metadata, trimmed to its desktop DMG.- let mut r = crate::release::parse_github_release(include_str!("../tests/fixtures/photocraft-release.json"), "storytold/photocraft").unwrap();- // Split builds must not outrank the published universal DMG, even when listed first.- r.assets.splice(0..0, release(&["photocraft-0.9.0-macos-arm64.dmg", "photocraft-0.9.0-macos-x86_64.dmg"]).assets);- for arch in [Arch::X64, Arch::Arm64] {- let (asset, kind) = pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]).unwrap();- assert_eq!(asset.name, "photocraft-0.9.0-macos-universal.dmg");- assert_eq!(kind, PackageKind::Dmg);- }- }-- #[test]- fn freebsd_picks_the_tarball_for_the_processor() {- // SoundCraft v0.10.0's GitHub metadata, trimmed to its FreeBSD tarballs.- let r = crate::release::parse_github_release(include_str!("../tests/fixtures/soundcraft-release.json"), "storytold/soundcraft").unwrap();- let pick = |arch| {- let platform = Platform { os: Os::FreeBsd, arch };- pick_asset(&r, &["soundcraft"], platform, platform.default_kinds()).map(|(a, k)| (a.name.as_str(), k))- };- assert_eq!(pick(Arch::X64), Some(("soundcraft-0.10.0-freebsd-x86_64.tar.gz", PackageKind::TarGz)));- assert_eq!(pick(Arch::Arm64), Some(("soundcraft-0.10.0-freebsd-aarch64.tar.gz", PackageKind::TarGz)));- for arch in [Arch::X86, Arch::Riscv64] {- assert_eq!(pick(arch), None);- }- // An x86_64 machine never takes the ARM tarball, nor an ARM machine the x86_64 one.- let only_arm = release(&["soundcraft-0.10.0-freebsd-aarch64.tar.gz"]);- assert_eq!(pick_asset(&only_arm, &["soundcraft"], Platform { os: Os::FreeBsd, arch: Arch::X64 }, &[PackageKind::TarGz]), None);- let only_x64 = release(&["soundcraft-0.10.0-freebsd-x86_64.tar.gz"]);- assert_eq!(pick_asset(&only_x64, &["soundcraft"], Platform { os: Os::FreeBsd, arch: Arch::Arm64 }, &[PackageKind::TarGz]), None);- }-- #[test]- fn macos_split_builds_match_the_processor() {- let r = release(&["photocraft-0.5.0-macos-arm64.dmg", "photocraft-0.5.0-macos-x86_64.dmg"]);- let pick = |arch| pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str());- assert_eq!(pick(Arch::X64), Some("photocraft-0.5.0-macos-x86_64.dmg"));- assert_eq!(pick(Arch::Arm64), Some("photocraft-0.5.0-macos-arm64.dmg"));- }-- #[test]- fn intel_macos_never_selects_an_arm_only_build() {- for token in ["macos-aarch64", "macos-arm64"] {- let name = format!("photocraft-0.5.0-{token}.dmg");- let r = release(&[&name]);- assert_eq!(pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::X64 }, &[PackageKind::Dmg]), None);- assert_eq!(- pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::Arm64 }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str()),- Some(name.as_str())- );- }- }-- #[test]- fn apple_silicon_keeps_the_intel_fallback() {- let r = release(&["photocraft-0.5.0-macos-x86_64.dmg"]);- assert_eq!(- pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::Arm64 }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str()),- Some("photocraft-0.5.0-macos-x86_64.dmg")- );- for arch in [Arch::X86, Arch::Riscv64] {- assert_eq!(pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]), None);- }- }-- #[test]- fn reads_asset_names() {- let n = parse_asset_name("photocraft-0.2.0-rc.1-windows-arm64-portable.zip", &["photocraft"]).unwrap();- assert_eq!((n.version.as_str(), n.token.as_str(), n.kind), ("0.2.0-rc.1", "windows-arm64", PackageKind::PortableZip));- let n = parse_asset_name("printcraft-0.2.1-linux-x86_64.tar.gz", &["pdfcraft", "printcraft"]).unwrap();- assert_eq!((n.stem.as_str(), n.kind), ("printcraft", PackageKind::TarGz));- for other in- ["photocraft-cli-0.5.0-macos-universal.zip", "photocraft-web-0.5.0.zip", "SHA256SUMS.txt", "photocraft-0.5.0-linux-x86_64.AppImage.zsync"]- {- assert_eq!(parse_asset_name(other, &["photocraft"]), None, "{other}");- }- // FigCraft also ships its collaboration server; that tarball is not the app.- assert_eq!(parse_asset_name("figcraft-server-0.5.1-linux-x86_64.tar.gz", &["figcraft"]), None);- assert!(parse_asset_name("figcraft-0.5.1-linux-x86_64.tar.gz", &["figcraft"]).is_some());- }-- #[test]- fn picks_the_native_build_first_and_falls_back_to_emulation() {- let r = release(PHOTO);- let pick = |os, arch, kinds: &[PackageKind]| pick_asset(&r, &["photocraft"], Platform { os, arch }, kinds).map(|(a, _)| a.name.clone());- assert_eq!(pick(Os::MacOs, Arch::Arm64, &[PackageKind::Dmg]).as_deref(), Some("photocraft-0.5.0-macos-universal.dmg"));- assert_eq!(pick(Os::Windows, Arch::X64, &[PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x64.msi"));- // No arm64 MSI in this release: Windows on ARM runs the x64 build.- assert_eq!(pick(Os::Windows, Arch::Arm64, &[PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x64.msi"));- assert_eq!(pick(Os::Linux, Arch::Arm64, &[PackageKind::AppImage]).as_deref(), Some("photocraft-0.5.0-linux-aarch64.AppImage"));- assert_eq!(pick(Os::Linux, Arch::Arm64, &[PackageKind::TarGz]), None);- assert_eq!(pick(Os::Linux, Arch::Riscv64, &[PackageKind::TarGz]).as_deref(), Some("photocraft-0.5.0-linux-riscv64.tar.gz"));- assert_eq!(pick(Os::FreeBsd, Arch::X64, &[PackageKind::TarGz]).as_deref(), Some("photocraft-0.5.0-freebsd-x86_64.tar.gz"));- // Preference order across kinds.- assert_eq!(- pick(Os::Windows, Arch::X64, &[PackageKind::PortableZip, PackageKind::Msi]).as_deref(),- Some("photocraft-0.5.0-windows-x64-portable.zip")- );- assert_eq!(pick(Os::Windows, Arch::X86, &[PackageKind::PortableZip, PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x86.msi"));-- let p_riscv = Platform { os: Os::Linux, arch: Arch::Riscv64 };- assert_eq!(- pick_asset(&r, &["photocraft"], p_riscv, p_riscv.default_kinds()).map(|(a, _)| a.name.clone()).as_deref(),- Some("photocraft-0.5.0-linux-riscv64.tar.gz")- );- assert_eq!(- pick_asset(&r, &["photocraft"], p_riscv, p_riscv.download_kinds()).map(|(a, _)| a.name.clone()).as_deref(),- Some("photocraft-0.5.0-linux-riscv64.tar.gz")- );- let r_riscv_deb =- release(&["photocraft-0.5.0-linux-riscv64.tar.gz", "photocraft-0.5.0-linux-riscv64.deb", "photocraft-0.5.0-linux-riscv64.rpm"]);- assert_eq!(- pick_asset(&r_riscv_deb, &["photocraft"], p_riscv, p_riscv.download_kinds()).map(|(a, _)| a.name.clone()).as_deref(),- Some("photocraft-0.5.0-linux-riscv64.deb")- );- }-- #[test]- fn strictly_matches_architecture_for_all_linux_package_formats() {- let multi_arch_release = release(&[- "photocraft-0.5.0-linux-aarch64.AppImage",- "photocraft-0.5.0-linux-x86_64.AppImage",- "photocraft-0.5.0-linux-aarch64.deb",- "photocraft-0.5.0-linux-x86_64.deb",- "photocraft-0.5.0-linux-aarch64.rpm",- "photocraft-0.5.0-linux-x86_64.rpm",- "photocraft-0.5.0-linux-aarch64.pkg.tar.zst",- "photocraft-0.5.0-linux-x86_64.pkg.tar.zst",- "photocraft-0.5.0-linux-aarch64.flatpak",- "photocraft-0.5.0-linux-x86_64.flatpak",- "photocraft-0.5.0-linux-aarch64.tar.gz",- "photocraft-0.5.0-linux-x86_64.tar.gz",- ]);-- let p_x64 = Platform { os: Os::Linux, arch: Arch::X64 };- let p_arm64 = Platform { os: Os::Linux, arch: Arch::Arm64 };-- for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz] {- let pick_x64 = pick_asset(&multi_arch_release, &["photocraft"], p_x64, &[kind]).map(|(a, _)| a.name.clone());- assert!(pick_x64.as_deref().unwrap().contains("linux-x86_64"), "Expected x86_64 asset for {kind:?}, got {pick_x64:?}");-- let pick_arm64 = pick_asset(&multi_arch_release, &["photocraft"], p_arm64, &[kind]).map(|(a, _)| a.name.clone());- assert!(pick_arm64.as_deref().unwrap().contains("linux-aarch64"), "Expected aarch64 asset for {kind:?}, got {pick_arm64:?}");- }-- // On x86_64 Linux, an aarch64-only asset must NEVER match or fall back- let arm_only_release = release(&[- "photocraft-0.5.0-linux-aarch64.AppImage",- "photocraft-0.5.0-linux-aarch64.deb",- "photocraft-0.5.0-linux-aarch64.rpm",- "photocraft-0.5.0-linux-aarch64.pkg.tar.zst",- "photocraft-0.5.0-linux-aarch64.flatpak",- "photocraft-0.5.0-linux-aarch64.tar.gz",- ]);- for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz] {- assert_eq!(pick_asset(&arm_only_release, &["photocraft"], p_x64, &[kind]), None, "x86_64 must never match an aarch64 asset for {kind:?}");- }- }-}deleted file mode 100644@@ -1,438 +0,0 @@-//! GitHub releases: reading the API's answer and `SHA256SUMS.txt`.-//!-//! Everything here is untrusted input. Sizes are bounded by the caller, download links must point-//! at the app's own release downloads, and checksums must look like checksums.--use serde::{Deserialize, Serialize};--use crate::version::{self, Version};--/// One downloadable file of a release.-#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]-pub struct Asset {- pub name: String,- /// Bytes; 0 when unknown.- pub size: u64,- /// `https://github.com/<repo>/releases/download/<tag>/<name>`.- pub url: String,- /// Lowercase hex SHA-256, when GitHub or `SHA256SUMS.txt` told us.- pub sha256: Option<String>,-}--/// A published release of one app.-#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]-pub struct Release {- pub tag: String,- pub version: Option<Version>,- /// `PhotoCraft v0.5.0`- pub name: String,- /// Unix seconds; `None` when only the tag is known.- pub published_at: Option<i64>,- pub html_url: String,- /// Release notes (Markdown), capped.- pub notes: String,- pub prerelease: bool,- pub assets: Vec<Asset>,-}--impl Release {- pub fn version_label(&self) -> String {- self.version.as_ref().map_or_else(|| self.tag.clone(), ToString::to_string)- }-- /// Fills in checksums from a `SHA256SUMS.txt` for assets that don't have one.- pub fn apply_sums(&mut self, sums: &[(String, String)]) {- for asset in &mut self.assets {- if asset.sha256.is_none() {- asset.sha256 = sums.iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h.clone());- }- }- }-- pub fn sums_asset(&self) -> Option<&Asset> {- self.assets.iter().find(|a| a.name == "SHA256SUMS.txt")- }-}--const MAX_NOTES: usize = 64 * 1024;--fn is_hex64(s: &str) -> bool {- s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())-}--/// Whether `url` is safe to hand to the system browser from untrusted text (release notes):-/// `https` only, a host, no user-info, no spaces or control characters, and no shell metacharacters.-///-/// The system opener is not always a direct `open`: the pinned `webbrowser` crate's WSL fallback-/// passes the URL to `cmd.exe /c start` while escaping only `^` and `&`, so a character like `|`-/// in an otherwise valid URL becomes a command separator (CWE-78). Because a legitimate-/// `https://` web link never needs these characters unescaped, we refuse any URL that contains a-/// shell/cmd metacharacter rather than rely on the opener to neutralise it.-pub fn safe_web_link(url: &str) -> bool {- let Some(rest) = url.strip_prefix("https://") else { return false };- let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();- url.len() <= 2000- && !authority.is_empty()- && !authority.contains(['@', '\\'])- && !url.chars().any(|c| c.is_control() || c.is_whitespace())- && !url.chars().any(is_shell_meta)-}--/// Characters a command interpreter (POSIX shells or `cmd.exe`) may treat specially. A real-/// `https` URL never needs any of them unescaped in a browser-opened link. `%` matters because-/// `cmd.exe` expands `%NAME%` references, and an expanded value could itself carry metacharacters.-pub fn is_shell_meta(c: char) -> bool {- matches!(c, '|' | '&' | ';' | '<' | '>' | '(' | ')' | '$' | '`' | '"' | '\'' | '^' | '!' | '*' | '%' | '{' | '}' | '[' | ']')-}--/// Whether `url` is a valid release page URL strictly for `repo` on GitHub:-/// starts with `https://github.com/<repo>/releases`, contains no dot-segments (`.` or `..`),-/// no percent-encoded path separators (`%2f`, `%5c`), no query/fragment/backslash, and-/// satisfies [`safe_web_link`] (no shell metacharacters or control bytes, CWE-78).-pub fn safe_release_html_url(repo: &str, url: &str) -> Option<String> {- if !safe_web_link(url) {- return None;- }- let releases = format!("https://github.com/{repo}/releases");- if url == releases {- return Some(url.to_string());- }- let rest = url.strip_prefix(&releases)?;- let path = rest.strip_prefix('/')?;- if path.is_empty() || path.contains(['?', '#', '\\']) {- return None;- }- for seg in path.split('/') {- if seg.is_empty() || seg == "." || seg == ".." || seg.contains("%2f") || seg.contains("%2F") || seg.contains("%5c") || seg.contains("%5C") {- return None;- }- }- Some(url.to_string())-}--/// The prefix every download of `repo` must start with.-pub fn download_prefix(repo: &str) -> String {- format!("https://github.com/{repo}/releases/download/")-}--/// Whether `url` is a download that truly belongs to `repo`.-///-/// A raw `starts_with(prefix)` check is not enough: a value such as-/// `https://github.com/storytold/craft-launcher/releases/download/../../../ATTACKER/REPO/releases/download/v1/x`-/// shares the prefix textually, yet its interpreted path escapes the repository. The link is-/// accepted only when the text after the prefix is exactly `<tag>/<name>` — two non-empty path-/// segments with no traversal (`.`/`..`), no additional slashes, no query or fragment, and no-/// backslash or percent-encoded separators that a client could later re-interpret.-pub fn download_url_under(url: &str, prefix: &str) -> bool {- let Some(rest) = url.strip_prefix(prefix) else { return false };- download_suffix_ok(rest)-}--/// Whether `url` sits under a developer mirror `base` (ending in `/`) without escaping it.-pub fn download_url_under_mirror(url: &str, base: &str) -> bool {- let Some(rest) = url.strip_prefix(base) else { return false };- // A mirror serves `<tag>/<name>` just like GitHub, but we only require that it can't climb out- // of its own folder; its layout is otherwise the developer's business.- !rest.is_empty() && !url.contains(['?', '#', '\\']) && !contains_traversal(rest) && !contains_encoded_separator(rest)-}--/// `<tag>/<name>`: exactly two non-empty segments, no traversal, no re-interpretable separators.-fn download_suffix_ok(rest: &str) -> bool {- if rest.contains(['?', '#', '\\']) || contains_encoded_separator(rest) {- return false;- }- let mut parts = rest.split('/');- let (Some(tag), Some(name), None) = (parts.next(), parts.next(), parts.next()) else { return false };- !tag.is_empty() && tag != "." && tag != ".." && tag.len() <= 64 && !tag.contains('\\') && valid_file_name(name)-}--/// Any `.` or `..` path segment that could climb out of the repository.-fn contains_traversal(rest: &str) -> bool {- rest.split('/').any(|seg| seg == "." || seg == "..")-}--/// Percent-encoded slash or backslash (`%2f`, `%5c`): a client could decode these back into-/// separators after the check, so a confined URL must not contain them.-fn contains_encoded_separator(rest: &str) -> bool {- let bytes = rest.as_bytes();- bytes.windows(3).any(|w| w[0] == b'%' && matches!(&w[1..].to_ascii_lowercase()[..], b"2f" | b"5c"))-}--/// Reads `GET /repos/<repo>/releases/latest` (or one element of `/releases`).-pub fn parse_github_release(json: &str, repo: &str) -> Result<Release, String> {- let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("unreadable answer from GitHub ({e})"))?;- release_from_value(&v, repo)-}--/// Reads `GET /repos/<repo>/releases` and returns the newest stable (or any, with-/// `allow_prerelease`) published release.-pub fn parse_github_release_list(json: &str, repo: &str, allow_prerelease: bool) -> Result<Option<Release>, String> {- let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("unreadable answer from GitHub ({e})"))?;- let list = v.as_array().ok_or("unexpected answer from GitHub")?;- let mut best: Option<Release> = None;- for item in list {- if item["draft"].as_bool().unwrap_or(false) {- continue;- }- let Ok(r) = release_from_value(item, repo) else { continue };- let Some(ver) = r.version.as_ref() else { continue };- if !allow_prerelease && (r.prerelease || !version::is_stable(ver)) {- continue;- }- let newer = best.as_ref().and_then(|b| b.version.as_ref()).is_none_or(|b| version::is_newer(ver, b));- if newer {- best = Some(r);- }- }- Ok(best)-}--fn release_from_value(v: &serde_json::Value, repo: &str) -> Result<Release, String> {- if let Some(msg) = v["message"].as_str() {- return Err(if msg == "Not Found" { "no releases yet".into() } else { format!("GitHub says: {}", truncate(msg, 200)) });- }- let tag = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string();- let releases = format!("https://github.com/{repo}/releases");- let html_url = v["html_url"].as_str().and_then(|u| safe_release_html_url(repo, u)).unwrap_or_else(|| releases.clone());- let prefix = download_prefix(repo);- let assets = v["assets"]- .as_array()- .map(|list| {- list.iter()- .filter_map(|a| {- let name = a["name"].as_str().filter(|n| valid_file_name(n))?.to_string();- let url = a["browser_download_url"].as_str().filter(|u| download_url_under(u, &prefix))?.to_string();- let sha256 = a["digest"].as_str().and_then(|d| d.strip_prefix("sha256:")).filter(|h| is_hex64(h)).map(str::to_ascii_lowercase);- Some(Asset { name, size: a["size"].as_u64().unwrap_or(0), url, sha256 })- })- .collect()- })- .unwrap_or_default();- Ok(Release {- version: version::parse(&tag),- name: v["name"].as_str().map(|n| truncate(n, 120)).filter(|n| !n.is_empty()).unwrap_or_else(|| tag.clone()),- tag,- published_at: v["published_at"].as_str().and_then(crate::time::parse_rfc3339),- html_url,- notes: v["body"].as_str().map(|b| truncate(b, MAX_NOTES)).unwrap_or_default(),- prerelease: v["prerelease"].as_bool().unwrap_or(false),- assets,- })-}--/// A release known only from its tag and `SHA256SUMS.txt` (the quota-free route: the-/// `releases/latest` redirect names the tag, and the sums list every file).-pub fn release_from_sums(repo: &str, tag: &str, sums: &[(String, String)]) -> Release {- let prefix = download_prefix(repo);- let mut assets: Vec<Asset> = sums- .iter()- .map(|(hash, name)| Asset { name: name.clone(), size: 0, url: format!("{prefix}{tag}/{name}"), sha256: Some(hash.clone()) })- .collect();- assets.push(Asset { name: "SHA256SUMS.txt".into(), size: 0, url: format!("{prefix}{tag}/SHA256SUMS.txt"), sha256: None });- Release {- tag: tag.to_string(),- version: version::parse(tag),- name: tag.to_string(),- published_at: None,- html_url: format!("https://github.com/{repo}/releases/tag/{tag}"),- notes: String::new(),- prerelease: false,- assets,- }-}--/// The tag in a `https://github.com/<repo>/releases/tag/<tag>` redirect target.-pub fn tag_from_redirect(location: &str, repo: &str) -> Option<String> {- let rest = location.strip_prefix(&format!("https://github.com/{repo}/releases/tag/"))?;- (!rest.is_empty() && rest.len() <= 64 && rest.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))).then(|| rest.to_string())-}--/// Reads `sha256sum` output: `<hex> <name>` (or `<hex> *<name>`) per line.-pub fn parse_sha256sums(text: &str) -> Vec<(String, String)> {- text.lines()- .filter_map(|line| {- let (hash, rest) = line.trim().split_once(char::is_whitespace)?;- let name = rest.trim_start().trim_start_matches('*');- (is_hex64(hash) && valid_file_name(name)).then(|| (hash.to_ascii_lowercase(), name.to_string()))- })- .collect()-}--/// A bare file name we'd be willing to save: no paths, no control characters.-pub fn valid_file_name(name: &str) -> bool {- !name.is_empty()- && name.len() <= 200- && name != "."- && name != ".."- && !name.starts_with('.')- && !name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*'))-}--/// `s` cut to at most `max` bytes (on a char boundary) with an ellipsis, for text from a-/// release or the feed shown in the UI.-pub(crate) fn truncate(s: &str, max: usize) -> String {- if s.len() <= max {- return s.to_string();- }- let mut end = max;- while !s.is_char_boundary(end) {- end -= 1;- }- format!("{}…", &s[..end])-}--#[cfg(test)]-mod tests {- use super::*;-- const REPO: &str = "storytold/photocraft";-- #[test]- fn truncate_keeps_whole_characters() {- assert_eq!(truncate("short", 10), "short");- assert_eq!(truncate("abcdef", 3), "abc…");- // `é` is two bytes: cutting at 2 would split it, so the cut backs off to 1.- assert_eq!(truncate("aé", 2), "a…");- }-- #[test]- fn only_plain_https_links_pass() {- assert!(safe_web_link("https://github.com/storytold/photocraft/releases/tag/v1"));- for bad in [- "http://example.com/",- "javascript:alert(1)",- "file:///C:/Windows/System32/calc.exe",- "ms-msdt:/id",- "https://",- "https://user@evil.example/",- "https://example.com/a b",- "https://example.com/\nx",- "https:///path",- // Shell/cmd metacharacters: the WSL opener fallback shells out, so these must be- // refused even inside an otherwise valid https URL (CWE-78).- "https://example.com/a|calc",- "https://example.com/p?x=1&y=2",- "https://example.com/`id`",- "https://example.com/$(whoami)",- "https://example.com/a;b",- "https://example.com/a>b",- "https://example.com/%COMSPEC%",- "https://example.com/%PATH%",- ] {- assert!(!safe_web_link(bad), "{bad:?}");- }- }- const HASH: &str = "8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1";-- fn api_json(url: &str) -> String {- format!(- r#"{{"tag_name":"v0.5.0","name":"PhotoCraft v0.5.0","html_url":"https://github.com/storytold/photocraft/releases/tag/v0.5.0",- "published_at":"2026-10-08T14:52:50Z","prerelease":false,"body":"Notes",- "assets":[{{"name":"photocraft-0.5.0-macos-universal.dmg","size":123,"browser_download_url":"{url}","digest":"sha256:{HASH}"}}]}}"#- )- }-- #[test]- fn reads_the_latest_release() {- let r = parse_github_release(- &api_json("https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg"),- REPO,- )- .unwrap();- assert_eq!(r.version, Some(Version::new(0, 5, 0)));- assert_eq!(r.name, "PhotoCraft v0.5.0");- assert_eq!(r.published_at, Some(1_791_471_170));- assert_eq!(r.assets.len(), 1);- assert_eq!(r.assets[0].sha256.as_deref(), Some(HASH));- assert_eq!(r.assets[0].size, 123);- }-- #[test]- fn drops_downloads_that_point_elsewhere() {- for url in ["https://evil.example/photocraft.dmg", "https://github.com/storytold/other/releases/download/v1/x.dmg", "javascript:alert(1)"] {- let r = parse_github_release(&api_json(url), REPO).unwrap();- assert!(r.assets.is_empty(), "{url}");- }- assert_eq!(parse_github_release(r#"{"message":"Not Found"}"#, REPO).unwrap_err(), "no releases yet");- assert!(parse_github_release("<html>", REPO).is_err());- }-- #[test]- fn release_html_url_rejects_traversal_and_metacharacters() {- let repo = "storytold/photocraft";- assert_eq!(- safe_release_html_url(repo, "https://github.com/storytold/photocraft/releases/tag/v1.0.0"),- Some("https://github.com/storytold/photocraft/releases/tag/v1.0.0".to_string())- );- assert_eq!(- safe_release_html_url(repo, "https://github.com/storytold/photocraft/releases"),- Some("https://github.com/storytold/photocraft/releases".to_string())- );- for bad in [- "https://github.com/storytold/photocraft/releases/../../attacker/repo/releases/tag/v1",- "https://github.com/storytold/photocraft/releases/tag/v1/../../../attacker/repo",- "https://github.com/storytold/photocraft/releases/tag/v1|calc.exe",- "https://github.com/storytold/photocraft/releases/tag/v1%2f..%2f..",- "https://github.com/storytold/photocraft/releases/tag/v1?x=1",- "https://github.com/storytold/photocraft/releases/tag/v1#hash",- "https://github.com/storytold/other/releases/tag/v1.0.0",- "http://github.com/storytold/photocraft/releases/tag/v1",- ] {- assert!(safe_release_html_url(repo, bad).is_none(), "should reject: {bad}");- }- }-- #[test]- fn traversal_cannot_escape_the_repository() {- // Raw-prefix matches but the interpreted path climbs into another repository (CWE-180).- for url in [- "https://github.com/storytold/photocraft/releases/download/../../../../attacker/repo/releases/download/v99/photocraft-99.0.0-linux-x86_64.AppImage",- "https://github.com/storytold/photocraft/releases/download/v1/../../../attacker/repo/x.dmg",- "https://github.com/storytold/photocraft/releases/download/./v1/x.dmg",- "https://github.com/storytold/photocraft/releases/download/v1/sub/x.dmg",- "https://github.com/storytold/photocraft/releases/download/v1%2f..%2f..%2fx.dmg",- "https://github.com/storytold/photocraft/releases/download/v1/x%2fy.dmg",- ] {- let prefix = download_prefix(REPO);- assert!(!download_url_under(url, &prefix), "{url}");- let r = parse_github_release(&api_json(url), REPO).unwrap();- assert!(r.assets.is_empty(), "{url}");- }- // A plain, confined download still passes.- let prefix = download_prefix(REPO);- assert!(download_url_under("https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg", &prefix));- }-- #[test]- fn list_picks_the_newest_stable_release() {- let json = r#"[- {"tag_name":"v0.6.0-rc.1","prerelease":false,"draft":false,"assets":[]},- {"tag_name":"v0.5.1","prerelease":true,"draft":false,"assets":[]},- {"tag_name":"v0.7.0","draft":true,"assets":[]},- {"tag_name":"v0.5.0","prerelease":false,"draft":false,"assets":[]},- {"tag_name":"v0.4.0","prerelease":false,"draft":false,"assets":[]}]"#;- assert_eq!(parse_github_release_list(json, REPO, false).unwrap().unwrap().tag, "v0.5.0");- assert_eq!(parse_github_release_list(json, REPO, true).unwrap().unwrap().tag, "v0.6.0-rc.1");- assert_eq!(parse_github_release_list("[]", REPO, false).unwrap(), None);- }-- #[test]- fn reads_checksums_and_redirects() {- let sums = parse_sha256sums(&format!("{HASH} photocraft-0.5.0-macos-universal.dmg\n{HASH} *b.zip\nnot a line\n{HASH} ../escape\n"));- assert_eq!(sums, [(HASH.to_string(), "photocraft-0.5.0-macos-universal.dmg".to_string()), (HASH.to_string(), "b.zip".to_string())]);- assert_eq!(tag_from_redirect("https://github.com/storytold/photocraft/releases/tag/v0.5.0", REPO).as_deref(), Some("v0.5.0"));- assert_eq!(tag_from_redirect("https://github.com/storytold/photocraft/releases", REPO), None);- assert_eq!(tag_from_redirect("https://evil/storytold/photocraft/releases/tag/v1", REPO), None);- let r = release_from_sums(REPO, "v0.5.0", &sums);- assert_eq!(r.assets[0].url, "https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg");- assert_eq!(r.version, Some(Version::new(0, 5, 0)));- }-- #[test]- fn file_names_cannot_escape() {- for bad in ["", "..", "a/b", "a\\b", ".hidden", "c:x", "x\u{0}"] {- assert!(!valid_file_name(bad), "{bad:?}");- }- assert!(valid_file_name("photocraft-0.5.0-linux-x86_64.AppImage"));- }-}deleted file mode 100644@@ -1,183 +0,0 @@-//! Versions: release tags (`v0.5.0`), installed versions from many sources, and ordering.--use std::cmp::Ordering;--pub use semver::Version;--/// Reads a version from a tag or an installed-version string.-///-/// Accepts `v0.5.0`, `0.5.0`, `0.2.0-rc.1`, Debian's `0.2.0~rc.1-1`, and the two-part `0.5`-/// some platforms report. Build metadata is kept but never affects ordering.-pub fn parse(text: &str) -> Option<Version> {- let t = text.trim();- let t = t.strip_prefix(['v', 'V']).unwrap_or(t);- if t.is_empty() || t.len() > 64 {- return None;- }- // Debian/RPM spell pre-releases with `~` and append a package revision after `-`.- let debianish = t.contains('~');- let t = if debianish { t.split_once('-').map_or(t, |(v, _)| v).replace('~', "-") } else { t.to_string() };- if let Ok(v) = Version::parse(&t) {- return Some(v);- }- // `0.5` → `0.5.0`- let (core, rest) = match t.find(['-', '+']) {- Some(i) => t.split_at(i),- None => (t.as_str(), ""),- };- let parts: Vec<&str> = core.split('.').collect();- if parts.len() == 2 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) {- return Version::parse(&format!("{core}.0{rest}")).ok();- }- None-}--/// Reads a version as a system package manager reports it, dropping what the packaging adds:-/// an epoch (`1:`), the package revision (`-1`, `-2.fc40`), Debian's `~` pre-release spelling, and-/// a VCS snapshot suffix (`0.3.0.r47.g47f9306` from an AUR `-git` package, read as `0.3.0`).-pub fn parse_package(text: &str) -> Option<Version> {- let t = text.trim();- // Epoch.- let t = match t.split_once(':') {- Some((epoch, rest)) if !epoch.is_empty() && epoch.bytes().all(|b| b.is_ascii_digit()) => rest,- _ => t,- };- // Package revision: whatever follows the last '-' when the part before it is a version.- let t = match t.rsplit_once('-') {- Some((upstream, rev)) if !rev.is_empty() && rev.starts_with(|c: char| c.is_ascii_digit()) && upstream.contains('.') => upstream,- _ => t,- };- let t = t.replace('~', "-");- if let Some(v) = parse(&t) {- return Some(v);- }- // VCS snapshots and other suffixes: the leading X.Y.Z.- let core: String = t.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();- let parts: Vec<&str> = core.split('.').filter(|p| !p.is_empty()).take(3).collect();- match parts.as_slice() {- [a, b, c] => Version::parse(&format!("{a}.{b}.{c}")).ok(),- [a, b] => Version::parse(&format!("{a}.{b}.0")).ok(),- // Text around the version, as in `--version` output (`PhotoCraft 0.5.0 (abc, 2026)`):- // the first `X.Y.Z` run anywhere.- _ => first_xyz(&t),- }-}--/// The first `major.minor.patch` run anywhere in `text`.-fn first_xyz(text: &str) -> Option<Version> {- let bytes = text.as_bytes();- for start in 0..bytes.len() {- if !bytes.get(start).is_some_and(u8::is_ascii_digit) || (start > 0 && bytes.get(start - 1).is_some_and(u8::is_ascii_digit)) {- continue;- }- let Some((major, i)) = digits(text, start) else { continue };- if bytes.get(i) != Some(&b'.') {- continue;- }- let Some((minor, j)) = digits(text, i + 1) else { continue };- if bytes.get(j) != Some(&b'.') {- continue;- }- let Some((patch, _)) = digits(text, j + 1) else { continue };- return Some(Version::new(major, minor, patch));- }- None-}--/// A run of ASCII digits from `from`, as a number and the index just past it.-fn digits(text: &str, from: usize) -> Option<(u64, usize)> {- let bytes = text.as_bytes();- let mut end = from;- while bytes.get(end).is_some_and(u8::is_ascii_digit) {- end += 1;- }- if end == from {- return None;- }- text.get(from..end)?.parse().ok().map(|n| (n, end))-}--/// A version that's a plain `X.Y.Z` with no pre-release suffix.-pub fn is_stable(v: &Version) -> bool {- v.pre.is_empty()-}--/// Orders by precedence, ignoring build metadata.-pub fn compare(a: &Version, b: &Version) -> Ordering {- a.cmp_precedence(b)-}--/// True when `latest` is newer than `installed`.-pub fn is_newer(latest: &Version, installed: &Version) -> bool {- compare(latest, installed) == Ordering::Greater-}--/// Platforms that only record `X.Y.Z` (the MSI ProductVersion, `CFBundleShortVersionString`)-/// drop a pre-release suffix. A numeric-only installed version therefore counts as current when it-/// matches a pre-release's numbers, rather than nagging forever.-pub fn is_update(latest: &Version, installed: &Version) -> bool {- if installed.pre.is_empty()- && !latest.pre.is_empty()- && (latest.major, latest.minor, latest.patch) == (installed.major, installed.minor, installed.patch)- {- return false;- }- is_newer(latest, installed)-}--#[cfg(test)]-mod tests {- use super::*;-- fn v(s: &str) -> Version {- parse(s).unwrap()- }-- #[test]- fn reads_tags_and_platform_spellings() {- assert_eq!(v("v0.5.0"), Version::new(0, 5, 0));- assert_eq!(v(" 1.2.3\n"), Version::new(1, 2, 3));- assert_eq!(v("0.5"), Version::new(0, 5, 0));- assert_eq!(v("0.2.0~rc.1-1").to_string(), "0.2.0-rc.1");- assert_eq!(v("0.2.0-rc.1").to_string(), "0.2.0-rc.1");- for bad in ["", "v", "latest", "1", "1.x.0", "0.5.", &"9".repeat(80)] {- assert_eq!(parse(bad), None, "{bad}");- }- }-- #[test]- fn reads_package_manager_versions() {- let p = |s: &str| parse_package(s).map(|v| v.to_string());- // dpkg and pacman add a package revision; it is not a pre-release.- assert_eq!(p("0.5.0-1").as_deref(), Some("0.5.0"));- assert_eq!(p("0.5.0-12").as_deref(), Some("0.5.0"));- assert_eq!(p("1:0.5.0-1").as_deref(), Some("0.5.0"));- assert_eq!(p("0.2.0~rc.1-1").as_deref(), Some("0.2.0-rc.1"));- assert_eq!(p("0.5.0-2.fc40").as_deref(), Some("0.5.0"));- // AUR -git packages: the release the snapshot follows.- assert_eq!(p("0.3.0.r47.g47f9306-1").as_deref(), Some("0.3.0"));- // Plain versions (rpm %{VERSION}, Flatpak) pass through.- assert_eq!(p("0.4.0").as_deref(), Some("0.4.0"));- assert_eq!(p("0.5").as_deref(), Some("0.5.0"));- // A version inside text, as `--version` prints it.- assert_eq!(p("PhotoCraft 0.5.0 (abc, 2026)").as_deref(), Some("0.5.0"));- assert_eq!(p("junk"), None);- assert_eq!(p("garbage"), None);- // The bug this guards: 0.5.0-1 used to read as a pre-release, so an installed .deb of the- // latest release always looked out of date.- assert!(!is_update(&v("0.5.0"), &parse_package("0.5.0-1").unwrap()));- }-- #[test]- fn orders_versions_and_spots_updates() {- assert!(is_newer(&v("0.10.0"), &v("0.9.9")));- assert!(is_newer(&v("0.2.0"), &v("0.2.0-rc.5")));- assert!(!is_newer(&v("0.2.0+abc"), &v("0.2.0")));- assert!(is_update(&v("0.5.0"), &v("0.4.1")));- assert!(!is_update(&v("0.5.0"), &v("0.5.0")));- assert!(!is_update(&v("0.5.0"), &v("0.6.0")));- // An MSI that recorded 0.3.0 for 0.3.0-rc.2 is not out of date against 0.3.0-rc.2.- assert!(!is_update(&v("0.3.0-rc.2"), &v("0.3.0")));- assert!(is_stable(&v("1.0.0")) && !is_stable(&v("1.0.0-beta.1")));- }-}deleted file mode 100644@@ -1,16 +0,0 @@-{- "tag_name": "v0.9.0",- "name": "PhotoCraft v0.9.0",- "html_url": "https://github.com/storytold/photocraft/releases/tag/v0.9.0",- "published_at": "2026-10-14T16:36:26Z",- "prerelease": false,- "draft": false,- "assets": [- {- "name": "photocraft-0.9.0-macos-universal.dmg",- "size": 90386785,- "browser_download_url": "https://github.com/storytold/photocraft/releases/download/v0.9.0/photocraft-0.9.0-macos-universal.dmg",- "digest": "sha256:06503eb1c845990e99f8824831cc73b274e2b1dbe4461eb6460dfc9a6008239d"- }- ]-}deleted file mode 100644@@ -1,22 +0,0 @@-{- "tag_name": "v0.10.0",- "name": "SoundCraft v0.10.0",- "html_url": "https://github.com/storytold/soundcraft/releases/tag/v0.10.0",- "published_at": "2026-10-20T14:34:31Z",- "prerelease": false,- "draft": false,- "assets": [- {- "name": "soundcraft-0.10.0-freebsd-aarch64.tar.gz",- "size": 14786997,- "browser_download_url": "https://github.com/storytold/soundcraft/releases/download/v0.10.0/soundcraft-0.10.0-freebsd-aarch64.tar.gz",- "digest": "sha256:6a791aa9afd693d701bdc80bc19839eabe0ccbc8d8424b5684ef1ac46d6d68e6"- },- {- "name": "soundcraft-0.10.0-freebsd-x86_64.tar.gz",- "size": 16384262,- "browser_download_url": "https://github.com/storytold/soundcraft/releases/download/v0.10.0/soundcraft-0.10.0-freebsd-x86_64.tar.gz",- "digest": "sha256:189d9e997cbab32d1672d824ee337a9447e54dd2be4934ff1537e2f53c52ea28"- }- ]-}@@ -13,12 +13,13 @@ workspace = true [features] # Lets ARTCRAFT_LAUNCHER_DOWNLOAD_BASE point the launcher at a developer's own loopback mirror # (`cargo xtask local serve`). Development only: release builds never enable it, so a shipped-# launcher can't be steered away from GitHub's releases by an environment variable.-local-mirror = []+# launcher can't be steered away from GitHub's releases by an environment variable. craft-update's+# own flag lets its last check before a download (and the self-update) accept the mirror's links.+local-mirror = ["craft-update/local-mirror"] [dependencies] artcraft-launcher-core = { workspace = true }-craft-update = { workspace = true }+craft-update = { workspace = true, features = ["native"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true }@@ -8,7 +8,7 @@ use std::time::Duration; use sha2::{Digest, Sha256}; -const USER_AGENT: &str = concat!("ArtCraftLauncher/", env!("CARGO_PKG_VERSION"));+pub(crate) const USER_AGENT: &str = concat!("ArtCraftLauncher/", env!("CARGO_PKG_VERSION")); /// Largest JSON or text answer we read. const MAX_SMALL: u64 = 4 << 20; @@ -58,6 +58,10 @@ pub fn install( type RecordCommit<'a> = Option<&'a mut dyn FnMut(&Record) -> Result<(), String>>; +/// [`install`], with `commit` called to save the record as soon as the new copy is in place. In+/// order, before anything is fetched: the destination, the app not running, the artifact's+/// version ([`bind_artifact_version`]), no downgrade unless `job.replace`, and the download link+/// itself ([`check_download_link`]). #[allow(clippy::too_many_arguments)] pub fn install_at( app: &CraftApp,@@ -90,6 +94,7 @@ pub fn install_at( return Err(format!("Cannot update {} to {}: installed version {} is newer", app.name, release.version_label(), inst_v)); } log::info!("{}: starting {} to {} ({})", app.name, if existing.is_some() { "update" } else { "install" }, release.version_label(), kind.label());+ check_download_link(app, release, asset)?; let file = download(release, asset, job)?; log::info!("{}: download ready ({}), running installer...", app.name, file.display()); // Past this point the install can't be interrupted halfway.@@ -170,7 +175,7 @@ pub(crate) fn bind_artifact_version(app: &CraftApp, release: &Release, asset: &A } // The release tag and filename embedded in a release download URL (`.../releases/download/<tag>/<name>`).- if let Some(rest) = asset.url.split("/releases/download/").nth(1) {+ if let Some((_, rest)) = asset.url.split_once("/releases/download/") { let mut parts = rest.split('/'); let tag = parts.next().unwrap_or(""); let url_filename = parts.next().unwrap_or("");@@ -187,6 +192,19 @@ pub(crate) fn bind_artifact_version(app: &CraftApp, release: &Release, asset: &A Ok(()) } +/// The last check before any bytes are fetched (#764): the link must be exactly the app's own+/// GitHub download for this release's tag and this asset's name, whichever converter (API, feed or+/// `SHA256SUMS.txt`) built the asset. A `local-mirror` build also accepts the developer's loopback+/// mirror, whose links the feed parser has already confined; so do this crate's tests, whose+/// downloads come from a server on 127.0.0.1.+fn check_download_link(app: &CraftApp, release: &Release, asset: &Asset) -> Result<(), String> {+ let mirrored = (cfg!(feature = "local-mirror") || cfg!(test)) && crate::http::is_loopback(&asset.url);+ if mirrored || release::expected_download_url(app.repo, &release.tag, &asset.name).as_deref() == Some(asset.url.as_str()) {+ return Ok(());+ }+ Err(format!("{}'s download link isn't the one its release publishes ({}), so it wasn't downloaded.", app.name, asset.name))+}+ /// What a new install would write over: something already at its destination that the launcher /// didn't put there. pub fn occupied(@@ -1307,8 +1325,10 @@ pub mod macos { Duration::from_secs(300), ) };+ // diskimages-helper can stay busy for a while (another image, a shared CI runner): a+ // minute of retries is still quick, as in craft-update (storytold/craft-libs#349). let mut attached = attach();- for wait in [1, 2, 4, 8] {+ for wait in [1, 2, 4, 8, 16, 32] { if attached.is_ok() { break; }@@ -2593,6 +2613,8 @@ mod tests { assert!(result.unwrap_err().contains("installed version 0.5.0 is newer")); } + /// An artifact whose file name, link tag or link repository disagrees with the release is+ /// refused before any download. #[test] fn install_rejects_an_artifact_whose_version_is_not_the_advertised_one() { // The release advertises 99.0.0, but the selected artifact is an older official build@@ -2638,6 +2660,18 @@ mod tests { ..Asset::default() }; assert!(bind_artifact_version(app, &release, &bad_tag).is_err());++ // #764: another app's release with the right tag and name passes the bind (it reads only+ // the path after `/releases/download/`), but the check before the download refuses it.+ let other_repo = Asset {+ name: "photocraft-99.0.0-linux-x86_64.AppImage".into(),+ url: "https://github.com/storytold/vectorcraft/releases/download/v99.0.0/photocraft-99.0.0-linux-x86_64.AppImage".into(),+ ..Asset::default()+ };+ assert!(bind_artifact_version(app, &release, &other_repo).is_ok());+ let err = install_at(app, &release, &other_repo, PackageKind::AppImage, None, None, &mut job, None).unwrap_err();+ assert!(err.contains("isn't the one its release publishes"), "{err}");+ assert!(check_download_link(app, &release, &good).is_ok()); } #[cfg(target_os = "linux")]@@ -28,51 +28,14 @@ fn to_update_paths(paths: &Paths) -> craft_update::Paths { craft_update::Paths { data: paths.data.clone(), cache: paths.cache.clone() } } -fn to_update_release(release: &Release) -> craft_update::Release {- craft_update::Release {- tag: release.tag.clone(),- version: release.version.clone(),- name: release.name.clone(),- published_at: release.published_at,- html_url: release.html_url.clone(),- notes: release.notes.clone(),- prerelease: release.prerelease,- assets: release- .assets- .iter()- .map(|a| craft_update::Asset { name: a.name.clone(), size: a.size, url: a.url.clone(), sha256: a.sha256.clone() })- .collect(),- }-}--fn to_update_platform(platform: Platform) -> craft_update::Platform {- craft_update::Platform {- os: match platform.os {- artcraft_launcher_core::Os::Windows => craft_update::Os::Windows,- artcraft_launcher_core::Os::MacOs => craft_update::Os::MacOs,- artcraft_launcher_core::Os::Linux => craft_update::Os::Linux,- artcraft_launcher_core::Os::FreeBsd => craft_update::Os::FreeBsd,- },- arch: match platform.arch {- artcraft_launcher_core::Arch::X64 => craft_update::Arch::X64,- artcraft_launcher_core::Arch::Arm64 => craft_update::Arch::Arm64,- artcraft_launcher_core::Arch::X86 => craft_update::Arch::X86,- artcraft_launcher_core::Arch::Riscv64 => craft_update::Arch::Riscv64,- },- }-}- /// Whether `release` is the version this launcher just updated itself to. pub fn just_updated_to(paths: &Paths, release: &Release) -> bool {- let update_paths = to_update_paths(paths);- let update_release = to_update_release(release);- craft_update::self_update::just_updated_to(&update_paths, &update_release)+ craft_update::self_update::just_updated_to(&to_update_paths(paths), release) } /// The error of the last update, if it failed, once. pub fn take_failure(paths: &Paths) -> Option<String> {- let update_paths = to_update_paths(paths);- craft_update::self_update::take_failure(&update_paths)+ craft_update::self_update::take_failure(&to_update_paths(paths)) } /// Downloads `release` for `platform`, puts it in place and starts the helper that relaunches@@ -89,10 +52,8 @@ pub fn update( phase: &mut dyn FnMut(JobPhase), last: &dyn Fn() -> Result<(), String>, ) -> Result<(), String> {- let update_release = to_update_release(release);- let update_platform = to_update_platform(platform);- let update_paths = to_update_paths(paths);- let update_http = craft_update::Http::new("ArtCraft-Launcher/SelfUpdate")?;+ // The download goes to github.com's release CDN, never the API, so no token is needed.+ let update_http = craft_update::Http::new(crate::http::USER_AGENT)?; let mut update_phase = |p: craft_update::JobPhase| { phase(match p { craft_update::JobPhase::Downloading { done, total } => JobPhase::Downloading { done, total },@@ -106,10 +67,10 @@ pub fn update( craft_update::self_update::update( &craft_update::suite::LAUNCHER, install,- &update_release,- update_platform,+ release,+ platform, &update_http,- &update_paths,+ &to_update_paths(paths), stop, &mut update_phase, last,@@ -88,7 +88,7 @@ fn token() -> Option<String> { fn fresh_secret() -> Option<String> { let mut bytes = [0u8; 32]; getrandom::fill(&mut bytes).ok()?;- Some(bytes.iter().map(|b| format!("{b:02x}")).collect())+ Some(crate::http::hex(&bytes)) } /// Writes `bytes` to `path` readable and writable only by the owner.