diff --git a/Cargo.lock b/Cargo.lock index da0a33b..9b110d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -528,6 +528,7 @@ name = "multitool" version = "0.2.1" dependencies = [ "clap", + "once_cell", "regex", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index d56ca4d..cf6fa27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ regex = "1.10.4" serde = { version = "1.0.200", features = ["derive"] } serde_json = "1.0.116" sha256 = "1.5.0" +once_cell = "1.19.0" # The profile that 'cargo dist' will build with [profile.dist] diff --git a/src/lockfile.rs b/src/lockfile.rs new file mode 100644 index 0000000..24b23fc --- /dev/null +++ b/src/lockfile.rs @@ -0,0 +1,151 @@ +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, HashMap}, + fmt::Display, +}; + +pub const SCHEMA: &str = + "https://raw.githubusercontent.com/theoremlp/rules_multitool/main/lockfile.schema.json"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SupportedOs { + Linux, + MacOS, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SupportedCpu { + Arm64, + X86_64, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct FileBinary { + pub url: String, + pub sha256: String, + pub os: SupportedOs, + pub cpu: SupportedCpu, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct ArchiveBinary { + pub url: String, + pub file: String, + pub sha256: String, + pub os: SupportedOs, + pub cpu: SupportedCpu, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_: Option, // TODO(mark): we should probably make this an enum +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct PkgBinary { + pub url: String, + pub file: String, + pub sha256: String, + pub os: SupportedOs, + pub cpu: SupportedCpu, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Binary { + File(FileBinary), + Archive(ArchiveBinary), + Pkg(PkgBinary), +} + +#[derive(Serialize, Deserialize)] +pub struct ToolDefinition { + pub binaries: Vec, +} + +impl Display for SupportedCpu { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self { + SupportedCpu::Arm64 => write!(f, "arm64"), + SupportedCpu::X86_64 => write!(f, "x86_64"), + } + } +} + +impl Display for SupportedOs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self { + SupportedOs::Linux => write!(f, "linux"), + SupportedOs::MacOS => write!(f, "macos"), + } + } +} + +fn schema() -> String { + SCHEMA.to_owned() +} + +#[derive(Serialize, Deserialize)] +pub struct Lockfile { + #[serde(rename = "$schema", default = "schema")] + pub schema: String, + + #[serde(flatten)] + pub tools: BTreeMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_empty_lockfile() { + let lockfile: Lockfile = serde_json::from_str("{}").unwrap(); + assert_eq!(lockfile.schema, schema()); + assert_eq!(lockfile.tools.len(), 0); + } + + #[test] + fn deserialize_lockfile_with_schema_and_no_tools() { + let lockfile: Lockfile = serde_json::from_str(r#"{ + "$schema": "https://raw.githubusercontent.com/theoremlp/rules_multitool/main/lockfile.schema.json" + }"#).unwrap(); + assert_eq!( + lockfile.schema, + "https://raw.githubusercontent.com/theoremlp/rules_multitool/main/lockfile.schema.json" + .to_owned() + ); + assert_eq!(lockfile.tools.len(), 0); + } + + #[test] + fn deserialize_lockfile_with_schema_and_tools() { + let lockfile: Lockfile = serde_json::from_str(r#"{ + "$schema": "https://raw.githubusercontent.com/theoremlp/rules_multitool/main/lockfile.schema.json", + "tool-name": { + "binaries": [ + { + "kind": "file", + "url": "https://github.com/theoremlp/multitool/releases/download/v0.2.1/multitool-x86_64-unknown-linux-gnu.tar.xz", + "sha256": "9523faf97e4e3fea5f98ba9d051e67c90799182580d8ae56cba2e45c7de0b4ce", + "os": "linux", + "cpu": "x86_64" + } + ] + } + }"#).unwrap(); + assert_eq!( + lockfile.schema, + "https://raw.githubusercontent.com/theoremlp/rules_multitool/main/lockfile.schema.json" + .to_owned() + ); + assert_eq!(lockfile.tools.len(), 1); + assert_eq!(lockfile.tools["tool-name"].binaries.len(), 1); + // TOOD(mark): richer tests + } +} diff --git a/src/main.rs b/src/main.rs index 47026cd..9ab7395 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,29 @@ use clap::{Parser, Subcommand}; +use lockfile::{ArchiveBinary, Binary, FileBinary, Lockfile, PkgBinary, ToolDefinition, SCHEMA}; +use once_cell::sync::Lazy as LazyLock; use regex::Regex; -use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ collections::{BTreeMap, HashMap}, error::Error, - fmt::Display, fs, }; +mod lockfile; + +static GITHUB_RELEASE_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?x) + https://github\.com/ + (?P[A-Za-z0-9_-]+)/ + (?P[A-Za-z0-9_-]+)/ + releases/download/ + (?Pv?[^/]+)/ + (?P.+)", + ) + .unwrap() +}); + #[derive(Parser)] struct Cli { #[clap(long)] @@ -25,122 +40,66 @@ enum Commands { Update, } -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum SupportedOs { - Linux, - MacOS, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum SupportedCpu { - Arm64, - X86_64, -} - -#[derive(Clone, Serialize, Deserialize)] -struct FileBinary { - url: String, - sha256: String, - os: SupportedOs, - cpu: SupportedCpu, - #[serde(skip_serializing_if = "Option::is_none")] - headers: Option>, -} - -#[derive(Clone, Serialize, Deserialize)] -struct ArchiveBinary { - url: String, - file: String, - sha256: String, - os: SupportedOs, - cpu: SupportedCpu, - #[serde(skip_serializing_if = "Option::is_none")] - headers: Option>, - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - type_: Option, // TODO(mark): we should probably make this an enum -} - -#[derive(Clone, Serialize, Deserialize)] -struct PkgBinary { - url: String, - file: String, - sha256: String, - os: SupportedOs, - cpu: SupportedCpu, - #[serde(skip_serializing_if = "Option::is_none")] - headers: Option>, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -enum BinaryUnion { - File(FileBinary), - Archive(ArchiveBinary), - Pkg(PkgBinary), -} - -#[derive(Serialize, Deserialize)] -struct Binary { - binaries: Vec, -} - -impl Display for SupportedCpu { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self { - SupportedCpu::Arm64 => write!(f, "arm64"), - SupportedCpu::X86_64 => write!(f, "x86_64"), - } - } -} - -impl Display for SupportedOs { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self { - SupportedOs::Linux => write!(f, "linux"), - SupportedOs::MacOS => write!(f, "macos"), - } - } -} - trait Common { fn url(&self) -> &str; fn sort_key(&self) -> String; } -impl Common for BinaryUnion { +impl Common for Binary { fn url(&self) -> &str { match &self { - BinaryUnion::File(file) => &file.url, - BinaryUnion::Archive(archive) => &archive.url, - BinaryUnion::Pkg(pkg) => &pkg.url, + Binary::File(file) => &file.url, + Binary::Archive(archive) => &archive.url, + Binary::Pkg(pkg) => &pkg.url, } } fn sort_key(&self) -> String { match &self { - BinaryUnion::File(bin) => format!("{}_{}", bin.os, bin.cpu), - BinaryUnion::Archive(bin) => format!("{}_{}", bin.os, bin.cpu), - BinaryUnion::Pkg(bin) => format!("{}_{}", bin.os, bin.cpu), + Binary::File(bin) => format!("{}_{}", bin.os, bin.cpu), + Binary::Archive(bin) => format!("{}_{}", bin.os, bin.cpu), + Binary::Pkg(bin) => format!("{}_{}", bin.os, bin.cpu), } } } +struct GitHubRelease<'a> { + org: &'a str, + repo: &'a str, + version: &'a str, + path: &'a str, +} + +impl GitHubRelease<'_> { + fn from(url: &str) -> Option { + GITHUB_RELEASE_PATTERN.captures(url).map(|capture| { + let (_, [org, repo, version, path]) = capture.extract(); + GitHubRelease { + org, + repo, + version, + path, + } + }) + } +} + fn compute_sha256(client: &reqwest::blocking::Client, url: &str) -> Result> { - let bytes = client.get(url).send()?.bytes()?; + let response = client.get(url).send()?.error_for_status()?; + let bytes = response.bytes()?; Ok(sha256::digest(bytes.to_vec())) } fn update_github_release( client: &reqwest::blocking::Client, gh_latest_releases: &mut HashMap, - binary: &BinaryUnion, - org: &str, - repo: &str, - version: &str, - path: &str, -) -> Result> { + tool: &str, + binary: &Binary, + release: &GitHubRelease, +) -> Result> { + let org = release.org; + let repo = release.repo; + let key = format!("https://api.github.com/repos/{org}/{repo}/releases/latest"); let raw = gh_latest_releases.entry(key.clone()).or_insert_with(|| { client @@ -156,58 +115,76 @@ fn update_github_release( .as_str() .unwrap_or_else(|| panic!("Failed to find tag_name in response:\n===\n{raw}\n===\n")); - if version == latest_tag { + if release.version == latest_tag { return Ok(binary.clone()); } - let version = version.strip_prefix('v').unwrap_or(version); + let version = release.version.strip_prefix('v').unwrap_or(release.version); let latest = latest_tag.strip_prefix('v').unwrap_or(latest_tag); let url = format!( "https://github.com/{org}/{repo}/releases/download/{latest_tag}/{0}", - path.replace(version, latest) + release.path.replace(version, latest) ); // TODO(mark): check that the new url is in .assets[].browser_download_url let sha256 = compute_sha256(client, &url)?; Ok(match binary { - BinaryUnion::File(bin) => BinaryUnion::File(FileBinary { - url, - cpu: bin.cpu.clone(), - os: bin.os.clone(), - sha256, - headers: bin.headers.clone(), - }), - BinaryUnion::Archive(bin) => BinaryUnion::Archive(ArchiveBinary { - url, - file: bin.file.replace(version, latest), - cpu: bin.cpu.clone(), - os: bin.os.clone(), - sha256, - headers: bin.headers.clone(), - type_: bin.type_.clone(), - }), - BinaryUnion::Pkg(bin) => BinaryUnion::Pkg(PkgBinary { - url, - file: bin.file.replace(version, latest), - cpu: bin.cpu.clone(), - os: bin.os.clone(), - sha256, - headers: bin.headers.clone(), - }), + Binary::File(bin) => { + println!( + "Updating {tool} ({}/{}) from {version} to {latest}", + bin.os, bin.cpu + ); + Binary::File(FileBinary { + url, + cpu: bin.cpu.clone(), + os: bin.os.clone(), + sha256, + headers: bin.headers.clone(), + }) + } + Binary::Archive(bin) => { + println!( + "Updating {tool} ({}/{}) from {version} to {latest}", + bin.os, bin.cpu + ); + Binary::Archive(ArchiveBinary { + url, + file: bin.file.replace(version, latest), + cpu: bin.cpu.clone(), + os: bin.os.clone(), + sha256, + headers: bin.headers.clone(), + type_: bin.type_.clone(), + }) + } + Binary::Pkg(bin) => { + println!( + "Updating {tool} ({}/{}) from {version} to {latest}", + bin.os, bin.cpu + ); + Binary::Pkg(PkgBinary { + url, + file: bin.file.replace(version, latest), + cpu: bin.cpu.clone(), + os: bin.os.clone(), + sha256, + headers: bin.headers.clone(), + }) + } }) } -fn update_lockfile(lockfile: &std::path::Path) { - let contents = fs::read_to_string(lockfile).expect("Unable to load lockfile"); +fn update_lockfile(path: &std::path::Path) { + let contents = fs::read_to_string(path).expect("Unable to load lockfile"); - let tools: HashMap = + let lockfile: Lockfile = serde_json::from_str(&contents).expect("Unable to deserialize lockfile"); - let github_release_pattern = Regex::new( - r"https://github\.com/(?P[A-Za-z0-9_-]+)/(?P[A-Za-z0-9_-]+)/releases/download/(?Pv?[^/]+)/(?P.+)" - ).unwrap(); + if lockfile.schema != SCHEMA { + panic!("Unsupported lockfile schema {}", lockfile.schema) + } let client = reqwest::blocking::Client::builder() .user_agent("multitool") @@ -217,43 +194,42 @@ fn update_lockfile(lockfile: &std::path::Path) { // basic cache of latest release lookups let mut gh_latest_releases: HashMap = HashMap::new(); - let tools: BTreeMap = tools + let tools: BTreeMap = lockfile + .tools .into_iter() .map(|(tool, binary)| { - let mut binaries: Vec = binary + let mut binaries: Vec = binary .binaries .into_iter() - .map( - |binary| match github_release_pattern.captures(binary.url()) { - Some(cap) => { - let (_, [org, repo, version, path]) = cap.extract(); - update_github_release( - &client, - &mut gh_latest_releases, - &binary, - org, - repo, - version, - path, - ) - .map_err(|e| { - println!("Encountered error while attempting to update {tool}: {e}") - }) - .unwrap_or(binary) - } - None => binary, - }, - ) + .map(|binary| match GitHubRelease::from(binary.url()) { + Some(release) => update_github_release( + &client, + &mut gh_latest_releases, + &tool, + &binary, + &release, + ) + .map_err(|e| { + println!("Encountered error while attempting to update {tool}: {e}") + }) + .unwrap_or(binary), + None => binary, + }) .collect(); binaries.sort_by_key(|v| v.sort_key()); - (tool, Binary { binaries }) + (tool, ToolDefinition { binaries }) }) .collect(); - let contents = serde_json::to_string_pretty(&tools).unwrap(); - fs::write(lockfile, contents + "\n").expect("Error updating lockfile") + let lockfile = Lockfile { + schema: lockfile.schema, + tools, + }; + + let contents = serde_json::to_string_pretty(&lockfile).unwrap(); + fs::write(path, contents + "\n").expect("Error updating lockfile") } fn main() {