feat(upgrade): SHA-256校验、Intel mac 交叉编译、全平台后发布
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Has been cancelled
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Has been cancelled
- upgrade: 下载后校验 .sha256 摘要再安装 - workflow: ARM mac 同时产出 aarch64/x86_64 双架构,补全 Intel mac 产物 - workflow: 各平台上传主资产及 .sha256,Linux/macOS/Windows 全成功才发布 Release - upgrade: 补充 parse_tag_version、parse_checksum_file、extract_from_targz 单元测试 - docs: README/AGENTS 同步 upgrade 与平台说明 Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use anyhow::{Context, Result, bail};
|
||||
use flate2::read::GzDecoder;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
const GITEA_API: &str = "https://gitea.refining.dev/api/v1/repos/refining/secrets/releases/latest";
|
||||
@@ -19,6 +20,26 @@ struct Asset {
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
fn available_assets(assets: &[Asset]) -> String {
|
||||
assets
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn find_asset_by_suffix<'a>(assets: &'a [Asset], suffix: &str) -> Result<&'a Asset> {
|
||||
assets
|
||||
.iter()
|
||||
.find(|a| a.name.ends_with(suffix))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"no asset found for this platform (looking for suffix: {suffix})\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"))]
|
||||
@@ -63,6 +84,40 @@ fn parse_tag_version(tag: &str) -> Result<semver::Version> {
|
||||
.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 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));
|
||||
@@ -137,43 +192,52 @@ pub async fn run(check_only: bool) -> Result<()> {
|
||||
}
|
||||
|
||||
let suffix = platform_asset_suffix()?;
|
||||
let asset = release
|
||||
let asset = find_asset_by_suffix(&release.assets, suffix)?;
|
||||
let checksum_name = format!("{}.sha256", asset.name);
|
||||
let checksum_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.ends_with(suffix))
|
||||
.find(|a| a.name == checksum_name)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"no asset found for this platform (looking for suffix: {suffix})\navailable: {}",
|
||||
release
|
||||
.assets
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
"missing checksum asset for download: {checksum_name}\navailable: {}",
|
||||
available_assets(&release.assets)
|
||||
)
|
||||
})?;
|
||||
|
||||
println!("Downloading {}...", asset.name);
|
||||
|
||||
let bytes = client
|
||||
.get(&asset.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("download failed")?
|
||||
.error_for_status()
|
||||
.context("download returned an error")?
|
||||
.bytes()
|
||||
.await
|
||||
.context("failed to read download body")?;
|
||||
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 expected_checksum = parse_checksum_file(
|
||||
std::str::from_utf8(&checksum_contents).context("checksum file is not valid UTF-8")?,
|
||||
)?;
|
||||
let actual_checksum = sha256_hex(&archive);
|
||||
|
||||
if actual_checksum != expected_checksum {
|
||||
bail!(
|
||||
"checksum verification failed for {}: expected {}, got {}",
|
||||
asset.name,
|
||||
expected_checksum,
|
||||
actual_checksum
|
||||
);
|
||||
}
|
||||
|
||||
println!("Verified SHA-256: {actual_checksum}");
|
||||
|
||||
println!("Extracting...");
|
||||
|
||||
let binary = if suffix.ends_with(".tar.gz") {
|
||||
extract_from_targz(&bytes)?
|
||||
extract_from_targz(&archive)?
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
extract_from_zip(&bytes)?
|
||||
extract_from_zip(&archive)?
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
bail!("zip extraction is only supported on Windows")
|
||||
@@ -196,3 +260,92 @@ pub async fn run(check_only: bool) -> Result<()> {
|
||||
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 sha256_hex_matches_known_value() {
|
||||
assert_eq!(
|
||||
sha256_hex(b"abc"),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user