diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c04f1b9..93d7106 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,3 +288,30 @@ jobs: name: runtime-smoke-screenshot-macos path: ${{ runner.temp }}/smoke/ if-no-files-found: warn + + # M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 / + # 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。 + # + # 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` + + # `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable` + # 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿 + # (r1 复审 P2-4)。 + # + # 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在 + # `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test + # update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。 + # + # 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与 + # panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。 + update-e2e: + name: update-e2e (windows x64) + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: update-e2e + run: > + cargo test --release -p unlucky-cli --features update-test-source + --test update_e2e_test --test update_e2e_tamper_test diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml index dafade5..c87a6cc 100644 --- a/.github/workflows/release-guard.yml +++ b/.github/workflows/release-guard.yml @@ -22,6 +22,16 @@ 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/**`(含复合 action)、`packaging/**` 递归脚本、 +# `Makefile`/`justfile`/`Taskfile`、各 `Cargo.toml`(`default` 里带上它也红)与 `.cargo/config*.toml` +# (`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`)。**唯一允许**是 `ci.yml` 里名为 +# `update-e2e` 的那一步(且必须带 `--test update_e2e_test`,文件名/步骤名/参数任一不符即红)。 +# `--self-test` 注入 26 条真实回归 + 6 条防误杀/放行用例(另加基线;条数以 `--self-test` 输出为准), +# 该红的红、该绿的绿。 on: push: @@ -48,3 +58,12 @@ 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/**`(含复合 action)、`packaging/**` 递归脚本、`Makefile`/`justfile`/ + # `Taskfile` 与各 `Cargo.toml`;归属按 `jobs.*.steps[]` 的**列表项**判,落不到具名步骤的行一律红。 + # 唯一允许:本仓 `ci.yml` 里名为 `update-e2e` 的那一步(且必须带 `--test update_e2e_test`)。 + - 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..248cecb 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -90,6 +90,13 @@ 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-app-iced --features update-test-source +# ⚠ `-p` 只认 **package** 名(`unlucky-app-iced`),`unlucky-launcher-iced` 是它在 [[bin]] 里 +# 声明的 bin 名——写成 `-p unlucky-launcher-iced` 会 `package(s) … not found`(r1 复审 P2-1)。 +# 转发给 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..ddd73f1 --- /dev/null +++ b/crates/cli/tests/support/update_e2e.rs @@ -0,0 +1,335 @@ +//! 更新通道端到端验收的夹具(M169):临时现场、真形状清单、测试通道的环境变量守卫。 +//! +//! 两个测试目标共用(`update_e2e_test` 正路 / 幂等,`update_e2e_tamper_test` 篡改);每个目标只 +//! 用到其中一部分,所以与 `support/mod.rs` 一样放开 dead_code。 +//! +//! 整个夹具只在 `update-test-source` feature 下真的被用到:那两个测试目标自己 `#![cfg]` 掉, +//! 这里只是跟着编译(不跑)。判据(feature + 两档环境变量)在 `core::update::job`。 +//! +//! # 为什么这套「真形状」只在 Windows 上成立(r1 复审 P2-4) +//! +//! 产物名与形态按 **Windows** 的真实发布走:`unlucky-launcher__windows_x64.exe` + +//! `portable-exe`(口径见 `packaging/make_artifacts.py` 的头注释)。Linux/macOS 的真实产物是 +//! `.tar.gz` + `portable-zip`,会被 `ensure_self_replaceable` **直接拒**(不是可原地自替换的形态)。 +//! +//! * 在 Windows 上跑 = 验真实形状; +//! * 在别的平台上跑绿只能证明「一个世上不存在的 linux/macOS portable-exe 能装上」——**假绿**。 +//! +//! 所以 CI 里它是**一个独立的 Windows job**(`.github/workflows/ci.yml` 的 `update-e2e`, +//! `runs-on: windows-latest`),本文件也**不**为非 Windows 平台改断言:要真支持,得让产物名与 +//! `format` 一起按平台分叉,那是另一个 mission 的事(真实 Linux 自更新根本不支持,D50 只发裸 exe)。 +//! +//! ⚠ **验收必须给 `--dest`**:用例在**进程内**跑 `unlucky_cli::run`,`Job::for_process` 的 +//! 「本进程的 exe」就是测试二进制——`apply` 不带 `--dest` 会真的把它 `rename` 成新字节 +//! (Windows 允许改名正在运行的映像,见 `core::update::install` 的文档),整轮测试当场崩。 +//! `Fixture::run` 里有结构性断言兜这一条(r1 复审 P2-5),不是只靠这段注释。 +#![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 一样由数据根派生暂存目录)。 + /// + /// **结构性保险(r1 复审 P2-5 / r2 复审 P2-3)**:`apply` 必须显式带 `--dest`,且**这一次调用实际 + /// 传下去的那个 `--dest` 值**不许是本进程的 exe。不带 `--dest` 就是 `Job::for_process` 的「本进程 + /// exe」——在这套用例里那是**测试二进制**,而 Windows 允许改名正在运行的映像,于是整轮测试会把它 + /// 换成一个假启动器(症状极难查)。 + /// + /// 比的是 **args 里那个 `--dest` 的值**(含 `--dest=` 形式),不是夹具字段:r2 复审指出 + /// 「比 `self.dest`」等于只守住了夹具自己那一份,手写 + /// `fx.run(&["apply", "--dest", "<测试二进制>", "--json"])` 照样能过。夹具字段那一份作为额外一道 + /// 保留(`Fixture::apply()` 走的就是它)。 + /// + /// 抓得住:漏 `--dest`、把 `--dest` 指到本进程 exe、夹具 dest 被改成 exe——都**当场 panic**。 + /// 抓不住:绕开 `Fixture` 直接调 `unlucky_cli::run`(那等于另写一套夹具,`--data-dir` 也没人拼了)。 + pub fn run(&self, args: &[&str]) -> (i32, String, String) { + let requested = args + .iter() + .position(|arg| *arg == "--dest") + .and_then(|index| args.get(index + 1).copied()) + .or_else(|| args.iter().find_map(|arg| arg.strip_prefix("--dest="))); + if args.first() == Some(&"apply") { + assert!( + requested.is_some(), + "apply 必须带 --dest:不带会替换本进程 exe(这套用例里就是测试二进制)。\ + 唯一的 apply 入口是 `Fixture::apply()`" + ); + } + if let Ok(exe) = std::env::current_exe().and_then(std::fs::canonicalize) { + let asked = requested.and_then(|path| std::fs::canonicalize(path).ok()); + let fixture = std::fs::canonicalize(&self.dest).ok(); + for dest in [asked, fixture].into_iter().flatten() { + assert_ne!( + dest, exe, + "替换目标不许是本进程的 exe(`--dest` 点的就是它)" + ); + } + } + 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..2fb8091 --- /dev/null +++ b/crates/cli/tests/update_e2e_tamper_test.rs @@ -0,0 +1,107 @@ +//! 更新通道端到端验收(M169)的**拒绝侧**:篡改摘要、篡改包体、截断包体。 +//! +//! 正路与幂等在 `update_e2e_test.rs`(现场说明、抓得住/抓不住、不验的部分都在那边,别重复读)。 +//! 这里只钉一条底线:**校验不过就一个字节都不许落到目标上**,而且失败不会把现场搞脏到 +//! 「修好清单也装不上」。 +//! +//! ⚠ 这三例**抓不住回滚/备份**:摘要与长度失败都发生在 `install_staged` 之前,那一步走不到 +//! (r1 复审 ⑤ 的变异实测:去掉 dest 备份后这三例仍全绿)。回滚由 `update_e2e_test.rs` 的备份 +//! 断言与 `core::update::tests::install_cases` 守。 +//! +//! 一键跑(必须带 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..b0c28db --- /dev/null +++ b/crates/cli/tests/update_e2e_test.rs @@ -0,0 +1,196 @@ +//! 更新通道端到端验收(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` 不能跨卷,那条路没验 | +//! | 失败路径三例(`update_e2e_tamper_test`:篡改清单 sha / 篡改包体 / 截断) | 「校验判据被绕过」「失败却已动过目标」「失败留下 `.ullupd`」 | **回滚与备份**:摘要/长度失败发生在 `install_staged` **之前**,那一步根本走不到——回滚靠正路的备份断言 + `install_cases` 单测(r1 复审 ⑤ 的变异实测:去掉 dest 备份后失败路径三例仍全绿,只有正路/幂等变红) | +//! +//! # 不验的部分(如实划界) +//! +//! 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..55d6914 100644 --- a/crates/presenter/Cargo.toml +++ b/crates/presenter/Cargo.toml @@ -26,3 +26,11 @@ unlucky-core = { path = "../core", version = "0.2.3" } [lints] workspace = true + +[features] +# M169:转发 core 的测试通道(默认关)。GUI 侧「更新」这条链的**人工验收**需要它: +# `cargo run -p unlucky-app-iced --features update-test-source` 配 +# `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 才能让设置页指向本地更新源 +# (`-p` 认 package 名 `unlucky-app-iced`;`unlucky-launcher-iced` 是 bin 名,写 `-p` 会找不到包)。 +# 官方发布构建不带这个 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..3c3959e 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -65,6 +65,8 @@ 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`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 | +| `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤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 +109,35 @@ 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`,两步: + `--self-test` + 正式扫描)扫 `.github/workflows/**`(含复合 action)、`packaging/**` 的递归脚本、 + `Makefile`/`justfile`/`Taskfile` 与各 `Cargo.toml` 的 `default`。**唯一允许**出现该 feature 的地方是 + `.github/workflows/ci.yml` 里名为 `update-e2e` 的那一步,且该步必须带 `--test update_e2e_test` + (文件名 / 步骤名 / 参数任一不符即红,见脚本的 `ALLOWED_STEP`)。 + 归属按 **`jobs.*.steps[]` 的列表项**判(不是「最近一行 `- name:`」):落不到具名步骤上的行——`jobs:` + 之前的孤儿行、项外键、没有 `name:` 的步骤、flow 风格步骤——**一律判红**(默认拒绝)。自测向量在 + `packaging/tests/guard_vectors.json`(32 条向量:26 条注入必须红 + 6 条合法写法必须绿,另加基线必须绿; + 条数以 `--self-test` 输出为准)。`.cargo/config*.toml` 也在扫描面里:`--cfg feature="…"` 与 + `rustc-wrapper` 都能绕过 `--features`(后者一律判红,合法用途要 review 明确同意)。 + 抓不住(如实记):变量间接拼接(`FEAT="update-""test-source"`)、**两步 `$GITHUB_ENV` 交接** + (第一步把 feature 写进 env、第二步只用 `--features "$FEAT"`——第二步没有字面量,任何静态扫描都 + 看不见,这是本守门的固有边界,不是漏判)、feature 改名后接回发布、`build.rs` 里 spawn cargo、以及 + 「把别的步骤**改名**成 `update-e2e` 并带 `--test update_e2e_test` 却干别的事」。 +- 验收本体:`cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test + --test update_e2e_tamper_test`(正路 + 幂等 / 篡改摘要 + 篡改包体 + 截断)。CI 里同一个命令挂在 + `ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` + + `portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。 + **不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。 diff --git a/packaging/check_test_source_off.py b/packaging/check_test_source_off.py new file mode 100644 index 0000000..d62bb17 --- /dev/null +++ b/packaging/check_test_source_off.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +"""测试通道守门(M169,r3:allowlist 改成**结构判定 + 默认拒绝**)。 + +`update-test-source` 让 `core::update::job::default_source` 可被两个环境变量指向任意本地目录,而更新 +链路终点是**替换用户盘上的 exe**。「默认关 + 编译期不存在」是唯一可靠边界,所以「谁也没在流水线里 +把它打开」必须是自动断言。 + +四条断言(任一不成立即 exit 1,CI 里红在 `.github/workflows/release-guard.yml`): + +1. **workflow / 复合 action 里不许打开它**。唯一例外:`.github/workflows/ci.yml` 里**名为 + `update-e2e` 的那个列表项**,且该项自己的文本必须带 `--test update_e2e_test`。归属按 + `jobs.*.steps[]` 的**列表项**判(见 `split_items`),不是「最近一行 `- name:`」——后者会把追加到 + 文件末尾的无名步骤 / 无名 job 的步骤 / flow 风格步骤算进上一块(r2 复审 P2-1 实测三条全绿)。 + **任何落不到具名步骤上的行(`jobs:` 之前的孤儿行、项外键、无名项)一律判红**:默认拒绝。 +2. **不许「打开所有 feature」**:`--all-features`、`cargo hack --feature-powerset|--each-feature|--all`。 +3. **不许设成默认**:`Cargo.toml` 里它只许是**自己的声明**(含跨行数组);进 `default` 或别的 + feature 的依赖数组即红。 +4. **扫描面逐项列举**(`WORKFLOW_GLOBS` / `SCRIPT_GLOBS` / `BUILD_ENTRY_GLOBS` / `CONFIG_GLOBS`): + 含 `.github/actions/**`、`packaging/**` 递归子目录、`Makefile`/`justfile`/`Taskfile`、以及 + `.cargo/config*.toml`(`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`,见 + `scan_cargo_config`)。 + +注释剥离是**引号感知**的(`echo "#"; cargo build --features …` 里那个 `#` 不是注释)。抓不住的都是 +**故意手打**或**运行期数据流**(完整清单见 `packaging/README.md` §14):变量拼接、两步 `$GITHUB_ENV` +交接(第二步没有字面量——静态扫描的固有边界)、feature 改名后接回、`build.rs` 里 spawn cargo、改名复用。 + +`--self-test` 把每条断言的**真实回归**注入临时副本(数据在 `packaging/tests/guard_vectors.json`), +断言该红的红、该绿的绿。守门自己抓不住回归时它会先红。 +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tempfile +from pathlib import Path + +#: 被守的 feature 名(改名就得同步改本文件与各 `Cargo.toml`)。 +FEATURE = "update-test-source" +#: 仓库根(本文件在 `packaging/` 下)与本文件自身(扫描时跳过:它必须写出 feature 名)。 +ROOT = Path(__file__).resolve().parent.parent +GUARD = Path(__file__).resolve() +#: 扫描面(逐个列举,不用 `**` 全仓:那会把 `target/` 拖进来)。 +WORKFLOW_GLOBS = ( + ".github/workflows/**/*.yml", + ".github/workflows/**/*.yaml", + ".github/actions/**/*.yml", + ".github/actions/**/*.yaml", +) +SCRIPT_GLOBS = ("packaging/**/*.py", "packaging/**/*.sh", "packaging/**/*.ps1") +BUILD_ENTRY_GLOBS = ( + "Makefile", + "justfile", + "Justfile", + "Taskfile.yml", + "Taskfile.yaml", + "crates/*/Makefile", + "packaging/Makefile", +) +MANIFEST_GLOBS = ("Cargo.toml", "crates/*/Cargo.toml") +#: cargo 配置:`--cfg feature="…"` 与 `rustc-wrapper` 都能在没有 `--features` 时打开这条通道(P2-2)。 +CONFIG_GLOBS = ( + ".cargo/config.toml", + ".cargo/config", + "crates/*/.cargo/config.toml", + "crates/*/.cargo/config", +) +#: 任意 wrapper 都能注入 `--cfg`,文本扫描看不见 ⇒ 一律判红。 +WRAPPER_KEY = re.compile(r"^\s*(rustc|rustc-workspace)-wrapper\s*=") +#: 唯一允许出现该 feature 的地方:**文件 + 列表项名 + 必须同在的字符串**,三者不符即红。 +ALLOWED_STEP = { + "workflow": ".github/workflows/ci.yml", + "name": "update-e2e", + "must_contain": "--test update_e2e_test", +} +#: 「打开全部 feature」的开关。`--all` 后面不许跟 `-`/字母(否则 `--all-targets` 会被误杀)。 +ALL_FEATURES = ( + re.compile(r"--all-features\b"), + re.compile(r"--feature-powerset\b"), + re.compile(r"--each-feature\b"), + re.compile(r"\bcargo\s+hack\b[^\n]*\s--all(?![-\w])"), +) +#: YAML 列表项(任意缩进);名字只认键位置上的 `name:`(块风格 / flow 风格)。 +LIST_ITEM = re.compile(r"^(\s*)-\s+") +NAME_BLOCK = re.compile(r"^['\"]?name['\"]?\s*:\s*(.+)$") +NAME_FLOW = re.compile(r"^\{[^{}]*?\bname\s*:\s*([^,}]+)") +#: Cargo.toml 里「自己的声明」:行首 `update-test-source = [`。 +DECLARATION = re.compile(rf"^\s*{re.escape(FEATURE)}\s*=\s*\[") +#: 块标量开头(`run: |` / `run: >-`):其后的更深缩进行是**内容**,不是键。 +BLOCK_SCALAR = ("|", ">", "|-", ">-", "|+", ">+") + + +def strip_comment(line: str) -> str: + """引号感知地剥注释:`#` 只有「行首或空白之后」且不在引号内时才开始注释。""" + quote = None + for index, char in enumerate(line): + if quote is not None: + if char == quote: + quote = None + elif char in "\"'": + quote = char + elif char == "#" and (index == 0 or line[index - 1].isspace()): + return line[:index] + return line + + +def dangerous_flag(code: str) -> str | None: + """这一行有没有「打开全部 feature」的开关(返回命中的开关,便于写清原因)。""" + for pattern in ALL_FEATURES: + match = pattern.search(code) + if match: + return match.group(0) + return None + + +def _lines(path: Path) -> list[tuple[int, str]]: + return list(enumerate(path.read_text(encoding="utf-8").splitlines(), start=1)) + + +def _name_of(key_indent: int, body: list[tuple[int, str]]) -> str | None: + """列表项的名字:项内**同级**的 `name:` 键(键序无关;块标量内容不当键)。 + + 「同级」= 项首行 `- ` 之后那一列(`item.end() == key_indent`,r3 复审 P2-1):更深缩进的 + `- name:`(`with:` / `env:` 子列表、`run: |` 内容)**不算**项名。找不到、或出现两个**不同**的 + `name:` 都返回 `None` ⇒ 落不到具名步骤 ⇒ 带 feature 就红(默认拒绝)。 + """ + in_block = False + found: str | None = None + for _number, line in body: + text = line.strip() + if not text or text.startswith("#"): + continue + indent = len(line) - len(line.lstrip(" ")) + item = LIST_ITEM.match(line) + if in_block and indent > key_indent: + continue + in_block = False + if item and item.end() == key_indent: + code = strip_comment(line[item.end() :]) # 项首行:` - name: x` → `name: x` + elif not item and indent == key_indent: + code = strip_comment(text) + else: + continue + for pattern in (NAME_BLOCK, NAME_FLOW): + match = pattern.match(code.strip()) + if match: + value = match.group(1).strip().rstrip("}").strip("'\"") + if found is not None and found != value: + return None + found = value + break + if code.rstrip().endswith(BLOCK_SCALAR): + in_block = True + return found + + +def split_items( + lines: list[tuple[int, str]], +) -> tuple[list[tuple[str | None, list[tuple[int, str]]]], list[tuple[int, str]]]: + """按 YAML **列表项**切块,返回((项名, 该项的行), 孤儿行)。 + + 每个 `- ` 起一项;更深的行归当前项,**同级或更浅的非列表行结束当前项**(job 键 / `steps:` / + 顶层键)。落不到列表项上的行是孤儿——不属于任何步骤,带 feature 就红。 + """ + items: list[tuple[str | None, list[tuple[int, str]]]] = [] + orphans: list[tuple[int, str]] = [] + body: list[tuple[int, str]] = [] + base, key_indent = 0, 0 + for number, line in lines: + text = line.strip() + if not text or text.startswith("#"): + (body if body else orphans).append((number, line)) + continue + indent = len(line) - len(line.lstrip(" ")) + item = LIST_ITEM.match(line) + if item and (not body or indent <= base): + if body: + items.append((_name_of(key_indent, body), body)) + body, base, key_indent = [(number, line)], indent, item.end() + continue + if body and indent > base: + body.append((number, line)) + continue + if body: + items.append((_name_of(key_indent, body), body)) + body = [] + orphans.append((number, line)) + if body: + items.append((_name_of(key_indent, body), body)) + return items, orphans + + +def _reason(where: str, name: str | None, text: str) -> str | None: + """`None` = 这一项被放行;否则返回「为什么红」的一句话(按最具体的那条给)。""" + if where != ALLOWED_STEP["workflow"]: + return f"`{FEATURE}` 只许出现在 `{ALLOWED_STEP['workflow']}` 的 `{ALLOWED_STEP['name']}` 步骤里(这里是 `{where}`)" + if name is None: + return f"这一项没有同级 `name:`,落不到具名步骤(默认拒绝);`{where}` 里只有 `{ALLOWED_STEP['name']}` 可以用它" + if name != ALLOWED_STEP["name"]: + return f"`{where}` 里只有名为 `{ALLOWED_STEP['name']}` 的步骤可以用 `{FEATURE}`(这一项叫 `{name}`)" + if ALLOWED_STEP["must_contain"] not in text: + return f"`{where}` 的 `{name}` 步骤少了 `{ALLOWED_STEP['must_contain']}`" + return None + + +def _flag_problems(where: str, body: list[tuple[int, str]]) -> list[str]: + problems: list[str] = [] + for number, line in body: + flag = dangerous_flag(strip_comment(line)) + if flag: + problems.append( + f"::error file={where}:{number}::{where} 用了 `{flag}`:它会把 `{FEATURE}` 一起打开" + ) + return problems + + +def scan_workflow(path: Path, root: Path) -> list[str]: + """扫 workflow / 复合 action:feature 只许出现在 allowlist 点名的那一项里。""" + where = path.relative_to(root).as_posix() + items, orphans = split_items(_lines(path)) + problems = _flag_problems(where, orphans) + for number, line in orphans: + if FEATURE in strip_comment(line): + problems.append( + f"::error file={where}:{number}::{where} 这一行落不到任何具名步骤(默认拒绝);" + "官方发布产物里不许有测试通道" + ) + for name, body in items: + problems += _flag_problems(where, body) + codes = [(number, strip_comment(line)) for number, line in body] + hits = [(number, code) for number, code in codes if FEATURE in code] + if not hits: + continue + reason = _reason(where, name, "\n".join(code for _number, code in codes)) + if reason is None: + continue + for number, _code in hits: + problems.append( + f"::error file={where}:{number}::{reason};" + "官方发布产物里不许有测试通道(它能把更新源指向任意本地目录)" + ) + return problems + + +def scan_code_file(path: Path, root: Path) -> list[str]: + """扫脚本 / 构建入口 / cargo config:没有 allowlist,字面量或开关出现即红。""" + where = path.relative_to(root).as_posix() + problems: list[str] = [] + for number, line in _lines(path): + code = strip_comment(line) + if not code: + continue + if FEATURE in code: + problems.append( + f"::error file={where}:{number}::{where} 打开了 `{FEATURE}`:" + "官方发布产物里不许有测试通道(它能把更新源指向任意本地目录)" + ) + flag = dangerous_flag(code) + if flag: + problems.append( + f"::error file={where}:{number}::{where} 用了 `{flag}`:它会把 `{FEATURE}` 一起打开" + ) + return problems + + +def scan_manifest(path: Path, root: Path) -> list[str]: + """扫 Cargo.toml:feature 只许出现在自己的声明里(**含跨行数组**),不许进别的列表。""" + where = path.relative_to(root).as_posix() + problems: list[str] = [] + in_declaration = False + for number, line in _lines(path): + code = strip_comment(line).strip() + if in_declaration: + in_declaration = "]" not in code + continue + if FEATURE not in code: + continue + if DECLARATION.match(code): + in_declaration = "]" not in code + continue + problems.append( + f"::error file={where}:{number}::{where} 把 `{FEATURE}` 写进了别的 feature 列表:" + "只许它是自己的声明(可跨行),不许进 `default` 或别的 feature" + ) + return problems + + +def scan_cargo_config(path: Path, root: Path) -> list[str]: + """扫 `.cargo/config*.toml`:`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`。 + + r3 复审 P2-2 实测:`RUSTFLAGS='--cfg=feature="update-test-source"'` 能让 `#[cfg(feature = …)]` + 段在没有 `--features` 时编进来。`[build] rustflags` / `[env]` 靠 feature 字面量抓(走 + `scan_code_file`);`rustc-wrapper` 抓不住(注入什么由程序决定)⇒ 一律判红,合法用途要 review 同意。 + """ + problems = scan_code_file(path, root) + where = path.relative_to(root).as_posix() + for number, line in _lines(path): + if WRAPPER_KEY.match(strip_comment(line)): + problems.append( + f"::error file={where}:{number}::{where} 设了 rustc wrapper:wrapper 能注入任意 " + f"`--cfg`,静态扫描看不见它是否打开了 `{FEATURE}`(默认拒绝)" + ) + return problems + + +def scan(root: Path) -> list[str]: + """扫整个仓库,返回所有问题(空 = 绿)。""" + problems: list[str] = [] + for patterns, scanner in ( + (WORKFLOW_GLOBS, scan_workflow), + (SCRIPT_GLOBS, scan_code_file), + (BUILD_ENTRY_GLOBS, scan_code_file), + (CONFIG_GLOBS, scan_cargo_config), + (MANIFEST_GLOBS, scan_manifest), + ): + for path in sorted({p for pattern in patterns for p in root.glob(pattern)}): + if scanner is scan_code_file and path.resolve() == GUARD: + continue + try: + problems += scanner(path, root) + except OSError as error: + problems.append(f"::error file={path}::读不到 {path}:{error}") + return problems + + +# ────────────────────────── 变异自测 ────────────────────────── + +#: 自测数据(基线 + 向量)单独放一份 JSON:`packaging/tests/manifest.golden.json` 是同一手法。 +#: 每个向量 = 一份「覆盖/新增的文件」;期望红的是真实回归,期望绿的是防误杀(合法写法不许红)。 +#: 条目标签自带出处(`allowlist:` / `P2-1` / `P2-2` / `误杀` / `Cargo`),红的时候一眼看得出守哪条。 +VECTORS_FILE = Path(__file__).resolve().parent / "tests" / "guard_vectors.json" + + +def _load_vectors() -> tuple[dict[str, str], list[tuple[str, dict[str, str], str]]]: + data = json.loads(VECTORS_FILE.read_text(encoding="utf-8")) + return data["baseline"], [ + (item["label"], item["files"], item["expect"]) for item in data["vectors"] + ] + + +def _scan_case(files: dict[str, str]) -> list[str]: + with tempfile.TemporaryDirectory(prefix="ull-test-source-guard-") as temp: + root = Path(temp) + for relative, text in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return scan(root) + + +def self_test() -> int: + try: + base_files, vectors = _load_vectors() + except (OSError, ValueError, KeyError) as error: + print(f"::error::读不到自测向量 {VECTORS_FILE}:{error}", file=sys.stderr) + return 1 + failures: list[str] = [] + baseline = _scan_case(base_files) + if baseline: + failures.append(f"基线应当绿,实际红了:{baseline}") + for label, changes, expect in vectors: + files = dict(base_files) + files.update(changes) + hits = _scan_case(files) + if expect == "red" and not hits: + failures.append(f"{label}:注入了真实回归,守门却没红(抓不住)") + if expect == "green" and hits: + failures.append(f"{label}:应当放行,实际红了(误杀):{hits}") + if failures: + for failure in failures: + print(f"::error::{failure}", file=sys.stderr) + return 1 + reds = sum(1 for _label, _changes, expect in vectors if expect == "red") + print(f"OK: 守门自测通过({reds} 条注入全红,{len(vectors) - reds + 1} 条基线/放行保持绿)") + 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()) diff --git a/packaging/tests/guard_vectors.json b/packaging/tests/guard_vectors.json new file mode 100644 index 0000000..4233773 --- /dev/null +++ b/packaging/tests/guard_vectors.json @@ -0,0 +1,236 @@ +{ + "baseline": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n", + ".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo build --release\n", + ".github/workflows/release-publish.yml": "jobs:\n update-e2e:\n steps:\n - run: echo publish\n", + "packaging/make_artifacts.py": "def build():\n return 'cargo build --release'\n", + "packaging/tests/build.sh": "#!/bin/sh\ncargo build --release\n", + "Makefile": "build:\n\tcargo build --release\n", + "Cargo.toml": "[workspace]\nmembers = []\n", + "crates/core/Cargo.toml": "[features]\nupdate-test-source = []\n# 注释里提到它不算违规\n" + }, + "vectors": [ + { + "label": "allowlist:ci.yml 的 update-e2e 一步", + "expect": "green", + "files": {} + }, + { + "label": "allowlist:同一步放进 release-build.yml", + "expect": "red", + "files": { + ".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n" + } + }, + { + "label": "allowlist:步骤名改成 update-e2e-r2", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e-r2\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n" + } + }, + { + "label": "allowlist:少了 --test update_e2e_test", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_tamper_test\n" + } + }, + { + "label": "allowlist:发布工作流里另起一步", + "expect": "red", + "files": { + ".github/workflows/release-publish.yml": "jobs:\n update-e2e:\n steps:\n - name: leak\n - run: cargo build --release --features update-test-source\n" + } + }, + { + "label": "allowlist:ci.yml 里另一个具名步骤", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - name: another\n - run: cargo build --release --features update-test-source\n" + } + }, + { + "label": "P2-1 ①:末尾追加无名步骤", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: cargo build --release --features update-test-source\n" + } + }, + { + "label": "P2-1 ②:末尾追加无名步骤的整个 job", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n release-from-ci:\n steps:\n - run: cargo build --release --features update-test-source\n" + } + }, + { + "label": "P2-1 ③:flow 风格步骤", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - {name: release-build, run: \"cargo build --features update-test-source\"}\n" + } + }, + { + "label": "P2-1 ④:步骤写在 jobs: 之前(孤儿行)", + "expect": "red", + "files": { + ".github/workflows/ci.yml": " - run: cargo build --release --features update-test-source\njobs:\n update-e2e:\n steps:\n - run: echo hi\n" + } + }, + { + "label": "P2-1 ⑤:`run: |` 块里伪造 `name: update-e2e`", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - run: |\n echo 'name: update-e2e'\n cargo build --release --features update-test-source\n" + } + }, + { + "label": "P2-2 ①:`#` 在引号内、真命令在后面", + "expect": "red", + "files": { + ".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: echo \"#\"; cargo build --features update-test-source\n" + } + }, + { + "label": "P2-2 ②:shell 变量拼接(字面量仍在)", + "expect": "red", + "files": { + ".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: FEAT=\"update-test-source\"; cargo build --features \"$FEAT\"\n" + } + }, + { + "label": "Cargo ①:default 把 feature 带上", + "expect": "red", + "files": { + "crates/core/Cargo.toml": "[features]\nupdate-test-source = []\n# 注释里提到它不算违规\ndefault = [\"update-test-source\"]\n" + } + }, + { + "label": "Cargo ②:别的 feature 依赖它", + "expect": "red", + "files": { + "crates/core/Cargo.toml": "[features]\nupdate-test-source = []\n# 注释里提到它不算违规\nrelease = [\"update-test-source\"]\n" + } + }, + { + "label": "误杀 ①:注释里提到该 feature", + "expect": "green", + "files": { + ".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n # 这里不许加 update-test-source\n - run: cargo build --release\n" + } + }, + { + "label": "误杀 ②:允许的那一步把 `run:` 写在 `name:` 之前(键序无关)", + "expect": "green", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo test --release --features update-test-source --test update_e2e_test\n name: update-e2e\n" + } + }, + { + "label": "误杀 ③:`cargo hack check --all-targets`(不打开任何 feature)", + "expect": "green", + "files": { + ".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack check --all-targets\n" + } + }, + { + "label": "误杀 ④:Cargo 多行 feature 声明", + "expect": "green", + "files": { + "crates/core/Cargo.toml": "[features]\nupdate-test-source = [\n \"unlucky-core/update-test-source\",\n \"unlucky-cli/update-test-source\",\n]\n" + } + }, + { + "label": "P2-2:cargo hack --feature-powerset", + "expect": "red", + "files": { + ".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack --feature-powerset build\n" + } + }, + { + "label": "P2-2:cargo hack --each-feature", + "expect": "red", + "files": { + ".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack --each-feature build\n" + } + }, + { + "label": "P2-2:cargo hack --all", + "expect": "red", + "files": { + ".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack --all build\n" + } + }, + { + "label": "P2-2 扫描面:packaging/tests/build.sh", + "expect": "red", + "files": { + "packaging/tests/build.sh": "#!/bin/sh\ncargo build --features update-test-source\n" + } + }, + { + "label": "P2-2 扫描面:.github/actions/rebuild/action.yml", + "expect": "red", + "files": { + ".github/actions/rebuild/action.yml": "runs:\n using: composite\n steps:\n - run: cargo build --features update-test-source\n shell: bash\n" + } + }, + { + "label": "P2-2 扫描面:.github/workflows/nested/extra.yml", + "expect": "red", + "files": { + ".github/workflows/nested/extra.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo build --release --features update-test-source\n" + } + }, + { + "label": "P2-2 扫描面:Makefile", + "expect": "red", + "files": { + "Makefile": "build:\n\tcargo build --release --features update-test-source\n" + } + }, + { + "label": "P2-1 ⑥:`name:` 写在更深缩进的 env 子项里", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: cargo build --release -p unlucky-cli --features update-test-source\n env:\n T: --test update_e2e_test\n HINT: |\n - name: update-e2e\n" + } + }, + { + "label": "P2-1 ⑦:`name:` 写在 `run: |` 块标量里", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: |\n name: update-e2e\n cargo build --release --features update-test-source --test update_e2e_test\n" + } + }, + { + "label": "P2-1 ⑧:同一项里两个不同的 `name:`", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n run: cargo build --release --features update-test-source --test update_e2e_test\n name: release-build\n" + } + }, + { + "label": "P2-2 ①:.cargo/config.toml 用 `--cfg feature=` 开该 feature", + "expect": "red", + "files": { + ".cargo/config.toml": "[build]\nrustflags = [\"--cfg\", 'feature=\"update-test-source\"']\n" + } + }, + { + "label": "P2-2 ②:.cargo/config.toml 设 rustc-wrapper", + "expect": "red", + "files": { + ".cargo/config.toml": "[build]\nrustc-wrapper = \"./tools/wrapper\"\n" + } + }, + { + "label": "误杀 ⑤:普通 .cargo/config.toml(无 feature / 无 wrapper)", + "expect": "green", + "files": { + ".cargo/config.toml": "[build]\njobs = 4\n" + } + } + ] +}