Merge branch 'feat/m169-updatee2e-update-channel-end-to-end'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
ab79a28b28
20 files changed
+1652
-44
No files matched your search
@@ -288,3 +288,30 @@ jobs:
|
|||||||
name: runtime-smoke-screenshot-macos
|
name: runtime-smoke-screenshot-macos
|
||||||
path: ${{ runner.temp }}/smoke/
|
path: ${{ runner.temp }}/smoke/
|
||||||
if-no-files-found: warn
|
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
|
||||||
@@ -22,6 +22,16 @@ name: Release guard
|
|||||||
# 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志;
|
# 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志;
|
||||||
# `--self-test` 再注入 8 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明)证明它会红。
|
# `--self-test` 再注入 8 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明)证明它会红。
|
||||||
# 发布侧那道「产物 ⇄ 名单」的运行时断言在 `release-publish.yml`(同一份脚本,`--dist`)。
|
# 发布侧那道「产物 ⇄ 名单」的运行时断言在 `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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -48,3 +58,12 @@ jobs:
|
|||||||
run: python3 packaging/check_publish_slots.py --self-test
|
run: python3 packaging/check_publish_slots.py --self-test
|
||||||
- name: 发布名单与矩阵槽位一致(拼错字 / 声明了 probe 档 当场红)
|
- name: 发布名单与矩阵槽位一致(拼错字 / 声明了 probe 档 当场红)
|
||||||
run: python3 packaging/check_publish_slots.py
|
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
|
||||||
@@ -90,6 +90,13 @@ iced = { version = "=0.14.0", default-features = false, features = [
|
|||||||
default = ["gpu"]
|
default = ["gpu"]
|
||||||
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
|
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
|
||||||
gpu = ["iced/wgpu"]
|
gpu = ["iced/wgpu"]
|
||||||
|
# M169:把「本地更新源」这条测试通道编进 GUI(默认关)。人工验收更新链路时的构建方式:
|
||||||
|
# ULL_UPDATE_DIR=<root> 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]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -33,3 +33,8 @@ serde_json.workspace = true
|
|||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# M169:转发 core 的测试通道(`--features update-test-source` 才编进来,默认关)。
|
||||||
|
# `ull update check/apply` 的端到端验收要靠它指向本地目录源。
|
||||||
|
update-test-source = ["unlucky-core/update-test-source"]
|
||||||
@@ -128,8 +128,10 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
|||||||
缺省替换本进程的 exe:**下次启动生效**(正在跑的这一份仍是旧版本)。
|
缺省替换本进程的 exe:**下次启动生效**(正在跑的这一份仍是旧版本)。
|
||||||
--dest 钉到别的路径(取证 / 自定义安装位置)。不写 = 本进程 exe。
|
--dest 钉到别的路径(取证 / 自定义安装位置)。不写 = 本进程 exe。
|
||||||
|
|
||||||
环境变量 ULL_UPDATE_DIR=<dir>:改走本地目录源(`<dir>/stable.json` + 产物),
|
环境变量 ULL_UPDATE_DIR=<dir> + ULL_UPDATE_TEST_SOURCE=1:改走本地目录源(`<dir>/stable.json`
|
||||||
用于内网手工分发与「发现新版本」这条分支的整环取证;不设就走 GitHub。
|
+ 产物),用于内网手工分发与更新链路的整环取证。**两者都只在带 `update-test-source` feature
|
||||||
|
编出来的验收构建里生效**(`cargo build --features update-test-source`);官方发布构建不含这条
|
||||||
|
路径,设了环境变量也照旧走 GitHub。
|
||||||
--json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。",
|
--json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。",
|
||||||
),
|
),
|
||||||
"install" => Some(
|
"install" => Some(
|
||||||
|
|||||||
@@ -16,6 +16,22 @@
|
|||||||
//! 同样不许说成「已是最新」。
|
//! 同样不许说成「已是最新」。
|
||||||
//!
|
//!
|
||||||
//! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。
|
//! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。
|
||||||
|
//!
|
||||||
|
//! # 测试通道(M169)
|
||||||
|
//!
|
||||||
|
//! 想在没有稳定 Release 的情况下整环验一遍,得用**专门编出来的验收二进制**:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo build --release --features update-test-source # 官方发布构建不带这个 feature
|
||||||
|
//! ULL_UPDATE_DIR=<root> ULL_UPDATE_TEST_SOURCE=1 ./ull update check
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 三档全给才生效(feature 默认关 = 官方二进制里没有这条路径;带 feature 时还要两档环境变量,
|
||||||
|
//! 判据见 `core::update::job`)。这时 `--json` 的 `source` 是 `file:<root>`、`test_source` 是
|
||||||
|
//! `true`,人读输出另有一行点名。它不是第二个更新源:没有设置键、也没有命令行参数。
|
||||||
|
//!
|
||||||
|
//! ⚠ **验收时必须给 `--dest`**:不给就是替换本进程的 exe。自动化用例里的「本进程」是测试二进制,
|
||||||
|
//! 去掉 `--dest` 会把它真改名换掉(`crates/cli/tests/update_e2e_test.rs` 的注释写了这件事)。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -77,6 +93,21 @@ fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
|
|||||||
.with_hint(hint)
|
.with_hint(hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 这个源是不是测试通道(判据派生自**实际用了哪个源**,不是回读环境变量:
|
||||||
|
/// `DirSource` 的 id 是 `file:<root>`,GitHub 源是 `github:<owner>/<repo>`)。
|
||||||
|
fn is_test_source(source_id: &str) -> bool {
|
||||||
|
source_id.starts_with("file:")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试通道的点名行(只在真走了本地目录源时出现)。文案里点明**这是验收构建**:官方发布二进制
|
||||||
|
/// 没有这条路径(`update-test-source` 默认关),所以出现这一行就说明手上这份不是官方产物。
|
||||||
|
fn test_channel_note(source: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"测试通道生效:源 = {source}(本地目录,不是 GitHub Releases;本二进制带 \
|
||||||
|
`update-test-source` feature 编出来,官方发布构建不含这条路径)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// `ull update check`:只查、不动任何文件。
|
/// `ull update check`:只查、不动任何文件。
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -109,6 +140,8 @@ pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
|
|||||||
.backup
|
.backup
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|path| path.display().to_string());
|
.map(|path| path.display().to_string());
|
||||||
|
let source = job.source_id();
|
||||||
|
let test_source = is_test_source(source);
|
||||||
let mut human = vec![
|
let mut human = vec![
|
||||||
format!("已下载并校验 v{version}({} 字节)", receipt.bytes),
|
format!("已下载并校验 v{version}({} 字节)", receipt.bytes),
|
||||||
format!("已替换:{dest}"),
|
format!("已替换:{dest}"),
|
||||||
@@ -118,10 +151,15 @@ pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
|
|||||||
None => "原本没有旧文件(首次落盘)".to_string(),
|
None => "原本没有旧文件(首次落盘)".to_string(),
|
||||||
});
|
});
|
||||||
human.push("下次启动生效(正在运行的进程仍是旧版本)。".to_string());
|
human.push("下次启动生效(正在运行的进程仍是旧版本)。".to_string());
|
||||||
|
if test_source {
|
||||||
|
human.push(test_channel_note(source));
|
||||||
|
}
|
||||||
Ok(Outcome::new(Report::new(
|
Ok(Outcome::new(Report::new(
|
||||||
json!({
|
json!({
|
||||||
"current": current.to_string(),
|
"current": current.to_string(),
|
||||||
"channel": CHANNEL.as_str(),
|
"channel": CHANNEL.as_str(),
|
||||||
|
"source": source,
|
||||||
|
"test_source": test_source,
|
||||||
"status": "installed",
|
"status": "installed",
|
||||||
"version": version,
|
"version": version,
|
||||||
"path": dest,
|
"path": dest,
|
||||||
@@ -147,16 +185,21 @@ pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 把检查结论排成「人读几行 + `--json` 一份」。
|
/// 把检查结论排成「人读几行 + `--json` 一份」。
|
||||||
|
///
|
||||||
|
/// `test_source` 逐条写进 JSON(不只在高亮时写):脚本据此判断「这一份结论是不是本地夹具给的」,
|
||||||
|
/// 而判据来自 `source`(实际用了哪个源),所以不会出现「环境变量说关了、输出说开着」。
|
||||||
fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report {
|
fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report {
|
||||||
|
let test_source = is_test_source(source);
|
||||||
let base = |status: &str| {
|
let base = |status: &str| {
|
||||||
json!({
|
json!({
|
||||||
"current": current.to_string(),
|
"current": current.to_string(),
|
||||||
"channel": CHANNEL.as_str(),
|
"channel": CHANNEL.as_str(),
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"test_source": test_source,
|
||||||
"status": status,
|
"status": status,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
match outcome {
|
let mut report = match outcome {
|
||||||
CheckOutcome::UpToDate { latest, .. } => {
|
CheckOutcome::UpToDate { latest, .. } => {
|
||||||
let latest = latest.as_ref().map(ToString::to_string);
|
let latest = latest.as_ref().map(ToString::to_string);
|
||||||
let mut data = base("up_to_date");
|
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(),
|
"current": current.to_string(),
|
||||||
"channel": CHANNEL.as_str(),
|
"channel": CHANNEL.as_str(),
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"test_source": test_source,
|
||||||
"status": "available",
|
"status": "available",
|
||||||
"version": version,
|
"version": version,
|
||||||
"size": candidate.artifact.size,
|
"size": candidate.artifact.size,
|
||||||
@@ -204,6 +248,7 @@ fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report
|
|||||||
"current": current.to_string(),
|
"current": current.to_string(),
|
||||||
"channel": CHANNEL.as_str(),
|
"channel": CHANNEL.as_str(),
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"test_source": test_source,
|
||||||
"status": "missing_artifact",
|
"status": "missing_artifact",
|
||||||
"version": version,
|
"version": version,
|
||||||
"target": unlucky_core::update::Target::current().as_str(),
|
"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
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
//! 每个测试目标都会整份编译本文件但只用其中一部分,因此这里放开 dead_code。
|
//! 每个测试目标都会整份编译本文件但只用其中一部分,因此这里放开 dead_code。
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod update_e2e;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|||||||
@@ -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_<ver>_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_<ver>_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<std::ffi::OsString>,
|
||||||
|
ack: Option<std::ffi::OsString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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
|
||||||
|
/// <temp>/<tag>-data/ --data-dir(自定义模式 ⇒ <data>/.ULL 与 <data>/.minecraft)
|
||||||
|
/// <temp>/<tag>-dist/ ULL_UPDATE_DIR(stable.json + 产物)
|
||||||
|
/// <temp>/<tag>-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<u8>,
|
||||||
|
/// 清单里的版本号(= 编译期版本次版本 +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<u8> {
|
||||||
|
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=<path>` 形式),不是夹具字段: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<String> {
|
||||||
|
list_dir(&self.staging())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装目录里的备份(`.unlucky-launcher.exe.ullold-*`)。
|
||||||
|
pub fn backups(&self) -> Vec<String> {
|
||||||
|
list_dir(self.install.path())
|
||||||
|
.into_iter()
|
||||||
|
.filter(|name| name.contains(".ullold-"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目标文件当前字节。
|
||||||
|
pub fn installed_bytes(&self) -> Vec<u8> {
|
||||||
|
std::fs::read(&self.dest).expect("读目标")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,3 +72,10 @@ path = "tests/download/partfile_test.rs"
|
|||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
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 = []
|
||||||
@@ -25,8 +25,10 @@
|
|||||||
//!
|
//!
|
||||||
//! # 换源
|
//! # 换源
|
||||||
//!
|
//!
|
||||||
//! 生产源是 D50 定死的 GitHub Releases([`default_source`]);`ULL_UPDATE_DIR` 指向本地目录时
|
//! 生产源是 D50 定死的 GitHub Releases([`default_source`]);**测试通道**(带 `update-test-source`
|
||||||
//! 改走 [`super::source::DirSource`]——那是取证/内网手工分发的口子,**不是**第二个更新源。
|
//! feature 编出来的二进制 + [`UPDATE_DIR_ENV`] + [`UPDATE_TEST_SOURCE_ENV`] 三档全给)时改走
|
||||||
|
//! [`super::source::DirSource`]——那是取证/内网手工分发的口子,**不是**第二个更新源。判据、隔离
|
||||||
|
//! 手段与残余风险见 [`test_source_root`]。
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -39,18 +41,29 @@ use super::error::UpdateError;
|
|||||||
use super::github::GitHubReleaseSource;
|
use super::github::GitHubReleaseSource;
|
||||||
use super::install::{install_staged, InstallReceipt, ReplacePolicy};
|
use super::install::{install_staged, InstallReceipt, ReplacePolicy};
|
||||||
use super::manifest::Artifact;
|
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::target::{PackageFormat, Target};
|
||||||
use super::verify::stage;
|
use super::verify::stage;
|
||||||
use super::version::Version;
|
use super::version::Version;
|
||||||
|
|
||||||
/// 本机更新源覆盖(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`<root>/stable.json`)。
|
/// 测试通道的源根(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`<root>/stable.json`)。
|
||||||
///
|
///
|
||||||
/// **只在调试构建里生效**(判据与理由见 [`local_dir_override`]):发布构建忽略它,
|
/// 只在**带 `update-test-source` feature** 编出来的二进制里生效,且**单独设它没有用**:还要
|
||||||
/// 真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支在没有稳定
|
/// [`UPDATE_TEST_SOURCE_ENV`](判据见 `test_source_root`)。默认构建里这两个变量纯粹是死字符串。
|
||||||
/// Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。
|
|
||||||
pub const UPDATE_DIR_ENV: &str = "ULL_UPDATE_DIR";
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum Applied {
|
pub enum Applied {
|
||||||
@@ -242,41 +255,75 @@ pub fn staging_dir(cache_root: &Path) -> PathBuf {
|
|||||||
cache_root.join("update").join("staging")
|
cache_root.join("update").join("staging")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生产源:GitHub Releases(D50);**只在调试构建里**,`ULL_UPDATE_DIR` 设了就走本地目录源。
|
/// 生产源:GitHub Releases(D50);**带 `update-test-source` feature 的构建**里,测试通道开启时
|
||||||
|
/// (`test_source_root`)走本地目录源。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn default_source(engine: Arc<DownloadEngine>) -> Box<dyn UpdateSource> {
|
pub fn default_source(engine: Arc<DownloadEngine>) -> Box<dyn UpdateSource> {
|
||||||
match local_dir_override() {
|
// 测试通道(M169):整段只在带 feature 的构建里存在。默认关 ⇒ 官方发布产物里
|
||||||
Some(root) => Box::new(DirSource::new(root)),
|
// `test_source_root` 根本编不出来,`DirSource` 也进不了这条入口(编译期事实,不看运行时)。
|
||||||
None => Box::new(GitHubReleaseSource::new(engine)),
|
#[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 成本进程
|
/// M159 的判据是 `cfg!(debug_assertions)`:`--release` 里整段被常量折叠掉,于是**发布产物没法
|
||||||
/// 的 exe。清单里的摘要也来自同一份清单,所以**这条路上没有可信根**——它不是权限提升(要能设用户
|
/// 指向本地更新源**,而发布产物正是 release,`ull update apply` 又会替换本进程 exe——这条链在
|
||||||
/// 环境变量 + 用户自己点「下载并安装」),但发布产物里没有任何理由保留它。
|
/// release 上从来没有端到端证据(M167 的结论)。要补这一环,判据必须与「是不是 debug」脱钩。
|
||||||
///
|
///
|
||||||
/// 三档选一,我选了「按构建画像判」:
|
/// M169 的三档(tower 定案 B,理由不是省事而是攻击面):
|
||||||
///
|
///
|
||||||
/// | 判据 | 为什么不选 |
|
/// | 档 | 怎么给 | 作用 |
|
||||||
/// |---|---|
|
/// |---|---|---|
|
||||||
/// | 命令行显式参数(`--update-dir`) | 要给 GUI 与 CLI 各加一条参数、还要穿到 `Job` 里;而它只服务取证/内网,不该出现在正式用法里 |
|
/// | 编译期 | `--features update-test-source` | 这段代码**才存在**;官方发布构建不带它 |
|
||||||
/// | 运行期开关(设置键 / 环境变量再加一层鉴权) | 等于「发布版也留着这条路」,只是多了一道可被一起设好的门 |
|
/// | 路径 | `ULL_UPDATE_DIR=<root>` | 本地源根(`<root>/stable.json` + 产物) |
|
||||||
/// | **`cfg!(debug_assertions)`** | 发布构建(`--release`)里这个常量是 `false`,整段分支被常量折叠掉——**release 二进制里这条路径根本不存在**,而且是编译期事实,不靠用户/环境配合 |
|
/// | 承认 | `ULL_UPDATE_TEST_SOURCE=1` | 「我知道这是测试通道」;值必须是 `1`,`true`/`yes` 不算 |
|
||||||
///
|
///
|
||||||
/// 调试构建仍认它:本 mission 的「发现新版本 → 下载 → 双检 → 自替换」整环就是用 `cargo build`
|
/// 只给路径(或只设环境变量)什么都不会发生:**默认构建里这段函数根本不存在**。
|
||||||
/// 出来的二进制 + 本地夹具验的(真实 GitHub 上 v0.2.2 还是 prerelease,取不到 stable 清单)。
|
///
|
||||||
fn local_dir_override() -> Option<std::path::PathBuf> {
|
/// # 隔离手段(逐条;别只信「有闸门」)
|
||||||
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:<root>`、
|
||||||
|
/// `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<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
if ack.map(str::trim) != Some(TEST_SOURCE_ACK) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let root = std::env::var(UPDATE_DIR_ENV).ok()?;
|
let root = dir?.trim();
|
||||||
let root = root.trim();
|
|
||||||
if root.is_empty() {
|
if root.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(std::path::PathBuf::from(root))
|
Some(PathBuf::from(root))
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,11 @@
|
|||||||
//! 更新源已定(D50):**GitHub Releases 是唯一更新源**,真实实现在
|
//! 更新源已定(D50):**GitHub Releases 是唯一更新源**,真实实现在
|
||||||
//! [`GitHubReleaseSource`](清单固定地址 + 产物走 `core::download` 引擎)。
|
//! [`GitHubReleaseSource`](清单固定地址 + 产物走 `core::download` 引擎)。
|
||||||
//! 这一层仍然保持源无关:核心只认 [`UpdateSource`] 三个方法,接哪家由上层注入
|
//! 这一层仍然保持源无关:核心只认 [`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
|
//! 设计输入:`DevDocs/analyse/07-settings-packaging-update-p2.md` §4(HMCL 自更新)与 §6.2
|
||||||
//! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。
|
//! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。
|
||||||
@@ -61,7 +65,9 @@ pub use self::github::GitHubReleaseSource;
|
|||||||
pub use self::install::{
|
pub use self::install::{
|
||||||
backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy,
|
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::manifest::{Artifact, Release, ReleaseManifest};
|
||||||
pub use self::source::{DirSource, UpdateSource};
|
pub use self::source::{DirSource, UpdateSource};
|
||||||
pub use self::target::{Arch, PackageFormat, Target};
|
pub use self::target::{Arch, PackageFormat, Target};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! `Job`(检查/应用的一条龙)的用例:包形态闸门 + 一条成功的自替换。
|
//! `Job`(检查/应用的一条龙)的用例:包形态闸门、一条成功的自替换、测试通道的两档判据。
|
||||||
//!
|
//!
|
||||||
//! 为什么这层要单独测:`Release::artifact_for` 只比 `target`、不看 `format`,所以「清单给
|
//! 为什么这层要单独测:`Release::artifact_for` 只比 `target`、不看 `format`,所以「清单给
|
||||||
//! win-x64 挂了一个 msi/zip」是完全可能的输入(复审 P3-6)。以前 `apply` 会把它直接 rename
|
//! 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");
|
assert_eq!(std::fs::read(&dest).expect("读目标"), b"old-bytes");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 调试构建认 `ULL_UPDATE_DIR`、发布构建不认——判据是靠 `cfg!(debug_assertions)` 折叠掉的,
|
/// 测试通道的判据是**编译期开 + 两档全给**:`--features update-test-source`、`ULL_UPDATE_DIR`、
|
||||||
/// 所以这里用源码锚点把「闸门还在」钉住(release 分支在测试里跑不到,只能这样守)。
|
/// `ULL_UPDATE_TEST_SOURCE=1`。
|
||||||
|
///
|
||||||
|
/// 旧判据是 `cfg!(debug_assertions)`(debug 认、release 整段折叠掉),M169 换掉它是因为发布
|
||||||
|
/// 产物正是 release——拿 release 验不了这条链,整条更新通道就没有端到端证据。换判据必须补隔离:
|
||||||
|
/// 编译期 feature 保证**官方二进制里不存在这条路**,两档环境变量保证「开了 feature 的验收构建」
|
||||||
|
/// 也不会被继承来的单个环境变量悄悄改源。这条用例守后者(纯判据,不碰进程环境)。
|
||||||
|
#[cfg(feature = "update-test-source")]
|
||||||
#[test]
|
#[test]
|
||||||
fn the_local_dir_override_is_debug_only() {
|
fn the_test_channel_needs_both_switches() {
|
||||||
let source = include_str!("../job.rs");
|
use std::path::PathBuf;
|
||||||
assert!(
|
|
||||||
source.contains("if !cfg!(debug_assertions) {"),
|
use crate::update::job::test_source_root_from;
|
||||||
"`ULL_UPDATE_DIR` 的闸门必须是 debug_assertions(发布构建里整段被折叠掉)"
|
|
||||||
|
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<std::ffi::OsString>, Option<std::ffi::OsString>);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
|
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
|
||||||
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
|
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
|
||||||
//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、
|
//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、
|
||||||
//! 「没有可装更新」不碰目标、`ULL_UPDATE_DIR` 只在调试构建里生效;
|
//! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来,
|
||||||
|
//! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给);
|
||||||
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝;
|
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝;
|
||||||
//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。
|
//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -26,3 +26,11 @@ unlucky-core = { path = "../core", version = "0.2.3" }
|
|||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
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"]
|
||||||
@@ -92,7 +92,9 @@ impl Paths {
|
|||||||
/// 这样「上一次用的源」不会跟着下一次跑,测试也能各用例各给一份本地目录。
|
/// 这样「上一次用的源」不会跟着下一次跑,测试也能各用例各给一份本地目录。
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum Factory {
|
pub enum Factory {
|
||||||
/// 生产/spec 形态:GitHub(或 `ULL_UPDATE_DIR` 指定的本地目录),引擎懒建一次。
|
/// 生产/spec 形态:GitHub Releases(**带 `update-test-source` feature 的验收构建**里,两档
|
||||||
|
/// 环境变量给全时是本地目录源,见 `unlucky_core::update::job` 的 `test_source_root`),
|
||||||
|
/// 引擎懒建一次。
|
||||||
Default {
|
Default {
|
||||||
/// 下载引擎(镜像/地区/健康度策略都在里面)。
|
/// 下载引擎(镜像/地区/健康度策略都在里面)。
|
||||||
engine: Arc<DownloadEngine>,
|
engine: Arc<DownloadEngine>,
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
|||||||
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
||||||
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
|
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
|
||||||
| `packaging/check_publish_slots.py` | 发布名单守卫(M164):名单 ⇄ 产物两个方向;`--self-test` 注入 8 条回归 | ≤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/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
|
||||||
| `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 |
|
| `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 |
|
||||||
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤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 第六件 =
|
`v0.2.2` 那个 Release **保留为历史预发布不动**,稳定通道以 `v0.2.3` 为起点。发版 checklist 第六件 =
|
||||||
确认 prerelease 标记与通道一致(`gh release view <tag> --json isPrerelease`),守门是
|
确认 prerelease 标记与通道一致(`gh release view <tag> --json isPrerelease`),守门是
|
||||||
`packaging/check_release_semantics.py`(CI:`.github/workflows/release-guard.yml`)。
|
`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=<root>` + `ULL_UPDATE_TEST_SOURCE=1`(两档缺一不可)。`ull update check --json` 会
|
||||||
|
如实写 `data.source: "file:<root>"`、`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 点击与重启替换(需人工)。
|
||||||
@@ -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())
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user