release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
commit
a5d1dec495
1580 files changed
+320938
No files matched your search
@@ -0,0 +1,278 @@
|
||||
//! 集成测试共用夹具:临时数据根、命令执行器、最小版本 JSON / 会话索引。
|
||||
//!
|
||||
//! 每个测试目标都会整份编译本文件但只用其中一部分,因此这里放开 dead_code。
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod update_e2e;
|
||||
pub mod update_e2e_sign;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 一次性临时目录(`Drop` 时清理)。
|
||||
pub struct TempDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempDir {
|
||||
/// 建一个唯一目录。
|
||||
pub fn new(tag: &str) -> Self {
|
||||
Self::in_root(&std::env::temp_dir(), tag).expect("创建临时目录")
|
||||
}
|
||||
|
||||
/// 建在**另一块卷**上(跨卷验收用);本机只有一块可写卷时返回 `None`。
|
||||
pub fn on_other_volume(tag: &str) -> Option<Self> {
|
||||
other_volume_root().and_then(|root| Self::in_root(&root, tag))
|
||||
}
|
||||
|
||||
fn in_root(root: &Path, tag: &str) -> Option<Self> {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_nanos());
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = root.join(format!(
|
||||
"ull-cli-test-{tag}-{}-{seq}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&path).ok()?;
|
||||
Some(Self { path })
|
||||
}
|
||||
|
||||
/// 目录路径。
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 目录的字符串形式(喂给 `--data-dir`)。
|
||||
pub fn arg(&self) -> String {
|
||||
self.path.display().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定异卷根目录的环境变量(本机找不到第二块卷时,用它显式指一块)。
|
||||
pub const XDEV_ROOT_ENV: &str = "ULL_XDEV_TEST_ROOT";
|
||||
|
||||
/// 「我知道本机没有第二块卷」的显式豁免(值必须是 `1`)。
|
||||
///
|
||||
/// 与 `crates/core/src/update/tests/support.rs` 的同名常量是**同一条政策**(源头在
|
||||
/// `crates/core/src/update/tests/install_xdev_cases.rs::xdev_guard`,改一处要改两处):
|
||||
/// 探不到第二块卷又不豁免 ⇒ 这条 e2e 必须红,不许静静 `return`(M171 r2 P2-1)。
|
||||
pub const XDEV_WAIVE_ENV: &str = "ULL_XDEV_TEST_WAIVE";
|
||||
|
||||
/// 「本机/本作业必须有第二块卷」的显式声明(值必须是 `1`),优先于豁免。
|
||||
pub const XDEV_REQUIRE_ENV: &str = "ULL_XDEV_TEST_REQUIRE";
|
||||
|
||||
/// 跨卷 e2e 探不到第二块卷时的裁决:**显式豁免**才允许跳过(打印一行记录),否则 panic。
|
||||
///
|
||||
/// `what` 只用来在输出里点名是哪条用例,裁决规则与 core 侧完全一致(见 [`XDEV_WAIVE_ENV`])。
|
||||
pub fn cross_volume_waived(what: &str) {
|
||||
let required = std::env::var(XDEV_REQUIRE_ENV).ok();
|
||||
let waived = std::env::var(XDEV_WAIVE_ENV).ok();
|
||||
let required = required.as_deref().map(str::trim) == Some("1");
|
||||
let waived = waived.as_deref().map(str::trim) == Some("1");
|
||||
assert!(
|
||||
waived && !required,
|
||||
"[xdev-guard] {what}:本机没有第二块可写卷,也没有显式豁免——跨卷 e2e 空跑了,\
|
||||
所以判红。三种合规做法任选其一:\
|
||||
① ULL_XDEV_TEST_ROOT=<另一块卷上的可写目录>;② ULL_XDEV_TEST_WAIVE=1(值必须是 1);\
|
||||
③ 认为本机本该有第二块卷就用 ULL_XDEV_TEST_REQUIRE=1 声明。\
|
||||
政策与理由见 crates/core/src/update/tests/install_xdev_cases.rs 的模块文档。"
|
||||
);
|
||||
eprintln!(
|
||||
"[xdev-guard] {what}:本机没有第二块可写卷,已显式豁免({XDEV_WAIVE_ENV}=1),\
|
||||
这条 e2e 跳过。"
|
||||
);
|
||||
}
|
||||
|
||||
/// 一块与会话临时目录**真的**不同卷的可写根目录(探出来的,不是按平台猜的)。
|
||||
///
|
||||
/// 候选:`ULL_XDEV_TEST_ROOT` → Windows 各盘符的 `Temp`(没有就盘根)/ Unix 的 `TMPDIR`、
|
||||
/// `/dev/shm`、`/tmp`、`/run/user/<uid>`。每个候选都要过「从 temp 目录 rename 进它必须报
|
||||
/// `CrossesDevices`」这一关,所以返回值**一定**是异卷——同一块卷上的候选会被跳过,
|
||||
/// 否则跨卷用例会在同卷上跑,等于什么都没验。
|
||||
pub fn other_volume_root() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(root) = std::env::var_os(XDEV_ROOT_ENV) {
|
||||
candidates.push(PathBuf::from(root));
|
||||
}
|
||||
candidates.extend(volume_candidates());
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|candidate| is_other_volume(candidate))
|
||||
}
|
||||
|
||||
fn volume_candidates() -> Vec<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
('A'..='Z')
|
||||
.map(|letter| PathBuf::from(format!("{letter}:/")))
|
||||
.filter(|root| root.is_dir())
|
||||
.map(|root| {
|
||||
if root.join("Temp").is_dir() {
|
||||
root.join("Temp")
|
||||
} else {
|
||||
root
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let mut candidates: Vec<PathBuf> = ["TMPDIR", "TMP", "TEMP"]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var_os(key).map(PathBuf::from))
|
||||
.collect();
|
||||
for root in ["/dev/shm", "/tmp", "/var/tmp"] {
|
||||
candidates.push(PathBuf::from(root));
|
||||
}
|
||||
if let Some(uid) = std::env::var_os("UID") {
|
||||
candidates.push(PathBuf::from("/run/user").join(uid));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
fn is_other_volume(dir: &Path) -> bool {
|
||||
if dir.as_os_str().is_empty() || std::fs::create_dir_all(dir).is_err() {
|
||||
return false;
|
||||
}
|
||||
let probe = std::env::temp_dir().join(format!("ull-cli-probe-{}", std::process::id()));
|
||||
if std::fs::write(&probe, b"probe").is_err() {
|
||||
return false;
|
||||
}
|
||||
let landed = dir.join(format!("ull-cli-probe-{}", std::process::id()));
|
||||
let cross = matches!(
|
||||
std::fs::rename(&probe, &landed),
|
||||
Err(ref error) if error.kind() == std::io::ErrorKind::CrossesDevices
|
||||
);
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
let _ = std::fs::remove_file(&landed);
|
||||
cross
|
||||
}
|
||||
|
||||
/// 跑一次 CLI,返回(退出码, stdout, stderr)。
|
||||
pub fn run_cli(args: &[&str]) -> (i32, String, String) {
|
||||
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut err: Vec<u8> = Vec::new();
|
||||
let code = unlucky_cli::run(&owned, &mut out, &mut err);
|
||||
(
|
||||
code,
|
||||
String::from_utf8_lossy(&out).into_owned(),
|
||||
String::from_utf8_lossy(&err).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 解析 `--json` 输出(断言它是合法 JSON 并返回)。
|
||||
pub fn parse_json(stdout: &str) -> serde_json::Value {
|
||||
serde_json::from_str(stdout).unwrap_or_else(|error| panic!("输出不是 JSON:{error}\n{stdout}"))
|
||||
}
|
||||
|
||||
/// 写一份最小可组装的新版版本 JSON + 空 client jar。
|
||||
pub fn write_version(data_root: &Path, id: &str) {
|
||||
let dir = data_root.join(".minecraft").join("versions").join(id);
|
||||
std::fs::create_dir_all(&dir).expect("创建版本目录");
|
||||
let version = serde_json::json!({
|
||||
"id": id,
|
||||
"type": "release",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"assets": "5",
|
||||
"assetIndex": { "id": "5" },
|
||||
"arguments": {
|
||||
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"],
|
||||
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
|
||||
},
|
||||
"libraries": [],
|
||||
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
|
||||
});
|
||||
std::fs::write(
|
||||
dir.join(format!("{id}.json")),
|
||||
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
|
||||
)
|
||||
.expect("写版本 JSON");
|
||||
std::fs::write(dir.join(format!("{id}.jar")), b"junk").expect("写 client jar");
|
||||
}
|
||||
|
||||
/// 写 `config/profiles.json`(实例名 → 版本 id)。
|
||||
pub fn write_profiles(data_root: &Path, profiles: &[(&str, &str)]) {
|
||||
let dir = data_root.join(".ULL").join("config");
|
||||
std::fs::create_dir_all(&dir).expect("创建配置目录");
|
||||
let entries: Vec<serde_json::Value> = profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (name, version))| {
|
||||
serde_json::json!({
|
||||
"id": format!("11111111-2222-4333-8444-{:012}", index + 1),
|
||||
"name": name,
|
||||
"version_id": version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let document = serde_json::json!({ "schema_version": 1, "profiles": entries });
|
||||
std::fs::write(
|
||||
dir.join("profiles.json"),
|
||||
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
|
||||
)
|
||||
.expect("写 profiles.json");
|
||||
}
|
||||
|
||||
/// 写一份「退出码 1 + OOM」的会话索引与事件流,返回会话 id。
|
||||
///
|
||||
/// **形状必须与生产写入一致**(M211/D2):`log_path` 是**相对会话根**的 `game/<sid>.jsonl`,
|
||||
/// 事件流真落点是 `<会话根>/game/<sid>.jsonl`。这里用的是旧家(`data/agent/sessions`)作会话根
|
||||
/// ——那是 M188 之前的生产落点,也是用户盘上存量会话的真实形状,读取侧必须还能读回它们。
|
||||
/// 夹具此前写的是不带 `game/` 的 `"{sid}.jsonl"`,于是「读取侧多 join 一层 `game/`」这个 bug
|
||||
/// 在测试里永远看不见:夹具自己说了另一种形状。
|
||||
pub fn write_session(data_root: &Path) -> String {
|
||||
let sid = "20261030T101500-abcdef";
|
||||
let dir = data_root
|
||||
.join(".ULL")
|
||||
.join("data")
|
||||
.join("agent")
|
||||
.join("sessions")
|
||||
.join("game");
|
||||
std::fs::create_dir_all(&dir).expect("创建会话目录");
|
||||
let events = [
|
||||
serde_json::json!({"v":1,"seq":0,"ts":"2026-10-30T10:15:00Z","t_ms":0,"kind":"session_start","session_id":sid,"payload":{"instance_name":"生存服","game_version":"1.20.1"}}),
|
||||
serde_json::json!({"v":1,"seq":1,"ts":"2026-10-30T10:15:02Z","t_ms":1500,"kind":"stdout","session_id":sid,"payload":{"line":"[main/INFO]: Loading Minecraft 1.20.1","level":"info"}}),
|
||||
serde_json::json!({"v":1,"seq":2,"ts":"2026-10-30T10:15:05Z","t_ms":4200,"kind":"stderr","session_id":sid,"payload":{"line":"java.lang.OutOfMemoryError: Java heap space","level":"error"}}),
|
||||
];
|
||||
let mut text = String::new();
|
||||
for event in events {
|
||||
text.push_str(&serde_json::to_string(&event).expect("序列化事件"));
|
||||
text.push('\n');
|
||||
}
|
||||
std::fs::write(dir.join(format!("{sid}.jsonl")), text).expect("写事件流");
|
||||
let entry = serde_json::json!({
|
||||
"v": 1,
|
||||
"session_id": sid,
|
||||
"instance_id": "11111111-2222-4333-8444-555555555555",
|
||||
"instance_name": "生存服",
|
||||
"game_version": "1.20.1",
|
||||
"started_at": "2026-10-30T10:15:00Z",
|
||||
"ended_at": "2026-10-30T10:15:06Z",
|
||||
"duration_ms": 5000,
|
||||
"exit_code": 1,
|
||||
"exit_type": "application_error",
|
||||
"silent_failure": false,
|
||||
"window_detected": false,
|
||||
"event_count": 3,
|
||||
"log_path": unlucky_core::launch::session_index::log_path_for(sid),
|
||||
"crash_found": false,
|
||||
"diagnosis_top": null
|
||||
});
|
||||
std::fs::write(
|
||||
dir.join("index.jsonl"),
|
||||
format!("{}\n", serde_json::to_string(&entry).expect("序列化索引")),
|
||||
)
|
||||
.expect("写会话索引");
|
||||
sid.to_string()
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! 更新通道端到端验收的夹具(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 super::update_e2e_sign::write_signature;
|
||||
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 {
|
||||
Self::with_install(tag, super::TempDir::new(&format!("{tag}-install")))
|
||||
}
|
||||
|
||||
/// 安装目录在**另一块卷**上的现场(数据根与暂存仍在会话 temp):M171 的真机跨卷形态。
|
||||
///
|
||||
/// 找不到第二块可写卷时返回 `None`(调用方跳过并说明原因)——这条用例在只有一块卷的
|
||||
/// 机器/CI 上**不生效**,跨卷逻辑本身由 `crates/core` 的注入用例守着。
|
||||
pub fn cross_volume(tag: &str) -> Option<Self> {
|
||||
let install = super::TempDir::on_other_volume(&format!("{tag}-install"))?;
|
||||
Some(Self::with_install(tag, install))
|
||||
}
|
||||
|
||||
fn with_install(tag: &str, install: super::TempDir) -> Self {
|
||||
let payload = payload_bytes();
|
||||
let version = next_version(env!("CARGO_PKG_VERSION"));
|
||||
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("写产物");
|
||||
let path = self.dist.path().join("stable.json");
|
||||
std::fs::write(&path, manifest(&self.version, &self.asset, size, sha256)).expect("写清单");
|
||||
// M190:清单与签名成对落盘。篡改用例给的是「错的 sha/size」,签的是**改过之后**那份清单
|
||||
// —— 那正是「清单本身可信、包体不对」的现场(摘要判据要独立于签名判据被证到)。
|
||||
write_signature(&path);
|
||||
}
|
||||
|
||||
/// 跑一条 `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,69 @@
|
||||
//! 更新 e2e 夹具的**签名**部分(M190):用 `packaging/sign_manifest.py` 给清单签 `.sig`。
|
||||
//!
|
||||
//! 为什么单独一个文件:`support/update_e2e.rs` 已经贴着全仓 400 行红线(M190 r1 复审 P1-3),
|
||||
//! 而签名这一摊只被 `Fixture::publish` 用到。搬出来是纯搬家,语义未改。
|
||||
//!
|
||||
//! 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒
|
||||
//! 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带
|
||||
//! python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// 测试签名钥的种子(**公开**:只对 `update-test-source` 构建有意义,正式信任表里没有这把钥,
|
||||
/// 见 `crates/core/src/update/trust.rs::TEST_ONLY`)。
|
||||
const TEST_KEY_SEED: &str = "ull-m190-test-key-seed-000000001";
|
||||
/// 测试钥的 `key_id`(与 `trust.rs::TEST_ONLY` 一致)。
|
||||
const TEST_KEY_ID: &str = "test-only-key";
|
||||
|
||||
/// 用 `packaging/sign_manifest.py` 给刚落盘的清单签一份 `.sig`(M190)。
|
||||
///
|
||||
/// 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒
|
||||
/// 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带
|
||||
/// python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。
|
||||
pub fn write_signature(manifest_path: &Path) {
|
||||
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packaging/sign_manifest.py");
|
||||
assert!(script.is_file(), "找不到签名脚本:{}", script.display());
|
||||
let seed_hex: String = TEST_KEY_SEED
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let secret = format!(r#"{{"key_id":"{TEST_KEY_ID}","seed_hex":"{seed_hex}"}}"#);
|
||||
let mut command = None;
|
||||
for candidate in ["python3", "python"] {
|
||||
if std::process::Command::new(candidate)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
{
|
||||
command = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let python = command.unwrap_or_else(|| panic!("这套用例需要 python3/python 跑 {script:?}"));
|
||||
let output = std::process::Command::new(python)
|
||||
.arg(&script)
|
||||
.arg("--sign")
|
||||
.arg(manifest_path)
|
||||
.env("ULL_MANIFEST_SIGNING_KEY", &secret)
|
||||
.output()
|
||||
.expect("跑签名脚本");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"签名脚本失败:stdout={} stderr={}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
manifest_path
|
||||
.with_file_name(format!(
|
||||
"{}.sig",
|
||||
manifest_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
))
|
||||
.is_file(),
|
||||
"签名脚本没产出 .sig"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user