Skip to main content

kaizen/shell/upgrade/
plan.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2use anyhow::{Result, bail};
3use serde::Deserialize;
4use std::path::Path;
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum UpgradeAction {
8    Homebrew,
9    ReleaseBinary,
10    SourceCargo,
11}
12
13#[derive(Debug, Deserialize)]
14pub struct GithubAsset {
15    pub name: String,
16    pub browser_download_url: String,
17}
18
19#[derive(Debug, Deserialize)]
20pub struct GithubRelease {
21    pub tag_name: String,
22    pub assets: Vec<GithubAsset>,
23}
24
25#[derive(Debug, Eq, PartialEq)]
26pub struct ReleaseAssetPlan {
27    pub version: String,
28    pub target: String,
29    pub archive_name: String,
30    pub checksum_name: String,
31    pub archive_url: String,
32    pub checksum_url: String,
33}
34
35pub fn upgrade_action_for(exe: &Path, from_source: bool) -> UpgradeAction {
36    if from_source {
37        UpgradeAction::SourceCargo
38    } else if super::is_homebrew_install(exe) {
39        UpgradeAction::Homebrew
40    } else {
41        UpgradeAction::ReleaseBinary
42    }
43}
44
45pub fn release_asset_plan(
46    release: &GithubRelease,
47    os: &str,
48    arch: &str,
49) -> Result<ReleaseAssetPlan> {
50    let target = target_triple(os, arch)?;
51    let version = release.tag_name.trim_start_matches('v').to_string();
52    let archive_name = format!("kaizen-v{version}-{target}.tar.gz");
53    let checksum_name = format!("{archive_name}.sha256");
54    Ok(ReleaseAssetPlan {
55        archive_url: asset_url(release, &archive_name)?,
56        checksum_url: asset_url(release, &checksum_name)?,
57        version,
58        target,
59        archive_name,
60        checksum_name,
61    })
62}
63
64pub fn target_triple(os: &str, arch: &str) -> Result<String> {
65    match (os, arch) {
66        ("macos", "aarch64") => Ok("aarch64-apple-darwin".into()),
67        ("macos", "x86_64") => Ok("x86_64-apple-darwin".into()),
68        ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu".into()),
69        ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu".into()),
70        _ => bail!("no binary release for {os}/{arch}; run `kaizen upgrade --from-source`"),
71    }
72}
73
74pub fn parse_sha256(text: &str) -> Result<String> {
75    let hash = text.split_whitespace().next().unwrap_or_default();
76    if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
77        Ok(hash.to_ascii_lowercase())
78    } else {
79        bail!("release checksum asset did not contain a SHA-256 hash")
80    }
81}
82
83pub fn verify_sha256(bytes: &[u8], expected: &str) -> Result<()> {
84    let actual = sha256_hex(bytes);
85    if actual == expected.to_ascii_lowercase() {
86        Ok(())
87    } else {
88        bail!("checksum mismatch for release binary")
89    }
90}
91
92fn asset_url(release: &GithubRelease, name: &str) -> Result<String> {
93    release
94        .assets
95        .iter()
96        .find(|asset| asset.name == name)
97        .map(|asset| asset.browser_download_url.clone())
98        .ok_or_else(|| anyhow::anyhow!("release asset missing: {name}"))
99}
100
101fn sha256_hex(bytes: &[u8]) -> String {
102    use sha2::{Digest, Sha256};
103    hex::encode(Sha256::digest(bytes))
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::path::Path;
110
111    #[test]
112    fn cargo_home_defaults_to_release_binary() {
113        let path = Path::new("/Users/me/.cargo/bin/kaizen");
114        assert_eq!(
115            upgrade_action_for(path, false),
116            UpgradeAction::ReleaseBinary
117        );
118    }
119
120    #[test]
121    fn from_source_uses_cargo_install() {
122        let path = Path::new("/Users/me/.cargo/bin/kaizen");
123        assert_eq!(upgrade_action_for(path, true), UpgradeAction::SourceCargo);
124    }
125
126    #[test]
127    fn homebrew_uses_brew_upgrade() {
128        let path = Path::new("/opt/homebrew/bin/kaizen");
129        assert_eq!(upgrade_action_for(path, false), UpgradeAction::Homebrew);
130    }
131
132    #[test]
133    fn maps_supported_targets() {
134        assert_eq!(
135            target_triple("linux", "x86_64").unwrap(),
136            "x86_64-unknown-linux-gnu"
137        );
138        assert_eq!(
139            target_triple("macos", "aarch64").unwrap(),
140            "aarch64-apple-darwin"
141        );
142    }
143
144    #[test]
145    fn rejects_unsupported_targets() {
146        assert!(target_triple("windows", "x86_64").is_err());
147    }
148
149    #[test]
150    fn parses_checksum_line() {
151        let hash = "0".repeat(64);
152        assert_eq!(
153            parse_sha256(&format!("{hash}  kaizen.tar.gz")).unwrap(),
154            hash
155        );
156    }
157
158    #[test]
159    fn detects_checksum_mismatch() {
160        assert!(verify_sha256(b"abc", &"0".repeat(64)).is_err());
161    }
162
163    #[test]
164    fn finds_release_assets() {
165        let release = GithubRelease {
166            tag_name: "v1.2.3".into(),
167            assets: vec![
168                asset("kaizen-v1.2.3-x86_64-unknown-linux-gnu.tar.gz", "archive"),
169                asset(
170                    "kaizen-v1.2.3-x86_64-unknown-linux-gnu.tar.gz.sha256",
171                    "sha",
172                ),
173            ],
174        };
175        let plan = release_asset_plan(&release, "linux", "x86_64").unwrap();
176        assert_eq!(plan.version, "1.2.3");
177        assert_eq!(plan.archive_url, "archive");
178        assert_eq!(plan.checksum_url, "sha");
179    }
180
181    fn asset(name: &str, url: &str) -> GithubAsset {
182        GithubAsset {
183            name: name.into(),
184            browser_download_url: url.into(),
185        }
186    }
187}