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:
1 parent
a14b9e17c1
commit
24826c1a7c
18 files changed
+1133
-44
No files matched your search
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user