- upgrade: SECRETS_UPGRADE_URL 改为构建时优先(option_env!),CI 自动注入 - upgrade: 支持运行时回退(.env/export),添加 dotenvy 加载 .env - 泛化示例:IP/实例 ID/域名/密钥名改为示例值(10.0.0.1、example.com 等) - tasks.json: 文件 secret 测试改用 test-fixtures/example-key.pem - 文档更新:AGENTS.md、README.md Made-with: Cursor
412 lines
13 KiB
Rust
412 lines
13 KiB
Rust
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<String> {
|
|
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<Asset>,
|
|
}
|
|
|
|
#[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::<Vec<_>>()
|
|
.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<semver::Version> {
|
|
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<String> {
|
|
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<String> {
|
|
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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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<u8> {
|
|
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::<u8>::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");
|
|
}
|
|
}
|