Files
Unlucky-Launcher/crates/core/tests/adopt/snapshot.rs
T
AskaEth a5d1dec495
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
release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
2026-11-11 21:41:38 +08:00

181 lines
6.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 目录树**全节点**快照:目录 / 文件 / 链接各记自己的类型与适用 mtime。
//!
//! # 为什么不是「只记文件」
//!
//! 复审 r1 的 R5:早先版本遇到目录只递归、不记录,于是「登记过程凭空建了一个空目录」
//! (例如错误地在 `<game>/versions/<v>/mods` 里 `create_dir_all`)在快照上**完全看不出来**。
//! 「只读外部」这条承诺必须能抓住新增/删除的**目录节点**,所以这里:
//!
//! - 根自身、每个目录、每个文件、每个链接都进快照(相对路径 → [`Node`]);
//! - 目录记 mtime(条目增删会改它),文件记字节数 + mtime + 内容哈希;
//! - **链接不跟随**:只记目标路径(跟随会把根外的内容算进「外部树」,让越界写入看起来像「没变化」);
//! Windows 的目录联接(junction)不是 `is_symlink`,这里额外用重解析点属性识别它;
//! - 任何读盘失败都**直接失败**(`panic`),不吞成「这项没变」——吞错就是假绿。
//!
//! # 为什么 CLI 测试也用这一份
//!
//! 以前 core 与 CLI 各写一份 walk,两份都漏了目录节点(同一处缺口两遍)。现在 CLI 的
//! `adopt_test/fixture.rs` 用 `#[path = "../../../core/tests/adopt/snapshot.rs"]` 复用本文件:
//! **判据只有一份**,不会再次漂移。
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
/// 快照里的一个节点。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Node {
/// 目录(只记 mtime:条目增删会改它)。
Dir {
/// mtime(纳秒;取不到记 0)。
mtime_ns: u128,
},
/// 文件(字节数 + mtime + 内容哈希)。
File {
/// 字节数。
len: u64,
/// mtime(纳秒)。
mtime_ns: u128,
/// 内容哈希(稳定即可,不要密码学强度)。
hash: u64,
},
/// 符号链接 / Windows 目录联接:只记目标,**不跟随**。
Link {
/// 链接目标(原样,可能是相对路径;读不出来时记 `<unreadable-link>`)。
target: PathBuf,
},
}
/// 一次快照:相对路径(根自身是 `.`)→ 节点。
pub type Snapshot = BTreeMap<String, Node>;
/// 拍一次全节点快照(目标不存在 = 空快照,见 [`snapshot_opt`] 的区分)。
///
/// # Panics
///
/// 任何目录/元数据/文件读不出来都 panic:读不出来不能当成「没变化」。
pub fn snapshot(root: &Path) -> Snapshot {
snapshot_opt(root).unwrap_or_default()
}
/// 同 [`snapshot`],但目标**不存在**时返回 `None`(用于区分「空树」与「根本没有」)。
///
/// # Panics
///
/// 同上(存在但读不出来即 panic)。
pub fn snapshot_opt(root: &Path) -> Option<Snapshot> {
match std::fs::symlink_metadata(root) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
Err(error) => panic!("快照根读不出来 {}:{error}", root.display()),
}
let mut out = Snapshot::new();
out.insert(".".to_string(), node_of(root));
walk(root, root, &mut out);
Some(out)
}
fn walk(root: &Path, dir: &Path, out: &mut Snapshot) {
let entries = std::fs::read_dir(dir)
.unwrap_or_else(|error| panic!("目录应可读 {}:{error}", dir.display()));
for entry in entries {
let entry = entry.unwrap_or_else(|error| panic!("目录项应可读 {}:{error}", dir.display()));
let path = entry.path();
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let node = node_of(&path);
let is_dir = matches!(node, Node::Dir { .. });
out.insert(relative, node);
if is_dir {
walk(root, &path, out);
}
}
}
/// 单个节点的形状(链接/联接**不跟随**)。
fn node_of(path: &Path) -> Node {
let meta = std::fs::symlink_metadata(path)
.unwrap_or_else(|error| panic!("元数据应可读 {}:{error}", path.display()));
if link_like(&meta) {
let target =
std::fs::read_link(path).unwrap_or_else(|_| PathBuf::from("<unreadable-link>"));
return Node::Link { target };
}
let mtime_ns = meta
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map_or(0, |duration| duration.as_nanos());
if meta.file_type().is_dir() {
return Node::Dir { mtime_ns };
}
let bytes = std::fs::read(path)
.unwrap_or_else(|error| panic!("文件应可读 {}:{error}", path.display()));
Node::File {
len: bytes.len() as u64,
mtime_ns,
hash: hash(&bytes),
}
}
/// 符号链接(所有平台)或 Windows 重解析点(junction / mount point)。
///
/// `FileType::is_symlink` 对 junction 返回 `false`,所以 Windows 上另看
/// `FILE_ATTRIBUTE_REPARSE_POINT`:不识别它就会把 junction 当普通目录**递归进去**,
/// 等于跟随链接读根外内容。
fn link_like(meta: &std::fs::Metadata) -> bool {
if meta.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
{
false
}
}
/// 内容哈希(只要稳定、不需要密码学强度)。
fn hash(bytes: &[u8]) -> u64 {
let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher);
hasher.finish()
}
/// 断言两次快照**完全一致**:路径集合(含目录/链接)与每个节点的形状都不许变。
///
/// 抓得住:
/// - 新增/删除**空目录**(目录节点进快照)、目录条目变化(目录 mtime 变);
/// - 文件被改写(哈希变、mtime 变)或被删(路径集合变);
/// - 链接目标被改(`Link.target` 变)。
///
/// 抓不住:只读打开(无可观测痕迹)、文件内容相同且 mtime 未被改写的「伪写」、
/// 以及检查期间的 TOCTOU。
///
/// # Panics
///
/// 快照不一致即 panic(消息里点名第一个不同的路径)。
pub fn assert_unchanged(before: &Snapshot, after: &Snapshot, what: &str) {
assert_eq!(
before.keys().collect::<Vec<_>>(),
after.keys().collect::<Vec<_>>(),
"{what}:路径集合(含目录/链接节点)变了"
);
for (path, node) in before {
assert_eq!(
node,
after.get(path).expect("同一路径"),
"{what}:节点被改动 -> {path}"
);
}
}