use anyhow::{Context, Result, bail}; use flate2::read::GzDecoder; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::io::{Cursor, Read, Write}; use std::time::Duration; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Build-time config via `option_env!("SECRETS_UPGRADE_URL")`. Set during `cargo build`, e.g.: /// SECRETS_UPGRADE_URL=https://... cargo build --release const BUILD_UPGRADE_URL: Option<&'static str> = option_env!("SECRETS_UPGRADE_URL"); fn upgrade_api_url() -> Result { if let Some(url) = BUILD_UPGRADE_URL.filter(|s| !s.trim().is_empty()) { return Ok(url.to_string()); } let url = std::env::var("SECRETS_UPGRADE_URL").context( "SECRETS_UPGRADE_URL is not set at build or runtime. Set it when building: \ SECRETS_UPGRADE_URL=https://... cargo build, or export before running secrets upgrade.", )?; if url.trim().is_empty() { anyhow::bail!("SECRETS_UPGRADE_URL is empty."); } Ok(url) } #[derive(Debug, Deserialize)] struct Release { tag_name: String, assets: Vec, } #[derive(Debug, Deserialize)] struct Asset { name: String, browser_download_url: String, } fn available_assets(assets: &[Asset]) -> String { assets .iter() .map(|a| a.name.as_str()) .collect::>() .join(", ") } fn release_asset_name(tag_name: &str, suffix: &str) -> String { format!("secrets-{tag_name}-{suffix}") } fn find_asset_by_name<'a>(assets: &'a [Asset], name: &str) -> Result<&'a Asset> { assets.iter().find(|a| a.name == name).with_context(|| { format!( "no matching release asset found: {name}\navailable: {}", available_assets(assets) ) }) } /// Detect the asset suffix for the current platform/arch at compile time. fn platform_asset_suffix() -> Result<&'static str> { #[cfg(all(target_os = "linux", target_arch = "x86_64"))] { Ok("x86_64-linux-musl.tar.gz") } #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { Ok("aarch64-macos.tar.gz") } #[cfg(all(target_os = "macos", target_arch = "x86_64"))] { Ok("x86_64-macos.tar.gz") } #[cfg(all(target_os = "windows", target_arch = "x86_64"))] { Ok("x86_64-windows.zip") } #[cfg(not(any( all(target_os = "linux", target_arch = "x86_64"), all(target_os = "macos", target_arch = "aarch64"), all(target_os = "macos", target_arch = "x86_64"), all(target_os = "windows", target_arch = "x86_64"), )))] bail!( "Unsupported platform: {}/{}", std::env::consts::OS, std::env::consts::ARCH ) } /// Strip the "secrets-" prefix from the tag and parse as semver. fn parse_tag_version(tag: &str) -> Result { let ver_str = tag .strip_prefix("secrets-") .with_context(|| format!("unexpected tag format: {tag}"))?; semver::Version::parse(ver_str) .with_context(|| format!("failed to parse version from tag: {tag}")) } fn sha256_hex(bytes: &[u8]) -> String { let digest = Sha256::digest(bytes); format!("{digest:x}") } fn verify_checksum(asset_name: &str, archive: &[u8], checksum_contents: &str) -> Result { let expected_checksum = parse_checksum_file(checksum_contents)?; let actual_checksum = sha256_hex(archive); if actual_checksum != expected_checksum { bail!( "checksum verification failed for {}: expected {}, got {}", asset_name, expected_checksum, actual_checksum ); } Ok(actual_checksum) } fn parse_checksum_file(contents: &str) -> Result { let checksum = contents .split_whitespace() .next() .context("checksum file is empty")? .trim() .to_ascii_lowercase(); if checksum.len() != 64 || !checksum.bytes().all(|b| b.is_ascii_hexdigit()) { bail!("invalid SHA-256 checksum format") } Ok(checksum) } async fn download_bytes(client: &reqwest::Client, url: &str, context: &str) -> Result> { Ok(client .get(url) .send() .await .with_context(|| format!("{context}: request failed"))? .error_for_status() .with_context(|| format!("{context}: server returned an error"))? .bytes() .await .with_context(|| format!("{context}: failed to read response body"))? .to_vec()) } /// Extract the binary from a tar.gz archive (first file whose name == "secrets"). fn extract_from_targz(bytes: &[u8]) -> Result> { let gz = GzDecoder::new(Cursor::new(bytes)); let mut archive = tar::Archive::new(gz); for entry in archive.entries().context("failed to read tar entries")? { let mut entry = entry.context("bad tar entry")?; let path = entry.path().context("bad tar entry path")?.into_owned(); let fname = path .file_name() .and_then(|n| n.to_str()) .unwrap_or_default(); if fname == "secrets" || fname == "secrets.exe" { let mut buf = Vec::new(); entry.read_to_end(&mut buf).context("read tar entry")?; return Ok(buf); } } bail!("binary not found inside tar.gz archive") } /// Extract the binary from a zip archive (first file whose name matches). #[cfg(target_os = "windows")] fn extract_from_zip(bytes: &[u8]) -> Result> { let reader = Cursor::new(bytes); let mut archive = zip::ZipArchive::new(reader).context("failed to open zip archive")?; for i in 0..archive.len() { let mut file = archive.by_index(i).context("bad zip entry")?; let fname = file.name().to_owned(); if fname.ends_with("secrets.exe") || fname.ends_with("secrets") { let mut buf = Vec::new(); file.read_to_end(&mut buf).context("read zip entry")?; return Ok(buf); } } bail!("binary not found inside zip archive") } pub async fn run(check_only: bool) -> Result<()> { let current = semver::Version::parse(CURRENT_VERSION).context("invalid current version")?; println!("Current version: v{current}"); println!("Checking for updates..."); let client = reqwest::Client::builder() .user_agent(format!("secrets-cli/{CURRENT_VERSION}")) .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(120)) .build() .context("failed to build HTTP client")?; let api_url = upgrade_api_url()?; let release: Release = client .get(&api_url) .send() .await .context("failed to fetch release info")? .error_for_status() .context("release API returned an error")? .json() .await .context("failed to parse release JSON")?; let latest = parse_tag_version(&release.tag_name)?; if latest <= current { println!("Already up to date (v{current})"); return Ok(()); } println!("New version available: v{latest}"); if check_only { println!("Run `secrets upgrade` to update."); return Ok(()); } let suffix = platform_asset_suffix()?; let asset_name = release_asset_name(&release.tag_name, suffix); let asset = find_asset_by_name(&release.assets, &asset_name)?; let checksum_name = format!("{}.sha256", asset.name); let checksum_asset = find_asset_by_name(&release.assets, &checksum_name)?; println!("Downloading {}...", asset.name); let archive = download_bytes(&client, &asset.browser_download_url, "archive download").await?; let checksum_contents = download_bytes( &client, &checksum_asset.browser_download_url, "checksum download", ) .await?; let actual_checksum = verify_checksum( &asset.name, &archive, std::str::from_utf8(&checksum_contents).context("checksum file is not valid UTF-8")?, )?; println!("Verified SHA-256: {actual_checksum}"); println!("Extracting..."); let binary = if suffix.ends_with(".tar.gz") { extract_from_targz(&archive)? } else { #[cfg(target_os = "windows")] { extract_from_zip(&archive)? } #[cfg(not(target_os = "windows"))] bail!("zip extraction is only supported on Windows") }; // Write to a temporary file, set executable permission, then atomically replace. let mut tmp = tempfile::NamedTempFile::new().context("failed to create temp file")?; tmp.write_all(&binary) .context("failed to write temp binary")?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o755); std::fs::set_permissions(tmp.path(), perms).context("failed to chmod temp binary")?; } self_replace::self_replace(tmp.path()).context("failed to replace current binary")?; println!("Updated: v{current} → v{latest}"); Ok(()) } #[cfg(test)] mod tests { use super::*; use flate2::Compression; use flate2::write::GzEncoder; use tar::Builder; #[test] fn parse_tag_version_accepts_release_tag() { let version = parse_tag_version("secrets-0.6.1").expect("version should parse"); assert_eq!(version, semver::Version::new(0, 6, 1)); } #[test] fn parse_tag_version_rejects_invalid_tag() { let err = parse_tag_version("v0.6.1").expect_err("tag should be rejected"); assert!(err.to_string().contains("unexpected tag format")); } #[test] fn parse_checksum_file_accepts_sha256sum_format() { let checksum = parse_checksum_file( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef secrets.tar.gz", ) .expect("checksum should parse"); assert_eq!( checksum, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ); } #[test] fn parse_checksum_file_rejects_invalid_checksum() { let err = parse_checksum_file("not-a-sha256").expect_err("checksum should be rejected"); assert!(err.to_string().contains("invalid SHA-256 checksum format")); } #[test] fn release_asset_name_matches_release_tag() { assert_eq!( release_asset_name("secrets-0.7.0", "x86_64-linux-musl.tar.gz"), "secrets-secrets-0.7.0-x86_64-linux-musl.tar.gz" ); } #[test] fn find_asset_by_name_rejects_stale_platform_match() { let assets = vec![ Asset { name: "secrets-secrets-0.6.9-x86_64-linux-musl.tar.gz".into(), browser_download_url: "https://example.invalid/old".into(), }, Asset { name: "secrets-secrets-0.7.0-aarch64-macos.tar.gz".into(), browser_download_url: "https://example.invalid/other".into(), }, ]; let err = find_asset_by_name(&assets, "secrets-secrets-0.7.0-x86_64-linux-musl.tar.gz") .expect_err("stale asset should not match"); assert!(err.to_string().contains("no matching release asset found")); } #[test] fn sha256_hex_matches_known_value() { assert_eq!( sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ); } #[test] fn verify_checksum_rejects_mismatch() { let err = verify_checksum( "secrets.tar.gz", b"abc", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef secrets.tar.gz", ) .expect_err("checksum mismatch should fail"); assert!(err.to_string().contains("checksum verification failed")); } #[test] fn extract_from_targz_reads_binary() { let payload = b"fake-secrets-binary"; let archive = make_test_targz("secrets", payload); let extracted = extract_from_targz(&archive).expect("binary should extract"); assert_eq!(extracted, payload); } fn make_test_targz(name: &str, payload: &[u8]) -> Vec { let encoder = GzEncoder::new(Vec::new(), Compression::default()); let mut builder = Builder::new(encoder); let mut header = tar::Header::new_gnu(); header.set_mode(0o755); header.set_size(payload.len() as u64); header.set_cksum(); builder .append_data(&mut header, name, payload) .expect("append tar entry"); let encoder = builder.into_inner().expect("finish tar builder"); encoder.finish().expect("finish gzip") } #[cfg(target_os = "windows")] #[test] fn extract_from_zip_reads_binary() { use zip::write::SimpleFileOptions; let cursor = Cursor::new(Vec::::new()); let mut writer = zip::ZipWriter::new(cursor); writer .start_file("secrets.exe", SimpleFileOptions::default()) .expect("start zip file"); writer .write_all(b"fake-secrets-binary") .expect("write zip payload"); let bytes = writer.finish().expect("finish zip").into_inner(); let extracted = extract_from_zip(&bytes).expect("binary should extract"); assert_eq!(extracted, b"fake-secrets-binary"); } }