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

+7
View File
@@ -72,3 +72,10 @@ path = "tests/download/partfile_test.rs"
[lints]
workspace = true
[features]
# M169:把「本地更新源」这条测试通道**编进二进制**。**默认关**——官方发布构建不带它,
# `core::update::job::default_source` 里那条分支根本不存在(编译期事实,不靠运行时判据)。
# 验收用法见 `crates/cli/tests/update_e2e_test.rs` 的文件头;`packaging/check_test_source_off.py`
# 守门「发布流水线与打包脚本都不许打开它」。
update-test-source = []
+76 -29
View File
@@ -25,8 +25,10 @@
//!
//! # 换源
//!
//! 生产源是 D50 定死的 GitHub Releases([`default_source`]);`ULL_UPDATE_DIR` 指向本地目录时
//! 改走 [`super::source::DirSource`]——那是取证/内网手工分发的口子,**不是**第二个更新源。
//! 生产源是 D50 定死的 GitHub Releases([`default_source`]);**测试通道**(带 `update-test-source`
//! feature 编出来的二进制 + [`UPDATE_DIR_ENV`] + [`UPDATE_TEST_SOURCE_ENV`] 三档全给)时改走
//! [`super::source::DirSource`]——那是取证/内网手工分发的口子,**不是**第二个更新源。判据、隔离
//! 手段与残余风险见 [`test_source_root`]。
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -39,18 +41,29 @@ use super::error::UpdateError;
use super::github::GitHubReleaseSource;
use super::install::{install_staged, InstallReceipt, ReplacePolicy};
use super::manifest::Artifact;
use super::source::{DirSource, UpdateSource};
#[cfg(feature = "update-test-source")]
use super::source::DirSource;
use super::source::UpdateSource;
use super::target::{PackageFormat, Target};
use super::verify::stage;
use super::version::Version;
/// 本机更新源覆盖(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`<root>/stable.json`)。
/// 测试通道的源根(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`<root>/stable.json`)。
///
/// **只在调试构建里生效**(判据与理由见 [`local_dir_override`]):发布构建忽略它,
/// 真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支在没有稳定
/// Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。
/// 只在**带 `update-test-source` feature** 编出来的二进制里生效,且**单独设它没有用**:还要
/// [`UPDATE_TEST_SOURCE_ENV`](判据见 `test_source_root`)。默认构建里这两个变量纯粹是死字符串。
pub const UPDATE_DIR_ENV: &str = "ULL_UPDATE_DIR";
/// 测试通道的显式承认开关:值必须是 `1`,[`UPDATE_DIR_ENV`] 才会被认(同样只在 feature 构建里)。
///
/// 两个变量分开是为了「路径」与「我知道我在用测试源」这两件事各自留痕:只漏出一个路径
/// (继承的环境、别的脚本 export 过)不足以把启动器的更新源改走本地目录。
pub const UPDATE_TEST_SOURCE_ENV: &str = "ULL_UPDATE_TEST_SOURCE";
/// [`UPDATE_TEST_SOURCE_ENV`] 唯一认的值(`true` / `yes` / `on` 一律不算:手误不该开闸)。
#[cfg(feature = "update-test-source")]
const TEST_SOURCE_ACK: &str = "1";
/// 一次应用的结局。
#[derive(Debug)]
pub enum Applied {
@@ -242,41 +255,75 @@ pub fn staging_dir(cache_root: &Path) -> PathBuf {
cache_root.join("update").join("staging")
}
/// 生产源:GitHub Releases(D50);**只在调试构建里**,`ULL_UPDATE_DIR` 设了就走本地目录源。
/// 生产源:GitHub Releases(D50);**带 `update-test-source` feature 的构建**里,测试通道开启时
/// (`test_source_root`)走本地目录源。
#[must_use]
pub fn default_source(engine: Arc<DownloadEngine>) -> Box<dyn UpdateSource> {
match local_dir_override() {
Some(root) => Box::new(DirSource::new(root)),
None => Box::new(GitHubReleaseSource::new(engine)),
// 测试通道(M169):整段只在带 feature 的构建里存在。默认关 ⇒ 官方发布产物里
// `test_source_root` 根本编不出来,`DirSource` 也进不了这条入口(编译期事实,不看运行时)。
#[cfg(feature = "update-test-source")]
if let Some(root) = test_source_root() {
return Box::new(DirSource::new(root));
}
Box::new(GitHubReleaseSource::new(engine))
}
/// `ULL_UPDATE_DIR` 的落点:**只认调试构建**。
/// 测试通道的判据:**feature 编进来 + 两档环境变量给全**,才返回本地源根。
///
/// # 为什么判据是 `debug_assertions` 而不是别的
/// # 为什么判据从「只看构建画像」改成「feature + 两档显式承认」
///
/// 这个后门能绕过 GitHub 源:谁能让启动器读到一份本地清单,谁就能让它把任意字节 rename 成本进程
/// 的 exe。清单里的摘要也来自同一份清单,所以**这条路上没有可信根**——它不是权限提升(要能设用户
/// 环境变量 + 用户自己点「下载并安装」),但发布产物里没有任何理由保留它。
/// M159 的判据是 `cfg!(debug_assertions)`:`--release` 里整段被常量折叠掉,于是**发布产物没法
/// 指向本地更新源**,而发布产物正是 release,`ull update apply` 又会替换本进程 exe——这条链在
/// release 上从来没有端到端证据(M167 的结论)。要补这一环,判据必须与「是不是 debug」脱钩。
///
/// 三档选一,我选了「按构建画像判」:
/// M169 的三档(tower 定案 B,理由不是省事而是攻击面):
///
/// | 判据 | 为什么不选 |
/// |---|---|
/// | 命令行显式参数(`--update-dir`) | 要给 GUI 与 CLI 各加一条参数、还要穿到 `Job` 里;而它只服务取证/内网,不该出现在正式用法里 |
/// | 运行期开关(设置键 / 环境变量再加一层鉴权) | 等于「发布版也留着这条路」,只是多了一道可被一起设好的门 |
/// | **`cfg!(debug_assertions)`** | 发布构建(`--release`)里这个常量是 `false`,整段分支被常量折叠掉——**release 二进制里这条路径根本不存在**,而且是编译期事实,不靠用户/环境配合 |
/// | 档 | 怎么给 | 作用 |
/// |---|---|---|
/// | 编译期 | `--features update-test-source` | 这段代码**才存在**;官方发布构建不带它 |
/// | 路径 | `ULL_UPDATE_DIR=<root>` | 本地源根(`<root>/stable.json` + 产物) |
/// | 承认 | `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;
}
let root = std::env::var(UPDATE_DIR_ENV).ok()?;
let root = root.trim();
let root = dir?.trim();
if root.is_empty() {
return None;
}
Some(std::path::PathBuf::from(root))
Some(PathBuf::from(root))
}
+8 -2
View File
@@ -3,7 +3,11 @@
//! 更新源已定(D50):**GitHub Releases 是唯一更新源**,真实实现在
//! [`GitHubReleaseSource`](清单固定地址 + 产物走 `core::download` 引擎)。
//! 这一层仍然保持源无关:核心只认 [`UpdateSource`] 三个方法,接哪家由上层注入
//! (本地目录源 [`DirSource`] 留作对拍基准与内网手工分发)。
//! (本地目录源 [`DirSource`] 留作对拍基准、内网手工分发与**测试通道**)。
//!
//! 测试通道不是第二个更新源:`update-test-source` feature(默认关)+ `ULL_UPDATE_DIR` +
//! `ULL_UPDATE_TEST_SOURCE=1` 三档全给才会把 [`default_source`] 换成本地目录(判据、隔离手段与
//! 为什么用编译期 feature 见 `job::test_source_root` 的文档)。
//!
//! 设计输入:`DevDocs/analyse/07-settings-packaging-update-p2.md` §4(HMCL 自更新)与 §6.2
//! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。
@@ -61,7 +65,9 @@ pub use self::github::GitHubReleaseSource;
pub use self::install::{
backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy,
};
pub use self::job::{default_source, process_dest, staging_dir, Applied, Job, UPDATE_DIR_ENV};
pub use self::job::{
default_source, process_dest, staging_dir, Applied, Job, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV,
};
pub use self::manifest::{Artifact, Release, ReleaseManifest};
pub use self::source::{DirSource, UpdateSource};
pub use self::target::{Arch, PackageFormat, Target};
+127 -8
View File
@@ -1,4 +1,4 @@
//! `Job`(检查/应用的一条龙)的用例:包形态闸门 + 一条成功的自替换。
//! `Job`(检查/应用的一条龙)的用例:包形态闸门、一条成功的自替换、测试通道的两档判据。
//!
//! 为什么这层要单独测:`Release::artifact_for` 只比 `target`、不看 `format`,所以「清单给
//! win-x64 挂了一个 msi/zip」是完全可能的输入(复审 P3-6)。以前 `apply` 会把它直接 rename
@@ -147,13 +147,132 @@ fn nothing_to_apply_does_not_touch_the_target() {
assert_eq!(std::fs::read(&dest).expect("读目标"), b"old-bytes");
}
/// 调试构建认 `ULL_UPDATE_DIR`、发布构建不认——判据是靠 `cfg!(debug_assertions)` 折叠掉的,
/// 所以这里用源码锚点把「闸门还在」钉住(release 分支在测试里跑不到,只能这样守)。
/// 测试通道的判据是**编译期开 + 两档全给**:`--features update-test-source`、`ULL_UPDATE_DIR`、
/// `ULL_UPDATE_TEST_SOURCE=1`。
///
/// 旧判据是 `cfg!(debug_assertions)`(debug 认、release 整段折叠掉),M169 换掉它是因为发布
/// 产物正是 release——拿 release 验不了这条链,整条更新通道就没有端到端证据。换判据必须补隔离:
/// 编译期 feature 保证**官方二进制里不存在这条路**,两档环境变量保证「开了 feature 的验收构建」
/// 也不会被继承来的单个环境变量悄悄改源。这条用例守后者(纯判据,不碰进程环境)。
#[cfg(feature = "update-test-source")]
#[test]
fn the_local_dir_override_is_debug_only() {
let source = include_str!("../job.rs");
assert!(
source.contains("if !cfg!(debug_assertions) {"),
"`ULL_UPDATE_DIR` 的闸门必须是 debug_assertions(发布构建里整段被折叠掉)"
fn the_test_channel_needs_both_switches() {
use std::path::PathBuf;
use crate::update::job::test_source_root_from;
assert_eq!(
test_source_root_from(Some("/d/dist"), Some("1")),
Some(PathBuf::from("/d/dist")),
"两档全给才开"
);
assert_eq!(
test_source_root_from(Some(" /d/dist \n"), Some(" 1 ")),
Some(PathBuf::from("/d/dist")),
"路径与承认值两侧空白忽略"
);
assert_eq!(
test_source_root_from(Some("/d/dist"), None),
None,
"只给路径不许改源(旧的单变量口子)"
);
assert_eq!(
test_source_root_from(None, Some("1")),
None,
"只给承认档但没有路径"
);
for ack in ["true", "yes", "on", "0", "", " "] {
assert_eq!(
test_source_root_from(Some("/d/dist"), Some(ack)),
None,
"承认值 `{ack}` 不算(手误不该开闸)"
);
}
assert_eq!(
test_source_root_from(Some(" "), Some("1")),
None,
"空路径按没给处理(`ULL_UPDATE_DIR=` 不该被读成当前目录)"
);
}
/// 环境变量 → [`default_source`] 的接线,**两种构建各断各的**:
///
/// * 带 `update-test-source`(验收构建):两档全给才换成 `file:` 源;半开仍走 GitHub;去掉承认档
/// 立刻回到 GitHub(判据不缓存)。
/// * 不带 feature(**官方发布构建**):环境变量设满了也照样走 GitHub——这条分支根本不存在。
///
/// 后者是本 mission 的关键断言:README 承诺「release 构建里不存在这条路径」,判据是编译期的,
/// 所以只能在「不带 feature 的测试跑」时验证。
///
/// 用例动的是**进程环境变量**(全局状态),所以先用一把进程内锁串起来、退出时还原原值——
/// `update` 的单测在同一个测试进程里并行跑。
#[test]
fn the_test_channel_is_a_compile_time_opt_in() {
use crate::download::RegionProfile;
use crate::update::job::{UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV};
use crate::update::{default_source, github};
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _serial = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
/// 记住两个变量的原值,`Drop` 时还原(用例 panic 也还原)。
struct Restore(Option<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);
}
}
+2 -1
View File
@@ -8,7 +8,8 @@
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、
//! 「没有可装更新」不碰目标、`ULL_UPDATE_DIR` 只在调试构建里生效;
//! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来,
//! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给);
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝;
//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。
//!