feat(m169): 更新通道端到端验收 —— 本地源改成编译期 feature,补一条可一键跑的 E2E + CI 守门

§6 欠账:更新链路的 GUI 端从来没验过,而 M167 发现它根本没法在本机验——`ULL_UPDATE_DIR` 只在
`debug_assertions` 下生效,发布产物却是 release;`apply` 又会替换本进程 exe,于是没人敢跑。

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

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

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

已知没验:真 GitHub Release 下载、GUI 点击与重启替换(人工);跨卷替换(另开 finding)。
This commit is contained in:
worker-updatee2e authored and AskaEth committed 2026-11-08 11:12:10 +08:00
1 parent a14b9e17c1
commit 24826c1a7c
18 files changed
+1133 -44

No files matched your search

+5
View File
@@ -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"]
+4 -2
View File
@@ -128,8 +128,10 @@ pub fn topic(name: &str) -> Option<&'static str> {
缺省替换本进程的 exe:**下次启动生效**(正在跑的这一份仍是旧版本)。
--dest 钉到别的路径(取证 / 自定义安装位置)。不写 = 本进程 exe。
环境变量 ULL_UPDATE_DIR=<dir>:改走本地目录源(`<dir>/stable.json` + 产物),
用于内网手工分发与「发现新版本」这条分支的整环取证;不设就走 GitHub。
环境变量 ULL_UPDATE_DIR=<dir> + ULL_UPDATE_TEST_SOURCE=1:改走本地目录源(`<dir>/stable.json`
+ 产物),用于内网手工分发与更新链路的整环取证。**两者都只在带 `update-test-source` feature
编出来的验收构建里生效**(`cargo build --features update-test-source`);官方发布构建不含这条
路径,设了环境变量也照旧走 GitHub。
--json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。",
),
"install" => Some(
+50 -1
View File
@@ -16,6 +16,22 @@
//! 同样不许说成「已是最新」。
//!
//! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。
//!
//! # 测试通道(M169)
//!
//! 想在没有稳定 Release 的情况下整环验一遍,得用**专门编出来的验收二进制**:
//!
//! ```text
//! cargo build --release --features update-test-source # 官方发布构建不带这个 feature
//! ULL_UPDATE_DIR=<root> ULL_UPDATE_TEST_SOURCE=1 ./ull update check
//! ```
//!
//! 三档全给才生效(feature 默认关 = 官方二进制里没有这条路径;带 feature 时还要两档环境变量,
//! 判据见 `core::update::job`)。这时 `--json` 的 `source` 是 `file:<root>`、`test_source` 是
//! `true`,人读输出另有一行点名。它不是第二个更新源:没有设置键、也没有命令行参数。
//!
//! ⚠ **验收时必须给 `--dest`**:不给就是替换本进程的 exe。自动化用例里的「本进程」是测试二进制,
//! 去掉 `--dest` 会把它真改名换掉(`crates/cli/tests/update_e2e_test.rs` 的注释写了这件事)。
use std::path::PathBuf;
use std::sync::Arc;
@@ -77,6 +93,21 @@ fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
.with_hint(hint)
}
/// 这个源是不是测试通道(判据派生自**实际用了哪个源**,不是回读环境变量:
/// `DirSource` 的 id 是 `file:<root>`,GitHub 源是 `github:<owner>/<repo>`)。
fn is_test_source(source_id: &str) -> bool {
source_id.starts_with("file:")
}
/// 测试通道的点名行(只在真走了本地目录源时出现)。文案里点明**这是验收构建**:官方发布二进制
/// 没有这条路径(`update-test-source` 默认关),所以出现这一行就说明手上这份不是官方产物。
fn test_channel_note(source: &str) -> String {
format!(
"测试通道生效:源 = {source}(本地目录,不是 GitHub Releases;本二进制带 \
`update-test-source` feature 编出来,官方发布构建不含这条路径)"
)
}
/// `ull update check`:只查、不动任何文件。
///
/// # Errors
@@ -109,6 +140,8 @@ pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
.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<Outcome, CliError> {
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<Outcome, CliError> {
}
/// 把检查结论排成「人读几行 + `--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
}
+2
View File
@@ -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};
+286
View File
@@ -0,0 +1,286 @@
//! 更新通道端到端验收的夹具(M169):临时现场、真形状清单、测试通道的环境变量守卫。
//!
//! 两个测试目标共用(`update_e2e_test` 正路 / 幂等,`update_e2e_tamper_test` 篡改);每个目标只
//! 用到其中一部分,所以与 `support/mod.rs` 一样放开 dead_code。
//!
//! 整个夹具只在 `update-test-source` feature 下真的被用到:那两个测试目标自己 `#![cfg]` 掉,
//! 这里只是跟着编译(不跑)。判据(feature + 两档环境变量)在 `core::update::job`。
//!
//! ⚠ **验收必须给 `--dest`**:用例在**进程内**跑 `unlucky_cli::run`,`Job::for_process` 的
//! 「本进程的 exe」就是测试二进制——`apply` 不带 `--dest` 会真的把它 `rename` 成新字节
//! (Windows 允许改名正在运行的映像,见 `core::update::install` 的文档),整轮测试当场崩。
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use unlucky_core::update::{HashAlgo, OsFamily, Target, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV};
/// 假装的「已安装版本」字节:与产物**必须不同**,否则「目标没被动过」这类断言会假绿。
pub const INSTALLED: &[u8] = b"installed 0.2.3 build (pretend)";
/// 产物体量上限(1 MiB)。
///
/// 现场构建出来的 `ull.exe` 是 **~400 MB**(debug + 全套 reqwest/rmcp/axum);拿整份当过测材,
/// 光 sha256 + 拷贝就要几十秒,验收会变成「谁都不想跑」。所以截取**前 1 MiB**:PE 头与头几节都在,
/// 「这是真 exe 的字节」这一点保留,代价是「装上去能不能启动」不再由它证明——那一跳本来就要人工
/// (见报告「不验的部分」)。清单里的 `size` / `sha256` 按这份截短后的真实字节算,仍然自洽。
const PAYLOAD_LIMIT: usize = 1024 * 1024;
/// 清单里的更新说明(断言它会原样出现在 `check --json` 里)。
const NOTES: &str = "修掉启动崩溃;实例页加载更快";
/// SHA-256 按真实字节算(与双检同一条实现:`core::download::verify::digest_bytes`)。
pub fn sha256(bytes: &[u8]) -> String {
unlucky_core::download::verify::digest_bytes(HashAlgo::Sha256, bytes)
}
/// 清单版本:**比编译期版本严格大一档次版本**。
///
/// 不能写死 `0.2.4`:workspace 版本一升,用例就变成「已是最新」的假红。次版本 +1 恒大于当前
/// 版本,且仍在同一 major 内(不会意外触发 `min_supported` 那条判据)。
pub fn next_version(current: &str) -> String {
let mut parts = current.split('.');
let major = parts.next().unwrap_or("0");
let minor: u64 = parts
.next()
.unwrap_or("0")
.parse()
.unwrap_or_else(|_| panic!("编译期版本号应是三段式:{current}"));
assert!(parts.next().is_some(), "编译期版本号应是三段式:{current}");
format!("{major}.{}.0", minor + 1)
}
/// 真实产物命名(D49):`unlucky-launcher_<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 一样由数据根派生暂存目录)。
pub fn run(&self, args: &[&str]) -> (i32, String, String) {
let mut full = vec!["update"];
full.extend_from_slice(args);
let data_dir = self.data.arg();
full.push("--data-dir");
full.push(data_dir.as_str());
super::run_cli(&full)
}
/// 跑 `apply --dest <副本> --json`(**永远**带 `--dest`,见文件头)。
pub fn apply(&self) -> (i32, serde_json::Value) {
let dest = self.dest.display().to_string();
let (code, out, _) = self.run(&["apply", "--dest", &dest, "--json"]);
(code, super::parse_json(&out))
}
/// 暂存目录(`<数据根>/.ULL/cache/update/staging`,与 `core::update::staging_dir` 同形)。
pub fn staging(&self) -> PathBuf {
self.data
.path()
.join(".ULL")
.join("cache")
.join("update")
.join("staging")
}
/// 暂存目录里还留着什么(`StagedPackage` 落地/被搬走之后应为空)。
pub fn staging_leftovers(&self) -> Vec<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("读目标")
}
}
+103
View File
@@ -0,0 +1,103 @@
//! 更新通道端到端验收(M169)的**拒绝侧**:篡改摘要、篡改包体、截断包体。
//!
//! 正路与幂等在 `update_e2e_test.rs`(现场说明、抓得住/抓不住、不验的部分都在那边,别重复读)。
//! 这里只钉一条底线:**校验不过就一个字节都不许落到目标上**,而且失败不会把现场搞脏到
//! 「修好清单也装不上」。
//!
//! 一键跑(必须带 feature;tower 定案:验收落在 release profile 上):
//!
//! ```text
//! cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_tamper_test
//! ```
//!
//! 不带 feature 时整个目标 `#![cfg]` 掉(0 个用例)——测试通道在那种构建里不存在。
#![cfg(feature = "update-test-source")]
#[path = "support/mod.rs"]
mod support;
use support::update_e2e::{channel_lock, sha256, Channel, Fixture, INSTALLED};
/// 清单的 `sha256` 与盘上字节不符 ⇒ 拒绝,且**现有安装一个字节都不动**。
///
/// 抓得住:校验和判据被绕过(写成恒真、先搬后校、只比长度)。抓不住:清单与包体一起被换
/// (同时改 sha),那条路上没有可信根。
#[test]
fn a_tampered_digest_is_refused_and_leaves_the_install_intact() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-digest");
Channel::enable(fx.dist.path());
// 长度对、摘要错:先过长度判据,才能测到摘要这一关。
fx.publish(&sha256(b"another build entirely"), fx.payload.len() as u64);
let (code, json) = fx.apply();
assert_ne!(code, 0, "摘要不符必须拒绝(实际退出码 0):{json}");
assert_eq!(json["error"]["code"], "update_apply_failed", "{json}");
assert_eq!(json["error"]["detail"]["kind"], "digest_mismatch", "{json}");
assert_eq!(fx.installed_bytes(), INSTALLED, "校验失败不许动目标文件");
assert!(
fx.backups().is_empty(),
"还没走到替换就失败了,安装目录里不该出现 .ullold:{:?}",
fx.backups()
);
assert!(
fx.staging().exists(),
"下载过才会建暂存目录(说明失败点在双检,不是根本没下)"
);
assert!(
fx.staging_leftovers().is_empty(),
"校验失败的 .ullupd 必须被删掉:{:?}",
fx.staging_leftovers()
);
// 修好清单再装一次:失败没把现场搞脏,这条路还能走通。
fx.publish_good();
let (code, json) = fx.apply();
assert_eq!(code, 0, "修好清单之后应能装上:{json}");
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "installed", "{json}");
assert_eq!(fx.installed_bytes(), fx.payload);
}
/// 产物被换(清单没动)⇒ 一样拒绝:篡改落在哪一侧都能抓到。
#[test]
fn a_tampered_artifact_is_refused_too() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-artifact");
Channel::enable(fx.dist.path());
fx.publish_good();
// 盘上换成同长度的另一份字节:只有摘要能抓(长度判据看不出区别)。
let mut evil = fx.payload.clone();
evil[0] ^= 0xff;
std::fs::write(fx.dist.path().join(&fx.asset), &evil).expect("篡改产物");
let (code, json) = fx.apply();
assert_ne!(code, 0, "篡改的包必须拒绝:{json}");
assert_eq!(
json["error"]["detail"]["kind"], "digest_mismatch",
"篡改产物应当报摘要不符:{json}"
);
assert_eq!(fx.installed_bytes(), INSTALLED);
assert!(fx.staging_leftovers().is_empty());
}
/// 包体被截断(清单长度按完整包算)⇒ 长度判据先拒:不读摘要、不碰目标。
#[test]
fn a_truncated_payload_is_refused_as_size_mismatch() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-truncated");
Channel::enable(fx.dist.path());
fx.publish_good();
let full = fx.payload.clone();
std::fs::write(fx.dist.path().join(&fx.asset), &full[..full.len() / 2]).expect("截断产物");
let (code, json) = fx.apply();
assert_ne!(code, 0, "截断的包必须拒绝:{json}");
assert_eq!(json["error"]["detail"]["kind"], "size_mismatch", "{json}");
assert_eq!(fx.installed_bytes(), INSTALLED);
}
+195
View File
@@ -0,0 +1,195 @@
//! 更新通道端到端验收(M169):**临时数据根 + 本地更新源 + 真形状的清单与产物**,
//! 从 CLI 入口走完「发现新版本 → 下载 → 长度/SHA256 双检 → 就位」。
//!
//! 一键跑(**必须带 feature**;tower 要求验收落在 release profile 上,debug 与 release 的
//! `debug_assertions`、优化与 panic 行为都不同,用 debug 验收等于没验真实产物):
//!
//! ```text
//! cargo test --release -p unlucky-cli --features update-test-source \
//! --test update_e2e_test --test update_e2e_tamper_test
//! ```
//!
//! (两个测试目标各跑一半:本文件是正路 + 幂等,`update_e2e_tamper_test` 是篡改/截断。)
//!
//! 不带 `--features update-test-source` 时整个目标 `#![cfg]` 掉(**0 个用例**,不是绿):测试通道
//! 在那种构建里根本不存在,「环境变量设满也走 GitHub」由 `crates/core` 的
//! `the_test_channel_is_a_compile_time_opt_in` 在**不带 feature 的跑法**下守着。
//!
//! 为什么需要 feature:`ULL_UPDATE_DIR` 曾经只在 `debug_assertions` 下生效,于是发布产物(release)
//! **没法**指向本地源,这条链在 release 上从来没有端到端证据。M169 把判据换成编译期 feature
//! (默认关 ⇒ 官方二进制里没有这条路径),验收构建显式打开它。
//!
//! # 现场
//!
//! 见 `tests/support/update_e2e.rs`:数据根 / 本地源 / 安装目录三份临时目录,产物是现场构建出来的
//! `ull.exe` 的**前 1 MiB**(真 PE 头、真字节;整份 ~400 MB,测材太重在 `PAYLOAD_LIMIT` 的注释里
//! 写了原因),清单与 `packaging/tests/manifest.golden.json` 同字段。**`--dest` 永远指向临时副本**
//! ——这个文件里没有一处会替换本进程的 exe。
//!
//! # 抓得住什么 / 抓不住什么
//!
//! | 本套断言 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | 清单 → 选版本 → 下载 → 双检 → 就位整环跑通 | 任一环断了(字段拼错、渠道不符、没下、没校验、没搬)都会红 | 真 GitHub Release 的下载链路(本套一个字节不出网) |
//! | 清单 `sha256` 与盘上字节不符 ⇒ 拒绝 + 目标不动 | 「校验和判据被绕过/写成恒真」;篡改包体或篡改清单都能抓(见 `update_e2e_tamper_test`) | 「CDN 换了内容**而清单里的 sha 也一起换**」——这条路上没有可信根,只能靠 GitHub 侧 |
//! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 |
//! | 重复 `apply` 后目标字节稳定、暂存无残留 | 「第二次把 dest 写坏 / 暂存留下垃圾」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe |
//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷(数据根与 exe 不同盘)时的替换:`rename` 不能跨卷,那条路没验 |
//!
//! # 不验的部分(如实划界)
//!
//! 1. **真实 GitHub Release 下载**:本套一个字节都不出网(源是本地目录)。`releases/latest` 的
//! 真实响应、镜像/gh-proxy 回退、CDN 的 ETag/断点续传都属于真网络验收。
//! 2. **GUI 的点击与重启替换**:设置页「下载并安装」、提示条、`RestartNow` 之后的进程重启不在
//! 自动化里(替换自身 exe 需要真机,且要人工确认新版本能起来)。
//! 3. **按版本号忽略**:`presenter::host::update_state` 的 `skipped` 是会话级行为,有单测
//! (`update_session_tests.rs`),CLI 侧没有对应开关,所以这里不覆盖。
//! 4. **`--dest` 之外的自替换**(替换本进程 exe):`tests/support/update_e2e.rs` 的文件头写了为什么
//! 这几条用例永远带 `--dest`,以及「去掉它会真的改名换掉测试二进制」这件事。
#![cfg(feature = "update-test-source")]
#[path = "support/mod.rs"]
mod support;
use support::update_e2e::{channel_lock, Channel, Fixture, INSTALLED};
use unlucky_core::update::Target;
/// 发现新版本 → 下载 → 双检 → 就位:整环从 CLI 入口跑一遍,并核对每一步的产物。
#[test]
fn discovers_downloads_and_installs_from_the_local_channel() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-happy");
Channel::enable(fx.dist.path());
fx.publish_good();
// 1) 发现新版本:源是本地目录,脚本能看出这一份结论来自测试通道。
let (code, out, err) = fx.run(&["check", "--json"]);
assert_eq!(code, 0, "check 应成功:{err}");
let json = support::parse_json(&out);
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "available", "{json}");
assert_eq!(data["version"], fx.version.as_str());
assert_eq!(data["current"], env!("CARGO_PKG_VERSION"));
assert_eq!(
data["size"].as_u64(),
Some(fx.payload.len() as u64),
"清单声明的字节数 = 产物真实字节数:{json}"
);
assert_eq!(data["target"], Target::current().as_str());
assert_eq!(data["notes"], "修掉启动崩溃;实例页加载更快");
assert_eq!(data["forced"], false);
assert_eq!(data["test_source"], true, "测试通道必须在输出里点名");
assert_eq!(
data["source"],
format!("file:{}", fx.dist.path().display()),
"源必须是本地目录(不是 GitHub)"
);
// 2) 人读输出也点名测试通道:人看的那份不能比 JSON 少说一句。
let (code, human, _) = fx.run(&["check"]);
assert_eq!(code, 0);
assert!(human.contains("发现新版本"), "{human}");
assert!(
human.contains("测试通道生效"),
"人读输出要点名「这一份结论来自本地夹具」:{human}"
);
// 3) 下载 → 长度/SHA256 双检 → 就位。
let (code, json) = fx.apply();
assert_eq!(code, 0, "apply 应成功:{json}");
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "installed", "{json}");
assert_eq!(data["version"], fx.version.as_str());
assert_eq!(
data["bytes"].as_u64(),
Some(fx.payload.len() as u64),
"{json}"
);
assert_eq!(data["path"], fx.dest.display().to_string().as_str());
assert_eq!(data["test_source"], true);
let backup = data["backup"]
.as_str()
.expect("旧版本应留备份(回滚用)")
.to_string();
// 4) 盘上事实:目标 = 产物字节,备份 = 旧字节,暂存无残留。
assert_eq!(
fx.installed_bytes(),
fx.payload,
"就位的必须就是清单里那一份字节"
);
assert_eq!(
std::fs::read(&backup).expect("读备份"),
INSTALLED,
"旧版本要在备份里,不能凭空消失"
);
assert!(
fx.staging_leftovers().is_empty(),
"暂存目录里不许留 .ullupd:{:?}",
fx.staging_leftovers()
);
// 5) 装完再查:**仍然报有更新**——因为 current 取的是编译期版本,不读 dest 里的 exe。
// 这不是 bug(真实场景里替换完就会重启),但脚本作者必须知道这一条。
let (code, out, _) = fx.run(&["check", "--json"]);
assert_eq!(code, 0);
let json = support::parse_json(&out);
assert_eq!(support::update_e2e::payload(&json)["status"], "available");
}
/// 没有清单的本地渠道 = 「还没有任何发布」,不是错误、也不是「已是最新」以外的胡话。
///
/// 顺带把「源真的被换成本地目录」在没有产物的情况下也验一遍(全程离线)。
#[test]
fn an_empty_local_channel_is_up_to_date_not_an_error() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-empty");
Channel::enable(fx.dist.path());
let (code, out, err) = fx.run(&["check", "--json"]);
assert_eq!(code, 0, "缺清单不该是错误:{err}");
let json = support::parse_json(&out);
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "up_to_date", "{json}");
assert_eq!(data["latest"], serde_json::Value::Null);
assert_eq!(data["test_source"], true);
}
/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾。
///
/// 如实记两条:`apply` 每次都留一个备份(CLI 从不删,等下一次启动/人工清理),而且第二次之所以
/// 还会「装上」,是因为 `current` 是编译期版本——重复执行不是空操作,只是结果幂等。
#[test]
fn applying_twice_leaves_the_same_target_bytes() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-twice");
Channel::enable(fx.dist.path());
fx.publish_good();
for round in 1..=2 {
let (code, json) = fx.apply();
assert_eq!(code, 0, "第 {round} 次 apply 应成功:{json}");
assert_eq!(support::update_e2e::payload(&json)["status"], "installed");
assert_eq!(
fx.installed_bytes(),
fx.payload,
"第 {round} 次 apply 之后目标字节必须仍是产物"
);
assert!(
fx.staging_leftovers().is_empty(),
"第 {round} 次 apply 之后暂存目录还有 {:?}",
fx.staging_leftovers()
);
}
assert_eq!(
fx.backups().len(),
2,
"每次 apply 留一个备份(CLI 不删):{:?}",
fx.backups()
);
}