From 24826c1a7cbe3aecf6cbe60b95244a73ad750833 Mon Sep 17 00:00:00 2001 From: worker-updatee2e Date: Sun, 8 Nov 2026 09:46:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(m169):=20=E6=9B=B4=E6=96=B0=E9=80=9A?= =?UTF-8?q?=E9=81=93=E7=AB=AF=E5=88=B0=E7=AB=AF=E9=AA=8C=E6=94=B6=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=9C=AC=E5=9C=B0=E6=BA=90=E6=94=B9?= =?UTF-8?q?=E6=88=90=E7=BC=96=E8=AF=91=E6=9C=9F=20feature=EF=BC=8C?= =?UTF-8?q?=E8=A1=A5=E4=B8=80=E6=9D=A1=E5=8F=AF=E4=B8=80=E9=94=AE=E8=B7=91?= =?UTF-8?q?=E7=9A=84=20E2E=20+=20CI=20=E5=AE=88=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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)。 --- .github/workflows/release-guard.yml | 13 + crates/app-iced/Cargo.toml | 5 + crates/cli/Cargo.toml | 5 + crates/cli/src/cli/help.rs | 6 +- crates/cli/src/commands/update.rs | 51 +++- crates/cli/tests/support/mod.rs | 2 + crates/cli/tests/support/update_e2e.rs | 286 +++++++++++++++++++++ crates/cli/tests/update_e2e_tamper_test.rs | 103 ++++++++ crates/cli/tests/update_e2e_test.rs | 195 ++++++++++++++ crates/core/Cargo.toml | 7 + crates/core/src/update/job.rs | 105 +++++--- crates/core/src/update/mod.rs | 10 +- crates/core/src/update/tests/job_cases.rs | 135 +++++++++- crates/core/src/update/tests/mod.rs | 3 +- crates/presenter/Cargo.toml | 7 + crates/presenter/src/host/update.rs | 4 +- packaging/README.md | 19 ++ packaging/check_test_source_off.py | 221 ++++++++++++++++ 18 files changed, 1133 insertions(+), 44 deletions(-) create mode 100644 crates/cli/tests/support/update_e2e.rs create mode 100644 crates/cli/tests/update_e2e_tamper_test.rs create mode 100644 crates/cli/tests/update_e2e_test.rs create mode 100644 packaging/check_test_source_off.py diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml index dafade5..39d99be 100644 --- a/.github/workflows/release-guard.yml +++ b/.github/workflows/release-guard.yml @@ -22,6 +22,12 @@ name: Release guard # 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志; # `--self-test` 再注入 8 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明)证明它会红。 # 发布侧那道「产物 ⇄ 名单」的运行时断言在 `release-publish.yml`(同一份脚本,`--dist`)。 +# +# (M169)同一个 job 再挂**测试通道守门** `packaging/check_test_source_off.py`:core/cli/presenter/ +# app-iced 各有一条 `update-test-source` feature,打开后 `core::update::job::default_source` 可以被 +# 两个环境变量指向任意本地目录——而更新链路的终点是**替换用户盘上的 exe**。它默认关,官方发布产物 +# 里不许有它:守门扫 `.github/workflows/**`、`packaging/*.py|*.sh` 与各 `Cargo.toml`(`default` +# 里带上它也红,那种开法扫 workflow 是看不见的)。`--self-test` 注入 5 条真实回归证明每条都会红。 on: push: @@ -48,3 +54,10 @@ jobs: run: python3 packaging/check_publish_slots.py --self-test - name: 发布名单与矩阵槽位一致(拼错字 / 声明了 probe 档 当场红) run: python3 packaging/check_publish_slots.py + # M169:`update-test-source` 这条 feature 能让更新源指向任意本地目录,而更新链路的终点是 + # 替换用户盘上的 exe。它**默认关**、官方发布产物里不许有它,这条由脚本自动断言(不再靠注释): + # 扫 `.github/workflows/**`、`packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`。 + - name: 测试通道守门自测(注入变异,每条都要变红) + run: python3 packaging/check_test_source_off.py --self-test + - name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开) + run: python3 packaging/check_test_source_off.py diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index c01ccea..7fed171 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -90,6 +90,11 @@ iced = { version = "=0.14.0", default-features = false, features = [ default = ["gpu"] # 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。 gpu = ["iced/wgpu"] +# M169:把「本地更新源」这条测试通道编进 GUI(默认关)。人工验收更新链路时的构建方式: +# ULL_UPDATE_DIR= ULL_UPDATE_TEST_SOURCE=1 \ +# cargo run -p unlucky-launcher-iced --features update-test-source +# 转发给 presenter 与内嵌的 cli(两者都依赖 core,feature 取并集);官方发布构建不带它。 +update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/update-test-source"] [lints] workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index fa50efc..84ce552 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -33,3 +33,8 @@ serde_json.workspace = true [lints] workspace = true + +[features] +# M169:转发 core 的测试通道(`--features update-test-source` 才编进来,默认关)。 +# `ull update check/apply` 的端到端验收要靠它指向本地目录源。 +update-test-source = ["unlucky-core/update-test-source"] diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index 494ca4b..aa1c720 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -128,8 +128,10 @@ pub fn topic(name: &str) -> Option<&'static str> { 缺省替换本进程的 exe:**下次启动生效**(正在跑的这一份仍是旧版本)。 --dest 钉到别的路径(取证 / 自定义安装位置)。不写 = 本进程 exe。 -环境变量 ULL_UPDATE_DIR=:改走本地目录源(`/stable.json` + 产物), -用于内网手工分发与「发现新版本」这条分支的整环取证;不设就走 GitHub。 +环境变量 ULL_UPDATE_DIR= + ULL_UPDATE_TEST_SOURCE=1:改走本地目录源(`/stable.json` ++ 产物),用于内网手工分发与更新链路的整环取证。**两者都只在带 `update-test-source` feature +编出来的验收构建里生效**(`cargo build --features update-test-source`);官方发布构建不含这条 +路径,设了环境变量也照旧走 GitHub。 --json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。", ), "install" => Some( diff --git a/crates/cli/src/commands/update.rs b/crates/cli/src/commands/update.rs index c7b99e8..a3d1df6 100644 --- a/crates/cli/src/commands/update.rs +++ b/crates/cli/src/commands/update.rs @@ -16,6 +16,22 @@ //! 同样不许说成「已是最新」。 //! //! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。 +//! +//! # 测试通道(M169) +//! +//! 想在没有稳定 Release 的情况下整环验一遍,得用**专门编出来的验收二进制**: +//! +//! ```text +//! cargo build --release --features update-test-source # 官方发布构建不带这个 feature +//! ULL_UPDATE_DIR= ULL_UPDATE_TEST_SOURCE=1 ./ull update check +//! ``` +//! +//! 三档全给才生效(feature 默认关 = 官方二进制里没有这条路径;带 feature 时还要两档环境变量, +//! 判据见 `core::update::job`)。这时 `--json` 的 `source` 是 `file:`、`test_source` 是 +//! `true`,人读输出另有一行点名。它不是第二个更新源:没有设置键、也没有命令行参数。 +//! +//! ⚠ **验收时必须给 `--dest`**:不给就是替换本进程的 exe。自动化用例里的「本进程」是测试二进制, +//! 去掉 `--dest` 会把它真改名换掉(`crates/cli/tests/update_e2e_test.rs` 的注释写了这件事)。 use std::path::PathBuf; use std::sync::Arc; @@ -77,6 +93,21 @@ fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError { .with_hint(hint) } +/// 这个源是不是测试通道(判据派生自**实际用了哪个源**,不是回读环境变量: +/// `DirSource` 的 id 是 `file:`,GitHub 源是 `github:/`)。 +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 @@ -109,6 +140,8 @@ pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result { .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}"), @@ -118,10 +151,15 @@ pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result { 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, @@ -147,16 +185,21 @@ pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result { } /// 把检查结论排成「人读几行 + `--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, }) }; - match outcome { + let mut report = match outcome { CheckOutcome::UpToDate { latest, .. } => { let latest = latest.as_ref().map(ToString::to_string); let mut data = base("up_to_date"); @@ -187,6 +230,7 @@ fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report "current": current.to_string(), "channel": CHANNEL.as_str(), "source": source, + "test_source": test_source, "status": "available", "version": version, "size": candidate.artifact.size, @@ -204,6 +248,7 @@ fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report "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(), @@ -214,5 +259,9 @@ fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report )], ) } + }; + if test_source { + report.human.push(test_channel_note(source)); } + report } diff --git a/crates/cli/tests/support/mod.rs b/crates/cli/tests/support/mod.rs index a14e011..bee4760 100644 --- a/crates/cli/tests/support/mod.rs +++ b/crates/cli/tests/support/mod.rs @@ -3,6 +3,8 @@ //! 每个测试目标都会整份编译本文件但只用其中一部分,因此这里放开 dead_code。 #![allow(dead_code)] +pub mod update_e2e; + use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/crates/cli/tests/support/update_e2e.rs b/crates/cli/tests/support/update_e2e.rs new file mode 100644 index 0000000..b4d9ce3 --- /dev/null +++ b/crates/cli/tests/support/update_e2e.rs @@ -0,0 +1,286 @@ +//! 更新通道端到端验收的夹具(M169):临时现场、真形状清单、测试通道的环境变量守卫。 +//! +//! 两个测试目标共用(`update_e2e_test` 正路 / 幂等,`update_e2e_tamper_test` 篡改);每个目标只 +//! 用到其中一部分,所以与 `support/mod.rs` 一样放开 dead_code。 +//! +//! 整个夹具只在 `update-test-source` feature 下真的被用到:那两个测试目标自己 `#![cfg]` 掉, +//! 这里只是跟着编译(不跑)。判据(feature + 两档环境变量)在 `core::update::job`。 +//! +//! ⚠ **验收必须给 `--dest`**:用例在**进程内**跑 `unlucky_cli::run`,`Job::for_process` 的 +//! 「本进程的 exe」就是测试二进制——`apply` 不带 `--dest` 会真的把它 `rename` 成新字节 +//! (Windows 允许改名正在运行的映像,见 `core::update::install` 的文档),整轮测试当场崩。 +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +use unlucky_core::update::{HashAlgo, OsFamily, Target, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV}; + +/// 假装的「已安装版本」字节:与产物**必须不同**,否则「目标没被动过」这类断言会假绿。 +pub const INSTALLED: &[u8] = b"installed 0.2.3 build (pretend)"; + +/// 产物体量上限(1 MiB)。 +/// +/// 现场构建出来的 `ull.exe` 是 **~400 MB**(debug + 全套 reqwest/rmcp/axum);拿整份当过测材, +/// 光 sha256 + 拷贝就要几十秒,验收会变成「谁都不想跑」。所以截取**前 1 MiB**:PE 头与头几节都在, +/// 「这是真 exe 的字节」这一点保留,代价是「装上去能不能启动」不再由它证明——那一跳本来就要人工 +/// (见报告「不验的部分」)。清单里的 `size` / `sha256` 按这份截短后的真实字节算,仍然自洽。 +const PAYLOAD_LIMIT: usize = 1024 * 1024; + +/// 清单里的更新说明(断言它会原样出现在 `check --json` 里)。 +const NOTES: &str = "修掉启动崩溃;实例页加载更快"; + +/// SHA-256 按真实字节算(与双检同一条实现:`core::download::verify::digest_bytes`)。 +pub fn sha256(bytes: &[u8]) -> String { + unlucky_core::download::verify::digest_bytes(HashAlgo::Sha256, bytes) +} + +/// 清单版本:**比编译期版本严格大一档次版本**。 +/// +/// 不能写死 `0.2.4`:workspace 版本一升,用例就变成「已是最新」的假红。次版本 +1 恒大于当前 +/// 版本,且仍在同一 major 内(不会意外触发 `min_supported` 那条判据)。 +pub fn next_version(current: &str) -> String { + let mut parts = current.split('.'); + let major = parts.next().unwrap_or("0"); + let minor: u64 = parts + .next() + .unwrap_or("0") + .parse() + .unwrap_or_else(|_| panic!("编译期版本号应是三段式:{current}")); + assert!(parts.next().is_some(), "编译期版本号应是三段式:{current}"); + format!("{major}.{}.0", minor + 1) +} + +/// 真实产物命名(D49):`unlucky-launcher__windows_x64.exe`。 +/// +/// 拿 `Target::as_str()`(`win-x64`)直接拼会得到一个世上不存在的文件名,清单那一栏也就 +/// 与真实发布对不上——「真形状」的意义正在这里。 +fn asset_name(version: &str) -> String { + let target = Target::current(); + let os = match target.os { + OsFamily::Windows => "windows", + OsFamily::MacOs => "macos", + OsFamily::Linux => "linux", + }; + format!( + "unlucky-launcher_{version}_{os}_{}.exe", + target.arch.as_str() + ) +} + +/// 一份与 `packaging/tests/manifest.golden.json` **同字段**的清单。 +/// +/// 字段一个不多一个不少:core 的 `deny_unknown_fields` 会把多余字段当清单损坏拒掉,所以这条 +/// 同时也是「清单形状与真实生成器(`packaging/make_manifest.py`)对齐」的断言。 +fn manifest(version: &str, asset: &str, size: u64, sha256: &str) -> String { + format!( + r#"{{ + "channel": "stable", + "releases": [ + {{ + "version": "{version}", + "channel": "stable", + "released_at": "2026-01-02T03:04:05Z", + "notes": "{NOTES}", + "force": false, + "min_supported": "0.1.0", + "artifacts": [ + {{ + "target": "{}", + "format": "portable-exe", + "url": "{asset}", + "size": {size}, + "sha256": "{sha256}" + }} + ] + }} + ] +}}"#, + Target::current().as_str() + ) +} + +/// 进程环境是共享的(同一测试进程里各用例并行):动环境变量的用例都拿这把锁串起来。 +pub fn channel_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// 两个测试通道变量的现场:构造时记住原值,`Drop` 还原(用例 panic 也还原)。 +pub struct Channel { + dir: Option, + ack: Option, +} + +impl Channel { + pub fn capture() -> Self { + Self { + dir: std::env::var_os(UPDATE_DIR_ENV), + ack: std::env::var_os(UPDATE_TEST_SOURCE_ENV), + } + } + + /// 测试通道开启:两档环境变量全给(二进制还得带 `update-test-source` feature;只给路径不会被 + /// 认,判据见 `core::update::job`)。 + pub fn enable(root: &Path) { + std::env::set_var(UPDATE_DIR_ENV, root); + std::env::set_var(UPDATE_TEST_SOURCE_ENV, "1"); + } +} + +impl Drop for Channel { + fn drop(&mut self) { + for (key, value) in [ + (UPDATE_DIR_ENV, self.dir.clone()), + (UPDATE_TEST_SOURCE_ENV, self.ack.clone()), + ] { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } +} + +/// `--json` 信封 → 命令载荷。 +/// +/// 成功信封是 `{"ok":true,"data":{…},"warnings":[…]}`,失败信封是 `{"ok":false,"error":{…}}` +/// (没有 `data`)。所以「取载荷」= 有 `data` 取 `data`,否则原样返回——失败用例照样能看 +/// `error.code` / `error.detail.kind`。 +pub fn payload(json: &serde_json::Value) -> &serde_json::Value { + json.get("data").unwrap_or(json) +} + +/// 目录里的文件名(读不到就当空)。 +fn list_dir(dir: &Path) -> Vec { + match std::fs::read_dir(dir) { + Ok(entries) => entries + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(), + Err(_) => Vec::new(), + } +} + +/// 一次验收的全部现场: +/// +/// ```text +/// /-data/ --data-dir(自定义模式 ⇒ /.ULL 与 /.minecraft) +/// /-dist/ ULL_UPDATE_DIR(stable.json + 产物) +/// /-install/ --dest 所在目录(**临时副本**,不是本进程的 exe) +/// ``` +/// +/// 三个 `TempDir` 都要活到用例结束(`Fixture` 持有它们)。 +pub struct Fixture { + pub data: super::TempDir, + pub dist: super::TempDir, + install: super::TempDir, + /// 产物字节 = 现场构建出来的 `ull.exe` 的前 1 MiB(真 PE 头,理由见 [`PAYLOAD_LIMIT`])。 + pub payload: Vec, + /// 清单里的版本号(= 编译期版本次版本 +1)。 + pub version: String, + /// 产物文件名(清单 `url` 与盘上文件同名)。 + pub asset: String, + /// 替换目标(安装目录里的副本)。 + pub dest: PathBuf, +} + +/// 现场构建出来的 `ull.exe` 的前 [`PAYLOAD_LIMIT`] 字节(读一次,整份不超过 1 MiB)。 +/// +/// 为什么不用占位字符串:这是**发布矩阵里那个单文件 exe** 的真字节,PE 头、节表、体积都对得上; +/// 为什么截短:整份 ~400 MB,测材太重(理由见 [`PAYLOAD_LIMIT`])。 +fn payload_bytes() -> Vec { + let path = env!("CARGO_BIN_EXE_ull"); + let mut payload = std::fs::read(path).expect("读构建出来的 ull.exe"); + payload.truncate(PAYLOAD_LIMIT); + assert!( + payload.len() >= 64 * 1024, + "产物要像真的(至少 64 KiB),实际 {} 字节", + payload.len() + ); + if cfg!(windows) { + assert!( + payload.starts_with(b"MZ"), + "Windows 产物得是 PE 映像(前两个字节 MZ),否则「真形状」这句话就没意义" + ); + } + payload +} + +impl Fixture { + pub fn new(tag: &str) -> Self { + let payload = payload_bytes(); + let version = next_version(env!("CARGO_PKG_VERSION")); + let install = super::TempDir::new(&format!("{tag}-install")); + let dest = install.path().join("unlucky-launcher.exe"); + std::fs::write(&dest, INSTALLED).expect("写旧版本"); + Self { + data: super::TempDir::new(&format!("{tag}-data")), + dist: super::TempDir::new(&format!("{tag}-dist")), + install, + asset: asset_name(&version), + payload, + version, + dest, + } + } + + /// 发一版:清单 + 产物都按 `payload` 的真实长度与摘要。 + pub fn publish_good(&self) { + self.publish(&sha256(&self.payload), self.payload.len() as u64); + } + + /// 发一版,摘要/长度由调用方给(篡改用例用)。 + pub fn publish(&self, sha256: &str, size: u64) { + std::fs::write(self.dist.path().join(&self.asset), &self.payload).expect("写产物"); + std::fs::write( + self.dist.path().join("stable.json"), + manifest(&self.version, &self.asset, size, sha256), + ) + .expect("写清单"); + } + + /// 跑一条 `update` 子命令:`--data-dir` 指向临时数据根(与 GUI 一样由数据根派生暂存目录)。 + pub fn run(&self, args: &[&str]) -> (i32, String, String) { + let mut full = vec!["update"]; + full.extend_from_slice(args); + let data_dir = self.data.arg(); + full.push("--data-dir"); + full.push(data_dir.as_str()); + super::run_cli(&full) + } + + /// 跑 `apply --dest <副本> --json`(**永远**带 `--dest`,见文件头)。 + pub fn apply(&self) -> (i32, serde_json::Value) { + let dest = self.dest.display().to_string(); + let (code, out, _) = self.run(&["apply", "--dest", &dest, "--json"]); + (code, super::parse_json(&out)) + } + + /// 暂存目录(`<数据根>/.ULL/cache/update/staging`,与 `core::update::staging_dir` 同形)。 + pub fn staging(&self) -> PathBuf { + self.data + .path() + .join(".ULL") + .join("cache") + .join("update") + .join("staging") + } + + /// 暂存目录里还留着什么(`StagedPackage` 落地/被搬走之后应为空)。 + pub fn staging_leftovers(&self) -> Vec { + list_dir(&self.staging()) + } + + /// 安装目录里的备份(`.unlucky-launcher.exe.ullold-*`)。 + pub fn backups(&self) -> Vec { + list_dir(self.install.path()) + .into_iter() + .filter(|name| name.contains(".ullold-")) + .collect() + } + + /// 目标文件当前字节。 + pub fn installed_bytes(&self) -> Vec { + std::fs::read(&self.dest).expect("读目标") + } +} diff --git a/crates/cli/tests/update_e2e_tamper_test.rs b/crates/cli/tests/update_e2e_tamper_test.rs new file mode 100644 index 0000000..7cb0cac --- /dev/null +++ b/crates/cli/tests/update_e2e_tamper_test.rs @@ -0,0 +1,103 @@ +//! 更新通道端到端验收(M169)的**拒绝侧**:篡改摘要、篡改包体、截断包体。 +//! +//! 正路与幂等在 `update_e2e_test.rs`(现场说明、抓得住/抓不住、不验的部分都在那边,别重复读)。 +//! 这里只钉一条底线:**校验不过就一个字节都不许落到目标上**,而且失败不会把现场搞脏到 +//! 「修好清单也装不上」。 +//! +//! 一键跑(必须带 feature;tower 定案:验收落在 release profile 上): +//! +//! ```text +//! cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_tamper_test +//! ``` +//! +//! 不带 feature 时整个目标 `#![cfg]` 掉(0 个用例)——测试通道在那种构建里不存在。 + +#![cfg(feature = "update-test-source")] + +#[path = "support/mod.rs"] +mod support; + +use support::update_e2e::{channel_lock, sha256, Channel, Fixture, INSTALLED}; + +/// 清单的 `sha256` 与盘上字节不符 ⇒ 拒绝,且**现有安装一个字节都不动**。 +/// +/// 抓得住:校验和判据被绕过(写成恒真、先搬后校、只比长度)。抓不住:清单与包体一起被换 +/// (同时改 sha),那条路上没有可信根。 +#[test] +fn a_tampered_digest_is_refused_and_leaves_the_install_intact() { + let _serial = channel_lock(); + let _restore = Channel::capture(); + let fx = Fixture::new("e2e-digest"); + Channel::enable(fx.dist.path()); + // 长度对、摘要错:先过长度判据,才能测到摘要这一关。 + fx.publish(&sha256(b"another build entirely"), fx.payload.len() as u64); + + let (code, json) = fx.apply(); + assert_ne!(code, 0, "摘要不符必须拒绝(实际退出码 0):{json}"); + assert_eq!(json["error"]["code"], "update_apply_failed", "{json}"); + assert_eq!(json["error"]["detail"]["kind"], "digest_mismatch", "{json}"); + + assert_eq!(fx.installed_bytes(), INSTALLED, "校验失败不许动目标文件"); + assert!( + fx.backups().is_empty(), + "还没走到替换就失败了,安装目录里不该出现 .ullold:{:?}", + fx.backups() + ); + assert!( + fx.staging().exists(), + "下载过才会建暂存目录(说明失败点在双检,不是根本没下)" + ); + assert!( + fx.staging_leftovers().is_empty(), + "校验失败的 .ullupd 必须被删掉:{:?}", + fx.staging_leftovers() + ); + + // 修好清单再装一次:失败没把现场搞脏,这条路还能走通。 + fx.publish_good(); + let (code, json) = fx.apply(); + assert_eq!(code, 0, "修好清单之后应能装上:{json}"); + let data = support::update_e2e::payload(&json); + assert_eq!(data["status"], "installed", "{json}"); + assert_eq!(fx.installed_bytes(), fx.payload); +} + +/// 产物被换(清单没动)⇒ 一样拒绝:篡改落在哪一侧都能抓到。 +#[test] +fn a_tampered_artifact_is_refused_too() { + let _serial = channel_lock(); + let _restore = Channel::capture(); + let fx = Fixture::new("e2e-artifact"); + Channel::enable(fx.dist.path()); + fx.publish_good(); + // 盘上换成同长度的另一份字节:只有摘要能抓(长度判据看不出区别)。 + let mut evil = fx.payload.clone(); + evil[0] ^= 0xff; + std::fs::write(fx.dist.path().join(&fx.asset), &evil).expect("篡改产物"); + + let (code, json) = fx.apply(); + assert_ne!(code, 0, "篡改的包必须拒绝:{json}"); + assert_eq!( + json["error"]["detail"]["kind"], "digest_mismatch", + "篡改产物应当报摘要不符:{json}" + ); + assert_eq!(fx.installed_bytes(), INSTALLED); + assert!(fx.staging_leftovers().is_empty()); +} + +/// 包体被截断(清单长度按完整包算)⇒ 长度判据先拒:不读摘要、不碰目标。 +#[test] +fn a_truncated_payload_is_refused_as_size_mismatch() { + let _serial = channel_lock(); + let _restore = Channel::capture(); + let fx = Fixture::new("e2e-truncated"); + Channel::enable(fx.dist.path()); + fx.publish_good(); + let full = fx.payload.clone(); + std::fs::write(fx.dist.path().join(&fx.asset), &full[..full.len() / 2]).expect("截断产物"); + + let (code, json) = fx.apply(); + assert_ne!(code, 0, "截断的包必须拒绝:{json}"); + assert_eq!(json["error"]["detail"]["kind"], "size_mismatch", "{json}"); + assert_eq!(fx.installed_bytes(), INSTALLED); +} diff --git a/crates/cli/tests/update_e2e_test.rs b/crates/cli/tests/update_e2e_test.rs new file mode 100644 index 0000000..1bcca93 --- /dev/null +++ b/crates/cli/tests/update_e2e_test.rs @@ -0,0 +1,195 @@ +//! 更新通道端到端验收(M169):**临时数据根 + 本地更新源 + 真形状的清单与产物**, +//! 从 CLI 入口走完「发现新版本 → 下载 → 长度/SHA256 双检 → 就位」。 +//! +//! 一键跑(**必须带 feature**;tower 要求验收落在 release profile 上,debug 与 release 的 +//! `debug_assertions`、优化与 panic 行为都不同,用 debug 验收等于没验真实产物): +//! +//! ```text +//! cargo test --release -p unlucky-cli --features update-test-source \ +//! --test update_e2e_test --test update_e2e_tamper_test +//! ``` +//! +//! (两个测试目标各跑一半:本文件是正路 + 幂等,`update_e2e_tamper_test` 是篡改/截断。) +//! +//! 不带 `--features update-test-source` 时整个目标 `#![cfg]` 掉(**0 个用例**,不是绿):测试通道 +//! 在那种构建里根本不存在,「环境变量设满也走 GitHub」由 `crates/core` 的 +//! `the_test_channel_is_a_compile_time_opt_in` 在**不带 feature 的跑法**下守着。 +//! +//! 为什么需要 feature:`ULL_UPDATE_DIR` 曾经只在 `debug_assertions` 下生效,于是发布产物(release) +//! **没法**指向本地源,这条链在 release 上从来没有端到端证据。M169 把判据换成编译期 feature +//! (默认关 ⇒ 官方二进制里没有这条路径),验收构建显式打开它。 +//! +//! # 现场 +//! +//! 见 `tests/support/update_e2e.rs`:数据根 / 本地源 / 安装目录三份临时目录,产物是现场构建出来的 +//! `ull.exe` 的**前 1 MiB**(真 PE 头、真字节;整份 ~400 MB,测材太重在 `PAYLOAD_LIMIT` 的注释里 +//! 写了原因),清单与 `packaging/tests/manifest.golden.json` 同字段。**`--dest` 永远指向临时副本** +//! ——这个文件里没有一处会替换本进程的 exe。 +//! +//! # 抓得住什么 / 抓不住什么 +//! +//! | 本套断言 | 抓得住 | 抓不住 | +//! |---|---|---| +//! | 清单 → 选版本 → 下载 → 双检 → 就位整环跑通 | 任一环断了(字段拼错、渠道不符、没下、没校验、没搬)都会红 | 真 GitHub Release 的下载链路(本套一个字节不出网) | +//! | 清单 `sha256` 与盘上字节不符 ⇒ 拒绝 + 目标不动 | 「校验和判据被绕过/写成恒真」;篡改包体或篡改清单都能抓(见 `update_e2e_tamper_test`) | 「CDN 换了内容**而清单里的 sha 也一起换**」——这条路上没有可信根,只能靠 GitHub 侧 | +//! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 | +//! | 重复 `apply` 后目标字节稳定、暂存无残留 | 「第二次把 dest 写坏 / 暂存留下垃圾」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe | +//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷(数据根与 exe 不同盘)时的替换:`rename` 不能跨卷,那条路没验 | +//! +//! # 不验的部分(如实划界) +//! +//! 1. **真实 GitHub Release 下载**:本套一个字节都不出网(源是本地目录)。`releases/latest` 的 +//! 真实响应、镜像/gh-proxy 回退、CDN 的 ETag/断点续传都属于真网络验收。 +//! 2. **GUI 的点击与重启替换**:设置页「下载并安装」、提示条、`RestartNow` 之后的进程重启不在 +//! 自动化里(替换自身 exe 需要真机,且要人工确认新版本能起来)。 +//! 3. **按版本号忽略**:`presenter::host::update_state` 的 `skipped` 是会话级行为,有单测 +//! (`update_session_tests.rs`),CLI 侧没有对应开关,所以这里不覆盖。 +//! 4. **`--dest` 之外的自替换**(替换本进程 exe):`tests/support/update_e2e.rs` 的文件头写了为什么 +//! 这几条用例永远带 `--dest`,以及「去掉它会真的改名换掉测试二进制」这件事。 + +#![cfg(feature = "update-test-source")] + +#[path = "support/mod.rs"] +mod support; + +use support::update_e2e::{channel_lock, Channel, Fixture, INSTALLED}; + +use unlucky_core::update::Target; + +/// 发现新版本 → 下载 → 双检 → 就位:整环从 CLI 入口跑一遍,并核对每一步的产物。 +#[test] +fn discovers_downloads_and_installs_from_the_local_channel() { + let _serial = channel_lock(); + let _restore = Channel::capture(); + let fx = Fixture::new("e2e-happy"); + Channel::enable(fx.dist.path()); + fx.publish_good(); + + // 1) 发现新版本:源是本地目录,脚本能看出这一份结论来自测试通道。 + let (code, out, err) = fx.run(&["check", "--json"]); + assert_eq!(code, 0, "check 应成功:{err}"); + let json = support::parse_json(&out); + let data = support::update_e2e::payload(&json); + assert_eq!(data["status"], "available", "{json}"); + assert_eq!(data["version"], fx.version.as_str()); + assert_eq!(data["current"], env!("CARGO_PKG_VERSION")); + assert_eq!( + data["size"].as_u64(), + Some(fx.payload.len() as u64), + "清单声明的字节数 = 产物真实字节数:{json}" + ); + assert_eq!(data["target"], Target::current().as_str()); + assert_eq!(data["notes"], "修掉启动崩溃;实例页加载更快"); + assert_eq!(data["forced"], false); + assert_eq!(data["test_source"], true, "测试通道必须在输出里点名"); + assert_eq!( + data["source"], + format!("file:{}", fx.dist.path().display()), + "源必须是本地目录(不是 GitHub)" + ); + + // 2) 人读输出也点名测试通道:人看的那份不能比 JSON 少说一句。 + let (code, human, _) = fx.run(&["check"]); + assert_eq!(code, 0); + assert!(human.contains("发现新版本"), "{human}"); + assert!( + human.contains("测试通道生效"), + "人读输出要点名「这一份结论来自本地夹具」:{human}" + ); + + // 3) 下载 → 长度/SHA256 双检 → 就位。 + let (code, json) = fx.apply(); + assert_eq!(code, 0, "apply 应成功:{json}"); + let data = support::update_e2e::payload(&json); + assert_eq!(data["status"], "installed", "{json}"); + assert_eq!(data["version"], fx.version.as_str()); + assert_eq!( + data["bytes"].as_u64(), + Some(fx.payload.len() as u64), + "{json}" + ); + assert_eq!(data["path"], fx.dest.display().to_string().as_str()); + assert_eq!(data["test_source"], true); + let backup = data["backup"] + .as_str() + .expect("旧版本应留备份(回滚用)") + .to_string(); + + // 4) 盘上事实:目标 = 产物字节,备份 = 旧字节,暂存无残留。 + assert_eq!( + fx.installed_bytes(), + fx.payload, + "就位的必须就是清单里那一份字节" + ); + assert_eq!( + std::fs::read(&backup).expect("读备份"), + INSTALLED, + "旧版本要在备份里,不能凭空消失" + ); + assert!( + fx.staging_leftovers().is_empty(), + "暂存目录里不许留 .ullupd:{:?}", + fx.staging_leftovers() + ); + + // 5) 装完再查:**仍然报有更新**——因为 current 取的是编译期版本,不读 dest 里的 exe。 + // 这不是 bug(真实场景里替换完就会重启),但脚本作者必须知道这一条。 + let (code, out, _) = fx.run(&["check", "--json"]); + assert_eq!(code, 0); + let json = support::parse_json(&out); + assert_eq!(support::update_e2e::payload(&json)["status"], "available"); +} + +/// 没有清单的本地渠道 = 「还没有任何发布」,不是错误、也不是「已是最新」以外的胡话。 +/// +/// 顺带把「源真的被换成本地目录」在没有产物的情况下也验一遍(全程离线)。 +#[test] +fn an_empty_local_channel_is_up_to_date_not_an_error() { + let _serial = channel_lock(); + let _restore = Channel::capture(); + let fx = Fixture::new("e2e-empty"); + Channel::enable(fx.dist.path()); + + let (code, out, err) = fx.run(&["check", "--json"]); + assert_eq!(code, 0, "缺清单不该是错误:{err}"); + let json = support::parse_json(&out); + let data = support::update_e2e::payload(&json); + assert_eq!(data["status"], "up_to_date", "{json}"); + assert_eq!(data["latest"], serde_json::Value::Null); + assert_eq!(data["test_source"], true); +} + +/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾。 +/// +/// 如实记两条:`apply` 每次都留一个备份(CLI 从不删,等下一次启动/人工清理),而且第二次之所以 +/// 还会「装上」,是因为 `current` 是编译期版本——重复执行不是空操作,只是结果幂等。 +#[test] +fn applying_twice_leaves_the_same_target_bytes() { + let _serial = channel_lock(); + let _restore = Channel::capture(); + let fx = Fixture::new("e2e-twice"); + Channel::enable(fx.dist.path()); + fx.publish_good(); + + for round in 1..=2 { + let (code, json) = fx.apply(); + assert_eq!(code, 0, "第 {round} 次 apply 应成功:{json}"); + assert_eq!(support::update_e2e::payload(&json)["status"], "installed"); + assert_eq!( + fx.installed_bytes(), + fx.payload, + "第 {round} 次 apply 之后目标字节必须仍是产物" + ); + assert!( + fx.staging_leftovers().is_empty(), + "第 {round} 次 apply 之后暂存目录还有 {:?}", + fx.staging_leftovers() + ); + } + assert_eq!( + fx.backups().len(), + 2, + "每次 apply 留一个备份(CLI 不删):{:?}", + fx.backups() + ); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d39441e..98838d4 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -72,3 +72,10 @@ path = "tests/download/partfile_test.rs" [lints] workspace = true + +[features] +# M169:把「本地更新源」这条测试通道**编进二进制**。**默认关**——官方发布构建不带它, +# `core::update::job::default_source` 里那条分支根本不存在(编译期事实,不靠运行时判据)。 +# 验收用法见 `crates/cli/tests/update_e2e_test.rs` 的文件头;`packaging/check_test_source_off.py` +# 守门「发布流水线与打包脚本都不许打开它」。 +update-test-source = [] diff --git a/crates/core/src/update/job.rs b/crates/core/src/update/job.rs index bd1929b..a1d1f91 100644 --- a/crates/core/src/update/job.rs +++ b/crates/core/src/update/job.rs @@ -25,8 +25,10 @@ //! //! # 换源 //! -//! 生产源是 D50 定死的 GitHub Releases([`default_source`]);`ULL_UPDATE_DIR` 指向本地目录时 -//! 改走 [`super::source::DirSource`]——那是取证/内网手工分发的口子,**不是**第二个更新源。 +//! 生产源是 D50 定死的 GitHub Releases([`default_source`]);**测试通道**(带 `update-test-source` +//! feature 编出来的二进制 + [`UPDATE_DIR_ENV`] + [`UPDATE_TEST_SOURCE_ENV`] 三档全给)时改走 +//! [`super::source::DirSource`]——那是取证/内网手工分发的口子,**不是**第二个更新源。判据、隔离 +//! 手段与残余风险见 [`test_source_root`]。 use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -39,18 +41,29 @@ use super::error::UpdateError; use super::github::GitHubReleaseSource; use super::install::{install_staged, InstallReceipt, ReplacePolicy}; use super::manifest::Artifact; -use super::source::{DirSource, UpdateSource}; +#[cfg(feature = "update-test-source")] +use super::source::DirSource; +use super::source::UpdateSource; use super::target::{PackageFormat, Target}; use super::verify::stage; use super::version::Version; -/// 本机更新源覆盖(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`/stable.json`)。 +/// 测试通道的源根(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`/stable.json`)。 /// -/// **只在调试构建里生效**(判据与理由见 [`local_dir_override`]):发布构建忽略它, -/// 真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支在没有稳定 -/// Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。 +/// 只在**带 `update-test-source` feature** 编出来的二进制里生效,且**单独设它没有用**:还要 +/// [`UPDATE_TEST_SOURCE_ENV`](判据见 `test_source_root`)。默认构建里这两个变量纯粹是死字符串。 pub const UPDATE_DIR_ENV: &str = "ULL_UPDATE_DIR"; +/// 测试通道的显式承认开关:值必须是 `1`,[`UPDATE_DIR_ENV`] 才会被认(同样只在 feature 构建里)。 +/// +/// 两个变量分开是为了「路径」与「我知道我在用测试源」这两件事各自留痕:只漏出一个路径 +/// (继承的环境、别的脚本 export 过)不足以把启动器的更新源改走本地目录。 +pub const UPDATE_TEST_SOURCE_ENV: &str = "ULL_UPDATE_TEST_SOURCE"; + +/// [`UPDATE_TEST_SOURCE_ENV`] 唯一认的值(`true` / `yes` / `on` 一律不算:手误不该开闸)。 +#[cfg(feature = "update-test-source")] +const TEST_SOURCE_ACK: &str = "1"; + /// 一次应用的结局。 #[derive(Debug)] pub enum Applied { @@ -242,41 +255,75 @@ pub fn staging_dir(cache_root: &Path) -> PathBuf { cache_root.join("update").join("staging") } -/// 生产源:GitHub Releases(D50);**只在调试构建里**,`ULL_UPDATE_DIR` 设了就走本地目录源。 +/// 生产源:GitHub Releases(D50);**带 `update-test-source` feature 的构建**里,测试通道开启时 +/// (`test_source_root`)走本地目录源。 #[must_use] pub fn default_source(engine: Arc) -> Box { - match local_dir_override() { - Some(root) => Box::new(DirSource::new(root)), - None => Box::new(GitHubReleaseSource::new(engine)), + // 测试通道(M169):整段只在带 feature 的构建里存在。默认关 ⇒ 官方发布产物里 + // `test_source_root` 根本编不出来,`DirSource` 也进不了这条入口(编译期事实,不看运行时)。 + #[cfg(feature = "update-test-source")] + if let Some(root) = test_source_root() { + return Box::new(DirSource::new(root)); } + Box::new(GitHubReleaseSource::new(engine)) } -/// `ULL_UPDATE_DIR` 的落点:**只认调试构建**。 +/// 测试通道的判据:**feature 编进来 + 两档环境变量给全**,才返回本地源根。 /// -/// # 为什么判据是 `debug_assertions` 而不是别的 +/// # 为什么判据从「只看构建画像」改成「feature + 两档显式承认」 /// -/// 这个后门能绕过 GitHub 源:谁能让启动器读到一份本地清单,谁就能让它把任意字节 rename 成本进程 -/// 的 exe。清单里的摘要也来自同一份清单,所以**这条路上没有可信根**——它不是权限提升(要能设用户 -/// 环境变量 + 用户自己点「下载并安装」),但发布产物里没有任何理由保留它。 +/// M159 的判据是 `cfg!(debug_assertions)`:`--release` 里整段被常量折叠掉,于是**发布产物没法 +/// 指向本地更新源**,而发布产物正是 release,`ull update apply` 又会替换本进程 exe——这条链在 +/// release 上从来没有端到端证据(M167 的结论)。要补这一环,判据必须与「是不是 debug」脱钩。 /// -/// 三档选一,我选了「按构建画像判」: +/// M169 的三档(tower 定案 B,理由不是省事而是攻击面): /// -/// | 判据 | 为什么不选 | -/// |---|---| -/// | 命令行显式参数(`--update-dir`) | 要给 GUI 与 CLI 各加一条参数、还要穿到 `Job` 里;而它只服务取证/内网,不该出现在正式用法里 | -/// | 运行期开关(设置键 / 环境变量再加一层鉴权) | 等于「发布版也留着这条路」,只是多了一道可被一起设好的门 | -/// | **`cfg!(debug_assertions)`** | 发布构建(`--release`)里这个常量是 `false`,整段分支被常量折叠掉——**release 二进制里这条路径根本不存在**,而且是编译期事实,不靠用户/环境配合 | +/// | 档 | 怎么给 | 作用 | +/// |---|---|---| +/// | 编译期 | `--features update-test-source` | 这段代码**才存在**;官方发布构建不带它 | +/// | 路径 | `ULL_UPDATE_DIR=` | 本地源根(`/stable.json` + 产物) | +/// | 承认 | `ULL_UPDATE_TEST_SOURCE=1` | 「我知道这是测试通道」;值必须是 `1`,`true`/`yes` 不算 | /// -/// 调试构建仍认它:本 mission 的「发现新版本 → 下载 → 双检 → 自替换」整环就是用 `cargo build` -/// 出来的二进制 + 本地夹具验的(真实 GitHub 上 v0.2.2 还是 prerelease,取不到 stable 清单)。 -fn local_dir_override() -> Option { - if !cfg!(debug_assertions) { +/// 只给路径(或只设环境变量)什么都不会发生:**默认构建里这段函数根本不存在**。 +/// +/// # 隔离手段(逐条;别只信「有闸门」) +/// +/// 1. **编译期不存在**:默认 features 关,官方发布二进制里没有这条路径——这是 README 里 +/// 「release builds do not contain that path at all」这句承诺的落点。 +/// 2. **不是默认**:feature 打开之后,还要两个环境变量同时给;判据是纯函数 +/// (`test_source_root_from`),每种半开都有单测。 +/// 3. **不是用户面开关**:没有设置键、没有命令行参数、不落盘;每次 [`default_source`] 重读环境 +/// 变量,同一个进程里改了就立刻生效/失效(不留缓存)。 +/// 4. **输出里点名**:`ull update check --json` 的信封里 `data.source` 是 `file:`、 +/// `data.test_source` 是 `true`(判据派生自**实际用了哪个源**,不是回读环境变量),人读输出另有 +/// 一行「测试通道生效」。 +/// 5. **发布流程不碰它**:`packaging/check_test_source_off.py` 扫 `.github/workflows/**`、 +/// `packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`,任何一处打开它就红(CI 的 +/// release-guard job 跑它)。 +/// +/// # 这条闸门**不是**权限边界 +/// +/// 谁能在 Windows 上给 GUI 设进程环境变量,谁就已经能以该用户身份运行任意程序。真正要守的是 +/// 「**官方二进制里不存在第二条更新源**」——第 1 条(编译期)才是这件事的判据,第 2-5 条是纵深。 +#[cfg(feature = "update-test-source")] +pub(super) fn test_source_root() -> Option { + let dir = std::env::var(UPDATE_DIR_ENV).ok(); + let ack = std::env::var(UPDATE_TEST_SOURCE_ENV).ok(); + test_source_root_from(dir.as_deref(), ack.as_deref()) +} + +/// [`test_source_root`] 的纯判据(不碰进程环境:单测直接喂两档,并行跑也不会互相干扰)。 +/// +/// 空路径按「没给」处理(`ULL_UPDATE_DIR=` 这种写法很常见,不该被当成「当前目录」); +/// 承认档两侧空白忽略、大小写不忽略(`1` 就是 `1`)。 +#[cfg(feature = "update-test-source")] +pub(super) fn test_source_root_from(dir: Option<&str>, ack: Option<&str>) -> Option { + if ack.map(str::trim) != Some(TEST_SOURCE_ACK) { return None; } - let root = std::env::var(UPDATE_DIR_ENV).ok()?; - let root = root.trim(); + let root = dir?.trim(); if root.is_empty() { return None; } - Some(std::path::PathBuf::from(root)) + Some(PathBuf::from(root)) } diff --git a/crates/core/src/update/mod.rs b/crates/core/src/update/mod.rs index edd7e73..6fee236 100644 --- a/crates/core/src/update/mod.rs +++ b/crates/core/src/update/mod.rs @@ -3,7 +3,11 @@ //! 更新源已定(D50):**GitHub Releases 是唯一更新源**,真实实现在 //! [`GitHubReleaseSource`](清单固定地址 + 产物走 `core::download` 引擎)。 //! 这一层仍然保持源无关:核心只认 [`UpdateSource`] 三个方法,接哪家由上层注入 -//! (本地目录源 [`DirSource`] 留作对拍基准与内网手工分发)。 +//! (本地目录源 [`DirSource`] 留作对拍基准、内网手工分发与**测试通道**)。 +//! +//! 测试通道不是第二个更新源:`update-test-source` feature(默认关)+ `ULL_UPDATE_DIR` + +//! `ULL_UPDATE_TEST_SOURCE=1` 三档全给才会把 [`default_source`] 换成本地目录(判据、隔离手段与 +//! 为什么用编译期 feature 见 `job::test_source_root` 的文档)。 //! //! 设计输入:`DevDocs/analyse/07-settings-packaging-update-p2.md` §4(HMCL 自更新)与 §6.2 //! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。 @@ -61,7 +65,9 @@ pub use self::github::GitHubReleaseSource; pub use self::install::{ backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy, }; -pub use self::job::{default_source, process_dest, staging_dir, Applied, Job, UPDATE_DIR_ENV}; +pub use self::job::{ + default_source, process_dest, staging_dir, Applied, Job, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV, +}; pub use self::manifest::{Artifact, Release, ReleaseManifest}; pub use self::source::{DirSource, UpdateSource}; pub use self::target::{Arch, PackageFormat, Target}; diff --git a/crates/core/src/update/tests/job_cases.rs b/crates/core/src/update/tests/job_cases.rs index 8be47f7..2788dae 100644 --- a/crates/core/src/update/tests/job_cases.rs +++ b/crates/core/src/update/tests/job_cases.rs @@ -1,4 +1,4 @@ -//! `Job`(检查/应用的一条龙)的用例:包形态闸门 + 一条成功的自替换。 +//! `Job`(检查/应用的一条龙)的用例:包形态闸门、一条成功的自替换、测试通道的两档判据。 //! //! 为什么这层要单独测:`Release::artifact_for` 只比 `target`、不看 `format`,所以「清单给 //! win-x64 挂了一个 msi/zip」是完全可能的输入(复审 P3-6)。以前 `apply` 会把它直接 rename @@ -147,13 +147,132 @@ fn nothing_to_apply_does_not_touch_the_target() { assert_eq!(std::fs::read(&dest).expect("读目标"), b"old-bytes"); } -/// 调试构建认 `ULL_UPDATE_DIR`、发布构建不认——判据是靠 `cfg!(debug_assertions)` 折叠掉的, -/// 所以这里用源码锚点把「闸门还在」钉住(release 分支在测试里跑不到,只能这样守)。 +/// 测试通道的判据是**编译期开 + 两档全给**:`--features update-test-source`、`ULL_UPDATE_DIR`、 +/// `ULL_UPDATE_TEST_SOURCE=1`。 +/// +/// 旧判据是 `cfg!(debug_assertions)`(debug 认、release 整段折叠掉),M169 换掉它是因为发布 +/// 产物正是 release——拿 release 验不了这条链,整条更新通道就没有端到端证据。换判据必须补隔离: +/// 编译期 feature 保证**官方二进制里不存在这条路**,两档环境变量保证「开了 feature 的验收构建」 +/// 也不会被继承来的单个环境变量悄悄改源。这条用例守后者(纯判据,不碰进程环境)。 +#[cfg(feature = "update-test-source")] #[test] -fn the_local_dir_override_is_debug_only() { - let source = include_str!("../job.rs"); - assert!( - source.contains("if !cfg!(debug_assertions) {"), - "`ULL_UPDATE_DIR` 的闸门必须是 debug_assertions(发布构建里整段被折叠掉)" +fn the_test_channel_needs_both_switches() { + use std::path::PathBuf; + + use crate::update::job::test_source_root_from; + + assert_eq!( + test_source_root_from(Some("/d/dist"), Some("1")), + Some(PathBuf::from("/d/dist")), + "两档全给才开" + ); + assert_eq!( + test_source_root_from(Some(" /d/dist \n"), Some(" 1 ")), + Some(PathBuf::from("/d/dist")), + "路径与承认值两侧空白忽略" + ); + + assert_eq!( + test_source_root_from(Some("/d/dist"), None), + None, + "只给路径不许改源(旧的单变量口子)" + ); + assert_eq!( + test_source_root_from(None, Some("1")), + None, + "只给承认档但没有路径" + ); + for ack in ["true", "yes", "on", "0", "", " "] { + assert_eq!( + test_source_root_from(Some("/d/dist"), Some(ack)), + None, + "承认值 `{ack}` 不算(手误不该开闸)" + ); + } + assert_eq!( + test_source_root_from(Some(" "), Some("1")), + None, + "空路径按没给处理(`ULL_UPDATE_DIR=` 不该被读成当前目录)" ); } + +/// 环境变量 → [`default_source`] 的接线,**两种构建各断各的**: +/// +/// * 带 `update-test-source`(验收构建):两档全给才换成 `file:` 源;半开仍走 GitHub;去掉承认档 +/// 立刻回到 GitHub(判据不缓存)。 +/// * 不带 feature(**官方发布构建**):环境变量设满了也照样走 GitHub——这条分支根本不存在。 +/// +/// 后者是本 mission 的关键断言:README 承诺「release 构建里不存在这条路径」,判据是编译期的, +/// 所以只能在「不带 feature 的测试跑」时验证。 +/// +/// 用例动的是**进程环境变量**(全局状态),所以先用一把进程内锁串起来、退出时还原原值—— +/// `update` 的单测在同一个测试进程里并行跑。 +#[test] +fn the_test_channel_is_a_compile_time_opt_in() { + use crate::download::RegionProfile; + use crate::update::job::{UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV}; + use crate::update::{default_source, github}; + + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _serial = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + /// 记住两个变量的原值,`Drop` 时还原(用例 panic 也还原)。 + struct Restore(Option, Option); + impl Drop for Restore { + fn drop(&mut self) { + for (key, value) in [ + (UPDATE_DIR_ENV, self.0.clone()), + (UPDATE_TEST_SOURCE_ENV, self.1.clone()), + ] { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } + + let tmp = TempDir::new("job-env"); + let _restore = Restore( + std::env::var_os(UPDATE_DIR_ENV), + std::env::var_os(UPDATE_TEST_SOURCE_ENV), + ); + let github_id = format!("github:{}/{}", github::DEFAULT_OWNER, github::DEFAULT_REPO); + let engine = || super::support::engine_for(&tmp, RegionProfile::direct(0)); + let root = tmp.join("dist"); + + // 两档都给满(不带 feature 的构建里这是最强的「攻击面」输入)。 + std::env::set_var(UPDATE_DIR_ENV, &root); + std::env::set_var(UPDATE_TEST_SOURCE_ENV, "1"); + + #[cfg(not(feature = "update-test-source"))] + { + assert_eq!( + default_source(engine()).source_id(), + github_id, + "不带 `update-test-source` 的构建(= 官方发布)里,环境变量设满了也不许改源:{root:?}" + ); + } + + #[cfg(feature = "update-test-source")] + { + use crate::update::job::test_source_root; + + assert_eq!( + default_source(engine()).source_id(), + format!("file:{}", root.display()), + "带 feature:两档全给才换成本地目录源" + ); + assert_eq!(test_source_root(), Some(root.clone())); + + std::env::remove_var(UPDATE_TEST_SOURCE_ENV); + assert_eq!( + default_source(engine()).source_id(), + github_id, + "半开(只给路径)必须回到 GitHub——旧的单变量口子不再生效" + ); + assert_eq!(test_source_root(), None); + } +} diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs index 8a7fcca..ac0116e 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -8,7 +8,8 @@ //! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留; //! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用; //! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、 -//! 「没有可装更新」不碰目标、`ULL_UPDATE_DIR` 只在调试构建里生效; +//! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来, +//! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给); //! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝; //! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。 //! diff --git a/crates/presenter/Cargo.toml b/crates/presenter/Cargo.toml index 921446d..abeb907 100644 --- a/crates/presenter/Cargo.toml +++ b/crates/presenter/Cargo.toml @@ -26,3 +26,10 @@ unlucky-core = { path = "../core", version = "0.2.3" } [lints] workspace = true + +[features] +# M169:转发 core 的测试通道(默认关)。GUI 侧「更新」这条链的**人工验收**需要它: +# `cargo run -p unlucky-launcher-iced --features update-test-source` 配 +# `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 才能让设置页指向本地更新源。 +# 官方发布构建不带这个 feature(`packaging/check_test_source_off.py` 守门)。 +update-test-source = ["unlucky-core/update-test-source"] diff --git a/crates/presenter/src/host/update.rs b/crates/presenter/src/host/update.rs index 35e6ab0..8fbd4f2 100644 --- a/crates/presenter/src/host/update.rs +++ b/crates/presenter/src/host/update.rs @@ -92,7 +92,9 @@ impl Paths { /// 这样「上一次用的源」不会跟着下一次跑,测试也能各用例各给一份本地目录。 #[derive(Clone)] pub enum Factory { - /// 生产/spec 形态:GitHub(或 `ULL_UPDATE_DIR` 指定的本地目录),引擎懒建一次。 + /// 生产/spec 形态:GitHub Releases(**带 `update-test-source` feature 的验收构建**里,两档 + /// 环境变量给全时是本地目录源,见 `unlucky_core::update::job` 的 `test_source_root`), + /// 引擎懒建一次。 Default { /// 下载引擎(镜像/地区/健康度策略都在里面)。 engine: Arc, diff --git a/packaging/README.md b/packaging/README.md index bf9de19..2545579 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -65,6 +65,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | | `packaging/check_publish_slots.py` | 发布名单守卫(M164):名单 ⇄ 产物两个方向;`--self-test` 注入 8 条回归 | ≤400 | +| `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;`--self-test` 注入 5 条回归 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | | `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 | | `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 | @@ -107,3 +108,21 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) `v0.2.2` 那个 Release **保留为历史预发布不动**,稳定通道以 `v0.2.3` 为起点。发版 checklist 第六件 = 确认 prerelease 标记与通道一致(`gh release view --json isPrerelease`),守门是 `packaging/check_release_semantics.py`(CI:`.github/workflows/release-guard.yml`)。 + +## 14. 更新链路的测试通道(`update-test-source`,M169) + +「发现新版本 → 下载 → 长度 + SHA256 双检 → 就位」这条链要能在**发布档(release)**上验收,就得让 +二进制能指向本地更新源;而这条能力同时是「离任意本地目录最近的一条路」(终点是替换用户盘上的 exe), +所以它必须是**编译期**的,不是运行期设置项: + +- **默认关**:`update-test-source` 是 `crates/core` / `cli` / `presenter` / `app-iced` 各一条 feature, + 默认不启用。官方发布构建不带它 ⇒ `core::update::job::default_source` 里那段分支**根本编不进来** + (README 的「release builds do not contain that path at all」就是这么成立的)。 +- **验收构建**:`cargo build --release --features update-test-source`,再配 + `ULL_UPDATE_DIR=` + `ULL_UPDATE_TEST_SOURCE=1`(两档缺一不可)。`ull update check --json` 会 + 如实写 `data.source: "file:"`、`data.test_source: true`,人读输出另有一行点名。 +- **守门**:`packaging/check_test_source_off.py`(CI:`.github/workflows/release-guard.yml`)扫 + `.github/workflows/**`、`packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`——任何一处打开它就红。 +- 验收本体:`cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test + --test update_e2e_tamper_test`(正路 + 幂等 / 篡改摘要 + 篡改包体 + 截断)。**不验**真 GitHub 下载、 + GUI 点击与重启替换(需人工)。 diff --git a/packaging/check_test_source_off.py b/packaging/check_test_source_off.py new file mode 100644 index 0000000..70900b1 --- /dev/null +++ b/packaging/check_test_source_off.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""测试通道守门(M169):**官方发布产物里不许有 `update-test-source` feature**。 + +为什么需要一条独立的守门:那条 feature 让 `core::update::job::default_source` 可以被两个环境变量 +指向任意本地目录,而更新链路的终点是**替换用户盘上的 exe**。默认关 + 编译期不存在是唯一可靠的 +边界,所以「谁也没在流水线里把它打开」必须是自动断言,不能靠注释和记性。 + +三条断言,任何一条不成立即 exit 1(CI 里红在 `.github/workflows/release-guard.yml`): + +1. **工作流与打包脚本不许打开它**:`.github/workflows/*.yml|*.yaml` 与 `packaging/*.py|*.sh` 的 + 可执行内容里不许出现 `update-test-source` 字面量(本文件自己除外——它当然得写这个名字)。 + 注释里提它不算(`#` 之后一律剥掉),否则「这里不许加 X」这种说明反而会被守门判红。 +2. **不许 `--all-features`**:它会把**所有** feature 打开,包括测试通道。两个目录里出现即红。 +3. **不许设成默认**:任何 `Cargo.toml` 里 `update-test-source` 只许作为**自己的声明** + (`update-test-source = [...]`)出现;出现在 `default = [...]` 或别的 feature 的依赖数组里即红。 + 这一条专门兜「第 1 条扫不到」的那种开法:feature 一旦进 default,任何 `cargo build` 都是验收构建。 + +`--self-test` 是这条守门的变异自测:把每一条的**真实回归**注入临时副本(workflow 里加 +`--features update-test-source`、打包脚本里加同一个字面量、workflow 里改 `--all-features`、 +`Cargo.toml` 的 `default` 加进去),断言**每一项都红**、基线绿、撤回后重新变绿。守门自己抓不住 +回归时它会先红,所以这条断言不会变成装饰品。 + +# 抓得住什么 / 抓不住什么 + +| 抓得住 | 抓不住 | +|---|---| +| 有人在 workflow / 打包脚本里加 `--features update-test-source` | 有人在自己机器上手工构建并**自己**分发(流水线管不到的范围) | +| 有人把 feature 塞进 `default`(第 3 条专治;第 1 条扫不到这种开法) | 有人改名后再接回发布:名字一变就脱离了本守门的扫描表 | +| 有人用 `--all-features` 一把梭 | `.github/actions/**` / 别的自定义 action 里间接打开:本守门只扫上面两个目录,扩扫描面时记得连它一起加 | +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tempfile +from pathlib import Path + +#: 被守的 feature 名(改名就得同步改本文件与 `crates/*/Cargo.toml`)。 +FEATURE = "update-test-source" + +#: 仓库根(本文件在 `packaging/` 下)。 +ROOT = Path(__file__).resolve().parent.parent +#: 本文件自己(扫描时跳过:它必须写出 feature 名)。 +GUARD = Path(__file__).resolve() + +WORKFLOW_DIR = Path(".github/workflows") +SCRIPT_DIR = Path("packaging") +SCRIPT_SUFFIXES = (".py", ".sh") +MANIFEST_GLOBS = ("Cargo.toml", "crates/*/Cargo.toml") + +#: Cargo.toml 里「自己的声明」长这样:行首 `update-test-source = [`。 +DECLARATION = re.compile(rf"^\s*{re.escape(FEATURE)}\s*=\s*\[") +#: `#` 之后的注释(三种文件都用 `#`;fixture 里没有字符串内 `#`,够用)。 +COMMENT = re.compile(r"#.*$") +#: 会把所有 feature 打开的开关。 +ALL_FEATURES = re.compile(r"--all-features\b") + + +def _stripped(line: str) -> str: + """去掉注释与行尾空白(注释里提 feature 不算违规)。""" + return COMMENT.sub("", line).strip() + + +def scan_enabling_text(path: Path, root: Path) -> list[str]: + """扫「可执行内容」:命中的开关会被原样带进一次真实构建。""" + problems: list[str] = [] + try: + text = path.read_text(encoding="utf-8") + except OSError as error: + return [f"::error file={path}::读不到 {path}:{error}"] + for number, line in enumerate(text.splitlines(), start=1): + code = _stripped(line) + if not code: + continue + where = path.relative_to(root).as_posix() + if FEATURE in code: + problems.append( + f"::error file={where}:{number}::{where} 打开了 `{FEATURE}`:" + "官方发布产物里不许有测试通道(它能把更新源指向任意本地目录)" + ) + if ALL_FEATURES.search(code): + problems.append( + f"::error file={where}:{number}::{where} 用了 `--all-features`:" + f"它会把 `{FEATURE}` 一起打开" + ) + return problems + + +def scan_manifest(path: Path, root: Path) -> list[str]: + """扫 Cargo.toml:feature 只许出现在自己的声明行上(不许进 default / 别的 feature)。""" + problems: list[str] = [] + try: + text = path.read_text(encoding="utf-8") + except OSError as error: + return [f"::error file={path}::读不到 {path}:{error}"] + for number, line in enumerate(text.splitlines(), start=1): + code = _stripped(line) + if FEATURE not in code or DECLARATION.match(code): + continue + where = path.relative_to(root).as_posix() + problems.append( + f"::error file={where}:{number}::{where} 把 `{FEATURE}` 写进了别的 feature 列表:" + "只许它是自己的声明(`update-test-source = [...]`),不许进 `default` 或别的 feature" + ) + return problems + + +def scan(root: Path) -> list[str]: + """扫整个仓库,返回所有问题(空 = 绿)。""" + problems: list[str] = [] + for pattern in ("*.yml", "*.yaml"): + for path in sorted((root / WORKFLOW_DIR).glob(pattern)): + problems += scan_enabling_text(path, root) + for suffix in SCRIPT_SUFFIXES: + for path in sorted((root / SCRIPT_DIR).glob(f"*{suffix}")): + if path.resolve() == GUARD: + continue + problems += scan_enabling_text(path, root) + for pattern in MANIFEST_GLOBS: + for path in sorted(root.glob(pattern)): + problems += scan_manifest(path, root) + return problems + + +# ────────────────────────── 变异自测 ────────────────────────── + +#: 自测基线:一份最小但结构完整的仓(路径与真实仓一致)。 +BASELINE: dict[str, str] = { + ".github/workflows/release.yml": "jobs:\n build:\n steps:\n - run: cargo build --release\n", + "packaging/make_artifacts.py": "def build():\n return 'cargo build --release'\n", + "Cargo.toml": "[workspace]\nmembers = []\n", + "crates/core/Cargo.toml": ( + "[features]\n" + f"{FEATURE} = []\n" + "# 注释里提到 update-test-source 不算违规\n" + ), +} + +#: 每条变异 =(构造时改哪个文件、插了什么);改完必须红,撤回后必须绿。 +MUTATIONS: list[tuple[str, str, str]] = [ + ( + "workflow 里加 --features update-test-source", + ".github/workflows/release.yml", + " - run: cargo build --release --features update-test-source\n", + ), + ( + "打包脚本里加同一个 feature", + "packaging/make_artifacts.py", + "FLAGS = '--features update-test-source'\n", + ), + ( + "workflow 里改 --all-features(会一并打开测试通道)", + ".github/workflows/release.yml", + " - run: cargo build --release --all-features\n", + ), + ( + "Cargo.toml 的 default 把 feature 带上", + "crates/core/Cargo.toml", + 'default = ["update-test-source"]\n', + ), + ( + "别的 feature 依赖它", + "crates/core/Cargo.toml", + 'release = ["update-test-source"]\n', + ), +] + + +def self_test() -> int: + failures: list[str] = [] + with tempfile.TemporaryDirectory(prefix="ull-test-source-guard-") as temp: + base = Path(temp) + for relative, text in BASELINE.items(): + path = base / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + baseline = scan(base) + if baseline: + failures.append(f"基线应当在守门里是绿的,实际红了:{baseline}") + + for label, relative, injected in MUTATIONS: + target = base / relative + original = target.read_text(encoding="utf-8") + target.write_text(original + injected, encoding="utf-8") + hits = scan(base) + if not hits: + failures.append(f"{label}:注入了真实回归,守门却没红(抓不住)") + target.write_text(original, encoding="utf-8") + if scan(base): + failures.append(f"{label}:撤回注入后没有回到绿") + + if failures: + for failure in failures: + print(f"::error::{failure}", file=sys.stderr) + return 1 + print(f"OK: 守门自测通过({len(MUTATIONS)} 条注入全部变红,基线绿)") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=f"守门:官方发布产物里不许有 {FEATURE}") + parser.add_argument("--self-test", action="store_true", help="注入变异自测(每条都必须红)") + parser.add_argument("--root", type=Path, default=ROOT, help="仓根(自测用)") + args = parser.parse_args() + + if args.self_test: + return self_test() + + problems = scan(args.root) + if problems: + for problem in problems: + print(problem, file=sys.stderr) + return 1 + print(f"OK: 流水线与打包脚本都没有打开 {FEATURE}(默认关,发布产物不含测试通道)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())