diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index 394e156..fa20128 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -210,7 +210,9 @@ versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 父版本 + 主 [--max-memory ] 可以是实例 id、实例名,或直接是版本 id。 ---dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌。 +--dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌, +也不碰版本树(不解压 natives、不建 natives 目录,连缺的目录也不补);启动器自身的 +运行日志(<数据根>/logs/launcher.log)照记,这一点与真实启动一致。 账户:--account 指定账户(玩家名或条目 id);不给时用唯一账户,一个都没有则回落 离线占位(--username 或 Player),有多个账户会报错并列出候选。 accessToken 一律脱敏(输出里出现的是 )。", diff --git a/crates/core/src/launch/launcher.rs b/crates/core/src/launch/launcher.rs index bfd474a..170f4c2 100644 --- a/crates/core/src/launch/launcher.rs +++ b/crates/core/src/launch/launcher.rs @@ -11,7 +11,7 @@ //! //! 阶段 B 说明:diagnose(M15)尚未合并,诊断走 [`DiagnosisHook`] 接缝,默认 [`NoopDiagnosis`]。 -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; @@ -19,7 +19,7 @@ use crate::launch::args_table::{assemble, AssembledCommand}; use crate::launch::diagnosis_hook::{DiagnosisHook, NoopDiagnosis}; use crate::launch::error::{AssemblyError, Result}; use crate::launch::launch_session::{LaunchSession, SessionHandle}; -use crate::launch::natives::{self, NativesReport}; +use crate::launch::natives::{self, NativesError, NativesPlan, NativesReport}; use crate::launch::platform::PlatformCtx; use crate::launch::process::{ProcessConfig, RunningProcess}; use crate::launch::redact::Redactor; @@ -91,10 +91,13 @@ impl LauncherConfig { } } -/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件**。 +/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件、不碰磁盘**。 /// -/// 会做的事与真实启动**一致到「环境准备」为止**:natives 解压照做(幂等,`natives` 字段 -/// 回报结果),因为「装完能不能起」的一半答案就在「native 库有没有落地」上。 +/// 「不碰磁盘」是本层(core 启动管线)的承诺:环境准备(第 6 段)在 dry-run 下走 +/// [`crate::launch::natives::preview`],只算「会解出几个原生库 / natives 目录是不是 +/// 已经就绪」,**不建目录、不写文件、不删文件**——对版本树(尤其是别人铺的 +/// `versions//`)是纯读的。调用方自己的运行日志(CLI 的 `<数据根>/logs/launcher.log`) +/// 不在此列。 #[derive(Debug, Clone)] pub struct DryRun { /// 实际要执行的程序。 @@ -109,7 +112,9 @@ pub struct DryRun { pub classpath_entries: usize, /// 日志解码编码。 pub encoding: String, - /// natives 解压结果(没有 natives 目录、即请求未配置时为 `None`)。 + /// natives 预览结果(没有 natives 目录、即请求未配置时为 `None`)。 + /// + /// `planned == true`:`files` 是「真启动会写下」的数量,盘上零改动。 pub natives: Option, } @@ -161,15 +166,32 @@ impl Launcher { assemble(request, &self.config.platform) } - /// 环境准备(管线第 6 段):natives 解压。 + /// 环境准备(管线第 6 段):natives 解压(**落盘**,真启动专用)。 /// /// 目标目录取 `request.dirs.natives_dir`(与 `${natives_directory}` 同一真相源); /// 目录为空视为调用方不需要,返回 `Ok(None)`。 fn prepare_environment(&self, request: &LaunchRequest) -> Result> { + self.environment(request, natives::extract) + } + + /// 环境准备的第 6 段预览版:只算「会解出什么」,**零落盘**(`--dry-run` 专用)。 + fn preview_environment(&self, request: &LaunchRequest) -> Result> { + self.environment(request, natives::preview) + } + + /// 两个入口共用的外壳:空 `natives_dir` = 调用方不需要,失败统一折成组装错误。 + /// + /// `run` 是 [`natives::extract`](落盘)或 [`natives::preview`](零落盘); + /// 返回值类型要写全 `std::result::Result`——本模块的 `Result` 别名固定带 `LaunchError`。 + fn environment( + &self, + request: &LaunchRequest, + run: fn(&Path, &NativesPlan) -> std::result::Result, + ) -> Result> { if request.dirs.natives_dir.as_os_str().is_empty() { return Ok(None); } - natives::extract(&request.dirs.natives_dir, &request.natives) + run(&request.dirs.natives_dir, &request.natives) .map(Some) .map_err(|err| AssemblyError::NativesExtraction { message: err.message(), @@ -177,12 +199,13 @@ impl Launcher { .map_err(Into::into) } - /// D18:组装完整命令行并返回,**不 spawn 进程、不写会话文件**。 + /// D18:组装完整命令行并返回,**不 spawn 进程、不写会话文件、不落盘**。 /// /// 供 `unlucky-launcher --dry-run ` 打印一行命令行后退出; + /// natives 走 [`natives::preview`](不建目录、不写文件),所以对版本树是纯读的; /// accessToken 已脱敏(绝不把凭据打到终端/日志里)。 pub fn dry_run(&self, request: &LaunchRequest) -> Result { - let natives = self.prepare_environment(request)?; + let natives = self.preview_environment(request)?; let command = self.plan(request)?; let redactor = self.redactor(request); let warnings = command diff --git a/crates/core/src/launch/mod.rs b/crates/core/src/launch/mod.rs index af63e1f..fc609c9 100644 --- a/crates/core/src/launch/mod.rs +++ b/crates/core/src/launch/mod.rs @@ -8,7 +8,7 @@ //! //! # 公共面 //! -//! - [`Launcher`]:`dry_run`(D18,只组装命令行)、`spawn`/`launch`(完整管线); +//! - [`Launcher`]:`dry_run`(D18,只组装命令行、零落盘)、`spawn`/`launch`(完整管线); //! - [`LaunchRequest`] 是命令行组装的**纯数据输入**,由 [`adapter`] 从 game 快照映射而来; //! - [`AssembledCommand`] 是组装结果(program + argv + classpath + 环境变量 + 告警); //! - 事件流([`session`])与索引([`session_index`])是 M15 诊断采集器与 diff --git a/crates/core/src/launch/natives/jar.rs b/crates/core/src/launch/natives/jar.rs index 5e8c559..734e230 100644 --- a/crates/core/src/launch/natives/jar.rs +++ b/crates/core/src/launch/natives/jar.rs @@ -68,26 +68,33 @@ pub(crate) fn io_error( } } -/// 解压单个 jar:排除前缀 → 原生库白名单 → zip-slip 防护 → 落盘。 -pub(crate) fn extract_jar( - native: &NativeJar, - dir: &Path, - report: &mut NativesReport, -) -> Result<(), NativesError> { +/// 打开一个 natives jar(打不开 / 不是合法 zip 都以 [`NativesError`] 带路径报出)。 +fn open_archive(native: &NativeJar) -> Result, NativesError> { let file = File::open(&native.path) .map_err(|source| io_error("open_native_jar", &native.path, source))?; - let mut archive = ZipArchive::new(file).map_err(|err| NativesError::Zip { + ZipArchive::new(file).map_err(|err| NativesError::Zip { path: native.path.clone(), reason: err.to_string(), - })?; + }) +} + +/// 该落盘的条目(zip 索引 + 相对路径):排除前缀 → 原生库白名单 → zip-slip 防护。 +/// +/// 只读遍历,不解压:`extract_jar` 拿它算落点,`count_jar` 拿它清点数量, +/// 两边共用同一套筛选口径(预览与真解压不会给出不同的数字)。 +fn extractable_entries( + archive: &mut ZipArchive, + native: &NativeJar, + report: &mut NativesReport, +) -> Result, NativesError> { let excludes: Vec<&str> = DEFAULT_EXCLUDES .iter() .copied() .chain(native.exclude.iter().map(String::as_str)) .collect(); - + let mut entries = Vec::new(); for index in 0..archive.len() { - let mut entry = archive.by_index(index).map_err(|err| NativesError::Zip { + let entry = archive.by_index(index).map_err(|err| NativesError::Zip { path: native.path.clone(), reason: err.to_string(), })?; @@ -104,6 +111,23 @@ pub(crate) fn extract_jar( report.ignored += 1; continue; } + entries.push((index, relative)); + } + Ok(entries) +} + +/// 解压单个 jar:`extractable_entries` 选条目,再逐个落到 `dir` 下。 +pub(crate) fn extract_jar( + native: &NativeJar, + dir: &Path, + report: &mut NativesReport, +) -> Result<(), NativesError> { + let mut archive = open_archive(native)?; + for (index, relative) in extractable_entries(&mut archive, native, report)? { + let mut entry = archive.by_index(index).map_err(|err| NativesError::Zip { + path: native.path.clone(), + reason: err.to_string(), + })?; let dest = dir.join(&relative); if let Some(parent) = dest.parent() { fs::create_dir_all(parent) @@ -118,6 +142,20 @@ pub(crate) fn extract_jar( Ok(()) } +/// 只读清点单个 jar:只算「真解压会写下几个原生库」,一个字节都不落盘。 +/// +/// 与 [`extract_jar`] 的差别只有「写」这一步;jar 打不开照样报错,所以预览不会把 +/// 真启动会遇到的坏包藏起来。 +pub(crate) fn count_jar( + native: &NativeJar, + report: &mut NativesReport, +) -> Result<(), NativesError> { + let mut archive = open_archive(native)?; + let entries = extractable_entries(&mut archive, native, report)?; + report.files += entries.len(); + Ok(()) +} + /// 该条目要不要解压:排除前缀命中、签名文件、非原生库扩展名 → 跳过。 pub(crate) fn should_extract(name: &str, excludes: &[&str]) -> bool { if excludes.iter().any(|prefix| name.starts_with(prefix)) { diff --git a/crates/core/src/launch/natives/mod.rs b/crates/core/src/launch/natives/mod.rs index b1fe040..df653f8 100644 --- a/crates/core/src/launch/natives/mod.rs +++ b/crates/core/src/launch/natives/mod.rs @@ -2,7 +2,15 @@ //! //! 这是 10 阶段管线里**第 6 段「环境准备」**的落盘部分(`PlanDoc/06` §1),发生顺序在 //! 命令行组装之前(HMCL 也在组装期做:`DefaultLauncher.decompressNatives`, -//! `analyse/03` §6.4-(7)),因此 `--dry-run` 也能验证「装完到底能不能起」。 +//! `analyse/03` §6.4-(7))。 +//! +//! # 两条路径 +//! +//! - [`extract`]:真启动走这条,把原生库落到 `java.library.path`; +//! - [`preview`]:`--dry-run` 走这条,**零落盘**(不建目录、不写文件、不删文件), +//! 只回答「真启动会解出几个文件 / 目录是不是已经就绪」。断言口径与 [`extract`] +//! 共用(同一套条目筛选、同一份戳记),所以预览能当「装完能不能起」的依据, +//! 又不会在别人的版本树里留下 `natives/` 目录或改掉父目录 mtime。 //! //! # 三个时代(`SubAgentsTalk/launch-natives-解压与时代分界.md`,piston-meta 实测) //! @@ -24,6 +32,8 @@ //! 混在同一个 `java.library.path` 里(HMCL 也是每次清目录,这里只把「没变就不动」补上)。 pub(crate) mod jar; +/// 报告与错误类型(`mod.rs` 只留计划与两个入口)。 +pub(crate) mod report; use std::path::{Path, PathBuf}; @@ -164,115 +174,8 @@ impl NativesPlan { } } -/// 解压结果(进 `natives_extracted` 事件与 `--dry-run` 输出)。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NativesReport { - /// 目标目录。 - pub dir: PathBuf, - /// 时代。 - pub era: NativesEra, - /// 参与的 jar 数。 - pub jars: usize, - /// 本次写盘的原生库文件数。 - pub files: usize, - /// 按戳记跳过、未重新解压的文件数(= 上次解压的文件数)。 - pub skipped: usize, - /// 被排除 / 非原生库而忽略的条目数(`META-INF/`、签名文件)。 - pub ignored: usize, - /// 目录是否用户自定义。 - pub custom: bool, - /// 是否按戳记判定「目录已就绪」而整体跳过。 - pub up_to_date: bool, -} - -impl NativesReport { - /// 人读一行(CLI / 日志用)。 - pub fn summary(&self) -> String { - if self.jars == 0 { - return format!( - "natives 目录已就绪(该版本无需解压,LWJGL 运行期自取)→ {}", - self.dir.display() - ); - } - if self.up_to_date { - return format!( - "natives 已就绪({} 个原生库,{} 个 jar 未变)", - self.skipped, self.jars - ); - } - format!( - "natives 解压 {} 个原生库({} 个 jar,忽略 {} 个条目)→ {}", - self.files, - self.jars, - self.ignored, - self.dir.display() - ) - } - - /// `--json` 的 `natives` 块(`ull launch --dry-run` 用;字段名是接口的一部分)。 - #[must_use] - pub fn to_json(&self) -> serde_json::Value { - serde_json::json!({ - "dir": self.dir.display().to_string(), - "files": self.files, - "skipped": self.skipped, - "ignored": self.ignored, - "jars": self.jars, - "custom": self.custom, - "up_to_date": self.up_to_date, - "era": self.era.as_str(), - }) - } -} - -/// 解压失败原因。 -#[derive(Debug)] -pub enum NativesError { - /// 目标目录为空(调用方漏填 `natives_dir`)。 - NoDir, - /// 文件系统操作失败。 - Io { - /// 操作名。 - op: &'static str, - /// 相关路径。 - path: PathBuf, - /// 底层错误。 - source: std::io::Error, - }, - /// natives jar 打不开 / 不是合法 zip(下载损坏的最常见形态)。 - Zip { - /// jar 路径。 - path: PathBuf, - /// 原因。 - reason: String, - }, -} - -impl NativesError { - /// 一行人读描述。 - pub fn message(&self) -> String { - match self { - Self::NoDir => "natives 目录未配置(natives_dir 为空)".to_string(), - Self::Io { op, path, source } => { - format!("{op} 失败({}):{source}", path.display()) - } - Self::Zip { path, reason } => { - format!( - "natives jar 无法作为 zip 打开({}):{reason}", - path.display() - ) - } - } - } -} - -impl std::fmt::Display for NativesError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message()) - } -} - -impl std::error::Error for NativesError {} +/// 解压结果 / 预览结果与失败原因单独成文件(`mod.rs` 只留计划与两个入口)。 +pub use report::{NativesError, NativesReport}; /// 从版本链库表收集 natives 分类器构件(adapter / app / CLI 共用这一段,别各写一份)。 /// @@ -324,8 +227,38 @@ pub fn collect( natives } +/// 目录状态的三种结论:两个入口共用一条判定链,别各写一份。 +enum DirStatus { + /// 无需解压:1.19+ 没有 natives 分类器 jar,只要目录在。 + NoJars, + /// 戳记与当前 jar 集一致:目录已就绪,上次解出 `usize` 个原生库。 + Ready(usize), + /// 需要(重新)解压。 + Stale, +} + +/// 判定链 + 本次源 jar 指纹(真解压拿它写戳记,预览丢弃)。 +/// +/// `extract` 与 `preview` **只在这里**决定「要不要解压」:改判定只改这一处, +/// 两边不会漂。 +fn inspect(dir: &Path, plan: &NativesPlan) -> (DirStatus, Vec) { + let stamps = jar_stamps(&plan.jars); + if plan.jars.is_empty() { + return (DirStatus::NoJars, stamps); + } + let ready = read_stamp(dir) + .filter(|stamp| stamp.matches(&stamps)) + .map(|stamp| stamp.files()); + match ready { + Some(files) => (DirStatus::Ready(files), stamps), + None => (DirStatus::Stale, stamps), + } +} + /// 解压一个计划(幂等:目录与源 jar 都没变时零写盘)。 /// +/// `--dry-run` 不走这里,走 [`preview`]:那里一个字节都不写。 +/// /// # Errors /// /// 目录为空、建目录 / 写文件失败、jar 不是合法 zip 时返回 [`NativesError`]。 @@ -342,22 +275,25 @@ pub fn extract(dir: &Path, plan: &NativesPlan) -> Result { report.up_to_date = true; - report.skipped = stamp.files(); return Ok(report); } + DirStatus::Ready(files) => { + report.up_to_date = true; + report.skipped = files; + return Ok(report); + } + DirStatus::Stale => {} } // 源变了:先清旧原生库(避免两代 dll 混在同一个 java.library.path 里),再全量解。 @@ -368,3 +304,51 @@ pub fn extract(dir: &Path, plan: &NativesPlan) -> Result Result { + if dir.as_os_str().is_empty() { + return Err(NativesError::NoDir); + } + let mut report = NativesReport { + dir: dir.to_path_buf(), + era: plan.era, + jars: plan.jars.len(), + files: 0, + skipped: 0, + ignored: 0, + custom: plan.custom, + up_to_date: false, + planned: true, + dir_exists: dir.is_dir(), + }; + let (status, _stamps) = inspect(dir, plan); + match status { + DirStatus::NoJars => { + report.up_to_date = true; + return Ok(report); + } + DirStatus::Ready(files) => { + report.up_to_date = true; + report.skipped = files; + return Ok(report); + } + DirStatus::Stale => {} + } + + for native in &plan.jars { + jar::count_jar(native, &mut report)?; + } + Ok(report) +} diff --git a/crates/core/src/launch/natives/report.rs b/crates/core/src/launch/natives/report.rs new file mode 100644 index 0000000..42a53fc --- /dev/null +++ b/crates/core/src/launch/natives/report.rs @@ -0,0 +1,160 @@ +//! natives 的报告与错误:真解压与预览共用的 [`NativesReport`] / [`NativesError`]。 +//! +//! 单独成文件是为了让 `mod.rs` 只留「计划 + 两个入口(`extract` / `preview`)」, +//! 本仓的红线是单文件 ≤400 行。 + +use std::path::PathBuf; + +use super::NativesEra; + +/// 解压结果 / 预览结果(进 `natives_extracted` 事件与 `--dry-run` 输出)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativesReport { + /// 目标目录。 + pub dir: PathBuf, + /// 时代。 + pub era: NativesEra, + /// 参与的 jar 数。 + pub jars: usize, + /// 原生库文件数:真解压时是**本次写下**的数量,预览时是**将会写下**的数量。 + pub files: usize, + /// 按戳记跳过、未重新解压的文件数(= 上次解压的文件数)。 + pub skipped: usize, + /// 被排除 / 非原生库而忽略的条目数(`META-INF/`、签名文件)。 + pub ignored: usize, + /// 目录是否用户自定义。 + pub custom: bool, + /// 是否按戳记判定「目录已就绪」而整体跳过。 + pub up_to_date: bool, + /// 报告是否来自只读预览([`super::preview`])。true = 盘上零改动,`files` 只是预计值。 + pub planned: bool, + /// 报告生成时目标目录是否已在盘上。 + /// + /// 预览时这是**盘上真值**(用于区分「目录已就绪」与「真启动才会建出来」); + /// 真解压路径在 `create_dir_all` 之后恒为 true。 + pub dir_exists: bool, +} + +impl NativesReport { + /// 人读一行(CLI / 日志用)。 + pub fn summary(&self) -> String { + if self.planned { + return self.planned_summary(); + } + if self.jars == 0 { + return format!( + "natives 目录已就绪(该版本无需解压,LWJGL 运行期自取)→ {}", + self.dir.display() + ); + } + if self.up_to_date { + return format!( + "natives 已就绪({} 个原生库,{} 个 jar 未变)", + self.skipped, self.jars + ); + } + format!( + "natives 解压 {} 个原生库({} 个 jar,忽略 {} 个条目)→ {}", + self.files, + self.jars, + self.ignored, + self.dir.display() + ) + } + + /// 预览版人读一行:句句带「将 / 未落盘」,别让用户把预览当成已经解好了。 + fn planned_summary(&self) -> String { + if self.jars == 0 { + return if self.dir_exists { + format!( + "natives 目录已就绪(该版本无需解压,LWJGL 运行期自取)→ {}", + self.dir.display() + ) + } else { + format!( + "natives 目录尚不存在,真启动会建出来(--dry-run 未落盘)→ {}", + self.dir.display() + ) + }; + } + if self.up_to_date { + return format!( + "natives 已就绪({} 个原生库,{} 个 jar 未变;--dry-run 未落盘)", + self.skipped, self.jars + ); + } + format!( + "将解压 {} 个原生库({} 个 jar,忽略 {} 个条目)→ {}(--dry-run 未落盘)", + self.files, + self.jars, + self.ignored, + self.dir.display() + ) + } + + /// `--json` 的 `natives` 块(`ull launch --dry-run` 用;字段名是接口的一部分)。 + #[must_use] + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "dir": self.dir.display().to_string(), + "files": self.files, + "skipped": self.skipped, + "ignored": self.ignored, + "jars": self.jars, + "custom": self.custom, + "up_to_date": self.up_to_date, + "era": self.era.as_str(), + "planned": self.planned, + "dir_exists": self.dir_exists, + }) + } +} + +/// 解压失败原因。 +#[derive(Debug)] +pub enum NativesError { + /// 目标目录为空(调用方漏填 `natives_dir`)。 + NoDir, + /// 文件系统操作失败。 + Io { + /// 操作名。 + op: &'static str, + /// 相关路径。 + path: PathBuf, + /// 底层错误。 + source: std::io::Error, + }, + /// natives jar 打不开 / 不是合法 zip(下载损坏的最常见形态)。 + Zip { + /// jar 路径。 + path: PathBuf, + /// 原因。 + reason: String, + }, +} + +impl NativesError { + /// 一行人读描述。 + pub fn message(&self) -> String { + match self { + Self::NoDir => "natives 目录未配置(natives_dir 为空)".to_string(), + Self::Io { op, path, source } => { + format!("{op} 失败({}):{source}", path.display()) + } + Self::Zip { path, reason } => { + format!( + "natives jar 无法作为 zip 打开({}):{reason}", + path.display() + ) + } + } + } +} + +impl std::fmt::Display for NativesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message()) + } +} + +impl std::error::Error for NativesError {} diff --git a/crates/core/src/launch/request.rs b/crates/core/src/launch/request.rs index 6a4f54d..dcc1c6a 100644 --- a/crates/core/src/launch/request.rs +++ b/crates/core/src/launch/request.rs @@ -305,8 +305,9 @@ pub struct LaunchRequest { pub dirs: LaunchDirs, /// natives 解压计划(`dirs.natives_dir` 为目标目录,`jars` 为分类器构件清单)。 /// - /// 解压发生在**组装期**(`Launcher::dry_run`/`spawn` 的「环境准备」段), - /// 见 [`crate::launch::natives`] 与 `SubAgentsTalk/launch-natives-解压与时代分界.md`。 + /// 解压发生在**组装期**(`spawn`/`launch` 的「环境准备」段);`dry_run` 只**预览**同一段 + /// 的结果(不建目录、不写文件),见 [`crate::launch::natives`] 与 + /// `SubAgentsTalk/launch-natives-解压与时代分界.md`。 pub natives: NativesPlan, /// 账户。 pub auth: AuthPlaceholders, diff --git a/crates/core/tests/launch/main.rs b/crates/core/tests/launch/main.rs index bdb2d2b..41c6999 100644 --- a/crates/core/tests/launch/main.rs +++ b/crates/core/tests/launch/main.rs @@ -7,6 +7,7 @@ mod exit_cases; mod graphics_backend_cases; mod natives_args_cases; mod natives_cases; +mod natives_dry_run_cases; mod pipeline_cases; mod prelude; mod process_smoke; diff --git a/crates/core/tests/launch/natives_cases.rs b/crates/core/tests/launch/natives_cases.rs index d289cc2..4677c10 100644 --- a/crates/core/tests/launch/natives_cases.rs +++ b/crates/core/tests/launch/natives_cases.rs @@ -2,44 +2,12 @@ //! //! 假 jar 用 `zip` 的 Stored 模式现造(不依赖真下载),真机验收另有人工步骤。 -use std::fs::File; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use zip::write::SimpleFileOptions; -use zip::{CompressionMethod, ZipWriter}; +use std::path::Path; use unlucky_core::launch::natives::{self, NativeJar, NativesEra, NativesPlan, STAMP_FILE}; use crate::prelude::*; -/// 造一个 natives jar:`entries` 是(条目名, 内容)。 -fn make_jar(path: &Path, entries: &[(&str, &[u8])]) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("建 jar 目录"); - } - let file = File::create(path).expect("建 jar"); - let mut writer = ZipWriter::new(file); - let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); - for (name, bytes) in entries { - writer.start_file(*name, options).expect("写条目"); - writer.write_all(bytes).expect("写条目内容"); - } - writer.finish().expect("收尾 jar"); -} - -/// 一份 1.16.5 风格的 natives jar 的条目集(原生库 + 该被排除的噪音)。 -fn lwjgl_jar_entries() -> Vec<(&'static str, &'static [u8])> { - vec![ - ("lwjgl.dll", b"DLL-BYTES".as_slice()), - ("OpenAL.dll", b"OPENAL-BYTES".as_slice()), - ("META-INF/MANIFEST.MF", b"manifest".as_slice()), - ("META-INF/LWJGL.SF", b"signature".as_slice()), - ("lwjgl.dll.sha1", b"deadbeef".as_slice()), - ("README.txt", b"noise".as_slice()), - ] -} - fn plan(jars: Vec) -> NativesPlan { NativesPlan::new(jars) } @@ -209,34 +177,6 @@ fn natives_extracted_event_carries_report_fields() { .ends_with("natives")); } -/// `--dry-run` 也做环境准备:第二次跑要看到 up_to_date(真机验收的判定依据)。 -#[test] -fn dry_run_prepares_natives_and_is_idempotent() { - let root = temp_dir("natives-dry-run"); - let jar = root.join("natives-windows.jar"); - make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]); - let natives_dir: PathBuf = root.join("versions/1.16.5/natives"); - - let (mut request, _) = legacy_forge_request(); - request.dirs.natives_dir = natives_dir.clone(); - request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]); - request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string()); - - let sessions = temp_sessions_root("natives-dry-run-sessions"); - let launcher = Launcher::new(LauncherConfig::new(&sessions)); - - let first = launcher.dry_run(&request).expect("首次 dry-run"); - let report = first.natives.expect("应有 natives 报告"); - assert_eq!(report.files, 1, "{report:?}"); - assert!(!report.up_to_date); - assert!(natives_dir.join("lwjgl.dll").is_file()); - - let second = launcher.dry_run(&request).expect("二次 dry-run"); - let report = second.natives.expect("应有 natives 报告"); - assert!(report.up_to_date, "第二次应命中戳记:{report:?}"); - assert_eq!(report.skipped, 1); -} - /// 缺 natives jar:适配层当成组件缺失(组装前显式失败),而不是等到 UnsatisfiedLinkError。 #[test] fn missing_native_jar_becomes_missing_component() { diff --git a/crates/core/tests/launch/natives_dry_run_cases.rs b/crates/core/tests/launch/natives_dry_run_cases.rs new file mode 100644 index 0000000..beb32de --- /dev/null +++ b/crates/core/tests/launch/natives_dry_run_cases.rs @@ -0,0 +1,245 @@ +//! `--dry-run` 的零落盘契约(M231 / DRYRUNPURE):预览只读版本树,不建目录、不改 mtime。 +//! +//! 判据是「版本树的节点类型 + mtime 快照逐字相等」而不是「natives 目录不存在」: +//! 后者抓不住 `create_dir_all` 之后立刻删掉的情形(父目录 mtime 已经变了), +//! 也抓不住「目录本来就在、但被重写/清空」的情形。 + +use std::path::{Path, PathBuf}; + +use unlucky_core::launch::natives::{self, NativeJar, NativesPlan}; + +use crate::prelude::*; + +/// 版本树的「节点类型 + mtime(纳秒)+ 相对路径」快照,用来逐字比较 dry-run 前后。 +/// +/// 抓得住:新增/删除任何节点(含 `natives/` 目录本身)、任何已有节点被重写或 mtime 被 +/// 更新;根节点(= 父目录 `versions//`)也在快照里,所以「建目录」会同时体现在 +/// 节点集合与父目录 mtime 上。 +/// 抓不住:①内容变了但 mtime 被回填成原值(本仓没有这种写法);②**建了立刻删**这类 +/// 瞬时写入只留父目录 mtime 一条线索,而 NTFS 会把该更新合并掉 —— 本仓实测:全量并行跑 +/// 测试时这条偶发看不见(单跑又看得见),所以瞬时建删只剩「`natives` 目录不存在」兜底。 +/// +/// 时间戳口径:NTFS / ext4 的 mtime 精度是 100ns 级,快照取纳秒整数比较,同一秒内的 +/// 两次操作(写文件 → 再写一次)分辨得出来,不需要 `sleep` 把操作推到不同秒。 +/// 判据自检见 `tree_snapshot_sees_new_nodes_and_mtime_changes`(防假绿)。 +fn tree_snapshot(root: &Path) -> Vec { + fn line(meta: &std::fs::Metadata, relative: &str) -> String { + let nanos = meta + .modified() + .expect("mtime") + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch 之后") + .as_nanos(); + let kind = if meta.is_dir() { "d" } else { "f" }; + format!("{kind} {nanos} {relative}") + } + fn walk(dir: &Path, prefix: &str, out: &mut Vec) { + let mut entries: Vec = std::fs::read_dir(dir) + .expect("读目录") + .flatten() + .map(|entry| entry.path()) + .collect(); + entries.sort(); + for path in entries { + let meta = std::fs::symlink_metadata(&path).expect("元数据"); + let name = path + .file_name() + .expect("文件名") + .to_string_lossy() + .to_string(); + let relative = format!("{prefix}{name}"); + out.push(line(&meta, &relative)); + if meta.is_dir() { + walk(&path, &format!("{relative}/"), out); + } + } + } + let mut out = vec![line( + &std::fs::symlink_metadata(root).expect("根元数据"), + "", + )]; + walk(root, "", &mut out); + out +} + +/// `--dry-run` 零落盘(有 jar):只报「将解压几个」,版本树里一个节点都不新增、mtime 不动。 +#[test] +fn dry_run_previews_natives_without_touching_disk() { + let root = temp_dir("natives-dry-run-pure"); + let jar = root.join("libraries/lwjgl-natives-windows.jar"); + make_jar(&jar, &lwjgl_jar_entries()); + let version_dir = root.join("versions/1.16.5"); + std::fs::create_dir_all(&version_dir).expect("建版本目录"); + std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位"); + let natives_dir = version_dir.join("natives"); + let before = tree_snapshot(&version_dir); + + let (mut request, _) = legacy_forge_request(); + request.dirs.natives_dir = natives_dir.clone(); + request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]); + request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string()); + let sessions = temp_sessions_root("natives-dry-run-pure-sessions"); + let launcher = Launcher::new(LauncherConfig::new(&sessions)); + + let dry = launcher.dry_run(&request).expect("dry-run 应成功"); + assert_eq!( + before, + tree_snapshot(&version_dir), + "dry-run 不许在版本树里加节点 / 改 mtime" + ); + let report = dry.natives.expect("应有 natives 预览"); + assert!(report.planned, "预览报告要自报家门:{report:?}"); + assert!(!report.dir_exists, "natives 目录原本不在盘上:{report:?}"); + assert_eq!(report.files, 2, "要算出「将写下」的数量:{report:?}"); + assert_eq!(report.ignored, 4, "忽略口径要与真解压一致:{report:?}"); + assert!(!report.up_to_date, "{report:?}"); + assert!( + report.summary().contains("未落盘"), + "人读行得说清没写盘:{}", + report.summary() + ); + + assert!(!natives_dir.exists(), "natives 目录不该被建出来"); +} + +/// `--dry-run` 零落盘(无 jar,1.19+ 常态):不建 natives 目录,人读行说清「尚不存在」。 +#[test] +fn dry_run_with_no_natives_jar_creates_no_directory() { + let root = temp_dir("natives-dry-run-nojar"); + let version_dir = root.join("versions/1.20.1"); + std::fs::create_dir_all(&version_dir).expect("建版本目录"); + let natives_dir = version_dir.join("natives"); + let before = tree_snapshot(&version_dir); + + let (mut request, _) = legacy_forge_request(); + request.dirs.natives_dir = natives_dir.clone(); + request.natives = NativesPlan::default(); + request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string()); + let sessions = temp_sessions_root("natives-dry-run-nojar-sessions"); + let launcher = Launcher::new(LauncherConfig::new(&sessions)); + + let dry = launcher.dry_run(&request).expect("dry-run 应成功"); + assert_eq!(before, tree_snapshot(&version_dir), "无 jar 也不许建目录"); + let report = dry.natives.expect("应有 natives 预览"); + assert_eq!(report.jars, 0, "{report:?}"); + assert_eq!(report.files, 0, "{report:?}"); + assert!(!report.dir_exists, "盘上没有这个目录:{report:?}"); + assert!(report.up_to_date, "无 jar = 无需解压:{report:?}"); + assert!( + report.summary().contains("尚不存在"), + "{}", + report.summary() + ); + + assert!(!natives_dir.exists(), "natives 目录不该被建出来"); +} + +/// `--dry-run` 零落盘(空 jar:只有 `META-INF/` 与噪音):数得出 0 个原生库,仍不建目录。 +#[test] +fn dry_run_with_empty_native_jar_creates_no_directory() { + let root = temp_dir("natives-dry-run-emptyjar"); + let jar = root.join("natives-windows.jar"); + make_jar( + &jar, + &[ + ("META-INF/MANIFEST.MF", b"manifest".as_slice()), + ("README.txt", b"noise".as_slice()), + ], + ); + let version_dir = root.join("versions/1.16.5"); + std::fs::create_dir_all(&version_dir).expect("建版本目录"); + let natives_dir = version_dir.join("natives"); + let before = tree_snapshot(&version_dir); + + let (mut request, _) = legacy_forge_request(); + request.dirs.natives_dir = natives_dir.clone(); + request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]); + request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string()); + let sessions = temp_sessions_root("natives-dry-run-emptyjar-sessions"); + let launcher = Launcher::new(LauncherConfig::new(&sessions)); + + let dry = launcher.dry_run(&request).expect("dry-run 应成功"); + assert_eq!(before, tree_snapshot(&version_dir), "空 jar 也不许建目录"); + let report = dry.natives.expect("应有 natives 预览"); + assert_eq!(report.files, 0, "空 jar 解不出原生库:{report:?}"); + assert_eq!(report.ignored, 2, "两条噪音都要数进忽略:{report:?}"); + assert!(!report.up_to_date, "{report:?}"); + assert!(!report.dir_exists, "{report:?}"); + + assert!(!natives_dir.exists(), "natives 目录不该被建出来"); +} + +/// 目录已经真解压过:预览读戳记报「已就绪」,同样零写盘(幂等路径也不许碰盘)。 +#[test] +fn dry_run_after_real_extract_reports_ready_without_writing() { + let root = temp_dir("natives-dry-run-ready"); + let jar = root.join("natives-windows.jar"); + make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]); + let version_dir = root.join("versions/1.16.5"); + let natives_dir = version_dir.join("natives"); + std::fs::create_dir_all(&version_dir).expect("建版本目录"); + natives::extract( + &natives_dir, + &NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]), + ) + .expect("先真解压一次"); + let before = tree_snapshot(&version_dir); + + let (mut request, _) = legacy_forge_request(); + request.dirs.natives_dir = natives_dir.clone(); + request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]); + request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string()); + let sessions = temp_sessions_root("natives-dry-run-ready-sessions"); + let launcher = Launcher::new(LauncherConfig::new(&sessions)); + + let dry = launcher.dry_run(&request).expect("dry-run 应成功"); + assert_eq!( + before, + tree_snapshot(&version_dir), + "预览不该重写已有的原生库或戳记" + ); + let report = dry.natives.expect("应有 natives 预览"); + assert!(report.planned, "{report:?}"); + assert!(report.dir_exists, "{report:?}"); + assert!(report.up_to_date, "戳记命中应报已就绪:{report:?}"); + assert_eq!(report.files, 0, "预览不该再数一遍:{report:?}"); + assert_eq!(report.skipped, 1, "{report:?}"); +} + +/// 判据自检(防假绿):快照必须看得见「新增节点」与「已有节点的 mtime 变化」, +/// 否则上面几条「快照逐字相等」等于没断言。 +/// +/// 这里**不**用「建了立刻删」来探父目录 mtime:NTFS 会把瞬时建删对父目录 mtime 的影响 +/// 合并掉(本仓实测:全量并行跑时偶发看不见、单跑看得见),拿它当门禁就是引入 flaky。 +/// 改成两种确定性变化:新节点(结构维度)+ 同内容重写(时间维度,只有文件 mtime 变, +/// 不依赖父目录时间戳的合并行为)。 +#[test] +fn tree_snapshot_sees_new_nodes_and_mtime_changes() { + let root = temp_dir("natives-snapshot-probe"); + let version_dir = root.join("versions/1.16.5"); + std::fs::create_dir_all(&version_dir).expect("建版本目录"); + std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位"); + let baseline = tree_snapshot(&version_dir); + + // 1) 结构维度:多一个目录节点(正是 `create_dir_all(natives)` 留下的痕迹)。 + std::fs::create_dir(version_dir.join("natives")).expect("建目录"); + let with_dir = tree_snapshot(&version_dir); + assert!( + with_dir.len() > baseline.len(), + "快照必须看得见新目录:{baseline:?} vs {with_dir:?}" + ); + assert_ne!(baseline, with_dir); + + // 2) 时间维度:同一份内容重写一遍,节点集合不变、文件 mtime 必须变。 + std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("重写清单占位"); + let rewritten = tree_snapshot(&version_dir); + assert_eq!( + with_dir.len(), + rewritten.len(), + "节点集合没变,差异只该在 mtime 上" + ); + assert_ne!( + with_dir, rewritten, + "快照必须看得见已有文件的 mtime 变化,否则零落盘断言是假绿" + ); +} diff --git a/crates/core/tests/launch/prelude.rs b/crates/core/tests/launch/prelude.rs index a24d2b5..fa0fa57 100644 --- a/crates/core/tests/launch/prelude.rs +++ b/crates/core/tests/launch/prelude.rs @@ -2,7 +2,12 @@ //! //! **crate 名只在本文件出现**(tower 定案 `unlucky_core`;若 M11 改包名,只改这里)。 -use std::path::PathBuf; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipWriter}; pub use unlucky_core::launch::args_table::{assemble, AssembledCommand, AssemblyWarning}; pub use unlucky_core::launch::argument::{ArgumentEntry, Rule}; @@ -147,3 +152,32 @@ pub fn read_events(path: &std::path::Path) -> Vec { .map(|line| serde_json::from_str(line).expect("解析事件行")) .collect() } + +/// 造一个假 jar(`zip` Stored 模式,不依赖真下载):`entries` 是(条目名, 内容)。 +/// +/// natives 用例共用(真解压与 `--dry-run` 预览两边都要现造 jar)。 +pub fn make_jar(path: &Path, entries: &[(&str, &[u8])]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建 jar 目录"); + } + let file = File::create(path).expect("建 jar"); + let mut writer = ZipWriter::new(file); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + for (name, bytes) in entries { + writer.start_file(*name, options).expect("写条目"); + writer.write_all(bytes).expect("写条目内容"); + } + writer.finish().expect("收尾 jar"); +} + +/// 一份 1.16.5 风格的 natives jar 的条目集(2 个原生库 + 4 条该被排除的噪音)。 +pub fn lwjgl_jar_entries() -> Vec<(&'static str, &'static [u8])> { + vec![ + ("lwjgl.dll", b"DLL-BYTES".as_slice()), + ("OpenAL.dll", b"OPENAL-BYTES".as_slice()), + ("META-INF/MANIFEST.MF", b"manifest".as_slice()), + ("META-INF/LWJGL.SF", b"signature".as_slice()), + ("lwjgl.dll.sha1", b"deadbeef".as_slice()), + ("README.txt", b"noise".as_slice()), + ] +}