collective

Browse / github.com/storytold/craft-libs / patch

craft-update: accept exactly one download link per asset (craft-launcher#764); keep the Linux self-update off other copies

craft-update: accept exactly one download link per asset (craft-launcher#764); keep the Linux self-update off other copies Fixes storytold/craft-launcher#764 in craft-update, and brings back a Linux self-update rule that the extraction lost. ## Why craft-update was copied out of ArtCraft Launcher in #311, so its `release.rs`, `version.rs`, `platform.rs`, `distro.rs` and `proxy.rs` are the launcher's files. craft-launcher#764 was fixed only in the launcher's copy. This PR brings the fix here, where every Craft app's self-update runs. Once this PR and #389 merge, the launcher drops its copy (storytold/craft-launcher#930). ## 1. Each asset has exactly one acceptable download link (#764) **The problem:** an asset'

Status
open
Score
0.00
Adopters
0 other operators
Author
smittie2000 (imported from pull request #388, open)
Base
bd97cb760c36c4398def923817702a7e7f3096af
Size
+292 −26 in 3 files
Review
pending
Shared
2026-10-28
Id
21899ccd74827c7165cc95f3

How it went

No other operator has reported using it yet.

Diff

raw · crates/update/src/install.rs crates/update/src/release.rs crates/update/src/self_update.rs

diff --git a/crates/update/src/install.rs b/crates/update/src/install.rs--- a/crates/update/src/install.rs+++ b/crates/update/src/install.rs@@ -58,6 +58,11 @@ pub fn install(  type RecordCommit<'a> = Option<&'a mut dyn FnMut(&Record) -> Result<(), String>>; +/// [`install`], with `commit` called once a portable ZIP's new files are in place and its version+/// checked, so the caller can save its record; an error from it rolls the previous copy back+/// (Windows only; ignored elsewhere). Refuses before downloading when the app is running, the+/// asset isn't the release's version (`bind_artifact_version`), a newer copy is installed, or the+/// link isn't the release's own (`check_download_link`). #[allow(clippy::too_many_arguments)] pub fn install_at(     app: &App,@@ -90,6 +95,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,13 +176,15 @@ pub(crate) fn bind_artifact_version(app: &App, release: &Release, asset: &Asset)     }      // 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) {+    // A `+` in either is `%2B` there, as GitHub and `expected_download_url` write it; no other+    // escape is decoded, so anything else still has to match as written.+    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("");+        let tag = parts.next().unwrap_or("").replace("%2B", "+");+        let url_filename = parts.next().unwrap_or("").replace("%2B", "+");         if tag.is_empty()             || release_version-                .map_or(tag != release.tag, |release_version| tag != release.tag && version::parse(tag).as_ref() != Some(release_version))+                .map_or(tag != release.tag, |release_version| tag != release.tag && version::parse(&tag).as_ref() != Some(release_version))         {             return Err(mismatch());         }@@ -187,6 +195,23 @@ pub(crate) fn bind_artifact_version(app: &App, release: &Release, asset: &Asset)     Ok(()) } +/// The last check before any bytes are fetched (storytold/craft-launcher#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, and so must the link of the+/// release's `SHA256SUMS.txt` that the download is checked against. 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.+pub(crate) fn check_download_link(app: &App, release: &Release, asset: &Asset) -> Result<(), String> {+    let own = |a: &Asset| {+        ((cfg!(feature = "local-mirror") || cfg!(test)) && crate::http::is_loopback(&a.url))+            || release::expected_download_url(app.repo, &release.tag, &a.name).as_deref() == Some(a.url.as_str())+    };+    if own(asset) && release.sums_asset().is_none_or(own) {+        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(@@ -2595,6 +2620,8 @@ mod tests {         assert!(result.unwrap_err().contains("installed version 0.5.0 is newer"));     } +    /// An asset whose file name, link tag or link file name isn't the release's is refused before+    /// the download, and so is one in another app's releases or with a foreign `SHA256SUMS.txt`.     #[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@@ -2640,6 +2667,55 @@ mod tests {             ..Asset::default()         };         assert!(bind_artifact_version(app, &release, &bad_tag).is_err());++        // storytold/craft-launcher#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());++        // The checksums the download is verified against must come from the release too.+        let mut foreign_sums = release.clone();+        foreign_sums.assets.push(Asset {+            name: "SHA256SUMS.txt".into(),+            url: "https://github.com/storytold/vectorcraft/releases/download/v99.0.0/SHA256SUMS.txt".into(),+            ..Asset::default()+        });+        assert!(check_download_link(app, &foreign_sums, &good).is_err());+    }++    /// A release whose tag has a `+` (semver build metadata) has `%2B` in its links. Both checks+    /// before the download accept them, so `install_at` gets as far as the download, which the+    /// cancelled job ends before anything is fetched. A link with another escape is still refused.+    #[test]+    fn install_accepts_a_plus_release_up_to_the_download() {+        let dir = tempfile::tempdir().unwrap();+        let paths = Paths::under(dir.path());+        let http = Http::new(crate::TEST_AGENT).unwrap();+        let phase = &mut |_| ();+        let locations = InstallLocations::default();+        let app = App::by_id("photocraft").unwrap();+        let name = "photocraft-1.0.0+build.1-linux-x86_64.AppImage";+        let release = release::release_from_sums(app.repo, "v1.0.0+build.1", &[("a".repeat(64), name.to_string())]);+        let asset = release.assets[0].clone();+        assert!(asset.url.ends_with("/download/v1.0.0%2Bbuild.1/photocraft-1.0.0%2Bbuild.1-linux-x86_64.AppImage"), "{}", asset.url);+        assert_eq!(bind_artifact_version(app, &release, &asset), Ok(()));++        let stop = Stop::default();+        stop.cancel();+        let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false };+        let result = install_at(app, &release, &asset, PackageKind::AppImage, None, None, &mut job, None);+        assert_eq!(result.unwrap_err(), crate::protocol::CANCELLED);++        let other_escape = Asset { url: asset.url.replace("%2B", "%2b"), ..asset };+        assert!(bind_artifact_version(app, &release, &other_escape).is_err());     }      #[cfg(target_os = "linux")]diff --git a/crates/update/src/release.rs b/crates/update/src/release.rs--- a/crates/update/src/release.rs+++ b/crates/update/src/release.rs@@ -115,6 +115,20 @@ pub fn download_prefix(repo: &str) -> String {     format!("https://github.com/{repo}/releases/download/") } +/// The one link a GitHub release asset may have: `https://github.com/<repo>/releases/download/<tag>/<name>`,+/// built from the release's own tag and the asset's own name, so a link can't point at another+/// release or anywhere else (#764). `None` when the tag or name holds anything but ASCII letters,+/// digits and `._+-`, which no Craft App release uses and which github.com could re-interpret.+/// A `+` (semver build metadata) is written `%2B`, as GitHub writes it in `browser_download_url`+/// (`.../download/17.0.9%2B9/jdk-17.0.9%2B9.deb` for the tag `17.0.9+9`).+pub fn expected_download_url(repo: &str, tag: &str, name: &str) -> Option<String> {+    let plain = |s: &str, max: usize| {+        !s.is_empty() && s.len() <= max && s != "." && s != ".." && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"._+-".contains(&b))+    };+    let encoded = |s: &str| s.replace('+', "%2B");+    (plain(tag, 64) && plain(name, 200) && valid_file_name(name)).then(|| format!("{}{}/{}", download_prefix(repo), encoded(tag), encoded(name)))+}+ /// Whether `url` is a download that truly belongs to `repo`. /// /// A raw `starts_with(prefix)` check is not enough: a value such as@@ -151,11 +165,12 @@ 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.+/// Percent-encoded slash, backslash or dot (`%2f`, `%5c`, `%2e`): a client or github.com could+/// decode these back into separators or a `..` segment after the check (#764), 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"))+    bytes.windows(3).any(|w| w[0] == b'%' && matches!(&w[1..].to_ascii_lowercase()[..], b"2f" | b"5c" | b"2e")) }  /// Reads `GET /repos/<repo>/releases/latest` (or one element of `/releases`).@@ -187,6 +202,9 @@ pub fn parse_github_release_list(json: &str, repo: &str, allow_prerelease: bool)     Ok(best) } +/// One release object of the GitHub API, with every text capped and only the assets whose name is+/// a plain file name and whose link is exactly [`expected_download_url`]. An API error object+/// (`message`) becomes its text. 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)) });@@ -194,14 +212,14 @@ fn release_from_value(v: &serde_json::Value, repo: &str) -> Result<Release, Stri     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 url =+                        a["browser_download_url"].as_str().filter(|u| expected_download_url(repo, &tag, &name).as_deref() == Some(*u))?.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 })                 })@@ -221,14 +239,13 @@ fn release_from_value(v: &serde_json::Value, repo: &str) -> Result<Release, Stri }  /// 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).+/// `releases/latest` redirect names the tag, and the sums list every file). Each link is+/// [`expected_download_url`], so it's the one the check before the download accepts; a file+/// without one is left out. 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 });+    let asset =+        |name: &str, sha256: Option<String>| expected_download_url(repo, tag, name).map(|url| Asset { name: name.to_string(), size: 0, url, sha256 });+    let assets: Vec<Asset> = sums.iter().filter_map(|(hash, name)| asset(name, Some(hash.clone()))).chain(asset("SHA256SUMS.txt", None)).collect();     Release {         tag: tag.to_string(),         version: version::parse(tag),@@ -244,7 +261,8 @@ pub fn release_from_sums(repo: &str, tag: &str, sums: &[(String, String)]) -> Re /// 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())+    (!rest.is_empty() && rest != "." && rest != ".." && 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.@@ -265,6 +283,7 @@ pub fn valid_file_name(name: &str) -> bool {         && name != "."         && name != ".."         && !name.starts_with('.')+        && !contains_encoded_separator(name)         && !name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*')) } @@ -274,11 +293,7 @@ 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])+    format!("{}…", &s[..s.floor_char_boundary(max)]) }  #[cfg(test)]@@ -347,6 +362,48 @@ mod tests {         assert_eq!(r.assets[0].size, 123);     } +    /// #764: an API asset's link must be exactly this release's `<tag>/<name>`.+    #[test]+    fn a_download_link_must_be_its_releases_own() {+        for url in [+            // Another release of the same app (a rollback), or another file name.+            "https://github.com/storytold/photocraft/releases/download/v0.4.0/photocraft-0.5.0-macos-universal.dmg",+            "https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.4.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.5.0-macos-universal.dmg",+        ] {+            assert!(parse_github_release(&api_json(url), REPO).unwrap().assets.is_empty(), "{url}");+        }+        assert_eq!(+            expected_download_url(REPO, "v0.6.0-rc.1", "photocraft-0.6.0-rc.1-macos-universal.dmg").as_deref(),+            Some("https://github.com/storytold/photocraft/releases/download/v0.6.0-rc.1/photocraft-0.6.0-rc.1-macos-universal.dmg")+        );+        for (tag, name) in [("", "x.dmg"), ("..", "x.dmg"), ("v1", ".."), ("v1", "a/b.dmg"), ("v1", "a%2fb.dmg"), ("v 1", "x.dmg"), ("v1", ".x.dmg")]+        {+            assert_eq!(expected_download_url(REPO, tag, name), None, "{tag:?} {name:?}");+        }+    }++    /// A `+` in the tag or name (semver build metadata) is `%2B` in GitHub's download links, but a+    /// plain `+` in the asset's name and in the `releases/latest` redirect, as on bell-sw/Liberica's+    /// releases. The API's assets, and those built from `SHA256SUMS.txt`, get the same link.+    #[test]+    fn a_plus_in_the_tag_is_percent_encoded_in_the_link() {+        let name = "photocraft-1.0.0+build.1-linux-x86_64.AppImage";+        let github = "https://github.com/storytold/photocraft/releases/download/v1.0.0%2Bbuild.1/photocraft-1.0.0%2Bbuild.1-linux-x86_64.AppImage";+        let api = |url: &str| format!(r#"{{"tag_name":"v1.0.0+build.1","assets":[{{"name":"{name}","size":1,"browser_download_url":"{url}"}}]}}"#);+        let r = parse_github_release(&api(github), REPO).unwrap();+        assert_eq!((r.assets[0].name.as_str(), r.assets[0].url.as_str()), (name, github));+        // GitHub never writes the `+` itself into the link.+        assert!(parse_github_release(&api(&github.replace("%2B", "+")), REPO).unwrap().assets.is_empty());++        let tag = tag_from_redirect("https://github.com/storytold/photocraft/releases/tag/v1.0.0+build.1", REPO).unwrap();+        let r = release_from_sums(REPO, &tag, &[(HASH.to_string(), name.to_string())]);+        assert_eq!(r.assets[0].url, github);+        assert_eq!(r.sums_asset().unwrap().url, "https://github.com/storytold/photocraft/releases/download/v1.0.0%2Bbuild.1/SHA256SUMS.txt");+    }++    /// Links to other hosts or repositories, or that climb out with `..` or encoded separators,+    /// are dropped.     #[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)"] {@@ -382,6 +439,8 @@ mod tests {         }     } +    /// A link that shares the download prefix as text but climbs into another repository with `..`+    /// or an encoded separator or dot isn't under it.     #[test]     fn traversal_cannot_escape_the_repository() {         // Raw-prefix matches but the interpreted path climbs into another repository (CWE-180).@@ -392,6 +451,9 @@ mod tests {             "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",+            "https://github.com/storytold/photocraft/releases/download/%2e%2e/x.dmg",+            "https://github.com/storytold/photocraft/releases/download/%2E%2E/x.dmg",+            "https://github.com/storytold/photocraft/releases/download/v1/%2e%2e",         ] {             let prefix = download_prefix(REPO);             assert!(!download_url_under(url, &prefix), "{url}");@@ -416,6 +478,8 @@ mod tests {         assert_eq!(parse_github_release_list("[]", REPO, false).unwrap(), None);     } +    /// `SHA256SUMS.txt` lines, the tag in a `releases/latest` redirect, and the release built from+    /// both; malformed lines, traversing names and `.`/`..` tags are dropped.     #[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"));@@ -423,16 +487,30 @@ mod tests {         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);+        for dots in [".", ".."] {+            assert_eq!(tag_from_redirect(&format!("https://github.com/storytold/photocraft/releases/tag/{dots}"), 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)));     } +    /// A name with a path, a hidden or reserved form, a control character or a percent-encoded+    /// separator isn't a file name we'd save.     #[test]     fn file_names_cannot_escape() {-        for bad in ["", "..", "a/b", "a\\b", ".hidden", "c:x", "x\u{0}"] {+        for bad in ["", "..", "a/b", "a\\b", ".hidden", "c:x", "x\u{0}", "%2e%2e%2fother", "a%2Fb", "a%5cb", "x%2E%2E"] {             assert!(!valid_file_name(bad), "{bad:?}");         }         assert!(valid_file_name("photocraft-0.5.0-linux-x86_64.AppImage"));     }++    /// A `SHA256SUMS.txt` line whose name hides a traversal is dropped, so `release_from_sums`+    /// never builds a link from it; the ordinary line beside it is kept.+    #[test]+    fn checksum_names_with_encoded_separators_make_no_download() {+        let hash = "a".repeat(64);+        let sums = parse_sha256sums(&format!("{hash}  %2e%2e%2f%2e%2e%2fother%2frepo%2fx.dmg\n{hash}  photocraft-0.5.0-macos-universal.dmg\n"));+        assert_eq!(sums.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>(), ["photocraft-0.5.0-macos-universal.dmg"]);+    } }diff --git a/crates/update/src/self_update.rs b/crates/update/src/self_update.rs--- a/crates/update/src/self_update.rs+++ b/crates/update/src/self_update.rs@@ -214,6 +214,9 @@ fn msi_install_dir(app: &App) -> Option<PathBuf> {     (!dir.is_empty()).then(|| PathBuf::from(dir)) } +/// How the running copy at `exe` was installed on Linux, or `None` when it isn't this app's to+/// update: a Flatpak bundle installed per user, an AppImage, the deb/rpm/Arch package at+/// `/usr/bin/<id>`, or a standalone copy outside the package managers' folders ([`linux_location`]). #[cfg(target_os = "linux")] fn detect(app: &App, exe: &Path) -> Option<Install> {     if process::in_sandbox() {@@ -226,6 +229,11 @@ fn detect(app: &App, exe: &Path) -> Option<Install> {     if let Some(appimage) = std::env::var_os("APPIMAGE") {         return Some(Install::AppImage(PathBuf::from(appimage)));     }+    match linux_location(app, exe) {+        LinuxLocation::PackagePath => {}+        LinuxLocation::System => return None,+        LinuxLocation::Elsewhere => return exe.is_file().then(|| Install::Standalone(exe.to_path_buf())),+    }     let t = std::time::Duration::from_secs(10);     if process::capture("dpkg-query", &["-W", "-f", "${db:Status-Status}", app.id], t).is_some_and(|s| s.trim() == "installed") {         return Some(Install::Package(PackageKind::Deb, exe.to_path_buf()));@@ -238,12 +246,38 @@ fn detect(app: &App, exe: &Path) -> Option<Install> {     if crate::detect::linux::has_pacman_db() && process::capture("pacman", &["-Q", &app.bundle_id()], t).is_some() {         return Some(Install::Package(PackageKind::ArchPkg, exe.to_path_buf()));     }-    if exe.is_file() {-        return Some(Install::Standalone(exe.to_path_buf()));-    }     None } +/// Where a Linux executable outside a Flatpak or AppImage runs from, for [`detect`].+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]+#[derive(Debug, PartialEq, Eq)]+enum LinuxLocation {+    /// `/usr/bin/<id>`, where the release's deb, rpm and Arch packages put it. Only this path is+    /// checked against the package databases: a copy elsewhere isn't the package's even when the+    /// package is installed too.+    PackagePath,+    /// Anywhere else under a folder a package manager owns (`/usr` but not `/usr/local`, `/opt`,+    /// `/bin`, `/sbin`, `/nix`, `/snap`): a distribution, AUR, Nix or Snap copy. It's theirs to+    /// update, and replacing it in place would write over a file the package manager owns (or+    /// fail on a read-only store).+    System,+    /// Anywhere else, such as a folder in the user's home: a standalone copy that replaces itself.+    Elsewhere,+}++/// Which [`LinuxLocation`] `exe` is in, from its path alone.+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]+fn linux_location(app: &App, exe: &Path) -> LinuxLocation {+    if exe == Path::new("/usr/bin").join(app.id) {+        LinuxLocation::PackagePath+    } else if !exe.starts_with("/usr/local") && ["/usr", "/opt", "/bin", "/sbin", "/nix", "/snap"].iter().any(|dir| exe.starts_with(dir)) {+        LinuxLocation::System+    } else {+        LinuxLocation::Elsewhere+    }+}+ /// Whether this sandbox runs the per-user installation's copy, from `app-path` in `/.flatpak-info`. #[cfg(target_os = "linux")] fn is_user_install() -> bool {@@ -313,6 +347,7 @@ pub fn update(         .ok_or_else(|| format!("{} {} has no {} for {}", app.name, release.version_label(), install.kind().label(), platform.label()))?;     let asset = asset.clone();     crate::install::bind_artifact_version(app, release, &asset)?;+    crate::install::check_download_link(app, release, &asset)?;     let mut job = Job { http, paths, stop, phase, install_locations: &Default::default(), replace: false };     let file = install::download(release, &asset, &mut job)?;     #[cfg(windows)]@@ -1738,6 +1773,83 @@ mod tests {         assert!(result.unwrap_err().contains("is not newer than currently running version"));     } +    /// Only `/usr/bin/<id>` is checked against the package databases, as before the launcher's+    /// self-update moved here; another copy under a system folder (an AUR build, say) is left to+    /// its package manager, and only a copy outside them (`/usr/local` included, which no package+    /// manager owns) replaces itself.+    #[test]+    fn only_the_packages_own_path_is_a_package_and_system_folders_are_left_alone() {+        let app = &crate::suite::LAUNCHER;+        assert_eq!(linux_location(app, Path::new("/usr/bin/artcraft-launcher")), LinuxLocation::PackagePath);+        for system in [+            "/usr/bin/artcraft-launcher-bin",+            "/nix/store/abc-artcraft-launcher/bin/artcraft-launcher",+            "/opt/artcraft-launcher/artcraft-launcher",+            "/bin/artcraft-launcher",+        ] {+            assert_eq!(linux_location(app, Path::new(system)), LinuxLocation::System, "{system}");+        }+        for own in [+            "/usr/local/bin/artcraft-launcher",+            "/home/me/bin/artcraft-launcher",+            "/home/me/.local/bin/artcraft-launcher",+            "/usrlocal/artcraft-launcher",+        ] {+            assert_eq!(linux_location(app, Path::new(own)), LinuxLocation::Elsewhere, "{own}");+        }+    }++    /// storytold/craft-launcher#764: a self-update downloads only its own release's link. One into+    /// another app's releases, with the right tag and name, is refused before anything is fetched.+    #[test]+    fn update_refuses_a_link_that_is_not_its_releases_own() {+        let dir = tempfile::tempdir().unwrap();+        let paths = Paths::under(dir.path());+        let http = Http::new(crate::TEST_AGENT).unwrap();+        let mut phase = |_| ();+        let last = || Ok(());+        let stop = Stop::default();+        let name = "artcraft-launcher-0.3.0-linux-x86_64.AppImage";+        let release = Release {+            version: version::parse("0.3.0"),+            tag: "v0.3.0".into(),+            assets: vec![crate::release::Asset {+                name: name.into(),+                size: 1,+                url: format!("https://github.com/storytold/photocraft/releases/download/v0.3.0/{name}"),+                sha256: None,+            }],+            ..Release::default()+        };+        let install = Install::AppImage(dir.path().join("launcher.AppImage"));+        use crate::{Arch, Os};+        let platform = Platform { os: Os::Linux, arch: Arch::X64 };+        let result = update(&crate::suite::LAUNCHER, &install, &release, platform, &http, &paths, &stop, &mut phase, &last, Some("0.2.0"));+        assert!(result.unwrap_err().contains("isn't the one its release publishes"));+    }++    /// A launcher release whose tag has a `+` (semver build metadata) has `%2B` in its links.+    /// The self-update accepts them and gets as far as the download, which the cancelled stop ends+    /// before anything is fetched.+    #[test]+    fn update_accepts_a_plus_release_up_to_the_download() {+        let dir = tempfile::tempdir().unwrap();+        let paths = Paths::under(dir.path());+        let http = Http::new(crate::TEST_AGENT).unwrap();+        let mut phase = |_| ();+        let last = || Ok(());+        let stop = Stop::default();+        stop.cancel();+        let app = &crate::suite::LAUNCHER;+        let name = "artcraft-launcher-0.3.0+build.1-linux-x86_64.AppImage";+        let release = crate::release::release_from_sums(app.repo, "v0.3.0+build.1", &[("a".repeat(64), name.to_string())]);+        let install = Install::AppImage(dir.path().join("launcher.AppImage"));+        use crate::{Arch, Os};+        let platform = Platform { os: Os::Linux, arch: Arch::X64 };+        let result = update(app, &install, &release, platform, &http, &paths, &stop, &mut phase, &last, Some("0.2.0"));+        assert_eq!(result.unwrap_err(), crate::protocol::CANCELLED);+    }+     #[cfg(target_os = "linux")]     #[test]     fn parses_flatpak_metadata_for_restart() {