Files
craft-libs/crates/update/src/release.rs
T
Dylan M. Taylor fe61243ebf feat(update): add craft-update crate for release checking, install, and self-updating (#311)
* feat(update): add craft-update crate for release checking, install, and self-updating

* fix(update): provide non-windows stub for finish

* fix(update): address CodeRabbit review feedback on craft-update

* feat(dialog,self_update): enhance prompt styling, support standalone self-updates

* fix(update): make startup prompt modal with Escape, skip non-file tar entries
2026-10-25 09:32:22 -04:00

439 lines
20 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 serde::{Deserialize, Serialize};
use crate::version::{self, Version};
/// One downloadable file of a release.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Asset {
pub name: String,
/// Bytes; 0 when unknown.
pub size: u64,
/// `https://github.com/<repo>/releases/download/<tag>/<name>`.
pub url: String,
/// Lowercase hex SHA-256, when GitHub or `SHA256SUMS.txt` told us.
pub sha256: Option<String>,
}
/// A published release of one app.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Release {
pub tag: String,
pub version: Option<Version>,
/// `PhotoCraft v0.5.0`
pub name: String,
/// Unix seconds; `None` when only the tag is known.
pub published_at: Option<i64>,
pub html_url: String,
/// Release notes (Markdown), capped.
pub notes: String,
pub prerelease: bool,
pub assets: Vec<Asset>,
}
impl Release {
pub fn version_label(&self) -> String {
self.version.as_ref().map_or_else(|| self.tag.clone(), ToString::to_string)
}
/// Fills in checksums from a `SHA256SUMS.txt` for assets that don't have one.
pub fn apply_sums(&mut self, sums: &[(String, String)]) {
for asset in &mut self.assets {
if asset.sha256.is_none() {
asset.sha256 = sums.iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h.clone());
}
}
}
pub fn sums_asset(&self) -> Option<&Asset> {
self.assets.iter().find(|a| a.name == "SHA256SUMS.txt")
}
}
const MAX_NOTES: usize = 64 * 1024;
fn is_hex64(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Whether `url` is safe to hand to the system browser from untrusted text (release notes):
/// `https` only, a host, no user-info, no spaces or control characters, and no shell metacharacters.
///
/// The system opener is not always a direct `open`: the pinned `webbrowser` crate's WSL fallback
/// passes the URL to `cmd.exe /c start` while escaping only `^` and `&`, so a character like `|`
/// in an otherwise valid URL becomes a command separator (CWE-78). Because a legitimate
/// `https://` web link never needs these characters unescaped, we refuse any URL that contains a
/// shell/cmd metacharacter rather than rely on the opener to neutralise it.
pub fn safe_web_link(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else { return false };
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
url.len() <= 2000
&& !authority.is_empty()
&& !authority.contains(['@', '\\'])
&& !url.chars().any(|c| c.is_control() || c.is_whitespace())
&& !url.chars().any(is_shell_meta)
}
/// Characters a command interpreter (POSIX shells or `cmd.exe`) may treat specially. A real
/// `https` URL never needs any of them unescaped in a browser-opened link. `%` matters because
/// `cmd.exe` expands `%NAME%` references, and an expanded value could itself carry metacharacters.
pub fn is_shell_meta(c: char) -> bool {
matches!(c, '|' | '&' | ';' | '<' | '>' | '(' | ')' | '$' | '`' | '"' | '\'' | '^' | '!' | '*' | '%' | '{' | '}' | '[' | ']')
}
/// Whether `url` is a valid release page URL strictly for `repo` on GitHub:
/// starts with `https://github.com/<repo>/releases`, contains no dot-segments (`.` or `..`),
/// no percent-encoded path separators (`%2f`, `%5c`), no query/fragment/backslash, and
/// satisfies [`safe_web_link`] (no shell metacharacters or control bytes, CWE-78).
pub fn safe_release_html_url(repo: &str, url: &str) -> Option<String> {
if !safe_web_link(url) {
return None;
}
let releases = format!("https://github.com/{repo}/releases");
if url == releases {
return Some(url.to_string());
}
let rest = url.strip_prefix(&releases)?;
let path = rest.strip_prefix('/')?;
if path.is_empty() || path.contains(['?', '#', '\\']) {
return None;
}
for seg in path.split('/') {
if seg.is_empty() || seg == "." || seg == ".." || seg.contains("%2f") || seg.contains("%2F") || seg.contains("%5c") || seg.contains("%5C") {
return None;
}
}
Some(url.to_string())
}
/// The prefix every download of `repo` must start with.
pub fn download_prefix(repo: &str) -> String {
format!("https://github.com/{repo}/releases/download/")
}
/// Whether `url` is a download that truly belongs to `repo`.
///
/// A raw `starts_with(prefix)` check is not enough: a value such as
/// `https://github.com/storytold/craft-launcher/releases/download/../../../ATTACKER/REPO/releases/download/v1/x`
/// shares the prefix textually, yet its interpreted path escapes the repository. The link is
/// accepted only when the text after the prefix is exactly `<tag>/<name>` — two non-empty path
/// segments with no traversal (`.`/`..`), no additional slashes, no query or fragment, and no
/// backslash or percent-encoded separators that a client could later re-interpret.
pub fn download_url_under(url: &str, prefix: &str) -> bool {
let Some(rest) = url.strip_prefix(prefix) else { return false };
download_suffix_ok(rest)
}
/// Whether `url` sits under a developer mirror `base` (ending in `/`) without escaping it.
pub fn download_url_under_mirror(url: &str, base: &str) -> bool {
let Some(rest) = url.strip_prefix(base) else { return false };
// A mirror serves `<tag>/<name>` just like GitHub, but we only require that it can't climb out
// of its own folder; its layout is otherwise the developer's business.
!rest.is_empty() && !url.contains(['?', '#', '\\']) && !contains_traversal(rest) && !contains_encoded_separator(rest)
}
/// `<tag>/<name>`: exactly two non-empty segments, no traversal, no re-interpretable separators.
fn download_suffix_ok(rest: &str) -> bool {
if rest.contains(['?', '#', '\\']) || contains_encoded_separator(rest) {
return false;
}
let mut parts = rest.split('/');
let (Some(tag), Some(name), None) = (parts.next(), parts.next(), parts.next()) else { return false };
!tag.is_empty() && tag != "." && tag != ".." && tag.len() <= 64 && !tag.contains('\\') && valid_file_name(name)
}
/// Any `.` or `..` path segment that could climb out of the repository.
fn contains_traversal(rest: &str) -> bool {
rest.split('/').any(|seg| seg == "." || seg == "..")
}
/// Percent-encoded slash or backslash (`%2f`, `%5c`): a client could decode these back into
/// separators after the check, so a confined URL must not contain them.
fn contains_encoded_separator(rest: &str) -> bool {
let bytes = rest.as_bytes();
bytes.windows(3).any(|w| w[0] == b'%' && matches!(&w[1..].to_ascii_lowercase()[..], b"2f" | b"5c"))
}
/// Reads `GET /repos/<repo>/releases/latest` (or one element of `/releases`).
pub fn parse_github_release(json: &str, repo: &str) -> Result<Release, String> {
let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("unreadable answer from GitHub ({e})"))?;
release_from_value(&v, repo)
}
/// Reads `GET /repos/<repo>/releases` and returns the newest stable (or any, with
/// `allow_prerelease`) published release.
pub fn parse_github_release_list(json: &str, repo: &str, allow_prerelease: bool) -> Result<Option<Release>, String> {
let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("unreadable answer from GitHub ({e})"))?;
let list = v.as_array().ok_or("unexpected answer from GitHub")?;
let mut best: Option<Release> = None;
for item in list {
if item["draft"].as_bool().unwrap_or(false) {
continue;
}
let Ok(r) = release_from_value(item, repo) else { continue };
let Some(ver) = r.version.as_ref() else { continue };
if !allow_prerelease && (r.prerelease || !version::is_stable(ver)) {
continue;
}
let newer = best.as_ref().and_then(|b| b.version.as_ref()).is_none_or(|b| version::is_newer(ver, b));
if newer {
best = Some(r);
}
}
Ok(best)
}
fn release_from_value(v: &serde_json::Value, repo: &str) -> Result<Release, String> {
if let Some(msg) = v["message"].as_str() {
return Err(if msg == "Not Found" { "no releases yet".into() } else { format!("GitHub says: {}", truncate(msg, 200)) });
}
let tag = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string();
let releases = format!("https://github.com/{repo}/releases");
let html_url = v["html_url"].as_str().and_then(|u| safe_release_html_url(repo, u)).unwrap_or_else(|| releases.clone());
let prefix = download_prefix(repo);
let assets = v["assets"]
.as_array()
.map(|list| {
list.iter()
.filter_map(|a| {
let name = a["name"].as_str().filter(|n| valid_file_name(n))?.to_string();
let url = a["browser_download_url"].as_str().filter(|u| download_url_under(u, &prefix))?.to_string();
let sha256 = a["digest"].as_str().and_then(|d| d.strip_prefix("sha256:")).filter(|h| is_hex64(h)).map(str::to_ascii_lowercase);
Some(Asset { name, size: a["size"].as_u64().unwrap_or(0), url, sha256 })
})
.collect()
})
.unwrap_or_default();
Ok(Release {
version: version::parse(&tag),
name: v["name"].as_str().map(|n| truncate(n, 120)).filter(|n| !n.is_empty()).unwrap_or_else(|| tag.clone()),
tag,
published_at: v["published_at"].as_str().and_then(crate::time::parse_rfc3339),
html_url,
notes: v["body"].as_str().map(|b| truncate(b, MAX_NOTES)).unwrap_or_default(),
prerelease: v["prerelease"].as_bool().unwrap_or(false),
assets,
})
}
/// A release known only from its tag and `SHA256SUMS.txt` (the quota-free route: the
/// `releases/latest` redirect names the tag, and the sums list every file).
pub fn release_from_sums(repo: &str, tag: &str, sums: &[(String, String)]) -> Release {
let prefix = download_prefix(repo);
let mut assets: Vec<Asset> = sums
.iter()
.map(|(hash, name)| Asset { name: name.clone(), size: 0, url: format!("{prefix}{tag}/{name}"), sha256: Some(hash.clone()) })
.collect();
assets.push(Asset { name: "SHA256SUMS.txt".into(), size: 0, url: format!("{prefix}{tag}/SHA256SUMS.txt"), sha256: None });
Release {
tag: tag.to_string(),
version: version::parse(tag),
name: tag.to_string(),
published_at: None,
html_url: format!("https://github.com/{repo}/releases/tag/{tag}"),
notes: String::new(),
prerelease: false,
assets,
}
}
/// The tag in a `https://github.com/<repo>/releases/tag/<tag>` redirect target.
pub fn tag_from_redirect(location: &str, repo: &str) -> Option<String> {
let rest = location.strip_prefix(&format!("https://github.com/{repo}/releases/tag/"))?;
(!rest.is_empty() && rest.len() <= 64 && rest.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))).then(|| rest.to_string())
}
/// Reads `sha256sum` output: `<hex> <name>` (or `<hex> *<name>`) per line.
pub fn parse_sha256sums(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|line| {
let (hash, rest) = line.trim().split_once(char::is_whitespace)?;
let name = rest.trim_start().trim_start_matches('*');
(is_hex64(hash) && valid_file_name(name)).then(|| (hash.to_ascii_lowercase(), name.to_string()))
})
.collect()
}
/// A bare file name we'd be willing to save: no paths, no control characters.
pub fn valid_file_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 200
&& name != "."
&& name != ".."
&& !name.starts_with('.')
&& !name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*'))
}
/// `s` cut to at most `max` bytes (on a char boundary) with an ellipsis, for text from a
/// release or the feed shown in the UI.
pub(crate) fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end])
}
#[cfg(test)]
mod tests {
use super::*;
const REPO: &str = "storytold/photocraft";
#[test]
fn truncate_keeps_whole_characters() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("abcdef", 3), "abc…");
// `é` is two bytes: cutting at 2 would split it, so the cut backs off to 1.
assert_eq!(truncate("aé", 2), "a…");
}
#[test]
fn only_plain_https_links_pass() {
assert!(safe_web_link("https://github.com/storytold/photocraft/releases/tag/v1"));
for bad in [
"http://example.com/",
"javascript:alert(1)",
"file:///C:/Windows/System32/calc.exe",
"ms-msdt:/id",
"https://",
"https://user@evil.example/",
"https://example.com/a b",
"https://example.com/\nx",
"https:///path",
// Shell/cmd metacharacters: the WSL opener fallback shells out, so these must be
// refused even inside an otherwise valid https URL (CWE-78).
"https://example.com/a|calc",
"https://example.com/p?x=1&y=2",
"https://example.com/`id`",
"https://example.com/$(whoami)",
"https://example.com/a;b",
"https://example.com/a>b",
"https://example.com/%COMSPEC%",
"https://example.com/%PATH%",
] {
assert!(!safe_web_link(bad), "{bad:?}");
}
}
const HASH: &str = "8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1";
fn api_json(url: &str) -> String {
format!(
r#"{{"tag_name":"v0.5.0","name":"PhotoCraft v0.5.0","html_url":"https://github.com/storytold/photocraft/releases/tag/v0.5.0",
"published_at":"2026-10-08T14:52:50Z","prerelease":false,"body":"Notes",
"assets":[{{"name":"photocraft-0.5.0-macos-universal.dmg","size":123,"browser_download_url":"{url}","digest":"sha256:{HASH}"}}]}}"#
)
}
#[test]
fn reads_the_latest_release() {
let r = parse_github_release(
&api_json("https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg"),
REPO,
)
.unwrap();
assert_eq!(r.version, Some(Version::new(0, 5, 0)));
assert_eq!(r.name, "PhotoCraft v0.5.0");
assert_eq!(r.published_at, Some(1_791_471_170));
assert_eq!(r.assets.len(), 1);
assert_eq!(r.assets[0].sha256.as_deref(), Some(HASH));
assert_eq!(r.assets[0].size, 123);
}
#[test]
fn drops_downloads_that_point_elsewhere() {
for url in ["https://evil.example/photocraft.dmg", "https://github.com/storytold/other/releases/download/v1/x.dmg", "javascript:alert(1)"] {
let r = parse_github_release(&api_json(url), REPO).unwrap();
assert!(r.assets.is_empty(), "{url}");
}
assert_eq!(parse_github_release(r#"{"message":"Not Found"}"#, REPO).unwrap_err(), "no releases yet");
assert!(parse_github_release("<html>", REPO).is_err());
}
#[test]
fn release_html_url_rejects_traversal_and_metacharacters() {
let repo = "storytold/photocraft";
assert_eq!(
safe_release_html_url(repo, "https://github.com/storytold/photocraft/releases/tag/v1.0.0"),
Some("https://github.com/storytold/photocraft/releases/tag/v1.0.0".to_string())
);
assert_eq!(
safe_release_html_url(repo, "https://github.com/storytold/photocraft/releases"),
Some("https://github.com/storytold/photocraft/releases".to_string())
);
for bad in [
"https://github.com/storytold/photocraft/releases/../../attacker/repo/releases/tag/v1",
"https://github.com/storytold/photocraft/releases/tag/v1/../../../attacker/repo",
"https://github.com/storytold/photocraft/releases/tag/v1|calc.exe",
"https://github.com/storytold/photocraft/releases/tag/v1%2f..%2f..",
"https://github.com/storytold/photocraft/releases/tag/v1?x=1",
"https://github.com/storytold/photocraft/releases/tag/v1#hash",
"https://github.com/storytold/other/releases/tag/v1.0.0",
"http://github.com/storytold/photocraft/releases/tag/v1",
] {
assert!(safe_release_html_url(repo, bad).is_none(), "should reject: {bad}");
}
}
#[test]
fn traversal_cannot_escape_the_repository() {
// Raw-prefix matches but the interpreted path climbs into another repository (CWE-180).
for url in [
"https://github.com/storytold/photocraft/releases/download/../../../../attacker/repo/releases/download/v99/photocraft-99.0.0-linux-x86_64.AppImage",
"https://github.com/storytold/photocraft/releases/download/v1/../../../attacker/repo/x.dmg",
"https://github.com/storytold/photocraft/releases/download/./v1/x.dmg",
"https://github.com/storytold/photocraft/releases/download/v1/sub/x.dmg",
"https://github.com/storytold/photocraft/releases/download/v1%2f..%2f..%2fx.dmg",
"https://github.com/storytold/photocraft/releases/download/v1/x%2fy.dmg",
] {
let prefix = download_prefix(REPO);
assert!(!download_url_under(url, &prefix), "{url}");
let r = parse_github_release(&api_json(url), REPO).unwrap();
assert!(r.assets.is_empty(), "{url}");
}
// A plain, confined download still passes.
let prefix = download_prefix(REPO);
assert!(download_url_under("https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg", &prefix));
}
#[test]
fn list_picks_the_newest_stable_release() {
let json = r#"[
{"tag_name":"v0.6.0-rc.1","prerelease":false,"draft":false,"assets":[]},
{"tag_name":"v0.5.1","prerelease":true,"draft":false,"assets":[]},
{"tag_name":"v0.7.0","draft":true,"assets":[]},
{"tag_name":"v0.5.0","prerelease":false,"draft":false,"assets":[]},
{"tag_name":"v0.4.0","prerelease":false,"draft":false,"assets":[]}]"#;
assert_eq!(parse_github_release_list(json, REPO, false).unwrap().unwrap().tag, "v0.5.0");
assert_eq!(parse_github_release_list(json, REPO, true).unwrap().unwrap().tag, "v0.6.0-rc.1");
assert_eq!(parse_github_release_list("[]", REPO, false).unwrap(), None);
}
#[test]
fn reads_checksums_and_redirects() {
let sums = parse_sha256sums(&format!("{HASH} photocraft-0.5.0-macos-universal.dmg\n{HASH} *b.zip\nnot a line\n{HASH} ../escape\n"));
assert_eq!(sums, [(HASH.to_string(), "photocraft-0.5.0-macos-universal.dmg".to_string()), (HASH.to_string(), "b.zip".to_string())]);
assert_eq!(tag_from_redirect("https://github.com/storytold/photocraft/releases/tag/v0.5.0", REPO).as_deref(), Some("v0.5.0"));
assert_eq!(tag_from_redirect("https://github.com/storytold/photocraft/releases", REPO), None);
assert_eq!(tag_from_redirect("https://evil/storytold/photocraft/releases/tag/v1", REPO), None);
let r = release_from_sums(REPO, "v0.5.0", &sums);
assert_eq!(r.assets[0].url, "https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg");
assert_eq!(r.version, Some(Version::new(0, 5, 0)));
}
#[test]
fn file_names_cannot_escape() {
for bad in ["", "..", "a/b", "a\\b", ".hidden", "c:x", "x\u{0}"] {
assert!(!valid_file_name(bad), "{bad:?}");
}
assert!(valid_file_name("photocraft-0.5.0-linux-x86_64.AppImage"));
}
}