chore: bump to 0.7.1, workflow/readme/init/upgrade updates, fix clippy needless_borrows
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m47s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 48s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m2s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
voson
2026-03-19 11:34:10 +08:00
parent 3973295d6a
commit 6ea9f0861b
6 changed files with 97 additions and 41 deletions

View File

@@ -5,6 +5,8 @@ on:
branches: [main]
paths:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -331,11 +333,14 @@ jobs:
- name: 安装依赖
shell: pwsh
run: |
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
Remove-Item rustup-init.exe
}
$env:Path = "$cargoBin;$env:Path"
Add-Content -Path $env:GITHUB_PATH -Value $cargoBin
rustup target add x86_64-pc-windows-msvc
- uses: actions/checkout@v4

2
Cargo.lock generated
View File

@@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secrets"
version = "0.7.0"
version = "0.7.1"
dependencies = [
"aes-gcm",
"anyhow",

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets"
version = "0.7.0"
version = "0.7.1"
edition = "2024"
[dependencies]

View File

@@ -19,11 +19,11 @@ cargo build --release
# 1. 配置数据库连接(会先验证连接可用再写入)
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
# 2. 初始化主密钥(提示输入主密码,派生后存入 OS 钥匙串)
# 2. 初始化主密钥(提示输入至少 8 位的主密码,派生后存入 OS 钥匙串)
secrets init
```
主密码不会存储仅用于派生主密钥。同一主密码在所有设备上会得到相同主密钥salt 存于数据库,首台设备生成后共享)。
主密码不会存储,仅用于派生主密钥,且至少需 8 位。同一主密码在所有设备上会得到相同主密钥salt 存于数据库,首台设备生成后共享)。
**主密钥存储**macOS → KeychainWindows → Credential ManagerLinux → keyutils会话级重启后需再次 `secrets init`)。
@@ -131,7 +131,7 @@ secrets update -n refining --kind service --name mqtt --remove-meta old_port --r
secrets delete -n refining --kind service --name legacy-mqtt
# ── init ─────────────────────────────────────────────────────────────────────
secrets init # 主密钥初始化(每台设备一次,主密码派生后存钥匙串)
secrets init # 主密钥初始化(每台设备一次,主密码至少 8 位,派生后存钥匙串)
# ── config ───────────────────────────────────────────────────────────────────
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets" # 先验证再写入

View File

@@ -4,15 +4,23 @@ use sqlx::PgPool;
use crate::{crypto, db};
const MIN_MASTER_PASSWORD_LEN: usize = 8;
pub async fn run(pool: &PgPool) -> Result<()> {
println!("Initializing secrets master key...");
println!();
// Read password (no echo)
let password =
rpassword::prompt_password("Enter master password: ").context("failed to read password")?;
if password.is_empty() {
anyhow::bail!("Master password must not be empty.");
let password = rpassword::prompt_password(format!(
"Enter master password (at least {} characters): ",
MIN_MASTER_PASSWORD_LEN
))
.context("failed to read password")?;
if password.chars().count() < MIN_MASTER_PASSWORD_LEN {
anyhow::bail!(
"Master password must be at least {} characters.",
MIN_MASTER_PASSWORD_LEN
);
}
let confirm = rpassword::prompt_password("Confirm master password: ")
.context("failed to read password confirmation")?;

View File

@@ -3,6 +3,7 @@ use flate2::read::GzDecoder;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::{Cursor, Read, Write};
use std::time::Duration;
const GITEA_API: &str = "https://gitea.refining.dev/api/v1/repos/refining/secrets/releases/latest";
@@ -28,13 +29,14 @@ fn available_assets(assets: &[Asset]) -> String {
.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(|| {
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 asset found for this platform (looking for suffix: {suffix})\navailable: {}",
"no matching release asset found: {name}\navailable: {}",
available_assets(assets)
)
})
@@ -89,6 +91,22 @@ fn sha256_hex(bytes: &[u8]) -> String {
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()
@@ -163,6 +181,8 @@ pub async fn run(check_only: bool) -> Result<()> {
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")?;
@@ -192,18 +212,10 @@ pub async fn run(check_only: bool) -> Result<()> {
}
let suffix = platform_asset_suffix()?;
let asset = find_asset_by_suffix(&release.assets, 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 = release
.assets
.iter()
.find(|a| a.name == checksum_name)
.with_context(|| {
format!(
"missing checksum asset for download: {checksum_name}\navailable: {}",
available_assets(&release.assets)
)
})?;
let checksum_asset = find_asset_by_name(&release.assets, &checksum_name)?;
println!("Downloading {}...", asset.name);
@@ -214,19 +226,11 @@ pub async fn run(check_only: bool) -> Result<()> {
"checksum download",
)
.await?;
let expected_checksum = parse_checksum_file(
let actual_checksum = verify_checksum(
&asset.name,
&archive,
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}");
@@ -298,6 +302,33 @@ mod tests {
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!(
@@ -306,6 +337,18 @@ mod tests {
);
}
#[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";