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() {
