Files
OneCAD-Simulation/crates/update/src/release.rs
T
jyvikoandClaude Opus 5.5 8fe31545ea Update: check, verify and stage releases in a new fieldcraft-update crate (#231)
Versions ordered as SemVer, the GitHub Releases API reply read as hostile input, how this
copy is installed (macOS app or CLI, Windows folder, AppImage, tar.gz folder, .deb/.rpm) and
which release asset updates it, the checksum list's minisign signature checked against the
built-in release key, the asset's SHA-256 checked against the list, archives unpacked inside
the staging folder, and the swap planned and applied by renames with undo on failure. The
network and unpacking parts are native only; a mock release server drives the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:48:30 -04:00

176 lines
6.6 KiB
Rust

//! A published release, as the GitHub Releases API describes it
//! (`GET /repos/{owner}/{repo}/releases/latest`, `…/releases/tags/{tag}`). The reply is hostile
//! input: every string and list is capped, and a draft is refused.
use serde_json::{Value, json};
use crate::{Error, Result, SIGNATURE, SUMS, Version};
/// The most assets a release may list (FieldCraft's have nine).
const MAX_ASSETS: usize = 100;
/// The longest asset name, URL or tag kept.
const MAX_NAME: usize = 512;
/// The most of the release notes kept (bytes).
const MAX_NOTES: usize = 64 * 1024;
/// A file of a release.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Asset {
pub name: String,
/// Where it downloads from (`browser_download_url`).
pub url: String,
/// Its size in bytes, as the release lists it (0 when not given).
pub size: u64,
}
/// A published (non-draft) release.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Release {
pub version: Version,
/// Its tag (`v0.5.8`).
pub tag: String,
/// Its page on GitHub: the release notes and every download.
pub url: String,
/// The release notes (Markdown, capped).
pub notes: String,
/// When it was published (ISO 8601), as GitHub gives it.
pub published: String,
pub prerelease: bool,
pub assets: Vec<Asset>,
}
/// A string field of `v`, capped at `max` bytes (cut at a character boundary).
fn text(v: &Value, key: &str, max: usize) -> String {
let s = v.get(key).and_then(Value::as_str).unwrap_or_default();
let mut end = s.len().min(max);
while !s.is_char_boundary(end) {
end -= 1;
}
s.get(..end).unwrap_or_default().to_string()
}
impl Release {
/// The release a GitHub Releases API reply describes.
pub fn from_github(v: &Value) -> Result<Release> {
if v.get("draft").and_then(Value::as_bool) == Some(true) {
return Err(Error::Release("the release is a draft".into()));
}
let tag = text(v, "tag_name", MAX_NAME);
let version =
Version::parse(&tag).ok_or_else(|| Error::Release(format!("tag `{}` is not a version", tag.chars().take(64).collect::<String>())))?;
let list = v.get("assets").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
if list.len() > MAX_ASSETS {
return Err(Error::Release(format!("the release lists {} assets (at most {MAX_ASSETS})", list.len())));
}
let assets = list
.iter()
.map(|a| Asset {
name: text(a, "name", MAX_NAME),
url: text(a, "browser_download_url", MAX_NAME),
size: a.get("size").and_then(Value::as_u64).unwrap_or(0),
})
.filter(|a| !a.name.is_empty() && !a.url.is_empty())
.collect();
Ok(Release {
version,
tag,
url: text(v, "html_url", MAX_NAME),
notes: text(v, "body", MAX_NOTES),
published: text(v, "published_at", 64),
prerelease: v.get("prerelease").and_then(Value::as_bool).unwrap_or(false),
assets,
})
}
/// The asset named `name`.
pub fn asset(&self, name: &str) -> Option<&Asset> {
self.assets.iter().find(|a| a.name == name)
}
/// Whether the release carries the signed checksum list an install needs
/// (`SHA256SUMS.txt` and `SHA256SUMS.txt.minisig`).
pub fn signed(&self) -> bool {
self.asset(SUMS).is_some() && self.asset(SIGNATURE).is_some()
}
}
/// What a check found: the running version and the latest release.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Check {
pub current: Version,
pub latest: Release,
}
impl Check {
/// The latest release is newer than the running version.
pub fn newer(&self) -> bool {
self.latest.version > self.current
}
/// As `app.update.check` and `fieldcraft-cli update --check` report it; `asset` is the
/// file this install would download (`None` when it cannot install itself).
pub fn to_json(&self, asset: Option<&str>) -> Value {
json!({
"current": self.current.to_string(),
"latest": self.latest.version.to_string(),
"newer": self.newer(),
"url": self.latest.url,
"published": self.latest.published,
"signed": self.latest.signed(),
"asset": asset,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) fn github(tag: &str) -> Value {
json!({
"tag_name": tag,
"html_url": format!("https://github.com/jyviko/fieldcraft/releases/tag/{tag}"),
"body": "## What's new\n- updates",
"draft": false,
"prerelease": false,
"published_at": "2026-10-15T04:06:51Z",
"assets": [
{"name": "SHA256SUMS.txt", "browser_download_url": "https://example.invalid/SHA256SUMS.txt", "size": 700},
{"name": "SHA256SUMS.txt.minisig", "browser_download_url": "https://example.invalid/SHA256SUMS.txt.minisig", "size": 300},
{"name": "", "browser_download_url": "https://example.invalid/x"},
],
})
}
#[test]
fn a_release_reads_from_the_api_reply() {
let r = Release::from_github(&github("v0.5.9")).unwrap();
assert_eq!((r.version.to_string().as_str(), r.tag.as_str(), r.assets.len()), ("0.5.9", "v0.5.9", 2));
assert!(r.signed() && r.notes.starts_with("## What's new"));
let check = Check { current: Version::parse("0.5.8").unwrap(), latest: r.clone() };
assert!(check.newer());
assert_eq!(check.to_json(Some("a.dmg"))["latest"], "0.5.9");
assert!(!Check { current: Version::parse("0.5.9").unwrap(), latest: r.clone() }.newer());
assert!(!Check { current: Version::parse("0.6.0-rc.1").unwrap(), latest: r }.newer());
let mut unsigned = github("v0.5.9");
unsigned["assets"] = json!([]);
assert!(!Release::from_github(&unsigned).unwrap().signed());
let mut draft = github("v0.5.9");
draft["draft"] = json!(true);
assert!(Release::from_github(&draft).is_err());
for hostile in [
json!(null),
json!([]),
json!({"tag_name": 7}),
json!({"tag_name": "latest"}),
json!({"tag_name": "v1.0.0", "assets": vec![json!({}); 1000]}),
] {
assert!(Release::from_github(&hostile).is_err(), "{hostile}");
}
let mut long = github("v1.0.0");
long["body"] = json!("é".repeat(MAX_NOTES));
assert!(Release::from_github(&long).unwrap().notes.len() <= MAX_NOTES);
}
}