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

- 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:
voson
2026-03-19 11:06:10 +08:00
parent 2da7aab3e5
commit baad623efe
6 changed files with 242 additions and 46 deletions

View File

@@ -204,9 +204,13 @@ jobs:
bin="target/x86_64-unknown-linux-musl/release/${{ env.BINARY_NAME }}" bin="target/x86_64-unknown-linux-musl/release/${{ env.BINARY_NAME }}"
archive="${{ env.BINARY_NAME }}-${tag}-x86_64-linux-musl.tar.gz" archive="${{ env.BINARY_NAME }}-${tag}-x86_64-linux-musl.tar.gz"
tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")" tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")"
sha256sum "$archive" > "${archive}.sha256"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}" \ -F "attachment=@${archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}.sha256" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
- name: 飞书通知 - name: 飞书通知
if: always() if: always()
@@ -229,7 +233,7 @@ jobs:
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL" curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
build-macos: build-macos:
name: Build (aarch64-apple-darwin) name: Build (macOS aarch64 + x86_64)
needs: [version, check] needs: [version, check]
runs-on: darwin-arm64 runs-on: darwin-arm64
timeout-minutes: 15 timeout-minutes: 15
@@ -241,6 +245,7 @@ jobs:
fi fi
source "$HOME/.cargo/env" 2>/dev/null || true source "$HOME/.cargo/env" 2>/dev/null || true
rustup target add aarch64-apple-darwin rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -253,12 +258,14 @@ jobs:
~/.cargo/registry/cache ~/.cargo/registry/cache
~/.cargo/git/db ~/.cargo/git/db
target target
key: cargo-aarch64-apple-darwin-${{ hashFiles('Cargo.lock') }} key: cargo-macos-${{ hashFiles('Cargo.lock') }}
restore-keys: | restore-keys: |
cargo-aarch64-apple-darwin- cargo-macos-
- run: cargo build --release --locked --target aarch64-apple-darwin - run: cargo build --release --locked --target aarch64-apple-darwin
- run: cargo build --release --locked --target x86_64-apple-darwin
- run: strip -x target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }} - run: strip -x target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}
- run: strip -x target/x86_64-apple-darwin/release/${{ env.BINARY_NAME }}
- name: 上传 Release 产物 - name: 上传 Release 产物
if: needs.version.outputs.release_id != '' if: needs.version.outputs.release_id != ''
@@ -267,12 +274,29 @@ jobs:
run: | run: |
[ -z "$RELEASE_TOKEN" ] && exit 0 [ -z "$RELEASE_TOKEN" ] && exit 0
tag="${{ needs.version.outputs.tag }}" tag="${{ needs.version.outputs.tag }}"
bin="target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}" release_id="${{ needs.version.outputs.release_id }}"
archive="${{ env.BINARY_NAME }}-${tag}-aarch64-macos.tar.gz"
tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")" arm_bin="target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}"
arm_archive="${{ env.BINARY_NAME }}-${tag}-aarch64-macos.tar.gz"
tar -czf "$arm_archive" -C "$(dirname "$arm_bin")" "$(basename "$arm_bin")"
shasum -a 256 "$arm_archive" > "${arm_archive}.sha256"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}" \ -F "attachment=@${arm_archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${arm_archive}.sha256" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
intel_bin="target/x86_64-apple-darwin/release/${{ env.BINARY_NAME }}"
intel_archive="${{ env.BINARY_NAME }}-${tag}-x86_64-macos.tar.gz"
tar -czf "$intel_archive" -C "$(dirname "$intel_bin")" "$(basename "$intel_bin")"
shasum -a 256 "$intel_archive" > "${intel_archive}.sha256"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${intel_archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${intel_archive}.sha256" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
- name: 飞书通知 - name: 飞书通知
if: always() if: always()
@@ -285,8 +309,9 @@ jobs:
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}" result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets macOS 构建${icon} msg="secrets macOS 双架构构建${icon}
版本:${tag} 版本:${tag}
目标aarch64-apple-darwin, x86_64-apple-darwin
提交:${commit} 提交:${commit}
作者:${{ github.actor }} 作者:${{ github.actor }}
详情:${url}" 详情:${url}"
@@ -338,10 +363,15 @@ jobs:
$bin = "target\x86_64-pc-windows-msvc\release\${{ env.BINARY_NAME }}.exe" $bin = "target\x86_64-pc-windows-msvc\release\${{ env.BINARY_NAME }}.exe"
$archive = "${{ env.BINARY_NAME }}-${tag}-x86_64-windows.zip" $archive = "${{ env.BINARY_NAME }}-${tag}-x86_64-windows.zip"
Compress-Archive -Path $bin -DestinationPath $archive -Force Compress-Archive -Path $bin -DestinationPath $archive -Force
$hash = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLower()
Set-Content -Path "${archive}.sha256" -Value "$hash $archive" -NoNewline
$url = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" $url = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
Invoke-RestMethod -Uri $url -Method Post ` Invoke-RestMethod -Uri $url -Method Post `
-Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } ` -Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } `
-Form @{ attachment = Get-Item $archive } -Form @{ attachment = Get-Item $archive }
Invoke-RestMethod -Uri $url -Method Post `
-Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } `
-Form @{ attachment = Get-Item "${archive}.sha256" }
- name: 飞书通知 - name: 飞书通知
if: always() if: always()
@@ -362,7 +392,7 @@ jobs:
publish-release: publish-release:
name: 发布草稿 Release name: 发布草稿 Release
needs: [version, build-linux] needs: [version, build-linux, build-macos, build-windows]
if: always() && needs.version.outputs.release_id != '' if: always() && needs.version.outputs.release_id != ''
runs-on: debian runs-on: debian
timeout-minutes: 5 timeout-minutes: 5
@@ -376,8 +406,11 @@ jobs:
[ -z "$RELEASE_TOKEN" ] && exit 0 [ -z "$RELEASE_TOKEN" ] && exit 0
linux_r="${{ needs.build-linux.result }}" linux_r="${{ needs.build-linux.result }}"
if [ "$linux_r" != "success" ]; then macos_r="${{ needs.build-macos.result }}"
echo "Linux 构建未成功 (${linux_r}),保留草稿 Release" windows_r="${{ needs.build-windows.result }}"
if [ "$linux_r" != "success" ] || [ "$macos_r" != "success" ] || [ "$windows_r" != "success" ]; then
echo "存在未成功的构建任务,保留草稿 Release"
echo "linux=${linux_r} macos=${macos_r} windows=${windows_r}"
exit 0 exit 0
fi fi
@@ -408,15 +441,16 @@ jobs:
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A") commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
check_r="${{ needs.version.result }}"
linux_r="${{ needs.build-linux.result }}" linux_r="${{ needs.build-linux.result }}"
macos_r="${{ needs.build-macos.result }}"
windows_r="${{ needs.build-windows.result }}"
publish_r="${{ job.status }}" publish_r="${{ job.status }}"
icon() { case "$1" in success) echo "✅";; skipped) echo "⏭";; *) echo "❌";; esac; } icon() { case "$1" in success) echo "✅";; skipped) echo "⏭";; *) echo "❌";; esac; }
if [ "$linux_r" = "success" ] && [ "$publish_r" = "success" ]; then if [ "$linux_r" = "success" ] && [ "$macos_r" = "success" ] && [ "$windows_r" = "success" ] && [ "$publish_r" = "success" ]; then
status="发布成功 ✅" status="发布成功 ✅"
elif [ "$linux_r" != "success" ]; then elif [ "$linux_r" != "success" ] || [ "$macos_r" != "success" ] || [ "$windows_r" != "success" ]; then
status="构建失败 ❌" status="构建失败 ❌"
else else
status="发布失败 ❌" status="发布失败 ❌"
@@ -430,7 +464,7 @@ jobs:
msg="secrets ${status} msg="secrets ${status}
${version_line} ${version_line}
linux $(icon "$linux_r") | Release $(icon "$publish_r") linux $(icon "$linux_r") | macOS $(icon "$macos_r") | windows $(icon "$windows_r") | Release $(icon "$publish_r")
提交:${commit} 提交:${commit}
作者:${{ github.actor }} 作者:${{ github.actor }}
详情:${url}" 详情:${url}"

View File

@@ -23,7 +23,7 @@ secrets/
update.rs # update 命令增量更新CAS 并发保护,含历史快照 update.rs # update 命令增量更新CAS 并发保护,含历史快照
rollback.rs # rollback / history 命令:版本回滚与历史查看 rollback.rs # rollback / history 命令:版本回滚与历史查看
run.rs # inject / run 命令:临时环境变量注入 run.rs # inject / run 命令:临时环境变量注入
upgrade.rs # upgrade 命令:检查并下载最新版本,自动替换二进制 upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制
scripts/ scripts/
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
.gitea/workflows/ .gitea/workflows/
@@ -408,13 +408,13 @@ secrets run -n refining --kind service --name gitea -- printenv
### upgrade — 自动更新 CLI 二进制 ### upgrade — 自动更新 CLI 二进制
从 Gitea Release 下载最新版本替换当前二进制,无需数据库连接或主密钥。 从 Gitea Release 下载最新版本,校验对应 `.sha256` 摘要后替换当前二进制,无需数据库连接或主密钥。
```bash ```bash
# 检查是否有新版本(不下载) # 检查是否有新版本(不下载)
secrets upgrade --check secrets upgrade --check
# 下载并安装最新版本 # 下载、校验 SHA-256 并安装最新版本
secrets upgrade secrets upgrade
``` ```
@@ -496,10 +496,11 @@ cargo fmt -- --check && cargo clippy -- -D warnings && cargo test
## CI/CD ## CI/CD
- Gitea Actionsrunner: debian - Gitea Actionsrunners: debian / darwin-arm64 / windows
- 触发:`src/**``Cargo.toml``Cargo.lock` 变更推送到 main - 触发:`src/**``Cargo.toml``Cargo.lock` 变更推送到 main
- 构建目标:`x86_64-unknown-linux-musl`(静态链接,无 glibc 依赖) - 构建目标:`x86_64-unknown-linux-musl``aarch64-apple-darwin``x86_64-apple-darwin`(由 ARM mac runner 交叉编译)、`x86_64-pc-windows-msvc`
- 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制到 Gitea Release - 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制与对应 `.sha256` 摘要到 Gitea Release
- Release 仅在 Linux/macOS/Windows 构建全部成功后才会从 draft 发布
- 通知:飞书 Webhook`vars.WEBHOOK_URL` - 通知:飞书 Webhook`vars.WEBHOOK_URL`
- 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`(通知,可选) - 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`(通知,可选)
- **注意**Gitea Actions 的 Secret/Variable 创建时,`data`/`value` 字段需传入**原始值**,不要使用 base64 编码 - **注意**Gitea Actions 的 Secret/Variable 创建时,`data`/`value` 字段需传入**原始值**,不要使用 base64 编码

1
Cargo.lock generated
View File

@@ -1853,6 +1853,7 @@ dependencies = [
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sqlx", "sqlx",
"tar", "tar",
"tempfile", "tempfile",

View File

@@ -19,6 +19,7 @@ self-replace = "1.5.0"
semver = "1.0.27" semver = "1.0.27"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
sha2 = "0.10.9"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] } sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] }
tar = "0.4.44" tar = "0.4.44"
tempfile = "3.19" tempfile = "3.19"

View File

@@ -11,7 +11,7 @@ cargo build --release
# 或从 Release 页面下载预编译二进制 # 或从 Release 页面下载预编译二进制
``` ```
已有旧版本时,可执行 `secrets upgrade` 自动下载最新版并替换。 已有旧版本时,可执行 `secrets upgrade` 自动下载最新版并替换。该命令会校验 Release 附带的 `.sha256` 摘要后再安装。
## 首次使用(每台设备各执行一次) ## 首次使用(每台设备各执行一次)
@@ -140,7 +140,7 @@ secrets config path # 打印配置文件路径
# ── upgrade ────────────────────────────────────────────────────────────────── # ── upgrade ──────────────────────────────────────────────────────────────────
secrets upgrade --check # 仅检查是否有新版本 secrets upgrade --check # 仅检查是否有新版本
secrets upgrade # 下载并安装最新版(从 Gitea Release secrets upgrade # 下载、校验 SHA-256 并安装最新版(从 Gitea Release
# ── 调试 ────────────────────────────────────────────────────────────────────── # ── 调试 ──────────────────────────────────────────────────────────────────────
secrets --verbose search -q mqtt secrets --verbose search -q mqtt
@@ -199,7 +199,7 @@ scripts/
## CI/CDGitea Actions ## CI/CDGitea Actions
推送 `main` 分支时自动fmt/clippy 检查 → musl 构建 → 创建 Release 并上传二进制 推送 `main` 分支时自动fmt/clippy/test 检查 → Linux/macOS/Windows 构建 → 上传二进制与 `.sha256` 摘要 → 所有平台成功后发布 Release。
**首次使用需配置 Actions 变量和 Secrets** **首次使用需配置 Actions 变量和 Secrets**
@@ -212,4 +212,10 @@ scripts/
- `WEBHOOK_URL`Variable飞书通知可选 - `WEBHOOK_URL`Variable飞书通知可选
- **注意**Secret/Variable 的 `data`/`value` 字段需传入原始值,不要 base64 编码 - **注意**Secret/Variable 的 `data`/`value` 字段需传入原始值,不要 base64 编码
当前 Release 预编译产物覆盖:
- Linux `x86_64-unknown-linux-musl`
- macOS Apple Silicon `aarch64-apple-darwin`
- macOS Intel `x86_64-apple-darwin`(由 ARM mac runner 交叉编译)
- Windows `x86_64-pc-windows-msvc`
详见 [AGENTS.md](AGENTS.md)。 详见 [AGENTS.md](AGENTS.md)。

View File

@@ -1,6 +1,7 @@
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use flate2::read::GzDecoder; use flate2::read::GzDecoder;
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::{Cursor, Read, Write}; use std::io::{Cursor, Read, Write};
const GITEA_API: &str = "https://gitea.refining.dev/api/v1/repos/refining/secrets/releases/latest"; 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, 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. /// Detect the asset suffix for the current platform/arch at compile time.
fn platform_asset_suffix() -> Result<&'static str> { fn platform_asset_suffix() -> Result<&'static str> {
#[cfg(all(target_os = "linux", target_arch = "x86_64"))] #[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}")) .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"). /// Extract the binary from a tar.gz archive (first file whose name == "secrets").
fn extract_from_targz(bytes: &[u8]) -> Result<Vec<u8>> { fn extract_from_targz(bytes: &[u8]) -> Result<Vec<u8>> {
let gz = GzDecoder::new(Cursor::new(bytes)); 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 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 .assets
.iter() .iter()
.find(|a| a.name.ends_with(suffix)) .find(|a| a.name == checksum_name)
.with_context(|| { .with_context(|| {
format!( format!(
"no asset found for this platform (looking for suffix: {suffix})\navailable: {}", "missing checksum asset for download: {checksum_name}\navailable: {}",
release available_assets(&release.assets)
.assets
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ")
) )
})?; })?;
println!("Downloading {}...", asset.name); println!("Downloading {}...", asset.name);
let bytes = client let archive = download_bytes(&client, &asset.browser_download_url, "archive download").await?;
.get(&asset.browser_download_url) let checksum_contents = download_bytes(
.send() &client,
.await &checksum_asset.browser_download_url,
.context("download failed")? "checksum download",
.error_for_status() )
.context("download returned an error")? .await?;
.bytes() let expected_checksum = parse_checksum_file(
.await std::str::from_utf8(&checksum_contents).context("checksum file is not valid UTF-8")?,
.context("failed to read download body")?; )?;
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..."); println!("Extracting...");
let binary = if suffix.ends_with(".tar.gz") { let binary = if suffix.ends_with(".tar.gz") {
extract_from_targz(&bytes)? extract_from_targz(&archive)?
} else { } else {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
extract_from_zip(&bytes)? extract_from_zip(&archive)?
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
bail!("zip extraction is only supported on 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}"); println!("Updated: v{current} → v{latest}");
Ok(()) 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");
}
}