Files
Unlucky-Launcher/crates/cli/src/commands/update.rs
T
worker-updatee2e 24826c1a7c feat(m169): 更新通道端到端验收 —— 本地源改成编译期 feature,补一条可一键跑的 E2E + CI 守门
§6 欠账:更新链路的 GUI 端从来没验过,而 M167 发现它根本没法在本机验——`ULL_UPDATE_DIR` 只在
`debug_assertions` 下生效,发布产物却是 release;`apply` 又会替换本进程 exe,于是没人敢跑。

判据换档(tower 定案 B):`cfg!(debug_assertions)` → 编译期 cargo feature `update-test-source`
(core 定义,cli/presenter/app-iced 链式转发,**默认关**)。带 feature 的构建里还要两档环境变量
(`ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1`),只给路径什么都不会发生。官方发布二进制里那段
分支根本编不进来——README「release builds do not contain that path at all」这句因此继续为真。

- core: `job::default_source` 的本地源分支整段 `#[cfg(feature = "update-test-source")]`;判据抽成
  纯函数 `test_source_root_from` 并逐条单测(半开一律关、空路径、承认值只认 `1`);
  `the_test_channel_is_a_compile_time_opt_in` 在**两种构建下各断各的**(带 feature ⇒ 换源;
  不带 ⇒ 环境变量设满也走 GitHub)。
- cli: `update check/apply` 的 `--json` 增加 `test_source` 字段(判据派生自实际用了哪个源,不是回读
  环境变量),人读输出多一行点名;`help.rs` 的 `ULL_UPDATE_DIR` 说明按新判据重写。
- 验收:`crates/cli/tests/update_e2e_test.rs` + `update_e2e_tamper_test.rs` + 共用夹具
  `tests/support/update_e2e.rs`。临时数据根 + 本地源 + 真形状清单(与 `manifest.golden.json` 同
  字段)+ 真 PE 产物(现场构建的 ull.exe 前 1 MiB,理由写在 `PAYLOAD_LIMIT`)。断言覆盖:发现
  新版本(source=file:… / test_source=true / size / notes)、下载 → 双检 → 就位(dest 字节、旧字节
  进备份、暂存无残留)、重复 apply 的幂等终态、篡改 sha256(清单撒谎 / 包体被换)与截断都必须
  拒绝且不动现有安装、修复后能重装。**永远带 `--dest`**:不带会真的改名换掉测试二进制。
- 守门:`packaging/check_test_source_off.py`(stdlib-only)扫 `.github/workflows/**`、
  `packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`,任何一处打开 feature(或 `--all-features`)
  就红;`--self-test` 注入 5 条真实回归证明每条都会红。CI 挂在 release-guard.yml 两步。

验证(本机):core update 单测 80 绿 / 带 feature 6 绿;E2E debug 6 绿;
`cargo test --release --features update-test-source` 6 绿(tower 要求 release 档验收);
`cargo test -p unlucky-cli -p unlucky-presenter` 全绿;守门 self-test + 正式扫描绿(真注入后红)。

已知没验:真 GitHub Release 下载、GUI 点击与重启替换(人工);跨卷替换(另开 finding)。
2026-11-08 11:12:10 +08:00

268 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 {
let hint = 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
}