mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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 -->
701 lines
35 KiB
Rust
701 lines
35 KiB
Rust
//! 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 std::collections::{HashMap, HashSet};
|
|
|
|
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>,
|
|
/// The `owner/name` repository the release was read from, whose download links its assets
|
|
/// must be (`install::check_download_link`). Usually the app's own; a feed that announces a
|
|
/// renamed repository reads the release from the new one. Empty means the app's own (a
|
|
/// release saved before this field, or one built by hand).
|
|
#[serde(default)]
|
|
pub repo: String,
|
|
}
|
|
|
|
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/")
|
|
}
|
|
|
|
/// 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` unless the tag is a [`valid_tag`] and the name a
|
|
/// [`valid_file_name`]: only ASCII letters, digits and `._+-`, which github.com can't 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 encoded = |s: &str| s.replace('+', "%2B");
|
|
(valid_tag(tag) && valid_file_name(name)).then(|| format!("{}{}/{}", download_prefix(repo), encoded(tag), encoded(name)))
|
|
}
|
|
|
|
/// Whether `url` is `expected`, an [`expected_download_url`], with each `%2B` in either read as
|
|
/// `+`. GitHub serves a `+` in a download path the same either way, and links built before
|
|
/// `%2B` (an older launcher's feed, or a release saved by an older version) write it plainly.
|
|
/// No other escape is read, so `%2b` or `%2e` still has to match as written.
|
|
pub fn same_download_url(url: &str, expected: &str) -> bool {
|
|
url == expected || url.replace("%2B", "+") == expected.replace("%2B", "+")
|
|
}
|
|
|
|
/// 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 };
|
|
valid_tag(tag) && 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, 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" | b"2e"))
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 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)) });
|
|
}
|
|
let tag = v["tag_name"].as_str().filter(|t| valid_tag(t)).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 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| 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 })
|
|
})
|
|
.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,
|
|
repo: repo.to_string(),
|
|
})
|
|
}
|
|
|
|
/// 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). 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 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),
|
|
name: tag.to_string(),
|
|
published_at: None,
|
|
html_url: format!("https://github.com/{repo}/releases/tag/{tag}"),
|
|
notes: String::new(),
|
|
prerelease: false,
|
|
assets,
|
|
repo: repo.to_string(),
|
|
}
|
|
}
|
|
|
|
/// 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/"))?;
|
|
valid_tag(rest).then(|| rest.to_string())
|
|
}
|
|
|
|
/// A release tag we'd put in a URL, a file or the UI: up to 64 of `[A-Za-z0-9._+-]`, not
|
|
/// starting with a dot, so no separators, control or bidi characters and no `.`/`..`.
|
|
pub fn valid_tag(tag: &str) -> bool {
|
|
tag.len() <= 64 && !tag.starts_with('.') && plain_name(tag)
|
|
}
|
|
|
|
/// Non-empty and only `[A-Za-z0-9._+-]`, the characters every Craft release tag and asset uses.
|
|
fn plain_name(s: &str) -> bool {
|
|
!s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
|
|
}
|
|
|
|
/// Reads `sha256sum` output: `<hex> <name>` (or `<hex> *<name>`) per line, one entry per name.
|
|
/// A line repeated as is counts once; a name listed with two different hashes is dropped,
|
|
/// whichever line comes first, so the list vouches for neither (a file nothing else vouches for
|
|
/// isn't installed).
|
|
pub fn parse_sha256sums(text: &str) -> Vec<(String, String)> {
|
|
let mut sums: Vec<(String, String)> = Vec::new();
|
|
// Where each name is in `sums`: a 4 MiB list holds some 60,000 names, too many to search.
|
|
let mut index: HashMap<&str, usize> = HashMap::new();
|
|
let mut conflicting: HashSet<&str> = HashSet::new();
|
|
for line in text.lines() {
|
|
let Some((hash, rest)) = line.trim().split_once(char::is_whitespace) else { continue };
|
|
let name = rest.trim_start().trim_start_matches('*');
|
|
if !is_hex64(hash) || !valid_file_name(name) {
|
|
continue;
|
|
}
|
|
let hash = hash.to_ascii_lowercase();
|
|
match index.get(name) {
|
|
None => {
|
|
index.insert(name, sums.len());
|
|
sums.push((hash, name.to_string()));
|
|
}
|
|
Some(&i) => {
|
|
if sums.get(i).is_some_and(|(h, _)| *h != hash) && conflicting.insert(name) {
|
|
log::warn!("SHA256SUMS.txt lists {name} with different checksums; ignoring it");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sums.retain(|(_, n)| !conflicting.contains(n.as_str()));
|
|
sums
|
|
}
|
|
|
|
/// A bare file name we'd be willing to save: only `[A-Za-z0-9._+-]` (so no path, escape, control
|
|
/// or bidi character), no leading or trailing dot, and not a name Windows reserves for a device
|
|
/// (`CON`, `nul.txt`, `COM1.msi`).
|
|
pub fn valid_file_name(name: &str) -> bool {
|
|
name.len() <= 200 && !name.starts_with('.') && !name.ends_with('.') && plain_name(name) && !windows_device(name)
|
|
}
|
|
|
|
/// Whether `name`'s part before the first dot is a Windows device name, which opens the device
|
|
/// instead of a file in any folder.
|
|
fn windows_device(name: &str) -> bool {
|
|
let stem = name.split('.').next().unwrap_or_default().to_ascii_uppercase();
|
|
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
|
|| matches!(stem.as_bytes(), [b'C', b'O', b'M', b'1'..=b'9'] | [b'L', b'P', b'T', b'1'..=b'9'])
|
|
}
|
|
|
|
/// `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();
|
|
}
|
|
format!("{}…", &s[..s.floor_char_boundary(max)])
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
/// #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",
|
|
// The right path on another host, over plain http, or behind a look-alike host.
|
|
"https://evil.example/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg",
|
|
"http://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg",
|
|
"https://github.com.evil.example/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg",
|
|
"https://github.com@evil.example/storytold/photocraft/releases/download/v0.5.0/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:?}");
|
|
}
|
|
}
|
|
|
|
/// Two entries for one asset name, one with the release's own link and one elsewhere: only the
|
|
/// release's own survives, in either order, so a later lookup by name can't pick the other.
|
|
/// Built from `SHA256SUMS.txt`, every entry for a repeated name gets that same single link.
|
|
#[test]
|
|
fn two_links_for_one_asset_keep_only_the_releases_own() {
|
|
let name = "photocraft-0.5.0-macos-universal.dmg";
|
|
let own = format!("https://github.com/storytold/photocraft/releases/download/v0.5.0/{name}");
|
|
let asset = |url: &str| format!(r#"{{"name":"{name}","size":123,"browser_download_url":"{url}","digest":"sha256:{HASH}"}}"#);
|
|
let elsewhere = "https://evil.example/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg";
|
|
for (first, second) in [(elsewhere, own.as_str()), (own.as_str(), elsewhere)] {
|
|
let json = 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":[{},{}]}}"#,
|
|
asset(first),
|
|
asset(second)
|
|
);
|
|
let r = parse_github_release(&json, REPO).unwrap();
|
|
assert_eq!(r.assets.len(), 1, "{first} {second}");
|
|
assert_eq!(r.assets[0].url, own);
|
|
}
|
|
|
|
let r = release_from_sums(REPO, "v0.5.0", &[("a".repeat(64), name.to_string()), ("b".repeat(64), name.to_string())]);
|
|
let links: Vec<&str> = r.assets.iter().filter(|a| a.name == name).map(|a| a.url.as_str()).collect();
|
|
assert!(!links.is_empty() && links.iter().all(|url| *url == own), "{links:?}");
|
|
}
|
|
|
|
/// 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)"] {
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// 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).
|
|
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",
|
|
"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}");
|
|
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);
|
|
}
|
|
|
|
/// `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"));
|
|
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);
|
|
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)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_listed_twice_gives_one_asset_or_none() {
|
|
let other = "b".repeat(64);
|
|
// The same line twice (and the same hash in upper case) is one entry.
|
|
let sums = parse_sha256sums(&format!("{HASH} a.zip\n{HASH} *a.zip\n{} a.zip\n", HASH.to_ascii_uppercase()));
|
|
assert_eq!(sums, [(HASH.to_string(), "a.zip".to_string())]);
|
|
let r = release_from_sums(REPO, "v0.5.0", &sums);
|
|
assert_eq!(r.assets.iter().filter(|a| a.name == "a.zip").count(), 1);
|
|
// Two hashes for one name: the name is dropped in either line order, and the other
|
|
// files keep theirs.
|
|
for text in [format!("{HASH} a.zip\n{other} a.zip\n{HASH} b.zip\n"), format!("{other} a.zip\n{HASH} b.zip\n{HASH} a.zip\n")] {
|
|
let sums = parse_sha256sums(&text);
|
|
assert_eq!(sums, [(HASH.to_string(), "b.zip".to_string())], "{text}");
|
|
let r = release_from_sums(REPO, "v0.5.0", &sums);
|
|
assert!(r.assets.iter().all(|a| a.name != "a.zip"), "{text}");
|
|
}
|
|
// A third, matching line doesn't bring the name back.
|
|
assert!(parse_sha256sums(&format!("{HASH} a.zip\n{other} a.zip\n{HASH} a.zip\n")).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_size_list_keeps_its_order_and_finds_a_conflict_across_it() {
|
|
// About as many names as fit in the 4 MiB a list may be, and a conflict between the
|
|
// first and last lines.
|
|
let other = "b".repeat(64);
|
|
let mut text: String = (0..57_000).map(|i| format!("{HASH} f{i}\n")).collect();
|
|
text.push_str(&format!("{other} f0\n"));
|
|
assert!(text.len() <= 4 << 20);
|
|
let sums = parse_sha256sums(&text);
|
|
assert_eq!(sums.len(), 56_999);
|
|
assert!(sums.iter().enumerate().all(|(i, (h, n))| *h == HASH && *n == format!("f{}", i + 1)));
|
|
}
|
|
|
|
/// 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}",
|
|
"%2e%2e%2fother",
|
|
"a%2Fb",
|
|
"a%5cb",
|
|
"x%2E%2E",
|
|
"a b.dmg",
|
|
"a#b",
|
|
"evil\u{202e}gpj.exe",
|
|
"x.msi.",
|
|
"CON",
|
|
"nul.txt",
|
|
"COM1.msi",
|
|
"lpt9",
|
|
"prn",
|
|
"aux.log",
|
|
"a".repeat(201).as_str(),
|
|
] {
|
|
assert!(!valid_file_name(bad), "{bad:?}");
|
|
}
|
|
for good in [
|
|
"photocraft-0.5.0-linux-x86_64.AppImage",
|
|
"lightcraft_0.9.0_amd64.deb",
|
|
"x-1.0+git.tar.gz",
|
|
"console.zip",
|
|
"COM10.txt",
|
|
"COM0.msi",
|
|
"lpt0",
|
|
] {
|
|
assert!(valid_file_name(good), "{good:?}");
|
|
}
|
|
}
|
|
|
|
/// The tag rule the launcher's feed shares (craft-launcher#1024): plain characters, no
|
|
/// leading dot, at most 64 bytes, for the API's `tag_name`, a redirect and a download link.
|
|
#[test]
|
|
fn tags_are_plain() {
|
|
for good in ["v0.6.0", "v1.0.0-beta.2", "nightly-2026-10-29", "v1+build.7"] {
|
|
assert!(valid_tag(good), "{good:?}");
|
|
}
|
|
for bad in ["", ".", "..", ".v1", "v1/x", "v1\\x", "v1\nx", "v1\u{0}x", "v1\u{202e}", "v 1", "v1?x", "v".repeat(65).as_str()] {
|
|
assert!(!valid_tag(bad), "{bad:?}");
|
|
}
|
|
let api = r#"{"tag_name": "v1\u202egpj", "html_url": "", "assets": []}"#;
|
|
assert!(parse_github_release(api, REPO).is_err());
|
|
let prefix = download_prefix(REPO);
|
|
assert!(download_url_under(&format!("{prefix}v1/a.dmg"), &prefix));
|
|
assert!(!download_url_under(&format!("{prefix}v1\u{202e}/a.dmg"), &prefix), "an odd tag in a download link");
|
|
assert!(!download_url_under(&format!("{prefix}v1/a\u{202e}.dmg"), &prefix), "an odd name in a download link");
|
|
assert_eq!(tag_from_redirect(&format!("https://github.com/{REPO}/releases/tag/v1\u{202e}"), REPO), None);
|
|
}
|
|
|
|
/// A `+` matches written plainly or as `%2B`, either side; no other escape is read.
|
|
#[test]
|
|
fn a_plus_link_matches_either_way_and_nothing_else_does() {
|
|
let expected = expected_download_url(REPO, "v1.0.0+b.1", "photocraft-1.0.0+b.1-linux-x86_64.AppImage").unwrap();
|
|
assert!(same_download_url(&expected, &expected));
|
|
assert!(same_download_url(&expected.replace("%2B", "+"), &expected));
|
|
assert!(!same_download_url(&expected.replace("%2B", "%2b"), &expected));
|
|
assert!(!same_download_url(&expected.replace("v1.0.0", "v1.0.1"), &expected));
|
|
}
|
|
|
|
/// Both converters record the repository they read the release from.
|
|
#[test]
|
|
fn a_release_knows_its_repository() {
|
|
assert_eq!(release_from_sums(REPO, "v0.5.0", &[]).repo, REPO);
|
|
let api = r#"{"tag_name": "v0.5.0", "assets": []}"#;
|
|
assert_eq!(parse_github_release(api, REPO).unwrap().repo, REPO);
|
|
}
|
|
|
|
/// 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"]);
|
|
}
|
|
}
|