Files
craft-libs/crates/update/src/source.rs
T
smittie2000 797d21a3f3 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 with it storytold/craft-launcher#765 (each link is tied to its own release's tag). Also 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's link could point at another release, or climb out of the app's releases with `..` or `%2e%2e`, which github.com decodes before routing.

**The fix:**
- `release::expected_download_url(repo, tag, name)` builds the only link an asset may have: `https://github.com/<repo>/releases/download/<tag>/<name>`. The tag and name may only use ASCII letters, digits and `._+-`. A `+` is written `%2B`, as GitHub writes it.
- Reading the GitHub API, an asset is kept only when its link equals that URL. This also ties each link to its own release.
- `release_from_sums` builds its links with the same function.
- `bind_artifact_version`, which compares the tag and file name inside the link with the release's, reads `%2B` there as `+`. It decodes nothing else.
- An encoded dot (`%2e`) is rejected like an encoded slash (`%2f`, `%5c`), both in links and in file names from `SHA256SUMS.txt`.
- `tag_from_redirect` refuses `.` and `..`.
- `install::check_download_link` checks the asset's link, and the link of the release's `SHA256SUMS.txt`, just before downloading. It runs in `install_at` **and** in `self_update::update`, because a self-update doesn't go through `install_at`.

## 2. Linux self-update: only `/usr/bin/<id>` counts as the package

Before #311, the launcher checked the package databases (dpkg, rpm, pacman) only for `/usr/bin/<id>`. The extraction dropped that check and added a `Standalone` fallback, which broke two cases:
- **A copy in `~/bin`** was treated as the deb/rpm/Arch package whenever that package was also installed. The update installed the package with pkexec, then relaunched the `~/bin` copy, which it never touched.
- **An AUR build in `/usr/bin`**, which the code comment says is pacman's to update, became `Standalone`. The update then tried to overwrite a file pacman owns.

`linux_location` brings the old rule back:

| Where the executable is | What happens |
|---|---|
| `/usr/bin/<id>` | checked against the package databases, as before |
| anywhere else under `/usr`, `/opt`, `/bin`, `/sbin`, `/nix`, `/snap` or `/gnu` (Guix), or in a Homebrew `.linuxbrew` prefix | no self-update: its package manager updates it |
| `/usr/local`, the home folder, anywhere else | updates itself (`Standalone`) |

**Symlinked installs are left alone on purpose.** `current_exe()` follows symlinks, so a package that puts the real file in `/opt` and a symlink in `/usr/bin` lands in the second row and doesn't self-update. That's intended. All 14 Craft apps' packages (`packaging/linux/nfpm.yaml`) put the real file at `/usr/bin/<id>`, so this only happens with someone else's repackaging, such as an AUR `-bin` package, and that copy belongs to its package manager.

## 3. Fixes from reviewing this PR with craft-launcher (3 November)

Rebased onto main (`55332dd`) instead of merging it; #460, #392, #481 and #478 changed the same files. Then two reviews against craft-launcher main, which feeds this crate its releases:

- **A release knows its repository.** `Release::repo` is the repository the release was read from, and `check_download_link` checks links against it (empty means the app's own). The launcher's feed can rename a repository (`Feed::repo_for`); checking against `app.repo` refused the renamed repository's downloads.
- **A `+` matches either way.** `release::same_download_url` reads `%2B` as `+` on both sides. The launcher's own `release_from_sums` and feed write a plain `+`, and so do releases saved by an older version, so only accepting `%2B` refused those self-updates. Nothing else is decoded (`%2b` still fails).
- **The launcher's tag and file-name rule (craft-launcher#1024).** `valid_tag`: up to 64 of `[A-Za-z0-9._+-]`, no leading dot, used for the API's `tag_name`, the redirect and download links. `valid_file_name`: the same characters, no leading or trailing dot, no Windows device name (`CON`, `nul.txt`, `COM1.msi`). The launcher's feed calls `release::valid_tag`, so craft-launcher#930 needs it here.
- **`ask_pages` fetches `SHA256SUMS.txt` from `expected_download_url`**, the link it stores.
- **Guix and Homebrew on Linux** are left to their package manager (table above), like Homebrew and Nix on macOS (#460).

## What changes for apps

- **Loopback mirrors:** a build that downloads from a developer's loopback mirror must turn on `craft-update/local-mirror`, or the new check refuses the mirror's links. The launcher's `local-mirror` feature turns it on (craft-launcher#930). Apps rolling out under #319 without a mirror need no change.
- **Linux:** copies under the package-manager folders above no longer update themselves. That's how the launcher behaved before #311.
- **`+` in a tag:** handled (section 1). No Craft release uses `+` today. GitHub's link form was checked on bell-sw/Liberica's releases: the tag and name keep `+`, the link has `%2B`, and the `releases/latest` redirect has a plain `+`.

## Compared with the launcher

I compared launcher 2d12245 (the snapshot #311 copied) and launcher `main` with this crate.
- **All of the launcher's security fixes from 15–25 October are here:** #747/#864, #865/#748, #867, the proxy part of #868, #689, #815, #821 and the archive limits. The launcher's two commits after the snapshot (#871, #903) change no behaviour here.
- **Two things are here but not yet in the launcher:**
  - the minute-long `hdiutil attach` retry (#349): craft-launcher#930 takes it;
  - the zip 9 drive-prefix test (#335): it waits for the launcher's zip 9 bump (storytold/craft-launcher#899).
- **Follow-ups that don't belong in this PR** are filed separately (linked below once filed).

## Tests

- **New:** `a_download_link_must_be_its_releases_own`, `a_plus_in_the_tag_is_percent_encoded_in_the_link`, `install_accepts_a_plus_release_up_to_the_download`, `update_accepts_a_plus_release_up_to_the_download`, `checksum_names_with_encoded_separators_make_no_download`, `update_refuses_a_link_that_is_not_its_releases_own`, `only_the_packages_own_path_is_a_package_and_system_folders_are_left_alone`.
- **Extended:** `install_rejects_an_artifact_whose_version_is_not_the_advertised_one` (another repo's link, a foreign checksums link), `drops_downloads_that_point_elsewhere`, `file_names_cannot_escape`, `tag_from_redirect`.
- **Mutation check:** `update_refuses_a_link…` fails when the call in `self_update::update` is removed.
- **Mutation check:** both `…_accepts_a_plus_release_up_to_the_download` tests fail with the bind error when the `%2B` decoding is removed.
- **On Windows, after merging main (31 October):** `cargo test -p craft-update`: 232 passed, 3 ignored, the same without default features. Clippy `-D warnings` is clean with default features and with `egui,local-mirror`, and `cargo fmt --check` is clean.
- **Merged with #389 (before the 31 October merge of main):** 173 passed, 28 without default features, and clippy for wasm32 is clean.
- **Launcher (before the 31 October merge of main):** with this branch merged with #389 (craft-launcher#930 pins that merge), the full ArtCraft Launcher suite passes: 401 tests, plus clippy, fmt and the wasm32 build of its core.
- **Added for review (3ad9074):** `two_links_for_one_asset_keep_only_the_releases_own`, `install_refuses_links_off_the_releases_own_before_downloading` (other hosts, and a `SHA256SUMS.txt` linked elsewhere), `install_refuses_while_the_installed_copy_is_running`, `a_download_that_fails_its_checksum_replaces_nothing`; `a_download_link_must_be_its_releases_own` also refuses other hosts. On Windows: 236 passed, 3 ignored; clippy and fmt clean. The bad-checksum test fails when the server sends the real file.
- **Added in section 3:** `tags_are_plain`, `a_plus_link_matches_either_way_and_nothing_else_does`, `a_release_knows_its_repository`; `file_names_cannot_escape` takes #1024's cases; the install test checks a renamed repository and a plain `+`; the Linux location test adds Guix and Linuxbrew. On Windows after the rebase: 251 passed, 3 ignored; the workspace builds; clippy `-D warnings` is clean for this crate (with `local-mirror`) apart from `translate::button` being unused, which comes from main's #508.
- **Not built yet:** the Linux-only `detect` code, and the Unix branch of the running-app test. CI builds it first, once a maintainer approves the workflow runs.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Update downloads are now restricted to assets belonging to the selected release, including checksum files. Unrelated or invalid links are rejected before a download begins.
  * Release links with unsafe path components or invalid redirect tags are rejected. Encoded plus signs in release tags and filenames are handled correctly.
  * Linux installation detection more accurately distinguishes package-managed installations, other system locations, and standalone installations, improving whether self-updates are available.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:47:52 +01:00

225 lines
9.0 KiB
Rust

//! Where release information comes from.
//!
//! [`GitHubSource`] asks the GitHub REST API (one request per app, revalidated with ETags) and,
//! when the API is out of quota, falls back to github.com's release pages: the
//! `releases/latest` redirect names the tag and `SHA256SUMS.txt` lists every file with its
//! checksum, which costs no API quota at all. A later [`ReleaseSource`] will ask the ArtCraft
//! catalog API for every app in one request.
use std::collections::BTreeMap;
use std::sync::{Mutex, PoisonError};
use crate::protocol::{LatestRelease, Source};
use crate::release::{self, Release};
use serde::{Deserialize, Serialize};
use crate::http::{Fetched, Http};
/// One answer from a source.
#[derive(Clone, Debug)]
pub struct Answer {
pub latest: LatestRelease,
pub source: Source,
}
/// Looks up latest releases. Called from worker threads.
pub trait ReleaseSource: Send + Sync {
fn latest(&self, repo: &str, prereleases: bool) -> Answer;
/// The last answer saved for `repo`, for an instant first paint.
fn cached(&self, repo: &str, prereleases: bool) -> Option<LatestRelease>;
/// The tag of `repo`'s newest stable release, by a route that costs no API quota (GitHub's
/// `releases/latest` redirect), or `None` when it can't be told. Quick: a check pressed by the
/// user asks this for every app to see whether the feed has fallen behind.
fn newest_tag(&self, repo: &str) -> Option<String> {
let _ = repo;
None
}
/// API refused until this time (Unix seconds).
fn rate_limited_until(&self) -> Option<i64>;
/// Persist anything worth keeping.
fn save(&self);
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct Entry {
etag: Option<String>,
/// `None`: the repo has no releases.
release: Option<Release>,
prereleases: bool,
fetched_at: i64,
}
#[derive(Default, Serialize, Deserialize)]
struct CacheFile {
entries: BTreeMap<String, Entry>,
}
pub struct GitHubSource {
http: Http,
cache: Mutex<CacheFile>,
cache_path: std::path::PathBuf,
limited_until: Mutex<Option<i64>>,
}
impl GitHubSource {
pub fn new(http: Http, cache_path: std::path::PathBuf) -> Self {
let cache = std::fs::read(&cache_path).ok().and_then(|b| serde_json::from_slice::<CacheFile>(&b).ok()).unwrap_or_default();
Self { http, cache: Mutex::new(cache), cache_path, limited_until: Mutex::new(None) }
}
fn entry(&self, repo: &str, prereleases: bool) -> Option<Entry> {
let cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
cache.entries.get(repo).filter(|e| e.prereleases == prereleases).cloned()
}
fn store(&self, repo: &str, entry: Entry) {
self.cache.lock().unwrap_or_else(PoisonError::into_inner).entries.insert(repo.to_string(), entry);
}
fn limited(&self) -> bool {
let until = *self.limited_until.lock().unwrap_or_else(PoisonError::into_inner);
until.is_some_and(|u| u > crate::now())
}
fn ask_api(&self, repo: &str, prereleases: bool) -> Result<Option<Answer>, String> {
let url = if prereleases {
format!("https://api.github.com/repos/{repo}/releases?per_page=30")
} else {
format!("https://api.github.com/repos/{repo}/releases/latest")
};
let saved = self.entry(repo, prereleases);
let found =
|release: Option<Release>| Answer { latest: release.map_or(LatestRelease::None, LatestRelease::Found), source: Source::GitHubApi };
match self.http.get_api(&url, saved.as_ref().and_then(|e| e.etag.as_deref()))? {
Fetched::Body { text, etag } => {
let release = if prereleases {
release::parse_github_release_list(&text, repo, true)?
} else {
Some(release::parse_github_release(&text, repo)?)
};
self.store(repo, Entry { etag, release: release.clone(), prereleases, fetched_at: crate::now() });
Ok(Some(found(release)))
}
Fetched::NotModified => match saved {
Some(e) => {
self.store(repo, Entry { fetched_at: crate::now(), ..e.clone() });
Ok(Some(found(e.release)))
}
None => Err("GitHub said nothing changed, but there's no saved answer".into()),
},
Fetched::NotFound => {
self.store(repo, Entry { etag: None, release: None, prereleases, fetched_at: crate::now() });
Ok(Some(found(None)))
}
Fetched::RateLimited { reset } => {
*self.limited_until.lock().unwrap_or_else(PoisonError::into_inner) = Some(retry_at(reset, crate::now()));
Ok(None)
}
}
}
/// The quota-free route through github.com (stable releases only).
fn ask_pages(&self, repo: &str) -> Result<Answer, String> {
let location = self.http.redirect_target(&format!("https://github.com/{repo}/releases/latest"))?;
let Some(tag) = location.as_deref().and_then(|l| release::tag_from_redirect(l, repo)) else {
// No published release: github.com sends us to the release list instead.
return Ok(Answer { latest: LatestRelease::None, source: Source::GitHubPages });
};
if let Some(e) = self.entry(repo, false)
&& let Some(r) = e.release.as_ref().filter(|r| r.tag == tag)
{
return Ok(Answer { latest: LatestRelease::Found(r.clone()), source: Source::GitHubPages });
}
// The same link `release_from_sums` stores and the check before the download accepts.
let sums_url = release::expected_download_url(repo, &tag, "SHA256SUMS.txt").ok_or("the latest release's tag isn't one we'd fetch")?;
let sums = self.http.get_text(&sums_url)?.map(|t| release::parse_sha256sums(&t)).unwrap_or_default();
let r = release::release_from_sums(repo, &tag, &sums);
self.store(repo, Entry { etag: None, release: Some(r.clone()), prereleases: false, fetched_at: crate::now() });
Ok(Answer { latest: LatestRelease::Found(r), source: Source::GitHubPages })
}
}
/// GitHub's quota refills within the hour, so a later reset is a bad answer.
const MAX_LIMIT_WAIT: i64 = 60 * 60;
/// When to ask the API again after a refusal.
fn retry_at(reset: Option<i64>, now: i64) -> i64 {
reset.unwrap_or(now + 15 * 60).min(now + MAX_LIMIT_WAIT)
}
impl ReleaseSource for GitHubSource {
fn latest(&self, repo: &str, prereleases: bool) -> Answer {
let api_error = if self.limited() {
None
} else {
match self.ask_api(repo, prereleases) {
Ok(Some(answer)) => return answer,
Ok(None) => None,
Err(e) => Some(e),
}
};
if !prereleases && api_error.is_none() {
match self.ask_pages(repo) {
Ok(answer) => return answer,
Err(e) => log::warn!("{repo}: release pages: {e}"),
}
}
let why = api_error.unwrap_or_else(|| "GitHub's request limit was reached".into());
match self.cached(repo, prereleases) {
Some(latest) => Answer { latest, source: Source::Cache },
None => Answer { latest: LatestRelease::Failed(why), source: Source::Cache },
}
}
fn cached(&self, repo: &str, prereleases: bool) -> Option<LatestRelease> {
self.entry(repo, prereleases).map(|e| e.release.map_or(LatestRelease::None, LatestRelease::Found))
}
fn newest_tag(&self, repo: &str) -> Option<String> {
match self.http.redirect_target(&format!("https://github.com/{repo}/releases/latest")) {
Ok(location) => location.as_deref().and_then(|l| release::tag_from_redirect(l, repo)),
Err(e) => {
log::debug!("{repo}: couldn't read the latest-release redirect: {e}");
None
}
}
}
fn rate_limited_until(&self) -> Option<i64> {
let until = *self.limited_until.lock().unwrap_or_else(PoisonError::into_inner);
until.filter(|u| *u > crate::now())
}
fn save(&self) {
let json = {
let cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
serde_json::to_vec(&*cache)
};
match json {
Ok(bytes) => {
if let Err(e) = crate::paths::write_atomic(&self.cache_path, &bytes) {
log::warn!("release cache: {e}");
}
}
Err(e) => log::warn!("release cache: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_refusal_waits_for_the_reset_github_names() {
assert_eq!(retry_at(Some(1_000 + 600), 1_000), 1_600);
assert_eq!(retry_at(None, 1_000), 1_000 + 15 * 60);
}
#[test]
fn a_reset_past_the_quota_window_cannot_stop_the_api_for_good() {
assert_eq!(retry_at(Some(i64::MAX), 1_000), 1_000 + 60 * 60);
assert_eq!(retry_at(Some(99_999_999_999), 1_000), 1_000 + 60 * 60);
}
}