Files
Unlucky-Launcher/crates/cli/src/commands/update.rs
T
AskaEth b7d9b99e12 feat(m190): 产物署名(PE VERSIONINFO)+ 清单 Ed25519 多签名与更新前验签
两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。

① PE 版本资源(两个 bin):`packaging/pe_version.rs` 是唯一口径(CompanyName
   「Unlucky Launcher Project」/ ProductName、FileDescription「Unlucky Launcher」/
   LegalCopyright「© 2026 … · Apache-2.0」/ 四段式版本号与 Cargo.toml 同源),被
   `crates/{cli,app-iced}/build.rs` 各 include 一次;非 windows 目标整段不执行。
   判据是读产物字节的 `packaging/check_pe_version.py`(CI 的 Windows 槽位)。

② 清单签名:`manifest.json` 旁边多一份 `manifest.json.sig`(分离签名,签的是清单**原始
   字节**),信封是 `{format, signatures:[{alg,key_id,sig,manifest_sha256}]}` —— 多签名数组,
   任意一条命中钉住的钥即放行(换钥窗口)。信任锚是 `crates/core/src/update/trust.rs`
   里的编译期公钥表(正式表为空 = fail-closed,测试钥只在 cfg(test)/update-test-source 里)。
   管线顺序改成「先验签、再解析」:`UpdateSource` 只交原文 + `.sig`,`verified_manifest`
   是唯一收口点,`Job::apply` 因此在取包**之前**就拒。新增五类错误分类
   (signature_missing / malformed / unknown_key / keyring_empty / invalid ),全部不可重试,
   CLI 的 hint 单独说人话。

③ Ed25519 验签自带实现(`update/ed25519/*`,零新依赖):scope 不含 core 的 Cargo.toml,
   用 RFC 8032 §7.1 向量 + 可锻/小阶/非规范编码的拒绝用例钉住;签名侧是
   `packaging/sign_manifest.py`(纯 Python 参考实现,零 pip 依赖,`--self-test` 同向量)。

④ 守门:`release-publish.yml` 加签名步(`set +x`,只回显公钥;没 Secret / 钥没钉 / 签不上
   一律红 ⇒ 不出 Release);`release-build.yml` 加 PE 资源读回;`release-guard.yml` 跑两个
   脚本的 `--self-test`。密钥纪律与轮换(旧钥签新钥)写在 `packaging/docs/signing.md`。

Cargo.lock:新增 winresource(build-dependency)与 toml 系;toml_datetime 1.1.2→1.1.3 是
toml 1.2.1 的硬要求(`cargo update --precise 1.1.2` 已实测被拒)。
2026-11-09 01:59:05 +08:00

276 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `ull update check` / `ull update apply`(M159):**复用 core 的同一内核**。
//!
//! 两条命令都只做「解析参数 → 造一个 [`Job`] → 调 `check` / `apply` → 排版输出」,
//! 版本比较、渠道准入、清单校验、长度/SHA256 双检、原子替换全在 `core::update`
//! (GUI 走的是同一个 `Job`,见 `crates/presenter/src/host/update.rs`)。
//!
//! 三条不许折叠的边界:
//!
//! | 情形 | 输出 |
//! |---|---|
//! | 已是最新 | `status=up_to_date`(退出码 0) |
//! | 有可装的更新 | `status=available`(退出码 0;`apply` 才动文件) |
//! | 检查失败 | **错误**(退出码 1,`code=update_check_failed`)——绝不说成「已是最新」 |
//!
//! 还有一条单独报的:`missing_artifact`(有更高版本但里面没有本平台的产物)。那是发布侧的漏项,
//! 同样不许说成「已是最新」。
//!
//! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。
//!
//! # 测试通道(M169)
//!
//! 想在没有稳定 Release 的情况下整环验一遍,得用**专门编出来的验收二进制**:
//!
//! ```text
//! cargo build --release --features update-test-source # 官方发布构建不带这个 feature
//! ULL_UPDATE_DIR=<root> ULL_UPDATE_TEST_SOURCE=1 ./ull update check
//! ```
//!
//! 三档全给才生效(feature 默认关 = 官方二进制里没有这条路径;带 feature 时还要两档环境变量,
//! 判据见 `core::update::job`)。这时 `--json` 的 `source` 是 `file:<root>`、`test_source` 是
//! `true`,人读输出另有一行点名。它不是第二个更新源:没有设置键、也没有命令行参数。
//!
//! ⚠ **验收时必须给 `--dest`**:不给就是替换本进程的 exe。自动化用例里的「本进程」是测试二进制,
//! 去掉 `--dest` 会把它真改名换掉(`crates/cli/tests/update_e2e_test.rs` 的注释写了这件事)。
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use unlucky_core::update::{default_source, Applied, CheckOutcome, Job, UpdateError, Version};
use crate::cli::{UpdateApplyArgs, UpdateCheckArgs};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{Outcome, Report};
/// D50:先只发 stable。
const CHANNEL: unlucky_core::update::Channel = unlucky_core::update::Channel::Stable;
/// 本进程版本(与 `ull --version` 同源)。
fn current_version() -> Result<Version, CliError> {
let text = env!("CARGO_PKG_VERSION");
Version::parse(text)
.map_err(|error| CliError::internal("bad_version", format!("编译期版本号不合法:{error}")))
}
/// 造一个内核 job(源 = GitHub / `ULL_UPDATE_DIR`,暂存 = 数据根缓存,目标 = 本进程 exe)。
fn job(ctx: &Ctx, dest: Option<PathBuf>) -> Job {
let engine = super::install::build_engine(ctx);
let job = Job::for_process(default_source(Arc::new(engine)), &ctx.layout().cache());
match dest {
Some(dest) => job.with_dest(dest),
None => job,
}
}
/// 失败 → CLI 错误(`code` 说「哪条命令的哪一类失败」,`detail.kind` 说**到底为什么**)。
///
/// `--json` 信封示例(复审 P3-3 之后的样子):
///
/// ```json
/// {"ok":false,"error":{"code":"update_apply_failed","kind":"user","message":"应用更新失败(digest_mismatch):…",
/// "hint":"…","detail":{"kind":"digest_mismatch","retryable":true}}}
/// ```
///
/// 为什么要两层:`code` 是命令级的(脚本据此决定「要不要提示用户重跑」),`detail.kind` 是
/// **底层原因**(`digest_mismatch` / `network` / `invalid_manifest` / `io` …,据此决定「重试有没有意义」)。
/// 以前原因只写在人读 message 里,脚本不解析散文就分不出来。
fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
// 签名类错误单独给一句:它们**不可重试**,而且绝不能靠「换个源/跳过校验」绕过——绕过一次
// 就等于把信任根拆了(见 `core::update::signature` 的文件头)。
let hint = if error.kind.is_signature() {
concat!(
"清单签名没通过,重试没有意义:signature_missing = 发布侧漏了签名;",
"signature_unknown_key / keyring_empty = 本构建的信任表里没有这把钥(升级启动器或等发布侧换钥);",
"signature_invalid = 签名之后清单被改过。别绕过校验,等发布侧修"
)
} else if error.is_retryable() {
"网络/占用类失败,过一会儿重试一次通常就好"
} else {
"这不是瞬时失败:清单或版本号本身有问题,等发布侧修"
};
CliError::user(
code,
format!("{action}失败({}):{error}", error.kind.as_str()),
)
.with_detail(json!({
"kind": error.kind.as_str(),
"retryable": error.is_retryable(),
}))
.with_hint(hint)
}
/// 这个源是不是测试通道(判据派生自**实际用了哪个源**,不是回读环境变量:
/// `DirSource` 的 id 是 `file:<root>`,GitHub 源是 `github:<owner>/<repo>`)。
fn is_test_source(source_id: &str) -> bool {
source_id.starts_with("file:")
}
/// 测试通道的点名行(只在真走了本地目录源时出现)。文案里点明**这是验收构建**:官方发布二进制
/// 没有这条路径(`update-test-source` 默认关),所以出现这一行就说明手上这份不是官方产物。
fn test_channel_note(source: &str) -> String {
format!(
"测试通道生效:源 = {source}(本地目录,不是 GitHub Releases;本二进制带 \
`update-test-source` feature 编出来,官方发布构建不含这条路径)"
)
}
/// `ull update check`:只查、不动任何文件。
///
/// # Errors
///
/// 清单取不到 / 清单不合法 / 渠道不符 → 用户错误(退出码 1)。
pub fn check(ctx: &Ctx, _args: &UpdateCheckArgs) -> Result<Outcome, CliError> {
let current = current_version()?;
let job = job(ctx, None);
let outcome = job
.check(&current, CHANNEL)
.map_err(|error| failure("update_check_failed", "检查更新", &error))?;
Ok(Outcome::new(report_of(&current, job.source_id(), &outcome)))
}
/// `ull update apply`:复查 → 下载 → 双检 → 替换(失败自动清理/回滚)。
///
/// # Errors
///
/// 复查、取包、校验、替换任一步失败 → 用户错误(退出码 1)。
pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
let current = current_version()?;
let job = job(ctx, args.dest.clone());
let dest = job.dest().display().to_string();
match job
.apply(&current, CHANNEL)
.map_err(|error| failure("update_apply_failed", "应用更新", &error))?
{
Applied::Installed { receipt, version } => {
let backup = receipt
.backup
.as_ref()
.map(|path| path.display().to_string());
let source = job.source_id();
let test_source = is_test_source(source);
let mut human = vec![
format!("已下载并校验 v{version}({} 字节)", receipt.bytes),
format!("已替换:{dest}"),
];
human.push(match &backup {
Some(backup) => format!("旧版本备份:{backup}"),
None => "原本没有旧文件(首次落盘)".to_string(),
});
human.push("下次启动生效(正在运行的进程仍是旧版本)。".to_string());
if test_source {
human.push(test_channel_note(source));
}
Ok(Outcome::new(Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"test_source": test_source,
"status": "installed",
"version": version,
"path": dest,
"bytes": receipt.bytes,
"backup": backup,
}),
human,
)))
}
Applied::Nothing(outcome) => {
let report = report_of(&current, job.source_id(), &outcome);
Ok(Outcome::new(Report::new(
json!({ "status": "nothing_to_apply", "check": report.data }),
{
let mut human =
vec!["复查之后没有可应用的更新(已经不是最新版本冲突)".to_string()];
human.extend(report.human);
human
},
)))
}
}
}
/// 把检查结论排成「人读几行 + `--json` 一份」。
///
/// `test_source` 逐条写进 JSON(不只在高亮时写):脚本据此判断「这一份结论是不是本地夹具给的」,
/// 而判据来自 `source`(实际用了哪个源),所以不会出现「环境变量说关了、输出说开着」。
fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report {
let test_source = is_test_source(source);
let base = |status: &str| {
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"test_source": test_source,
"status": status,
})
};
let mut report = match outcome {
CheckOutcome::UpToDate { latest, .. } => {
let latest = latest.as_ref().map(ToString::to_string);
let mut data = base("up_to_date");
data["latest"] = json!(latest);
Report::new(
data,
vec![match &latest {
Some(latest) => format!("已是最新:v{current}(渠道 {latest} 内最高)"),
None => format!("已是最新:v{current}(该渠道还没有任何发布)"),
}],
)
}
CheckOutcome::Available(candidate) => {
let version = candidate.release.version.to_string();
let forced = candidate.urgency == unlucky_core::update::Urgency::Forced;
let notes = candidate.release.notes.clone().unwrap_or_default();
let mut human = vec![format!(
"发现新版本 v{version}(当前 v{current},{} 字节{})",
candidate.artifact.size,
if forced { ",强制更新" } else { "" }
)];
if !notes.trim().is_empty() {
human.push(format!("说明:{}", notes.trim()));
}
human.push("用 `%prog% update apply` 下载并应用(下次启动生效)".to_string());
Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"test_source": test_source,
"status": "available",
"version": version,
"size": candidate.artifact.size,
"forced": forced,
"notes": notes,
"target": candidate.artifact.target.as_str(),
}),
human,
)
}
CheckOutcome::MissingArtifact { release } => {
let version = release.version.to_string();
Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"test_source": test_source,
"status": "missing_artifact",
"version": version,
"target": unlucky_core::update::Target::current().as_str(),
}),
vec![format!(
"v{version} 已发布,但没有为本平台({})提供产物——这是发布侧漏项,不是「已是最新」",
unlucky_core::update::Target::current().as_str()
)],
)
}
};
if test_source {
report.human.push(test_channel_note(source));
}
report
}