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

本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
AskaEth committed 2026-11-11 21:41:38 +08:00
commit a5d1dec495
1580 files changed
+320938

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
//! 目录树**全节点**快照:目录 / 文件 / 链接各记自己的类型与适用 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}"
);
}
}